diff --git a/ee/app/assets/javascripts/subscriptions/buy_minutes/components/app.vue b/ee/app/assets/javascripts/subscriptions/buy_minutes/components/app.vue index 956879ab1ffa66309a7e021fbcd3f5c68c08e77b..7848179cf59f85c6728a988c5c0bfcabbfff6483 100644 --- a/ee/app/assets/javascripts/subscriptions/buy_minutes/components/app.vue +++ b/ee/app/assets/javascripts/subscriptions/buy_minutes/components/app.vue @@ -7,11 +7,13 @@ import { ERROR_FETCHING_DATA_HEADER, ERROR_FETCHING_DATA_DESCRIPTION } from '~/e import plansQuery from '../../graphql/queries/plans.customer.query.graphql'; import { planTags, CUSTOMER_CLIENT } from '../constants'; import Checkout from './checkout.vue'; +import OrderSummary from './order_summary.vue'; export default { components: { Checkout, GlEmptyState, + OrderSummary, StepOrderApp, }, i18n: { @@ -59,6 +61,8 @@ export default { <template #checkout> <checkout :plans="plans" /> </template> - <template #order-summary></template> + <template #order-summary> + <order-summary :plans="plans" /> + </template> </step-order-app> </template> diff --git a/ee/app/assets/javascripts/subscriptions/buy_minutes/components/checkout.vue b/ee/app/assets/javascripts/subscriptions/buy_minutes/components/checkout.vue index a8d215181512e3fddb1a2b49905ae7b329555742..a1d54d62757570ca148d6001e917d5679c57dbe4 100644 --- a/ee/app/assets/javascripts/subscriptions/buy_minutes/components/checkout.vue +++ b/ee/app/assets/javascripts/subscriptions/buy_minutes/components/checkout.vue @@ -33,12 +33,12 @@ export default { <template> <div v-if="!$apollo.loading" - class="checkout gl-flex gl-flex-column gl-justify-content-between w-100" + 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-4 gl-mb-3 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" /> </div> </div> diff --git a/ee/app/assets/javascripts/subscriptions/buy_minutes/components/order_summary.vue b/ee/app/assets/javascripts/subscriptions/buy_minutes/components/order_summary.vue new file mode 100644 index 0000000000000000000000000000000000000000..1e31d322e8cf03c6a68ed7cba5b842ff0f6384bb --- /dev/null +++ b/ee/app/assets/javascripts/subscriptions/buy_minutes/components/order_summary.vue @@ -0,0 +1,153 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import STATE_QUERY from 'ee/subscriptions/graphql/queries/state.query.graphql'; +import { TAX_RATE, NEW_GROUP } from 'ee/subscriptions/new/constants'; +import formattingMixins from 'ee/subscriptions/new/formatting_mixins'; +import { sprintf, s__ } from '~/locale'; +import SummaryDetails from './order_summary/summary_details.vue'; + +export default { + components: { + SummaryDetails, + GlIcon, + }, + mixins: [formattingMixins], + props: { + plans: { + type: Array, + required: true, + }, + }, + apollo: { + state: { + query: STATE_QUERY, + update(data) { + return data; + }, + result({ data }) { + this.subscription = data.subscription; + this.namespaces = data.namespaces; + this.isSetupForCompany = data.isSetupForCompany; + this.fullName = data.fullName; + this.customer = data.customer; + }, + }, + }, + data() { + return { + subscription: {}, + namespaces: [], + isSetupForCompany: false, + collapsed: true, + fullName: null, + customer: {}, + }; + }, + computed: { + selectedPlan() { + return this.plans.find((plan) => plan.code === this.subscription.planId); + }, + selectedPlanPrice() { + return this.selectedPlan.pricePerYear; + }, + selectedGroup() { + return this.namespaces.find((group) => group.id === this.subscription.namespaceId); + }, + totalExVat() { + return this.subscription.quantity * this.selectedPlanPrice; + }, + vat() { + return TAX_RATE * this.totalExVat; + }, + totalAmount() { + return this.totalExVat + this.vat; + }, + usersPresent() { + return this.subscription.quantity > 0; + }, + isGroupSelected() { + return this.subscription.namespaceId && this.subscription.namespaceId !== NEW_GROUP; + }, + isSelectedGroupPresent() { + return ( + this.isGroupSelected && + this.namespaces.some((namespace) => namespace.id === this.subscription.namespaceId) + ); + }, + name() { + if (this.isSetupForCompany && this.customer.company) { + return this.customer.company; + } + + if (this.isGroupSelected && this.isSelectedGroupPresent) { + return this.selectedGroup.name; + } + + if (this.isSetupForCompany) { + return s__('Checkout|Your organization'); + } + + return this.fullName; + }, + titleWithName() { + return sprintf(this.$options.i18n.title, { name: this.name }); + }, + }, + methods: { + toggleCollapse() { + this.collapsed = !this.collapsed; + }, + }, + i18n: { + title: s__("Checkout|%{name}'s GitLab subscription"), + }, + taxRate: TAX_RATE, +}; +</script> +<template> + <div + v-if="!$apollo.loading && (!isGroupSelected || isSelectedGroupPresent)" + class="order-summary gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-mt-2 mt-lg-5" + > + <div class="d-lg-none"> + <div @click="toggleCollapse"> + <h4 class="d-flex justify-content-between gl-font-lg" :class="{ 'gl-mb-7': !collapsed }"> + <div class="d-flex"> + <gl-icon v-if="collapsed" name="chevron-right" :size="18" use-deprecated-sizes /> + <gl-icon v-else name="chevron-down" :size="18" use-deprecated-sizes /> + <div>{{ titleWithName }}</div> + </div> + <div class="gl-ml-3">{{ formatAmount(totalAmount, usersPresent) }}</div> + </h4> + </div> + <summary-details + v-show="!collapsed" + :vat="vat" + :total-ex-vat="totalExVat" + :users-present="usersPresent" + :selected-plan-text="selectedPlan.name" + :selected-plan-price="selectedPlanPrice" + :total-amount="totalAmount" + :number-of-users="subscription.quantity" + :tax-rate="$options.taxRate" + /> + </div> + <div class="d-none d-lg-block"> + <div class="append-bottom-20"> + <h4> + {{ titleWithName }} + </h4> + </div> + <summary-details + :vat="vat" + :total-ex-vat="totalExVat" + :users-present="usersPresent" + :selected-plan-text="selectedPlan.name" + :selected-plan-price="selectedPlanPrice" + :total-amount="totalAmount" + :number-of-users="subscription.quantity" + :tax-rate="$options.taxRate" + /> + </div> + </div> +</template> diff --git a/ee/app/assets/javascripts/subscriptions/buy_minutes/components/order_summary/summary_details.vue b/ee/app/assets/javascripts/subscriptions/buy_minutes/components/order_summary/summary_details.vue new file mode 100644 index 0000000000000000000000000000000000000000..860ae9e83ac97d150d465310a0af6d19810747eb --- /dev/null +++ b/ee/app/assets/javascripts/subscriptions/buy_minutes/components/order_summary/summary_details.vue @@ -0,0 +1,104 @@ +<script> +import formattingMixins from 'ee/subscriptions/new/formatting_mixins'; +import { s__ } from '~/locale'; + +export default { + mixins: [formattingMixins], + props: { + vat: { + type: Number, + required: true, + }, + totalExVat: { + type: Number, + required: true, + }, + usersPresent: { + type: Boolean, + required: true, + }, + selectedPlanText: { + type: String, + required: true, + }, + selectedPlanPrice: { + type: Number, + required: true, + }, + totalAmount: { + type: Number, + required: true, + }, + numberOfUsers: { + type: Number, + required: true, + }, + taxRate: { + type: Number, + required: false, + default: null, + }, + }, + computed: { + startDate() { + return new Date(Date.now()); + }, + endDate() { + return new Date(this.startDate).setFullYear(this.startDate.getFullYear() + 1); + }, + }, + i18n: { + selectedPlanText: s__('Checkout|%{selectedPlanText} plan'), + numberOfUsers: s__('Checkout|(x%{numberOfUsers})'), + pricePerUserPerYear: s__('Checkout|$%{selectedPlanPrice} per user per year'), + dates: s__('Checkout|%{startDate} - %{endDate}'), + subtotal: s__('Checkout|Subtotal'), + tax: s__('Checkout|Tax'), + total: s__('Checkout|Total'), + }, +}; +</script> +<template> + <div> + <div class="d-flex justify-content-between bold gl-mt-3 gl-mb-3"> + <div class="js-selected-plan"> + {{ sprintf($options.i18n.selectedPlanText, { selectedPlanText }) }} + <span v-if="usersPresent" class="js-number-of-users">{{ + sprintf($options.i18n.numberOfUsers, { numberOfUsers }) + }}</span> + </div> + <div class="js-amount">{{ formatAmount(totalExVat, usersPresent) }}</div> + </div> + <div class="text-secondary js-per-user"> + {{ + sprintf($options.i18n.pricePerUserPerYear, { + selectedPlanPrice: selectedPlanPrice.toLocaleString(), + }) + }} + </div> + <div class="text-secondary js-dates"> + {{ + sprintf($options.i18n.dates, { + startDate: formatDate(startDate), + endDate: formatDate(endDate), + }) + }} + </div> + <div v-if="taxRate"> + <div class="border-bottom gl-mt-3 gl-mb-3"></div> + <div class="d-flex justify-content-between text-secondary"> + <div>{{ $options.i18n.subtotal }}</div> + <div class="js-total-ex-vat">{{ formatAmount(totalExVat, usersPresent) }}</div> + </div> + <div class="d-flex justify-content-between text-secondary"> + <div>{{ $options.i18n.tax }}</div> + <div class="js-vat">{{ formatAmount(vat, usersPresent) }}</div> + </div> + </div> + <div class="border-bottom gl-mt-3 gl-mb-3"></div> + <div class="d-flex justify-content-between bold gl-font-lg"> + <div>{{ $options.i18n.total }}</div> + <div class="js-total-amount">{{ formatAmount(totalAmount, usersPresent) }}</div> + </div> + </div> +</template> diff --git a/ee/app/assets/javascripts/subscriptions/buy_minutes/graphql/resolvers.js b/ee/app/assets/javascripts/subscriptions/buy_minutes/graphql/resolvers.js index 31eeca798373d02bfcd3f7ee50206049d8884d9c..00aa22c4a5993845c34eb46d413944701d143f2b 100644 --- a/ee/app/assets/javascripts/subscriptions/buy_minutes/graphql/resolvers.js +++ b/ee/app/assets/javascripts/subscriptions/buy_minutes/graphql/resolvers.js @@ -34,13 +34,13 @@ export const resolvers = { return SubscriptionsApi.createSubscription(groupId, customer, subscription); }, updateState: (_, { input }, { cache }) => { - const { state: oldState } = cache.readQuery({ query: STATE_QUERY }); + const oldState = cache.readQuery({ query: STATE_QUERY }); const state = produce(oldState, (draftState) => { merge(draftState, input); }); - cache.writeQuery({ query: STATE_QUERY, data: { state } }); + cache.writeQuery({ query: STATE_QUERY, data: state }); }, }, }; diff --git a/ee/spec/frontend/subscriptions/buy_minutes/components/checkout/order_summary_spec.js b/ee/spec/frontend/subscriptions/buy_minutes/components/checkout/order_summary_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1d0de49cdd2d9446588aa335f27e594019968d61 --- /dev/null +++ b/ee/spec/frontend/subscriptions/buy_minutes/components/checkout/order_summary_spec.js @@ -0,0 +1,225 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import { merge } from 'lodash'; +import VueApollo from 'vue-apollo'; +import OrderSummary from 'ee/subscriptions/buy_minutes/components/order_summary.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 { + mockCiMinutesPlans, + stateData as mockStateData, +} from 'ee_jest/subscriptions/buy_minutes/mock_data'; +import createMockApollo from 'helpers/mock_apollo_helper'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('Order Summary', () => { + const resolvers = { ...purchaseFlowResolvers, ...subscriptionsResolvers }; + const initialStateData = { + subscription: { planId: 'silver' }, + }; + let wrapper; + + const createMockApolloProvider = (stateData = {}) => { + const mockApollo = createMockApollo([], resolvers); + + const data = merge({}, mockStateData, initialStateData, stateData); + + mockApollo.clients.defaultClient.cache.writeQuery({ + query: stateQuery, + data, + }); + + return mockApollo; + }; + + const createComponent = (stateData) => { + const apolloProvider = createMockApolloProvider(stateData); + + wrapper = mount(OrderSummary, { + localVue, + apolloProvider, + propsData: { + plans: mockCiMinutesPlans, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Changing the company name', () => { + describe('When purchasing for a single user', () => { + beforeEach(() => { + createComponent({ isSetupForCompany: false }); + }); + + it('should display the title with the passed name', () => { + expect(wrapper.find('h4').text()).toContain("Full Name's GitLab subscription"); + }); + }); + + describe('When purchasing for a company or group', () => { + describe('Without a group name provided', () => { + beforeEach(() => { + createComponent({ isSetupForCompany: true }); + }); + + it('should display the title with the default name', () => { + expect(wrapper.find('h4').text()).toContain("Your organization's GitLab subscription"); + }); + }); + + describe('With a group name provided', () => { + beforeEach(() => { + createComponent({ + isSetupForCompany: true, + customer: { company: 'My group' }, + }); + }); + + it('when given a group name, it should display the title with the group name', () => { + expect(wrapper.find('h4').text()).toContain("My group's GitLab subscription"); + }); + }); + }); + }); + + describe('Changing the plan', () => { + beforeEach(() => { + createComponent(); + }); + + describe('the selected plan', () => { + it('should display the chosen plan', () => { + expect(wrapper.find('.js-selected-plan').text()).toContain('silver plan'); + }); + + it('should display the correct formatted amount price per user', () => { + expect(wrapper.find('.js-per-user').text()).toContain('$228 per user per year'); + }); + }); + + describe('the default plan', () => { + beforeEach(() => { + createComponent({ + subscription: { planId: 'bronze', quantity: 1 }, + }); + }); + + it('should display the chosen plan', () => { + expect(wrapper.find('.js-selected-plan').text()).toContain('bronze plan'); + }); + + it('should display the correct formatted amount price per user', () => { + expect(wrapper.find('.js-per-user').text()).toContain('$48 per user per year'); + }); + + it('should display the correct formatted total amount', () => { + expect(wrapper.find('.js-total-amount').text()).toContain('$48'); + }); + }); + }); + + describe('Changing the number of users', () => { + beforeEach(() => { + createComponent({ + subscription: { planId: 'silver', quantity: 1 }, + }); + }); + + describe('the default of 1 selected user', () => { + it('should display the correct number of users', () => { + expect(wrapper.find('.js-number-of-users').text()).toContain('(x1)'); + }); + + it('should display the correct formatted amount price per user', () => { + expect(wrapper.find('.js-per-user').text()).toContain('$228 per user per year'); + }); + + it('should display the correct multiplied formatted amount of the chosen plan', () => { + expect(wrapper.find('.js-amount').text()).toContain('$228'); + }); + + it('should display the correct formatted total amount', () => { + expect(wrapper.find('.js-total-amount').text()).toContain('$228'); + }); + }); + + describe('3 selected users', () => { + beforeEach(() => { + createComponent({ + subscription: { planId: 'silver', quantity: 3 }, + }); + }); + + it('should display the correct number of users', () => { + expect(wrapper.find('.js-number-of-users').text()).toContain('(x3)'); + }); + + it('should display the correct formatted amount price per user', () => { + expect(wrapper.find('.js-per-user').text()).toContain('$228 per user per year'); + }); + + it('should display the correct multiplied formatted amount of the chosen plan', () => { + expect(wrapper.find('.js-amount').text()).toContain('$684'); + }); + + it('should display the correct formatted total amount', () => { + expect(wrapper.find('.js-total-amount').text()).toContain('$684'); + }); + }); + + describe('no selected users', () => { + beforeEach(() => { + createComponent({ + subscription: { planId: 'silver', quantity: 0 }, + }); + }); + + it('should not display the number of users', () => { + expect(wrapper.find('.js-number-of-users').exists()).toBe(false); + }); + + it('should display the correct formatted amount price per user', () => { + expect(wrapper.find('.js-per-user').text()).toContain('$228 per user per year'); + }); + + it('should not display the amount', () => { + expect(wrapper.find('.js-amount').text()).toContain('-'); + }); + + it('should display the correct formatted total amount', () => { + expect(wrapper.find('.js-total-amount').text()).toContain('-'); + }); + }); + + describe('date range', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows the formatted date range from the start date to one year in the future', () => { + expect(wrapper.find('.js-dates').text()).toContain('Jul 6, 2020 - Jul 6, 2021'); + }); + }); + + describe('tax rate', () => { + beforeEach(() => { + createComponent(); + }); + + describe('a tax rate of 0', () => { + it('should not display the total amount excluding vat', () => { + expect(wrapper.find('.js-total-ex-vat').exists()).toBe(false); + }); + + it('should not display the vat amount', () => { + expect(wrapper.find('.js-vat').exists()).toBe(false); + }); + }); + }); + }); +});