Commit bee8f13c authored by Dallas Reedy's avatar Dallas Reedy Committed by Mark Florian

Show popover with more info on hover of paid feature highlight badge

- Add popover component with more details about the paid feature being
  highlighted
- Track an event whenever the popover is shown
- Do not show the popover on smaller screen sizes
- Only show the tooltip on smaller screen sizes
parent 47f3cd02
import '~/pages/projects/merge_requests/creations/new/index';
import { initPaidFeatureCalloutBadge } from 'ee/paid_feature_callouts/index';
import { initPaidFeatureCalloutBadgeAndPopover } from 'ee/paid_feature_callouts/index';
import UserCallout from '~/user_callout';
import initForm from '../../shared/init_form';
initForm();
initPaidFeatureCalloutBadge();
initPaidFeatureCalloutBadgeAndPopover();
// eslint-disable-next-line no-new
new UserCallout();
import '~/pages/projects/merge_requests/edit/index';
import { initPaidFeatureCalloutBadge } from 'ee/paid_feature_callouts/index';
import { initPaidFeatureCalloutBadgeAndPopover } from 'ee/paid_feature_callouts/index';
import initForm from '../shared/init_form';
initForm();
initPaidFeatureCalloutBadge();
initPaidFeatureCalloutBadgeAndPopover();
<script>
import { GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { debounce } from 'lodash';
import { __ } from '~/locale';
import Tracking from '~/tracking';
const RESIZE_EVENT_DEBOUNCE_MS = 150;
export default {
components: {
GlBadge,
......@@ -15,28 +19,47 @@ export default {
i18n: {
title: __('This feature is part of your GitLab Ultimate trial.'),
},
data() {
return {
tooltipDisabled: false,
};
},
created() {
this.debouncedResize = debounce(() => this.onResize(), RESIZE_EVENT_DEBOUNCE_MS);
window.addEventListener('resize', this.debouncedResize);
},
mounted() {
this.trackBadgeDisplayedForExperiment();
this.onResize();
},
beforeDestroy() {
window.removeEventListener('resize', this.debouncedResize);
},
methods: {
onResize() {
this.updateTooltipDisabledState();
},
trackBadgeDisplayedForExperiment() {
this.track('display_badge', {
label: 'feature_highlight_badge',
property: 'experiment:highlight_paid_features_during_active_trial',
});
},
updateTooltipDisabledState() {
this.tooltipDisabled = bp.getBreakpointSize() !== 'xs';
},
},
};
</script>
<template>
<gl-badge
v-gl-tooltip
v-gl-tooltip="{ disabled: tooltipDisabled }"
:title="$options.i18n.title"
tabindex="0"
size="sm"
class="feature-highlight-badge"
>
<gl-icon name="license" :size="12" />
<gl-icon name="license" :size="14" />
</gl-badge>
</template>
<script>
import { GlPopover } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { debounce } from 'lodash';
import { n__, s__, sprintf } from '~/locale';
import Tracking from '~/tracking';
const RESIZE_EVENT_DEBOUNCE_MS = 150;
export default {
components: {
GlPopover,
},
mixins: [Tracking.mixin()],
props: {
containerId: {
type: String,
required: false,
default: undefined,
},
daysRemaining: {
type: Number,
required: true,
},
featureName: {
type: String,
required: true,
},
planNameForTrial: {
type: String,
required: true,
},
planNameForUpgrade: {
type: String,
required: true,
},
targetId: {
type: String,
required: true,
},
},
data() {
return {
disabled: false,
};
},
computed: {
popoverTitle() {
const i18nPopoverTitle = n__(
'FeatureHighlight|%{daysRemaining} day remaining to enjoy %{featureName}',
'FeatureHighlight|%{daysRemaining} days remaining to enjoy %{featureName}',
this.daysRemaining,
);
return sprintf(i18nPopoverTitle, {
daysRemaining: this.daysRemaining,
featureName: this.featureName,
});
},
popoverContent() {
const i18nPopoverContent = s__(`FeatureHighlight|Enjoying your GitLab %{planNameForTrial} trial? To continue
using %{featureName} after your trial ends, upgrade to GitLab %{planNameForUpgrade}.`);
return sprintf(i18nPopoverContent, {
featureName: this.featureName,
planNameForTrial: this.planNameForTrial,
planNameForUpgrade: this.planNameForUpgrade,
});
},
},
created() {
this.debouncedResize = debounce(() => this.onResize(), RESIZE_EVENT_DEBOUNCE_MS);
window.addEventListener('resize', this.debouncedResize);
},
mounted() {
this.onResize();
},
beforeDestroy() {
window.removeEventListener('resize', this.debouncedResize);
},
methods: {
onResize() {
this.updateDisabledState();
},
onShown() {
this.track('popover_shown', {
label: `feature_highlight_popover:${this.featureName}`,
property: 'experiment:highlight_paid_features_during_active_trial',
});
},
updateDisabledState() {
this.disabled = bp.getBreakpointSize() === 'xs';
},
},
};
</script>
<template>
<gl-popover
:container="containerId"
:target="targetId"
:disabled="disabled"
placement="top"
boundary="viewport"
:delay="{ hide: 400 }"
@shown="onShown"
>
<template #title>{{ popoverTitle }}</template>
{{ popoverContent }}
</gl-popover>
</template>
import Vue from 'vue';
import PaidFeatureCalloutBadge from './components/paid_feature_callout_badge.vue';
import PaidFeatureCalloutPopover from './components/paid_feature_callout_popover.vue';
export const initPaidFeatureCalloutBadge = () => {
const el = document.getElementById('js-paid-feature-badge');
if (!el) return undefined;
const { id } = el.dataset;
return new Vue({
el,
render: (createElement) => createElement(PaidFeatureCalloutBadge, { attrs: { id } }),
});
};
export const initPaidFeatureCalloutPopover = () => {
const el = document.getElementById('js-paid-feature-popover');
if (!el) return undefined;
const {
containerId,
daysRemaining,
featureName,
planNameForTrial,
planNameForUpgrade,
targetId,
} = el.dataset;
return new Vue({
el,
render: (createElement) => createElement(PaidFeatureCalloutBadge),
render: (createElement) =>
createElement(PaidFeatureCalloutPopover, {
props: {
containerId,
daysRemaining: Number(daysRemaining),
featureName,
planNameForTrial,
planNameForUpgrade,
targetId,
},
}),
});
};
export const initPaidFeatureCalloutBadgeAndPopover = () => {
return {
badge: initPaidFeatureCalloutBadge(),
popover: initPaidFeatureCalloutPopover(),
};
};
# frozen_string_literal: true
# NOTE: This is largely mimicking the structure created as part of the
# TrialStatusWidgetHelper (ee/app/helpers/trial_status_widget_helper.rb), & it
# is utilizing a few methods (including private ones) from that helper as well.
module PaidFeatureCalloutHelper
def run_highlight_paid_features_during_active_trial_experiment(group, &block)
experiment(:highlight_paid_features_during_active_trial, group: group) do |e|
......@@ -9,4 +12,27 @@ module PaidFeatureCalloutHelper
e.try(&block)
end
end
def paid_feature_badge_data_attrs(feature_name)
{ id: feature_callout_container_id(feature_name) }
end
def paid_feature_popover_data_attrs(group:, feature_name:)
container_id = feature_callout_container_id(feature_name)
{
container_id: container_id,
days_remaining: group.trial_days_remaining,
feature_name: feature_name,
plan_name_for_trial: group.gitlab_subscription&.plan_title,
plan_name_for_upgrade: 'Premium',
target_id: container_id
}
end
private
def feature_callout_container_id(feature_name)
"#{feature_name.parameterize}-callout"
end
end
# frozen_string_literal: true
# NOTE: The patterns first introduced in this helper for doing trial-related
# callouts are mimicked by the PaidFeatureCalloutHelper. A third reuse of these
# patterns (especially as these experiments finish & become permanent parts of
# the codebase) could trigger the need to extract these patterns into a single,
# reusable, sharable helper.
module TrialStatusWidgetHelper
def trial_status_popover_data_attrs(group)
base_attrs = trial_status_common_data_attrs(group)
......
......@@ -5,12 +5,14 @@
- if !Feature.enabled?(:mr_collapsed_approval_rules, @project)
.form-group.row
.col-sm-2.col-form-label
.col-sm-2.col-form-label.gl-static
.gl-display-flex.gl-align-items-center.gl-justify-content-end
- root_group = @project.group&.root_ancestor
- run_highlight_paid_features_during_active_trial_experiment(root_group) do
- feature_name = 'merge request approvals'
.gl-mr-3.gl-mb-2
#js-paid-feature-badge
#js-paid-feature-badge{ data: paid_feature_badge_data_attrs(feature_name) }
#js-paid-feature-popover{ data: paid_feature_popover_data_attrs(group: root_group, feature_name: feature_name) }
= form.label :approver_ids, "Approval rules"
.col-sm-10
= render_if_exists 'shared/issuable/approver_suggestion', issuable: issuable, presenter: presenter
import { GlBadge } from '@gitlab/ui';
import { GlBadge, GlIcon } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { shallowMount } from '@vue/test-utils';
import PaidFeatureCalloutBadge from 'ee/paid_feature_callouts/components/paid_feature_callout_badge.vue';
......@@ -9,30 +10,74 @@ describe('PaidFeatureCalloutBadge component', () => {
let wrapper;
const findGlBadge = () => wrapper.findComponent(GlBadge);
const findGlIcon = () => wrapper.findComponent(GlIcon);
const createComponent = () => {
return shallowMount(PaidFeatureCalloutBadge);
};
beforeEach(() => {
trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders the title', () => {
expect(findGlBadge().attributes('title')).toBe(
'This feature is part of your GitLab Ultimate trial.',
);
describe('with some default props', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('sets attributes on the GlBadge component', () => {
expect(findGlBadge().attributes()).toMatchObject({
title: 'This feature is part of your GitLab Ultimate trial.',
tabindex: '0',
size: 'sm',
class: 'feature-highlight-badge',
});
});
it('sets attributes on the GlIcon component', () => {
expect(findGlIcon().attributes()).toEqual({
name: 'license',
size: '14',
});
});
});
it('tracks that the badge has been displayed when mounted', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'display_badge', {
label: 'feature_highlight_badge',
property: 'experiment:highlight_paid_features_during_active_trial',
describe('tracking', () => {
beforeEach(() => {
trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
wrapper = createComponent();
});
it('tracks that the badge has been displayed when mounted', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'display_badge', {
label: 'feature_highlight_badge',
property: 'experiment:highlight_paid_features_during_active_trial',
});
});
});
describe('onResize', () => {
beforeEach(() => {
wrapper = createComponent();
});
it.each`
bp | tooltipDisabled
${'xs'} | ${false}
${'sm'} | ${true}
${'md'} | ${true}
${'lg'} | ${true}
${'xl'} | ${true}
`(
'sets tooltipDisabled to `$tooltipDisabled` when the breakpoint is "$bp"',
async ({ bp, tooltipDisabled }) => {
jest.spyOn(GlBreakpointInstance, 'getBreakpointSize').mockReturnValue(bp);
wrapper.vm.onResize();
await wrapper.vm.$nextTick();
expect(wrapper.vm.tooltipDisabled).toBe(tooltipDisabled);
},
);
});
});
import { GlPopover } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { shallowMount } from '@vue/test-utils';
import PaidFeatureCalloutPopover from 'ee/paid_feature_callouts/components/paid_feature_callout_popover.vue';
import { mockTracking } from 'helpers/tracking_helper';
describe('PaidFeatureCalloutPopover', () => {
let trackingSpy;
let wrapper;
const findGlPopover = () => wrapper.findComponent(GlPopover);
const defaultProps = {
daysRemaining: 12,
featureName: 'some feature',
planNameForTrial: 'Ultimate',
planNameForUpgrade: 'Premium',
targetId: 'some-feature-callout-target',
};
const createComponent = (props = defaultProps) => {
return shallowMount(PaidFeatureCalloutPopover, {
propsData: props,
});
};
afterEach(() => {
wrapper.destroy();
});
describe('with some default props', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('sets attributes on the GlPopover component', () => {
const attributes = findGlPopover().attributes();
expect(attributes).toMatchObject({
boundary: 'viewport',
placement: 'top',
target: 'some-feature-callout-target',
});
expect(attributes.containerId).toBeUndefined();
});
});
describe('with additional, optional props', () => {
beforeEach(() => {
wrapper = createComponent({
...defaultProps,
containerId: 'some-container-id',
});
});
it('sets more attributes on the GlPopover component', () => {
expect(findGlPopover().attributes()).toMatchObject({
boundary: 'viewport',
container: 'some-container-id',
placement: 'top',
target: 'some-feature-callout-target',
});
});
});
describe('onShown', () => {
beforeEach(() => {
trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
wrapper = createComponent();
findGlPopover().vm.$emit('shown');
});
it('tracks that the popover has been shown', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'popover_shown', {
label: 'feature_highlight_popover:some feature',
property: 'experiment:highlight_paid_features_during_active_trial',
});
});
});
describe('onResize', () => {
beforeEach(() => {
wrapper = createComponent();
});
it.each`
bp | disabled
${'xs'} | ${'true'}
${'sm'} | ${undefined}
${'md'} | ${undefined}
${'lg'} | ${undefined}
${'xl'} | ${undefined}
`(
'sets the GlPopover’s disabled attribute to `$disabled` when the breakpoint is "$bp"',
async ({ bp, disabled }) => {
jest.spyOn(GlBreakpointInstance, 'getBreakpointSize').mockReturnValue(bp);
wrapper.vm.onResize();
await wrapper.vm.$nextTick();
expect(findGlPopover().attributes('disabled')).toBe(disabled);
},
);
});
});
......@@ -60,4 +60,32 @@ RSpec.describe PaidFeatureCalloutHelper do
end
end
end
describe '#paid_feature_badge_data_attrs' do
subject { helper.paid_feature_badge_data_attrs('some feature') }
it 'returns the set of data attributes needed to bootstrap the PaidFeatureCalloutBadge component' do
is_expected.to eq({ id: 'some-feature-callout' })
end
end
describe '#paid_feature_popover_data_attrs' do
let(:subscription) { instance_double(GitlabSubscription, plan_title: 'Ultimate') }
let(:group) { instance_double(Group, trial_days_remaining: 12, gitlab_subscription: subscription) }
subject { helper.paid_feature_popover_data_attrs(group: group, feature_name: 'first feature') }
it 'returns the set of data attributes needed to bootstrap the PaidFeatureCalloutPopover component' do
expected_attrs = {
container_id: 'first-feature-callout',
feature_name: 'first feature',
days_remaining: 12,
plan_name_for_trial: 'Ultimate',
plan_name_for_upgrade: 'Premium',
target_id: 'first-feature-callout'
}
is_expected.to eq(expected_attrs)
end
end
end
......@@ -86,6 +86,10 @@ RSpec.describe 'shared/issuable/_approvals.html.haml' do
it 'does not render the paid feature badge' do
expect(rendered).not_to have_css('#js-paid-feature-badge')
end
it 'does not render the paid feature popover' do
expect(rendered).not_to have_css('#js-paid-feature-popover')
end
end
context 'when user is in the candidate' do
......@@ -94,6 +98,10 @@ RSpec.describe 'shared/issuable/_approvals.html.haml' do
it 'renders the paid feature badge' do
expect(rendered).to have_css('#js-paid-feature-badge')
end
it 'renders the paid feature popover' do
expect(rendered).to have_css('#js-paid-feature-popover')
end
end
end
end
......@@ -13732,6 +13732,14 @@ msgstr ""
msgid "FeatureFlag|User List"
msgstr ""
msgid "FeatureHighlight|%{daysRemaining} day remaining to enjoy %{featureName}"
msgid_plural "FeatureHighlight|%{daysRemaining} days remaining to enjoy %{featureName}"
msgstr[0] ""
msgstr[1] ""
msgid "FeatureHighlight|Enjoying your GitLab %{planNameForTrial} trial? To continue using %{featureName} after your trial ends, upgrade to GitLab %{planNameForUpgrade}."
msgstr ""
msgid "Feb"
msgstr ""
......
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