Commit 54176651 authored by Doug Stull's avatar Doug Stull

Add popover for suggesting ci config add

- nudge users to create a pipeline with
  a popover to increase expansion into
  ci use.
parent ec85e345
<script> <script>
import { GlLink, GlSprintf } from '@gitlab/ui'; import { GlLink, GlSprintf } from '@gitlab/ui';
import MrWidgetIcon from './mr_widget_icon.vue'; import MrWidgetIcon from './mr_widget_icon.vue';
import PipelineTourState from './states/mr_widget_pipeline_tour.vue';
export default { export default {
name: 'MRWidgetSuggestPipeline', name: 'MRWidgetSuggestPipeline',
iconName: 'status_notfound', iconName: 'status_notfound',
popoverTarget: 'suggest-popover',
popoverContainer: 'suggest-pipeline',
trackLabel: 'no_pipeline_noticed',
linkTrackValue: 30,
linkTrackEvent: 'click_link',
components: { components: {
GlLink, GlLink,
GlSprintf, GlSprintf,
MrWidgetIcon, MrWidgetIcon,
PipelineTourState,
}, },
props: { props: {
pipelinePath: { pipelinePath: {
type: String, type: String,
required: true, required: true,
}, },
pipelineSvgPath: {
type: String,
required: true,
},
humanAccess: {
type: String,
required: true,
},
}, },
}; };
</script> </script>
<template> <template>
<div class="d-flex mr-pipeline-suggest append-bottom-default"> <div :id="$options.popoverContainer" class="d-flex mr-pipeline-suggest append-bottom-default">
<mr-widget-icon :name="$options.iconName" /> <mr-widget-icon :name="$options.iconName" />
<gl-sprintf <div :id="$options.popoverTarget">
class="js-no-pipeline-message" <gl-sprintf
:message=" :message="
s__(`mrWidget|%{prefixToLinkStart}No pipeline%{prefixToLinkEnd} s__(`mrWidget|%{prefixToLinkStart}No pipeline%{prefixToLinkEnd}
%{addPipelineLinkStart}Add the .gitlab-ci.yml file%{addPipelineLinkEnd} %{addPipelineLinkStart}Add the .gitlab-ci.yml file%{addPipelineLinkEnd}
to create one.`) to create one.`)
" "
> >
<template #prefixToLink="{content}"> <template #prefixToLink="{content}">
<strong> <strong>
{{ content }} {{ content }}
</strong> </strong>
</template> </template>
<template #addPipelineLink="{content}"> <template #addPipelineLink="{content}">
<gl-link :href="pipelinePath" class="ml-2"> <gl-link
{{ content }} :href="pipelinePath"
</gl-link> class="ml-2 js-add-pipeline-path"
&nbsp; :data-track-property="humanAccess"
</template> :data-track-value="$options.linkTrackValue"
</gl-sprintf> :data-track-event="$options.linkTrackEvent"
:data-track-label="$options.trackLabel"
>
{{ content }}
</gl-link>
</template>
</gl-sprintf>
<pipeline-tour-state
:pipeline-path="pipelinePath"
:pipeline-svg-path="pipelineSvgPath"
:human-access="humanAccess"
:popover-target="$options.popoverTarget"
:popover-container="$options.popoverContainer"
:track-label="$options.trackLabel"
/>
</div>
</div> </div>
</template> </template>
<script>
import { s__, sprintf } from '~/locale';
import { GlPopover, GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import Cookies from 'js-cookie';
import { parseBoolean } from '~/lib/utils/common_utils';
import Tracking from '~/tracking';
const trackingMixin = Tracking.mixin();
const cookieKey = 'suggest_pipeline_dismissed';
export default {
name: 'MRWidgetPipelineTour',
dismissTrackValue: 20,
showTrackValue: 10,
trackEvent: 'click_button',
popoverContent: sprintf(
'%{messageText1}%{lineBreak}%{messageText2}%{lineBreak}%{messageText3}%{lineBreak}%{messageText4}%{lineBreak}%{messageText5}',
{
messageText1: s__('mrWidget|Detect issues before deployment with a CI pipeline'),
messageText2: s__('mrWidget|that continuously tests your code. We created'),
messageText3: s__("mrWidget|a quick guide that'll show you how to create"),
messageText4: s__('mrWidget|one. Make your code more secure and more'),
messageText5: s__('mrWidget|robust in just a minute.'),
lineBreak: '<br/>',
},
false,
),
components: {
GlPopover,
GlButton,
Icon,
},
mixins: [trackingMixin],
props: {
pipelinePath: {
type: String,
required: true,
},
pipelineSvgPath: {
type: String,
required: true,
},
humanAccess: {
type: String,
required: true,
},
popoverTarget: {
type: String,
required: true,
},
popoverContainer: {
type: String,
required: true,
},
trackLabel: {
type: String,
required: true,
},
},
data() {
return {
popoverDismissed: parseBoolean(Cookies.get(cookieKey)),
tracking: {
label: this.trackLabel,
property: this.humanAccess,
},
};
},
mounted() {
this.trackOnShow();
},
methods: {
trackOnShow() {
if (!this.popoverDismissed) {
this.track();
}
},
dismissPopover() {
this.popoverDismissed = true;
Cookies.set(cookieKey, this.popoverDismissed, { expires: 365 });
},
},
};
</script>
<template>
<gl-popover
v-if="!popoverDismissed"
show
:target="popoverTarget"
:container="popoverContainer"
placement="rightbottom"
>
<template #title>
<button
class="btn-blank float-right mt-1"
type="button"
:aria-label="__('Close')"
:data-track-property="humanAccess"
:data-track-value="$options.dismissTrackValue"
:data-track-event="$options.trackEvent"
:data-track-label="trackLabel"
@click="dismissPopover"
>
<icon name="close" aria-hidden="true" />
</button>
{{ s__('mrWidget|Are you adding technical debt or code vulnerabilities?') }}
</template>
<div class="svg-content svg-150 pt-1">
<img :src="pipelineSvgPath" />
</div>
<p v-html="$options.popoverContent"></p>
<gl-button
ref="ok"
category="primary"
class="mt-2 mb-0"
variant="info"
block
:href="pipelinePath"
:data-track-property="humanAccess"
:data-track-value="$options.showTrackValue"
:data-track-event="$options.trackEvent"
:data-track-label="trackLabel"
>
{{ __('Show me how') }}
</gl-button>
<gl-button
ref="no-thanks"
category="secondary"
class="mt-2 mb-0"
variant="info"
block
:data-track-property="humanAccess"
:data-track-value="$options.dismissTrackValue"
:data-track-event="$options.trackEvent"
:data-track-label="trackLabel"
@click="dismissPopover"
>
{{ __("No thanks, don't show this again") }}
</gl-button>
</gl-popover>
</template>
...@@ -362,6 +362,8 @@ export default { ...@@ -362,6 +362,8 @@ export default {
v-if="shouldSuggestPipelines" v-if="shouldSuggestPipelines"
class="mr-widget-workflow" class="mr-widget-workflow"
:pipeline-path="mr.mergeRequestAddCiConfigPath" :pipeline-path="mr.mergeRequestAddCiConfigPath"
:pipeline-svg-path="mr.pipelinesEmptySvgPath"
:human-access="mr.humanAccess.toLowerCase()"
/> />
<mr-widget-pipeline-container <mr-widget-pipeline-container
v-if="shouldRenderPipelines" v-if="shouldRenderPipelines"
......
...@@ -176,6 +176,7 @@ export default class MergeRequestStore { ...@@ -176,6 +176,7 @@ export default class MergeRequestStore {
this.eligibleApproversDocsPath = data.eligible_approvers_docs_path; this.eligibleApproversDocsPath = data.eligible_approvers_docs_path;
this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path; this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path;
this.mergeRequestAddCiConfigPath = data.merge_request_add_ci_config_path; this.mergeRequestAddCiConfigPath = data.merge_request_add_ci_config_path;
this.pipelinesEmptySvgPath = data.pipelines_empty_svg_path;
this.humanAccess = data.human_access; this.humanAccess = data.human_access;
} }
......
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
width: 100%; width: 100%;
} }
$image-widths: 80 130 250 306 394 430; $image-widths: 80 130 150 250 306 394 430;
@each $width in $image-widths { @each $width in $image-widths {
&.svg-#{$width} { &.svg-#{$width} {
img, img,
......
...@@ -614,6 +614,10 @@ $mr-widget-min-height: 69px; ...@@ -614,6 +614,10 @@ $mr-widget-min-height: 69px;
.circle-icon-container { .circle-icon-container {
color: $gl-text-color-quaternary; color: $gl-text-color-quaternary;
} }
.popover {
z-index: 240;
}
} }
.card-new-merge-request { .card-new-merge-request {
......
...@@ -10,5 +10,6 @@ ...@@ -10,5 +10,6 @@
window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: 'troubleshooting')}'; window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: 'troubleshooting')}';
window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.html', anchor: 'security-approvals-in-merge-requests-ultimate')}'; window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.html', anchor: 'security-approvals-in-merge-requests-ultimate')}';
window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}'; window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}';
window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}';
#js-vue-mr-widget.mr-widget #js-vue-mr-widget.mr-widget
...@@ -257,6 +257,8 @@ export default { ...@@ -257,6 +257,8 @@ export default {
v-if="shouldSuggestPipelines" v-if="shouldSuggestPipelines"
class="mr-widget-workflow" class="mr-widget-workflow"
:pipeline-path="mr.mergeRequestAddCiConfigPath" :pipeline-path="mr.mergeRequestAddCiConfigPath"
:pipeline-svg-path="mr.pipelinesEmptySvgPath"
:human-access="mr.humanAccess.toLowerCase()"
/> />
<mr-widget-pipeline-container <mr-widget-pipeline-container
v-if="shouldRenderPipelines" v-if="shouldRenderPipelines"
......
...@@ -12957,6 +12957,9 @@ msgstr "" ...@@ -12957,6 +12957,9 @@ msgstr ""
msgid "No template" msgid "No template"
msgstr "" msgstr ""
msgid "No thanks, don't show this again"
msgstr ""
msgid "No value set by top-level parent group." msgid "No value set by top-level parent group."
msgstr "" msgstr ""
...@@ -17662,6 +17665,9 @@ msgstr "" ...@@ -17662,6 +17665,9 @@ msgstr ""
msgid "Show latest version" msgid "Show latest version"
msgstr "" msgstr ""
msgid "Show me how"
msgstr ""
msgid "Show only direct members" msgid "Show only direct members"
msgstr "" msgstr ""
...@@ -23346,6 +23352,9 @@ msgstr "" ...@@ -23346,6 +23352,9 @@ msgstr ""
msgid "mrWidget|Approved by" msgid "mrWidget|Approved by"
msgstr "" msgstr ""
msgid "mrWidget|Are you adding technical debt or code vulnerabilities?"
msgstr ""
msgid "mrWidget|Cancel automatic merge" msgid "mrWidget|Cancel automatic merge"
msgstr "" msgstr ""
...@@ -23379,6 +23388,9 @@ msgstr "" ...@@ -23379,6 +23388,9 @@ msgstr ""
msgid "mrWidget|Deployment statistics are not available currently" msgid "mrWidget|Deployment statistics are not available currently"
msgstr "" msgstr ""
msgid "mrWidget|Detect issues before deployment with a CI pipeline"
msgstr ""
msgid "mrWidget|Did not close" msgid "mrWidget|Did not close"
msgstr "" msgstr ""
...@@ -23556,6 +23568,9 @@ msgstr "" ...@@ -23556,6 +23568,9 @@ msgstr ""
msgid "mrWidget|Your password" msgid "mrWidget|Your password"
msgstr "" msgstr ""
msgid "mrWidget|a quick guide that'll show you how to create"
msgstr ""
msgid "mrWidget|branch does not exist." msgid "mrWidget|branch does not exist."
msgstr "" msgstr ""
...@@ -23565,6 +23580,15 @@ msgstr "" ...@@ -23565,6 +23580,15 @@ msgstr ""
msgid "mrWidget|into" msgid "mrWidget|into"
msgstr "" msgstr ""
msgid "mrWidget|one. Make your code more secure and more"
msgstr ""
msgid "mrWidget|robust in just a minute."
msgstr ""
msgid "mrWidget|that continuously tests your code. We created"
msgstr ""
msgid "mrWidget|to be added to the merge train when the pipeline succeeds" msgid "mrWidget|to be added to the merge train when the pipeline succeeds"
msgstr "" msgstr ""
......
...@@ -8,7 +8,7 @@ let handlers; ...@@ -8,7 +8,7 @@ let handlers;
export function mockTracking(category = '_category_', documentOverride, spyMethod) { export function mockTracking(category = '_category_', documentOverride, spyMethod) {
document = documentOverride || window.document; document = documentOverride || window.document;
window.snowplow = () => {}; window.snowplow = () => {};
Tracking.bindDocument(category, document); handlers = Tracking.bindDocument(category, document);
return spyMethod ? spyMethod(Tracking, 'event') : null; return spyMethod ? spyMethod(Tracking, 'event') : null;
} }
......
...@@ -226,6 +226,14 @@ describe('Tracking', () => { ...@@ -226,6 +226,14 @@ describe('Tracking', () => {
}; };
}); });
it('calls the event method with no category or action defined', () => {
mixin.trackingCategory = mixin.trackingCategory();
mixin.trackingOptions = mixin.trackingOptions();
mixin.track();
expect(eventSpy).toHaveBeenCalledWith(undefined, undefined, {});
});
it('calls the event method', () => { it('calls the event method', () => {
mixin.trackingCategory = mixin.trackingCategory(); mixin.trackingCategory = mixin.trackingCategory();
mixin.trackingOptions = mixin.trackingOptions(); mixin.trackingOptions = mixin.trackingOptions();
......
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui'; import { GlLink } from '@gitlab/ui';
import suggestPipelineComponent from '~/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue'; import suggestPipelineComponent from '~/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue';
import stubChildren from 'helpers/stub_children';
import PipelineTourState from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue';
import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.vue'; import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.vue';
import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper';
describe('MRWidgetHeader', () => { describe('MRWidgetHeader', () => {
let wrapper; let wrapper;
const pipelinePath = '/foo/bar/add/pipeline/path'; const pipelinePath = '/foo/bar/add/pipeline/path';
const pipelineSvgPath = '/foo/bar/pipeline/svg/path';
const humanAccess = 'maintainer';
const iconName = 'status_notfound'; const iconName = 'status_notfound';
beforeEach(() => { beforeEach(() => {
wrapper = mount(suggestPipelineComponent, { wrapper = mount(suggestPipelineComponent, {
propsData: { pipelinePath }, propsData: { pipelinePath, pipelineSvgPath, humanAccess },
stubs: {
...stubChildren(PipelineTourState),
},
}); });
}); });
...@@ -22,30 +30,47 @@ describe('MRWidgetHeader', () => { ...@@ -22,30 +30,47 @@ describe('MRWidgetHeader', () => {
it('renders add pipeline file link', () => { it('renders add pipeline file link', () => {
const link = wrapper.find(GlLink); const link = wrapper.find(GlLink);
return wrapper.vm.$nextTick().then(() => { expect(link.exists()).toBe(true);
expect(link.exists()).toBe(true); expect(link.attributes().href).toBe(pipelinePath);
expect(link.attributes().href).toBe(pipelinePath);
});
}); });
it('renders the expected text', () => { it('renders the expected text', () => {
const messageText = /\s*No pipeline\s*Add the .gitlab-ci.yml file\s*to create one./; const messageText = /\s*No pipeline\s*Add the .gitlab-ci.yml file\s*to create one./;
return wrapper.vm.$nextTick().then(() => { expect(wrapper.text()).toMatch(messageText);
expect(wrapper.text()).toMatch(messageText);
});
}); });
it('renders widget icon', () => { it('renders widget icon', () => {
const icon = wrapper.find(MrWidgetIcon); const icon = wrapper.find(MrWidgetIcon);
return wrapper.vm.$nextTick().then(() => { expect(icon.exists()).toBe(true);
expect(icon.exists()).toBe(true); expect(icon.props()).toEqual(
expect(icon.props()).toEqual( expect.objectContaining({
expect.objectContaining({ name: iconName,
name: iconName, }),
}), );
); });
describe('tracking', () => {
let spy;
beforeEach(() => {
spy = mockTracking('_category_', wrapper.element, jest.spyOn);
});
afterEach(() => {
unmockTracking();
});
it('send an event when ok button is clicked', () => {
const link = wrapper.find(GlLink);
triggerEvent(link.element);
expect(spy).toHaveBeenCalledWith('_category_', 'click_link', {
label: 'no_pipeline_noticed',
property: humanAccess,
value: '30',
});
}); });
}); });
}); });
......
import { shallowMount } from '@vue/test-utils';
import { GlPopover } from '@gitlab/ui';
import Cookies from 'js-cookie';
import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper';
import pipelineTourState from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue';
import { popoverProps, cookieKey } from './pipeline_tour_mock_data';
describe('MRWidgetPipelineTour', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
describe(`when ${cookieKey} cookie is set`, () => {
beforeEach(() => {
Cookies.set(cookieKey, true);
wrapper = shallowMount(pipelineTourState, {
propsData: popoverProps,
});
});
it('does not render the popover', () => {
const popover = wrapper.find(GlPopover);
expect(popover.exists()).toBe(false);
});
describe('tracking', () => {
let trackingSpy;
beforeEach(() => {
trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
});
afterEach(() => {
unmockTracking();
});
it('does not call tracking', () => {
expect(trackingSpy).not.toHaveBeenCalled();
});
});
});
describe(`when ${cookieKey} cookie is not set`, () => {
const findOkBtn = () => wrapper.find({ ref: 'ok' });
const findDismissBtn = () => wrapper.find({ ref: 'no-thanks' });
beforeEach(() => {
Cookies.remove(cookieKey);
wrapper = shallowMount(pipelineTourState, {
propsData: popoverProps,
});
});
it('renders the popover', () => {
const popover = wrapper.find(GlPopover);
expect(popover.exists()).toBe(true);
});
it('renders the show me how button', () => {
const button = findOkBtn();
expect(button.exists()).toBe(true);
expect(button.attributes().category).toBe('primary');
});
it('renders the dismiss button', () => {
const button = findDismissBtn();
expect(button.exists()).toBe(true);
expect(button.attributes().category).toBe('secondary');
});
it('renders the empty pipelines image', () => {
const image = wrapper.find('img');
expect(image.exists()).toBe(true);
expect(image.attributes().src).toBe(popoverProps.pipelineSvgPath);
});
describe('tracking', () => {
let trackingSpy;
beforeEach(() => {
trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
});
afterEach(() => {
unmockTracking();
});
it('send event for basic view of popover', () => {
document.body.dataset.page = 'projects:merge_requests:show';
wrapper.vm.trackOnShow();
expect(trackingSpy).toHaveBeenCalledWith(undefined, undefined, {
label: popoverProps.trackLabel,
property: popoverProps.humanAccess,
});
});
it('send an event when ok button is clicked', () => {
const okBtn = findOkBtn();
triggerEvent(okBtn.element);
expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_button', {
label: popoverProps.trackLabel,
property: popoverProps.humanAccess,
value: '10',
});
});
it('send an event when dismiss button is clicked', () => {
const dismissBtn = findDismissBtn();
triggerEvent(dismissBtn.element);
expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_button', {
label: popoverProps.trackLabel,
property: popoverProps.humanAccess,
value: '20',
});
});
});
describe('dismissPopover', () => {
it('updates popoverDismissed', () => {
const button = findDismissBtn();
const popover = wrapper.find(GlPopover);
button.vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(Cookies.get(cookieKey)).toBe('true');
expect(popover.exists()).toBe(false);
});
});
});
});
});
});
export const popoverProps = {
pipelinePath: '/foo/bar/add/pipeline/path',
pipelineSvgPath: 'assets/illustrations/something.svg',
humanAccess: 'maintainer',
popoverTarget: 'suggest-popover',
popoverContainer: 'suggest-pipeline',
trackLabel: 'some_tracking_label',
};
export const cookieKey = 'suggest_pipeline_dismissed';
...@@ -8,7 +8,7 @@ let handlers; ...@@ -8,7 +8,7 @@ let handlers;
export function mockTracking(category = '_category_', documentOverride, spyMethod) { export function mockTracking(category = '_category_', documentOverride, spyMethod) {
document = documentOverride || window.document; document = documentOverride || window.document;
window.snowplow = () => {}; window.snowplow = () => {};
Tracking.bindDocument(category, document); handlers = Tracking.bindDocument(category, document);
return spyMethod ? spyMethod(Tracking, 'event') : null; return spyMethod ? spyMethod(Tracking, 'event') : null;
} }
......
...@@ -28,6 +28,7 @@ export default { ...@@ -28,6 +28,7 @@ export default {
}, },
merge_status: 'can_be_merged', merge_status: 'can_be_merged',
merge_user_id: null, merge_user_id: null,
pipelines_empty_svg_path: '/path/to/svg',
source_branch: 'daaaa', source_branch: 'daaaa',
source_branch_link: 'daaaa', source_branch_link: 'daaaa',
source_project_id: 19, source_project_id: 19,
......
...@@ -96,5 +96,11 @@ describe('MergeRequestStore', () => { ...@@ -96,5 +96,11 @@ describe('MergeRequestStore', () => {
expect(store.humanAccess).toEqual('Maintainer'); expect(store.humanAccess).toEqual('Maintainer');
}); });
it('should set pipelinesEmptySvgPath', () => {
store.setData({ ...mockData });
expect(store.pipelinesEmptySvgPath).toBe('/path/to/svg');
});
}); });
}); });
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