Commit 57d72e27 authored by Vitaly Slobodin's avatar Vitaly Slobodin Committed by Phil Hughes

Adapt SubscriptionDetails for purchasing the CI Minutes addon

parent 362e10df
<script> <script>
import ProgressBar from 'ee/registrations/components/progress_bar.vue';
import { STEPS, SUBSCRIPTON_FLOW_STEPS } from 'ee/registrations/constants';
import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import AddonPurchaseDetails from './checkout/addon_purchase_details.vue';
import BillingAddress from './checkout/billing_address.vue'; import BillingAddress from './checkout/billing_address.vue';
import ConfirmOrder from './checkout/confirm_order.vue'; import ConfirmOrder from './checkout/confirm_order.vue';
import PaymentMethod from './checkout/payment_method.vue'; import PaymentMethod from './checkout/payment_method.vue';
import SubscriptionDetails from './checkout/subscription_details.vue';
export default { export default {
components: { ProgressBar, SubscriptionDetails, BillingAddress, PaymentMethod, ConfirmOrder }, components: { AddonPurchaseDetails, BillingAddress, PaymentMethod, ConfirmOrder },
props: { props: {
plans: { plans: {
type: Array, type: Array,
required: true, required: true,
}, },
}, },
apollo: {
isNewUser: {
query: stateQuery,
},
},
currentStep: STEPS.checkout,
steps: SUBSCRIPTON_FLOW_STEPS,
i18n: { i18n: {
checkout: s__('Checkout|Checkout'), checkout: s__('Checkout|Checkout'),
}, },
}; };
</script> </script>
<template> <template>
<div <div class="checkout gl-display-flex gl-flex-direction-column gl-align-items-center">
v-if="!$apollo.loading"
class="checkout gl-display-flex gl-flex-direction-column gl-justify-content-between w-100"
>
<div class="full-width">
<progress-bar v-if="isNewUser" :steps="$options.steps" :current-step="$options.currentStep" />
<div class="flash-container"></div> <div class="flash-container"></div>
<h2 class="gl-mt-6 gl-mb-7 gl-mb-lg-5">{{ $options.i18n.checkout }}</h2> <h2 class="gl-mt-6 gl-mb-7 gl-mb-lg-5">{{ $options.i18n.checkout }}</h2>
<subscription-details :plans="plans" /> <addon-purchase-details :plans="plans" />
<billing-address /> <billing-address />
<payment-method /> <payment-method />
</div>
<confirm-order /> <confirm-order />
</div> </div>
</template> </template>
<script>
import { GlAlert, GlFormInput, GlSprintf } from '@gitlab/ui';
import { CI_MINUTES_PER_PACK } from 'ee/subscriptions/buy_minutes/constants';
import { STEPS } from 'ee/subscriptions/constants';
import updateState from 'ee/subscriptions/graphql/mutations/update_state.mutation.graphql';
import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql';
import Step from 'ee/vue_shared/purchase_flow/components/step.vue';
import { GENERAL_ERROR_MESSAGE } from 'ee/vue_shared/purchase_flow/constants';
import createFlash from '~/flash';
import { sprintf, s__, formatNumber } from '~/locale';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
export default {
name: 'AddonPurchaseDetails',
components: {
GlAlert,
GlFormInput,
GlSprintf,
Step,
},
directives: {
autofocusonshow,
},
apollo: {
quantity: {
query: stateQuery,
update(data) {
return data.subscription.quantity;
},
},
},
computed: {
quantityModel: {
get() {
return this.quantity || 1;
},
set(quantity) {
this.updateQuantity(quantity);
},
},
isValid() {
return this.quantity > 0;
},
totalCiMinutes() {
return this.quantity * CI_MINUTES_PER_PACK;
},
summaryCiMinutesQuantityText() {
return sprintf(this.$options.i18n.summaryCiMinutesQuantity, {
quantity: this.quantity,
});
},
ciMinutesQuantityText() {
return sprintf(this.$options.i18n.ciMinutesQuantityText, {
totalCiMinutes: formatNumber(this.totalCiMinutes),
});
},
summaryCiMinutesTotal() {
return sprintf(this.$options.i18n.summaryCiMinutesTotal, {
quantity: formatNumber(this.totalCiMinutes),
});
},
},
methods: {
updateQuantity(quantity = 1) {
this.$apollo
.mutate({
mutation: updateState,
variables: {
input: { subscription: { quantity } },
},
})
.catch((error) => {
createFlash({ message: GENERAL_ERROR_MESSAGE, error, captureError: true });
});
},
},
i18n: {
stepTitle: s__('Checkout|Purchase details'),
nextStepButtonText: s__('Checkout|Continue to billing'),
ciMinutesPacksLabel: s__('Checkout|CI minute packs'),
ciMinutesAlertText: s__(
"Checkout|CI minute packs are only used after you've used your subscription's monthly quota. The additional minutes will roll over month to month and are valid for one year.",
),
ciMinutesPacksQuantityFormula: s__('Checkout|x 1,000 minutes per pack = %{strong}'),
ciMinutesQuantityText: s__('Checkout|%{totalCiMinutes} CI minutes'),
summaryCiMinutesQuantity: s__('Checkout|%{quantity} CI minute packs'),
summaryCiMinutesTotal: s__('Checkout|Total minutes: %{quantity}'),
},
stepId: STEPS[0].id,
};
</script>
<template>
<step
v-if="!$apollo.loading"
:step-id="$options.stepId"
:title="$options.i18n.stepTitle"
:is-valid="isValid"
:next-step-button-text="$options.i18n.nextStepButtonText"
>
<template #body>
<gl-alert variant="info" class="gl-mb-3" :dismissible="false">
{{ $options.i18n.ciMinutesAlertText }}
</gl-alert>
<label for="quantity">{{ $options.i18n.ciMinutesPacksLabel }}</label>
<div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
<gl-form-input
ref="quantity"
v-model.number="quantityModel"
name="quantity"
type="number"
:min="1"
data-qa-selector="quantity"
class="gl-w-15"
/>
<div class="gl-ml-3" data-testid="ci-minutes-quantity-text">
<gl-sprintf :message="$options.i18n.ciMinutesPacksQuantityFormula">
<template #strong>
<strong>{{ ciMinutesQuantityText }}</strong>
</template>
</gl-sprintf>
</div>
</div>
</template>
<template #summary>
<strong ref="summary-line-1">
{{ summaryCiMinutesQuantityText }}
</strong>
<div ref="summary-line-3">{{ summaryCiMinutesTotal }}</div>
</template>
</step>
</template>
<script>
import { GlFormGroup, GlFormSelect, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { STEPS } from 'ee/subscriptions/constants';
import UPDATE_STATE from 'ee/subscriptions/graphql/mutations/update_state.mutation.graphql';
import STATE_QUERY from 'ee/subscriptions/graphql/queries/state.query.graphql';
import { NEW_GROUP } from 'ee/subscriptions/new/constants';
import Step from 'ee/vue_shared/purchase_flow/components/step.vue';
import { GENERAL_ERROR_MESSAGE } from 'ee/vue_shared/purchase_flow/constants';
import createFlash from '~/flash';
import { getParameterValues } from '~/lib/utils/url_utility';
import { sprintf, s__, __ } from '~/locale';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
export default {
components: {
GlFormGroup,
GlFormSelect,
GlFormInput,
GlSprintf,
GlLink,
Step,
},
directives: {
autofocusonshow,
},
props: {
plans: {
type: Array,
required: true,
},
},
data() {
return {
subscription: {},
namespaces: [],
customer: {},
isSetupForCompany: null,
isNewUser: null,
selectedPlanId: null,
};
},
apollo: {
state: {
query: STATE_QUERY,
manual: true,
result({ data, loading }) {
if (loading) {
return;
}
this.subscription = data.subscription;
this.namespaces = data.namespaces;
this.customer = data.customer;
this.isSetupForCompany = data.isSetupForCompany;
this.isNewUser = data.isNewUser;
this.selectedPlanId = data.selectedPlanId;
},
},
},
computed: {
selectedPlanModel: {
get() {
return this.selectedPlanId || this.plans[0].id;
},
set(planId) {
this.updateState({ subscription: { planId } });
},
},
selectedGroupModel: {
get() {
return this.subscription.namespaceId;
},
set(namespaceId) {
const quantity =
this.namespaces.find((namespace) => namespace.id === namespaceId)?.users || 1;
this.updateState({ subscription: { namespaceId, quantity } });
},
},
numberOfUsersModel: {
get() {
return this.selectedGroupUsers || 1;
},
set(number) {
this.updateState({ subscription: { quantity: number } });
},
},
companyModel: {
get() {
return this.customer.company;
},
set(company) {
this.updateState({ customer: { company } });
},
},
selectedPlan() {
const selectedPlan = this.plans.find((plan) => plan.id === this.selectedPlanId);
if (!selectedPlan) {
return this.plans[0];
}
return selectedPlan;
},
selectedPlanTextLine() {
return sprintf(this.$options.i18n.selectedPlan, { selectedPlanText: this.selectedPlan.id });
},
selectedGroup() {
return this.namespaces.find((namespace) => namespace.id === this.subscription.namespaceId);
},
selectedGroupUsers() {
return this.selectedGroup?.users || 1;
},
isGroupSelected() {
return this.subscription.namespaceId !== null;
},
isNumberOfUsersValid() {
return (
this.subscription.quantity > 0 && this.subscription.quantity >= this.selectedGroupUsers
);
},
isValid() {
if (this.isSetupForCompany) {
return (
this.isNumberOfUsersValid &&
!isEmpty(this.selectedPlanId) &&
(!isEmpty(this.customer.company) || this.isGroupSelected)
);
}
return this.subscription.quantity === 1 && !isEmpty(this.selectedPlanId);
},
isShowingGroupSelector() {
return !this.isNewUser && this.namespaces.length;
},
isNewGroupSelected() {
return this.subscription.namespaceId === NEW_GROUP;
},
isShowingNameOfCompanyInput() {
return this.isSetupForCompany && (!this.namespaces.length || this.isNewGroupSelected);
},
groupOptionsWithDefault() {
return [
{
name: this.$options.i18n.groupSelectPrompt,
id: null,
},
...this.namespaces,
{
name: this.$options.i18n.groupSelectCreateNewOption,
id: NEW_GROUP,
},
];
},
groupSelectDescription() {
return this.isNewGroupSelected
? this.$options.i18n.createNewGroupDescription
: this.$options.i18n.selectedGroupDescription;
},
},
mounted() {
this.preselectPlan();
},
methods: {
updateState(payload = {}) {
this.$apollo
.mutate({
mutation: UPDATE_STATE,
variables: {
input: payload,
},
})
.catch((error) => {
createFlash({ message: GENERAL_ERROR_MESSAGE, error, captureError: true });
});
},
toggleIsSetupForCompany() {
this.updateSubscription({ isSetupForCompany: !this.isSetupForCompany });
},
preselectPlan() {
if (this.selectedPlanId) {
return;
}
let preselectedPlan = this.plans[0];
const planIdFromSearchParams = getParameterValues('planId');
if (planIdFromSearchParams.length > 0) {
preselectedPlan =
this.plans.find((plan) => plan.id === planIdFromSearchParams[0].id) || preselectedPlan;
}
this.updateState({ selectedPlanId: preselectedPlan.id });
},
},
i18n: {
stepTitle: s__('Checkout|Subscription details'),
nextStepButtonText: s__('Checkout|Continue to billing'),
selectedPlanLabel: s__('Checkout|GitLab plan'),
selectedGroupLabel: s__('Checkout|GitLab group'),
groupSelectPrompt: __('Select'),
groupSelectCreateNewOption: s__('Checkout|Create a new group'),
selectedGroupDescription: s__('Checkout|Your subscription will be applied to this group'),
createNewGroupDescription: s__("Checkout|You'll create your new group after checkout"),
organizationNameLabel: s__('Checkout|Name of company or organization using GitLab'),
numberOfUsersLabel: s__('Checkout|Number of users'),
needMoreUsersLink: s__('Checkout|Need more users? Purchase GitLab for your %{company}.'),
companyOrTeam: s__('Checkout|company or team'),
selectedPlan: s__('Checkout|%{selectedPlanText} plan'),
group: __('Group'),
users: __('Users'),
},
stepId: STEPS[0].id,
};
</script>
<template>
<step
v-if="!$apollo.loading"
:step-id="$options.stepId"
:title="$options.i18n.stepTitle"
:is-valid="isValid"
:next-step-button-text="$options.i18n.nextStepButtonText"
>
<template #body>
<gl-form-group :label="$options.i18n.selectedPlanLabel" label-size="sm" class="mb-3">
<gl-form-select
v-model="selectedPlanModel"
v-autofocusonshow
:options="plans"
value-field="id"
text-field="name"
data-qa-selector="plan_name"
/>
</gl-form-group>
<gl-form-group
v-if="isShowingGroupSelector"
:label="$options.i18n.selectedGroupLabel"
:description="groupSelectDescription"
label-size="sm"
class="mb-3"
>
<gl-form-select
ref="group-select"
v-model="selectedGroupModel"
:options="groupOptionsWithDefault"
value-field="id"
text-field="name"
data-qa-selector="group_name"
/>
</gl-form-group>
<gl-form-group
v-if="isShowingNameOfCompanyInput"
:label="$options.i18n.organizationNameLabel"
label-size="sm"
class="mb-3"
>
<gl-form-input ref="organization-name" v-model="companyModel" type="text" />
</gl-form-group>
<div class="combined d-flex">
<gl-form-group :label="$options.i18n.numberOfUsersLabel" label-size="sm" class="number">
<gl-form-input
ref="number-of-users"
v-model.number="numberOfUsersModel"
type="number"
:min="selectedGroupUsers"
:disabled="!isSetupForCompany"
data-qa-selector="number_of_users"
/>
</gl-form-group>
<gl-form-group
v-if="!isSetupForCompany"
ref="company-link"
class="label ml-3 align-self-end"
>
<gl-sprintf :message="$options.i18n.needMoreUsersLink">
<template #company>
<gl-link @click="toggleIsSetupForCompany">{{ $options.i18n.companyOrTeam }}</gl-link>
</template>
</gl-sprintf>
</gl-form-group>
</div>
</template>
<template #summary>
<strong ref="summary-line-1">
{{ selectedPlanTextLine }}
</strong>
<div v-if="isSetupForCompany" ref="summary-line-2">
{{ $options.i18n.group }}: {{ customer.company || selectedGroup.name }}
</div>
<div ref="summary-line-3">{{ $options.i18n.users }}: {{ subscription.quantity }}</div>
</template>
</step>
</template>
...@@ -5,3 +5,5 @@ export const planTags = { ...@@ -5,3 +5,5 @@ export const planTags = {
/* eslint-enable @gitlab/require-i18n-strings */ /* eslint-enable @gitlab/require-i18n-strings */
export const CUSTOMER_CLIENT = 'customerClient'; export const CUSTOMER_CLIENT = 'customerClient';
export const GITLAB_CLIENT = 'gitlabClient'; export const GITLAB_CLIENT = 'gitlabClient';
export const CI_MINUTES_PER_PACK = 1000;
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { GlAlert } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import { merge } from 'lodash'; import { merge } from 'lodash';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import ProgressBar from 'ee/registrations/components/progress_bar.vue'; import AddonPurchaseDetails from 'ee/subscriptions/buy_minutes/components/checkout/addon_purchase_details.vue';
import Checkout from 'ee/subscriptions/buy_minutes/components/checkout.vue';
import subscriptionsResolvers from 'ee/subscriptions/buy_minutes/graphql/resolvers'; import subscriptionsResolvers from 'ee/subscriptions/buy_minutes/graphql/resolvers';
import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql'; import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql';
import Step from 'ee/vue_shared/purchase_flow/components/step.vue';
import purchaseFlowResolvers from 'ee/vue_shared/purchase_flow/graphql/resolvers'; import purchaseFlowResolvers from 'ee/vue_shared/purchase_flow/graphql/resolvers';
import { import {
stateData as initialStateData, stateData as initialStateData,
mockCiMinutesPlans, mockCiMinutesPlans,
} from 'ee_jest/subscriptions/buy_minutes/mock_data'; } from 'ee_jest/subscriptions/buy_minutes/mock_data';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(VueApollo); localVue.use(VueApollo);
describe('Checkout', () => { describe('AddonPurchaseDetails', () => {
const resolvers = merge({}, purchaseFlowResolvers, subscriptionsResolvers); const resolvers = { ...purchaseFlowResolvers, ...subscriptionsResolvers };
let wrapper; let wrapper;
const createMockApolloProvider = (stateData = {}) => { const createMockApolloProvider = (stateData = {}) => {
...@@ -36,50 +37,59 @@ describe('Checkout', () => { ...@@ -36,50 +37,59 @@ describe('Checkout', () => {
const createComponent = (stateData = {}) => { const createComponent = (stateData = {}) => {
const apolloProvider = createMockApolloProvider(stateData); const apolloProvider = createMockApolloProvider(stateData);
wrapper = shallowMount(Checkout, { return mount(AddonPurchaseDetails, {
apolloProvider,
localVue, localVue,
apolloProvider,
propsData: { propsData: {
plans: mockCiMinutesPlans, plans: mockCiMinutesPlans,
}, },
stubs: {
Step,
},
}); });
}; };
const findProgressBar = () => wrapper.find(ProgressBar); const findQuantity = () => wrapper.findComponent({ ref: 'quantity' });
const findGlAlert = () => wrapper.findComponent(GlAlert);
const findCiMinutesQuantityText = () => wrapper.find('[data-testid="ci-minutes-quantity-text"]');
const isStepValid = () => wrapper.findComponent(Step).props('isValid');
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
describe.each([ it('sets the min quantity to 1', () => {
[true, true], expect(findQuantity().attributes('min')).toBe('1');
[false, false],
])('when isNewUser=%s', (isNewUser, visible) => {
beforeEach(async () => {
createComponent({ isNewUser });
}); });
it(`progress bar visibility is ${visible}`, () => { it('displays the alert', () => {
expect(findProgressBar().exists()).toBe(visible); expect(findGlAlert().isVisible()).toBe(true);
}); expect(findGlAlert().text()).toMatchInterpolatedText(
AddonPurchaseDetails.i18n.ciMinutesAlertText,
);
}); });
describe('passing the correct options to the progress bar component', () => { it('displays the total CI minutes text', async () => {
beforeEach(async () => { expect(findCiMinutesQuantityText().text()).toMatchInterpolatedText(
createComponent({ isNewUser: true }); 'x 1,000 minutes per pack = 1,000 CI minutes',
await waitForPromises(); );
}); });
it('passes the steps', () => { it('is valid', () => {
expect(findProgressBar().props('steps')).toEqual([ expect(isStepValid()).toBe(true);
'Your profile',
'Checkout',
'Your GitLab group',
]);
}); });
it('passes the current step', () => { it('is invalid when quantity is less than 1', async () => {
expect(findProgressBar().props('currentStep')).toEqual('Checkout'); wrapper = createComponent({
subscription: { namespaceId: 483, quantity: 0 },
}); });
await nextTick();
expect(isStepValid()).toBe(false);
}); });
}); });
...@@ -6310,18 +6310,30 @@ msgstr "" ...@@ -6310,18 +6310,30 @@ msgstr ""
msgid "Checkout|%{name}'s GitLab subscription" msgid "Checkout|%{name}'s GitLab subscription"
msgstr "" msgstr ""
msgid "Checkout|%{quantity} CI minute packs"
msgstr ""
msgid "Checkout|%{selectedPlanText} plan" msgid "Checkout|%{selectedPlanText} plan"
msgstr "" msgstr ""
msgid "Checkout|%{startDate} - %{endDate}" msgid "Checkout|%{startDate} - %{endDate}"
msgstr "" msgstr ""
msgid "Checkout|%{totalCiMinutes} CI minutes"
msgstr ""
msgid "Checkout|(x%{numberOfUsers})" msgid "Checkout|(x%{numberOfUsers})"
msgstr "" msgstr ""
msgid "Checkout|Billing address" msgid "Checkout|Billing address"
msgstr "" msgstr ""
msgid "Checkout|CI minute packs"
msgstr ""
msgid "Checkout|CI minute packs are only used after you've used your subscription's monthly quota. The additional minutes will roll over month to month and are valid for one year."
msgstr ""
msgid "Checkout|Checkout" msgid "Checkout|Checkout"
msgstr "" msgstr ""
...@@ -6403,6 +6415,9 @@ msgstr "" ...@@ -6403,6 +6415,9 @@ msgstr ""
msgid "Checkout|Please select a state" msgid "Checkout|Please select a state"
msgstr "" msgstr ""
msgid "Checkout|Purchase details"
msgstr ""
msgid "Checkout|Select" msgid "Checkout|Select"
msgstr "" msgstr ""
...@@ -6427,6 +6442,9 @@ msgstr "" ...@@ -6427,6 +6442,9 @@ msgstr ""
msgid "Checkout|Total" msgid "Checkout|Total"
msgstr "" msgstr ""
msgid "Checkout|Total minutes: %{quantity}"
msgstr ""
msgid "Checkout|Users" msgid "Checkout|Users"
msgstr "" msgstr ""
...@@ -6445,6 +6463,9 @@ msgstr "" ...@@ -6445,6 +6463,9 @@ msgstr ""
msgid "Checkout|company or team" msgid "Checkout|company or team"
msgstr "" msgstr ""
msgid "Checkout|x 1,000 minutes per pack = %{strong}"
msgstr ""
msgid "Cherry-pick this commit" msgid "Cherry-pick this commit"
msgstr "" 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