Commit 0d0ef612 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch 'growth-87-summary-details' into 'master'

Order summary component for paid signup flow

Closes gitlab-org/growth/engineering#5049

See merge request gitlab-org/gitlab!21734
parents 1d3aaf71 13184d68
<script>
import { mapGetters } from 'vuex';
import { sprintf, s__ } from '~/locale';
import { GlIcon } from '@gitlab/ui';
import formattingMixins from '../formatting_mixins';
import SummaryDetails from './order_summary/summary_details.vue';
export default {
components: {
SummaryDetails,
GlIcon,
},
mixins: [formattingMixins],
data() {
return {
collapsed: true,
};
},
computed: {
...mapGetters(['totalAmount', 'name', 'usersPresent']),
titleWithName() {
return sprintf(this.$options.i18n.title, { name: this.name });
},
},
methods: {
toggleCollapse() {
this.collapsed = !this.collapsed;
},
},
i18n: {
title: s__("Checkout|%{name}'s GitLab subscription"),
},
};
</script>
<template>
<div class="order-summary d-flex flex-column flex-grow-1 prepend-top-5 mt-lg-5">
<div class="d-lg-none">
<div @click="toggleCollapse">
<h4
class="d-flex justify-content-between gl-font-size-16"
:class="{ 'prepend-bottom-32': !collapsed }"
>
<div class="d-flex">
<gl-icon v-if="collapsed" name="chevron-right" :size="18" />
<gl-icon v-else name="chevron-down" :size="18" />
<div>{{ titleWithName }}</div>
</div>
<div class="prepend-left-default">{{ formatAmount(totalAmount, usersPresent) }}</div>
</h4>
</div>
<summary-details v-show="!collapsed" />
</div>
<div class="d-none d-lg-block">
<div class="append-bottom-20">
<h4 class="gl-font-size-20">
{{ titleWithName }}
</h4>
</div>
<summary-details />
</div>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import { s__ } from '~/locale';
import formattingMixins from '../../formatting_mixins';
export default {
mixins: [formattingMixins],
computed: {
...mapState(['startDate', 'taxRate', 'numberOfUsers']),
...mapGetters([
'selectedPlanText',
'selectedPlanPrice',
'endDate',
'totalExVat',
'vat',
'totalAmount',
'usersPresent',
]),
},
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 prepend-top-10 append-bottom-8">
<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 prepend-top-default append-bottom-default"></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 prepend-top-default append-bottom-default"></div>
<div class="d-flex justify-content-between bold gl-font-size-large">
<div>{{ $options.i18n.total }}</div>
<div class="js-total-amount">{{ formatAmount(totalAmount, usersPresent) }}</div>
</div>
</div>
</template>
/* eslint-disable import/prefer-default-export */
export const STEPS = ['subscriptionDetails']; export const STEPS = ['subscriptionDetails'];
export const TAX_RATE = 0;
import dateFormat from 'dateformat';
export default {
methods: {
formatAmount(amount, show) {
return show ? `$${(Math.round(amount * 100) / 100).toLocaleString()}` : '-';
},
formatDate(date) {
return dateFormat(date, 'mmm d, yyyy');
},
},
};
import Vue from 'vue'; import Vue from 'vue';
import createStore from './store'; import createStore from './store';
import Checkout from './components/checkout.vue'; import Checkout from './components/checkout.vue';
import OrderSummary from './components/order_summary.vue';
export default () => { export default () => {
const checkoutEl = document.getElementById('checkout'); const checkoutEl = document.getElementById('checkout');
const summaryEl = document.getElementById('summary');
const store = createStore(checkoutEl.dataset);
return new Vue({ // eslint-disable-next-line no-new
new Vue({
el: checkoutEl, el: checkoutEl,
store: createStore(checkoutEl.dataset), store,
render(createElement) { render(createElement) {
return createElement(Checkout); return createElement(Checkout);
}, },
}); });
return new Vue({
el: summaryEl,
store,
render(createElement) {
return createElement(OrderSummary);
},
});
}; };
import { STEPS } from '../constants'; import { STEPS } from '../constants';
import { s__ } from '~/locale';
export const currentStep = state => state.currentStep; export const currentStep = state => state.currentStep;
...@@ -8,5 +9,25 @@ export const currentStepIndex = (state, getters) => getters.stepIndex(state.curr ...@@ -8,5 +9,25 @@ export const currentStepIndex = (state, getters) => getters.stepIndex(state.curr
export const selectedPlanText = (state, getters) => getters.selectedPlanDetails.text; export const selectedPlanText = (state, getters) => getters.selectedPlanDetails.text;
export const selectedPlanPrice = (state, getters) =>
getters.selectedPlanDetails.pricePerUserPerYear;
export const selectedPlanDetails = state => export const selectedPlanDetails = state =>
state.availablePlans.find(plan => plan.value === state.selectedPlan); state.availablePlans.find(plan => plan.value === state.selectedPlan);
export const endDate = state =>
new Date(state.startDate).setFullYear(state.startDate.getFullYear() + 1);
export const totalExVat = (state, getters) => state.numberOfUsers * getters.selectedPlanPrice;
export const vat = (state, getters) => state.taxRate * getters.totalExVat;
export const totalAmount = (_, getters) => getters.totalExVat + getters.vat;
export const name = state => {
if (state.isSetupForCompany && state.organizationName) return state.organizationName;
else if (state.isSetupForCompany) return s__('Checkout|Your organization');
return state.fullName;
};
export const usersPresent = state => state.numberOfUsers > 0;
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import { STEPS } from '../constants'; import { STEPS, TAX_RATE } from '../constants';
const parsePlanData = planData => const parsePlanData = planData =>
JSON.parse(planData).map(plan => ({ JSON.parse(planData).map(plan => ({
...@@ -27,5 +27,7 @@ export default ({ planData = '[]', planId, setupForCompany, fullName }) => { ...@@ -27,5 +27,7 @@ export default ({ planData = '[]', planId, setupForCompany, fullName }) => {
fullName, fullName,
organizationName: null, organizationName: null,
numberOfUsers: parseBoolean(setupForCompany) ? 0 : 1, numberOfUsers: parseBoolean(setupForCompany) ? 0 : 1,
taxRate: TAX_RATE,
startDate: new Date(Date.now()),
}; };
}; };
/* Checkout Page */ /* Checkout Page */
$subscriptions-form-group-width: 420px;
$subscriptions-full-width-md: $subscriptions-form-group-width + $gl-padding * 2 + 2;
$subscriptions-full-width-lg: 541px;
.subscriptions-layout-html { .subscriptions-layout-html {
.container { .container {
max-width: none; max-width: none;
...@@ -20,7 +24,7 @@ ...@@ -20,7 +24,7 @@
align-items: center; align-items: center;
flex-grow: 1; flex-grow: 1;
margin-top: $gl-padding; margin-top: $gl-padding;
max-width: 454px; max-width: $subscriptions-full-width-md;
@media(min-width: map-get($grid-breakpoints, lg)) { @media(min-width: map-get($grid-breakpoints, lg)) {
justify-content: inherit !important; justify-content: inherit !important;
...@@ -29,7 +33,7 @@ ...@@ -29,7 +33,7 @@
} }
.full-width { .full-width {
max-width: 541px; max-width: $subscriptions-full-width-lg;
width: 100%; width: 100%;
} }
} }
...@@ -67,10 +71,10 @@ ...@@ -67,10 +71,10 @@
margin-bottom: 0; margin-bottom: 0;
min-height: 32px; min-height: 32px;
padding: $gl-padding; padding: $gl-padding;
max-width: 541px; max-width: $subscriptions-full-width-lg;
@media(min-width: map-get($grid-breakpoints, lg)) { @media(min-width: map-get($grid-breakpoints, lg)) {
width: 541px; width: $subscriptions-full-width-lg;
} }
} }
...@@ -125,4 +129,12 @@ ...@@ -125,4 +129,12 @@
} }
} }
} }
.order-summary {
max-width: $subscriptions-full-width-md;
@media(min-width: map-get($grid-breakpoints, lg)) {
max-width: none;
}
}
} }
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import createStore from 'ee/subscriptions/new/store';
import * as types from 'ee/subscriptions/new/store/mutation_types';
import Component from 'ee/subscriptions/new/components/order_summary.vue';
describe('Order Summary', () => {
const localVue = createLocalVue();
localVue.use(Vuex);
let wrapper;
const planData = [
{ id: 'firstPlanId', code: 'bronze', price_per_year: 48 },
{ id: 'secondPlanId', code: 'silver', price_per_year: 228 },
{ id: 'thirdPlanId', code: 'gold', price_per_year: 1188 },
];
const initialData = {
planData: JSON.stringify(planData),
planId: 'thirdPlanId',
fullName: 'Full Name',
};
const store = createStore(initialData);
const createComponent = (opts = {}) => {
wrapper = mount(Component, {
localVue,
store,
...opts,
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('Changing the company name', () => {
describe('When purchasing for a single user', () => {
beforeEach(() => {
store.commit(types.UPDATE_IS_SETUP_FOR_COMPANY, 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', () => {
beforeEach(() => {
store.commit(types.UPDATE_IS_SETUP_FOR_COMPANY, true);
});
describe('Without a group name provided', () => {
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(() => {
store.commit(types.UPDATE_ORGANIZATION_NAME, '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', () => {
describe('the selected plan', () => {
it('should display the chosen plan', () => {
expect(wrapper.find('.js-selected-plan').text()).toContain('Gold plan');
});
it('should display the correct formatted amount price per user', () => {
expect(wrapper.find('.js-per-user').text()).toContain('$1,188 per user per year');
});
});
describe('the default plan', () => {
beforeEach(() => {
store.commit(types.UPDATE_SELECTED_PLAN, 'firstPlanId');
store.commit(types.UPDATE_NUMBER_OF_USERS, 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(() => {
store.commit(types.UPDATE_SELECTED_PLAN, 'thirdPlanId');
store.commit(types.UPDATE_NUMBER_OF_USERS, 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('$1,188 per user per year');
});
it('should display the correct multiplied formatted amount of the chosen plan', () => {
expect(wrapper.find('.js-amount').text()).toContain('$1,188');
});
it('should display the correct formatted total amount', () => {
expect(wrapper.find('.js-total-amount').text()).toContain('$1,188');
});
});
describe('3 selected users', () => {
beforeEach(() => {
store.commit(types.UPDATE_SELECTED_PLAN, 'thirdPlanId');
store.commit(types.UPDATE_NUMBER_OF_USERS, 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('$1,188 per user per year');
});
it('should display the correct multiplied formatted amount of the chosen plan', () => {
expect(wrapper.find('.js-amount').text()).toContain('$3,564');
});
it('should display the correct formatted total amount', () => {
expect(wrapper.find('.js-total-amount').text()).toContain('$3,564');
});
});
describe('no selected users', () => {
beforeEach(() => {
store.commit(types.UPDATE_SELECTED_PLAN, 'thirdPlanId');
store.commit(types.UPDATE_NUMBER_OF_USERS, 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('$1,188 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(() => {
store.state.startDate = new Date('2019-12-05');
});
it('shows the formatted date range from the start date to one year in the future', () => {
expect(wrapper.find('.js-dates').text()).toContain('Dec 5, 2019 - Dec 5, 2020');
});
});
describe('tax rate', () => {
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);
});
});
describe('a tax rate of 8%', () => {
beforeEach(() => {
store.state.taxRate = 0.08;
});
it('should display the total amount excluding vat', () => {
expect(wrapper.find('.js-total-ex-vat').text()).toContain('$1,188');
});
it('should display the vat amount', () => {
expect(wrapper.find('.js-vat').text()).toContain('$95.04');
});
it('should display the total amount including the vat', () => {
expect(wrapper.find('.js-total-amount').text()).toContain('$1,283.04');
});
});
});
});
});
...@@ -18,7 +18,7 @@ const state = { ...@@ -18,7 +18,7 @@ const state = {
describe('Subscriptions Getters', () => { describe('Subscriptions Getters', () => {
describe('currentStep', () => { describe('currentStep', () => {
it('returns the states currentStep', () => { it('returns the states currentStep', () => {
expect(getters.currentStep(state)).toEqual('secondStep'); expect(getters.currentStep(state)).toBe('secondStep');
}); });
}); });
...@@ -28,7 +28,7 @@ describe('Subscriptions Getters', () => { ...@@ -28,7 +28,7 @@ describe('Subscriptions Getters', () => {
}); });
it('returns a function that returns the index of the given step', () => { it('returns a function that returns the index of the given step', () => {
expect(getters.stepIndex()('secondStep')).toEqual(1); expect(getters.stepIndex()('secondStep')).toBe(1);
}); });
}); });
...@@ -49,7 +49,7 @@ describe('Subscriptions Getters', () => { ...@@ -49,7 +49,7 @@ describe('Subscriptions Getters', () => {
it('returns the text for selectedPlan', () => { it('returns the text for selectedPlan', () => {
expect( expect(
getters.selectedPlanText(state, { selectedPlanDetails: { text: 'selected plan' } }), getters.selectedPlanText(state, { selectedPlanDetails: { text: 'selected plan' } }),
).toEqual('selected plan'); ).toBe('selected plan');
}); });
}); });
...@@ -61,4 +61,56 @@ describe('Subscriptions Getters', () => { ...@@ -61,4 +61,56 @@ describe('Subscriptions Getters', () => {
}); });
}); });
}); });
describe('endDate', () => {
it('returns a date 1 year after the startDate', () => {
expect(getters.endDate({ startDate: new Date('2020-01-07') })).toBe(
new Date('2021-01-07').getTime(),
);
});
});
describe('totalExVat', () => {
it('returns the number of users times the selected plan price', () => {
expect(getters.totalExVat({ numberOfUsers: 5 }, { selectedPlanPrice: 10 })).toBe(50);
});
});
describe('vat', () => {
it('returns the tax rate times the total ex vat', () => {
expect(getters.vat({ taxRate: 0.08 }, { totalExVat: 100 })).toBe(8);
});
});
describe('totalAmount', () => {
it('returns the total ex vat plus the vat', () => {
expect(getters.totalAmount({}, { totalExVat: 100, vat: 8 })).toBe(108);
});
});
describe('name', () => {
it('returns the organization name when setting up for a company and when it is present', () => {
expect(getters.name({ isSetupForCompany: true, organizationName: 'My organization' })).toBe(
'My organization',
);
});
it('returns the default text when setting up for a company and the organization name is not present', () => {
expect(getters.name({ isSetupForCompany: true })).toBe('Your organization');
});
it('returns the full name when not setting up for a company', () => {
expect(getters.name({ isSetupForCompany: false, fullName: 'My name' })).toBe('My name');
});
});
describe('usersPresent', () => {
it('returns true when the number of users is greater than zero', () => {
expect(getters.usersPresent({ numberOfUsers: 1 })).toBe(true);
});
it('returns false when the number of users is zero', () => {
expect(getters.usersPresent({ numberOfUsers: 0 })).toBe(false);
});
});
}); });
...@@ -2,6 +2,7 @@ import createState from 'ee/subscriptions/new/store/state'; ...@@ -2,6 +2,7 @@ import createState from 'ee/subscriptions/new/store/state';
import * as constants from 'ee/subscriptions/new/constants'; import * as constants from 'ee/subscriptions/new/constants';
constants.STEPS = ['firstStep', 'secondStep']; constants.STEPS = ['firstStep', 'secondStep'];
constants.TAX_RATE = 0;
describe('projectsSelector default state', () => { describe('projectsSelector default state', () => {
const planData = [ const planData = [
...@@ -16,6 +17,10 @@ describe('projectsSelector default state', () => { ...@@ -16,6 +17,10 @@ describe('projectsSelector default state', () => {
fullName: 'Full Name', fullName: 'Full Name',
}; };
const currentDate = new Date('2020-01-07T12:44:08.135Z');
jest.spyOn(global.Date, 'now').mockImplementationOnce(() => currentDate.valueOf());
const state = createState(initialData); const state = createState(initialData);
it('sets the currentStep to the first item of the STEPS constant', () => { it('sets the currentStep to the first item of the STEPS constant', () => {
...@@ -92,4 +97,16 @@ describe('projectsSelector default state', () => { ...@@ -92,4 +97,16 @@ describe('projectsSelector default state', () => {
expect(modifiedState.numberOfUsers).toEqual(1); expect(modifiedState.numberOfUsers).toEqual(1);
}); });
}); });
describe('taxRate', () => {
it('sets the taxRate to the TAX_RATE constant', () => {
expect(state.taxRate).toEqual(0);
});
});
describe('startDate', () => {
it('sets the startDate to the current date', () => {
expect(state.startDate).toEqual(currentDate);
});
});
}); });
...@@ -3313,9 +3313,21 @@ msgstr "" ...@@ -3313,9 +3313,21 @@ msgstr ""
msgid "Checkout" msgid "Checkout"
msgstr "" msgstr ""
msgid "Checkout|$%{selectedPlanPrice} per user per year"
msgstr ""
msgid "Checkout|%{name}'s GitLab subscription"
msgstr ""
msgid "Checkout|%{selectedPlanText} plan" msgid "Checkout|%{selectedPlanText} plan"
msgstr "" msgstr ""
msgid "Checkout|%{startDate} - %{endDate}"
msgstr ""
msgid "Checkout|(x%{numberOfUsers})"
msgstr ""
msgid "Checkout|1. Your profile" msgid "Checkout|1. Your profile"
msgstr "" msgstr ""
...@@ -3352,9 +3364,21 @@ msgstr "" ...@@ -3352,9 +3364,21 @@ msgstr ""
msgid "Checkout|Subscription details" msgid "Checkout|Subscription details"
msgstr "" msgstr ""
msgid "Checkout|Subtotal"
msgstr ""
msgid "Checkout|Tax"
msgstr ""
msgid "Checkout|Total"
msgstr ""
msgid "Checkout|Users" msgid "Checkout|Users"
msgstr "" msgstr ""
msgid "Checkout|Your organization"
msgstr ""
msgid "Checkout|company or team" msgid "Checkout|company or team"
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