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>
import { GlAlert, GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { __ } from '~/locale';
import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
import securityTrainingProvidersQuery from '../graphql/security_training_providers.query.graphql';
import configureSecurityTrainingProvidersMutation from '../graphql/configure_security_training_providers.mutation.graphql';
......@@ -43,6 +45,7 @@ export default {
errorMessage: '',
toggleLoading: false,
securityTrainingProviders: [],
hasTouchedConfiguration: false,
};
},
computed: {
......@@ -50,7 +53,36 @@ export default {
return this.$apollo.queries.securityTrainingProviders.loading;
},
},
created() {
const unwatchConfigChance = this.$watch('hasTouchedConfiguration', () => {
this.dismissFeaturePromotionCallout();
unwatchConfigChance();
});
},
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) {
const toggledProviders = this.securityTrainingProviders.map((provider) => ({
...provider,
......@@ -85,6 +117,8 @@ export default {
// throwing an error here means we can handle scenarios within the `catch` block below
throw new Error();
}
this.hasTouchedConfiguration = true;
} catch {
this.errorMessage = this.$options.i18n.configMutationErrorMessage;
} finally {
......
......@@ -32,24 +32,28 @@ export default {
Mutation: {
configureSecurityTrainingProviders: (
_,
{ input: { enabledProviders, primaryProvider } },
{ input: { enabledProviders, primaryProvider, fullPath } },
{ cache },
) => {
const sourceData = cache.readQuery({
query: securityTrainingProvidersQuery,
variables: {
fullPath,
},
});
const data = produce(sourceData.securityTrainingProviders, (draftData) => {
const data = produce(sourceData.project, (draftData) => {
/* eslint-disable no-param-reassign */
draftData.forEach((provider) => {
draftData.securityTrainingProviders.forEach((provider) => {
provider.isPrimary = provider.id === primaryProvider;
provider.isEnabled =
provider.id === primaryProvider || enabledProviders.includes(provider.id);
});
});
return {
__typename: 'configureSecurityTrainingProvidersPayload',
securityTrainingProviders: data,
securityTrainingProviders: data.securityTrainingProviders,
};
},
},
......
import * as Sentry from '@sentry/browser';
import { GlAlert, GlLink, GlToggle, GlCard, GlSkeletonLoader } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
......@@ -6,8 +7,11 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue';
import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.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 {
dismissUserCalloutResponse,
dismissUserCalloutErrorResponse,
securityTrainingProviders,
securityTrainingProvidersResponse,
testProjectPath,
......@@ -20,13 +24,18 @@ describe('TrainingProviderList component', () => {
let wrapper;
let apolloProvider;
const createApolloProvider = ({ resolvers, queryHandler } = {}) => {
const defaultQueryHandler = jest.fn().mockResolvedValue(securityTrainingProvidersResponse);
const createApolloProvider = ({ resolvers, handlers = [] } = {}) => {
const defaultHandlers = [
[
securityTrainingProvidersQuery,
jest.fn().mockResolvedValue(securityTrainingProvidersResponse),
],
];
apolloProvider = createMockApollo(
[[securityTrainingProvidersQuery, queryHandler || defaultQueryHandler]],
resolvers,
);
// make sure we don't have any duplicate handlers to avoid 'Request handler already defined for query` errors
const mergedHandlers = [...new Map([...defaultHandlers, ...handlers])];
apolloProvider = createMockApollo(mergedHandlers, resolvers);
};
const createComponent = () => {
......@@ -60,7 +69,7 @@ describe('TrainingProviderList component', () => {
const pendingHandler = () => new Promise(() => {});
createApolloProvider({
queryHandler: pendingHandler,
handlers: [[securityTrainingProvidersQuery, pendingHandler]],
});
createComponent();
});
......@@ -76,7 +85,20 @@ describe('TrainingProviderList component', () => {
describe('with a successful response', () => {
beforeEach(() => {
createApolloProvider();
createApolloProvider({
handlers: [
[dismissUserCalloutMutation, jest.fn().mockResolvedValue(dismissUserCalloutResponse)],
],
resolvers: {
Mutation: {
configureSecurityTrainingProviders: () => ({
errors: [],
securityTrainingProviders: [],
}),
},
},
});
createComponent();
});
......@@ -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', () => {
describe('when fetching training providers', () => {
beforeEach(async () => {
createApolloProvider({
queryHandler: jest.fn().mockReturnValue(new Error()),
handlers: [[securityTrainingProvidersQuery, jest.fn().mockRejectedValue()]],
});
createComponent();
......@@ -200,5 +253,39 @@ describe('TrainingProviderList component', () => {
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 = {
},
},
};
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