Commit 88222f8b authored by Sam Figueroa's avatar Sam Figueroa

Add instrumentation to SaaS New Subscription purchase flow

- Track tax link in order summary

- Track render of SaaS Checkout page

- Add the body data to the checkout layout so it has the proper
  context (category) when triggering the tracking in snowplow.

- Track render of Billing page

- Track clicks on edit steps in checkout

- Fix naming of snakeCasedStep since what was called snakeCasedStep was
  actually dash-case/kebab-case.

- Track subscriptionsDetails events on form transition

- Track billingDetails events on form transition

- Track paymentMethod events on form transition

- Track Confirm Purchase

- Zuora iframe will likely not respond with
  error messages unless something goes very wrong.
  Since all card related errors are processed
  within it's iframe until resolved.

- Refactor Step constants for subscriptions

- Step has been changed to only have to know to emit events the steps
  are responsible for the tracking, this also makes it so Step
  doesn't have to pull in the getters & state to be able to
  pass the data on to the tracking.

- refs:
  https://gitlab.com/gitlab-org/gitlab/-/issues/342853
  https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74828#note_802788391
  https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74828#note_804004486
  https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74828#note_804004490
  https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74828#note_804004492
  https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74828#note_804555436
  https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74828#note_804555439
  https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74828#note_804555440
  https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74828#note_804555442
  https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74828#note_804555444
  https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74828#note_784574426
  https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74828#note_810556340
  https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74828#note_817671640
  https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74828#note_817671638
  https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74828#note_817671634

- Lessons some of the concerns expressed in:
  https://gitlab.com/gitlab-org/gitlab/-/issues/350344
