diff --git a/ee/app/assets/javascripts/subscriptions/new/components/checkout/billing_address.vue b/ee/app/assets/javascripts/subscriptions/new/components/checkout/billing_address.vue index 08a3dae2963722f9de44c954a5ba9bd86d54a843..027809cfbe6b75bb14a58519fae135fc61cf5925 100644 --- a/ee/app/assets/javascripts/subscriptions/new/components/checkout/billing_address.vue +++ b/ee/app/assets/javascripts/subscriptions/new/components/checkout/billing_address.vue @@ -13,9 +13,16 @@ import Step from 'ee/vue_shared/purchase_flow/components/step.vue'; import { s__ } from '~/locale'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import Tracking from '~/tracking'; +import getBillingAccountQuery from 'ee/vue_shared/purchase_flow/graphql/queries/get_billing_account.customer.query.graphql'; +import { CUSTOMERSDOT_CLIENT } from 'ee/subscriptions/buy_addons_shared/constants'; +import { logError } from '~/lib/logger'; +import SprintfWithLinks from 'ee/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { helpPagePath } from '~/helpers/help_page_helper'; export default { components: { + SprintfWithLinks, Step, GlFormGroup, GlFormInput, @@ -25,6 +32,23 @@ export default { autofocusonshow, }, mixins: [Tracking.mixin()], + data() { + return { + billingAccount: null, + }; + }, + apollo: { + billingAccount: { + query: getBillingAccountQuery, + client: CUSTOMERSDOT_CLIENT, + skip() { + return !gon.features?.keyContactsManagement; + }, + error(error) { + this.handleError(error); + }, + }, + }, computed: { ...mapState([ 'country', @@ -90,15 +114,21 @@ export default { isStateValid() { return this.isStateRequired ? !isEmpty(this.countryState) : true; }, - isValid() { + areRequiredFieldsValid() { return ( - this.isStateValid && !isEmpty(this.country) && !isEmpty(this.streetAddressLine1) && !isEmpty(this.city) && !isEmpty(this.zipCode) ); }, + isValid() { + if (this.shouldShowManageContacts) { + return true; + } + + return this.isStateValid && this.areRequiredFieldsValid; + }, countryOptionsWithDefault() { return [ { @@ -117,6 +147,14 @@ export default { ...this.stateOptions, ]; }, + shouldShowManageContacts() { + return Boolean(this.billingAccount?.zuoraAccountName); + }, + stepTitle() { + return this.shouldShowManageContacts + ? this.$options.i18n.contactInformationStepTitle + : this.$options.i18n.billingAddressStepTitle; + }, }, mounted() { this.fetchCountries(); @@ -147,15 +185,27 @@ export default { property: STEP_BILLING_ADDRESS, }); }, + handleError(error) { + Sentry.captureException(error); + logError(error); + }, }, i18n: { - stepTitle: s__('Checkout|Billing address'), + billingAddressStepTitle: s__('Checkout|Billing address'), + contactInformationStepTitle: s__('Checkout|Contact information'), nextStepButtonText: s__('Checkout|Continue to payment'), countryLabel: s__('Checkout|Country'), streetAddressLabel: s__('Checkout|Street address'), cityLabel: s__('Checkout|City'), stateLabel: s__('Checkout|State'), zipCodeLabel: s__('Checkout|Zip code'), + manageContacts: s__( + 'Checkout|Manage the subscription and billing contacts for your billing account in the %{customersPortalLinkStart}Customers Portal%{customersPortalLinkEnd}. Learn more about %{manageContactsLinkStart}how to manage your contacts%{manageContactsLinkEnd}.', + ), + }, + manageContactsLinkObject: { + customersPortalLink: gon.subscriptions_url, + manageContactsLink: helpPagePath('subscriptions/customers_portal'), }, stepId: STEP_BILLING_ADDRESS, }; @@ -163,56 +213,67 @@ export default { <template> <step :step-id="$options.stepId" - :title="$options.i18n.stepTitle" + :title="stepTitle" :is-valid="isValid" :next-step-button-text="$options.i18n.nextStepButtonText" @nextStep="trackStepTransition" @stepEdit="trackStepEdit" > <template #body> - <gl-form-group :label="$options.i18n.countryLabel" label-size="sm" class="mb-3"> - <gl-form-select - v-model="countryModel" - v-autofocusonshow - :options="countryOptionsWithDefault" - class="js-country" - data-testid="country" - @change="fetchStates" - /> - </gl-form-group> - <gl-form-group :label="$options.i18n.streetAddressLabel" label-size="sm" class="mb-3"> - <gl-form-input - v-model="streetAddressLine1Model" - type="text" - data-testid="street-address-1" + <div v-if="shouldShowManageContacts" class="gl-mb-3"> + <sprintf-with-links + :message="$options.i18n.manageContacts" + :link-object="$options.manageContactsLinkObject" /> - <gl-form-input - v-model="streetAddressLine2Model" - type="text" - data-testid="street-address-2" - class="gl-mt-3" - /> - </gl-form-group> - <gl-form-group :label="$options.i18n.cityLabel" label-size="sm" class="mb-3"> - <gl-form-input v-model="cityModel" type="text" data-testid="city" /> - </gl-form-group> - <div class="combined d-flex"> - <gl-form-group :label="$options.i18n.stateLabel" label-size="sm" class="mr-3 w-50"> + </div> + + <div v-else data-testid="checkout-billing-address-form"> + <gl-form-group :label="$options.i18n.countryLabel" label-size="sm" class="gl-mb-3"> <gl-form-select - v-model="countryStateModel" - :options="stateOptionsWithDefault" - data-testid="state" + v-model="countryModel" + v-autofocusonshow + :options="countryOptionsWithDefault" + class="js-country" + data-testid="country" + @change="fetchStates" /> </gl-form-group> - <gl-form-group :label="$options.i18n.zipCodeLabel" label-size="sm" class="w-50"> - <gl-form-input v-model="zipCodeModel" type="text" data-testid="zip-code" /> + <gl-form-group :label="$options.i18n.streetAddressLabel" label-size="sm" class="gl-mb-3"> + <gl-form-input + v-model="streetAddressLine1Model" + type="text" + data-testid="street-address-1" + /> + <gl-form-input + v-model="streetAddressLine2Model" + type="text" + data-testid="street-address-2" + class="gl-mt-3" + /> </gl-form-group> + <gl-form-group :label="$options.i18n.cityLabel" label-size="sm" class="gl-mb-3"> + <gl-form-input v-model="cityModel" type="text" data-testid="city" /> + </gl-form-group> + <div class="combined gl-display-flex"> + <gl-form-group :label="$options.i18n.stateLabel" label-size="sm" class="mr-3 w-50"> + <gl-form-select + v-model="countryStateModel" + :options="stateOptionsWithDefault" + data-testid="state" + /> + </gl-form-group> + <gl-form-group :label="$options.i18n.zipCodeLabel" label-size="sm" class="w-50"> + <gl-form-input v-model="zipCodeModel" type="text" data-testid="zip-code" /> + </gl-form-group> + </div> </div> </template> - <template #summary> - <div class="js-summary-line-1">{{ streetAddressLine1 }}</div> - <div class="js-summary-line-2">{{ streetAddressLine2 }}</div> - <div class="js-summary-line-3">{{ city }}, {{ countryState }} {{ zipCode }}</div> + <template v-if="!shouldShowManageContacts" #summary> + <div data-testid="checkout-billing-address-summary"> + <div class="js-summary-line-1">{{ streetAddressLine1 }}</div> + <div class="js-summary-line-2">{{ streetAddressLine2 }}</div> + <div class="js-summary-line-3">{{ city }}, {{ countryState }} {{ zipCode }}</div> + </div> </template> </step> </template> diff --git a/ee/app/assets/javascripts/vue_shared/purchase_flow/components/checkout/billing_address.vue b/ee/app/assets/javascripts/vue_shared/purchase_flow/components/checkout/billing_address.vue index b51d7b2f16457ad17e062afd4f6c26319394d8b2..baa8baa212dc77c39cb7c7e341d188deefb6dc24 100644 --- a/ee/app/assets/javascripts/vue_shared/purchase_flow/components/checkout/billing_address.vue +++ b/ee/app/assets/javascripts/vue_shared/purchase_flow/components/checkout/billing_address.vue @@ -12,9 +12,15 @@ import countriesQuery from 'ee/subscriptions/graphql/queries/countries.query.gra import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql'; import statesQuery from 'ee/subscriptions/graphql/queries/states.query.graphql'; import Step from 'ee/vue_shared/purchase_flow/components/step.vue'; +import SprintfWithLinks from 'ee/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue'; import { s__ } from '~/locale'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import { PurchaseEvent } from 'ee/subscriptions/new/constants'; +import { CUSTOMERSDOT_CLIENT } from 'ee/subscriptions/buy_addons_shared/constants'; +import getBillingAccountQuery from 'ee/vue_shared/purchase_flow/graphql/queries/get_billing_account.customer.query.graphql'; +import { logError } from '~/lib/logger'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { helpPagePath } from '~/helpers/help_page_helper'; export default { components: { @@ -22,6 +28,7 @@ export default { GlFormGroup, GlFormInput, GlFormSelect, + SprintfWithLinks, }, directives: { autofocusonshow, @@ -29,9 +36,20 @@ export default { data() { return { countries: [], + billingAccount: null, }; }, apollo: { + billingAccount: { + client: CUSTOMERSDOT_CLIENT, + query: getBillingAccountQuery, + skip() { + return !gon.features?.keyContactsManagement; + }, + error(error) { + this.handleError(error); + }, + }, customer: { query: stateQuery, }, @@ -99,21 +117,35 @@ export default { this.updateState({ customer: { zipCode } }); }, }, + shouldShowManageContacts() { + return Boolean(this.billingAccount?.zuoraAccountName); + }, + stepTitle() { + return this.shouldShowManageContacts + ? this.$options.i18n.contactInformationStepTitle + : this.$options.i18n.billingAddressStepTitle; + }, isStateRequired() { return COUNTRIES_WITH_STATES_REQUIRED.includes(this.customer.country); }, isStateValid() { return this.isStateRequired ? !isEmpty(this.customer.state) : true; }, - isValid() { + areRequiredFieldsValid() { return ( - this.isStateValid && !isEmpty(this.customer.country) && !isEmpty(this.customer.address1) && !isEmpty(this.customer.city) && !isEmpty(this.customer.zipCode) ); }, + isValid() { + if (this.shouldShowManageContacts) { + return true; + } + + return this.isStateValid && this.areRequiredFieldsValid; + }, countryOptionsWithDefault() { return [ { @@ -153,15 +185,27 @@ export default { this.$emit(PurchaseEvent.ERROR, error); }); }, + handleError(error) { + Sentry.captureException(error); + logError(error); + }, }, i18n: { - stepTitle: s__('Checkout|Billing address'), + billingAddressStepTitle: s__('Checkout|Billing address'), + contactInformationStepTitle: s__('Checkout|Contact information'), nextStepButtonText: s__('Checkout|Continue to payment'), countryLabel: s__('Checkout|Country'), streetAddressLabel: s__('Checkout|Street address'), cityLabel: s__('Checkout|City'), stateLabel: s__('Checkout|State'), zipCodeLabel: s__('Checkout|Zip code'), + manageContacts: s__( + 'Checkout|Manage the subscription and billing contacts for your billing account in the %{customersPortalLinkStart}Customers Portal%{customersPortalLinkEnd}. Learn more about %{manageContactsLinkStart}how to manage your contacts%{manageContactsLinkEnd}.', + ), + }, + manageContactsLinkObject: { + customersPortalLink: gon.subscriptions_url, + manageContactsLink: helpPagePath('subscriptions/customers_portal'), }, stepId: STEPS[1].id, }; @@ -170,68 +214,78 @@ export default { <step v-if="!$apollo.loading.customer" :step-id="$options.stepId" - :title="$options.i18n.stepTitle" + :title="stepTitle" :is-valid="isValid" :next-step-button-text="$options.i18n.nextStepButtonText" > <template #body> - <gl-form-group - v-if="!$apollo.loading.countries" - :label="$options.i18n.countryLabel" - label-size="sm" - class="mb-3" - > - <gl-form-select - v-model="countryModel" - v-autofocusonshow - :options="countryOptionsWithDefault" - class="js-country" - value-field="id" - text-field="name" - data-testid="country" - /> - </gl-form-group> - <gl-form-group :label="$options.i18n.streetAddressLabel" label-size="sm" class="mb-3"> - <gl-form-input - v-model="streetAddressLine1Model" - type="text" - data-testid="street-address-1" - /> - <gl-form-input - v-model="streetAddressLine2Model" - type="text" - data-testid="street-address-2" - class="gl-mt-3" + <div v-if="shouldShowManageContacts" class="gl-mb-3"> + <sprintf-with-links + :message="$options.i18n.manageContacts" + :link-object="$options.manageContactsLinkObject" /> - </gl-form-group> - <gl-form-group :label="$options.i18n.cityLabel" label-size="sm" class="mb-3"> - <gl-form-input v-model="cityModel" type="text" data-testid="city" /> - </gl-form-group> - <div class="combined d-flex"> + </div> + <div v-else data-testid="checkout-billing-address-form"> <gl-form-group - v-if="!$apollo.loading.states && states" - :label="$options.i18n.stateLabel" + v-if="!$apollo.loading.countries" + :label="$options.i18n.countryLabel" label-size="sm" - class="mr-3 w-50" + class="mb-3" > <gl-form-select - v-model="countryStateModel" - :options="stateOptionsWithDefault" + v-model="countryModel" + v-autofocusonshow + :options="countryOptionsWithDefault" + class="js-country" value-field="id" text-field="name" - data-testid="state" + data-testid="country" + /> + </gl-form-group> + <gl-form-group :label="$options.i18n.streetAddressLabel" label-size="sm" class="mb-3"> + <gl-form-input + v-model="streetAddressLine1Model" + type="text" + data-testid="street-address-1" + /> + <gl-form-input + v-model="streetAddressLine2Model" + type="text" + data-testid="street-address-2" + class="gl-mt-3" /> </gl-form-group> - <gl-form-group :label="$options.i18n.zipCodeLabel" label-size="sm" class="w-50"> - <gl-form-input v-model="zipCodeModel" type="text" data-testid="zip-code" /> + <gl-form-group :label="$options.i18n.cityLabel" label-size="sm" class="mb-3"> + <gl-form-input v-model="cityModel" type="text" data-testid="city" /> </gl-form-group> + <div class="combined gl-display-flex"> + <gl-form-group + v-if="!$apollo.loading.states && states" + :label="$options.i18n.stateLabel" + label-size="sm" + class="mr-3 w-50" + > + <gl-form-select + v-model="countryStateModel" + :options="stateOptionsWithDefault" + value-field="id" + text-field="name" + data-testid="state" + /> + </gl-form-group> + <gl-form-group :label="$options.i18n.zipCodeLabel" label-size="sm" class="w-50"> + <gl-form-input v-model="zipCodeModel" type="text" data-testid="zip-code" /> + </gl-form-group> + </div> </div> </template> - <template #summary> - <div class="js-summary-line-1">{{ customer.address1 }}</div> - <div class="js-summary-line-2">{{ customer.address2 }}</div> - <div class="js-summary-line-3"> - {{ customer.city }}, {{ customer.country }} {{ selectedStateName }} {{ customer.zipCode }} + <template v-if="!shouldShowManageContacts" #summary> + <div data-testid="checkout-billing-address-summary"> + <div class="js-summary-line-1">{{ customer.address1 }}</div> + <div class="js-summary-line-2">{{ customer.address2 }}</div> + <div class="js-summary-line-3"> + {{ customer.city }}, {{ customer.country }} {{ selectedStateName }} {{ customer.zipCode }} + </div> </div> </template> </step> diff --git a/ee/app/assets/javascripts/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue b/ee/app/assets/javascripts/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue new file mode 100644 index 0000000000000000000000000000000000000000..216ec751802e5481a2e8448cbad60409c5d12776 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue @@ -0,0 +1,32 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlLink, + GlSprintf, + }, + props: { + message: { + type: String, + required: true, + }, + linkObject: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <gl-sprintf :message="message"> + <template v-for="(href, name) in linkObject" #[name]="{ content }"> + <gl-link :key="name" class="gl-text-decoration-none!" :href="href" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </div> +</template> diff --git a/ee/app/assets/javascripts/vue_shared/purchase_flow/graphql/queries/get_billing_account.customer.query.graphql b/ee/app/assets/javascripts/vue_shared/purchase_flow/graphql/queries/get_billing_account.customer.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..06fbe5cbc0b4f1dd8b17e805555a2aba0b3e107d --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/purchase_flow/graphql/queries/get_billing_account.customer.query.graphql @@ -0,0 +1,41 @@ +query getBillingAccount { + billingAccount { + zuoraAccountName + zuoraAccountVatId + vatFieldVisible + billingAccountCustomers { + id + email + firstName + lastName + fullName + alternativeContactFullNames + alternativeContactFirstNames + alternativeContactLastNames + } + soldToContact { + id + firstName + lastName + workEmail + address1 + address2 + city + state + postalCode + country + } + billToContact { + id + firstName + lastName + workEmail + address1 + address2 + city + state + postalCode + country + } + } +} diff --git a/ee/spec/features/registrations/saas/subscription_flow_company_paid_plan_spec.rb b/ee/spec/features/registrations/saas/subscription_flow_company_paid_plan_spec.rb index 00304c6883bbdc640683e6ea0f920d42562a5b3e..62a159584e6b4ee45863acc68e4aa4e84e102c8c 100644 --- a/ee/spec/features/registrations/saas/subscription_flow_company_paid_plan_spec.rb +++ b/ee/spec/features/registrations/saas/subscription_flow_company_paid_plan_spec.rb @@ -12,6 +12,7 @@ with_them do it 'registers the user, processes subscription purchase and creates a group' do + stub_feature_flags(key_contacts_management: false) sign_up_method.call expect_to_see_subscription_welcome_form diff --git a/ee/spec/features/registrations/saas/subscription_flow_just_me_paid_plan_spec.rb b/ee/spec/features/registrations/saas/subscription_flow_just_me_paid_plan_spec.rb index 438c4c26732d56a13c09d23e19db4d2bda6ac52f..7299d64a47fb27e6a69492930a615d8dbfcecddd 100644 --- a/ee/spec/features/registrations/saas/subscription_flow_just_me_paid_plan_spec.rb +++ b/ee/spec/features/registrations/saas/subscription_flow_just_me_paid_plan_spec.rb @@ -12,6 +12,7 @@ with_them do it 'registers the user, processes subscription purchase and creates a group' do + stub_feature_flags(key_contacts_management: false) sign_up_method.call expect_to_see_subscription_welcome_form diff --git a/ee/spec/features/subscriptions/subscription_flow_for_existing_user_with_eligible_group_spec.rb b/ee/spec/features/subscriptions/subscription_flow_for_existing_user_with_eligible_group_spec.rb index 4a3eccc7c0d6c4be45b0966b6c1afedf3a34e4f0..2a5ab3417c246b6d6a10e8a22122ded4ea770a2d 100644 --- a/ee/spec/features/subscriptions/subscription_flow_for_existing_user_with_eligible_group_spec.rb +++ b/ee/spec/features/subscriptions/subscription_flow_for_existing_user_with_eligible_group_spec.rb @@ -16,6 +16,7 @@ stub_eligible_namespaces stub_billing_plans(nil) stub_invoice_preview('null', premium_plan[:id]) + stub_feature_flags(key_contacts_management: false) sign_in(user) end diff --git a/ee/spec/frontend/subscriptions/mock_data.js b/ee/spec/frontend/subscriptions/mock_data.js index c804e6a63f6a00a03b288f5ad67be8c3b862b5a8..219a56f7315632138e350ce611f16c8b4a6bfc01 100644 --- a/ee/spec/frontend/subscriptions/mock_data.js +++ b/ee/spec/frontend/subscriptions/mock_data.js @@ -213,3 +213,43 @@ export const mockInvoicePreviewWithoutPromoOffer = { }, }, }; + +export const mockBillingAccount = { + zuoraAccountName: 'Day Off LLC', + zuoraAccountVatId: 1234, + vatFieldVisible: 'true', + billingAccountCustomers: { + id: 1234, + email: 'day@off.com', + firstName: 'Ferris', + lastName: 'Bueller', + fullName: 'Ferris Bueller', + alternativeContactFullNames: [], + alternativeContactFirstNames: [], + alternativeContactLastNames: [], + }, + soldToContact: { + id: 5678, + firstName: 'Jeanie', + lastName: 'Bueller', + workEmail: 'jeanie@dayoff.com', + address1: '123 Green St', + address2: '', + city: 'Chicago', + state: 'IL', + postalCode: 99999, + country: 'USA', + }, + billToContact: { + id: 5678, + firstName: 'Jeanie', + lastName: 'Bueller', + workEmail: 'jeanie@dayoff.com', + address1: '123 Green St', + address2: '', + city: 'Chicago', + state: 'IL', + postalCode: 99999, + country: 'USA', + }, +}; diff --git a/ee/spec/frontend/subscriptions/new/components/checkout/billing_address_spec.js b/ee/spec/frontend/subscriptions/new/components/checkout/billing_address_spec.js index 772b6bd5dc80b70b35a97ec71fe053df6295b51c..38cd6115279624ee7ef557fc6663542c5e54ad1d 100644 --- a/ee/spec/frontend/subscriptions/new/components/checkout/billing_address_spec.js +++ b/ee/spec/frontend/subscriptions/new/components/checkout/billing_address_spec.js @@ -1,9 +1,9 @@ -import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; +import getBillingAccountQuery from 'ee/vue_shared/purchase_flow/graphql/queries/get_billing_account.customer.query.graphql'; import { mockTracking } from 'helpers/tracking_helper'; import { STEPS } from 'ee/subscriptions/constants'; import BillingAddress from 'ee/subscriptions/new/components/checkout/billing_address.vue'; @@ -12,9 +12,18 @@ import * as types from 'ee/subscriptions/new/store/mutation_types'; import Step from 'ee/vue_shared/purchase_flow/components/step.vue'; import activateNextStepMutation from 'ee/vue_shared/purchase_flow/graphql/mutations/activate_next_step.mutation.graphql'; import { createMockApolloProvider } from 'ee_jest/vue_shared/purchase_flow/spec_helper'; +import SprintfWithLinks from 'ee/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue'; +import { mockBillingAccount } from 'ee_jest/subscriptions/mock_data'; +import { CUSTOMERSDOT_CLIENT } from 'ee/subscriptions/buy_addons_shared/constants'; +import { createMockClient } from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { logError } from '~/lib/logger'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; Vue.use(Vuex); Vue.use(VueApollo); +jest.mock('~/lib/logger'); describe('Billing Address', () => { let store; @@ -41,20 +50,159 @@ describe('Billing Address', () => { } function createComponent(options = {}) { - return mount(BillingAddress, { + return mountExtended(BillingAddress, { ...options, }); } + // Sets up all required fields + const setupValidForm = () => { + store.commit(types.UPDATE_COUNTRY, 'country'); + store.commit(types.UPDATE_STREET_ADDRESS_LINE_ONE, 'address line 1'); + store.commit(types.UPDATE_CITY, 'city'); + store.commit(types.UPDATE_ZIP_CODE, 'zip'); + }; + + // Sets up all fields in the form group + const setupAllFormFields = () => { + setupValidForm(); + store.commit(types.UPDATE_STREET_ADDRESS_LINE_TWO, 'address line 2'); + store.commit(types.UPDATE_COUNTRY_STATE, 'state'); + }; + + const findStep = () => wrapper.findComponent(Step); + const findManageContacts = () => wrapper.findComponent(SprintfWithLinks); + const findAddressForm = () => wrapper.findByTestId('checkout-billing-address-form'); + const findAddressSummary = () => wrapper.findByTestId('checkout-billing-address-summary'); + beforeEach(() => { store = createStore(); mockApolloProvider = createMockApolloProvider(STEPS); - wrapper = createComponent({ store, apolloProvider: mockApolloProvider }); + mockApolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([ + [getBillingAccountQuery, jest.fn().mockResolvedValue({ data: { billingAccount: null } })], + ]); }); describe('mounted', () => { - it('should load the countries', () => { - expect(actionMocks.fetchCountries).toHaveBeenCalled(); + describe('when keyContactsManagement flag is true', () => { + beforeEach(() => { + gon.features = { keyContactsManagement: true }; + }); + + describe.each` + billingAccountExists | billingAccountData | stepTitle | showAddress + ${true} | ${mockBillingAccount} | ${'Contact information'} | ${false} + ${false} | ${null} | ${'Billing address'} | ${true} + `( + 'when billingAccount exists is $billingAccountExists', + ({ billingAccountData, stepTitle, showAddress }) => { + beforeEach(async () => { + mockApolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([ + [ + getBillingAccountQuery, + jest.fn().mockResolvedValue({ data: { billingAccount: billingAccountData } }), + ], + ]); + + wrapper = createComponent({ store, apolloProvider: mockApolloProvider }); + await waitForPromises(); + }); + + it('should load the countries', () => { + expect(actionMocks.fetchCountries).toHaveBeenCalled(); + }); + + it('shows step component', () => { + expect(findStep().exists()).toBe(true); + }); + + it('passes correct step title', () => { + expect(findStep().props('title')).toEqual(stepTitle); + }); + + it(`${showAddress ? 'shows' : 'does not show'} address form`, () => { + expect(findAddressForm().exists()).toBe(showAddress); + }); + + it(`${showAddress ? 'does not show' : 'shows'} manage contact message`, () => { + expect(findManageContacts().exists()).toBe(!showAddress); + }); + }, + ); + }); + describe('when keyContactsManagement flag is false', () => { + beforeEach(() => { + gon.features = { keyContactsManagement: false }; + }); + + describe.each` + billingAccountExists | billingAccountData | stepTitle | showAddress + ${true} | ${mockBillingAccount} | ${'Billing address'} | ${true} + ${false} | ${null} | ${'Billing address'} | ${true} + `( + 'when billingAccount exists is $billingAccountExists', + ({ billingAccountData, stepTitle, showAddress }) => { + beforeEach(async () => { + mockApolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([ + [ + getBillingAccountQuery, + jest.fn().mockResolvedValue({ data: { billingAccount: billingAccountData } }), + ], + ]); + + wrapper = createComponent({ store, apolloProvider: mockApolloProvider }); + await waitForPromises(); + }); + + it('should load the countries', () => { + expect(actionMocks.fetchCountries).toHaveBeenCalled(); + }); + + it('shows step component', () => { + expect(findStep().exists()).toBe(true); + }); + + it('passes correct step title', () => { + expect(findStep().props('title')).toEqual(stepTitle); + }); + + it(`${showAddress ? 'shows' : 'does not show'} address form`, () => { + expect(findAddressForm().exists()).toBe(showAddress); + }); + + it(`${showAddress ? 'does not show' : 'shows'} manage contact message`, () => { + expect(findManageContacts().exists()).toBe(!showAddress); + }); + }, + ); + }); + }); + + describe('manage contacts', () => { + beforeEach(async () => { + gon.features = { keyContactsManagement: true }; + mockApolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([ + [ + getBillingAccountQuery, + jest.fn().mockResolvedValue({ data: { billingAccount: mockBillingAccount } }), + ], + ]); + + wrapper = createComponent({ store, apolloProvider: mockApolloProvider }); + await waitForPromises(); + }); + + it('shows correct message', () => { + expect(findManageContacts().props('message')).toEqual( + 'Manage the subscription and billing contacts for your billing account in the %{customersPortalLinkStart}Customers Portal%{customersPortalLinkEnd}. Learn more about %{manageContactsLinkStart}how to manage your contacts%{manageContactsLinkEnd}.', + ); + }); + + it('renders correct number of links', () => { + expect(findManageContacts().props('linkObject')).toMatchObject({ + customersPortalLink: gon.subscriptions_url, + manageContactsLink: '/help/subscriptions/customers_portal', + }); }); }); @@ -62,9 +210,14 @@ describe('Billing Address', () => { const countrySelect = () => wrapper.find('.js-country'); beforeEach(() => { + wrapper = createComponent({ store, apolloProvider: mockApolloProvider }); store.commit(types.UPDATE_COUNTRY_OPTIONS, [{ text: 'Netherlands', value: 'NL' }]); }); + it('render', () => { + expect(countrySelect().exists()).toBe(true); + }); + it('should display the select prompt', () => { expect(countrySelect().html()).toContain('<option value="">Select a country</option>'); }); @@ -83,6 +236,7 @@ describe('Billing Address', () => { describe('tracking', () => { beforeEach(() => { + wrapper = createComponent({ store, apolloProvider: mockApolloProvider }); store.commit(types.UPDATE_COUNTRY, 'US'); store.commit(types.UPDATE_ZIP_CODE, '10467'); store.commit(types.UPDATE_COUNTRY_STATE, 'NY'); @@ -123,87 +277,215 @@ describe('Billing Address', () => { }); }); - describe('validations', () => { - const isStepValid = () => wrapper.findComponent(Step).props('isValid'); + describe('when validating', () => { + const isStepValid = () => findStep().props('isValid'); - beforeEach(() => { - store.commit(types.UPDATE_COUNTRY, 'country'); - store.commit(types.UPDATE_STREET_ADDRESS_LINE_ONE, 'address line 1'); - store.commit(types.UPDATE_CITY, 'city'); - store.commit(types.UPDATE_ZIP_CODE, 'zip'); - }); + describe('with a billing account', () => { + beforeEach(async () => { + gon.features = { keyContactsManagement: true }; + mockApolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([ + [ + getBillingAccountQuery, + jest.fn().mockResolvedValue({ data: { billingAccount: mockBillingAccount } }), + ], + ]); - it('should be valid when country, streetAddressLine1, city and zipCode have been entered', () => { - expect(isStepValid()).toBe(true); - }); + wrapper = createComponent({ store, apolloProvider: mockApolloProvider }); - it('should be invalid when country is undefined', async () => { - store.commit(types.UPDATE_COUNTRY, null); - await nextTick(); + await waitForPromises(); + setupValidForm(); + }); - expect(isStepValid()).toBe(false); + it.each` + caseName | commitFn + ${'country is null'} | ${() => store.commit(types.UPDATE_COUNTRY, null)} + ${'state is null for country that requires state'} | ${() => { + store.commit(types.UPDATE_COUNTRY, 'US'); + store.commit(types.UPDATE_COUNTRY_STATE, null); +}} + ${'when streetAddressLine1 is null'} | ${() => store.commit(types.UPDATE_STREET_ADDRESS_LINE_ONE, null)} + ${'when zipcode is null'} | ${() => store.commit(types.UPDATE_ZIP_CODE, null)} + ${'when city is null'} | ${() => store.commit(types.UPDATE_CITY, null)} + `('passes true isValid prop when $caseName', async ({ commitFn }) => { + commitFn(); + await nextTick(); + + expect(isStepValid()).toBe(true); + }); }); - it('should be invalid when state is undefined for countries that require state', async () => { - store.commit(types.UPDATE_COUNTRY, 'US'); - store.commit(types.UPDATE_COUNTRY_STATE, null); - await nextTick(); + describe('without a billing account', () => { + beforeEach(() => { + wrapper = createComponent({ store, apolloProvider: mockApolloProvider }); + setupValidForm(); + }); - expect(isStepValid()).toBe(false); - }); + it('should be valid when country, streetAddressLine1, city and zipCode have been entered', () => { + expect(isStepValid()).toBe(true); + }); - it(`should be valid when state is undefined for countries that don't require state`, async () => { - store.commit(types.UPDATE_COUNTRY, 'NZL'); - store.commit(types.UPDATE_COUNTRY_STATE, null); - await nextTick(); + it('should be invalid when country is undefined', async () => { + store.commit(types.UPDATE_COUNTRY, null); + await nextTick(); - expect(isStepValid()).toBe(true); + expect(isStepValid()).toBe(false); + }); + + it('should be invalid when state is undefined for countries that require state', async () => { + store.commit(types.UPDATE_COUNTRY, 'US'); + store.commit(types.UPDATE_COUNTRY_STATE, null); + await nextTick(); + + expect(isStepValid()).toBe(false); + }); + + it(`should be valid when state is undefined for countries that don't require state`, async () => { + store.commit(types.UPDATE_COUNTRY, 'NZL'); + store.commit(types.UPDATE_COUNTRY_STATE, null); + await nextTick(); + + expect(isStepValid()).toBe(true); + }); + + it('should be invalid when streetAddressLine1 is undefined', async () => { + store.commit(types.UPDATE_STREET_ADDRESS_LINE_ONE, null); + await nextTick(); + + expect(isStepValid()).toBe(false); + }); + + it('should be invalid when city is undefined', async () => { + store.commit(types.UPDATE_CITY, null); + await nextTick(); + + expect(isStepValid()).toBe(false); + }); + + it('should be invalid when zipCode is undefined', async () => { + store.commit(types.UPDATE_ZIP_CODE, null); + await nextTick(); + + expect(isStepValid()).toBe(false); + }); }); + }); - it('should be invalid when streetAddressLine1 is undefined', async () => { - store.commit(types.UPDATE_STREET_ADDRESS_LINE_ONE, null); - await nextTick(); + describe('summary', () => { + describe('when keyContactsManagement flag is true', () => { + beforeEach(() => { + gon.features = { keyContactsManagement: true }; + }); - expect(isStepValid()).toBe(false); + describe.each` + billingAccountExists | billingAccountData | showSummary + ${true} | ${mockBillingAccount} | ${false} + ${true} | ${null} | ${true} + ${false} | ${null} | ${true} + `( + 'when billingAccount exists is $billingAccountExists', + ({ billingAccountData, showSummary }) => { + beforeEach(async () => { + mockApolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([ + [ + getBillingAccountQuery, + jest.fn().mockResolvedValue({ data: { billingAccount: billingAccountData } }), + ], + ]); + + wrapper = createComponent({ store, apolloProvider: mockApolloProvider }); + + await waitForPromises(); + + setupAllFormFields(); + await activateNextStep(); + await activateNextStep(); + }); + + it(`${showSummary ? 'renders' : 'does not render'}`, () => { + expect(findAddressSummary().exists()).toBe(showSummary); + }); + }, + ); }); - it('should be invalid when city is undefined', async () => { - store.commit(types.UPDATE_CITY, null); - await nextTick(); + describe('when keyContactsManagement flag is false', () => { + beforeEach(() => { + gon.features = { keyContactsManagement: false }; + }); - expect(isStepValid()).toBe(false); + describe.each` + billingAccountExists | billingAccountData + ${true} | ${mockBillingAccount} + ${true} | ${null} + ${false} | ${null} + ${false} | ${mockBillingAccount} + `('when billingAccount exists is $billingAccountExists', ({ billingAccountData }) => { + beforeEach(async () => { + mockApolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([ + [ + getBillingAccountQuery, + jest.fn().mockResolvedValue({ data: { billingAccount: billingAccountData } }), + ], + ]); + + wrapper = createComponent({ store, apolloProvider: mockApolloProvider }); + + await waitForPromises(); + + setupAllFormFields(); + await activateNextStep(); + await activateNextStep(); + }); + + it('shows address summary', () => { + expect(findAddressSummary().exists()).toBe(true); + }); + }); }); - it('should be invalid when zipCode is undefined', async () => { - store.commit(types.UPDATE_ZIP_CODE, null); - await nextTick(); + describe('without a billing account', () => { + beforeEach(async () => { + wrapper = createComponent({ store, apolloProvider: mockApolloProvider }); + setupAllFormFields(); + await activateNextStep(); + await activateNextStep(); + }); - expect(isStepValid()).toBe(false); + it('should show the entered address line 1', () => { + expect(wrapper.find('.js-summary-line-1').text()).toEqual('address line 1'); + }); + + it('should show the entered address line 2', () => { + expect(wrapper.find('.js-summary-line-2').text()).toEqual('address line 2'); + }); + + it('should show the entered address city, state and zip code', () => { + expect(wrapper.find('.js-summary-line-3').text()).toEqual('city, state zip'); + }); }); }); - describe('showing the summary', () => { + describe('when getBillingAccountQuery responds with error', () => { + const error = new Error('oh no!'); + beforeEach(async () => { - store.commit(types.UPDATE_COUNTRY, 'country'); - store.commit(types.UPDATE_STREET_ADDRESS_LINE_ONE, 'address line 1'); - store.commit(types.UPDATE_STREET_ADDRESS_LINE_TWO, 'address line 2'); - store.commit(types.UPDATE_COUNTRY_STATE, 'state'); - store.commit(types.UPDATE_CITY, 'city'); - store.commit(types.UPDATE_ZIP_CODE, 'zip'); - await activateNextStep(); - await activateNextStep(); - }); + gon.features = { keyContactsManagement: true }; + jest.spyOn(Sentry, 'captureException'); + + mockApolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([ + [getBillingAccountQuery, jest.fn().mockRejectedValue(error)], + ]); - it('should show the entered address line 1', () => { - expect(wrapper.find('.js-summary-line-1').text()).toEqual('address line 1'); + wrapper = createComponent({ store, apolloProvider: mockApolloProvider }); + await waitForPromises(); }); - it('should show the entered address line 2', () => { - expect(wrapper.find('.js-summary-line-2').text()).toEqual('address line 2'); + it('logs to Sentry', () => { + expect(Sentry.captureException).toHaveBeenCalledWith(error); }); - it('should show the entered address city, state and zip code', () => { - expect(wrapper.find('.js-summary-line-3').text()).toEqual('city, state zip'); + it('logs the error to console', () => { + expect(logError).toHaveBeenCalledWith(error); }); }); }); diff --git a/ee/spec/frontend/vue_shared/purchase_flow/components/checkout/billing_address_spec.js b/ee/spec/frontend/vue_shared/purchase_flow/components/checkout/billing_address_spec.js index bcf608c821a508df8c20dfc4be0a43ff21b613b3..426d0f90336595ba41267d62e41b9445de0bb659 100644 --- a/ee/spec/frontend/vue_shared/purchase_flow/components/checkout/billing_address_spec.js +++ b/ee/spec/frontend/vue_shared/purchase_flow/components/checkout/billing_address_spec.js @@ -1,26 +1,42 @@ import Vue from 'vue'; import { merge } from 'lodash'; import VueApollo from 'vue-apollo'; +import getBillingAccountQuery from 'ee/vue_shared/purchase_flow/graphql/queries/get_billing_account.customer.query.graphql'; import { gitLabResolvers } from 'ee/subscriptions/buy_addons_shared/graphql/resolvers'; import { STEPS } from 'ee/subscriptions/constants'; import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql'; import BillingAddress from 'ee/vue_shared/purchase_flow/components/checkout/billing_address.vue'; +import SprintfWithLinks from 'ee/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue'; import Step from 'ee/vue_shared/purchase_flow/components/step.vue'; -import { stateData as initialStateData } from 'ee_jest/subscriptions/mock_data'; +import { mockBillingAccount, stateData as initialStateData } from 'ee_jest/subscriptions/mock_data'; import { createMockApolloProvider } from 'ee_jest/vue_shared/purchase_flow/spec_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { PurchaseEvent } from 'ee/subscriptions/new/constants'; +import { CUSTOMERSDOT_CLIENT } from 'ee/subscriptions/buy_addons_shared/constants'; +import { createMockClient } from 'helpers/mock_apollo_helper'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { logError } from '~/lib/logger'; Vue.use(VueApollo); +jest.mock('~/lib/logger'); describe('Billing Address', () => { let wrapper; let updateState = jest.fn(); + let apolloProvider; const findCountrySelect = () => wrapper.findByTestId('country'); - const createComponent = (apolloLocalState = {}) => { + const findStep = () => wrapper.findComponent(Step); + const findManageContacts = () => wrapper.findComponent(SprintfWithLinks); + const findAddressForm = () => wrapper.findByTestId('checkout-billing-address-form'); + const findAddressSummary = () => wrapper.findByTestId('checkout-billing-address-summary'); + + const createComponent = async ( + apolloLocalStateData = {}, + billingAccountFn = jest.fn().mockResolvedValue({ data: { billingAccount: null } }), + ) => { const apolloResolvers = { Query: { countries: jest.fn().mockResolvedValue([ @@ -32,27 +48,128 @@ describe('Billing Address', () => { Mutation: { updateState }, }; - const apolloProvider = createMockApolloProvider(STEPS, STEPS[1], { + apolloProvider = createMockApolloProvider(STEPS, STEPS[1], { ...gitLabResolvers, ...apolloResolvers, }); apolloProvider.clients.defaultClient.cache.writeQuery({ query: stateQuery, - data: merge({}, initialStateData, apolloLocalState), + data: merge({}, initialStateData, apolloLocalStateData), }); + apolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([ + [getBillingAccountQuery, billingAccountFn], + ]); wrapper = mountExtended(BillingAddress, { apolloProvider, }); + + await waitForPromises(); }; + describe('with keyContactsManagement flag true', () => { + beforeEach(() => { + gon.features = { keyContactsManagement: true }; + }); + describe.each` + billingAccountExists | billingAccountData | stepTitle | showAddress + ${true} | ${mockBillingAccount} | ${'Contact information'} | ${false} + ${false} | ${null} | ${'Billing address'} | ${true} + `( + 'when billingAccount exists is $billingAccountExists', + ({ billingAccountData, stepTitle, showAddress }) => { + beforeEach(async () => { + await createComponent( + {}, + jest.fn().mockResolvedValue({ data: { billingAccount: billingAccountData } }), + ); + }); + + it('shows step component', () => { + expect(findStep().exists()).toBe(true); + }); + + it('passes correct step title', () => { + expect(findStep().props('title')).toEqual(stepTitle); + }); + + it(`${showAddress ? 'shows' : 'does not show'} address form`, () => { + expect(findAddressForm().exists()).toBe(showAddress); + }); + + it(`${showAddress ? 'does not show' : 'shows'} manage contact message`, () => { + expect(findManageContacts().exists()).toBe(!showAddress); + }); + }, + ); + }); + describe('with keyContactsManagement flag false', () => { + beforeEach(() => { + gon.features = { keyContactsManagement: false }; + }); + + describe.each` + billingAccountExists | billingAccountData | stepTitle | showAddress + ${true} | ${mockBillingAccount} | ${'Billing address'} | ${true} + ${false} | ${null} | ${'Billing address'} | ${true} + `( + 'when billingAccount exists is $billingAccountExists', + ({ billingAccountData, stepTitle, showAddress }) => { + beforeEach(async () => { + await createComponent( + {}, + jest.fn().mockResolvedValue({ data: { billingAccount: billingAccountData } }), + ); + }); + + it('shows step component', () => { + expect(findStep().exists()).toBe(true); + }); + + it('passes correct step title', () => { + expect(findStep().props('title')).toEqual(stepTitle); + }); + + it(`${showAddress ? 'shows' : 'does not show'} address form`, () => { + expect(findAddressForm().exists()).toBe(showAddress); + }); + + it(`${showAddress ? 'does not show' : 'shows'} manage contact message`, () => { + expect(findManageContacts().exists()).toBe(!showAddress); + }); + }, + ); + }); + + describe('manage contacts', () => { + beforeEach(async () => { + gon.features = { keyContactsManagement: true }; + + await createComponent( + {}, + jest.fn().mockResolvedValue({ data: { billingAccount: mockBillingAccount } }), + ); + }); + + it('shows correct message', () => { + expect(findManageContacts().props('message')).toEqual( + 'Manage the subscription and billing contacts for your billing account in the %{customersPortalLinkStart}Customers Portal%{customersPortalLinkEnd}. Learn more about %{manageContactsLinkStart}how to manage your contacts%{manageContactsLinkEnd}.', + ); + }); + + it('renders correct number of links', () => { + expect(findManageContacts().props('linkObject')).toMatchObject({ + customersPortalLink: gon.subscriptions_url, + manageContactsLink: '/help/subscriptions/customers_portal', + }); + }); + }); + describe('country options', () => { const countrySelect = () => wrapper.find('.js-country'); - beforeEach(() => { - createComponent(); - - return waitForPromises(); + beforeEach(async () => { + await createComponent(); }); it('displays the countries returned from the server', () => { @@ -71,115 +188,204 @@ describe('Billing Address', () => { state: null, }; - it('is valid when country, streetAddressLine1, city and zipCode have been entered', async () => { - createComponent({ customer: customerData }); - - await waitForPromises(); - - expect(isStepValid()).toBe(true); - }); - - it('is invalid when country is undefined', async () => { - createComponent({ customer: { ...customerData, country: null } }); - - await waitForPromises(); - - expect(isStepValid()).toBe(false); + describe('with a billing account', () => { + it.each` + caseName | addressData + ${'country is null'} | ${{ country: null }} + ${'when streetAddressLine1 is null'} | ${{ address1: null }} + ${'when city is null'} | ${{ city: null }} + ${'when zipcode is null'} | ${{ zipCode: null }} + ${'state is null for country that requires state'} | ${{ country: 'US' }} + `('passes true isValid prop when $caseName', async ({ addressData }) => { + await createComponent({ customer: { ...customerData, addressData } }); + + expect(isStepValid()).toBe(true); + }); }); - it('is invalid when streetAddressLine1 is undefined', async () => { - createComponent({ customer: { ...customerData, address1: null } }); - - await waitForPromises(); - - expect(isStepValid()).toBe(false); - }); + describe('without a billing account', () => { + it('is valid when country, streetAddressLine1, city and zipCode have been entered', async () => { + await createComponent({ customer: customerData }); - it('is invalid when city is undefined', async () => { - createComponent({ customer: { ...customerData, city: null } }); + expect(isStepValid()).toBe(true); + }); - await waitForPromises(); + it('is invalid when country is undefined', async () => { + await createComponent({ customer: { ...customerData, country: null } }); - expect(isStepValid()).toBe(false); - }); + expect(isStepValid()).toBe(false); + }); - it('is invalid when zipCode is undefined', async () => { - createComponent({ customer: { ...customerData, zipCode: null } }); + it('is invalid when streetAddressLine1 is undefined', async () => { + await createComponent({ customer: { ...customerData, address1: null } }); - await waitForPromises(); + expect(isStepValid()).toBe(false); + }); - expect(isStepValid()).toBe(false); - }); + it('is invalid when city is undefined', async () => { + await createComponent({ customer: { ...customerData, city: null } }); - it('is invalid when state is undefined for countries that require state', async () => { - createComponent({ customer: { ...customerData, country: 'US' } }); + expect(isStepValid()).toBe(false); + }); - await waitForPromises(); + it('is invalid when zipCode is undefined', async () => { + await createComponent({ customer: { ...customerData, zipCode: null } }); - expect(isStepValid()).toBe(false); - }); + expect(isStepValid()).toBe(false); + }); - it(`is valid when state is undefined for countries that don't require state`, async () => { - createComponent({ customer: { ...customerData, country: 'NL' } }); + it('is invalid when state is undefined for countries that require state', async () => { + await createComponent({ customer: { ...customerData, country: 'US' } }); - await waitForPromises(); + expect(isStepValid()).toBe(false); + }); - expect(isStepValid()).toBe(true); - }); + it(`is valid when state is undefined for countries that don't require state`, async () => { + await createComponent({ customer: { ...customerData, country: 'NL' } }); - it(`is valid when state exists for countries that require state`, async () => { - createComponent({ customer: { ...customerData, country: 'US', state: 'CA' } }); + expect(isStepValid()).toBe(true); + }); - await waitForPromises(); + it(`is valid when state exists for countries that require state`, async () => { + await createComponent({ customer: { ...customerData, country: 'US', state: 'CA' } }); - expect(isStepValid()).toBe(true); + expect(isStepValid()).toBe(true); + }); }); }); - describe('showing the summary', () => { - beforeEach(() => { - createComponent({ - customer: { - country: 'US', - address1: 'address line 1', - address2: 'address line 2', - city: 'city', - zipCode: 'zip', - state: 'CA', + describe('summary', () => { + describe('when keyContactsManagement flag is true', () => { + beforeEach(() => { + gon.features = { keyContactsManagement: true }; + }); + describe.each` + billingAccountExists | billingAccountData | showSummary + ${true} | ${mockBillingAccount} | ${false} + ${false} | ${null} | ${true} + `( + 'when billingAccount exists is $billingAccountExists', + ({ billingAccountData, showSummary }) => { + beforeEach(async () => { + await createComponent( + { + customer: { + country: 'US', + address1: 'address line 1', + address2: 'address line 2', + city: 'city', + zipCode: 'zip', + state: 'CA', + }, + }, + jest.fn().mockResolvedValue({ data: { billingAccount: billingAccountData } }), + ); + }); + + it(`${showSummary ? 'renders' : 'does not render'}`, () => { + expect(findAddressSummary().exists()).toBe(showSummary); + }); }, + ); + }); + describe('when keyContactsManagement flag is false', () => { + beforeEach(() => { + gon.features = { keyContactsManagement: false }; }); - - return waitForPromises(); + describe.each` + billingAccountExists | billingAccountData | showSummary + ${true} | ${mockBillingAccount} | ${true} + ${false} | ${null} | ${true} + `( + 'when billingAccount exists is $billingAccountExists', + ({ billingAccountData, showSummary }) => { + beforeEach(async () => { + await createComponent( + { + customer: { + country: 'US', + address1: 'address line 1', + address2: 'address line 2', + city: 'city', + zipCode: 'zip', + state: 'CA', + }, + }, + jest.fn().mockResolvedValue({ data: { billingAccount: billingAccountData } }), + ); + }); + + it(`${showSummary ? 'renders' : 'does not render'}`, () => { + expect(findAddressSummary().exists()).toBe(showSummary); + }); + }, + ); }); - it('should show the entered address line 1', () => { - expect(wrapper.find('.js-summary-line-1').text()).toBe('address line 1'); - }); + describe('without billing account', () => { + beforeEach(async () => { + await createComponent({ + customer: { + country: 'US', + address1: 'address line 1', + address2: 'address line 2', + city: 'city', + zipCode: 'zip', + state: 'CA', + }, + }); + }); - it('should show the entered address line 2', () => { - expect(wrapper.find('.js-summary-line-2').text()).toBe('address line 2'); - }); + it('should show the entered address line 1', () => { + expect(wrapper.find('.js-summary-line-1').text()).toBe('address line 1'); + }); - it('should show the entered address city, state and zip code', () => { - expect(wrapper.find('.js-summary-line-3').text()).toBe('city, US California zip'); + it('should show the entered address line 2', () => { + expect(wrapper.find('.js-summary-line-2').text()).toBe('address line 2'); + }); + + it('should show the entered address city, state and zip code', () => { + expect(wrapper.find('.js-summary-line-3').text()).toBe('city, US California zip'); + }); }); }); describe('when the mutation fails', () => { const error = new Error('Yikes!'); - beforeEach(() => { + beforeEach(async () => { updateState = jest.fn().mockRejectedValue(error); - createComponent({ + await createComponent({ customer: { country: 'US' }, }); + }); + + it('emits an error', async () => { findCountrySelect().vm.$emit('input', 'IT'); - return waitForPromises(); - }); + await waitForPromises(); - it('emits an error', () => { expect(wrapper.emitted(PurchaseEvent.ERROR)).toEqual([[error]]); }); }); + + describe('when getBillingAccountQuery responds with error', () => { + const error = new Error('oh no!'); + + beforeEach(async () => { + gon.features = { keyContactsManagement: true }; + jest.spyOn(Sentry, 'captureException'); + + wrapper = await createComponent({}, jest.fn().mockRejectedValue(error)); + await waitForPromises(); + }); + + it('logs to Sentry', () => { + expect(Sentry.captureException).toHaveBeenCalledWith(error); + }); + + it('logs the error to console', () => { + expect(logError).toHaveBeenCalledWith(error); + }); + }); }); diff --git a/ee/spec/frontend/vue_shared/purchase_flow/components/checkout/sprintf_with_links_spec.js b/ee/spec/frontend/vue_shared/purchase_flow/components/checkout/sprintf_with_links_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..cec9c8dbf3f4d26ff2b7b31545a7cf03e844c660 --- /dev/null +++ b/ee/spec/frontend/vue_shared/purchase_flow/components/checkout/sprintf_with_links_spec.js @@ -0,0 +1,69 @@ +import { mount } from '@vue/test-utils'; +import { GlLink, GlSprintf } from '@gitlab/ui'; +import SprintfWithLinks from 'ee/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue'; + +describe('SprintfWithLinks', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + return mount(SprintfWithLinks, { propsData }); + }; + + const findSprintf = () => wrapper.findComponent(GlSprintf); + const findLinks = () => wrapper.findAllComponents(GlLink); + + const initialLinkObject = { firstLink: 'hitchhikersguide.org', lifeLink: '42.com' }; + + describe('with links present in linkObject', () => { + beforeEach(() => { + wrapper = createComponent({ + message: + 'Go to %{firstLinkStart}this link%{firstLinkEnd} for the answer to %{lifeLinkStart}life%{lifeLinkEnd}', + linkObject: initialLinkObject, + }); + }); + + it('shows correct message', () => { + expect(findSprintf().text()).toEqual('Go to'); + }); + + it('renders correct number of links', () => { + expect(findLinks()).toHaveLength(2); + }); + + it('renders correct content in first link', () => { + expect(findLinks().at(0).text()).toBe('this link'); + expect(findLinks().at(0).attributes('href')).toEqual(initialLinkObject.firstLink); + }); + + it('renders correct content in second link', () => { + expect(findLinks().at(1).text()).toBe('life'); + expect(findLinks().at(1).attributes('href')).toEqual(initialLinkObject.lifeLink); + }); + }); + + describe('with links not present in linkObject', () => { + beforeEach(() => { + wrapper = createComponent({ + message: + '%{towelLinkStart}A towel%{towelLinkEnd}, it says, is about the most massively useful thing an %{firstLinkStart}interstellar hitchhiker%{firstLinkEnd} can have', + linkObject: initialLinkObject, + }); + }); + + it('shows correct message', () => { + expect(findSprintf().text()).toEqual( + '%{towelLinkStart}A towel%{towelLinkEnd}, it says, is about the most massively useful thing an', + ); + }); + + it('renders correct number of links', () => { + expect(findLinks()).toHaveLength(1); + }); + + it('renders correct content', () => { + expect(findLinks().at(0).text()).toBe('interstellar hitchhiker'); + expect(findLinks().at(0).attributes('href')).toEqual(initialLinkObject.firstLink); + }); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d4a4fd6fd7a60764631d1c659af46d2429841d9a..79a1381ec463a18571a4eca8a7526e519d5b770e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -10073,6 +10073,9 @@ msgstr "" msgid "Checkout|Confirming..." msgstr "" +msgid "Checkout|Contact information" +msgstr "" + msgid "Checkout|Continue to billing" msgstr "" @@ -10133,6 +10136,9 @@ msgstr "" msgid "Checkout|Invalid coupon code. Enter a valid coupon code." msgstr "" +msgid "Checkout|Manage the subscription and billing contacts for your billing account in the %{customersPortalLinkStart}Customers Portal%{customersPortalLinkEnd}. Learn more about %{manageContactsLinkStart}how to manage your contacts%{manageContactsLinkEnd}." +msgstr "" + msgid "Checkout|Must be %{minimumNumberOfUsers} (your seats in use) or more." msgstr ""