Commit 1a1f854c authored by Brandon Labuschagne's avatar Brandon Labuschagne Committed by Martin Wortschack

Multi-select for devops adoption

Allow mutliple groups to be added / removed
via the devops adoption groups modal.
parent 333df774
......@@ -178,7 +178,7 @@ export default {
v-if="hasGroupData"
:key="modalKey"
:groups="groups.nodes"
:segment="selectedSegment"
:enabled-groups="devopsAdoptionSegments.nodes"
@trackModalOpenState="trackModalOpenState"
/>
<div v-if="hasSegmentsData" class="gl-mt-3">
......
......@@ -3,7 +3,7 @@ import { GlModal, GlSprintf, GlAlert } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { DEVOPS_ADOPTION_STRINGS, DEVOPS_ADOPTION_SEGMENT_DELETE_MODAL_ID } from '../constants';
import deleteDevopsAdoptionSegmentMutation from '../graphql/mutations/delete_devops_adoption_segment.mutation.graphql';
import { deleteSegmentFromCache } from '../utils/cache_updates';
import { deleteSegmentsFromCache } from '../utils/cache_updates';
export default {
name: 'DevopsAdoptionDeleteModal',
......@@ -66,7 +66,7 @@ export default {
id: [id],
},
update(store) {
deleteSegmentFromCache(store, id);
deleteSegmentsFromCache(store, [id]);
},
});
......
<script>
import { GlFormGroup, GlFormInput, GlFormRadioGroup, GlModal, GlAlert, GlIcon } from '@gitlab/ui';
import { GlFormGroup, GlFormInput, GlFormCheckboxTree, GlModal, GlAlert, GlIcon } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { convertToGraphQLId, TYPE_GROUP } from '~/graphql_shared/utils';
import _ from 'lodash';
import { convertToGraphQLId, getIdFromGraphQLId, TYPE_GROUP } from '~/graphql_shared/utils';
import { DEVOPS_ADOPTION_STRINGS, DEVOPS_ADOPTION_SEGMENT_MODAL_ID } from '../constants';
import createDevopsAdoptionSegmentMutation from '../graphql/mutations/create_devops_adoption_segment.mutation.graphql';
import { addSegmentToCache } from '../utils/cache_updates';
import bulkFindOrCreateDevopsAdoptionSegmentsMutation from '../graphql/mutations/bulk_find_or_create_devops_adoption_segments.mutation.graphql';
import deleteDevopsAdoptionSegmentMutation from '../graphql/mutations/delete_devops_adoption_segment.mutation.graphql';
import { addSegmentsToCache, deleteSegmentsFromCache } from '../utils/cache_updates';
export default {
name: 'DevopsAdoptionSegmentModal',
......@@ -12,7 +14,7 @@ export default {
GlModal,
GlFormGroup,
GlFormInput,
GlFormRadioGroup,
GlFormCheckboxTree,
GlAlert,
GlIcon,
},
......@@ -21,19 +23,33 @@ export default {
type: Array,
required: true,
},
enabledGroups: {
type: Array,
required: false,
default: () => [],
},
},
i18n: DEVOPS_ADOPTION_STRINGS.modal,
data() {
const checkboxValuesFromEnabledGroups = this.enabledGroups.map((group) =>
getIdFromGraphQLId(group.namespace.id),
);
return {
selectedGroupId: null,
checkboxValuesFromEnabledGroups,
checkboxValues: checkboxValuesFromEnabledGroups,
filter: '',
loading: false,
loadingAdd: false,
loadingDelete: false,
errors: [],
};
},
computed: {
loading() {
return this.loadingAdd || this.loadingDelete;
},
checkboxOptions() {
return this.groups.map(({ id, full_name }) => ({ text: full_name, value: id }));
return this.groups.map(({ id, full_name }) => ({ label: full_name, value: id }));
},
cancelOptions() {
return {
......@@ -41,7 +57,6 @@ export default {
text: this.$options.i18n.cancel,
attributes: [{ disabled: this.loading }],
},
callback: this.resetForm,
};
},
primaryOptions() {
......@@ -56,11 +71,11 @@ export default {
},
],
},
callback: this.createSegment,
callback: this.saveChanges,
};
},
canSubmit() {
return Boolean(this.selectedGroupId);
return !this.anyChangesMade;
},
displayError() {
return this.errors[0];
......@@ -71,44 +86,101 @@ export default {
filteredOptions() {
return this.filter
? this.checkboxOptions.filter((option) =>
option.text.toLowerCase().includes(this.filter.toLowerCase()),
option.label.toLowerCase().includes(this.filter.toLowerCase()),
)
: this.checkboxOptions;
},
anyChangesMade() {
return _.isEqual(
_.sortBy(this.checkboxValues),
_.sortBy(this.checkboxValuesFromEnabledGroups),
);
},
},
methods: {
async createSegment() {
async saveChanges() {
await this.deleteMissingGroups();
await this.addNewGroups();
if (!this.errors.length) this.closeModal();
},
async addNewGroups() {
try {
this.loading = true;
const {
data: {
createDevopsAdoptionSegment: { errors },
},
} = await this.$apollo.mutate({
mutation: createDevopsAdoptionSegmentMutation,
variables: {
namespaceId: convertToGraphQLId(TYPE_GROUP, this.selectedGroupId),
},
update: (store, { data }) => {
const {
createDevopsAdoptionSegment: { segment, errors: requestErrors },
} = data;
const originalEnabledIds = this.enabledGroups.map((group) =>
getIdFromGraphQLId(group.namespace.id),
);
if (!requestErrors.length) addSegmentToCache(store, segment);
},
});
const namespaceIds = this.checkboxValues
.filter((id) => !originalEnabledIds.includes(id))
.map((id) => convertToGraphQLId(TYPE_GROUP, id));
if (namespaceIds.length) {
this.loadingAdd = true;
const {
data: {
bulkFindOrCreateDevopsAdoptionSegments: { errors },
},
} = await this.$apollo.mutate({
mutation: bulkFindOrCreateDevopsAdoptionSegmentsMutation,
variables: {
namespaceIds,
},
update: (store, { data }) => {
const {
bulkFindOrCreateDevopsAdoptionSegments: { segments, errors: requestErrors },
} = data;
if (!requestErrors.length) addSegmentsToCache(store, segments);
},
});
if (errors.length) {
this.errors = errors;
}
}
} catch (error) {
this.errors.push(this.$options.i18n.error);
Sentry.captureException(error);
} finally {
this.loadingAdd = false;
}
},
async deleteMissingGroups() {
try {
const removedGroupGids = this.enabledGroups
.filter((group) => !this.checkboxValues.includes(getIdFromGraphQLId(group.namespace.id)))
.map((group) => group.id);
if (removedGroupGids.length) {
this.loadingDelete = true;
const {
data: {
deleteDevopsAdoptionSegment: { errors },
},
} = await this.$apollo.mutate({
mutation: deleteDevopsAdoptionSegmentMutation,
variables: {
id: removedGroupGids,
},
update: (store, { data }) => {
const {
deleteDevopsAdoptionSegment: { errors: requestErrors },
} = data;
if (!requestErrors.length) deleteSegmentsFromCache(store, removedGroupGids);
},
});
if (errors.length) {
this.errors = errors;
} else {
this.resetForm();
this.closeModal();
if (errors.length) {
this.errors = errors;
}
}
} catch (error) {
this.errors.push(this.$options.i18n.error);
Sentry.captureException(error);
} finally {
this.loading = false;
this.loadingDelete = false;
}
},
clearErrors() {
......@@ -118,7 +190,7 @@ export default {
this.$refs.modal.hide();
},
resetForm() {
this.selectedGroupId = null;
this.checkboxValues = [];
this.filter = '';
this.$emit('trackModalOpenState', false);
},
......@@ -136,8 +208,7 @@ export default {
:action-primary="primaryOptions.button"
:action-cancel="cancelOptions.button"
@primary.prevent="primaryOptions.callback"
@canceled="cancelOptions.callback"
@hide="resetForm"
@hidden="resetForm"
@show="$emit('trackModalOpenState', true)"
>
<gl-alert v-if="errors.length" variant="danger" class="gl-mb-3" @dismiss="clearErrors">
......@@ -159,10 +230,10 @@ export default {
/>
</gl-form-group>
<gl-form-group class="gl-mb-0">
<gl-form-radio-group
<gl-form-checkbox-tree
v-if="filteredOptions.length"
:key="filteredOptions.length"
v-model="selectedGroupId"
v-model="checkboxValues"
data-testid="groups"
:options="filteredOptions"
:hide-toggle-all="true"
......
......@@ -29,7 +29,7 @@ export const DEVOPS_ADOPTION_STRINGS = {
text: s__(
'DevopsAdoption|Feature adoption is based on usage in the last calendar month. Last updated: %{timestamp}.',
),
button: s__('DevopsAdoption|Add Group'),
button: s__('DevopsAdoption|Add / remove groups'),
buttonTooltip: sprintf(s__('DevopsAdoption|Maximum %{maxSegments} groups allowed'), {
maxSegments: MAX_SEGMENTS,
}),
......@@ -43,15 +43,12 @@ export const DEVOPS_ADOPTION_STRINGS = {
button: s__('DevopsAdoption|Add Group'),
},
modal: {
addingTitle: s__('DevopsAdoption|Add Group'),
addingButton: s__('DevopsAdoption|Add Group'),
editingButton: s__('DevopsAdoption|Save changes'),
addingTitle: s__('DevopsAdoption|Add / remove groups'),
addingButton: s__('DevopsAdoption|Save changes'),
cancel: __('Cancel'),
namePlaceholder: s__('DevopsAdoption|My group'),
filterPlaceholder: s__('DevopsAdoption|Filter by name'),
selectedGroupsTextSingular: s__('DevopsAdoption|%{selectedCount} group selected'),
selectedGroupsTextPlural: s__('DevopsAdoption|%{selectedCount} groups selected'),
error: s__('DevopsAdoption|An error occurred while saving the group. Please try again.'),
error: s__('DevopsAdoption|An error occurred while saving changes. Please try again.'),
noResults: s__('DevopsAdoption|No filter results.'),
},
table: {
......
mutation($namespaceId: NamespaceID!) {
createDevopsAdoptionSegment(input: { namespaceId: $namespaceId }) {
segment {
mutation($namespaceIds: [NamespaceID!]!) {
bulkFindOrCreateDevopsAdoptionSegments(input: { namespaceIds: $namespaceIds }) {
segments {
id
latestSnapshot {
issueOpened
......
import produce from 'immer';
import devopsAdoptionSegmentsQuery from '../graphql/queries/devops_adoption_segments.query.graphql';
export const addSegmentToCache = (store, segment) => {
export const addSegmentsToCache = (store, segments) => {
const sourceData = store.readQuery({
query: devopsAdoptionSegmentsQuery,
});
const data = produce(sourceData, (draftData) => {
draftData.devopsAdoptionSegments.nodes = [...draftData.devopsAdoptionSegments.nodes, segment];
draftData.devopsAdoptionSegments.nodes = [
...draftData.devopsAdoptionSegments.nodes,
...segments,
];
});
store.writeQuery({
......@@ -16,14 +19,14 @@ export const addSegmentToCache = (store, segment) => {
});
};
export const deleteSegmentFromCache = (store, segmentId) => {
export const deleteSegmentsFromCache = (store, segmentIds) => {
const sourceData = store.readQuery({
query: devopsAdoptionSegmentsQuery,
});
const updatedData = produce(sourceData, (draftData) => {
draftData.devopsAdoptionSegments.nodes = draftData.devopsAdoptionSegments.nodes.filter(
({ id }) => id !== segmentId,
({ id }) => !segmentIds.includes(id),
);
});
......
---
title: DevOps Adoption - Allow users to select multiple groups
merge_request: 56073
author:
type: changed
......@@ -13,31 +13,37 @@ import {
genericErrorMessage,
dataErrorMessage,
groupNodeLabelValues,
devopsAdoptionSegmentsData,
} from '../mock_data';
const mockEvent = { preventDefault: jest.fn() };
const mutate = jest.fn().mockResolvedValue({
data: {
createDevopsAdoptionSegment: {
bulkFindOrCreateDevopsAdoptionSegments: {
errors: [],
},
deleteDevopsAdoptionSegment: {
errors: [],
},
},
});
const mutateWithDataErrors = () =>
jest.fn().mockResolvedValue({
data: {
createDevopsAdoptionSegment: {
errors: [dataErrorMessage],
},
const mutateWithDataErrors = jest.fn().mockResolvedValue({
data: {
bulkFindOrCreateDevopsAdoptionSegments: {
errors: [dataErrorMessage],
},
});
deleteDevopsAdoptionSegment: {
errors: [],
},
},
});
const mutateLoading = jest.fn().mockResolvedValue(new Promise(() => {}));
const mutateWithErrors = jest.fn().mockRejectedValue(genericErrorMessage);
describe('DevopsAdoptionSegmentModal', () => {
let wrapper;
const createComponent = ({ mutationMock = mutate } = {}) => {
const createComponent = ({ mutationMock = mutate, props = {} } = {}) => {
const $apollo = {
mutate: mutationMock,
};
......@@ -45,6 +51,7 @@ describe('DevopsAdoptionSegmentModal', () => {
wrapper = shallowMount(DevopsAdoptionSegmentModal, {
propsData: {
groups: groupNodes,
...props,
},
stubs: {
GlSprintf,
......@@ -65,7 +72,6 @@ describe('DevopsAdoptionSegmentModal', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('contains the corrrect id', () => {
......@@ -77,15 +83,32 @@ describe('DevopsAdoptionSegmentModal', () => {
expect(modal.props('modalId')).toBe(DEVOPS_ADOPTION_SEGMENT_MODAL_ID);
});
it.each`
enabledGroups | checkboxValues | disabled | condition | state
${[]} | ${[]} | ${true} | ${'no changes'} | ${'disables'}
${[]} | ${[1]} | ${false} | ${'changes'} | ${'enables'}
`(
'$state the primary action if there are $condition',
async ({ enabledGroups, disabled, checkboxValues }) => {
createComponent({ props: { enabledGroups } });
wrapper.setData({ checkboxValues });
await nextTick();
expect(actionButtonDisabledState()).toBe(disabled);
},
);
describe('displays the correct content', () => {
beforeEach(() => createComponent());
const isCorrectShape = (option) => {
const keys = Object.keys(option);
return keys.includes('text') && keys.includes('value');
return keys.includes('label') && keys.includes('value');
};
it('contains the radio group component', () => {
it('contains the checkbox tree component', () => {
const checkboxes = findByTestId('groups');
expect(checkboxes.exists()).toBe(true);
......@@ -160,9 +183,9 @@ describe('DevopsAdoptionSegmentModal', () => {
});
describe.each`
state | action | expected
${'opening'} | ${'show'} | ${true}
${'closing'} | ${'hide'} | ${false}
state | action | expected
${'opening'} | ${'show'} | ${true}
${'closing'} | ${'hidden'} | ${false}
`('$state the modal', ({ action, expected }) => {
beforeEach(() => {
createComponent();
......@@ -174,29 +197,21 @@ describe('DevopsAdoptionSegmentModal', () => {
});
});
it.each`
selectedGroupId | disabled | values | state
${null} | ${true} | ${'checkbox'} | ${'disables'}
${1} | ${false} | ${'nothing'} | ${'enables'}
`('$state the primary action if $values is missing', async ({ selectedGroupId, disabled }) => {
createComponent();
wrapper.setData({ selectedGroupId });
await nextTick();
expect(actionButtonDisabledState()).toBe(disabled);
});
describe('handles the form submission correctly when creating a new segment', () => {
const additionalData = { selectedGroupId: groupIds[0] };
describe('handles the form submission correctly when saving changes', () => {
const enableFirstGroup = { checkboxValues: [groupIds[0]] };
const enableSecondGroup = { checkboxValues: [groupIds[1]] };
const noEnabledGroups = { checkboxValues: [] };
const firstGroupEnabledData = [devopsAdoptionSegmentsData.nodes[0]];
const firstGroupId = [groupIds[0]];
const firstGroupGid = [groupGids[0]];
const secondGroupGid = [groupGids[1]];
describe('submitting the form', () => {
describe('while waiting for the mutation', () => {
beforeEach(() => {
createComponent({ mutationMock: mutateLoading });
wrapper.setData(additionalData);
wrapper.setData(enableFirstGroup);
});
it('disables the form inputs', async () => {
......@@ -232,46 +247,72 @@ describe('DevopsAdoptionSegmentModal', () => {
});
});
describe('successful submission', () => {
beforeEach(() => {
createComponent();
wrapper.setData(additionalData);
wrapper.vm.$refs.modal.hide = jest.fn();
findModal().vm.$emit('primary', mockEvent);
});
it('submits the correct request variables', async () => {
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: { namespaceId: groupGids[0] },
}),
);
});
it('closes the modal after a successful mutation', async () => {
expect(wrapper.vm.$refs.modal.hide).toHaveBeenCalled();
});
it('resets the form fields', async () => {
expect(wrapper.vm.selectedGroupId).toEqual(null);
expect(wrapper.vm.filter).toBe('');
});
});
describe.each`
action | enabledGroups | newGroups | expectedAddGroupGids | expectedDeleteIds
${'adding'} | ${[]} | ${enableFirstGroup} | ${firstGroupGid} | ${[]}
${'removing'} | ${firstGroupEnabledData} | ${noEnabledGroups} | ${[]} | ${firstGroupId}
${'adding and removing'} | ${firstGroupEnabledData} | ${enableSecondGroup} | ${secondGroupGid} | ${firstGroupId}
`(
'$action groups',
({ enabledGroups, newGroups, expectedAddGroupGids, expectedDeleteIds }) => {
describe('successful submission', () => {
beforeEach(async () => {
createComponent({ props: { enabledGroups } });
wrapper.setData(newGroups);
wrapper.vm.$refs.modal.hide = jest.fn();
findModal().vm.$emit('primary', mockEvent);
await waitForPromises();
});
if (expectedAddGroupGids.length) {
it('submits the correct add request variables', () => {
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: { namespaceIds: expectedAddGroupGids },
}),
);
});
}
if (expectedDeleteIds.length) {
it('submits the correct delete request variables', () => {
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: { id: expectedDeleteIds },
}),
);
});
}
it('closes the modal after a successful mutation', () => {
expect(wrapper.vm.$refs.modal.hide).toHaveBeenCalled();
});
it('resets the form fields', () => {
findModal().vm.$emit('hidden');
expect(wrapper.vm.checkboxValues).toEqual([]);
expect(wrapper.vm.filter).toBe('');
});
});
},
);
describe('error handling', () => {
it.each`
errorType | errorLocation | mutationSpy | message
${'generic'} | ${'top level'} | ${mutateWithErrors} | ${genericErrorMessage}
${'specific'} | ${'data'} | ${mutateWithDataErrors()} | ${dataErrorMessage}
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 });
wrapper.setData(additionalData);
wrapper.setData(enableFirstGroup);
findModal().vm.$emit('primary', mockEvent);
......@@ -290,7 +331,7 @@ describe('DevopsAdoptionSegmentModal', () => {
createComponent({ mutationMock: mutateWithErrors });
wrapper.setData(additionalData);
wrapper.setData(enableFirstGroup);
findModal().vm.$emit('primary', mockEvent);
......
......@@ -21,11 +21,11 @@ export const groupNodes = [
];
export const groupNodeLabelValues = [
{ text: 'Foo', value: '1' },
{ text: 'Bar', value: '2' },
{ label: 'Foo', value: '1' },
{ label: 'Bar', value: '2' },
];
export const groupIds = ['1', '2'];
export const groupIds = [1, 2];
export const groupGids = ['gid://gitlab/Group/1', 'gid://gitlab/Group/2'];
......@@ -128,7 +128,7 @@ export const devopsAdoptionTableHeaders = [
export const segmentName = 'Foooo';
export const genericErrorMessage = 'An error occurred while saving the group. Please try again.';
export const genericErrorMessage = 'An error occurred while saving changes. Please try again.';
export const dataErrorMessage = 'Name already taken.';
......
import {
deleteSegmentFromCache,
addSegmentToCache,
deleteSegmentsFromCache,
addSegmentsToCache,
} from 'ee/analytics/devops_report/devops_adoption/utils/cache_updates';
import { devopsAdoptionSegmentsData } from '../mock_data';
describe('addSegmentToCache', () => {
describe('addSegmentsToCache', () => {
const store = {
readQuery: jest.fn(() => ({ devopsAdoptionSegments: { nodes: [] } })),
writeQuery: jest.fn(),
};
it('calls writeQuery with the correct response', () => {
addSegmentToCache(store, devopsAdoptionSegmentsData.nodes[0]);
addSegmentsToCache(store, devopsAdoptionSegmentsData.nodes);
expect(store.writeQuery).toHaveBeenCalledWith(
expect.objectContaining({
data: {
devopsAdoptionSegments: {
nodes: [devopsAdoptionSegmentsData.nodes[0]],
nodes: devopsAdoptionSegmentsData.nodes,
},
},
}),
......@@ -25,7 +25,7 @@ describe('addSegmentToCache', () => {
});
});
describe('deleteSegmentFromCache', () => {
describe('deleteSegmentsFromCache', () => {
const store = {
readQuery: jest.fn(() => ({ devopsAdoptionSegments: devopsAdoptionSegmentsData })),
writeQuery: jest.fn(),
......@@ -33,7 +33,7 @@ describe('deleteSegmentFromCache', () => {
it('calls writeQuery with the correct response', () => {
// Remove the item at the first index
deleteSegmentFromCache(store, devopsAdoptionSegmentsData.nodes[0].id);
deleteSegmentsFromCache(store, [devopsAdoptionSegmentsData.nodes[0].id]);
expect(store.writeQuery).toHaveBeenCalledWith(
expect.not.objectContaining({
......
......@@ -10640,10 +10640,7 @@ msgstr ""
msgid "DevOps Report"
msgstr ""
msgid "DevopsAdoption|%{selectedCount} group selected"
msgstr ""
msgid "DevopsAdoption|%{selectedCount} groups selected"
msgid "DevopsAdoption|Add / remove groups"
msgstr ""
msgid "DevopsAdoption|Add Group"
......@@ -10658,7 +10655,7 @@ msgstr ""
msgid "DevopsAdoption|An error occurred while removing the group. Please try again."
msgstr ""
msgid "DevopsAdoption|An error occurred while saving the group. Please try again."
msgid "DevopsAdoption|An error occurred while saving changes. Please try again."
msgstr ""
msgid "DevopsAdoption|Approvals"
......
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