Commit 2ab23a78 authored by eugielimpin's avatar eugielimpin Committed by Douglas Barbosa Alexandre

Implement Pipeline Editor Walkthrough experiment

In this experiment, a walkthrough popover is displayed when the user
views the editor tab of the pipeline editor. This popover will guide
them to committing a new CI config file in the current branch. The
pipeline drawer is also closed when the user is in the candidate group
of the experiment (it is open for control group).

Changelog: added
parent d1aaf644
...@@ -36,6 +36,11 @@ export default { ...@@ -36,6 +36,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
scrollToCommitForm: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
...@@ -52,6 +57,13 @@ export default { ...@@ -52,6 +57,13 @@ export default {
return !(this.message && this.targetBranch); return !(this.message && this.targetBranch);
}, },
}, },
watch: {
scrollToCommitForm(flag) {
if (flag) {
this.scrollIntoView();
}
},
},
methods: { methods: {
onSubmit() { onSubmit() {
this.$emit('submit', { this.$emit('submit', {
...@@ -63,6 +75,10 @@ export default { ...@@ -63,6 +75,10 @@ export default {
onReset() { onReset() {
this.$emit('cancel'); this.$emit('cancel');
}, },
scrollIntoView() {
this.$el.scrollIntoView({ behavior: 'smooth' });
this.$emit('scrolled-to-commit-form');
},
}, },
i18n: { i18n: {
commitMessage: __('Commit message'), commitMessage: __('Commit message'),
......
...@@ -45,6 +45,11 @@ export default { ...@@ -45,6 +45,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
scrollToCommitForm: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
...@@ -146,6 +151,8 @@ export default { ...@@ -146,6 +151,8 @@ export default {
:current-branch="currentBranch" :current-branch="currentBranch"
:default-message="defaultCommitMessage" :default-message="defaultCommitMessage"
:is-saving="isSaving" :is-saving="isSaving"
:scroll-to-commit-form="scrollToCommitForm"
v-on="$listeners"
@cancel="onCommitCancel" @cancel="onCommitCancel"
@submit="onCommitSubmit" @submit="onCommitSubmit"
/> />
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { GlButton, GlIcon } from '@gitlab/ui'; import { GlButton, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { experiment } from '~/experimentation/utils';
import { DRAWER_EXPANDED_KEY } from '../../constants'; import { DRAWER_EXPANDED_KEY } from '../../constants';
import FirstPipelineCard from './cards/first_pipeline_card.vue'; import FirstPipelineCard from './cards/first_pipeline_card.vue';
import GettingStartedCard from './cards/getting_started_card.vue'; import GettingStartedCard from './cards/getting_started_card.vue';
...@@ -53,12 +54,23 @@ export default { ...@@ -53,12 +54,23 @@ export default {
}, },
methods: { methods: {
setInitialExpandState() { setInitialExpandState() {
let isExpanded;
experiment('pipeline_editor_walkthrough', {
control: () => {
isExpanded = true;
},
candidate: () => {
isExpanded = false;
},
});
// We check in the local storage and if no value is defined, we want the default // We check in the local storage and if no value is defined, we want the default
// to be true. We want to explicitly set it to true here so that the drawer // to be true. We want to explicitly set it to true here so that the drawer
// animates to open on load. // animates to open on load.
const localValue = localStorage.getItem(this.$options.localDrawerKey); const localValue = localStorage.getItem(this.$options.localDrawerKey);
if (localValue === null) { if (localValue === null) {
this.isExpanded = true; this.isExpanded = isExpanded;
} }
}, },
setTopPosition() { setTopPosition() {
......
...@@ -4,6 +4,7 @@ import { s__ } from '~/locale'; ...@@ -4,6 +4,7 @@ import { s__ } from '~/locale';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
import { import {
CREATE_TAB, CREATE_TAB,
EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_EMPTY,
...@@ -22,6 +23,7 @@ import CiEditorHeader from './editor/ci_editor_header.vue'; ...@@ -22,6 +23,7 @@ import CiEditorHeader from './editor/ci_editor_header.vue';
import TextEditor from './editor/text_editor.vue'; import TextEditor from './editor/text_editor.vue';
import CiLint from './lint/ci_lint.vue'; import CiLint from './lint/ci_lint.vue';
import EditorTab from './ui/editor_tab.vue'; import EditorTab from './ui/editor_tab.vue';
import WalkthroughPopover from './walkthrough_popover.vue';
export default { export default {
i18n: { i18n: {
...@@ -63,6 +65,8 @@ export default { ...@@ -63,6 +65,8 @@ export default {
GlTabs, GlTabs,
PipelineGraph, PipelineGraph,
TextEditor, TextEditor,
GitlabExperiment,
WalkthroughPopover,
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
props: { props: {
...@@ -79,6 +83,10 @@ export default { ...@@ -79,6 +83,10 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
isNewCiConfigFile: {
type: Boolean,
required: true,
},
}, },
apollo: { apollo: {
appStatus: { appStatus: {
...@@ -136,11 +144,17 @@ export default { ...@@ -136,11 +144,17 @@ export default {
> >
<editor-tab <editor-tab
class="gl-mb-3" class="gl-mb-3"
title-link-class="js-walkthrough-popover-target"
:title="$options.i18n.tabEdit" :title="$options.i18n.tabEdit"
lazy lazy
data-testid="editor-tab" data-testid="editor-tab"
@click="setCurrentTab($options.tabConstants.CREATE_TAB)" @click="setCurrentTab($options.tabConstants.CREATE_TAB)"
> >
<gitlab-experiment name="pipeline_editor_walkthrough">
<template #candidate>
<walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" />
</template>
</gitlab-experiment>
<ci-editor-header /> <ci-editor-header />
<text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" /> <text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" />
</editor-tab> </editor-tab>
......
<script> <script>
import { GlButton, GlPopover, GlSprintf, GlOutsideDirective as Outside } from '@gitlab/ui'; import { GlButton, GlPopover, GlSprintf, GlOutsideDirective as Outside } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import eventHub from '../event_hub';
export default { export default {
directives: { Outside }, directives: { Outside },
...@@ -36,7 +35,7 @@ export default { ...@@ -36,7 +35,7 @@ export default {
}, },
handleClickCta() { handleClickCta() {
this.close(); this.close();
eventHub.$emit('walkthroughPopoverCtaClicked'); this.$emit('walkthrough-popover-cta-clicked');
}, },
}, },
}; };
......
...@@ -58,6 +58,7 @@ export default { ...@@ -58,6 +58,7 @@ export default {
data() { data() {
return { return {
currentTab: CREATE_TAB, currentTab: CREATE_TAB,
scrollToCommitForm: false,
shouldLoadNewBranch: false, shouldLoadNewBranch: false,
showSwitchBranchModal: false, showSwitchBranchModal: false,
}; };
...@@ -81,6 +82,9 @@ export default { ...@@ -81,6 +82,9 @@ export default {
setCurrentTab(tabName) { setCurrentTab(tabName) {
this.currentTab = tabName; this.currentTab = tabName;
}, },
setScrollToCommitForm(newValue = true) {
this.scrollToCommitForm = newValue;
},
}, },
}; };
</script> </script>
...@@ -117,8 +121,10 @@ export default { ...@@ -117,8 +121,10 @@ export default {
:ci-config-data="ciConfigData" :ci-config-data="ciConfigData"
:ci-file-content="ciFileContent" :ci-file-content="ciFileContent"
:commit-sha="commitSha" :commit-sha="commitSha"
:is-new-ci-config-file="isNewCiConfigFile"
v-on="$listeners" v-on="$listeners"
@set-current-tab="setCurrentTab" @set-current-tab="setCurrentTab"
@walkthrough-popover-cta-clicked="setScrollToCommitForm"
/> />
<commit-section <commit-section
v-if="showCommitForm" v-if="showCommitForm"
...@@ -126,6 +132,8 @@ export default { ...@@ -126,6 +132,8 @@ export default {
:ci-file-content="ciFileContent" :ci-file-content="ciFileContent"
:commit-sha="commitSha" :commit-sha="commitSha"
:is-new-ci-config-file="isNewCiConfigFile" :is-new-ci-config-file="isNewCiConfigFile"
:scroll-to-commit-form="scrollToCommitForm"
@scrolled-to-commit-form="setScrollToCommitForm(false)"
v-on="$listeners" v-on="$listeners"
/> />
<pipeline-editor-drawer /> <pipeline-editor-drawer />
......
...@@ -5,6 +5,9 @@ import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; ...@@ -5,6 +5,9 @@ import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue';
import { mockCommitMessage, mockDefaultBranch } from '../../mock_data'; import { mockCommitMessage, mockDefaultBranch } from '../../mock_data';
const scrollIntoViewMock = jest.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
describe('Pipeline Editor | Commit Form', () => { describe('Pipeline Editor | Commit Form', () => {
let wrapper; let wrapper;
...@@ -113,4 +116,20 @@ describe('Pipeline Editor | Commit Form', () => { ...@@ -113,4 +116,20 @@ describe('Pipeline Editor | Commit Form', () => {
expect(findSubmitBtn().attributes('disabled')).toBe('disabled'); expect(findSubmitBtn().attributes('disabled')).toBe('disabled');
}); });
}); });
describe('when scrollToCommitForm becomes true', () => {
beforeEach(async () => {
createComponent();
wrapper.setProps({ scrollToCommitForm: true });
await wrapper.vm.$nextTick();
});
it('scrolls into view', () => {
expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth' });
});
it('emits "scrolled-to-commit-form"', () => {
expect(wrapper.emitted()['scrolled-to-commit-form']).toBeTruthy();
});
});
}); });
...@@ -277,4 +277,16 @@ describe('Pipeline Editor | Commit section', () => { ...@@ -277,4 +277,16 @@ describe('Pipeline Editor | Commit section', () => {
expect(wrapper.emitted('resetContent')).toHaveLength(1); expect(wrapper.emitted('resetContent')).toHaveLength(1);
}); });
}); });
it('sets listeners on commit form', () => {
const handler = jest.fn();
createComponent({ options: { listeners: { event: handler } } });
findCommitForm().vm.$emit('event');
expect(handler).toHaveBeenCalled();
});
it('passes down scroll-to-commit-form prop to commit form', () => {
createComponent({ props: { 'scroll-to-commit-form': true } });
expect(findCommitForm().props('scrollToCommitForm')).toBe(true);
});
}); });
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { stubExperiments } from 'helpers/experimentation_helper';
import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import FirstPipelineCard from '~/pipeline_editor/components/drawer/cards/first_pipeline_card.vue'; import FirstPipelineCard from '~/pipeline_editor/components/drawer/cards/first_pipeline_card.vue';
import GettingStartedCard from '~/pipeline_editor/components/drawer/cards/getting_started_card.vue'; import GettingStartedCard from '~/pipeline_editor/components/drawer/cards/getting_started_card.vue';
...@@ -33,19 +34,41 @@ describe('Pipeline editor drawer', () => { ...@@ -33,19 +34,41 @@ describe('Pipeline editor drawer', () => {
const clickToggleBtn = async () => findToggleBtn().vm.$emit('click'); const clickToggleBtn = async () => findToggleBtn().vm.$emit('click');
const originalObjects = [];
beforeEach(() => {
originalObjects.push(window.gon, window.gl);
stubExperiments({ pipeline_editor_walkthrough: 'control' });
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
localStorage.clear(); localStorage.clear();
[window.gon, window.gl] = originalObjects;
}); });
it('it sets the drawer to be opened by default', async () => { describe('default expanded state', () => {
createComponent(); describe('when experiment control', () => {
it('sets the drawer to be opened by default', async () => {
expect(findDrawerContent().exists()).toBe(false); createComponent();
expect(findDrawerContent().exists()).toBe(false);
await nextTick(); await nextTick();
expect(findDrawerContent().exists()).toBe(true);
});
});
expect(findDrawerContent().exists()).toBe(true); describe('when experiment candidate', () => {
beforeEach(() => {
stubExperiments({ pipeline_editor_walkthrough: 'candidate' });
});
it('sets the drawer to be closed by default', async () => {
createComponent();
expect(findDrawerContent().exists()).toBe(false);
await nextTick();
expect(findDrawerContent().exists()).toBe(false);
});
});
}); });
describe('when the drawer is collapsed', () => { describe('when the drawer is collapsed', () => {
......
import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils'; import { shallowMount, mount } from '@vue/test-utils';
import { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
import setWindowLocation from 'helpers/set_window_location_helper'; import setWindowLocation from 'helpers/set_window_location_helper';
import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue'; import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue';
import WalkthroughPopover from '~/pipeline_editor/components/walkthrough_popover.vue';
import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue'; import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue';
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue'; import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue';
import { stubExperiments } from 'helpers/experimentation_helper';
import { import {
CREATE_TAB, CREATE_TAB,
EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_EMPTY,
...@@ -19,6 +21,8 @@ import { ...@@ -19,6 +21,8 @@ import {
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import { mockLintResponse, mockLintResponseWithoutMerged, mockCiYml } from '../mock_data'; import { mockLintResponse, mockLintResponseWithoutMerged, mockCiYml } from '../mock_data';
Vue.config.ignoredElements = ['gl-emoji'];
describe('Pipeline editor tabs component', () => { describe('Pipeline editor tabs component', () => {
let wrapper; let wrapper;
const MockTextEditor = { const MockTextEditor = {
...@@ -26,6 +30,7 @@ describe('Pipeline editor tabs component', () => { ...@@ -26,6 +30,7 @@ describe('Pipeline editor tabs component', () => {
}; };
const createComponent = ({ const createComponent = ({
listeners = {},
props = {}, props = {},
provide = {}, provide = {},
appStatus = EDITOR_APP_STATUS_VALID, appStatus = EDITOR_APP_STATUS_VALID,
...@@ -35,6 +40,7 @@ describe('Pipeline editor tabs component', () => { ...@@ -35,6 +40,7 @@ describe('Pipeline editor tabs component', () => {
propsData: { propsData: {
ciConfigData: mockLintResponse, ciConfigData: mockLintResponse,
ciFileContent: mockCiYml, ciFileContent: mockCiYml,
isNewCiConfigFile: true,
...props, ...props,
}, },
data() { data() {
...@@ -47,6 +53,7 @@ describe('Pipeline editor tabs component', () => { ...@@ -47,6 +53,7 @@ describe('Pipeline editor tabs component', () => {
TextEditor: MockTextEditor, TextEditor: MockTextEditor,
EditorTab, EditorTab,
}, },
listeners,
}); });
}; };
...@@ -62,6 +69,7 @@ describe('Pipeline editor tabs component', () => { ...@@ -62,6 +69,7 @@ describe('Pipeline editor tabs component', () => {
const findPipelineGraph = () => wrapper.findComponent(PipelineGraph); const findPipelineGraph = () => wrapper.findComponent(PipelineGraph);
const findTextEditor = () => wrapper.findComponent(MockTextEditor); const findTextEditor = () => wrapper.findComponent(MockTextEditor);
const findMergedPreview = () => wrapper.findComponent(CiConfigMergedPreview); const findMergedPreview = () => wrapper.findComponent(CiConfigMergedPreview);
const findWalkthroughPopover = () => wrapper.findComponent(WalkthroughPopover);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -236,4 +244,63 @@ describe('Pipeline editor tabs component', () => { ...@@ -236,4 +244,63 @@ describe('Pipeline editor tabs component', () => {
expect(findGlTabs().props('syncActiveTabWithQueryParams')).toBe(true); expect(findGlTabs().props('syncActiveTabWithQueryParams')).toBe(true);
}); });
}); });
describe('pipeline_editor_walkthrough experiment', () => {
describe('when in control path', () => {
beforeEach(() => {
stubExperiments({ pipeline_editor_walkthrough: 'control' });
});
it('does not show walkthrough popover', async () => {
createComponent({ mountFn: mount });
await nextTick();
expect(findWalkthroughPopover().exists()).toBe(false);
});
});
describe('when in candidate path', () => {
beforeEach(() => {
stubExperiments({ pipeline_editor_walkthrough: 'candidate' });
});
describe('when isNewCiConfigFile prop is true (default)', () => {
beforeEach(async () => {
createComponent({
mountFn: mount,
});
await nextTick();
});
it('shows walkthrough popover', async () => {
expect(findWalkthroughPopover().exists()).toBe(true);
});
});
describe('when isNewCiConfigFile prop is false', () => {
it('does not show walkthrough popover', async () => {
createComponent({ props: { isNewCiConfigFile: false }, mountFn: mount });
await nextTick();
expect(findWalkthroughPopover().exists()).toBe(false);
});
});
});
});
it('sets listeners on walkthrough popover', async () => {
stubExperiments({ pipeline_editor_walkthrough: 'candidate' });
const handler = jest.fn();
createComponent({
mountFn: mount,
listeners: {
event: handler,
},
});
await nextTick();
findWalkthroughPopover().vm.$emit('event');
expect(handler).toHaveBeenCalled();
});
}); });
import { mount, shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import Vue from 'vue'; import Vue from 'vue';
import WalkthroughPopover from '~/pipeline_editor/components/walkthrough_popover.vue'; import WalkthroughPopover from '~/pipeline_editor/components/walkthrough_popover.vue';
import pipelineEditorEventHub from '~/pipeline_editor/event_hub';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
Vue.config.ignoredElements = ['gl-emoji']; Vue.config.ignoredElements = ['gl-emoji'];
...@@ -19,13 +18,12 @@ describe('WalkthroughPopover component', () => { ...@@ -19,13 +18,12 @@ describe('WalkthroughPopover component', () => {
describe('CTA button clicked', () => { describe('CTA button clicked', () => {
beforeEach(async () => { beforeEach(async () => {
jest.spyOn(pipelineEditorEventHub, '$emit');
wrapper = createComponent(mount); wrapper = createComponent(mount);
await wrapper.findByTestId('ctaBtn').trigger('click'); await wrapper.findByTestId('ctaBtn').trigger('click');
}); });
it('emits "walkthroughPopoverCtaClicked" event on Pipeline Editor eventHub', async () => { it('emits "walkthrough-popover-cta-clicked" event', async () => {
expect(pipelineEditorEventHub.$emit).toHaveBeenCalledWith('walkthroughPopoverCtaClicked'); expect(wrapper.emitted()['walkthrough-popover-cta-clicked']).toBeTruthy();
}); });
}); });
}); });
...@@ -152,4 +152,27 @@ describe('Pipeline editor home wrapper', () => { ...@@ -152,4 +152,27 @@ describe('Pipeline editor home wrapper', () => {
expect(findCommitSection().exists()).toBe(true); expect(findCommitSection().exists()).toBe(true);
}); });
}); });
describe('WalkthroughPopover events', () => {
beforeEach(() => {
createComponent();
});
describe('when "walkthrough-popover-cta-clicked" is emitted from pipeline editor tabs', () => {
it('passes down `scrollToCommitForm=true` to commit section', async () => {
expect(findCommitSection().props('scrollToCommitForm')).toBe(false);
await findPipelineEditorTabs().vm.$emit('walkthrough-popover-cta-clicked');
expect(findCommitSection().props('scrollToCommitForm')).toBe(true);
});
});
describe('when "scrolled-to-commit-form" is emitted from commit section', () => {
it('passes down `scrollToCommitForm=false` to commit section', async () => {
await findPipelineEditorTabs().vm.$emit('walkthrough-popover-cta-clicked');
expect(findCommitSection().props('scrollToCommitForm')).toBe(true);
await findCommitSection().vm.$emit('scrolled-to-commit-form');
expect(findCommitSection().props('scrollToCommitForm')).toBe(false);
});
});
});
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment