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>
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 AddonPurchaseDetails from './checkout/addon_purchase_details.vue';
import BillingAddress from './checkout/billing_address.vue';
import ConfirmOrder from './checkout/confirm_order.vue';
import PaymentMethod from './checkout/payment_method.vue';
import SubscriptionDetails from './checkout/subscription_details.vue';
export default {
components: { ProgressBar, SubscriptionDetails, BillingAddress, PaymentMethod, ConfirmOrder },
components: { AddonPurchaseDetails, BillingAddress, PaymentMethod, ConfirmOrder },
props: {
plans: {
type: Array,
required: true,
},
},
apollo: {
isNewUser: {
query: stateQuery,
},
},
currentStep: STEPS.checkout,
steps: SUBSCRIPTON_FLOW_STEPS,
i18n: {
checkout: s__('Checkout|Checkout'),
},
};
</script>
<template>
<div
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>
<h2 class="gl-mt-6 gl-mb-7 gl-mb-lg-5">{{ $options.i18n.checkout }}</h2>
<subscription-details :plans="plans" />
<billing-address />
<payment-method />
</div>
<div class="checkout gl-display-flex gl-flex-direction-column gl-align-items-center">
<div class="flash-container"></div>
<h2 class="gl-mt-6 gl-mb-7 gl-mb-lg-5">{{ $options.i18n.checkout }}</h2>
<addon-purchase-details :plans="plans" />
<billing-address />
<payment-method />
<confirm-order />
</div>
</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 = {
/* eslint-enable @gitlab/require-i18n-strings */
export const CUSTOMER_CLIENT = 'customerClient';
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 { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import ProgressBar from 'ee/registrations/components/progress_bar.vue';
import Checkout from 'ee/subscriptions/buy_minutes/components/checkout.vue';
import AddonPurchaseDetails from 'ee/subscriptions/buy_minutes/components/checkout/addon_purchase_details.vue';
import subscriptionsResolvers from 'ee/subscriptions/buy_minutes/graphql/resolvers';
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 {
stateData as initialStateData,
mockCiMinutesPlans,
} from 'ee_jest/subscriptions/buy_minutes/mock_data';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Checkout', () => {
const resolvers = merge({}, purchaseFlowResolvers, subscriptionsResolvers);
describe('AddonPurchaseDetails', () => {
const resolvers = { ...purchaseFlowResolvers, ...subscriptionsResolvers };
let wrapper;
const createMockApolloProvider = (stateData = {}) => {
......@@ -36,50 +37,59 @@ describe('Checkout', () => {
const createComponent = (stateData = {}) => {
const apolloProvider = createMockApolloProvider(stateData);
wrapper = shallowMount(Checkout, {
apolloProvider,
return mount(AddonPurchaseDetails, {
localVue,
apolloProvider,
propsData: {
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(() => {
wrapper.destroy();
});
describe.each([
[true, true],
[false, false],
])('when isNewUser=%s', (isNewUser, visible) => {
beforeEach(async () => {
createComponent({ isNewUser });
});
it('sets the min quantity to 1', () => {
expect(findQuantity().attributes('min')).toBe('1');
});
it(`progress bar visibility is ${visible}`, () => {
expect(findProgressBar().exists()).toBe(visible);
});
it('displays the alert', () => {
expect(findGlAlert().isVisible()).toBe(true);
expect(findGlAlert().text()).toMatchInterpolatedText(
AddonPurchaseDetails.i18n.ciMinutesAlertText,
);
});
describe('passing the correct options to the progress bar component', () => {
beforeEach(async () => {
createComponent({ isNewUser: true });
await waitForPromises();
});
it('displays the total CI minutes text', async () => {
expect(findCiMinutesQuantityText().text()).toMatchInterpolatedText(
'x 1,000 minutes per pack = 1,000 CI minutes',
);
});
it('passes the steps', () => {
expect(findProgressBar().props('steps')).toEqual([
'Your profile',
'Checkout',
'Your GitLab group',
]);
});
it('is valid', () => {
expect(isStepValid()).toBe(true);
});
it('passes the current step', () => {
expect(findProgressBar().props('currentStep')).toEqual('Checkout');
it('is invalid when quantity is less than 1', async () => {
wrapper = createComponent({
subscription: { namespaceId: 483, quantity: 0 },
});
await nextTick();
expect(isStepValid()).toBe(false);
});
});
......@@ -6310,18 +6310,30 @@ msgstr ""
msgid "Checkout|%{name}'s GitLab subscription"
msgstr ""
msgid "Checkout|%{quantity} CI minute packs"
msgstr ""
msgid "Checkout|%{selectedPlanText} plan"
msgstr ""
msgid "Checkout|%{startDate} - %{endDate}"
msgstr ""
msgid "Checkout|%{totalCiMinutes} CI minutes"
msgstr ""
msgid "Checkout|(x%{numberOfUsers})"
msgstr ""
msgid "Checkout|Billing address"
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"
msgstr ""
......@@ -6403,6 +6415,9 @@ msgstr ""
msgid "Checkout|Please select a state"
msgstr ""
msgid "Checkout|Purchase details"
msgstr ""
msgid "Checkout|Select"
msgstr ""
......@@ -6427,6 +6442,9 @@ msgstr ""
msgid "Checkout|Total"
msgstr ""
msgid "Checkout|Total minutes: %{quantity}"
msgstr ""
msgid "Checkout|Users"
msgstr ""
......@@ -6445,6 +6463,9 @@ msgstr ""
msgid "Checkout|company or team"
msgstr ""
msgid "Checkout|x 1,000 minutes per pack = %{strong}"
msgstr ""
msgid "Cherry-pick this commit"
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