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>
import { GlLink, GlSprintf } from '@gitlab/ui';
import MrWidgetIcon from './mr_widget_icon.vue';
import PipelineTourState from './states/mr_widget_pipeline_tour.vue';
export default {
name: 'MRWidgetSuggestPipeline',
iconName: 'status_notfound',
popoverTarget: 'suggest-popover',
popoverContainer: 'suggest-pipeline',
trackLabel: 'no_pipeline_noticed',
linkTrackValue: 30,
linkTrackEvent: 'click_link',
components: {
GlLink,
GlSprintf,
MrWidgetIcon,
PipelineTourState,
},
props: {
pipelinePath: {
type: String,
required: true,
},
pipelineSvgPath: {
type: String,
required: true,
},
humanAccess: {
type: String,
required: true,
},
},
};
</script>
<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" />
<gl-sprintf
class="js-no-pipeline-message"
:message="
s__(`mrWidget|%{prefixToLinkStart}No pipeline%{prefixToLinkEnd}
<div :id="$options.popoverTarget">
<gl-sprintf
:message="
s__(`mrWidget|%{prefixToLinkStart}No pipeline%{prefixToLinkEnd}
%{addPipelineLinkStart}Add the .gitlab-ci.yml file%{addPipelineLinkEnd}
to create one.`)
"
>
<template #prefixToLink="{content}">
<strong>
{{ content }}
</strong>
</template>
<template #addPipelineLink="{content}">
<gl-link :href="pipelinePath" class="ml-2">
{{ content }}
</gl-link>
&nbsp;
</template>
</gl-sprintf>
"
>
<template #prefixToLink="{content}">
<strong>
{{ content }}
</strong>
</template>
<template #addPipelineLink="{content}">
<gl-link
:href="pipelinePath"
class="ml-2 js-add-pipeline-path"
:data-track-property="humanAccess"
:data-track-value="$options.linkTrackValue"
: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>
</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 {
v-if="shouldSuggestPipelines"
class="mr-widget-workflow"
:pipeline-path="mr.mergeRequestAddCiConfigPath"
:pipeline-svg-path="mr.pipelinesEmptySvgPath"
:human-access="mr.humanAccess.toLowerCase()"
/>
<mr-widget-pipeline-container
v-if="shouldRenderPipelines"
......
......@@ -176,6 +176,7 @@ export default class MergeRequestStore {
this.eligibleApproversDocsPath = data.eligible_approvers_docs_path;
this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path;
this.mergeRequestAddCiConfigPath = data.merge_request_add_ci_config_path;
this.pipelinesEmptySvgPath = data.pipelines_empty_svg_path;
this.humanAccess = data.human_access;
}
......
......@@ -20,7 +20,7 @@
width: 100%;
}
$image-widths: 80 130 250 306 394 430;
$image-widths: 80 130 150 250 306 394 430;
@each $width in $image-widths {
&.svg-#{$width} {
img,
......
......@@ -614,6 +614,10 @@ $mr-widget-min-height: 69px;
.circle-icon-container {
color: $gl-text-color-quaternary;
}
.popover {
z-index: 240;
}
}
.card-new-merge-request {
......
......@@ -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.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.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}';
#js-vue-mr-widget.mr-widget
......@@ -257,6 +257,8 @@ export default {
v-if="shouldSuggestPipelines"
class="mr-widget-workflow"
:pipeline-path="mr.mergeRequestAddCiConfigPath"
:pipeline-svg-path="mr.pipelinesEmptySvgPath"
:human-access="mr.humanAccess.toLowerCase()"
/>
<mr-widget-pipeline-container
v-if="shouldRenderPipelines"
......
......@@ -12957,6 +12957,9 @@ msgstr ""
msgid "No template"
msgstr ""
msgid "No thanks, don't show this again"
msgstr ""
msgid "No value set by top-level parent group."
msgstr ""
......@@ -17662,6 +17665,9 @@ msgstr ""
msgid "Show latest version"
msgstr ""
msgid "Show me how"
msgstr ""
msgid "Show only direct members"
msgstr ""
......@@ -23346,6 +23352,9 @@ msgstr ""
msgid "mrWidget|Approved by"
msgstr ""
msgid "mrWidget|Are you adding technical debt or code vulnerabilities?"
msgstr ""
msgid "mrWidget|Cancel automatic merge"
msgstr ""
......@@ -23379,6 +23388,9 @@ msgstr ""
msgid "mrWidget|Deployment statistics are not available currently"
msgstr ""
msgid "mrWidget|Detect issues before deployment with a CI pipeline"
msgstr ""
msgid "mrWidget|Did not close"
msgstr ""
......@@ -23556,6 +23568,9 @@ msgstr ""
msgid "mrWidget|Your password"
msgstr ""
msgid "mrWidget|a quick guide that'll show you how to create"
msgstr ""
msgid "mrWidget|branch does not exist."
msgstr ""
......@@ -23565,6 +23580,15 @@ msgstr ""
msgid "mrWidget|into"
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"
msgstr ""
......
......@@ -8,7 +8,7 @@ let handlers;
export function mockTracking(category = '_category_', documentOverride, spyMethod) {
document = documentOverride || window.document;
window.snowplow = () => {};
Tracking.bindDocument(category, document);
handlers = Tracking.bindDocument(category, document);
return spyMethod ? spyMethod(Tracking, 'event') : null;
}
......
......@@ -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', () => {
mixin.trackingCategory = mixin.trackingCategory();
mixin.trackingOptions = mixin.trackingOptions();
......
import { mount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui';
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 { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper';
describe('MRWidgetHeader', () => {
let wrapper;
const pipelinePath = '/foo/bar/add/pipeline/path';
const pipelineSvgPath = '/foo/bar/pipeline/svg/path';
const humanAccess = 'maintainer';
const iconName = 'status_notfound';
beforeEach(() => {
wrapper = mount(suggestPipelineComponent, {
propsData: { pipelinePath },
propsData: { pipelinePath, pipelineSvgPath, humanAccess },
stubs: {
...stubChildren(PipelineTourState),
},
});
});
......@@ -22,30 +30,47 @@ describe('MRWidgetHeader', () => {
it('renders add pipeline file link', () => {
const link = wrapper.find(GlLink);
return wrapper.vm.$nextTick().then(() => {
expect(link.exists()).toBe(true);
expect(link.attributes().href).toBe(pipelinePath);
});
expect(link.exists()).toBe(true);
expect(link.attributes().href).toBe(pipelinePath);
});
it('renders the expected text', () => {
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', () => {
const icon = wrapper.find(MrWidgetIcon);
return wrapper.vm.$nextTick().then(() => {
expect(icon.exists()).toBe(true);
expect(icon.props()).toEqual(
expect.objectContaining({
name: iconName,
}),
);
expect(icon.exists()).toBe(true);
expect(icon.props()).toEqual(
expect.objectContaining({
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;
export function mockTracking(category = '_category_', documentOverride, spyMethod) {
document = documentOverride || window.document;
window.snowplow = () => {};
Tracking.bindDocument(category, document);
handlers = Tracking.bindDocument(category, document);
return spyMethod ? spyMethod(Tracking, 'event') : null;
}
......
......@@ -28,6 +28,7 @@ export default {
},
merge_status: 'can_be_merged',
merge_user_id: null,
pipelines_empty_svg_path: '/path/to/svg',
source_branch: 'daaaa',
source_branch_link: 'daaaa',
source_project_id: 19,
......
......@@ -96,5 +96,11 @@ describe('MergeRequestStore', () => {
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