parent 82f1ec0f
...@@ -16,12 +16,18 @@ export const ERROR_LOADING_PAYMENT_FORM = s__( ...@@ -16,12 +16,18 @@ export const ERROR_LOADING_PAYMENT_FORM = s__(
'Checkout|Failed to load the payment form. Please try again.', 'Checkout|Failed to load the payment form. Please try again.',
); );
// The order of the steps in this array determines the flow of the application
/* eslint-disable @gitlab/require-i18n-strings */ /* eslint-disable @gitlab/require-i18n-strings */
export const STEP_SUBSCRIPTION_DETAILS = 'subscriptionDetails';
export const STEP_BILLING_ADDRESS = 'billingAddress';
export const STEP_PAYMENT_METHOD = 'paymentMethod';
export const STEP_CONFIRM_ORDER = 'confirmOrder';
// The order of the steps in this array determines the flow of the application
export const STEPS = [ export const STEPS = [
{ id: 'subscriptionDetails', __typename: 'Step' }, { id: STEP_SUBSCRIPTION_DETAILS, __typename: 'Step' },
{ id: 'billingAddress', __typename: 'Step' }, { id: STEP_BILLING_ADDRESS, __typename: 'Step' },
{ id: 'paymentMethod', __typename: 'Step' }, { id: STEP_PAYMENT_METHOD, __typename: 'Step' },
{ id: 'confirmOrder', __typename: 'Step' }, { id: STEP_CONFIRM_ORDER, __typename: 'Step' },
]; ];
export const TRACK_SUCCESS_MESSAGE = 'Success';
/* eslint-enable @gitlab/require-i18n-strings */ /* eslint-enable @gitlab/require-i18n-strings */
...@@ -3,6 +3,7 @@ import { mapState } from 'vuex'; ...@@ -3,6 +3,7 @@ import { mapState } from 'vuex';
import ProgressBar from 'ee/registrations/components/progress_bar.vue'; import ProgressBar from 'ee/registrations/components/progress_bar.vue';
import { STEPS, SUBSCRIPTON_FLOW_STEPS } from 'ee/registrations/constants'; import { STEPS, SUBSCRIPTON_FLOW_STEPS } from 'ee/registrations/constants';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import Tracking from '~/tracking';
import BillingAddress from 'jh_else_ee/subscriptions/new/components/checkout/billing_address.vue'; import BillingAddress from 'jh_else_ee/subscriptions/new/components/checkout/billing_address.vue';
import ConfirmOrder from './checkout/confirm_order.vue'; import ConfirmOrder from './checkout/confirm_order.vue';
import PaymentMethod from './checkout/payment_method.vue'; import PaymentMethod from './checkout/payment_method.vue';
...@@ -10,11 +11,15 @@ import SubscriptionDetails from './checkout/subscription_details.vue'; ...@@ -10,11 +11,15 @@ import SubscriptionDetails from './checkout/subscription_details.vue';
export default { export default {
components: { ProgressBar, SubscriptionDetails, BillingAddress, PaymentMethod, ConfirmOrder }, components: { ProgressBar, SubscriptionDetails, BillingAddress, PaymentMethod, ConfirmOrder },
mixins: [Tracking.mixin()],
currentStep: STEPS.checkout, currentStep: STEPS.checkout,
steps: SUBSCRIPTON_FLOW_STEPS, steps: SUBSCRIPTON_FLOW_STEPS,
computed: { computed: {
...mapState(['isNewUser']), ...mapState(['isNewUser']),
}, },
mounted() {
this.track('render', { label: 'saas_checkout' });
},
i18n: { i18n: {
checkout: s__('Checkout|Checkout'), checkout: s__('Checkout|Checkout'),
}, },
......
...@@ -2,10 +2,11 @@ ...@@ -2,10 +2,11 @@
import { GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui'; import { GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { STEPS } from 'ee/subscriptions/constants'; import { STEP_BILLING_ADDRESS } from 'ee/subscriptions/constants';
import Step from 'ee/vue_shared/purchase_flow/components/step.vue'; import Step from 'ee/vue_shared/purchase_flow/components/step.vue';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import Tracking from '~/tracking';
export default { export default {
components: { components: {
...@@ -17,6 +18,7 @@ export default { ...@@ -17,6 +18,7 @@ export default {
directives: { directives: {
autofocusonshow, autofocusonshow,
}, },
mixins: [Tracking.mixin()],
computed: { computed: {
...mapState([ ...mapState([
'country', 'country',
...@@ -117,6 +119,21 @@ export default { ...@@ -117,6 +119,21 @@ export default {
'updateCountryState', 'updateCountryState',
'updateZipCode', 'updateZipCode',
]), ]),
trackStepTransition() {
this.track('click_button', { label: 'select_country', property: this.country });
this.track('click_button', { label: 'state', property: this.countryState });
this.track('click_button', {
label: 'saas_checkout_postal_code',
property: this.zipCode,
});
this.track('click_button', { label: 'continue_payment' });
},
trackStepEdit() {
this.track('click_button', {
label: 'edit',
property: STEP_BILLING_ADDRESS,
});
},
}, },
i18n: { i18n: {
stepTitle: s__('Checkout|Billing address'), stepTitle: s__('Checkout|Billing address'),
...@@ -129,7 +146,7 @@ export default { ...@@ -129,7 +146,7 @@ export default {
stateSelectPrompt: s__('Checkout|Please select a state'), stateSelectPrompt: s__('Checkout|Please select a state'),
zipCodeLabel: s__('Checkout|Zip code'), zipCodeLabel: s__('Checkout|Zip code'),
}, },
stepId: STEPS[1].id, stepId: STEP_BILLING_ADDRESS,
}; };
</script> </script>
<template> <template>
...@@ -138,6 +155,8 @@ export default { ...@@ -138,6 +155,8 @@ export default {
:title="$options.i18n.stepTitle" :title="$options.i18n.stepTitle"
:is-valid="isValid" :is-valid="isValid"
:next-step-button-text="$options.i18n.nextStepButtonText" :next-step-button-text="$options.i18n.nextStepButtonText"
@nextStep="trackStepTransition"
@stepEdit="trackStepEdit"
> >
<template #body> <template #body>
<gl-form-group :label="$options.i18n.countryLabel" label-size="sm" class="mb-3"> <gl-form-group :label="$options.i18n.countryLabel" label-size="sm" class="mb-3">
......
<script> <script>
import { GlSprintf } from '@gitlab/ui'; import { GlSprintf } from '@gitlab/ui';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import { STEPS } from 'ee/subscriptions/constants'; import { STEP_PAYMENT_METHOD, TRACK_SUCCESS_MESSAGE } from 'ee/subscriptions/constants';
import Step from 'ee/vue_shared/purchase_flow/components/step.vue'; import Step from 'ee/vue_shared/purchase_flow/components/step.vue';
import { sprintf, s__ } from '~/locale'; import { sprintf, s__ } from '~/locale';
import Tracking from '~/tracking';
import Zuora from './zuora.vue'; import Zuora from './zuora.vue';
export default { export default {
...@@ -12,6 +13,7 @@ export default { ...@@ -12,6 +13,7 @@ export default {
Step, Step,
Zuora, Zuora,
}, },
mixins: [Tracking.mixin()],
computed: { computed: {
...mapState(['paymentMethodId', 'creditCardDetails']), ...mapState(['paymentMethodId', 'creditCardDetails']),
isValid() { isValid() {
...@@ -24,18 +26,43 @@ export default { ...@@ -24,18 +26,43 @@ export default {
}); });
}, },
}, },
methods: {
trackStepSuccess() {
this.track('click_button', {
label: 'review_order',
property: TRACK_SUCCESS_MESSAGE,
});
},
trackStepError(errorMessage) {
this.track('click_button', {
label: 'review_order',
property: errorMessage,
});
},
trackStepEdit() {
this.track('click_button', {
label: 'edit',
property: STEP_PAYMENT_METHOD,
});
},
},
i18n: { i18n: {
stepTitle: s__('Checkout|Payment method'), stepTitle: s__('Checkout|Payment method'),
creditCardDetails: s__('Checkout|%{cardType} ending in %{lastFourDigits}'), creditCardDetails: s__('Checkout|%{cardType} ending in %{lastFourDigits}'),
expirationDate: s__('Checkout|Exp %{expirationMonth}/%{expirationYear}'), expirationDate: s__('Checkout|Exp %{expirationMonth}/%{expirationYear}'),
}, },
stepId: STEPS[2].id, stepId: STEP_PAYMENT_METHOD,
}; };
</script> </script>
<template> <template>
<step :step-id="$options.stepId" :title="$options.i18n.stepTitle" :is-valid="isValid"> <step
:step-id="$options.stepId"
:title="$options.i18n.stepTitle"
:is-valid="isValid"
@stepEdit="trackStepEdit"
>
<template #body="props"> <template #body="props">
<zuora :active="props.active" /> <zuora :active="props.active" @success="trackStepSuccess" @error="trackStepError" />
</template> </template>
<template #summary> <template #summary>
<div class="js-summary-line-1"> <div class="js-summary-line-1">
......
...@@ -2,11 +2,12 @@ ...@@ -2,11 +2,12 @@
import { GlFormGroup, GlFormSelect, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui'; import { GlFormGroup, GlFormSelect, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import { STEPS } from 'ee/subscriptions/constants'; import { STEP_SUBSCRIPTION_DETAILS } from 'ee/subscriptions/constants';
import { NEW_GROUP } from 'ee/subscriptions/new/constants'; import { NEW_GROUP } from 'ee/subscriptions/new/constants';
import Step from 'ee/vue_shared/purchase_flow/components/step.vue'; import Step from 'ee/vue_shared/purchase_flow/components/step.vue';
import { sprintf, s__ } from '~/locale'; import { sprintf, s__ } from '~/locale';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import Tracking from '~/tracking';
export default { export default {
components: { components: {
...@@ -20,6 +21,7 @@ export default { ...@@ -20,6 +21,7 @@ export default {
directives: { directives: {
autofocusonshow, autofocusonshow,
}, },
mixins: [Tracking.mixin()],
computed: { computed: {
...mapState([ ...mapState([
'availablePlans', 'availablePlans',
...@@ -33,6 +35,8 @@ export default { ...@@ -33,6 +35,8 @@ export default {
]), ]),
...mapGetters([ ...mapGetters([
'selectedPlanText', 'selectedPlanText',
'selectedPlanDetails',
'selectedGroupId',
'isGroupSelected', 'isGroupSelected',
'selectedGroupUsers', 'selectedGroupUsers',
'selectedGroupName', 'selectedGroupName',
...@@ -134,6 +138,21 @@ export default { ...@@ -134,6 +138,21 @@ export default {
'updateNumberOfUsers', 'updateNumberOfUsers',
'updateOrganizationName', 'updateOrganizationName',
]), ]),
trackStepTransition() {
this.track('click_button', {
label: 'update_plan_type',
property: this.selectedPlanDetails.code,
});
this.track('click_button', { label: 'update_group', property: this.selectedGroupId });
this.track('click_button', { label: 'update_seat_count', property: this.numberOfUsers });
this.track('click_button', { label: 'continue_billing' });
},
trackStepEdit() {
this.track('click_button', {
label: 'edit',
property: STEP_SUBSCRIPTION_DETAILS,
});
},
}, },
i18n: { i18n: {
stepTitle: s__('Checkout|Subscription details'), stepTitle: s__('Checkout|Subscription details'),
...@@ -152,7 +171,7 @@ export default { ...@@ -152,7 +171,7 @@ export default {
group: s__('Checkout|Group'), group: s__('Checkout|Group'),
users: s__('Checkout|Users'), users: s__('Checkout|Users'),
}, },
stepId: STEPS[0].id, stepId: STEP_SUBSCRIPTION_DETAILS,
}; };
</script> </script>
<template> <template>
...@@ -161,6 +180,8 @@ export default { ...@@ -161,6 +180,8 @@ export default {
:title="$options.i18n.stepTitle" :title="$options.i18n.stepTitle"
:is-valid="isValid" :is-valid="isValid"
:next-step-button-text="$options.i18n.nextStepButtonText" :next-step-button-text="$options.i18n.nextStepButtonText"
@nextStep="trackStepTransition"
@stepEdit="trackStepEdit"
> >
<template #body> <template #body>
<gl-form-group :label="$options.i18n.selectedPlanLabel" label-size="sm" class="mb-3"> <gl-form-group :label="$options.i18n.selectedPlanLabel" label-size="sm" class="mb-3">
......
<script> <script>
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import Tracking from '~/tracking';
import { ZUORA_SCRIPT_URL, ZUORA_IFRAME_OVERRIDE_PARAMS } from 'ee/subscriptions/constants'; import { ZUORA_SCRIPT_URL, ZUORA_IFRAME_OVERRIDE_PARAMS } from 'ee/subscriptions/constants';
export default { export default {
components: { components: {
GlLoadingIcon, GlLoadingIcon,
}, },
mixins: [Tracking.mixin()],
props: { props: {
active: { active: {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
}, },
emits: ['success', 'error'],
computed: { computed: {
...mapState([ ...mapState([
'paymentFormParams', 'paymentFormParams',
...@@ -51,10 +54,18 @@ export default { ...@@ -51,10 +54,18 @@ export default {
this.fetchPaymentFormParams(); this.fetchPaymentFormParams();
} }
}, },
handleZuoraCallback(response) {
this.paymentFormSubmitted(response);
if (response?.success === 'true') {
this.$emit('success');
} else {
this.$emit('error', response?.errorMessage);
}
},
renderZuoraIframe() { renderZuoraIframe() {
const params = { ...this.paymentFormParams, ...ZUORA_IFRAME_OVERRIDE_PARAMS }; const params = { ...this.paymentFormParams, ...ZUORA_IFRAME_OVERRIDE_PARAMS };
window.Z.runAfterRender(this.zuoraIframeRendered); window.Z.runAfterRender(this.zuoraIframeRendered);
window.Z.render(params, {}, this.paymentFormSubmitted); window.Z.render(params, {}, this.handleZuoraCallback);
}, },
}, },
}; };
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { GlLink, GlSprintf } from '@gitlab/ui'; import { GlLink, GlSprintf } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex'; import { mapState, mapGetters } from 'vuex';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import Tracking from '~/tracking';
import formattingMixins from '../../formatting_mixins'; import formattingMixins from '../../formatting_mixins';
export default { export default {
...@@ -9,7 +10,7 @@ export default { ...@@ -9,7 +10,7 @@ export default {
GlLink, GlLink,
GlSprintf, GlSprintf,
}, },
mixins: [formattingMixins], mixins: [formattingMixins, Tracking.mixin()],
computed: { computed: {
...mapState(['startDate', 'taxRate', 'numberOfUsers']), ...mapState(['startDate', 'taxRate', 'numberOfUsers']),
...mapGetters([ ...mapGetters([
...@@ -81,6 +82,7 @@ export default { ...@@ -81,6 +82,7 @@ export default {
href="https://about.gitlab.com/handbook/tax/#indirect-taxes-management" href="https://about.gitlab.com/handbook/tax/#indirect-taxes-management"
target="_blank" target="_blank"
data-testid="tax-help-link" data-testid="tax-help-link"
@click="track('click_button', { label: 'tax_link' })"
>{{ content }}</gl-link >{{ content }}</gl-link
> >
</template> </template>
......
...@@ -72,9 +72,9 @@ export default { ...@@ -72,9 +72,9 @@ export default {
throw new Error(JSON.stringify(data.errors)); throw new Error(JSON.stringify(data.errors));
} }
}) })
.catch((error) => .catch((error) => {
createFlash({ message: GENERAL_ERROR_MESSAGE, error, captureError: true }), createFlash({ message: GENERAL_ERROR_MESSAGE, error, captureError: true });
) })
.finally(() => { .finally(() => {
this.isLoading = false; this.isLoading = false;
}); });
......
...@@ -41,6 +41,7 @@ export default { ...@@ -41,6 +41,7 @@ export default {
default: '', default: '',
}, },
}, },
emits: ['nextStep', 'stepEdit'],
data() { data() {
return { return {
activeStep: {}, activeStep: {},
...@@ -71,7 +72,7 @@ export default { ...@@ -71,7 +72,7 @@ export default {
const activeIndex = this.stepList.findIndex(({ id }) => id === this.activeStep.id); const activeIndex = this.stepList.findIndex(({ id }) => id === this.activeStep.id);
return this.isFinished && index < activeIndex; return this.isFinished && index < activeIndex;
}, },
snakeCasedStep() { dasherizedStep() {
return dasherize(convertToSnakeCase(this.stepId)); return dasherize(convertToSnakeCase(this.stepId));
}, },
}, },
...@@ -93,10 +94,12 @@ export default { ...@@ -93,10 +94,12 @@ export default {
}) })
.finally(() => { .finally(() => {
this.loading = false; this.loading = false;
this.$emit('nextStep');
}); });
}, },
async edit() { async edit() {
this.loading = true; this.loading = true;
this.$emit('stepEdit', this.stepId);
await this.$apollo await this.$apollo
.mutate({ .mutate({
mutation: updateStepMutation, mutation: updateStepMutation,
...@@ -115,7 +118,7 @@ export default { ...@@ -115,7 +118,7 @@ export default {
<template> <template>
<div class="mb-3 mb-lg-5 gl-w-full"> <div class="mb-3 mb-lg-5 gl-w-full">
<step-header :title="title" :is-finished="isFinished" /> <step-header :title="title" :is-finished="isFinished" />
<div :class="['card', snakeCasedStep]"> <div class="card" :class="dasherizedStep">
<div v-show="isActive" @keyup.enter="nextStep"> <div v-show="isActive" @keyup.enter="nextStep">
<slot name="body" :active="isActive"></slot> <slot name="body" :active="isActive"></slot>
<gl-form-group <gl-form-group
......
...@@ -83,7 +83,11 @@ class SubscriptionsController < ApplicationController ...@@ -83,7 +83,11 @@ class SubscriptionsController < ApplicationController
group = params[:selected_group] ? current_group : create_group group = params[:selected_group] ? current_group : create_group
return not_found if group.nil? return not_found if group.nil?
return render json: group.errors.to_json unless group.persisted?
unless group.persisted?
track_purchase message: group.errors.full_messages.to_s
return render json: group.errors.to_json
end
response = Subscriptions::CreateService.new( response = Subscriptions::CreateService.new(
current_user, current_user,
...@@ -93,7 +97,10 @@ class SubscriptionsController < ApplicationController ...@@ -93,7 +97,10 @@ class SubscriptionsController < ApplicationController
).execute ).execute
if response[:success] if response[:success]
track_purchase message: 'Success', namespace: group
response[:data] = { location: redirect_location(group) } response[:data] = { location: redirect_location(group) }
else
track_purchase message: response.dig(:data, :errors), namespace: group
end end
render json: response[:data] render json: response[:data]
...@@ -101,6 +108,15 @@ class SubscriptionsController < ApplicationController ...@@ -101,6 +108,15 @@ class SubscriptionsController < ApplicationController
private private
def track_purchase(message:, namespace: nil)
Gitlab::Tracking.event(self.class.name, 'click_button',
label: 'confirm_purchase',
property: message,
user: current_user,
namespace: namespace
)
end
def redirect_location(group) def redirect_location(group)
return safe_redirect_path(params[:redirect_after_success]) if params[:redirect_after_success] return safe_redirect_path(params[:redirect_after_success]) if params[:redirect_after_success]
......
!!! 5 !!! 5
%html.subscriptions-layout-html{ lang: 'en' } %html.subscriptions-layout-html{ lang: 'en' }
= render 'layouts/head' = render 'layouts/head'
%body.ui-indigo.d-flex.vh-100 %body.ui-indigo.d-flex.vh-100{ data: body_data }
= render "layouts/header/logo_with_title" = render "layouts/header/logo_with_title"
= render "layouts/broadcast" = render "layouts/broadcast"
.container.d-flex.gl-flex-direction-column.m-0 .container.d-flex.gl-flex-direction-column.m-0
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
- if show_plans?(namespace) - if show_plans?(namespace)
- plans = billing_available_plans(plans_data, current_plan) - plans = billing_available_plans(plans_data, current_plan)
.billing-plans.gl-mt-7 .billing-plans.gl-mt-7{ data: { track: { action: 'render', label: 'billing' } } }
- plans.each do |plan| - plans.each do |plan|
- next if plan.hide_card - next if plan.hide_card
- is_default_plan = current_plan.nil? && plan.default? - is_default_plan = current_plan.nil? && plan.default?
......
...@@ -264,7 +264,7 @@ RSpec.describe SubscriptionsController do ...@@ -264,7 +264,7 @@ RSpec.describe SubscriptionsController do
end end
end end
describe 'POST #create' do describe 'POST #create', :snowplow do
subject do subject do
post :create, post :create,
params: params, params: params,
...@@ -350,6 +350,20 @@ RSpec.describe SubscriptionsController do ...@@ -350,6 +350,20 @@ RSpec.describe SubscriptionsController do
expect(Gitlab::Json.parse(response.body)['name']).to match_array([Gitlab::Regex.group_name_regex_message, HtmlSafetyValidator.error_message]) expect(Gitlab::Json.parse(response.body)['name']).to match_array([Gitlab::Regex.group_name_regex_message, HtmlSafetyValidator.error_message])
end end
it 'tracks errors' do
group.valid?
subject
expect_snowplow_event(
category: 'SubscriptionsController',
label: 'confirm_purchase',
action: 'click_button',
property: group.errors.full_messages.to_s,
user: user,
namespace: nil
)
end
end end
end end
...@@ -372,6 +386,14 @@ RSpec.describe SubscriptionsController do ...@@ -372,6 +386,14 @@ RSpec.describe SubscriptionsController do
subject subject
expect(response.body).to eq('{"errors":"error message"}') expect(response.body).to eq('{"errors":"error message"}')
expect_snowplow_event(
category: 'SubscriptionsController',
label: 'confirm_purchase',
action: 'click_button',
property: 'error message',
user: user,
namespace: group
)
end end
end end
...@@ -430,6 +452,19 @@ RSpec.describe SubscriptionsController do ...@@ -430,6 +452,19 @@ RSpec.describe SubscriptionsController do
expect(response.body).to eq({ location: redirect_after_success }.to_json) expect(response.body).to eq({ location: redirect_after_success }.to_json)
end end
it 'tracks the creation of the subscriptions' do
subject
expect_snowplow_event(
category: 'SubscriptionsController',
label: 'confirm_purchase',
action: 'click_button',
property: 'Success',
namespace: selected_group,
user: user
)
end
end end
end end
......
...@@ -3,6 +3,7 @@ import Vue, { nextTick } from 'vue'; ...@@ -3,6 +3,7 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { mockTracking } from 'helpers/tracking_helper';
import { STEPS } from 'ee/subscriptions/constants'; import { STEPS } from 'ee/subscriptions/constants';
import BillingAddress from 'ee/subscriptions/new/components/checkout/billing_address.vue'; import BillingAddress from 'ee/subscriptions/new/components/checkout/billing_address.vue';
import { getStoreConfig } from 'ee/subscriptions/new/store'; import { getStoreConfig } from 'ee/subscriptions/new/store';
...@@ -83,6 +84,48 @@ describe('Billing Address', () => { ...@@ -83,6 +84,48 @@ describe('Billing Address', () => {
}); });
}); });
describe('tracking', () => {
beforeEach(() => {
store.commit(types.UPDATE_COUNTRY, 'US');
store.commit(types.UPDATE_ZIP_CODE, '10467');
store.commit(types.UPDATE_COUNTRY_STATE, 'NY');
});
it('tracks completion details', () => {
const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
wrapper.findComponent(Step).vm.$emit('nextStep');
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'select_country',
property: 'US',
});
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'state',
property: 'NY',
});
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'saas_checkout_postal_code',
property: '10467',
});
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'continue_payment',
});
});
it('tracks step edits', async () => {
const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
wrapper.findComponent(Step).vm.$emit('stepEdit', 'stepID');
await nextTick();
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'edit',
property: 'billingAddress',
});
});
});
describe('validations', () => { describe('validations', () => {
const isStepValid = () => wrapper.findComponent(Step).props('isValid'); const isStepValid = () => wrapper.findComponent(Step).props('isValid');
......
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { triggerEvent, mockTracking, unmockTracking } from 'helpers/tracking_helper';
import Component from 'ee/subscriptions/new/components/order_summary.vue'; import Component from 'ee/subscriptions/new/components/order_summary.vue';
import createStore from 'ee/subscriptions/new/store'; import createStore from 'ee/subscriptions/new/store';
import * as types from 'ee/subscriptions/new/store/mutation_types'; import * as types from 'ee/subscriptions/new/store/mutation_types';
...@@ -9,6 +10,7 @@ describe('Order Summary', () => { ...@@ -9,6 +10,7 @@ describe('Order Summary', () => {
Vue.use(Vuex); Vue.use(Vuex);
let wrapper; let wrapper;
let trackingSpy;
const availablePlans = [ const availablePlans = [
{ id: 'firstPlanId', code: 'bronze', price_per_year: 48, name: 'bronze plan' }, { id: 'firstPlanId', code: 'bronze', price_per_year: 48, name: 'bronze plan' },
...@@ -36,9 +38,11 @@ describe('Order Summary', () => { ...@@ -36,9 +38,11 @@ describe('Order Summary', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
}); });
afterEach(() => { afterEach(() => {
unmockTracking();
wrapper.destroy(); wrapper.destroy();
}); });
...@@ -188,6 +192,17 @@ describe('Order Summary', () => { ...@@ -188,6 +192,17 @@ describe('Order Summary', () => {
}); });
describe('tax rate', () => { describe('tax rate', () => {
describe('tracking', () => {
it('track click on tax_link', () => {
trackingSpy = mockTracking(undefined, findTaxHelpLink().element, jest.spyOn);
triggerEvent(findTaxHelpLink().element);
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'tax_link',
});
});
});
describe('with a tax rate of 0', () => { describe('with a tax rate of 0', () => {
it('displays the total amount excluding vat', () => { it('displays the total amount excluding vat', () => {
expect(wrapper.find('.js-total-ex-vat').exists()).toBe(true); expect(wrapper.find('.js-total-ex-vat').exists()).toBe(true);
......
...@@ -2,6 +2,8 @@ import { mount } from '@vue/test-utils'; ...@@ -2,6 +2,8 @@ import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import Vuex from 'vuex'; import Vuex from 'vuex';
import Zuora from 'ee/subscriptions/new/components/checkout/zuora.vue';
import { mockTracking } from 'helpers/tracking_helper';
import { STEPS } from 'ee/subscriptions/constants'; import { STEPS } from 'ee/subscriptions/constants';
import PaymentMethod from 'ee/subscriptions/new/components/checkout/payment_method.vue'; import PaymentMethod from 'ee/subscriptions/new/components/checkout/payment_method.vue';
import createStore from 'ee/subscriptions/new/store'; import createStore from 'ee/subscriptions/new/store';
...@@ -68,4 +70,42 @@ describe('Payment Method', () => { ...@@ -68,4 +70,42 @@ describe('Payment Method', () => {
expect(wrapper.find('.js-summary-line-2').text()).toEqual('Exp 12/09'); expect(wrapper.find('.js-summary-line-2').text()).toEqual('Exp 12/09');
}); });
}); });
describe('tracking', () => {
it('tracks step completion details', async () => {
const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
wrapper.findComponent(Zuora).vm.$emit('success');
await nextTick();
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'review_order',
property: 'Success',
});
});
it('tracks zuora errors on step transition', async () => {
const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
wrapper.findComponent(Zuora).vm.$emit('error', 'This was a mistake.');
await nextTick();
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'review_order',
property: 'This was a mistake.',
});
});
it('tracks step edits', async () => {
const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
wrapper.findComponent(Step).vm.$emit('stepEdit', 'stepID');
await nextTick();
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'edit',
property: 'paymentMethod',
});
});
});
}); });
...@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'; ...@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { mockTracking } from 'helpers/tracking_helper';
import { STEPS } from 'ee/subscriptions/constants'; import { STEPS } from 'ee/subscriptions/constants';
import Component from 'ee/subscriptions/new/components/checkout/subscription_details.vue'; import Component from 'ee/subscriptions/new/components/checkout/subscription_details.vue';
import { NEW_GROUP } from 'ee/subscriptions/new/constants'; import { NEW_GROUP } from 'ee/subscriptions/new/constants';
...@@ -340,6 +341,58 @@ describe('Subscription Details', () => { ...@@ -340,6 +341,58 @@ describe('Subscription Details', () => {
}); });
}); });
describe('tracking', () => {
let store;
beforeEach(() => {
const mockApollo = createMockApolloProvider(STEPS);
store = createStore(
createDefaultInitialStoreData({
isNewUser: 'false',
namespaceId: '132',
setupForCompany: 'false',
}),
);
store.commit(types.UPDATE_NUMBER_OF_USERS, 13);
wrapper = createComponent({ apolloProvider: mockApollo, store });
});
it('tracks completion details', async () => {
const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
wrapper.findComponent(Step).vm.$emit('nextStep');
await nextTick();
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'update_plan_type',
property: 'silver',
});
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'update_seat_count',
property: 13,
});
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'update_group',
property: 132,
});
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'continue_billing',
});
});
it('tracks step edits', async () => {
const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
wrapper.findComponent(Step).vm.$emit('stepEdit', 'stepID');
await nextTick();
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'edit',
property: 'subscriptionDetails',
});
});
});
describe('validations', () => { describe('validations', () => {
const isStepValid = () => wrapper.findComponent(Step).props('isValid'); const isStepValid = () => wrapper.findComponent(Step).props('isValid');
let store; let store;
......
...@@ -117,7 +117,25 @@ describe('Zuora', () => { ...@@ -117,7 +117,25 @@ describe('Zuora', () => {
return nextTick().then(() => { return nextTick().then(() => {
expect(actionMocks.zuoraIframeRendered).toHaveBeenCalled(); expect(actionMocks.zuoraIframeRendered).toHaveBeenCalled();
wrapper.vm.handleZuoraCallback();
expect(actionMocks.paymentFormSubmitted).toHaveBeenCalled();
}); });
}); });
}); });
describe('tracking', () => {
it('emits success event on correct response', async () => {
wrapper.vm.handleZuoraCallback({ success: 'true' });
await nextTick();
expect(wrapper.emitted().success.length).toEqual(1);
});
it('emits error with message', async () => {
createComponent();
wrapper.vm.handleZuoraCallback({ errorMessage: '1337' });
await nextTick();
expect(wrapper.emitted().error.length).toEqual(1);
expect(wrapper.emitted().error[0]).toEqual(['1337']);
});
});
}); });
...@@ -4,6 +4,7 @@ import Vuex from 'vuex'; ...@@ -4,6 +4,7 @@ import Vuex from 'vuex';
import ProgressBar from 'ee/registrations/components/progress_bar.vue'; import ProgressBar from 'ee/registrations/components/progress_bar.vue';
import Component from 'ee/subscriptions/new/components/checkout.vue'; import Component from 'ee/subscriptions/new/components/checkout.vue';
import createStore from 'ee/subscriptions/new/store'; import createStore from 'ee/subscriptions/new/store';
import { mockTracking } from 'helpers/tracking_helper';
describe('Checkout', () => { describe('Checkout', () => {
Vue.use(Vuex); Vue.use(Vuex);
...@@ -58,4 +59,16 @@ describe('Checkout', () => { ...@@ -58,4 +59,16 @@ describe('Checkout', () => {
expect(findProgressBar().props('currentStep')).toEqual('Checkout'); expect(findProgressBar().props('currentStep')).toEqual('Checkout');
}); });
}); });
describe('tracking', () => {
it('tracks render on mount', () => {
const trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
shallowMount(Component, { store: createStore() });
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'render', {
label: 'saas_checkout',
});
});
});
}); });
...@@ -17,7 +17,6 @@ jest.mock('~/flash'); ...@@ -17,7 +17,6 @@ jest.mock('~/flash');
describe('Step', () => { describe('Step', () => {
let wrapper; let wrapper;
const initialProps = { const initialProps = {
stepId: STEPS[1].id, stepId: STEPS[1].id,
isValid: true, isValid: true,
...@@ -33,6 +32,7 @@ describe('Step', () => { ...@@ -33,6 +32,7 @@ describe('Step', () => {
} }
function createComponent(options = {}) { function createComponent(options = {}) {
const { apolloProvider, propsData } = options; const { apolloProvider, propsData } = options;
return shallowMount(Step, { return shallowMount(Step, {
propsData: { ...initialProps, ...propsData }, propsData: { ...initialProps, ...propsData },
apolloProvider, apolloProvider,
...@@ -198,4 +198,28 @@ describe('Step', () => { ...@@ -198,4 +198,28 @@ describe('Step', () => {
}); });
}); });
}); });
describe('tracking', () => {
it('emits stepEdit', async () => {
const mockApollo = createMockApolloProvider(STEPS, 1);
wrapper = createComponent({ propsData: { stepId: STEPS[1].id }, apolloProvider: mockApollo });
// button in step-summary is not rendered b/c of shallowMount
wrapper.vm.edit();
await waitForPromises();
expect(wrapper.emitted().stepEdit[0]).toEqual(['secondStep']);
});
it('emits nextStep on step transition', async () => {
const mockApollo = createMockApolloProvider(STEPS, 1);
wrapper = createComponent({ propsData: { stepId: STEPS[1].id }, apolloProvider: mockApollo });
await activateFirstStep(mockApollo);
wrapper.findComponent(GlButton).vm.$emit('click');
await waitForPromises();
expect(wrapper.emitted().nextStep).toBeTruthy();
});
});
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment