Commit adc61522 authored by Vitaly Slobodin's avatar Vitaly Slobodin Committed by Scott Hampton

Create a copy of purchase a sub app based on GQL

parent b011b740
<script> <script>
import StepOrderApp from 'ee/vue_shared/purchase_flow/components/step_order_app.vue'; import StepOrderApp from 'ee/vue_shared/purchase_flow/components/step_order_app.vue';
import Checkout from './checkout.vue';
export default { export default {
components: { components: {
Checkout,
StepOrderApp, StepOrderApp,
}, },
}; };
</script> </script>
<template> <template>
<step-order-app> <step-order-app>
<template #checkout></template> <template #checkout>
<checkout />
</template>
<template #order-summary></template> <template #order-summary></template>
</step-order-app> </step-order-app>
</template> </template>
<script>
import ProgressBar from 'ee/registrations/components/progress_bar.vue';
import { STEPS, SUBSCRIPTON_FLOW_STEPS } from 'ee/registrations/constants';
import STATE_QUERY from 'ee/subscriptions/graphql/queries/state.query.graphql';
import { s__ } from '~/locale';
import SubscriptionDetails from './checkout/subscription_details.vue';
export default {
components: { ProgressBar, SubscriptionDetails },
apollo: {
state: {
query: STATE_QUERY,
},
},
computed: {
isNewUser() {
return this.state.isNewUser;
},
},
currentStep: STEPS.checkout,
steps: SUBSCRIPTON_FLOW_STEPS,
i18n: {
checkout: s__('Checkout|Checkout'),
},
};
</script>
<template>
<div class="checkout gl-flex gl-flex-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-4 gl-mb-3 gl-mb-lg-5">{{ $options.i18n.checkout }}</h2>
<subscription-details />
</div>
</div>
</template>
<script>
import { GlFormGroup, GlFormSelect, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui';
import { isEmpty } from 'lodash';
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, STEPS } from 'ee/subscriptions/new/constants';
import Step from 'ee/vue_shared/purchase_flow/components/step.vue';
import { sprintf, s__, __ } from '~/locale';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
export default {
components: {
GlFormGroup,
GlFormSelect,
GlFormInput,
GlSprintf,
GlLink,
Step,
},
directives: {
autofocusonshow,
},
apollo: {
state: {
query: STATE_QUERY,
},
},
computed: {
subscription() {
return this.state.subscription;
},
plans() {
return this.state.plans;
},
namespaces() {
return this.state.namespaces;
},
selectedPlanModel: {
get() {
return this.subscription.planId;
},
set(planId) {
this.updateSubscription({ subscription: { planId } });
},
},
selectedGroupModel: {
get() {
return this.subscription.namespaceId;
},
set(namespaceId) {
const quantity =
this.namespaces.find((namespace) => namespace.id === namespaceId)?.users || 1;
this.updateSubscription({ subscription: { namespaceId, quantity } });
},
},
numberOfUsersModel: {
get() {
return this.selectedGroupUsers || 1;
},
set(number) {
this.updateSubscription({ subscription: { quantity: number } });
},
},
companyModel: {
get() {
return this.state.customer.company;
},
set(company) {
this.updateSubscription({ customer: { company } });
},
},
selectedPlan() {
return this.state.plans.find((plan) => plan.code === this.subscription.planId);
},
selectedPlanTextLine() {
return sprintf(this.$options.i18n.selectedPlan, { selectedPlanText: this.selectedPlan.code });
},
selectedGroupUsers() {
return (
this.namespaces.find((namespace) => namespace.id === this.subscription.namespaceId)
?.users || 1
);
},
isGroupSelected() {
return this.subscription.namespaceId !== null;
},
isNumberOfUsersValid() {
return (
this.subscription.quantity > 0 && this.subscription.quantity >= this.selectedGroupUsers
);
},
isValid() {
if (this.state.isSetupForCompany) {
return (
!isEmpty(this.subscription.planId) &&
(!isEmpty(this.state.customer.company) || this.isNewGroupSelected) &&
this.isNumberOfUsersValid
);
}
return !isEmpty(this.subscription.planId) && this.subscription.quantity === 1;
},
isShowingGroupSelector() {
return !this.state.isNewUser && this.namespaces.length;
},
isNewGroupSelected() {
return this.subscription.namespaceId === NEW_GROUP;
},
isShowingNameOfCompanyInput() {
return this.state.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;
},
},
methods: {
updateSubscription(payload = {}) {
this.$apollo.mutate({
mutation: UPDATE_STATE,
variables: {
input: payload,
},
});
},
toggleIsSetupForCompany() {
this.updateSubscription({ isSetupForCompany: !this.state.isSetupForCompany });
},
},
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
: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="code"
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="!state.isSetupForCompany"
data-qa-selector="number_of_users"
/>
</gl-form-group>
<gl-form-group
v-if="!state.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="state.isSetupForCompany" ref="summary-line-2">
{{ $options.i18n.group }}: {{ customer.company || selectedGroupName }}
</div>
<div ref="summary-line-3">{{ $options.i18n.users }}: {{ subscription.quantity }}</div>
</template>
</step>
</template>
query seed {
seed @client {
plans
namespaces
newUser
fullName
setupForCompany
}
}
import { produce } from 'immer';
import { merge } from 'lodash';
import Api from 'ee/api'; import Api from 'ee/api';
import * as SubscriptionsApi from 'ee/api/subscriptions_api'; import * as SubscriptionsApi from 'ee/api/subscriptions_api';
import { ERROR_FETCHING_COUNTRIES, ERROR_FETCHING_STATES } from 'ee/subscriptions/constants'; import { ERROR_FETCHING_COUNTRIES, ERROR_FETCHING_STATES } from 'ee/subscriptions/constants';
import STATE_QUERY from 'ee/subscriptions/graphql/queries/state.query.graphql';
import createFlash from '~/flash'; import createFlash from '~/flash';
// NOTE: These resolvers are temporary and will be removed in the future. // NOTE: These resolvers are temporary and will be removed in the future.
...@@ -30,5 +33,14 @@ export const resolvers = { ...@@ -30,5 +33,14 @@ export const resolvers = {
purchaseMinutes: (_, { groupId, customer, subscription }) => { purchaseMinutes: (_, { groupId, customer, subscription }) => {
return SubscriptionsApi.createSubscription(groupId, customer, subscription); return SubscriptionsApi.createSubscription(groupId, customer, subscription);
}, },
updateState: (_, { input }, { cache }) => {
const { state: oldState } = cache.readQuery({ query: STATE_QUERY });
const state = produce(oldState, (draftState) => {
merge(draftState, input);
});
cache.writeQuery({ query: STATE_QUERY, data: { state } });
},
}, },
}; };
import Vue from 'vue'; import Vue from 'vue';
import App from 'ee/subscriptions/buy_minutes/components/app.vue';
import { STEPS } from 'ee/subscriptions/new/constants';
import ensureData from '~/ensure_data'; import ensureData from '~/ensure_data';
import { parseBoolean } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import App from './components/app.vue'; import stateQuery from '../graphql/queries/state.query.graphql';
import apolloProvider from './graphql'; import apolloProvider from './graphql';
import seedQuery from './graphql/queries/seed.query.graphql';
import { parseData } from './utils'; import { parseData } from './utils';
const arrayToGraphqlArray = (arr, typename) => const arrayToGraphqlArray = (arr, typename) =>
Array.from(arr, (item) => Object.assign(item, { __typename: typename })); Array.from(arr, (item) =>
Object.assign(convertObjectPropsToCamelCase(item, { deep: true }), { __typename: typename }),
);
const writeInitialDataToApolloProvider = (dataset) => { const writeInitialDataToApolloProvider = (dataset) => {
const { newUser, fullName, setupForCompany } = dataset; // eslint-disable-next-line @gitlab/require-i18n-strings
const plans = arrayToGraphqlArray(JSON.parse(dataset.ciMinutesPlans), 'Plan');
// eslint-disable-next-line @gitlab/require-i18n-strings
const namespaces = arrayToGraphqlArray(JSON.parse(dataset.groupData), 'Namespace');
const isNewUser = parseBoolean(dataset.newUser);
const isSetupForCompany = parseBoolean(dataset.setupForCompany) || !isNewUser;
apolloProvider.clients.defaultClient.cache.writeQuery({ apolloProvider.clients.defaultClient.cache.writeQuery({
query: seedQuery, query: stateQuery,
data: { data: {
// eslint-disable-next-line @gitlab/require-i18n-strings state: {
plans: arrayToGraphqlArray(JSON.parse(dataset.ciMinutesPlans), 'Plan'), isNewUser,
// eslint-disable-next-line @gitlab/require-i18n-strings isSetupForCompany,
namespaces: arrayToGraphqlArray(JSON.parse(dataset.groupData), 'Namespace'), plans,
newUser: parseBoolean(newUser), namespaces,
setupForCompany: parseBoolean(setupForCompany), fullName: dataset.fullName,
fullName, subscription: {
planId: plans[0].code,
paymentMethodId: null,
quantity: 1,
namespaceId: null,
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Subscription',
},
customer: {
country: null,
address1: null,
address2: null,
city: null,
state: null,
zipCode: null,
company: null,
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Customer',
},
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'State',
},
activeStep: STEPS[0],
stepList: STEPS,
}, },
}); });
}; };
......
mutation UpdateState($input: UpdateStateInput!) {
updateState(input: $input) @client
}
query state {
state @client {
plans {
name
code
pricePerYear
}
namespaces {
id
name
users
}
isNewUser
fullName
isSetupForCompany
customer {
country
address1
address2
city
state
zipCode
company
}
subscription {
planId
paymentMethodId
quantity
namespaceId
}
}
activeStep @client {
id
}
stepList @client {
id
}
}
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { merge } from 'lodash';
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 subscriptionsResolvers from 'ee/subscriptions/buy_minutes/graphql/resolvers';
import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql';
import purchaseFlowResolvers from 'ee/vue_shared/purchase_flow/graphql/resolvers';
import { stateData as initialStateData } from 'ee_jest/subscriptions/buy_minutes/mock_data';
import createMockApollo from 'helpers/mock_apollo_helper';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Checkout', () => {
const resolvers = { ...purchaseFlowResolvers, ...subscriptionsResolvers };
let wrapper;
const createMockApolloProvider = (stateData = {}) => {
const mockApollo = createMockApollo([], resolvers);
const data = merge({}, initialStateData, stateData);
mockApollo.clients.defaultClient.cache.writeQuery({
query: stateQuery,
data,
});
return mockApollo;
};
const createComponent = (stateData = {}) => {
const apolloProvider = createMockApolloProvider(stateData);
wrapper = shallowMount(Checkout, {
apolloProvider,
localVue,
});
};
const findProgressBar = () => wrapper.find(ProgressBar);
afterEach(() => {
wrapper.destroy();
});
describe.each([
[true, true],
[false, false],
])('when isNewUser=%s', (isNewUser, visible) => {
beforeEach(() => {
createComponent({ state: { isNewUser } });
});
it(`progress bar visibility is ${visible}`, () => {
expect(findProgressBar().exists()).toBe(visible);
});
});
describe('passing the correct options to the progress bar component', () => {
beforeEach(() => {
createComponent({ state: { isNewUser: true } });
});
it('passes the steps', () => {
expect(findProgressBar().props('steps')).toEqual([
'Your profile',
'Checkout',
'Your GitLab group',
]);
});
it('passes the current step', () => {
expect(findProgressBar().props('currentStep')).toEqual('Checkout');
});
});
});
import { GlEmptyState } from '@gitlab/ui'; import { GlEmptyState } from '@gitlab/ui';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { createWrapper } from '@vue/test-utils'; import { createWrapper } from '@vue/test-utils';
import initBuyMinutesApp from 'ee/subscriptions/buy_minutes'; import initBuyMinutesApp from 'ee/subscriptions/buy_minutes';
import * as utils from 'ee/subscriptions/buy_minutes/utils'; import * as utils from 'ee/subscriptions/buy_minutes/utils';
import StepOrderApp from 'ee/vue_shared/purchase_flow/components/step_order_app.vue'; import StepOrderApp from 'ee/vue_shared/purchase_flow/components/step_order_app.vue';
...@@ -15,7 +14,11 @@ describe('initBuyMinutesApp', () => { ...@@ -15,7 +14,11 @@ describe('initBuyMinutesApp', () => {
function createComponent() { function createComponent() {
const el = document.createElement('div'); const el = document.createElement('div');
Object.assign(el.dataset, { ciMinutesPlans: mockCiMinutesPlans, groupData: '[]' }); Object.assign(el.dataset, {
ciMinutesPlans: mockCiMinutesPlans,
groupData: '[]',
fullName: 'GitLab',
});
vm = initBuyMinutesApp(el).$mount(); vm = initBuyMinutesApp(el).$mount();
wrapper = createWrapper(vm); wrapper = createWrapper(vm);
} }
......
import { STEPS } from 'ee/subscriptions/new/constants';
export const mockCiMinutesPlans = export const mockCiMinutesPlans =
'[{"deprecated":false,"name":"1000 CI minutes pack","code":"ci_minutes","active":true,"free":null,"price_per_month":0.8333333333333334,"price_per_year":10.0,"features":null,"about_page_href":null,"hide_deprecated_card":false}]'; '[{"deprecated":false,"name":"1000 CI minutes pack","code":"ci_minutes","active":true,"free":null,"price_per_month":0.8333333333333334,"price_per_year":10.0,"features":null,"about_page_href":null,"hide_deprecated_card":false}]';
export const mockParsedCiMinutesPlans = [ export const mockParsedCiMinutesPlans = [
...@@ -14,3 +16,42 @@ export const mockParsedCiMinutesPlans = [ ...@@ -14,3 +16,42 @@ export const mockParsedCiMinutesPlans = [
hideDeprecatedCard: false, hideDeprecatedCard: false,
}, },
]; ];
export const namespaces = [
{ id: 132, name: 'My first group', users: 3, __typename: 'Namespace' },
{ id: 483, name: 'My second group', users: 12, __typename: 'Namespace' },
];
export const plans = [
{ id: 'firstPlanId', code: 'bronze', pricePerYear: 48, name: 'bronze', __typename: 'Plan' },
{ id: 'secondPlanId', code: 'silver', pricePerYear: 228, name: 'silver', __typename: 'Plan' },
];
export const stateData = {
state: {
plans,
namespaces: [],
subscription: {
planId: 'secondPlanId',
quantity: 1,
namespaceId: null,
paymentMethodId: null,
__typename: 'Subscription',
},
customer: {
country: null,
address1: null,
address2: null,
city: null,
state: null,
zipCode: null,
company: null,
__typename: 'Customer',
},
fullName: 'Full Name',
isNewUser: false,
isSetupForCompany: true,
},
stepList: STEPS,
activeStep: STEPS[0],
};
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