Commit 24f68da2 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'vs/reactivate-extend-trial-button' into 'master'

Add Vue.js-based button for extending/reactivating trial

See merge request gitlab-org/gitlab!66049
parents fdb0a34b 3bbd0dcb
......@@ -2,6 +2,12 @@ import { buildApiUrl } from '~/api/api_utils';
import axios from '~/lib/utils/axios_utils';
const SUBSCRIPTIONS_PATH = '/api/:version/subscriptions';
const EXTEND_REACTIVATE_TRIAL_PATH = '/-/trials/extend_reactivate';
const TRIAL_EXTENSION_TYPE = Object.freeze({
extended: 1,
reactivated: 2,
});
export function createSubscription(groupId, customer, subscription) {
const url = buildApiUrl(SUBSCRIPTIONS_PATH);
......@@ -13,3 +19,25 @@ export function createSubscription(groupId, customer, subscription) {
return axios.post(url, { params });
}
const updateTrial = async (namespaceId, trialExtensionType) => {
if (!Object.values(TRIAL_EXTENSION_TYPE).includes(trialExtensionType)) {
throw new TypeError('The "trialExtensionType" argument is invalid.');
}
const url = buildApiUrl(EXTEND_REACTIVATE_TRIAL_PATH);
const params = {
namespace_id: namespaceId,
trial_extension_type: trialExtensionType,
};
return axios.put(url, params);
};
export const extendTrial = async (namespaceId) => {
return updateTrial(namespaceId, TRIAL_EXTENSION_TYPE.extended);
};
export const reactivateTrial = async (namespaceId) => {
return updateTrial(namespaceId, TRIAL_EXTENSION_TYPE.reactivated);
};
......@@ -9,7 +9,7 @@ export default {
},
inject: {
namespaceId: {
default: '',
default: null,
},
},
created() {
......
......@@ -9,6 +9,7 @@ import {
DAYS_FOR_RENEWAL,
PLAN_TITLE_TRIAL_TEXT,
} from 'ee/billings/constants';
import ExtendReactivateTrialButton from 'ee/trials/extend_reactivate_trial/components/extend_reactivate_trial_button.vue';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { getDayDifference } from '~/lib/utils/datetime/date_calculation_utility';
......@@ -24,6 +25,7 @@ export default {
GlButton,
GlLoadingIcon,
SubscriptionTableRow,
ExtendReactivateTrialButton,
},
mixins: [glFeatureFlagsMixin()],
inject: {
......@@ -34,7 +36,7 @@ export default {
default: '',
},
namespaceId: {
default: '',
default: null,
},
customerPortalUrl: {
default: '',
......@@ -54,6 +56,9 @@ export default {
refreshSeatsHref: {
default: '',
},
availableTrialAction: {
default: null,
},
},
computed: {
...mapState([
......@@ -182,7 +187,14 @@ export default {
data-testid="subscription-header"
>
<strong>{{ subscriptionHeader }}</strong>
<div class="controls">
<div class="gl-display-flex">
<extend-reactivate-trial-button
v-if="availableTrialAction"
:namespace-id="namespaceId"
:action="availableTrialAction"
:plan-name="planName"
class="gl-mr-3"
/>
<gl-button
v-for="(button, index) in buttons"
:key="button.text"
......
......@@ -24,13 +24,14 @@ export default (containerId = 'js-billing-plans') => {
planName,
freePersonalNamespace,
refreshSeatsHref,
action,
} = containerEl.dataset;
return new Vue({
el: containerEl,
store: new Vuex.Store(initialStore()),
provide: {
namespaceId,
namespaceId: Number(namespaceId),
namespaceName,
addSeatsHref,
planUpgradeHref,
......@@ -40,6 +41,7 @@ export default (containerId = 'js-billing-plans') => {
planName,
freePersonalNamespace: parseBoolean(freePersonalNamespace),
refreshSeatsHref,
availableTrialAction: action,
},
render(createElement) {
return createElement(SubscriptionApp);
......
import { shouldQrtlyReconciliationMount } from 'ee/billings/qrtly_reconciliation';
import initSubscriptions from 'ee/billings/subscriptions';
import { shouldExtendReactivateTrialButtonMount } from 'ee/trials/extend_reactivate_trial';
import PersistentUserCallout from '~/persistent_user_callout';
PersistentUserCallout.factory(document.querySelector('.js-gold-trial-callout'));
initSubscriptions();
shouldExtendReactivateTrialButtonMount();
shouldQrtlyReconciliationMount();
import initSubscriptions from 'ee/billings/subscriptions';
import { shouldExtendReactivateTrialButtonMount } from 'ee/trials/extend_reactivate_trial';
import PersistentUserCallout from '~/persistent_user_callout';
PersistentUserCallout.factory(document.querySelector('.js-gold-trial-callout'));
shouldExtendReactivateTrialButtonMount();
initSubscriptions();
<script>
import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
import { extendTrial, reactivateTrial } from 'ee/api/subscriptions_api';
import createFlash from '~/flash';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import { sprintf, __ } from '~/locale';
import { i18n, TRIAL_ACTION_EXTEND, TRIAL_ACTIONS } from '../constants';
export default {
name: 'ExtendReactivateTrialButton',
components: { GlButton, GlModal },
directives: {
GlModal: GlModalDirective,
},
props: {
namespaceId: {
type: Number,
required: true,
},
action: {
type: String,
required: true,
default: TRIAL_ACTION_EXTEND,
validator: (value) => TRIAL_ACTIONS.includes(value),
},
planName: {
type: String,
required: true,
},
},
data() {
return {
isLoading: false,
};
},
computed: {
i18nContext() {
return this.action === TRIAL_ACTION_EXTEND
? this.$options.i18n.extend
: this.$options.i18n.reactivate;
},
modalText() {
return sprintf(this.i18nContext.modalText, {
action: this.actionName,
planName: sprintf(this.$options.i18n.planName, { planName: this.planName }),
});
},
actionPrimary() {
return {
text: this.i18nContext.buttonText,
};
},
actionSecondary() {
return {
text: __('Cancel'),
};
},
},
methods: {
async submit() {
this.isLoading = true;
this.$refs.modal.hide();
const action = this.action === TRIAL_ACTION_EXTEND ? extendTrial : reactivateTrial;
await action(this.namespaceId)
.then(() => {
refreshCurrentPage();
})
.catch((error) => {
createFlash({
message: this.i18nContext.trialActionError,
captureError: true,
error,
});
})
.finally(() => {
this.isLoading = false;
});
},
},
i18n,
};
</script>
<template>
<div>
<gl-button v-gl-modal.extend-trial :loading="isLoading" category="primary" variant="info">
{{ i18nContext.buttonText }}
</gl-button>
<gl-modal
ref="modal"
modal-id="extend-trial"
:title="i18nContext.buttonText"
:action-primary="actionPrimary"
:action-secondary="actionSecondary"
data-testid="extend-reactivate-trial-modal"
@primary="submit"
>
{{ modalText }}
</gl-modal>
</div>
</template>
import { s__ } from '~/locale';
export const TRIAL_ACTION_EXTEND = 'extend';
export const TRIAL_ACTION_REACTIVATE = 'reactivate';
export const TRIAL_ACTIONS = [TRIAL_ACTION_EXTEND, TRIAL_ACTION_REACTIVATE];
export const i18n = Object.freeze({
planName: s__('Billings|%{planName} plan'),
extend: {
buttonText: s__('Billings|Extend trial'),
modalText: s__(
'Billings|By extending your trial, you will receive an additional 30 days of %{planName}. Your trial can be only extended once.',
),
trialActionError: s__('Billings|An error occurred while extending your trial.'),
},
reactivate: {
buttonText: s__('Billings|Reactivate trial'),
modalText: s__(
'Billings|By reactivating your trial, you will receive an additional 30 days of %{planName}. Your trial can be only reactivated once.',
),
trialActionError: s__('Billings|An error occurred while reactivating your trial.'),
},
});
export const shouldExtendReactivateTrialButtonMount = async () => {
const el = document.querySelector('.js-extend-reactivate-trial-button');
if (el) {
const { initExtendReactivateTrialButton } = await import(
/* webpackChunkName: 'init_extend_reactivate_trial_button' */ './init_extend_reactivate_trial_button'
);
initExtendReactivateTrialButton(el);
}
};
import Vue from 'vue';
import ExtendReactivateTrialButton from 'ee/trials/extend_reactivate_trial/components/extend_reactivate_trial_button.vue';
export const initExtendReactivateTrialButton = (el) => {
const { namespaceId, action, planName } = el.dataset;
return new Vue({
el,
render(createElement) {
return createElement(ExtendReactivateTrialButton, {
props: {
namespaceId: Number(namespaceId),
planName,
action,
},
});
},
});
};
......@@ -123,7 +123,6 @@ $badge-height: $gl-spacing-scale-7;
.card-wrapper {
margin-bottom: $gutter-small;
padding-top: $badge-height;
width: calc(50% - #{$gutter-small} / 2);
&-has-badge {
......
......@@ -69,7 +69,7 @@ module EE
{
namespace_id: namespace.id,
plan_name: namespace.actual_plan_name.titleize,
plan_name: ::Plan::ULTIMATE.titleize,
action: action
}
end
......
......@@ -9,4 +9,5 @@
- else
= render 'shared/billings/billing_plans', plans_data: @plans_data, namespace: @group, current_plan: current_plan
#js-billing-plans{ data: subscription_plan_data_attributes(@group, current_plan) }
- data_attributes = subscription_plan_data_attributes(@group, current_plan).merge(extend_reactivate_trial_button_data(@group))
#js-billing-plans{ data: data_attributes }
- page_title _('Billing')
- current_plan = subscription_plan_info(@plans_data, current_user.namespace.actual_plan_name)
- namespace = current_user.namespace
- current_plan = subscription_plan_info(@plans_data, namespace.actual_plan_name)
- data_attributes = subscription_plan_data_attributes(namespace, current_plan).merge(extend_reactivate_trial_button_data(namespace))
= render 'shared/billings/billing_plans', plans_data: @plans_data, namespace: current_user.namespace, current_plan: current_plan
#js-billing-plans{ data: subscription_plan_data_attributes(current_user.namespace, current_plan) }
#js-billing-plans{ data: data_attributes }
......@@ -3,7 +3,7 @@
- if namespace_for_user
= render_if_exists 'trials/banner', namespace: namespace
.billing-plan-header.content-block.center.gl-mb-5
.billing-plan-header.content-block.center.gl-mb-3
.billing-plan-logo
- if namespace_for_user
.avatar-container.s96.home-panel-avatar.gl-mr-3.float-none.mx-auto.mb-4.mt-1
......@@ -34,3 +34,7 @@
- if show_start_free_trial_messages?(namespace)
- glm_content = namespace_for_user ? 'user-billing' : 'group-billing'
%p= link_to 'Start your free trial', new_trial_registration_path(glm_source: 'gitlab.com', glm_content: glm_content), class: 'btn btn-confirm gl-button', data: { qa_selector: 'start_your_free_trial' }
- if show_extend_reactivate_trial_button?(namespace)
.gl-mt-3
.js-extend-reactivate-trial-button.gl-mt-3{ data: extend_reactivate_trial_button_data(namespace) }
......@@ -20,12 +20,22 @@ FactoryBot.define do
trial_ends_on { Date.current.advance(days: 15) }
end
trait :extended_trial do
active_trial
trial_extension_type { GitlabSubscription.trial_extension_types[:extended] }
end
trait :expired_trial do
trial { true }
trial_starts_on { Date.current.advance(days: -31) }
trial_ends_on { Date.current.advance(days: -1) }
end
trait :reactivated_trial do
expired_trial
trial_extension_type { GitlabSubscription.trial_extension_types[:reactivated] }
end
trait :default do
association :hosted_plan, factory: :default_plan
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Billings > Extend / Reactivate Trial', :js do
include SubscriptionPortalHelpers
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:plan) { create(:free_plan) }
let_it_be(:plans_data) do
Gitlab::Json.parse(File.read(Rails.root.join('ee/spec/fixtures/gitlab_com_plans.json'))).map do |data|
data.deep_symbolize_keys
end
end
let(:initial_trial_end_date) { Date.current }
let(:extended_or_reactivated_trial_end_date) { initial_trial_end_date + 30.days }
before do
group.add_owner(user)
allow(Gitlab).to receive(:com?).and_return(true)
stub_ee_application_setting(should_check_namespace_plan: true)
stub_feature_flags(allow_extend_reactivate_trial: true)
stub_full_request("#{EE::SUBSCRIPTIONS_URL}/gitlab_plans?plan=#{plan.name}&namespace_id=#{group.id}")
.to_return(status: 200, body: plans_data.to_json)
stub_full_request("#{EE::SUBSCRIPTIONS_URL}/trials/extend_reactivate_trial", method: :put)
.to_return(status: 200)
sign_in(user)
end
shared_examples 'a non-reactivatable trial' do
before do
visit group_billings_path(group)
end
it 'does not display the "Reactivate trial" button' do
expect(page).not_to have_button('Reactivate trial')
end
end
shared_examples 'a non-extendable trial' do
before do
visit group_billings_path(group)
end
it 'does not display the "Extend trial" button' do
expect(page).not_to have_button('Extend trial')
end
end
shared_examples 'a reactivatable trial' do
before do
allow_next_instance_of(GitlabSubscriptions::ExtendReactivateTrialService) do |service|
group.gitlab_subscription.update!(trial_extension_type: GitlabSubscription.trial_extension_types[:reactivated],
end_date: extended_or_reactivated_trial_end_date,
trial_ends_on: extended_or_reactivated_trial_end_date)
end
visit group_billings_path(group)
end
it 'reactivates trial' do
expect(page).to have_content("trial expired on #{initial_trial_end_date}")
within '.billing-plan-header' do
click_button('Reactivate trial')
end
within '[data-testid="extend-reactivate-trial-modal"]' do
click_button('Reactivate trial')
end
wait_for_requests
expect(page).to have_content("trial will expire after #{extended_or_reactivated_trial_end_date}")
expect(page).not_to have_button('Reactivate trial')
end
end
shared_examples 'an extendable trial' do
before do
allow_next_instance_of(GitlabSubscriptions::ExtendReactivateTrialService) do |service|
group.gitlab_subscription.update!(trial_extension_type: GitlabSubscription.trial_extension_types[:extended],
end_date: initial_trial_end_date,
trial_ends_on: extended_or_reactivated_trial_end_date)
end
visit group_billings_path(group)
end
it 'extends the trial' do
expect(page).to have_content("trial will expire after #{initial_trial_end_date}")
within '.billing-plan-header' do
click_button('Extend trial')
end
within '[data-testid="extend-reactivate-trial-modal"]' do
click_button('Extend trial')
end
wait_for_requests
expect(page).to have_content("trial will expire after #{extended_or_reactivated_trial_end_date}")
expect(page).not_to have_button('Extend trial')
end
end
context 'with paid subscription' do
context 'when expired' do
let_it_be(:subscription) { create(:gitlab_subscription, :expired, hosted_plan: plan, namespace: group) }
it_behaves_like 'a non-reactivatable trial'
it_behaves_like 'a non-extendable trial'
context 'when the feature flag is disabled' do
before do
stub_feature_flags(allow_extend_reactivate_trial: false)
end
it_behaves_like 'a non-reactivatable trial'
it_behaves_like 'a non-extendable trial'
end
end
context 'when not expired' do
let_it_be(:subscription) { create(:gitlab_subscription, hosted_plan: plan, namespace: group) }
it_behaves_like 'a non-reactivatable trial'
it_behaves_like 'a non-extendable trial'
end
end
context 'without a subscription' do
it_behaves_like 'a non-reactivatable trial'
it_behaves_like 'a non-extendable trial'
end
context 'with active trial near the expiration date' do
let(:initial_trial_end_date) { Date.tomorrow }
let_it_be(:subscription) { create(:gitlab_subscription, :active_trial, trial_ends_on: Date.tomorrow, hosted_plan: plan, namespace: group) }
it_behaves_like 'an extendable trial'
it_behaves_like 'a non-reactivatable trial'
end
context 'with extended trial' do
let_it_be(:subscription) { create(:gitlab_subscription, :extended_trial, hosted_plan: plan, namespace: group) }
it_behaves_like 'a non-extendable trial'
it_behaves_like 'a non-reactivatable trial'
end
context 'with reactivated trial' do
let_it_be(:subscription) { create(:gitlab_subscription, :reactivated_trial, hosted_plan: plan, namespace: group) }
it_behaves_like 'a non-extendable trial'
it_behaves_like 'a non-reactivatable trial'
end
context 'with expired trial' do
let(:initial_trial_end_date) { Date.current.advance(days: -1) }
let_it_be(:subscription) { create(:gitlab_subscription, :expired_trial, hosted_plan: plan, namespace: group) }
it_behaves_like 'a reactivatable trial'
it_behaves_like 'a non-extendable trial'
end
end
......@@ -6,6 +6,7 @@ import SubscriptionTable from 'ee/billings/subscriptions/components/subscription
import SubscriptionTableRow from 'ee/billings/subscriptions/components/subscription_table_row.vue';
import initialStore from 'ee/billings/subscriptions/store';
import * as types from 'ee/billings/subscriptions/store/mutation_types';
import ExtendReactivateTrialButton from 'ee/trials/extend_reactivate_trial/components/extend_reactivate_trial_button.vue';
import { mockDataSubscription } from 'ee_jest/billings/mock_data';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
......@@ -361,4 +362,33 @@ describe('SubscriptionTable component', () => {
expect(findRefreshSeatsButton().exists()).toBe(false);
});
});
describe.each`
availableTrialAction | buttonVisible
${null} | ${false}
${'extend'} | ${true}
${'reactivate'} | ${true}
`(
'with availableTrialAction=$availableTrialAction',
({ availableTrialAction, buttonVisible }) => {
beforeEach(() => {
createComponentWithStore({
provide: {
namespaceId: 1,
availableTrialAction,
},
});
});
if (buttonVisible) {
it('renders the trial button', () => {
expect(wrapper.findComponent(ExtendReactivateTrialButton).isVisible()).toBe(true);
});
} else {
it('does not render the trial button', () => {
expect(wrapper.findComponent(ExtendReactivateTrialButton).exists()).toBe(false);
});
}
},
);
});
import { GlButton, GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ExtendReactivateTrialButton from 'ee/trials/extend_reactivate_trial/components/extend_reactivate_trial_button.vue';
import {
i18n,
TRIAL_ACTION_EXTEND,
TRIAL_ACTION_REACTIVATE,
} from 'ee/trials/extend_reactivate_trial/constants';
import { sprintf } from '~/locale';
describe('ExtendReactivateTrialButton', () => {
let wrapper;
const createComponent = (props = {}) => {
return shallowMount(ExtendReactivateTrialButton, {
propsData: {
...props,
},
});
};
const findButton = () => wrapper.findComponent(GlButton);
const findModal = () => wrapper.findComponent(GlModal);
afterEach(() => {
wrapper.destroy();
});
describe('rendering', () => {
beforeEach(() => {
wrapper = createComponent({
namespaceId: 1,
action: TRIAL_ACTION_EXTEND,
planName: 'Ultimate',
});
});
it('does not have loading icon', () => {
expect(findButton().props('loading')).toBe(false);
});
});
describe('when extending trial', () => {
beforeEach(() => {
wrapper = createComponent({
namespaceId: 1,
action: TRIAL_ACTION_EXTEND,
planName: 'Ultimate',
});
});
it('has the "Extend trial" text on the button', () => {
expect(findButton().text()).toBe(i18n.extend.buttonText);
});
it('has the correct text in the modal', () => {
expect(findModal().text()).toBe(
sprintf(i18n.extend.modalText, { planName: 'Ultimate plan' }),
);
});
});
describe('when reactivating trial', () => {
beforeEach(() => {
wrapper = createComponent({
namespaceId: 1,
action: TRIAL_ACTION_REACTIVATE,
planName: 'Ultimate',
});
});
it('has the "Reactivate trial" text on the button', () => {
expect(findButton().text()).toBe(i18n.reactivate.buttonText);
});
it('has the correct text in the modal', () => {
expect(findModal().text()).toBe(
sprintf(i18n.reactivate.modalText, { planName: 'Ultimate plan' }),
);
});
});
});
......@@ -197,7 +197,6 @@ RSpec.describe EE::TrialHelper do
it { is_expected.to be_falsey }
end
context 'when feature flag is enabled' do
where(:can_extend_trial, :can_reactivate_trial, :result) do
false | false | false
true | false | true
......@@ -207,8 +206,6 @@ RSpec.describe EE::TrialHelper do
with_them do
before do
stub_feature_flags(allow_extend_reactivate_trial: true)
allow(namespace).to receive(:can_extend_trial?).and_return(can_extend_trial)
allow(namespace).to receive(:can_reactivate_trial?).and_return(can_reactivate_trial)
end
......@@ -216,7 +213,6 @@ RSpec.describe EE::TrialHelper do
it { is_expected.to eq(result) }
end
end
end
describe '#extend_reactivate_trial_button_data' do
let(:namespace) { build(:namespace, id: 1) }
......@@ -253,7 +249,6 @@ RSpec.describe EE::TrialHelper do
end
end
context 'when feature flag is enabled' do
context 'when trial can be extended' do
before do
allow(namespace).to receive(:can_extend_trial?).and_return(true)
......@@ -270,5 +265,4 @@ RSpec.describe EE::TrialHelper do
it { is_expected.to eq({ namespace_id: 1, plan_name: 'Ultimate', action: 'reactivate' }) }
end
end
end
end
......@@ -1261,7 +1261,6 @@ RSpec.describe Namespace do
it { is_expected.to be_falsey }
end
context 'when feature flag is enabled' do
where(:trial_active, :trial_extended_or_reactivated, :can_extend_trial) do
false | false | false
false | true | false
......@@ -1278,7 +1277,6 @@ RSpec.describe Namespace do
it { is_expected.to be can_extend_trial }
end
end
end
describe '#can_reactivate_trial?' do
subject { namespace.can_reactivate_trial? }
......@@ -1296,7 +1294,6 @@ RSpec.describe Namespace do
it { is_expected.to be_falsey }
end
context 'when feature flag is enabled' do
where(:trial_active, :never_had_trial, :trial_extended_or_reactivated, :free_plan, :can_reactivate_trial) do
false | false | false | false | false
false | false | false | true | true
......@@ -1327,7 +1324,6 @@ RSpec.describe Namespace do
it { is_expected.to be can_reactivate_trial }
end
end
end
describe '#file_template_project_id' do
it 'is cleared before validation' do
......
......@@ -5178,6 +5178,27 @@ msgstr ""
msgid "BillingPlan|Upgrade for free"
msgstr ""
msgid "Billings|%{planName} plan"
msgstr ""
msgid "Billings|An error occurred while extending your trial."
msgstr ""
msgid "Billings|An error occurred while reactivating your trial."
msgstr ""
msgid "Billings|By extending your trial, you will receive an additional 30 days of %{planName}. Your trial can be only extended once."
msgstr ""
msgid "Billings|By reactivating your trial, you will receive an additional 30 days of %{planName}. Your trial can be only reactivated once."
msgstr ""
msgid "Billings|Extend trial"
msgstr ""
msgid "Billings|Reactivate trial"
msgstr ""
msgid "Billings|Shared runners cannot be enabled until a valid credit card is on file."
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