Commit 8da6b7f5 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch '271247-fe-devops-report-segment-modal' into 'master'

DevOps Report: Integrate segment modal

See merge request gitlab-org/gitlab!48225
parents 0dff644a 1b15621c
<script> <script>
import { GlFormGroup, GlFormInput, GlFormCheckboxTree, GlModal, GlSprintf } from '@gitlab/ui'; import {
GlFormGroup,
GlFormInput,
GlFormCheckboxTree,
GlModal,
GlSprintf,
GlAlert,
} from '@gitlab/ui';
import { convertToGraphQLIds, TYPE_GROUP } from '~/graphql_shared/utils';
import * as Sentry from '~/sentry/wrapper';
import createDevopsAdoptionSegmentMutation from '../graphql/mutations/create_devops_adoption_segment.mutation.graphql';
import { DEVOPS_ADOPTION_STRINGS, DEVOPS_ADOPTION_SEGMENT_MODAL_ID } from '../constants'; import { DEVOPS_ADOPTION_STRINGS, DEVOPS_ADOPTION_SEGMENT_MODAL_ID } from '../constants';
export default { export default {
...@@ -10,6 +20,7 @@ export default { ...@@ -10,6 +20,7 @@ export default {
GlFormInput, GlFormInput,
GlFormCheckboxTree, GlFormCheckboxTree,
GlSprintf, GlSprintf,
GlAlert,
}, },
props: { props: {
segmentId: { segmentId: {
...@@ -27,43 +38,109 @@ export default { ...@@ -27,43 +38,109 @@ export default {
return { return {
name: '', name: '',
checkboxValues: [], checkboxValues: [],
loading: false,
errors: [],
}; };
}, },
computed: { computed: {
checkboxOptions() { checkboxOptions() {
return this.groups.map(({ id, full_name }) => ({ label: full_name, value: id })); return this.groups.map(({ id, full_name }) => ({ label: full_name, value: id }));
}, },
cancelOptions() {
return {
text: this.$options.i18n.cancel,
attributes: [{ disabled: this.loading }],
};
},
primaryOptions() {
return {
text: this.$options.i18n.button,
attributes: [
{
variant: 'info',
loading: this.loading,
disabled: !this.canSubmit,
},
],
};
},
canSubmit() {
return this.name.length && this.checkboxValues.length;
},
displayError() {
return this.errors[0];
},
}, },
methods: { methods: {
createSegment() {}, async createSegment() {
try {
this.loading = true;
const {
data: {
createDevopsAdoptionSegment: { errors },
},
} = await this.$apollo.mutate({
mutation: createDevopsAdoptionSegmentMutation,
variables: {
name: this.name,
groupIds: convertToGraphQLIds(TYPE_GROUP, this.checkboxValues),
},
});
if (errors.length) {
this.errors = errors;
} else {
this.name = '';
this.checkboxValues = [];
this.$refs.modal.hide();
}
} catch (error) {
this.errors.push(this.$options.i18n.error);
Sentry.captureException(error);
} finally {
this.loading = false;
}
},
clearErrors() {
this.errors = [];
},
}, },
devopsSegmentModalId: DEVOPS_ADOPTION_SEGMENT_MODAL_ID, devopsSegmentModalId: DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
}; };
</script> </script>
<template> <template>
<gl-modal <gl-modal
ref="modal"
:modal-id="$options.devopsSegmentModalId" :modal-id="$options.devopsSegmentModalId"
:title="$options.i18n.title" :title="$options.i18n.title"
:ok-title="$options.i18n.button"
ok-variant="info"
size="sm" size="sm"
scrollable scrollable
@ok="createSegment" :action-primary="primaryOptions"
:action-cancel="cancelOptions"
@primary.prevent="createSegment"
> >
<gl-form-group :label="$options.i18n.nameLabel" label-for="name" data-testid="name"> <gl-alert v-if="errors.length" variant="danger" class="gl-mb-3" @dismiss="clearErrors">
{{ displayError }}
</gl-alert>
<gl-form-group :label="$options.i18n.nameLabel" label-for="name">
<gl-form-input <gl-form-input
id="name" id="name"
v-model="name" v-model="name"
data-testid="name"
type="text" type="text"
:placeholder="$options.i18n.namePlaceholder" :placeholder="$options.i18n.namePlaceholder"
:required="true" required
:disabled="loading"
/> />
</gl-form-group> </gl-form-group>
<gl-form-group class="gl-mb-0" data-testid="groups"> <gl-form-group class="gl-mb-0">
<gl-form-checkbox-tree <gl-form-checkbox-tree
v-model="checkboxValues" v-model="checkboxValues"
data-testid="groups"
:options="checkboxOptions" :options="checkboxOptions"
:hide-toggle-all="true" :hide-toggle-all="true"
:disabled="loading"
class="gl-p-3 gl-pb-0 gl-mb-2 gl-border-1 gl-border-solid gl-border-gray-100 gl-rounded-base" class="gl-p-3 gl-pb-0 gl-mb-2 gl-border-1 gl-border-solid gl-border-gray-100 gl-rounded-base"
/> />
<div class="gl-text-gray-400" data-testid="groupsHelperText"> <div class="gl-text-gray-400" data-testid="groupsHelperText">
......
import { s__ } from '~/locale'; import { s__, __ } from '~/locale';
export const MAX_REQUEST_COUNT = 10; export const MAX_REQUEST_COUNT = 10;
...@@ -36,10 +36,12 @@ export const DEVOPS_ADOPTION_STRINGS = { ...@@ -36,10 +36,12 @@ export const DEVOPS_ADOPTION_STRINGS = {
modal: { modal: {
title: s__('DevopsAdoption|New segment'), title: s__('DevopsAdoption|New segment'),
button: s__('DevopsAdoption|Create new segment'), button: s__('DevopsAdoption|Create new segment'),
cancel: __('Cancel'),
namePlaceholder: s__('DevopsAdoption|My segment'), namePlaceholder: s__('DevopsAdoption|My segment'),
nameLabel: s__('DevopsAdoption|Name'), nameLabel: s__('DevopsAdoption|Name'),
selectedGroupsTextSingular: s__('DevopsAdoption|%{selectedCount} group selected (20 max)'), selectedGroupsTextSingular: s__('DevopsAdoption|%{selectedCount} group selected (20 max)'),
selectedGroupsTextPlural: s__('DevopsAdoption|%{selectedCount} groups selected (20 max)'), selectedGroupsTextPlural: s__('DevopsAdoption|%{selectedCount} groups selected (20 max)'),
error: s__('DevopsAdoption|An error occured while saving the segment. Please try again.'),
}, },
}; };
......
mutation($name: String!, $groupIds: [GroupID!]!) {
createDevopsAdoptionSegment(input: { name: $name, groupIds: $groupIds }) {
segment {
id
name
groups {
id
}
}
errors
}
}
import { ApolloMutation } from 'vue-apollo';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlModal, GlFormInput, GlFormCheckboxTree, GlSprintf } from '@gitlab/ui'; import { GlModal, GlFormInput, GlSprintf, GlAlert } from '@gitlab/ui';
import { getByText } from '@testing-library/dom'; import { getByText } from '@testing-library/dom';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
import DevopsAdoptionSegmentModal from 'ee/admin/dev_ops_report/components/devops_adoption_segment_modal.vue'; import DevopsAdoptionSegmentModal from 'ee/admin/dev_ops_report/components/devops_adoption_segment_modal.vue';
import { DEVOPS_ADOPTION_SEGMENT_MODAL_ID } from 'ee/admin/dev_ops_report/constants'; import { DEVOPS_ADOPTION_SEGMENT_MODAL_ID } from 'ee/admin/dev_ops_report/constants';
import { groupNodes } from '../mock_data'; import * as Sentry from '~/sentry/wrapper';
import {
groupNodes,
groupIds,
groupGids,
segmentName,
genericErrorMessage,
dataErrorMessage,
} from '../mock_data';
const mockEvent = { preventDefault: jest.fn() };
const mutate = jest.fn().mockResolvedValue({
data: {
createDevopsAdoptionSegment: {
errors: [],
},
},
});
const mutateWithDataErrors = jest.fn().mockResolvedValue({
data: {
createDevopsAdoptionSegment: {
errors: [dataErrorMessage],
},
},
});
const mutateLoading = jest.fn().mockResolvedValue(new Promise(() => {}));
const mutateWithErrors = jest.fn().mockRejectedValue(genericErrorMessage);
describe('DevopsAdoptionSegmentModal', () => { describe('DevopsAdoptionSegmentModal', () => {
let wrapper; let wrapper;
const createComponent = () => { const createComponent = ({ mutationMock = mutate } = {}) => {
const $apollo = {
mutate: mutationMock,
};
wrapper = shallowMount(DevopsAdoptionSegmentModal, { wrapper = shallowMount(DevopsAdoptionSegmentModal, {
propsData: { propsData: {
groups: groupNodes, groups: groupNodes,
}, },
stubs: { stubs: {
GlSprintf, GlSprintf,
ApolloMutation,
},
mocks: {
$apollo,
}, },
}); });
}; };
const findModal = () => wrapper.find(GlModal); const findModal = () => wrapper.find(GlModal);
const findByTestId = testId => findModal().find(`[data-testid="${testId}"`); const findByTestId = testId => findModal().find(`[data-testid="${testId}"]`);
const actionButtonDisabledState = () => findModal().props('actionPrimary').attributes[0].disabled;
const cancelButtonDisabledState = () => findModal().props('actionCancel').attributes[0].disabled;
const actionButtonLoadingState = () => findModal().props('actionPrimary').attributes[0].loading;
const findAlert = () => findModal().find(GlAlert);
const assertHelperText = text => expect(getByText(wrapper.element, text)).not.toBeNull(); const assertHelperText = text => expect(getByText(wrapper.element, text)).not.toBeNull();
beforeEach(() => createComponent());
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
}); });
it('contains the corrrect id', () => { it('contains the corrrect id', () => {
createComponent();
const modal = findModal(); const modal = findModal();
expect(modal.exists()).toBe(true); expect(modal.exists()).toBe(true);
...@@ -40,6 +80,8 @@ describe('DevopsAdoptionSegmentModal', () => { ...@@ -40,6 +80,8 @@ describe('DevopsAdoptionSegmentModal', () => {
}); });
describe('displays the correct content', () => { describe('displays the correct content', () => {
beforeEach(() => createComponent());
const isCorrectShape = option => { const isCorrectShape = option => {
const keys = Object.keys(option); const keys = Object.keys(option);
return keys.includes('label') && keys.includes('value'); return keys.includes('label') && keys.includes('value');
...@@ -53,7 +95,7 @@ describe('DevopsAdoptionSegmentModal', () => { ...@@ -53,7 +95,7 @@ describe('DevopsAdoptionSegmentModal', () => {
}); });
it('contains the checkbox tree component', () => { it('contains the checkbox tree component', () => {
const checkboxes = findByTestId('groups').find(GlFormCheckboxTree); const checkboxes = findByTestId('groups');
expect(checkboxes.exists()).toBe(true); expect(checkboxes.exists()).toBe(true);
...@@ -84,5 +126,136 @@ describe('DevopsAdoptionSegmentModal', () => { ...@@ -84,5 +126,136 @@ describe('DevopsAdoptionSegmentModal', () => {
assertHelperText('2 groups selected (20 max)'); assertHelperText('2 groups selected (20 max)');
}); });
}); });
it('does not display an error', () => {
expect(findAlert().exists()).toBe(false);
});
});
it.each`
checkboxValues | name | disabled | values | state
${[]} | ${''} | ${true} | ${'checkbox and name'} | ${'disables'}
${[1]} | ${''} | ${true} | ${'checkbox'} | ${'disables'}
${[]} | ${segmentName} | ${true} | ${'name'} | ${'disables'}
${[1]} | ${segmentName} | ${false} | ${'nothing'} | ${'enables'}
`(
'$state the primary action if $values is missing',
async ({ checkboxValues, name, disabled }) => {
createComponent();
wrapper.setData({ checkboxValues, name });
await nextTick();
expect(actionButtonDisabledState()).toBe(disabled);
},
);
describe('submitting the form', () => {
describe('while waiting for the mutation', () => {
beforeEach(() => {
createComponent({ mutationMock: mutateLoading });
wrapper.setData({ checkboxValues: [1], name: segmentName });
});
it('disables the form inputs', async () => {
const checkboxes = findByTestId('groups');
const name = findByTestId('name');
expect(checkboxes.attributes('disabled')).not.toBeDefined();
expect(name.attributes('disabled')).not.toBeDefined();
findModal().vm.$emit('primary', mockEvent);
await waitForPromises();
expect(checkboxes.attributes('disabled')).toBeDefined();
expect(name.attributes('disabled')).toBeDefined();
});
it('disables the cancel button', async () => {
expect(cancelButtonDisabledState()).toBe(false);
findModal().vm.$emit('primary', mockEvent);
await waitForPromises();
expect(cancelButtonDisabledState()).toBe(true);
});
it('sets the action button state to loading', async () => {
expect(actionButtonLoadingState()).toBe(false);
findModal().vm.$emit('primary', mockEvent);
await waitForPromises();
expect(actionButtonLoadingState()).toBe(true);
});
});
describe('successful submission', () => {
beforeEach(async () => {
createComponent();
wrapper.setData({ checkboxValues: groupIds, name: segmentName });
wrapper.vm.$refs.modal.hide = jest.fn();
findModal().vm.$emit('primary', mockEvent);
await waitForPromises();
});
it('submits the correct request variables', async () => {
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
groupIds: groupGids,
name: segmentName,
},
}),
);
});
it('closes the modal after a successful mutation', async () => {
expect(wrapper.vm.$refs.modal.hide).toHaveBeenCalled();
});
});
describe('error handling', () => {
it.each`
errorType | errorLocation | mutationSpy | message
${'generic'} | ${'top level'} | ${mutateWithErrors} | ${genericErrorMessage}
${'specific'} | ${'data'} | ${mutateWithDataErrors} | ${dataErrorMessage}
`(
'displays a $errorType error if the mutation has a $errorLocation error',
async ({ mutationSpy, message }) => {
createComponent({ mutationMock: mutationSpy });
findModal().vm.$emit('primary', mockEvent);
await waitForPromises();
const alert = findAlert();
expect(alert.exists()).toBe(true);
expect(alert.props('variant')).toBe('danger');
expect(alert.text()).toBe(message);
},
);
it('calls sentry on top level error', async () => {
jest.spyOn(Sentry, 'captureException');
createComponent({ mutationMock: mutateWithErrors });
findModal().vm.$emit('primary', mockEvent);
await waitForPromises();
expect(Sentry.captureException.mock.calls[0][0]).toBe(genericErrorMessage);
});
});
}); });
}); });
...@@ -17,6 +17,10 @@ export const groupNodes = [ ...@@ -17,6 +17,10 @@ export const groupNodes = [
}, },
]; ];
export const groupIds = ['foo', 'bar'];
export const groupGids = ['gid://gitlab/Group/foo', 'gid://gitlab/Group/bar'];
export const nextGroupNode = { export const nextGroupNode = {
__typename: 'Group', __typename: 'Group',
full_name: 'Baz', full_name: 'Baz',
...@@ -64,3 +68,9 @@ export const devopsAdoptionTableHeaders = [ ...@@ -64,3 +68,9 @@ export const devopsAdoptionTableHeaders = [
'Scanning', 'Scanning',
'', '',
]; ];
export const segmentName = 'Foooo';
export const genericErrorMessage = 'An error occured while saving the segment. Please try again.';
export const dataErrorMessage = 'Name already taken.';
...@@ -9568,6 +9568,9 @@ msgstr "" ...@@ -9568,6 +9568,9 @@ msgstr ""
msgid "DevopsAdoption|Add new segment" msgid "DevopsAdoption|Add new segment"
msgstr "" msgstr ""
msgid "DevopsAdoption|An error occured while saving the segment. Please try again."
msgstr ""
msgid "DevopsAdoption|Approvals" msgid "DevopsAdoption|Approvals"
msgstr "" 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