Commit a7ca3737 authored by David O'Regan's avatar David O'Regan

Merge branch '321650-mlunoe-migrate-purchase-flow-components-follow-up' into 'master'

Feat(Purchase flow): Add steps error handling

See merge request gitlab-org/gitlab!58084
parents a7c591ee adef44af
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { GENERAL_ERROR_MESSAGE } from 'ee/vue_shared/purchase_flow/constants';
import activeStepQuery from 'ee/vue_shared/purchase_flow/graphql/queries/active_step.query.graphql';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { STEPS } from '../../constants';
......@@ -18,7 +20,10 @@ export default {
apollo: {
isActive: {
query: activeStepQuery,
update: ({ activeStep }) => activeStep.id === STEPS[3].id,
update: ({ activeStep }) => activeStep?.id === STEPS[3].id,
error: (error) => {
createFlash({ message: GENERAL_ERROR_MESSAGE, error, captureError: true });
},
},
},
computed: {
......
import Api from 'ee/api';
import { GENERAL_ERROR_MESSAGE } from 'ee/vue_shared/purchase_flow/constants';
import activateNextStepMutation from 'ee/vue_shared/purchase_flow/graphql/mutations/activate_next_step.mutation.graphql';
import createFlash from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility';
......@@ -169,9 +170,13 @@ export const fetchPaymentMethodDetails = ({ state, dispatch, commit }) =>
export const fetchPaymentMethodDetailsSuccess = ({ commit }, creditCardDetails) => {
commit(types.UPDATE_CREDIT_CARD_DETAILS, creditCardDetails);
defaultClient.mutate({
mutation: activateNextStepMutation,
});
defaultClient
.mutate({
mutation: activateNextStepMutation,
})
.catch((error) => {
createFlash({ message: GENERAL_ERROR_MESSAGE, error, captureError: true });
});
};
export const fetchPaymentMethodDetailsError = () => {
......
......@@ -4,7 +4,9 @@ import activateNextStepMutation from 'ee/vue_shared/purchase_flow/graphql/mutati
import updateStepMutation from 'ee/vue_shared/purchase_flow/graphql/mutations/update_active_step.mutation.graphql';
import activeStepQuery from 'ee/vue_shared/purchase_flow/graphql/queries/active_step.query.graphql';
import stepListQuery from 'ee/vue_shared/purchase_flow/graphql/queries/step_list.query.graphql';
import createFlash from '~/flash';
import { convertToSnakeCase, dasherize } from '~/lib/utils/text_utility';
import { GENERAL_ERROR_MESSAGE } from '../constants';
import StepHeader from './step_header.vue';
import StepSummary from './step_summary.vue';
......@@ -44,6 +46,9 @@ export default {
apollo: {
activeStep: {
query: activeStepQuery,
error(error) {
this.handleError(error);
},
},
stepList: {
query: stepListQuery,
......@@ -66,6 +71,9 @@ export default {
},
},
methods: {
handleError(error) {
createFlash({ message: GENERAL_ERROR_MESSAGE, error, captureError: true });
},
async nextStep() {
if (!this.isValid) {
return;
......@@ -75,6 +83,9 @@ export default {
.mutate({
mutation: activateNextStepMutation,
})
.catch((error) => {
this.handleError(error);
})
.finally(() => {
this.loading = false;
});
......@@ -86,6 +97,9 @@ export default {
mutation: updateStepMutation,
variables: { id: this.stepId },
})
.catch((error) => {
this.handleError(error);
})
.finally(() => {
this.loading = false;
});
......
import { s__ } from '~/locale';
export const GENERAL_ERROR_MESSAGE = s__(
'PurchaseStep|An error occured in the purchase step. If the problem persists please contact support@gitlab.com.',
);
---
title: Add error handling feedback inside purchase flow
merge_request: 58084
author:
type: changed
......@@ -6,8 +6,11 @@ import Api from 'ee/api';
import ConfirmOrder from 'ee/subscriptions/new/components/checkout/confirm_order.vue';
import { STEPS } from 'ee/subscriptions/new/constants';
import createStore from 'ee/subscriptions/new/store';
import updateStepMutation from 'ee/vue_shared/purchase_flow/graphql/mutations/update_active_step.mutation.graphql';
import { GENERAL_ERROR_MESSAGE } from 'ee/vue_shared/purchase_flow/constants';
import { createMockApolloProvider } from 'ee_jest/vue_shared/purchase_flow/spec_helper';
import flash from '~/flash';
jest.mock('~/flash');
describe('Confirm Order', () => {
const localVue = createLocalVue();
......@@ -15,19 +18,11 @@ describe('Confirm Order', () => {
localVue.use(VueApollo);
let wrapper;
let mockApolloProvider;
jest.mock('ee/api.js');
const store = createStore();
function activateStep(stepId) {
return mockApolloProvider.clients.defaultClient.mutate({
mutation: updateStepMutation,
variables: { id: stepId },
});
}
function createComponent(options = {}) {
return shallowMount(ConfirmOrder, {
localVue,
......@@ -39,34 +34,34 @@ describe('Confirm Order', () => {
const findConfirmButton = () => wrapper.find(GlButton);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
beforeEach(() => {
mockApolloProvider = createMockApolloProvider(STEPS);
wrapper = createComponent({ apolloProvider: mockApolloProvider });
});
afterEach(() => {
wrapper.destroy();
});
describe('Active', () => {
beforeEach(async () => {
await activateStep(STEPS[3].id);
});
describe('when receiving proper step data', () => {
beforeEach(async () => {
const mockApolloProvider = createMockApolloProvider(STEPS, 3);
wrapper = createComponent({ apolloProvider: mockApolloProvider });
});
it('button should be visible', () => {
expect(findConfirmButton().exists()).toBe(true);
});
it('button should be visible', () => {
expect(findConfirmButton().exists()).toBe(true);
});
it('shows the text "Confirm purchase"', () => {
expect(findConfirmButton().text()).toBe('Confirm purchase');
});
it('shows the text "Confirm purchase"', () => {
expect(findConfirmButton().text()).toBe('Confirm purchase');
});
it('the loading indicator should not be visible', () => {
expect(findLoadingIcon().exists()).toBe(false);
it('the loading indicator should not be visible', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('Clicking the button', () => {
beforeEach(() => {
const mockApolloProvider = createMockApolloProvider(STEPS, 3);
wrapper = createComponent({ apolloProvider: mockApolloProvider });
Api.confirmOrder = jest.fn().mockReturnValue(new Promise(jest.fn()));
findConfirmButton().vm.$emit('click');
......@@ -84,11 +79,32 @@ describe('Confirm Order', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
});
describe('when failing to receive step data', () => {
beforeEach(async () => {
const mockApolloProvider = createMockApolloProvider([]);
mockApolloProvider.clients.defaultClient.clearStore();
wrapper = createComponent({ apolloProvider: mockApolloProvider });
});
afterEach(() => {
flash.mockClear();
});
it('displays an error', () => {
expect(flash.mock.calls[0][0]).toMatchObject({
message: GENERAL_ERROR_MESSAGE,
captureError: true,
error: expect.any(Error),
});
});
});
});
describe('Inactive', () => {
beforeEach(async () => {
await activateStep(STEPS[1].id);
const mockApolloProvider = createMockApolloProvider(STEPS, 1);
wrapper = createComponent({ apolloProvider: mockApolloProvider });
});
it('button should not be visible', () => {
......
......@@ -3,13 +3,18 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import Step from 'ee/vue_shared/purchase_flow/components/step.vue';
import StepSummary from 'ee/vue_shared/purchase_flow/components/step_summary.vue';
import { GENERAL_ERROR_MESSAGE } from 'ee/vue_shared/purchase_flow/constants';
import updateStepMutation from 'ee/vue_shared/purchase_flow/graphql/mutations/update_active_step.mutation.graphql';
import waitForPromises from 'helpers/wait_for_promises';
import flash from '~/flash';
import { STEPS } from '../mock_data';
import { createMockApolloProvider } from '../spec_helper';
const localVue = createLocalVue();
localVue.use(VueApollo);
jest.mock('~/flash');
describe('Step', () => {
let wrapper;
......@@ -32,11 +37,15 @@ describe('Step', () => {
localVue,
propsData: { ...initialProps, ...propsData },
apolloProvider,
stubs: {
StepSummary,
},
});
}
afterEach(() => {
wrapper.destroy();
flash.mockClear();
});
describe('Step Body', () => {
......@@ -61,7 +70,27 @@ describe('Step', () => {
const mockApollo = createMockApolloProvider(STEPS, 1);
await activateFirstStep(mockApollo);
wrapper = createComponent({ apolloProvider: mockApollo });
expect(wrapper.find(StepSummary).exists()).toBe(true);
expect(wrapper.findComponent(StepSummary).exists()).toBe(true);
});
it('displays an error when editing a wrong step', async () => {
const mockApollo = createMockApolloProvider(STEPS, 1);
await activateFirstStep(mockApollo);
wrapper = createComponent({
propsData: { stepId: 'does not exist' },
apolloProvider: mockApollo,
});
wrapper.findComponent(StepSummary).findComponent(GlButton).vm.$emit('click');
await waitForPromises();
expect(flash.mock.calls).toHaveLength(1);
expect(flash.mock.calls[0][0]).toMatchObject({
message: GENERAL_ERROR_MESSAGE,
captureError: true,
error: expect.any(Error),
});
});
it('should not be shown when this step is not valid and not active', async () => {
......@@ -69,21 +98,21 @@ describe('Step', () => {
await activateFirstStep(mockApollo);
wrapper = createComponent({ propsData: { isValid: false }, apolloProvider: mockApollo });
expect(wrapper.find(StepSummary).exists()).toBe(false);
expect(wrapper.findComponent(StepSummary).exists()).toBe(false);
});
it('should not be shown when this step is valid and active', () => {
const mockApollo = createMockApolloProvider(STEPS, 1);
wrapper = createComponent({ apolloProvider: mockApollo });
expect(wrapper.find(StepSummary).exists()).toBe(false);
expect(wrapper.findComponent(StepSummary).exists()).toBe(false);
});
it('should not be shown when this step is not valid and active', () => {
const mockApollo = createMockApolloProvider(STEPS, 1);
wrapper = createComponent({ propsData: { isValid: false }, apolloProvider: mockApollo });
expect(wrapper.find(StepSummary).exists()).toBe(false);
expect(wrapper.findComponent(StepSummary).exists()).toBe(false);
});
});
......@@ -92,7 +121,7 @@ describe('Step', () => {
const mockApollo = createMockApolloProvider(STEPS, 1);
wrapper = createComponent({ propsData: { stepId: STEPS[0].id }, apolloProvider: mockApollo });
expect(wrapper.find(StepSummary).props('isEditable')).toBe(true);
expect(wrapper.findComponent(StepSummary).props('isEditable')).toBe(true);
});
});
......@@ -102,14 +131,14 @@ describe('Step', () => {
await activateFirstStep(mockApollo);
wrapper = createComponent({ apolloProvider: mockApollo });
expect(wrapper.find(StepSummary).exists()).toBe(true);
expect(wrapper.findComponent(StepSummary).exists()).toBe(true);
});
it('does not show the summary when this step is not finished', () => {
const mockApollo = createMockApolloProvider(STEPS, 1);
wrapper = createComponent({ apolloProvider: mockApollo });
expect(wrapper.find(StepSummary).exists()).toBe(false);
expect(wrapper.findComponent(StepSummary).exists()).toBe(false);
});
});
......@@ -135,7 +164,7 @@ describe('Step', () => {
const mockApollo = createMockApolloProvider(STEPS, 1);
wrapper = createComponent({ propsData: { isValid: false }, apolloProvider: mockApollo });
expect(wrapper.find(GlButton).attributes('disabled')).toBe('true');
expect(wrapper.findComponent(GlButton).attributes('disabled')).toBe('true');
});
it('is enabled when this step is valid', () => {
......@@ -144,5 +173,20 @@ describe('Step', () => {
expect(wrapper.find(GlButton).attributes('disabled')).toBeUndefined();
});
it('displays an error if navigating too far', async () => {
const mockApollo = createMockApolloProvider(STEPS, 2);
wrapper = createComponent({ propsData: { stepId: STEPS[2].id }, apolloProvider: mockApollo });
wrapper.find(GlButton).vm.$emit('click');
await waitForPromises();
expect(flash.mock.calls).toHaveLength(1);
expect(flash.mock.calls[0][0]).toMatchObject({
message: GENERAL_ERROR_MESSAGE,
captureError: true,
error: expect.any(Error),
});
});
});
});
import { createMockClient } from 'mock-apollo-client';
import activateNextStepMutation from 'ee/vue_shared/purchase_flow/graphql/mutations/activate_next_step.mutation.graphql';
import updateStepMutation from 'ee/vue_shared/purchase_flow/graphql/mutations/update_active_step.mutation.graphql';
import activeStepQuery from 'ee/vue_shared/purchase_flow/graphql/queries/active_step.query.graphql';
import stepListQuery from 'ee/vue_shared/purchase_flow/graphql/queries/step_list.query.graphql';
import resolvers from 'ee/vue_shared/purchase_flow/graphql/resolvers';
import typeDefs from 'ee/vue_shared/purchase_flow/graphql/typedefs.graphql';
import { STEPS } from '../mock_data';
import { createMockApolloProvider } from '../spec_helper';
describe('ee/vue_shared/purchase_flow/graphql/resolvers', () => {
let mockClient;
let mockApolloClient;
beforeEach(async () => {
mockClient = createMockClient({ resolvers, typeDefs });
mockClient.cache.writeQuery({
query: stepListQuery,
data: {
stepList: STEPS,
},
});
mockClient.cache.writeQuery({
query: activeStepQuery,
data: {
activeStep: STEPS[0],
},
describe('Query', () => {
beforeEach(async () => {
const mockApollo = createMockApolloProvider(STEPS, 0);
mockApolloClient = mockApollo.clients.defaultClient;
});
});
describe('Query', () => {
describe('stepListQuery', () => {
it('stores the stepList', async () => {
const queryResult = await mockClient.query({ query: stepListQuery });
const queryResult = await mockApolloClient.query({ query: stepListQuery });
expect(queryResult.data.stepList).toMatchObject(
STEPS.map(({ id }) => {
return { id };
......@@ -38,8 +25,8 @@ describe('ee/vue_shared/purchase_flow/graphql/resolvers', () => {
});
it('throws an error when cache is not initiated properly', async () => {
mockClient.clearStore();
await mockClient.query({ query: stepListQuery }).catch((e) => {
mockApolloClient.clearStore();
await mockApolloClient.query({ query: stepListQuery }).catch((e) => {
expect(e instanceof Error).toBe(true);
});
});
......@@ -47,13 +34,13 @@ describe('ee/vue_shared/purchase_flow/graphql/resolvers', () => {
describe('activeStepQuery', () => {
it('stores the activeStep', async () => {
const queryResult = await mockClient.query({ query: activeStepQuery });
const queryResult = await mockApolloClient.query({ query: activeStepQuery });
expect(queryResult.data.activeStep).toMatchObject({ id: STEPS[0].id });
});
it('throws an error when cache is not initiated properly', async () => {
mockClient.clearStore();
await mockClient.query({ query: activeStepQuery }).catch((e) => {
mockApolloClient.clearStore();
await mockApolloClient.query({ query: activeStepQuery }).catch((e) => {
expect(e instanceof Error).toBe(true);
});
});
......@@ -62,18 +49,23 @@ describe('ee/vue_shared/purchase_flow/graphql/resolvers', () => {
describe('Mutation', () => {
describe('updateActiveStep', () => {
beforeEach(async () => {
const mockApollo = createMockApolloProvider(STEPS, 0);
mockApolloClient = mockApollo.clients.defaultClient;
});
it('updates the active step', async () => {
await mockClient.mutate({
await mockApolloClient.mutate({
mutation: updateStepMutation,
variables: { id: STEPS[1].id },
});
const queryResult = await mockClient.query({ query: activeStepQuery });
const queryResult = await mockApolloClient.query({ query: activeStepQuery });
expect(queryResult.data.activeStep).toMatchObject({ id: STEPS[1].id });
});
it('throws an error when STEP is not present', async () => {
const id = 'does not exist';
await mockClient
await mockApolloClient
.mutate({
mutation: updateStepMutation,
variables: { id },
......@@ -84,8 +76,8 @@ describe('ee/vue_shared/purchase_flow/graphql/resolvers', () => {
});
it('throws an error when cache is not initiated properly', async () => {
mockClient.clearStore();
await mockClient
mockApolloClient.clearStore();
await mockApolloClient
.mutate({
mutation: updateStepMutation,
variables: { id: STEPS[1].id },
......@@ -98,19 +90,20 @@ describe('ee/vue_shared/purchase_flow/graphql/resolvers', () => {
describe('activateNextStep', () => {
it('updates the active step to the next', async () => {
await mockClient.mutate({
const mockApollo = createMockApolloProvider(STEPS, 0);
mockApolloClient = mockApollo.clients.defaultClient;
await mockApolloClient.mutate({
mutation: activateNextStepMutation,
});
const queryResult = await mockClient.query({ query: activeStepQuery });
const queryResult = await mockApolloClient.query({ query: activeStepQuery });
expect(queryResult.data.activeStep).toMatchObject({ id: STEPS[1].id });
});
it('throws an error when out of bounds', async () => {
await mockClient.mutate({
mutation: activateNextStepMutation,
});
const mockApollo = createMockApolloProvider(STEPS, 2);
mockApolloClient = mockApollo.clients.defaultClient;
await mockClient
await mockApolloClient
.mutate({
mutation: activateNextStepMutation,
})
......@@ -120,8 +113,8 @@ describe('ee/vue_shared/purchase_flow/graphql/resolvers', () => {
});
it('throws an error when cache is not initiated properly', async () => {
mockClient.clearStore();
await mockClient
mockApolloClient.clearStore();
await mockApolloClient
.mutate({
mutation: activateNextStepMutation,
})
......
export const STEPS = [
{ __typename: 'Step', id: 'firstStep' },
{ __typename: 'Step', id: 'secondStep' },
{ __typename: 'Step', id: 'finalStep' },
];
......@@ -25506,6 +25506,9 @@ msgstr ""
msgid "Purchase more storage"
msgstr ""
msgid "PurchaseStep|An error occured in the purchase step. If the problem persists please contact support@gitlab.com."
msgstr ""
msgid "Push"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment