Commit 983eadcf authored by Savas Vedova's avatar Savas Vedova

Merge branch '348711-error-handling-for-configuration-security-training-mutation' into 'master'

Add error handling to security training configuration (GraphQL query and mutation)

See merge request gitlab-org/gitlab!77166
parents 748862ff b405e234
<script> <script>
import { GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui'; import { GlAlert, GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui';
import { __ } from '~/locale';
import securityTrainingProvidersQuery from '../graphql/security_training_providers.query.graphql'; import securityTrainingProvidersQuery from '../graphql/security_training_providers.query.graphql';
import configureSecurityTrainingProvidersMutation from '../graphql/configure_security_training_providers.mutation.graphql'; import configureSecurityTrainingProvidersMutation from '../graphql/configure_security_training_providers.mutation.graphql';
const i18n = {
providerQueryErrorMessage: __(
'Could not fetch training providers. Please refresh the page, or try again later.',
),
configMutationErrorMessage: __(
'Could not save configuration. Please refresh the page, or try again later.',
),
};
export default { export default {
components: { components: {
GlAlert,
GlCard, GlCard,
GlToggle, GlToggle,
GlLink, GlLink,
...@@ -14,10 +25,14 @@ export default { ...@@ -14,10 +25,14 @@ export default {
apollo: { apollo: {
securityTrainingProviders: { securityTrainingProviders: {
query: securityTrainingProvidersQuery, query: securityTrainingProvidersQuery,
error() {
this.errorMessage = this.$options.i18n.providerQueryErrorMessage;
},
}, },
}, },
data() { data() {
return { return {
errorMessage: '',
toggleLoading: false, toggleLoading: false,
securityTrainingProviders: [], securityTrainingProviders: [],
}; };
...@@ -34,17 +49,21 @@ export default { ...@@ -34,17 +49,21 @@ export default {
...(provider.id === selectedProviderId && { isEnabled: !provider.isEnabled }), ...(provider.id === selectedProviderId && { isEnabled: !provider.isEnabled }),
})); }));
this.storeEnabledProviders(toggledProviders);
},
storeEnabledProviders(toggledProviders) {
const enabledProviderIds = toggledProviders const enabledProviderIds = toggledProviders
.filter(({ isEnabled }) => isEnabled) .filter(({ isEnabled }) => isEnabled)
.map(({ id }) => id); .map(({ id }) => id);
this.storeEnabledProviders(toggledProviders, enabledProviderIds);
},
async storeEnabledProviders(toggledProviders, enabledProviderIds) {
this.toggleLoading = true; this.toggleLoading = true;
return this.$apollo try {
.mutate({ const {
data: {
configureSecurityTrainingProviders: { errors = [] },
},
} = await this.$apollo.mutate({
mutation: configureSecurityTrainingProvidersMutation, mutation: configureSecurityTrainingProvidersMutation,
variables: { variables: {
input: { input: {
...@@ -52,50 +71,63 @@ export default { ...@@ -52,50 +71,63 @@ export default {
fullPath: this.projectPath, fullPath: this.projectPath,
}, },
}, },
})
.then(() => {
this.toggleLoading = false;
}); });
if (errors.length > 0) {
// throwing an error here means we can handle scenarios within the `catch` block below
throw new Error();
}
} catch {
this.errorMessage = this.$options.i18n.configMutationErrorMessage;
} finally {
this.toggleLoading = false;
}
}, },
}, },
i18n,
}; };
</script> </script>
<template> <template>
<div <div>
v-if="isLoading" <gl-alert v-if="errorMessage" variant="danger" :dismissible="false" class="gl-mb-6">
class="gl-bg-white gl-py-6 gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100" {{ errorMessage }}
> </gl-alert>
<gl-skeleton-loader :width="350" :height="44"> <div
<rect width="200" height="8" x="10" y="0" rx="4" /> v-if="isLoading"
<rect width="300" height="8" x="10" y="15" rx="4" /> class="gl-bg-white gl-py-6 gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100"
<rect width="100" height="8" x="10" y="35" rx="4" />
</gl-skeleton-loader>
</div>
<ul v-else class="gl-list-style-none gl-m-0 gl-p-0">
<li
v-for="{ id, isEnabled, name, description, url } in securityTrainingProviders"
:key="id"
class="gl-mb-6"
> >
<gl-card> <gl-skeleton-loader :width="350" :height="44">
<div class="gl-display-flex"> <rect width="200" height="8" x="10" y="0" rx="4" />
<gl-toggle <rect width="300" height="8" x="10" y="15" rx="4" />
:value="isEnabled" <rect width="100" height="8" x="10" y="35" rx="4" />
:label="__('Training mode')" </gl-skeleton-loader>
label-position="hidden" </div>
:is-loading="toggleLoading" <ul v-else class="gl-list-style-none gl-m-0 gl-p-0">
@change="toggleProvider(id)" <li
/> v-for="{ id, isEnabled, name, description, url } in securityTrainingProviders"
<div class="gl-ml-5"> :key="id"
<h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ name }}</h3> class="gl-mb-6"
<p> >
{{ description }} <gl-card>
<gl-link :href="url" target="_blank">{{ __('Learn more.') }}</gl-link> <div class="gl-display-flex">
</p> <gl-toggle
:value="isEnabled"
:label="__('Training mode')"
label-position="hidden"
:is-loading="toggleLoading"
@change="toggleProvider(id)"
/>
<div class="gl-ml-5">
<h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ name }}</h3>
<p>
{{ description }}
<gl-link :href="url" target="_blank">{{ __('Learn more.') }}</gl-link>
</p>
</div>
</div> </div>
</div> </gl-card>
</gl-card> </li>
</li> </ul>
</ul> </div>
</template> </template>
mutation configureSecurityTrainingProviders($input: configureSecurityTrainingProvidersInput!) { mutation configureSecurityTrainingProviders($input: configureSecurityTrainingProvidersInput!) {
configureSecurityTrainingProviders(input: $input) @client { configureSecurityTrainingProviders(input: $input) @client {
errors
securityTrainingProviders { securityTrainingProviders {
id id
isEnabled isEnabled
......
...@@ -9853,6 +9853,9 @@ msgstr "" ...@@ -9853,6 +9853,9 @@ msgstr ""
msgid "Could not fetch policy because existing policy YAML is invalid" msgid "Could not fetch policy because existing policy YAML is invalid"
msgstr "" msgstr ""
msgid "Could not fetch training providers. Please refresh the page, or try again later."
msgstr ""
msgid "Could not find design." msgid "Could not find design."
msgstr "" msgstr ""
...@@ -9889,6 +9892,9 @@ msgstr "" ...@@ -9889,6 +9892,9 @@ msgstr ""
msgid "Could not revoke project access token %{project_access_token_name}." msgid "Could not revoke project access token %{project_access_token_name}."
msgstr "" msgstr ""
msgid "Could not save configuration. Please refresh the page, or try again later."
msgstr ""
msgid "Could not save group ID" msgid "Could not save group ID"
msgstr "" msgstr ""
......
import { GlLink, GlToggle, GlCard, GlSkeletonLoader } from '@gitlab/ui'; import { GlAlert, GlLink, GlToggle, GlCard, GlSkeletonLoader } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
...@@ -8,7 +8,7 @@ import configureSecurityTrainingProvidersMutation from '~/security_configuration ...@@ -8,7 +8,7 @@ import configureSecurityTrainingProvidersMutation from '~/security_configuration
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { import {
securityTrainingProviders, securityTrainingProviders,
mockResolvers, createMockResolvers,
testProjectPath, testProjectPath,
textProviderIds, textProviderIds,
} from '../mock_data'; } from '../mock_data';
...@@ -17,113 +17,177 @@ Vue.use(VueApollo); ...@@ -17,113 +17,177 @@ Vue.use(VueApollo);
describe('TrainingProviderList component', () => { describe('TrainingProviderList component', () => {
let wrapper; let wrapper;
let mockApollo; let apolloProvider;
let mockSecurityTrainingProvidersData;
const createComponent = () => { const createApolloProvider = ({ resolvers } = {}) => {
mockApollo = createMockApollo([], mockResolvers); apolloProvider = createMockApollo([], createMockResolvers({ resolvers }));
};
const createComponent = () => {
wrapper = shallowMount(TrainingProviderList, { wrapper = shallowMount(TrainingProviderList, {
provide: { provide: {
projectPath: testProjectPath, projectPath: testProjectPath,
}, },
apolloProvider: mockApollo, apolloProvider,
}); });
}; };
const waitForQueryToBeLoaded = () => waitForPromises(); const waitForQueryToBeLoaded = () => waitForPromises();
const waitForMutationToBeLoaded = waitForQueryToBeLoaded;
const findCards = () => wrapper.findAllComponents(GlCard); const findCards = () => wrapper.findAllComponents(GlCard);
const findLinks = () => wrapper.findAllComponents(GlLink); const findLinks = () => wrapper.findAllComponents(GlLink);
const findToggles = () => wrapper.findAllComponents(GlToggle); const findToggles = () => wrapper.findAllComponents(GlToggle);
const findFirstToggle = () => findToggles().at(0);
const findLoader = () => wrapper.findComponent(GlSkeletonLoader); const findLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findErrorAlert = () => wrapper.findComponent(GlAlert);
beforeEach(() => { const toggleFirstProvider = () => findFirstToggle().vm.$emit('change');
mockSecurityTrainingProvidersData = jest.fn();
mockSecurityTrainingProvidersData.mockResolvedValue(securityTrainingProviders);
createComponent();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
mockApollo = null; apolloProvider = null;
}); });
describe('when loading', () => { describe('with a successful response', () => {
it('shows the loader', () => { beforeEach(() => {
expect(findLoader().exists()).toBe(true); createApolloProvider();
createComponent();
}); });
it('does not show the cards', () => { describe('when loading', () => {
expect(findCards().exists()).toBe(false); it('shows the loader', () => {
}); expect(findLoader().exists()).toBe(true);
}); });
describe('basic structure', () => {
beforeEach(async () => {
await waitForQueryToBeLoaded();
});
it('renders correct amount of cards', () => { it('does not show the cards', () => {
expect(findCards()).toHaveLength(securityTrainingProviders.length); expect(findCards().exists()).toBe(false);
});
}); });
securityTrainingProviders.forEach(({ name, description, url, isEnabled }, index) => { describe('basic structure', () => {
it(`shows the name for card ${index}`, () => { beforeEach(async () => {
expect(findCards().at(index).text()).toContain(name); await waitForQueryToBeLoaded();
}); });
it(`shows the description for card ${index}`, () => { it('renders correct amount of cards', () => {
expect(findCards().at(index).text()).toContain(description); expect(findCards()).toHaveLength(securityTrainingProviders.length);
}); });
it(`shows the learn more link for card ${index}`, () => { securityTrainingProviders.forEach(({ name, description, url, isEnabled }, index) => {
expect(findLinks().at(index).attributes()).toEqual({ it(`shows the name for card ${index}`, () => {
target: '_blank', expect(findCards().at(index).text()).toContain(name);
href: url, });
it(`shows the description for card ${index}`, () => {
expect(findCards().at(index).text()).toContain(description);
});
it(`shows the learn more link for card ${index}`, () => {
expect(findLinks().at(index).attributes()).toEqual({
target: '_blank',
href: url,
});
});
it(`shows the toggle with the correct value for card ${index}`, () => {
expect(findToggles().at(index).props('value')).toEqual(isEnabled);
});
it('does not show loader when query is populated', () => {
expect(findLoader().exists()).toBe(false);
}); });
}); });
});
describe('storing training provider settings', () => {
beforeEach(async () => {
jest.spyOn(apolloProvider.defaultClient, 'mutate');
it(`shows the toggle with the correct value for card ${index}`, () => { await waitForMutationToBeLoaded();
expect(findToggles().at(index).props('value')).toEqual(isEnabled);
toggleFirstProvider();
}); });
it('does not show loader when query is populated', () => { it.each`
expect(findLoader().exists()).toBe(false); loading | wait | desc
${true} | ${false} | ${'enables loading of GlToggle when mutation is called'}
${false} | ${true} | ${'disables loading of GlToggle when mutation is complete'}
`('$desc', async ({ loading, wait }) => {
if (wait) {
await waitForMutationToBeLoaded();
}
expect(findFirstToggle().props('isLoading')).toBe(loading);
});
it('calls mutation when toggle is changed', () => {
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: configureSecurityTrainingProvidersMutation,
variables: { input: { enabledProviders: textProviderIds, fullPath: testProjectPath } },
}),
);
}); });
}); });
}); });
describe('success mutation', () => { describe('with errors', () => {
const firstToggle = () => findToggles().at(0); const expectErrorAlertToExist = () => {
expect(findErrorAlert().props()).toMatchObject({
dismissible: false,
variant: 'danger',
});
};
describe('when fetching training providers', () => {
beforeEach(async () => {
createApolloProvider({
resolvers: {
Query: {
securityTrainingProviders: jest.fn().mockReturnValue(new Error()),
},
},
});
createComponent();
beforeEach(async () => { await waitForQueryToBeLoaded();
jest.spyOn(mockApollo.defaultClient, 'mutate'); });
await waitForQueryToBeLoaded(); it('shows an non-dismissible error alert', () => {
expectErrorAlertToExist();
});
firstToggle().vm.$emit('change'); it('shows an error description', () => {
expect(findErrorAlert().text()).toBe(TrainingProviderList.i18n.providerQueryErrorMessage);
});
}); });
it('calls mutation when toggle is changed', () => { describe('when storing training provider configurations', () => {
expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith( beforeEach(async () => {
expect.objectContaining({ createApolloProvider({
mutation: configureSecurityTrainingProvidersMutation, resolvers: {
variables: { input: { enabledProviders: textProviderIds, fullPath: testProjectPath } }, Mutation: {
}), configureSecurityTrainingProviders: () => ({
); errors: ['something went wrong!'],
}); securityTrainingProviders: [],
}),
},
},
});
createComponent();
it.each` await waitForQueryToBeLoaded();
loading | wait | desc toggleFirstProvider();
${true} | ${false} | ${'enables loading of GlToggle when mutation is called'} await waitForMutationToBeLoaded();
${false} | ${true} | ${'disables loading of GlToggle when mutation is complete'} });
`('$desc', async ({ loading, wait }) => {
if (wait) { it('shows an non-dismissible error alert', () => {
await waitForPromises(); expectErrorAlertToExist();
} });
expect(firstToggle().props('isLoading')).toBe(loading);
it('shows an error description', () => {
expect(findErrorAlert().text()).toBe(TrainingProviderList.i18n.configMutationErrorMessage);
});
}); });
}); });
}); });
...@@ -25,10 +25,15 @@ export const securityTrainingProvidersResponse = { ...@@ -25,10 +25,15 @@ export const securityTrainingProvidersResponse = {
}, },
}; };
export const mockResolvers = { const defaultMockResolvers = {
Query: { Query: {
securityTrainingProviders() { securityTrainingProviders() {
return securityTrainingProviders; return securityTrainingProviders;
}, },
}, },
}; };
export const createMockResolvers = ({ resolvers: customMockResolvers = {} } = {}) => ({
...defaultMockResolvers,
...customMockResolvers,
});
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