Commit ea4ec87b authored by David Pisek's avatar David Pisek Committed by Ezekiel Kigbo

Dismiss security-training promo when enabling it

This commit adds a backend call (GraphQL) mutation, which
dismisses the promotion callout for the security-training feature,
when a user enables the training within the config.
parent b4672964
<script> <script>
import { GlAlert, GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui'; import { GlAlert, GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { __ } from '~/locale'; import { __ } from '~/locale';
import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
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';
...@@ -43,6 +45,7 @@ export default { ...@@ -43,6 +45,7 @@ export default {
errorMessage: '', errorMessage: '',
toggleLoading: false, toggleLoading: false,
securityTrainingProviders: [], securityTrainingProviders: [],
hasTouchedConfiguration: false,
}; };
}, },
computed: { computed: {
...@@ -50,7 +53,36 @@ export default { ...@@ -50,7 +53,36 @@ export default {
return this.$apollo.queries.securityTrainingProviders.loading; return this.$apollo.queries.securityTrainingProviders.loading;
}, },
}, },
created() {
const unwatchConfigChance = this.$watch('hasTouchedConfiguration', () => {
this.dismissFeaturePromotionCallout();
unwatchConfigChance();
});
},
methods: { methods: {
async dismissFeaturePromotionCallout() {
try {
const {
data: {
userCalloutCreate: { errors },
},
} = await this.$apollo.mutate({
mutation: dismissUserCalloutMutation,
variables: {
input: {
featureName: 'security_training_feature_promotion',
},
},
});
// handle errors reported from the backend
if (errors?.length > 0) {
throw new Error(errors[0]);
}
} catch (e) {
Sentry.captureException(e);
}
},
toggleProvider(selectedProviderId) { toggleProvider(selectedProviderId) {
const toggledProviders = this.securityTrainingProviders.map((provider) => ({ const toggledProviders = this.securityTrainingProviders.map((provider) => ({
...provider, ...provider,
...@@ -85,6 +117,8 @@ export default { ...@@ -85,6 +117,8 @@ export default {
// throwing an error here means we can handle scenarios within the `catch` block below // throwing an error here means we can handle scenarios within the `catch` block below
throw new Error(); throw new Error();
} }
this.hasTouchedConfiguration = true;
} catch { } catch {
this.errorMessage = this.$options.i18n.configMutationErrorMessage; this.errorMessage = this.$options.i18n.configMutationErrorMessage;
} finally { } finally {
......
...@@ -32,24 +32,28 @@ export default { ...@@ -32,24 +32,28 @@ export default {
Mutation: { Mutation: {
configureSecurityTrainingProviders: ( configureSecurityTrainingProviders: (
_, _,
{ input: { enabledProviders, primaryProvider } }, { input: { enabledProviders, primaryProvider, fullPath } },
{ cache }, { cache },
) => { ) => {
const sourceData = cache.readQuery({ const sourceData = cache.readQuery({
query: securityTrainingProvidersQuery, query: securityTrainingProvidersQuery,
variables: {
fullPath,
},
}); });
const data = produce(sourceData.securityTrainingProviders, (draftData) => { const data = produce(sourceData.project, (draftData) => {
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
draftData.forEach((provider) => { draftData.securityTrainingProviders.forEach((provider) => {
provider.isPrimary = provider.id === primaryProvider; provider.isPrimary = provider.id === primaryProvider;
provider.isEnabled = provider.isEnabled =
provider.id === primaryProvider || enabledProviders.includes(provider.id); provider.id === primaryProvider || enabledProviders.includes(provider.id);
}); });
}); });
return { return {
__typename: 'configureSecurityTrainingProvidersPayload', __typename: 'configureSecurityTrainingProvidersPayload',
securityTrainingProviders: data, securityTrainingProviders: data.securityTrainingProviders,
}; };
}, },
}, },
......
import * as Sentry from '@sentry/browser';
import { GlAlert, 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';
...@@ -6,8 +7,11 @@ import createMockApollo from 'helpers/mock_apollo_helper'; ...@@ -6,8 +7,11 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue'; import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue';
import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql'; import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql';
import configureSecurityTrainingProvidersMutation from '~/security_configuration/graphql/configure_security_training_providers.mutation.graphql'; import configureSecurityTrainingProvidersMutation from '~/security_configuration/graphql/configure_security_training_providers.mutation.graphql';
import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { import {
dismissUserCalloutResponse,
dismissUserCalloutErrorResponse,
securityTrainingProviders, securityTrainingProviders,
securityTrainingProvidersResponse, securityTrainingProvidersResponse,
testProjectPath, testProjectPath,
...@@ -20,13 +24,18 @@ describe('TrainingProviderList component', () => { ...@@ -20,13 +24,18 @@ describe('TrainingProviderList component', () => {
let wrapper; let wrapper;
let apolloProvider; let apolloProvider;
const createApolloProvider = ({ resolvers, queryHandler } = {}) => { const createApolloProvider = ({ resolvers, handlers = [] } = {}) => {
const defaultQueryHandler = jest.fn().mockResolvedValue(securityTrainingProvidersResponse); const defaultHandlers = [
[
securityTrainingProvidersQuery,
jest.fn().mockResolvedValue(securityTrainingProvidersResponse),
],
];
apolloProvider = createMockApollo( // make sure we don't have any duplicate handlers to avoid 'Request handler already defined for query` errors
[[securityTrainingProvidersQuery, queryHandler || defaultQueryHandler]], const mergedHandlers = [...new Map([...defaultHandlers, ...handlers])];
resolvers,
); apolloProvider = createMockApollo(mergedHandlers, resolvers);
}; };
const createComponent = () => { const createComponent = () => {
...@@ -60,7 +69,7 @@ describe('TrainingProviderList component', () => { ...@@ -60,7 +69,7 @@ describe('TrainingProviderList component', () => {
const pendingHandler = () => new Promise(() => {}); const pendingHandler = () => new Promise(() => {});
createApolloProvider({ createApolloProvider({
queryHandler: pendingHandler, handlers: [[securityTrainingProvidersQuery, pendingHandler]],
}); });
createComponent(); createComponent();
}); });
...@@ -76,7 +85,20 @@ describe('TrainingProviderList component', () => { ...@@ -76,7 +85,20 @@ describe('TrainingProviderList component', () => {
describe('with a successful response', () => { describe('with a successful response', () => {
beforeEach(() => { beforeEach(() => {
createApolloProvider(); createApolloProvider({
handlers: [
[dismissUserCalloutMutation, jest.fn().mockResolvedValue(dismissUserCalloutResponse)],
],
resolvers: {
Mutation: {
configureSecurityTrainingProviders: () => ({
errors: [],
securityTrainingProviders: [],
}),
},
},
});
createComponent(); createComponent();
}); });
...@@ -143,6 +165,37 @@ describe('TrainingProviderList component', () => { ...@@ -143,6 +165,37 @@ describe('TrainingProviderList component', () => {
}), }),
); );
}); });
it('dismisses the callout when the feature gets first enabled', async () => {
// wait for configuration update mutation to complete
await waitForMutationToBeLoaded();
// both the config and dismiss mutations have been called
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledTimes(2);
expect(apolloProvider.defaultClient.mutate).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
mutation: dismissUserCalloutMutation,
variables: {
input: {
featureName: 'security_training_feature_promotion',
},
},
}),
);
toggleFirstProvider();
await waitForMutationToBeLoaded();
// the config mutation has been called again but not the dismiss mutation
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledTimes(3);
expect(apolloProvider.defaultClient.mutate).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
mutation: configureSecurityTrainingProvidersMutation,
}),
);
});
}); });
}); });
...@@ -157,7 +210,7 @@ describe('TrainingProviderList component', () => { ...@@ -157,7 +210,7 @@ describe('TrainingProviderList component', () => {
describe('when fetching training providers', () => { describe('when fetching training providers', () => {
beforeEach(async () => { beforeEach(async () => {
createApolloProvider({ createApolloProvider({
queryHandler: jest.fn().mockReturnValue(new Error()), handlers: [[securityTrainingProvidersQuery, jest.fn().mockRejectedValue()]],
}); });
createComponent(); createComponent();
...@@ -200,5 +253,39 @@ describe('TrainingProviderList component', () => { ...@@ -200,5 +253,39 @@ describe('TrainingProviderList component', () => {
expect(findErrorAlert().text()).toBe(TrainingProviderList.i18n.configMutationErrorMessage); expect(findErrorAlert().text()).toBe(TrainingProviderList.i18n.configMutationErrorMessage);
}); });
}); });
describe.each`
errorType | mutationHandler
${'backend error'} | ${jest.fn().mockReturnValue(dismissUserCalloutErrorResponse)}
${'network error'} | ${jest.fn().mockRejectedValue()}
`('when dismissing the callout and a "$errorType" happens', ({ mutationHandler }) => {
beforeEach(async () => {
jest.spyOn(Sentry, 'captureException').mockImplementation();
createApolloProvider({
handlers: [[dismissUserCalloutMutation, mutationHandler]],
resolvers: {
Mutation: {
configureSecurityTrainingProviders: () => ({
errors: [],
securityTrainingProviders: [],
}),
},
},
});
createComponent();
await waitForQueryToBeLoaded();
toggleFirstProvider();
});
it('logs the error to sentry', async () => {
expect(Sentry.captureException).not.toHaveBeenCalled();
await waitForMutationToBeLoaded();
expect(Sentry.captureException).toHaveBeenCalled();
});
});
}); });
}); });
...@@ -27,3 +27,27 @@ export const securityTrainingProvidersResponse = { ...@@ -27,3 +27,27 @@ export const securityTrainingProvidersResponse = {
}, },
}, },
}; };
export const dismissUserCalloutResponse = {
data: {
userCalloutCreate: {
errors: [],
userCallout: {
dismissedAt: '2022-02-02T04:36:57Z',
featureName: 'SECURITY_TRAINING_FEATURE_PROMOTION',
},
},
},
};
export const dismissUserCalloutErrorResponse = {
data: {
userCalloutCreate: {
errors: ['Something went wrong'],
userCallout: {
dismissedAt: '',
featureName: 'SECURITY_TRAINING_FEATURE_PROMOTION',
},
},
},
};
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