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 7848179cf59f85c6728a988c5c0bfcabbfff6483..3912d32acb4b15fe768c25d2e776b860678cfab9 100644 --- a/ee/app/assets/javascripts/subscriptions/buy_minutes/components/app.vue +++ b/ee/app/assets/javascripts/subscriptions/buy_minutes/components/app.vue @@ -23,7 +23,6 @@ export default { emptySvg, data() { return { - plans: null, hasError: false, }; }, @@ -59,10 +58,10 @@ export default { /> <step-order-app v-else-if="!$apollo.loading"> <template #checkout> - <checkout :plans="plans" /> + <checkout :plan="plans[0]" /> </template> <template #order-summary> - <order-summary :plans="plans" /> + <order-summary :plan="plans[0]" /> </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 7301510107dba9bab44f6241d7d46f7949d63e5e..f9460ab9a20a3505c3af8b723263ae2f48c09ac5 100644 --- a/ee/app/assets/javascripts/subscriptions/buy_minutes/components/checkout.vue +++ b/ee/app/assets/javascripts/subscriptions/buy_minutes/components/checkout.vue @@ -1,4 +1,7 @@ <script> +import updateState from 'ee/subscriptions/graphql/mutations/update_state.mutation.graphql'; +import { GENERAL_ERROR_MESSAGE } from 'ee/vue_shared/purchase_flow/constants'; +import createFlash from '~/flash'; import { s__ } from '~/locale'; import AddonPurchaseDetails from './checkout/addon_purchase_details.vue'; import BillingAddress from './checkout/billing_address.vue'; @@ -8,11 +11,28 @@ import PaymentMethod from './checkout/payment_method.vue'; export default { components: { AddonPurchaseDetails, BillingAddress, PaymentMethod, ConfirmOrder }, props: { - plans: { - type: Array, + plan: { + type: Object, required: true, }, }, + mounted() { + this.updateSelectedPlanId(this.plan.id); + }, + methods: { + updateSelectedPlanId(planId) { + this.$apollo + .mutate({ + mutation: updateState, + variables: { + input: { selectedPlanId: planId }, + }, + }) + .catch((error) => { + createFlash({ message: GENERAL_ERROR_MESSAGE, error, captureError: true }); + }); + }, + }, i18n: { checkout: s__('Checkout|Checkout'), }, @@ -22,7 +42,7 @@ export default { <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" /> + <addon-purchase-details :plan="plan" /> <billing-address /> <payment-method /> <confirm-order /> 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 index f4d94ed01bea914c0b3ef2a7ba0297640427cfd6..f45c34fc99e6471f24af4021b8fe5ac7458ca7f8 100644 --- a/ee/app/assets/javascripts/subscriptions/buy_minutes/components/order_summary.vue +++ b/ee/app/assets/javascripts/subscriptions/buy_minutes/components/order_summary.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlCollapse, GlCollapseToggleDirective } from '@gitlab/ui'; import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql'; -import { TAX_RATE, NEW_GROUP } from 'ee/subscriptions/new/constants'; +import { TAX_RATE } 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'; @@ -17,8 +17,8 @@ export default { }, mixins: [formattingMixins], props: { - plans: { - type: Array, + plan: { + type: Object, required: true, }, }, @@ -27,12 +27,8 @@ export default { query: stateQuery, manual: true, result({ data }) { - this.subscription = data.subscription; this.namespaces = data.namespaces; - this.isSetupForCompany = data.isSetupForCompany; - this.fullName = data.fullName; - this.customer = data.customer; - this.selectedPlanId = data.selectedPlanId; + this.subscription = data.subscription; }, }, }, @@ -40,22 +36,15 @@ export default { return { subscription: {}, namespaces: [], - isSetupForCompany: false, isBottomSummaryVisible: false, - fullName: null, - customer: {}, - selectedPlanId: null, }; }, computed: { - selectedPlan() { - return this.plans.find((plan) => plan.id === this.selectedPlanId); - }, selectedPlanPrice() { - return this.selectedPlan.pricePerYear; + return this.plan.pricePerYear; }, selectedGroup() { - return this.namespaces.find((group) => group.id === this.subscription.namespaceId); + return this.namespaces.find((group) => group.id === Number(this.subscription.namespaceId)); }, totalExVat() { return this.subscription.quantity * this.selectedPlanPrice; @@ -66,46 +55,21 @@ export default { totalAmount() { return this.totalExVat + this.vat; }, - usersPresent() { + quantityPresent() { 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; + namespaceName() { + return this.selectedGroup.name; }, titleWithName() { - return sprintf(this.$options.i18n.title, { name: this.name }); + return sprintf(this.$options.i18n.title, { name: this.namespaceName }); }, isVisible() { - return ( - !this.$apollo.loading && - (!this.isGroupSelected || this.isSelectedGroupPresent) && - this.selectedPlan - ); + return !this.$apollo.loading; }, }, i18n: { - title: s__("Checkout|%{name}'s GitLab subscription"), + title: s__("Checkout|%{name}'s CI minutes"), }, taxRate: TAX_RATE, }; @@ -121,20 +85,22 @@ export default { <div class="d-flex"> <gl-icon v-if="isBottomSummaryVisible" name="chevron-down" /> <gl-icon v-else name="chevron-right" /> - <div>{{ titleWithName }}</div> + <div data-testid="title">{{ titleWithName }}</div> + </div> + <div class="gl-ml-3" data-testid="amount"> + {{ formatAmount(totalAmount, quantityPresent) }} </div> - <div class="gl-ml-3">{{ formatAmount(totalAmount, usersPresent) }}</div> </h4> </div> <gl-collapse id="summary-details" v-model="isBottomSummaryVisible"> <summary-details :vat="vat" :total-ex-vat="totalExVat" - :users-present="usersPresent" - :selected-plan-text="selectedPlan.name" + :quantity-present="quantityPresent" + :selected-plan-text="plan.name" :selected-plan-price="selectedPlanPrice" :total-amount="totalAmount" - :number-of-users="subscription.quantity" + :quantity="subscription.quantity" :tax-rate="$options.taxRate" /> </gl-collapse> @@ -148,11 +114,11 @@ export default { <summary-details :vat="vat" :total-ex-vat="totalExVat" - :users-present="usersPresent" - :selected-plan-text="selectedPlan.name" + :quantity-present="quantityPresent" + :selected-plan-text="plan.name" :selected-plan-price="selectedPlanPrice" :total-amount="totalAmount" - :number-of-users="subscription.quantity" + :quantity="subscription.quantity" :tax-rate="$options.taxRate" /> </div> 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 index a1a3dfae025c453860b1f9c340c93d2781c25940..8ad44d43c9f861e3cf5c13a51229e71883dd21c5 100644 --- 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 @@ -13,10 +13,6 @@ export default { type: Number, required: true, }, - usersPresent: { - type: Boolean, - required: true, - }, selectedPlanText: { type: String, required: true, @@ -29,7 +25,7 @@ export default { type: Number, required: true, }, - numberOfUsers: { + quantity: { type: Number, required: true, }, @@ -38,6 +34,10 @@ export default { required: false, default: null, }, + purchaseHasExpiration: { + type: Boolean, + required: false, + }, }, data() { return { @@ -51,8 +51,8 @@ export default { }, i18n: { selectedPlanText: s__('Checkout|%{selectedPlanText} plan'), - numberOfUsers: s__('Checkout|(x%{numberOfUsers})'), - pricePerUserPerYear: s__('Checkout|$%{selectedPlanPrice} per user per year'), + quantity: s__('Checkout|(x%{quantity})'), + pricePerUnitPerYear: s__('Checkout|$%{selectedPlanPrice} per pack per year'), dates: s__('Checkout|%{startDate} - %{endDate}'), subtotal: s__('Checkout|Subtotal'), tax: s__('Checkout|Tax'), @@ -63,22 +63,22 @@ export default { <template> <div> <div class="d-flex justify-content-between bold gl-mt-3 gl-mb-3"> - <div class="js-selected-plan"> + <div data-testid="selected-plan"> {{ sprintf($options.i18n.selectedPlanText, { selectedPlanText }) }} - <span v-if="usersPresent" class="js-number-of-users">{{ - sprintf($options.i18n.numberOfUsers, { numberOfUsers }) + <span v-if="quantity" data-testid="quantity">{{ + sprintf($options.i18n.quantity, { quantity }) }}</span> </div> - <div class="js-amount">{{ formatAmount(totalExVat, usersPresent) }}</div> + <div data-testid="amount">{{ formatAmount(totalExVat, quantity > 0) }}</div> </div> - <div class="text-secondary js-per-user"> + <div class="text-secondary" data-testid="price-per-unit"> {{ - sprintf($options.i18n.pricePerUserPerYear, { + sprintf($options.i18n.pricePerUnitPerYear, { selectedPlanPrice: selectedPlanPrice.toLocaleString(), }) }} </div> - <div class="text-secondary js-dates"> + <div v-if="purchaseHasExpiration" class="text-secondary" data-testid="subscription-period"> {{ sprintf($options.i18n.dates, { startDate: formatDate(startDate), @@ -90,17 +90,17 @@ export default { <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 data-testid="total-ex-vat">{{ formatAmount(totalExVat, quantity > 0) }}</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 data-testid="vat">{{ formatAmount(vat, quantity > 0) }}</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 data-itestid="total-amount">{{ formatAmount(totalAmount, quantity > 0) }}</div> </div> </div> </template> diff --git a/ee/app/assets/javascripts/subscriptions/buy_minutes/utils.js b/ee/app/assets/javascripts/subscriptions/buy_minutes/utils.js index 3afdc256f906212868b1d3ef2670376da12a00ec..5a34f76cf14e9ee6eee7f50a80df4ac9b82d8f5f 100644 --- a/ee/app/assets/javascripts/subscriptions/buy_minutes/utils.js +++ b/ee/app/assets/javascripts/subscriptions/buy_minutes/utils.js @@ -1,6 +1,6 @@ import { STEPS } from 'ee/subscriptions/constants'; import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql'; -import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; function arrayToGraphqlArray(arr, typename) { return Array.from(arr, (item) => { @@ -11,23 +11,21 @@ function arrayToGraphqlArray(arr, typename) { } export function writeInitialDataToApolloCache(apolloProvider, dataset) { - const { groupData, newUser, setupForCompany, fullName, planId = null } = dataset; + const { groupData, namespaceId } = dataset; // eslint-disable-next-line @gitlab/require-i18n-strings const namespaces = arrayToGraphqlArray(JSON.parse(groupData), 'Namespace'); - const isNewUser = parseBoolean(newUser); - const isSetupForCompany = parseBoolean(setupForCompany) || !isNewUser; apolloProvider.clients.defaultClient.cache.writeQuery({ query: stateQuery, data: { - isNewUser, - isSetupForCompany, + isNewUser: false, + fullName: null, + isSetupForCompany: false, + selectedPlanId: null, namespaces, - fullName, - selectedPlanId: planId, subscription: { quantity: 1, - namespaceId: null, + namespaceId, // eslint-disable-next-line @gitlab/require-i18n-strings __typename: 'Subscription', }, diff --git a/ee/app/assets/javascripts/vue_shared/purchase_flow/components/step.vue b/ee/app/assets/javascripts/vue_shared/purchase_flow/components/step.vue index 3aed9a5c3919e6dd7031bf50470041537acbb46a..a9b2896ef801170be09f4fb79d4dcc2dc8bb4162 100644 --- a/ee/app/assets/javascripts/vue_shared/purchase_flow/components/step.vue +++ b/ee/app/assets/javascripts/vue_shared/purchase_flow/components/step.vue @@ -108,7 +108,7 @@ export default { }; </script> <template> - <div class="mb-3 mb-lg-5"> + <div class="mb-3 mb-lg-5 gl-w-full"> <step-header :title="title" :is-finished="isFinished" /> <div :class="['card', snakeCasedStep]"> <div v-show="isActive" @keyup.enter="nextStep"> diff --git a/ee/app/controllers/subscriptions_controller.rb b/ee/app/controllers/subscriptions_controller.rb index 4868b2a463418b8e8ac25477c9d523b5e40af5e8..b9e3995d1811c6c980887b161803077201e66121 100644 --- a/ee/app/controllers/subscriptions_controller.rb +++ b/ee/app/controllers/subscriptions_controller.rb @@ -2,9 +2,9 @@ class SubscriptionsController < ApplicationController layout 'checkout' - skip_before_action :authenticate_user!, only: [:new, :buy_minutes] + skip_before_action :authenticate_user!, only: [:new] - before_action :load_eligible_groups, only: %i[new buy_minutes] + before_action :load_eligible_groups, only: :new feature_category :purchase @@ -30,8 +30,11 @@ def new end def buy_minutes - render_404 unless Feature.enabled?(:new_route_ci_minutes_purchase, default_enabled: :yaml) - redirect_unauthenticated_user + return render_404 unless Feature.enabled?(:new_route_ci_minutes_purchase, default_enabled: :yaml) + + @group = find_group + + return render_404 if @group.nil? end def payment_form diff --git a/ee/app/helpers/subscriptions_helper.rb b/ee/app/helpers/subscriptions_helper.rb index 42e98107b1730444236195d4f7cb0c6f5defce95..f1d0a7b41594093223f21a0f5664f731afde8b10 100644 --- a/ee/app/helpers/subscriptions_helper.rb +++ b/ee/app/helpers/subscriptions_helper.rb @@ -16,6 +16,14 @@ def subscription_data(eligible_groups) } end + def addon_data(group) + { + group_data: [present_group(group)].to_json, + namespace_id: params[:selected_group], + source: params[:source] + } + end + def plan_title strong_memoize(:plan_title) do plan = subscription_available_plans.find { |plan| plan[:id] == params[:plan_id] } @@ -45,13 +53,15 @@ def subscription_available_plans end def present_groups(groups) - groups.map do |namespace| - { - id: namespace.id, - name: namespace.name, - users: namespace.member_count, - guests: namespace.guest_count - } - end + groups.map { |namespace| present_group(namespace) } + end + + def present_group(namespace) + { + id: namespace.id, + name: namespace.name, + users: namespace.member_count, + guests: namespace.guest_count + } end end diff --git a/ee/app/views/subscriptions/buy_minutes.html.haml b/ee/app/views/subscriptions/buy_minutes.html.haml index 06886d2e1dd9d72bf10f16c1f2e378ad9bf0c423..b634e99b869052b7a582e25f287e4953e0079206 100644 --- a/ee/app/views/subscriptions/buy_minutes.html.haml +++ b/ee/app/views/subscriptions/buy_minutes.html.haml @@ -1,3 +1,3 @@ - page_title _('Buy CI Minutes') -#js-buy-minutes{ data: subscription_data(@eligible_groups) } +#js-buy-minutes{ data: addon_data(@group) } diff --git a/ee/spec/controllers/subscriptions_controller_spec.rb b/ee/spec/controllers/subscriptions_controller_spec.rb index 14c226e93719720e3fb4d10928be7b60ce3cf5b0..cef3bcce78f421e74ac3b9340ad7873eaacd3d33 100644 --- a/ee/spec/controllers/subscriptions_controller_spec.rb +++ b/ee/spec/controllers/subscriptions_controller_spec.rb @@ -65,19 +65,18 @@ end describe 'GET #buy_minutes' do - subject(:buy_minutes) { get :buy_minutes, params: { plan_id: 'bronze_id' } } + let_it_be(:group) { create(:group) } - it_behaves_like 'unauthenticated subscription request', 'buy_minutes' + subject(:buy_minutes) { get :buy_minutes, params: { selected_group: group.id } } context 'with authenticated user' do before do + group.add_owner(user) + stub_feature_flags(new_route_ci_minutes_purchase: true) sign_in(user) end - it { is_expected.to render_template 'layouts/checkout' } - it { is_expected.to render_template :buy_minutes } - - context 'when there are groups eligible for the subscription' do + context 'when there are groups eligible for the addon' do let_it_be(:group) { create(:group) } before do @@ -88,30 +87,21 @@ end end - it 'assigns the eligible groups for the subscription' do - buy_minutes - - expect(assigns(:eligible_groups)).to eq [group] - end - end - - context 'when there are no eligible groups for the subscription' do - it 'assigns eligible groups as an empty array' do - allow_next_instance_of(GitlabSubscriptions::FilterPurchaseEligibleNamespacesService, user: user, namespaces: []) do |instance| - allow(instance).to receive(:execute).and_return(instance_double(ServiceResponse, success?: true, payload: [])) - end + it { is_expected.to render_template 'layouts/checkout' } + it { is_expected.to render_template :buy_minutes } + it 'assigns the group for the addon' do buy_minutes - expect(assigns(:eligible_groups)).to eq [] + expect(assigns(:group)).to eq group end end end context 'with :new_route_ci_minutes_purchase disabled' do before do - sign_in(user) stub_feature_flags(new_route_ci_minutes_purchase: false) + sign_in(user) end it { is_expected.to have_gitlab_http_status(:not_found) } diff --git a/ee/spec/frontend/subscriptions/buy_minutes/components/checkout/addon_purchase_details_spec.js b/ee/spec/frontend/subscriptions/buy_minutes/components/checkout/addon_purchase_details_spec.js index b13aaa5307f0ac65648b8449cd850a9199f62ef0..749680165d6648ebafe546d89ebf369bd879c97d 100644 --- a/ee/spec/frontend/subscriptions/buy_minutes/components/checkout/addon_purchase_details_spec.js +++ b/ee/spec/frontend/subscriptions/buy_minutes/components/checkout/addon_purchase_details_spec.js @@ -8,10 +8,7 @@ import subscriptionsResolvers from 'ee/subscriptions/buy_minutes/graphql/resolve 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 { stateData as initialStateData } from 'ee_jest/subscriptions/buy_minutes/mock_data'; import createMockApollo from 'helpers/mock_apollo_helper'; const localVue = createLocalVue(); @@ -40,9 +37,6 @@ describe('AddonPurchaseDetails', () => { return mount(AddonPurchaseDetails, { localVue, apolloProvider, - propsData: { - plans: mockCiMinutesPlans, - }, stubs: { Step, }, 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 deleted file mode 100644 index 5a914ac0b5639f6d186f6c149c7aa4ee79dea925..0000000000000000000000000000000000000000 --- a/ee/spec/frontend/subscriptions/buy_minutes/components/checkout/order_summary_spec.js +++ /dev/null @@ -1,226 +0,0 @@ -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 = { - selectedPlanId: 'secondPlanId', - }; - 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: { quantity: 1 }, - selectedPlanId: 'firstPlanId', - }); - }); - - 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: { 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: { 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: { 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); - }); - }); - }); - }); -}); diff --git a/ee/spec/frontend/subscriptions/buy_minutes/components/order_summary/summary_details_spec.js b/ee/spec/frontend/subscriptions/buy_minutes/components/order_summary/summary_details_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..175c8f8c88f3c5141a6abe93e55f30593ed0a9ce --- /dev/null +++ b/ee/spec/frontend/subscriptions/buy_minutes/components/order_summary/summary_details_spec.js @@ -0,0 +1,112 @@ +import { shallowMount } from '@vue/test-utils'; +import SummaryDetails from 'ee/subscriptions/buy_minutes/components/order_summary/summary_details.vue'; + +describe('SummaryDetails', () => { + let wrapper; + + const createComponent = (props = {}) => { + return shallowMount(SummaryDetails, { + propsData: { + vat: 8, + totalExVat: 10, + selectedPlanText: 'Test', + selectedPlanPrice: 10, + totalAmount: 10, + quantity: 1, + ...props, + }, + }); + }; + + const findQuantity = () => wrapper.find('[data-testid="quantity"]'); + const findSubscriptionPeriod = () => wrapper.find('[data-testid="subscription-period"]'); + const findTotalExVat = () => wrapper.find('[data-testid="total-ex-vat"]'); + const findVat = () => wrapper.find('[data-testid="vat"]'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('rendering', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('renders the plan name', () => { + expect(wrapper.find('[data-testid="selected-plan"]').text()).toMatchInterpolatedText( + 'Test plan (x1)', + ); + }); + + it('renders the price per unit', () => { + expect(wrapper.find('[data-testid="price-per-unit"]').text()).toBe('$10 per pack per year'); + }); + }); + + describe('when quantity is greater then zero', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('renders quantity', () => { + expect(findQuantity().isVisible()).toBe(true); + expect(findQuantity().text()).toBe('(x1)'); + }); + }); + + describe('when quantity is less or equal to zero', () => { + beforeEach(() => { + wrapper = createComponent({ quantity: 0 }); + }); + + it('does not render quantity', () => { + expect(wrapper.find('[data-testid="quantity"]').exists()).toBe(false); + }); + }); + + describe('when subscription has expiration', () => { + beforeEach(() => { + wrapper = createComponent({ purchaseHasExpiration: true }); + }); + + it('renders subscription period', () => { + expect(findSubscriptionPeriod().isVisible()).toBe(true); + expect(findSubscriptionPeriod().text()).toBe('Jul 6, 2020 - Jul 6, 2021'); + }); + }); + + describe('when subscription does not have expiration', () => { + beforeEach(() => { + wrapper = createComponent({ purchaseHasExpiration: false }); + }); + + it('does not render subscription period', () => { + expect(findSubscriptionPeriod().exists()).toBe(false); + }); + }); + + describe('when tax rate is applied', () => { + beforeEach(() => { + wrapper = createComponent({ taxRate: 8 }); + }); + + it('renders tax fields', () => { + expect(findTotalExVat().isVisible()).toBe(true); + expect(findTotalExVat().text()).toBe('$10'); + + expect(findVat().isVisible()).toBe(true); + expect(findVat().text()).toBe('$8'); + }); + }); + + describe('when tax rate is not applied', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('does not render tax fields', () => { + expect(findTotalExVat().exists()).toBe(false); + expect(findVat().exists()).toBe(false); + }); + }); +}); diff --git a/ee/spec/frontend/subscriptions/buy_minutes/components/order_summary_spec.js b/ee/spec/frontend/subscriptions/buy_minutes/components/order_summary_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..533cfaf9d88c51cf51d3728b307d3d4351cefe1b --- /dev/null +++ b/ee/spec/frontend/subscriptions/buy_minutes/components/order_summary_spec.js @@ -0,0 +1,96 @@ +import { shallowMount, 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, + mockParsedNamespaces, + 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 = { + selectedPlanId: 'ciMinutesPackPlanId', + namespaces: [mockParsedNamespaces[0]], + subscription: { + namespaceId: mockParsedNamespaces[0].id, + }, + }; + 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 = shallowMount(OrderSummary, { + localVue, + apolloProvider, + propsData: { + plan: mockCiMinutesPlans[0], + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('the default plan', () => { + beforeEach(() => { + createComponent({ + subscription: { quantity: 1 }, + selectedPlanId: 'ciMinutesPackPlanId', + }); + }); + + it('displays the title', () => { + expect(wrapper.find('[data-testid="title"]').text()).toMatchInterpolatedText( + "Gitlab Org's CI minutes", + ); + }); + }); + + describe('when quantity is greater than zero', () => { + beforeEach(() => { + createComponent({ + subscription: { quantity: 3 }, + }); + }); + + it('renders amount', () => { + expect(wrapper.find('[data-testid="amount"]').text()).toBe('$30'); + }); + }); + + describe('when quantity is less than or equal to zero', () => { + beforeEach(() => { + createComponent({ + subscription: { quantity: 0 }, + }); + }); + + it('does not render amount', () => { + expect(wrapper.find('[data-testid="amount"]').text()).toBe('-'); + }); + }); +}); diff --git a/ee/spec/frontend/subscriptions/buy_minutes/mock_data.js b/ee/spec/frontend/subscriptions/buy_minutes/mock_data.js index b0ddd44aedbf8d061eabb1a5116b7dab509b2b31..1ad9ddbf2b1305b0ba72f1d8a9b6101597c4cd57 100644 --- a/ee/spec/frontend/subscriptions/buy_minutes/mock_data.js +++ b/ee/spec/frontend/subscriptions/buy_minutes/mock_data.js @@ -1,8 +1,13 @@ import { STEPS } from 'ee/subscriptions/constants'; export const mockCiMinutesPlans = [ - { id: 'firstPlanId', code: 'bronze', pricePerYear: 48, name: 'bronze', __typename: 'Plan' }, - { id: 'secondPlanId', code: 'silver', pricePerYear: 228, name: 'silver', __typename: 'Plan' }, + { + id: 'ciMinutesPackPlanId', + code: 'ci_minutes', + pricePerYear: 10, + name: '1000 CI minutes pack', + __typename: 'Plan', + }, ]; export const mockNamespaces = '[{"id":132,"name":"Gitlab Org","users":3},{"id":483,"name":"Gnuwget","users":12}]'; diff --git a/ee/spec/frontend/subscriptions/buy_minutes/utils_spec.js b/ee/spec/frontend/subscriptions/buy_minutes/utils_spec.js index eeaf809de89d695598f15b48f965e5332e98c122..5f83de918bbc053aa3123b1d791e1d022f15bc13 100644 --- a/ee/spec/frontend/subscriptions/buy_minutes/utils_spec.js +++ b/ee/spec/frontend/subscriptions/buy_minutes/utils_spec.js @@ -1,19 +1,14 @@ import apolloProvider from 'ee/subscriptions/buy_minutes/graphql'; import { writeInitialDataToApolloCache } from 'ee/subscriptions/buy_minutes/utils'; import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql'; -import { - mockNamespaces, - mockParsedNamespaces, - mockNewUser, - mockFullName, - mockSetupForCompany, -} from './mock_data'; +import { mockNamespaces, mockParsedNamespaces } from './mock_data'; const DEFAULT_DATA = { groupData: mockNamespaces, - newUser: mockNewUser, - fullName: mockFullName, - setupForCompany: mockSetupForCompany, + namespaceId: mockParsedNamespaces[0].id, + newUser: false, + fullName: null, + setupForCompany: false, }; describe('utils', () => { @@ -48,72 +43,5 @@ describe('utils', () => { }); }); }); - - describe('newUser', () => { - describe.each` - newUser | parsedNewUser | throws - ${'true'} | ${true} | ${false} - ${mockNewUser} | ${false} | ${false} - ${''} | ${false} | ${true} - `('parameter decoding', ({ newUser, parsedNewUser, throws }) => { - it(`decodes ${newUser} to ${parsedNewUser}`, async () => { - if (throws) { - expect(() => { - writeInitialDataToApolloCache(apolloProvider, { newUser }); - }).toThrow(); - } else { - writeInitialDataToApolloCache(apolloProvider, { ...DEFAULT_DATA, newUser }); - const sourceData = await apolloProvider.clients.defaultClient.query({ - query: stateQuery, - }); - expect(sourceData.data.isNewUser).toEqual(parsedNewUser); - } - }); - }); - }); - - describe('fullName', () => { - describe.each` - fullName | parsedFullName - ${mockFullName} | ${mockFullName} - ${''} | ${''} - ${null} | ${null} - `('parameter decoding', ({ fullName, parsedFullName }) => { - it(`decodes ${fullName} to ${parsedFullName}`, async () => { - writeInitialDataToApolloCache(apolloProvider, { ...DEFAULT_DATA, fullName }); - const sourceData = await apolloProvider.clients.defaultClient.query({ - query: stateQuery, - }); - expect(sourceData.data.fullName).toEqual(parsedFullName); - }); - }); - }); - - describe('setupForCompany', () => { - describe.each` - setupForCompany | parsedSetupForCompany | throws - ${mockSetupForCompany} | ${true} | ${false} - ${'false'} | ${false} | ${false} - ${''} | ${false} | ${true} - `('parameter decoding', ({ setupForCompany, parsedSetupForCompany, throws }) => { - it(`decodes ${setupForCompany} to ${parsedSetupForCompany}`, async () => { - if (throws) { - expect(() => { - writeInitialDataToApolloCache(apolloProvider, { setupForCompany }); - }).toThrow(); - } else { - writeInitialDataToApolloCache(apolloProvider, { - ...DEFAULT_DATA, - newUser: 'true', - setupForCompany, - }); - const sourceData = await apolloProvider.clients.defaultClient.query({ - query: stateQuery, - }); - expect(sourceData.data.isSetupForCompany).toEqual(parsedSetupForCompany); - } - }); - }); - }); }); }); diff --git a/ee/spec/helpers/subscriptions_helper_spec.rb b/ee/spec/helpers/subscriptions_helper_spec.rb index e8660762f7831bf7b4e7bf151dca7dfd7fdd63ef..2b4820f210cbd8c99f70902ed45dccc8ef70dafd 100644 --- a/ee/spec/helpers/subscriptions_helper_spec.rb +++ b/ee/spec/helpers/subscriptions_helper_spec.rb @@ -132,4 +132,21 @@ it { is_expected.to eq(nil) } end end + + describe '#addon_data' do + subject(:addon_data) { helper.addon_data(group) } + + let_it_be(:user) { create(:user, name: 'First Last') } + let_it_be(:group) { create(:group, name: 'My Namespace') } + + before do + allow(helper).to receive(:current_user).and_return(user) + allow(helper).to receive(:params).and_return({ selected_group: group.id.to_s, source: 'some_source' }) + group.add_owner(user) + end + + it { is_expected.to include(namespace_id: group.id.to_s) } + it { is_expected.to include(source: 'some_source') } + it { is_expected.to include(group_data: %Q{[{"id":#{group.id},"name":"My Namespace","users":1,"guests":0}]}) } + end end diff --git a/ee/spec/support/shared_examples/views/subscription_shared_examples.rb b/ee/spec/support/shared_examples/views/subscription_shared_examples.rb index 92362599e346895f5a09bded7f1e2130694c67d0..fb9d98a298f39a88e49e5fa9f54adf8514cf3829 100644 --- a/ee/spec/support/shared_examples/views/subscription_shared_examples.rb +++ b/ee/spec/support/shared_examples/views/subscription_shared_examples.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + RSpec.shared_examples_for 'subscription form data' do |js_selector| before do allow(view).to receive(:subscription_data).and_return( @@ -18,3 +19,21 @@ it { is_expected.to have_selector("#{js_selector}[data-plan-id='bronze_id']") } it { is_expected.to have_selector("#{js_selector}[data-source='some_source']") } end + +RSpec.shared_examples_for 'addon form data' do |js_selector| + before do + allow(view).to receive(:addon_data).and_return( + plan_data: '[{"id":"ci_minutes_plan_id","code":"ci_minutes","price_per_year":10.0}]', + namespace_id: '1', + plan_id: 'ci_minutes_plan_id', + source: 'some_source' + ) + end + + subject { render } + + it { is_expected.to have_selector("#{js_selector}[data-plan-data='[{\"id\":\"ci_minutes_plan_id\",\"code\":\"ci_minutes\",\"price_per_year\":10.0}]']") } + it { is_expected.to have_selector("#{js_selector}[data-plan-id='ci_minutes_plan_id']") } + it { is_expected.to have_selector("#{js_selector}[data-namespace-id='1']") } + it { is_expected.to have_selector("#{js_selector}[data-source='some_source']") } +end diff --git a/ee/spec/views/subscriptions/buy_minutes.html.haml_spec.rb b/ee/spec/views/subscriptions/buy_minutes.html.haml_spec.rb index 9b5f2abbb93c493ff2ba5f672b9828df1a65151f..2183db206614cfb3257fa64353f15a292659550c 100644 --- a/ee/spec/views/subscriptions/buy_minutes.html.haml_spec.rb +++ b/ee/spec/views/subscriptions/buy_minutes.html.haml_spec.rb @@ -3,5 +3,5 @@ require 'spec_helper' RSpec.describe 'subscriptions/buy_minutes' do - it_behaves_like 'subscription form data', '#js-buy-minutes' + it_behaves_like 'addon form data', '#js-buy-minutes' end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index bcbe90e6ea8b5a7dfcf3afeb3c1d91fef69984e8..686b4c6afaab5d26379d95780666af61b2aac4ad 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -6418,12 +6418,18 @@ msgstr "" msgid "Checkout" msgstr "" +msgid "Checkout|$%{selectedPlanPrice} per pack per year" +msgstr "" + msgid "Checkout|$%{selectedPlanPrice} per user per year" msgstr "" msgid "Checkout|%{cardType} ending in %{lastFourDigits}" msgstr "" +msgid "Checkout|%{name}'s CI minutes" +msgstr "" + msgid "Checkout|%{name}'s GitLab subscription" msgstr "" @@ -6442,6 +6448,9 @@ msgstr "" msgid "Checkout|(x%{numberOfUsers})" msgstr "" +msgid "Checkout|(x%{quantity})" +msgstr "" + msgid "Checkout|Billing address" msgstr ""