Commit ffe7f77f authored by Frédéric Caplette's avatar Frédéric Caplette

Merge branch 'dismissible-user-callout-component' into 'master'

Add UserCalloutDismisser component

See merge request gitlab-org/gitlab!63243
parents 655791a5 5ed41dac
<script>
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.query.graphql';
import { __ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants';
import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql';
import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql';
import { reportToSentry, reportMessageToSentry } from '../../utils';
import { listByLayers } from '../parsing_utils';
import { IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants';
......
<script>
import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
import getUserCalloutsQuery from '~/graphql_shared/queries/get_user_callouts.query.graphql';
/**
* A renderless component for querying/dismissing UserCallouts via GraphQL.
*
* Simplest example usage:
*
* <user-callout-dismisser feature-name="my_user_callout">
* <template #default="{ dismiss, shouldShowCallout }">
* <my-callout-component
* v-if="shouldShowCallout"
* @close="dismiss"
* />
* </template>
* </user-callout-dismisser>
*
* If you don't want the asynchronous query to run when the component is
* created, and know by some other means whether the user callout has already
* been dismissed, you can use the `skipQuery` prop, and a regular `v-if`
* directive:
*
* <user-callout-dismisser
* v-if="userCalloutIsNotDismissed"
* feature-name="my_user_callout"
* skip-query
* >
* <template #default="{ dismiss, shouldShowCallout }">
* <my-callout-component
* v-if="shouldShowCallout"
* @close="dismiss"
* />
* </template>
* </user-callout-dismisser>
*
* The component exposes various scoped slot props on the default slot,
* allowing for granular rendering behaviors based on the state of the initial
* query and user-initiated mutation:
*
* - dismiss: Function
* - Triggers mutation to dismiss the user callout.
* - isAnonUser: boolean
* - Whether the current user is anonymous or not (i.e., whether or not
* they're logged in).
* - isDismissed: boolean
* - Whether the given user callout has been dismissed or not.
* - isLoadingMutation: boolean
* - Whether the mutation is loading.
* - isLoadingQuery: boolean
* - Whether the initial query is loading.
* - mutationError: string[] | null
* - The mutation's errors, if any; otherwise `null`.
* - queryError: Error | null
* - The query's error, if any; otherwise `null`.
* - shouldShowCallout: boolean
* - A combination of the above which should cover 95% of use cases: `true`
* if the query has loaded without error, and the user is logged in, and
* the callout has not been dismissed yet; `false` otherwise.
*/
export default {
name: 'UserCalloutDismisser',
props: {
featureName: {
type: String,
required: true,
},
skipQuery: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
currentUser: null,
isDismissedLocal: false,
isLoadingMutation: false,
mutationError: null,
queryError: null,
};
},
apollo: {
currentUser: {
query: getUserCalloutsQuery,
update(data) {
return data?.currentUser;
},
error(err) {
this.queryError = err;
},
skip() {
return this.skipQuery;
},
},
},
computed: {
featureNameEnumValue() {
return this.featureName.toUpperCase();
},
isLoadingQuery() {
return this.$apollo.queries.currentUser.loading;
},
isAnonUser() {
return !(this.skipQuery || this.queryError || this.isLoadingQuery || this.currentUser);
},
isDismissedRemote() {
const callouts = this.currentUser?.callouts?.nodes ?? [];
return callouts.some(({ featureName }) => featureName === this.featureNameEnumValue);
},
isDismissed() {
return this.isDismissedLocal || this.isDismissedRemote;
},
slotProps() {
const {
dismiss,
isAnonUser,
isDismissed,
isLoadingMutation,
isLoadingQuery,
mutationError,
queryError,
shouldShowCallout,
} = this;
return {
dismiss,
isAnonUser,
isDismissed,
isLoadingMutation,
isLoadingQuery,
mutationError,
queryError,
shouldShowCallout,
};
},
shouldShowCallout() {
return !(this.isLoadingQuery || this.isDismissed || this.queryError || this.isAnonUser);
},
},
methods: {
async dismiss() {
this.isLoadingMutation = true;
this.isDismissedLocal = true;
try {
const { data } = await this.$apollo.mutate({
mutation: dismissUserCalloutMutation,
variables: {
input: {
featureName: this.featureName,
},
},
});
const errors = data?.userCalloutCreate?.errors ?? [];
if (errors.length > 0) {
this.onDismissalError(errors);
}
} catch (err) {
this.onDismissalError([err.message]);
} finally {
this.isLoadingMutation = false;
}
},
onDismissalError(errors) {
this.mutationError = errors;
},
},
render() {
return this.$scopedSlots.default(this.slotProps);
},
};
</script>
......@@ -5,6 +5,7 @@ import VueApollo from 'vue-apollo';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.query.graphql';
import {
IID_FAILURE,
LAYER_VIEW,
......@@ -17,7 +18,6 @@ import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import * as parsingUtils from '~/pipelines/components/parsing_utils';
import getUserCallouts from '~/pipelines/graphql/queries/get_user_callouts.query.graphql';
import { mapCallouts, mockCalloutsResponse, mockPipelineResponse } from './mock_data';
const defaultProvide = {
......
export const userCalloutsResponse = (callouts = []) => ({
data: {
currentUser: {
id: 'gid://gitlab/User/46',
__typename: 'UserCore',
callouts: {
__typename: 'UserCalloutConnection',
nodes: callouts.map((callout) => ({
__typename: 'UserCallout',
featureName: callout.toUpperCase(),
dismissedAt: '2021-02-12T11:10:01Z',
})),
},
},
},
});
export const anonUserCalloutsResponse = () => ({ data: { currentUser: null } });
export const userCalloutMutationResponse = (variables, errors = []) => ({
data: {
userCalloutCreate: {
errors,
userCallout: {
featureName: variables.input.featureName.toUpperCase(),
dismissedAt: '2021-02-12T11:10:01Z',
},
},
},
});
import { mount } from '@vue/test-utils';
import { merge } from 'lodash';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
import getUserCalloutsQuery from '~/graphql_shared/queries/get_user_callouts.query.graphql';
import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
import {
anonUserCalloutsResponse,
userCalloutMutationResponse,
userCalloutsResponse,
} from './user_callout_dismisser_mock_data';
Vue.use(VueApollo);
const initialSlotProps = (changes = {}) => ({
dismiss: expect.any(Function),
isAnonUser: false,
isDismissed: false,
isLoadingQuery: true,
isLoadingMutation: false,
mutationError: null,
queryError: null,
shouldShowCallout: false,
...changes,
});
describe('UserCalloutDismisser', () => {
let wrapper;
const MOCK_FEATURE_NAME = 'mock_feature_name';
// Query handlers
const successHandlerFactory = (dismissedCallouts = []) => async () =>
userCalloutsResponse(dismissedCallouts);
const anonUserHandler = async () => anonUserCalloutsResponse();
const errorHandler = () => Promise.reject(new Error('query error'));
const pendingHandler = () => new Promise(() => {});
// Mutation handlers
const mutationSuccessHandlerSpy = jest.fn(async (variables) =>
userCalloutMutationResponse(variables),
);
const mutationErrorHandlerSpy = jest.fn(async (variables) =>
userCalloutMutationResponse(variables, ['mutation error']),
);
const defaultScopedSlotSpy = jest.fn();
const callDismissSlotProp = () => defaultScopedSlotSpy.mock.calls[0][0].dismiss();
const createComponent = ({ queryHandler, mutationHandler, ...options }) => {
wrapper = mount(
UserCalloutDismisser,
merge(
{
propsData: {
featureName: MOCK_FEATURE_NAME,
},
scopedSlots: {
default: defaultScopedSlotSpy,
},
apolloProvider: createMockApollo([
[getUserCalloutsQuery, queryHandler],
[dismissUserCalloutMutation, mutationHandler],
]),
},
options,
),
);
};
afterEach(() => {
wrapper.destroy();
});
describe('when loading', () => {
beforeEach(() => {
createComponent({
queryHandler: pendingHandler,
});
});
it('passes expected slot props to child', () => {
expect(defaultScopedSlotSpy).lastCalledWith(initialSlotProps());
});
});
describe('when loaded and dismissed', () => {
beforeEach(() => {
createComponent({
queryHandler: successHandlerFactory([MOCK_FEATURE_NAME]),
});
return waitForPromises();
});
it('passes expected slot props to child', () => {
expect(defaultScopedSlotSpy).lastCalledWith(
initialSlotProps({
isDismissed: true,
isLoadingQuery: false,
}),
);
});
});
describe('when loaded and not dismissed', () => {
beforeEach(() => {
createComponent({
queryHandler: successHandlerFactory(),
});
return waitForPromises();
});
it('passes expected slot props to child', () => {
expect(defaultScopedSlotSpy).lastCalledWith(
initialSlotProps({
isLoadingQuery: false,
shouldShowCallout: true,
}),
);
});
});
describe('when loaded with errors', () => {
beforeEach(() => {
createComponent({
queryHandler: errorHandler,
});
return waitForPromises();
});
it('passes expected slot props to child', () => {
expect(defaultScopedSlotSpy).lastCalledWith(
initialSlotProps({
isLoadingQuery: false,
queryError: expect.any(Error),
}),
);
});
});
describe('when loaded and the user is anonymous', () => {
beforeEach(() => {
createComponent({
queryHandler: anonUserHandler,
});
return waitForPromises();
});
it('passes expected slot props to child', () => {
expect(defaultScopedSlotSpy).lastCalledWith(
initialSlotProps({
isAnonUser: true,
isLoadingQuery: false,
}),
);
});
});
describe('when skipQuery is true', () => {
let queryHandler;
beforeEach(() => {
queryHandler = jest.fn();
createComponent({
queryHandler,
propsData: {
skipQuery: true,
},
});
});
it('does not run the query', async () => {
expect(queryHandler).not.toHaveBeenCalled();
await waitForPromises();
expect(queryHandler).not.toHaveBeenCalled();
});
it('passes expected slot props to child', () => {
expect(defaultScopedSlotSpy).lastCalledWith(
initialSlotProps({
isLoadingQuery: false,
shouldShowCallout: true,
}),
);
});
});
describe('dismissing', () => {
describe('given it succeeds', () => {
beforeEach(() => {
createComponent({
queryHandler: successHandlerFactory(),
mutationHandler: mutationSuccessHandlerSpy,
});
return waitForPromises();
});
it('dismissing calls mutation', () => {
expect(mutationSuccessHandlerSpy).not.toHaveBeenCalled();
callDismissSlotProp();
expect(mutationSuccessHandlerSpy).toHaveBeenCalledWith({
input: { featureName: MOCK_FEATURE_NAME },
});
});
it('passes expected slot props to child', async () => {
expect(defaultScopedSlotSpy).lastCalledWith(
initialSlotProps({
isLoadingQuery: false,
shouldShowCallout: true,
}),
);
callDismissSlotProp();
// Wait for Vue re-render due to prop change
await nextTick();
expect(defaultScopedSlotSpy).lastCalledWith(
initialSlotProps({
isDismissed: true,
isLoadingMutation: true,
isLoadingQuery: false,
}),
);
// Wait for mutation to resolve
await waitForPromises();
expect(defaultScopedSlotSpy).lastCalledWith(
initialSlotProps({
isDismissed: true,
isLoadingQuery: false,
}),
);
});
});
describe('given it fails', () => {
beforeEach(() => {
createComponent({
queryHandler: successHandlerFactory(),
mutationHandler: mutationErrorHandlerSpy,
});
return waitForPromises();
});
it('calls mutation', () => {
expect(mutationErrorHandlerSpy).not.toHaveBeenCalled();
callDismissSlotProp();
expect(mutationErrorHandlerSpy).toHaveBeenCalledWith({
input: { featureName: MOCK_FEATURE_NAME },
});
});
it('passes expected slot props to child', async () => {
expect(defaultScopedSlotSpy).lastCalledWith(
initialSlotProps({
isLoadingQuery: false,
shouldShowCallout: true,
}),
);
callDismissSlotProp();
// Wait for Vue re-render due to prop change
await nextTick();
expect(defaultScopedSlotSpy).lastCalledWith(
initialSlotProps({
isDismissed: true,
isLoadingMutation: true,
isLoadingQuery: false,
}),
);
// Wait for mutation to resolve
await waitForPromises();
expect(defaultScopedSlotSpy).lastCalledWith(
initialSlotProps({
isDismissed: true,
isLoadingQuery: false,
mutationError: ['mutation error'],
}),
);
});
});
});
});
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