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