Commit 1b15621c authored by Brandon Labuschagne's avatar Brandon Labuschagne

Integrate segment modal

This commit integrates the devops adoption modal with the
BE graphql API for creating segments.
parent 44456e4f
<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';
export default {
......@@ -10,6 +20,7 @@ export default {
GlFormInput,
GlFormCheckboxTree,
GlSprintf,
GlAlert,
},
props: {
segmentId: {
......@@ -27,43 +38,109 @@ export default {
return {
name: '',
checkboxValues: [],
loading: false,
errors: [],
};
},
computed: {
checkboxOptions() {
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: {
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,
};
</script>
<template>
<gl-modal
ref="modal"
:modal-id="$options.devopsSegmentModalId"
:title="$options.i18n.title"
:ok-title="$options.i18n.button"
ok-variant="info"
size="sm"
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
id="name"
v-model="name"
data-testid="name"
type="text"
:placeholder="$options.i18n.namePlaceholder"
:required="true"
required
:disabled="loading"
/>
</gl-form-group>
<gl-form-group class="gl-mb-0" data-testid="groups">
<gl-form-group class="gl-mb-0">
<gl-form-checkbox-tree
v-model="checkboxValues"
data-testid="groups"
:options="checkboxOptions"
: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"
/>
<div class="gl-text-gray-400" data-testid="groupsHelperText">
......
import { s__ } from '~/locale';
import { s__, __ } from '~/locale';
export const MAX_REQUEST_COUNT = 10;
......@@ -36,10 +36,12 @@ export const DEVOPS_ADOPTION_STRINGS = {
modal: {
title: s__('DevopsAdoption|New segment'),
button: s__('DevopsAdoption|Create new segment'),
cancel: __('Cancel'),
namePlaceholder: s__('DevopsAdoption|My segment'),
nameLabel: s__('DevopsAdoption|Name'),
selectedGroupsTextSingular: s__('DevopsAdoption|%{selectedCount} group 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 { GlModal, GlFormInput, GlFormCheckboxTree, GlSprintf } from '@gitlab/ui';
import { GlModal, GlFormInput, GlSprintf, GlAlert } from '@gitlab/ui';
import { getByText } from '@testing-library/dom';
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 { 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', () => {
let wrapper;
const createComponent = () => {
const createComponent = ({ mutationMock = mutate } = {}) => {
const $apollo = {
mutate: mutationMock,
};
wrapper = shallowMount(DevopsAdoptionSegmentModal, {
propsData: {
groups: groupNodes,
},
stubs: {
GlSprintf,
ApolloMutation,
},
mocks: {
$apollo,
},
});
};
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();
beforeEach(() => createComponent());
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('contains the corrrect id', () => {
createComponent();
const modal = findModal();
expect(modal.exists()).toBe(true);
......@@ -40,6 +80,8 @@ describe('DevopsAdoptionSegmentModal', () => {
});
describe('displays the correct content', () => {
beforeEach(() => createComponent());
const isCorrectShape = option => {
const keys = Object.keys(option);
return keys.includes('label') && keys.includes('value');
......@@ -53,7 +95,7 @@ describe('DevopsAdoptionSegmentModal', () => {
});
it('contains the checkbox tree component', () => {
const checkboxes = findByTestId('groups').find(GlFormCheckboxTree);
const checkboxes = findByTestId('groups');
expect(checkboxes.exists()).toBe(true);
......@@ -84,5 +126,136 @@ describe('DevopsAdoptionSegmentModal', () => {
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 = [
},
];
export const groupIds = ['foo', 'bar'];
export const groupGids = ['gid://gitlab/Group/foo', 'gid://gitlab/Group/bar'];
export const nextGroupNode = {
__typename: 'Group',
full_name: 'Baz',
......@@ -64,3 +68,9 @@ export const devopsAdoptionTableHeaders = [
'Scanning',
'',
];
export const segmentName = 'Foooo';
export const genericErrorMessage = 'An error occured while saving the segment. Please try again.';
export const dataErrorMessage = 'Name already taken.';
......@@ -9543,6 +9543,9 @@ msgstr ""
msgid "DevopsAdoption|Add new segment"
msgstr ""
msgid "DevopsAdoption|An error occured while saving the segment. Please try again."
msgstr ""
msgid "DevopsAdoption|Approvals"
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