Commit 22c50207 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '224185-track-the-mr-approval-promo-experiment' into 'master'

Track the promote_mr_approvals_in_free experiment

See merge request gitlab-org/gitlab!76893
parents 6662fa41 0f711b8b
...@@ -17,7 +17,6 @@ export const BV_HIDE_MODAL = 'bv::hide::modal'; ...@@ -17,7 +17,6 @@ export const BV_HIDE_MODAL = 'bv::hide::modal';
export const BV_HIDE_TOOLTIP = 'bv::hide::tooltip'; export const BV_HIDE_TOOLTIP = 'bv::hide::tooltip';
export const BV_DROPDOWN_SHOW = 'bv::dropdown::show'; export const BV_DROPDOWN_SHOW = 'bv::dropdown::show';
export const BV_DROPDOWN_HIDE = 'bv::dropdown::hide'; export const BV_DROPDOWN_HIDE = 'bv::dropdown::hide';
export const BV_COLLAPSE_STATE = 'bv::collapse::state';
export const DEFAULT_TH_CLASSES = export const DEFAULT_TH_CLASSES =
'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!'; 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!';
......
...@@ -64,9 +64,9 @@ ...@@ -64,9 +64,9 @@
for this project. for this project.
- if issuable.new_record? - if issuable.new_record?
= form.submit "#{_('Create')} #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-confirm gl-mr-2', data: { qa_selector: 'issuable_create_button' } = form.submit "#{_('Create')} #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-confirm gl-mr-2', data: { qa_selector: 'issuable_create_button', track_experiment: 'promote_mr_approvals_in_free', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 }
- else - else
= form.submit _('Save changes'), class: 'gl-button btn btn-confirm gl-mr-2' = form.submit _('Save changes'), class: 'gl-button btn btn-confirm gl-mr-2', data: { track_experiment: 'promote_mr_approvals_in_free', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 }
- if issuable.new_record? - if issuable.new_record?
= link_to _('Cancel'), polymorphic_path([@project, issuable.class]), class: 'btn gl-button btn-default' = link_to _('Cancel'), polymorphic_path([@project, issuable.class]), class: 'btn gl-button btn-default'
......
<script> <script>
import { GlAccordion, GlAccordionItem, GlButton, GlLink } from '@gitlab/ui'; import { GlButton, GlLink, GlCollapse } from '@gitlab/ui';
import { parseBoolean } from '~/lib/utils/common_utils'; import Tracking from '~/tracking';
import AccessorUtilities from '~/lib/utils/accessor'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { BV_COLLAPSE_STATE } from '~/lib/utils/constants'; import {
import { MR_APPROVALS_PROMO_DISMISSED, MR_APPROVALS_PROMO_I18N } from '../../constants'; MR_APPROVALS_PROMO_DISMISSED,
MR_APPROVALS_PROMO_I18N,
MR_APPROVALS_PROMO_TRACKING_EVENTS,
} from '../../constants';
const canUseLocalStorage = AccessorUtilities.canUseLocalStorage(); const EXPERIMENT_KEY = 'promote_mr_approvals_in_free';
const trackingMixin = Tracking.mixin({ experiment: EXPERIMENT_KEY });
export default { export default {
components: { components: {
GlAccordion,
GlAccordionItem,
GlButton, GlButton,
GlLink, GlLink,
LocalStorageSync,
GlCollapse,
}, },
mixins: [trackingMixin],
inject: ['learnMorePath', 'promoImageAlt', 'promoImagePath', 'tryNowPath'], inject: ['learnMorePath', 'promoImageAlt', 'promoImagePath', 'tryNowPath'],
data() { data() {
return { return {
userManuallyCollapsed: // isReady - used to render components after local storage has synced
canUseLocalStorage && parseBoolean(localStorage.getItem(MR_APPROVALS_PROMO_DISMISSED)), isReady: false,
// userManuallyCollapsed - set to true if the collapsible is collapsed
userManuallyCollapsed: false,
// isExpanded - the current collapsible state
isExpanded: true,
}; };
}, },
i18n: MR_APPROVALS_PROMO_I18N, computed: {
icon() {
return this.isExpanded ? 'chevron-down' : 'chevron-right';
},
},
watch: {
userManuallyCollapsed(isCollapsed) {
this.isExpanded = !isCollapsed;
},
},
mounted() { mounted() {
if (!this.userManuallyCollapsed) { this.$nextTick(this.ready);
this.$root.$on(BV_COLLAPSE_STATE, this.collapseAccordionItem);
}
}, },
methods: { methods: {
collapseAccordionItem(_, state) { ready() {
if (state === false) { this.isReady = true;
// We only need to track that this happens at least once },
this.$root.$off(BV_COLLAPSE_STATE, this.collapseAccordionItem); toggleCollapse() {
// If we're expanded already, then the user tried to collapse...
if (this.isExpanded) {
this.userManuallyCollapsed = true; this.userManuallyCollapsed = true;
if (canUseLocalStorage) { const { action, ...options } = MR_APPROVALS_PROMO_TRACKING_EVENTS.collapsePromo;
localStorage.setItem(MR_APPROVALS_PROMO_DISMISSED, true); this.track(action, options);
} } else {
const { action, ...options } = MR_APPROVALS_PROMO_TRACKING_EVENTS.expandPromo;
this.track(action, options);
} }
this.isExpanded = !this.isExpanded;
}, },
}, },
trackingEvents: MR_APPROVALS_PROMO_TRACKING_EVENTS,
i18n: MR_APPROVALS_PROMO_I18N,
MR_APPROVALS_PROMO_DISMISSED,
EXPERIMENT_KEY,
}; };
</script> </script>
<template> <template>
<div class="gl-mt-2"> <div class="gl-mt-2">
<p class="gl-mb-0 gl-text-gray-500"> <local-storage-sync
{{ $options.i18n.summary }} v-model="userManuallyCollapsed"
</p> :storage-key="$options.MR_APPROVALS_PROMO_DISMISSED"
as-json
/>
<template v-if="isReady">
<p class="gl-mb-0 gl-text-gray-500">
{{ $options.i18n.summary }}
</p>
<gl-button variant="link" :icon="icon" data-testid="collapse-btn" @click="toggleCollapse">
{{ $options.i18n.accordionTitle }}
</gl-button>
<gl-accordion :header-level="3"> <gl-collapse v-model="isExpanded" class="gl-ml-5 gl-pl-2">
<gl-accordion-item :title="$options.i18n.accordionTitle" :visible="!userManuallyCollapsed">
<h4 class="gl-font-base gl-line-height-20 gl-mt-5 gl-mb-3"> <h4 class="gl-font-base gl-line-height-20 gl-mt-5 gl-mb-3">
{{ $options.i18n.promoTitle }} {{ $options.i18n.promoTitle }}
</h4> </h4>
...@@ -63,19 +97,32 @@ export default { ...@@ -63,19 +97,32 @@ export default {
</li> </li>
</ul> </ul>
<p> <p>
<gl-link :href="learnMorePath" target="_blank"> <gl-link
:href="learnMorePath"
target="_blank"
:data-track-action="$options.trackingEvents.learnMoreClick.action"
:data-track-label="$options.trackingEvents.learnMoreClick.label"
:data-track-experiment="$options.EXPERIMENT_KEY"
>
{{ $options.i18n.learnMore }} {{ $options.i18n.learnMore }}
</gl-link> </gl-link>
</p> </p>
<gl-button category="primary" variant="confirm" :href="tryNowPath" target="_blank">{{ <gl-button
$options.i18n.tryNow category="primary"
}}</gl-button> variant="confirm"
:href="tryNowPath"
target="_blank"
:data-track-action="$options.trackingEvents.tryNowClick.action"
:data-track-label="$options.trackingEvents.tryNowClick.label"
:data-track-experiment="$options.EXPERIMENT_KEY"
>{{ $options.i18n.tryNow }}</gl-button
>
</div> </div>
<div class="gl-flex-grow-0 gl-w-full gl-max-w-26 gl-display-none gl-md-display-block"> <div class="gl-flex-grow-0 gl-w-full gl-max-w-26 gl-display-none gl-md-display-block">
<img :src="promoImagePath" :alt="promoImageAlt" class="svg gl-w-full" /> <img :src="promoImagePath" :alt="promoImageAlt" class="svg gl-w-full" />
</div> </div>
</div> </div>
</gl-accordion-item> </gl-collapse>
</gl-accordion> </template>
</div> </div>
</template> </template>
...@@ -159,6 +159,12 @@ export const APPROVAL_VULNERABILITY_STATES = { ...@@ -159,6 +159,12 @@ export const APPROVAL_VULNERABILITY_STATES = {
}; };
export const MR_APPROVALS_PROMO_DISMISSED = 'mr_approvals_promo.dismissed'; export const MR_APPROVALS_PROMO_DISMISSED = 'mr_approvals_promo.dismissed';
export const MR_APPROVALS_PROMO_TRACKING_EVENTS = {
learnMoreClick: { action: 'click_link', label: 'learn_more_merge_approval' },
tryNowClick: { action: 'click_button', label: 'start_trial' },
collapsePromo: { action: 'click_button', label: 'collapse_approval_rules' },
expandPromo: { action: 'click_button', label: 'expand_approval_rules' },
};
export const MR_APPROVALS_PROMO_I18N = { export const MR_APPROVALS_PROMO_I18N = {
accordionTitle: s__('ApprovalRule|Approval rules'), accordionTitle: s__('ApprovalRule|Approval rules'),
learnMore: s__('ApprovalRule|Learn more about merge request approval.'), learnMore: s__('ApprovalRule|Learn more about merge request approval.'),
......
import { GlAccordionItem, GlButton, GlLink } from '@gitlab/ui'; import { GlButton, GlLink, GlCollapse } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import { BV_COLLAPSE_STATE } from '~/lib/utils/constants'; import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { MR_APPROVALS_PROMO_I18N } from 'ee/approvals/constants'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import {
MR_APPROVALS_PROMO_I18N,
MR_APPROVALS_PROMO_TRACKING_EVENTS,
MR_APPROVALS_PROMO_DISMISSED,
} from 'ee/approvals/constants';
import FreeTierPromo from 'ee/approvals/components/mr_edit/free_tier_promo.vue'; import FreeTierPromo from 'ee/approvals/components/mr_edit/free_tier_promo.vue';
describe('PaidFeatureCalloutBadge component', () => { const EXPANDED_ICON = 'chevron-down';
const COLLAPSED_ICON = 'chevron-right';
describe('FreeTierPromo component', () => {
useLocalStorageSpy();
let wrapper; let wrapper;
let trackingSpy;
const trackingEvents = MR_APPROVALS_PROMO_TRACKING_EVENTS;
const createComponent = (providers = {}) => { const expectTracking = (category, { action, ...options } = {}) => {
return shallowMountExtended(FreeTierPromo, { return expect(trackingSpy).toHaveBeenCalledWith(category, action, options);
};
const createComponent = () => {
wrapper = shallowMountExtended(FreeTierPromo, {
provide: { provide: {
learnMorePath: '/learn-more', learnMorePath: '/learn-more',
promoImageAlt: 'some promo image', promoImageAlt: 'some promo image',
promoImagePath: '/some-image.svg', promoImagePath: '/some-image.svg',
tryNowPath: '/try-now', tryNowPath: '/try-now',
...providers, },
stubs: {
LocalStorageSync,
}, },
}); });
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
}; };
beforeEach(() => { const waitForReady = () => wrapper.vm.$nextTick();
wrapper = createComponent(); const findCollapseToggleButton = () => wrapper.findByTestId('collapse-btn');
}); const findCollapse = () => extendedWrapper(wrapper.findComponent(GlCollapse));
const findLearnMore = () => findCollapse().findComponent(GlLink);
const findTryNow = () => findCollapse().findComponent(GlButton);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
unmockTracking();
localStorage.clear();
}); });
describe('summary text', () => { describe('when ready', () => {
it('is rendered correctly', () => { beforeEach(async () => {
createComponent();
await waitForReady();
});
it('shows summary', () => {
expect(wrapper.findByText(MR_APPROVALS_PROMO_I18N.summary).exists()).toBeTruthy(); expect(wrapper.findByText(MR_APPROVALS_PROMO_I18N.summary).exists()).toBeTruthy();
}); });
});
describe('promo gl-accordion item', () => { it('shows collapse toggle button', () => {
let promoItem; const btn = findCollapseToggleButton();
beforeEach(() => { expect(btn.text()).toBe(MR_APPROVALS_PROMO_I18N.accordionTitle);
promoItem = wrapper.findComponent(GlAccordionItem); expect(btn.attributes()).toMatchObject({
variant: 'link',
icon: EXPANDED_ICON,
});
}); });
it('is given the expected title prop', () => { it('sets up collapse component (visible by default)', () => {
expect(promoItem.props('title')).toBe(MR_APPROVALS_PROMO_I18N.accordionTitle); expect(findCollapse().attributes()).toMatchObject({
visible: 'true',
});
}); });
it('starts expanded by default', () => { describe('within the collapse', () => {
expect(promoItem.props('visible')).toBeTruthy(); it('shows the title', () => {
}); const promoTitle = findCollapse().findByRole('heading', {
}); name: MR_APPROVALS_PROMO_I18N.promoTitle,
});
describe('promo title', () => { expect(promoTitle.exists()).toBeTruthy();
it('is rendered correctly', () => {
const promoTitle = wrapper.findByRole('heading', {
name: MR_APPROVALS_PROMO_I18N.promoTitle,
}); });
expect(promoTitle.exists()).toBeTruthy(); it('shows promo value statements', () => {
}); const statementItemTexts = findCollapse()
}); .findAllByRole('listitem')
.wrappers.map((li) => li.text());
describe('promo value statements list', () => { expect(statementItemTexts).toEqual(MR_APPROVALS_PROMO_I18N.valueStatements);
it('contains the expected statements', () => { });
const statementItemTexts = wrapper.findAllByRole('listitem').wrappers.map((li) => li.text());
expect(statementItemTexts).toEqual(MR_APPROVALS_PROMO_I18N.valueStatements); it('shows "Learn More" link under collapse', () => {
}); const learnMore = findLearnMore();
});
describe('"Learn More" link', () => { expect(learnMore.attributes()).toMatchObject({
let learnMoreLink; href: '/learn-more',
target: '_blank',
});
expect(learnMore.text()).toBe(MR_APPROVALS_PROMO_I18N.learnMore);
});
beforeEach(() => { it('when "Learn More" clicked, tracks', () => {
learnMoreLink = wrapper.findComponent(GlLink); findLearnMore().trigger('click');
});
it('has correct href', () => { expectTracking('_category_', trackingEvents.learnMoreClick);
expect(learnMoreLink.attributes('href')).toBe('/learn-more'); });
});
it('has correct text', () => { it('shows "Try Now" link under collapse', () => {
expect(learnMoreLink.text()).toBe(MR_APPROVALS_PROMO_I18N.learnMore); const tryNow = findTryNow();
});
});
describe('"Try Now" button', () => { expect(tryNow.attributes()).toMatchObject({
let tryNowBtn; category: 'primary',
variant: 'confirm',
href: '/try-now',
target: '_blank',
});
expect(tryNow.text()).toBe(MR_APPROVALS_PROMO_I18N.tryNow);
});
beforeEach(() => { it('when "Try Now" clicked, tracks', () => {
tryNowBtn = wrapper.findComponent(GlButton); findTryNow().trigger('click');
});
it('has correct href', () => { expectTracking('_category_', trackingEvents.tryNowClick);
expect(tryNowBtn.attributes('href')).toBe('/try-now'); });
});
it('shows the promo image', () => {
const promoImage = findCollapse().findByAltText('some promo image');
it('has correct text', () => { expect(promoImage.attributes('src')).toBe('/some-image.svg');
expect(tryNowBtn.text()).toBe(MR_APPROVALS_PROMO_I18N.tryNow); });
}); });
});
describe('promo image', () => { describe('when user clicks collapse toggle', () => {
it('has correct src', () => { beforeEach(() => {
const promoImage = wrapper.findByAltText('some promo image'); findCollapseToggleButton().vm.$emit('click');
});
expect(promoImage.attributes('src')).toBe('/some-image.svg'); it('tracks intent to collapse', () => {
expectTracking(undefined, trackingEvents.collapsePromo);
});
it('collapses the collapse component', () => {
expect(findCollapse().attributes('visible')).toBeUndefined();
});
it('updates local storage', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(MR_APPROVALS_PROMO_DISMISSED, 'true');
});
it('updates button icon', () => {
expect(findCollapseToggleButton().attributes('icon')).toBe(COLLAPSED_ICON);
});
}); });
}); });
describe('user interactions', () => { describe('when local storage is initialized with mr_approvals_promo.dismissed=true', () => {
describe('when user does not interact with the promo', () => { beforeEach(async () => {
describe('and we render a second time', () => { localStorage.setItem(MR_APPROVALS_PROMO_DISMISSED, 'true');
it('also starts expanded by default', () => { createComponent();
const secondWrapper = createComponent(); await waitForReady();
const promoItem = secondWrapper.findComponent(GlAccordionItem); localStorage.setItem.mockClear();
});
expect(promoItem.props('visible')).toBeTruthy(); it('should show collapse container as collapsed', async () => {
}); expect(findCollapse().attributes('visible')).toBeUndefined();
});
}); });
describe('when user collapses the promo', () => { describe('when user clicks collapse toggle', () => {
beforeEach(async () => { beforeEach(() => {
await wrapper.vm.$root.$emit(BV_COLLAPSE_STATE, 'accordion-item-id', false); findCollapseToggleButton().vm.$emit('click');
}); });
afterEach(() => { it('tracks intent to expand', () => {
localStorage.clear(); expectTracking(undefined, trackingEvents.expandPromo);
}); });
it('reflects that state in the promo collapsible item', () => { it('expands the collapse component', () => {
const promoItem = wrapper.findComponent(GlAccordionItem); expect(findCollapse().attributes('visible')).toBe('true');
expect(promoItem.props('visible')).toBeFalsy();
}); });
describe('and we render a second time', () => { it('does NOT update local storage', () => {
it('starts collapsed by default', () => { expect(localStorage.setItem).not.toHaveBeenCalled();
const secondWrapper = createComponent(); });
const promoItem = secondWrapper.findComponent(GlAccordionItem);
expect(promoItem.props('visible')).toBeFalsy(); it('updates button icon', () => {
}); expect(findCollapseToggleButton().attributes('icon')).toBe(EXPANDED_ICON);
}); });
}); });
}); });
......
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