Commit ecf8963a authored by Dallas Reedy's avatar Dallas Reedy Committed by David O'Regan

Add popover w/ more details on hover of trial status widget in sidebar

- Create separate popover Vue component
- Link the widget component and the popover component together so that
  the popover shows up on hover
- Add some explanatory text in the popover as well as a main CTA button
  and a secondary action button
- Add a linebreak to the widget title to accommodate the length of
  "Ultimate" vs. "Gold"
parent 71f8a6f8
<script>
import { GlButton, GlPopover, GlSprintf } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { debounce } from 'lodash';
import { formatDate } from '~/lib/utils/datetime_utility';
import { s__ } from '~/locale';
const RESIZE_EVENT_DEBOUNCE_MS = 150;
export default {
components: {
GlButton,
GlPopover,
GlSprintf,
},
props: {
containerId: {
type: [String, null],
required: false,
default: null,
},
groupName: {
type: String,
required: true,
},
planName: {
type: String,
required: true,
},
plansHref: {
type: String,
required: true,
},
purchaseHref: {
type: String,
required: true,
},
targetId: {
type: String,
required: true,
},
trialEndDate: {
type: Date,
required: true,
},
},
data: () => ({
disabled: false,
}),
i18n: {
compareAllButtonTitle: s__('Trials|Compare all plans'),
popoverTitle: s__('Trials|Hey there'),
popoverContent: s__(`Trials|Your trial ends on
%{boldStart}%{trialEndDate}%{boldEnd}. We hope you are enjoying GitLab
%{planName}. To continue using GitLab %{planName} after your trial ends,
you will need to buy a subscription. You can also choose GitLab Premium
if its features are sufficient for your needs.`),
upgradeButtonTitle: s__('Trials|Upgrade %{groupName} to %{planName}'),
},
computed: {
formattedTrialEndDate() {
return formatDate(this.trialEndDate, 'yyyy-mm-dd');
},
},
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();
},
updateDisabledState() {
this.disabled = ['xs', 'sm'].includes(bp.getBreakpointSize());
},
},
};
</script>
<template>
<gl-popover
:container="containerId"
:target="targetId"
:disabled="disabled"
triggers="hover focus"
placement="rightbottom"
boundary="viewport"
:delay="{ hide: 400 }"
>
<template #title>
{{ $options.i18n.popoverTitle }}
<gl-emoji class="gl-vertical-align-baseline font-size-inherit gl-ml-1" data-name="wave" />
</template>
<gl-sprintf :message="$options.i18n.popoverContent">
<template #bold="{ content }">
<b>{{ sprintf(content, { trialEndDate: formattedTrialEndDate }) }}</b>
</template>
<template #planName>{{ planName }}</template>
</gl-sprintf>
<div class="gl-mt-5">
<gl-button
:href="purchaseHref"
category="primary"
variant="confirm"
size="small"
class="gl-mb-0"
block
>
<span class="gl-font-sm">
<gl-sprintf :message="$options.i18n.upgradeButtonTitle">
<template #groupName>{{ groupName }}</template>
<template #planName>{{ planName }}</template>
</gl-sprintf>
</span>
</gl-button>
<gl-button
:href="plansHref"
category="secondary"
variant="confirm"
size="small"
class="gl-mb-0"
block
:title="$options.i18n.compareAllButtonTitle"
>
<span class="gl-font-sm">{{ $options.i18n.compareAllButtonTitle }}</span>
</gl-button>
</div>
</gl-popover>
</template>
<script>
import { GlLink, GlProgressBar } from '@gitlab/ui';
import { n__, sprintf } from '~/locale';
export default {
components: {
......@@ -7,8 +8,13 @@ export default {
GlProgressBar,
},
props: {
href: {
type: String,
containerId: {
type: [String, null],
required: false,
default: null,
},
daysRemaining: {
type: Number,
required: true,
},
navIconImagePath: {
......@@ -19,23 +25,42 @@ export default {
type: Number,
required: true,
},
title: {
planName: {
type: String,
required: true,
},
plansHref: {
type: String,
required: true,
},
},
computed: {
widgetTitle() {
const i18nWidgetTitle = n__(
'Trials|%{planName} Trial %{enDash} %{num} day left',
'Trials|%{planName} Trial %{enDash} %{num} days left',
this.daysRemaining,
);
return sprintf(i18nWidgetTitle, {
planName: this.planName,
enDash: '',
num: this.daysRemaining,
});
},
},
};
</script>
<template>
<gl-link :title="title" :href="href">
<gl-link :id="containerId" :title="widgetTitle" :href="plansHref">
<div class="gl-display-flex gl-flex-direction-column gl-align-items-stretch gl-w-full">
<span class="gl-display-flex gl-align-items-center">
<span class="nav-icon-container svg-container">
<img :src="navIconImagePath" width="16" class="svg" />
</span>
<span class="nav-item-name gl-white-space-normal">
{{ title }}
{{ widgetTitle }}
</span>
</span>
<span class="gl-display-flex gl-align-items-stretch gl-mt-3">
......
import Vue from 'vue';
import TrialStatusPopover from './components/trial_status_popover.vue';
import TrialStatusWidget from './components/trial_status_widget.vue';
export default () => {
export const initTrialStatusWidget = () => {
const el = document.getElementById('js-trial-status-widget');
if (!el) return undefined;
const { percentageComplete } = el.dataset;
const { daysRemaining, percentageComplete, ...props } = el.dataset;
return new Vue({
el,
render: (createElement) =>
createElement(TrialStatusWidget, {
props: {
...el.dataset,
...props,
daysRemaining: Number(daysRemaining),
percentageComplete: Number(percentageComplete),
},
}),
});
};
export const initTrialStatusPopover = () => {
const el = document.getElementById('js-trial-status-popover');
if (!el) return undefined;
const { trialEndDate, ...props } = el.dataset;
return new Vue({
el,
render: (createElement) =>
createElement(TrialStatusPopover, {
props: {
...props,
trialEndDate: new Date(trialEndDate),
},
}),
});
};
export const initTrialStatusWidgetAndPopover = () => {
return {
widget: initTrialStatusWidget(),
popover: initTrialStatusPopover(),
};
};
import $ from 'jquery';
import 'bootstrap/js/dist/modal';
import initTrialStatusWidget from 'ee/contextual_sidebar/group_trial_status_widget';
import initEETrialBanner from 'ee/ee_trial_banner';
import trackNavbarEvents from 'ee/event_tracking/navbar';
import initNamespaceStorageLimitAlert from 'ee/namespace_storage_limit_alert';
......@@ -16,6 +15,4 @@ $(() => {
initNamespaceStorageLimitAlert();
trackNavbarEvents();
initTrialStatusWidget();
});
import { initTrialStatusWidgetAndPopover } from 'ee/contextual_sidebar/group_trial_status_widget_and_popover';
initTrialStatusWidgetAndPopover();
......@@ -8,15 +8,8 @@ module TrialStatusWidgetHelper
user_can_administer_group?(group)
end
def trial_days_remaining_in_words(group)
num_of_days = group.trial_days_remaining
plan_title = group.gitlab_subscription&.plan_title
ns_(
"Trials|%{plan} Trial %{en_dash} %{num} day left",
"Trials|%{plan} Trial %{en_dash} %{num} days left",
num_of_days
) % { plan: plan_title, num: num_of_days, en_dash: '–' }
def plan_title_for_group(group)
group.gitlab_subscription&.plan_title
end
private
......
......@@ -2,7 +2,17 @@
- return unless show_trial_status_widget?(root_group)
= nav_link do
#js-trial-status-widget{ data: { href: group_billings_path(root_group),
#js-trial-status-widget{ data: { container_id: 'trial-status-sidebar-widget',
days_remaining: root_group.trial_days_remaining,
nav_icon_image_path: image_path('illustrations/golden_tanuki.svg'),
title: trial_days_remaining_in_words(root_group),
percentage_complete: root_group.trial_percentage_complete } }
percentage_complete: root_group.trial_percentage_complete,
plan_name: plan_title_for_group(root_group),
plans_href: group_billings_path(root_group) } }
#js-trial-status-popover{ data: { container_id: 'trial-status-sidebar-widget',
group_name: root_group.name,
plan_name: plan_title_for_group(root_group),
plans_href: group_billings_path(root_group),
purchase_href: new_subscriptions_path(namespace_id: group.id, plan_id: '2c92a0fc5a83f01d015aa6db83c45aac'),
target_id: 'trial-status-sidebar-widget',
trial_end_date: root_group.trial_ends_on } }
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TrialStatusPopover component matches the snapshot 1`] = `
<gl-popover-stub
boundary="viewport"
cssclasses=""
delay="[object Object]"
placement="rightbottom"
target="target-element-identifier"
triggers="hover focus"
>
<gl-sprintf-stub
message="Your trial ends on %{boldStart}%{trialEndDate}%{boldEnd}. We hope you are enjoying GitLab %{planName}. To continue using GitLab %{planName} after your trial ends, you will need to buy a subscription. You can also choose GitLab Premium if its features are sufficient for your needs."
/>
<div
class="gl-mt-5"
>
<gl-button-stub
block=""
buttontextclasses=""
category="primary"
class="gl-mb-0"
href="transactions/new"
icon=""
size="small"
variant="confirm"
>
<span
class="gl-font-sm"
>
<gl-sprintf-stub
message="Upgrade %{groupName} to %{planName}"
/>
</span>
</gl-button-stub>
<gl-button-stub
block=""
buttontextclasses=""
category="secondary"
class="gl-mb-0"
href="billing/path-for/group"
icon=""
size="small"
title="Compare all plans"
variant="confirm"
>
<span
class="gl-font-sm"
>
Compare all plans
</span>
</gl-button-stub>
</div>
</gl-popover-stub>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TrialStatusWidget component matches the snapshot 1`] = `
exports[`TrialStatusWidget component without the optional containerId prop matches the snapshot 1`] = `
<gl-link-stub
href="billing/path-for/group"
title="Gold Trial – 27 days left"
title="Ultimate Trial – 20 days left"
>
<div
class="gl-display-flex gl-flex-direction-column gl-align-items-stretch gl-w-full"
......@@ -25,7 +25,7 @@ exports[`TrialStatusWidget component matches the snapshot 1`] = `
class="nav-item-name gl-white-space-normal"
>
Gold Trial – 27 days left
Ultimate Trial – 20 days left
</span>
</span>
......
import { GlPopover } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { shallowMount } from '@vue/test-utils';
import TrialStatusPopover from 'ee/contextual_sidebar/components/trial_status_popover.vue';
describe('TrialStatusPopover component', () => {
let wrapper;
const createComponent = () => {
return shallowMount(TrialStatusPopover, {
propsData: {
groupName: 'Some Test Group',
planName: 'Ultimate',
plansHref: 'billing/path-for/group',
purchaseHref: 'transactions/new',
targetId: 'target-element-identifier',
trialEndDate: new Date('2021-02-28'),
},
});
};
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('methods', () => {
describe('onResize', () => {
const getGlPopover = () => wrapper.findComponent(GlPopover);
it.each`
bp | isDisabled
${'xs'} | ${'true'}
${'sm'} | ${'true'}
${'md'} | ${undefined}
${'lg'} | ${undefined}
${'xl'} | ${undefined}
`(
'sets disabled to `$isDisabled` when the breakpoint is "$bp"',
async ({ bp, isDisabled }) => {
jest.spyOn(GlBreakpointInstance, 'getBreakpointSize').mockReturnValue(bp);
wrapper.vm.onResize();
await wrapper.vm.$nextTick();
expect(getGlPopover().attributes('disabled')).toBe(isDisabled);
},
);
});
});
});
import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import TrialStatusWidget from 'ee/contextual_sidebar/components/trial_status_widget.vue';
......@@ -5,26 +6,47 @@ import TrialStatusWidget from 'ee/contextual_sidebar/components/trial_status_wid
describe('TrialStatusWidget component', () => {
let wrapper;
const createComponent = () => {
const getGlLink = () => wrapper.findComponent(GlLink);
const createComponent = ({ props } = {}) => {
return shallowMount(TrialStatusWidget, {
propsData: {
href: 'billing/path-for/group',
daysRemaining: 20,
navIconImagePath: 'illustrations/golden_tanuki.svg',
percentageComplete: 10,
title: 'Gold Trial – 27 days left',
planName: 'Ultimate',
plansHref: 'billing/path-for/group',
...props,
},
});
};
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
describe('without the optional containerId prop', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders without an id', () => {
expect(getGlLink().attributes('id')).toBe(undefined);
});
});
describe('with the optional containerId prop', () => {
beforeEach(() => {
wrapper = createComponent({ props: { containerId: 'some-id' } });
});
it('renders with the given id', () => {
expect(getGlLink().attributes('id')).toBe('some-id');
});
});
});
......@@ -58,34 +58,25 @@ RSpec.describe TrialStatusWidgetHelper do
end
end
describe '#trial_days_remaining_in_words' do
let_it_be(:group) { build(:group) }
let!(:subscription) { build(:gitlab_subscription, :active_trial, namespace: group) }
describe '#plan_title_for_group' do
using RSpec::Parameterized::TableSyntax
subject { helper.trial_days_remaining_in_words(group) }
let_it_be(:group) { create(:group) }
context 'when there are 0 days remaining' do
before do
subscription.trial_ends_on = Date.current
end
subject { helper.plan_title_for_group(group) }
it { is_expected.to eq('Ultimate Trial – 0 days left') }
where(:plan, :title) do
:bronze | 'Bronze'
:silver | 'Silver'
:gold | 'Gold'
:premium | 'Premium'
:ultimate | 'Ultimate'
end
context 'when there is 1 day remaining' do
before do
subscription.trial_ends_on = Date.current.advance(days: 1)
end
with_them do
let!(:subscription) { build(:gitlab_subscription, plan, namespace: group) }
it { is_expected.to eq('Ultimate Trial – 1 day left') }
end
context 'when there are 2+ days remaining' do
before do
subscription.trial_ends_on = Date.current.advance(days: 13)
end
it { is_expected.to eq('Ultimate Trial – 13 days left') }
it { is_expected.to eq(title) }
end
end
end
......@@ -5,31 +5,43 @@ require 'spec_helper'
RSpec.describe 'layouts/nav/sidebar/_group' do
before do
assign(:group, group)
allow(view).to receive(:show_trial_status_widget?).with(group).and_return(show_trial_status_widget)
allow(view).to receive(:show_trial_status_widget?).and_return(false)
end
let(:group) { create(:group) }
let(:user) { create(:user) }
let(:show_trial_status_widget) { false }
describe 'trial status widget' do
describe 'trial status widget', :aggregate_failures do
let!(:gitlab_subscription) { create(:gitlab_subscription, :active_trial, namespace: group) }
let(:show_widget) { false }
context 'when the experiment is off' do
it 'is not rendered' do
render
before do
allow(view).to receive(:show_trial_status_widget?).and_return(show_widget)
render
end
expect(rendered).not_to have_selector '#js-trial-status-widget'
subject { rendered }
context 'when the widget should not be shown' do
it 'does not render' do
is_expected.not_to have_selector '#js-trial-status-widget'
is_expected.not_to have_selector '#js-trial-status-popover'
end
end
context 'when the experiment is on' do
let(:show_trial_status_widget) { true }
context 'when the widget should be shown' do
let(:show_widget) { true }
it 'is rendered' do
render
it 'renders both the widget & popover component initialization elements' do
is_expected.to have_selector '#js-trial-status-widget'
is_expected.to have_selector '#js-trial-status-popover'
end
it 'supplies the same popover-trigger id value to both initialization elements' do
expected_id = 'trial-status-sidebar-widget'
expect(rendered).to have_selector '#js-trial-status-widget'
is_expected.to have_selector "[data-container-id=#{expected_id}]"
is_expected.to have_selector "[data-target-id=#{expected_id}]"
end
end
end
......
......@@ -31378,20 +31378,29 @@ msgstr ""
msgid "Trending"
msgstr ""
msgid "Trials|%{plan} Trial %{en_dash} %{num} day left"
msgid_plural "Trials|%{plan} Trial %{en_dash} %{num} days left"
msgid "Trials|%{planName} Trial %{enDash} %{num} day left"
msgid_plural "Trials|%{planName} Trial %{enDash} %{num} days left"
msgstr[0] ""
msgstr[1] ""
msgid "Trials|Compare all plans"
msgstr ""
msgid "Trials|Create a new group to start your GitLab Ultimate trial."
msgstr ""
msgid "Trials|Go back to GitLab"
msgstr ""
msgid "Trials|Hey there"
msgstr ""
msgid "Trials|Skip Trial"
msgstr ""
msgid "Trials|Upgrade %{groupName} to %{planName}"
msgstr ""
msgid "Trials|You can always resume this process by selecting your avatar and choosing 'Start an Ultimate trial'"
msgstr ""
......@@ -31407,6 +31416,9 @@ msgstr ""
msgid "Trials|You won't get a free trial right now but you can always resume this process by clicking on your avatar and choosing 'Start a free trial'"
msgstr ""
msgid "Trials|Your trial ends on %{boldStart}%{trialEndDate}%{boldEnd}. We hope you are enjoying GitLab %{planName}. To continue using GitLab %{planName} after your trial ends, you will need to buy a subscription. You can also choose GitLab Premium if its features are sufficient for your needs."
msgstr ""
msgid "Trial|Company name"
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