Commit a30f5124 authored by Coung Ngo's avatar Coung Ngo Committed by Ezekiel Kigbo

Fix remove label inconsistency

There is a bug where if on the issue page you:

1. Add a label via the /label quick action
2. Remove a label by clicking on the label `x` on the sidebar

Then the label added in 1. is also removed. This commit fixes this.
parent 2b12d832
...@@ -9,4 +9,8 @@ ...@@ -9,4 +9,8 @@
export const getIdFromGraphQLId = (gid = '') => export const getIdFromGraphQLId = (gid = '') =>
parseInt((gid || '').replace(/gid:\/\/gitlab\/.*\//g, ''), 10) || null; parseInt((gid || '').replace(/gid:\/\/gitlab\/.*\//g, ''), 10) || null;
export default {}; export const MutationOperationMode = {
Append: 'APPEND',
Remove: 'REMOVE',
Replace: 'REPLACE',
};
<script> <script>
import $ from 'jquery'; import $ from 'jquery';
import { difference, union } from 'lodash'; import { camelCase, difference, union } from 'lodash';
import flash from '~/flash'; import updateIssueLabelsMutation from '~/boards/queries/issue_set_labels.mutation.graphql';
import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash';
import { IssuableType } from '~/issue_show/constants';
import { __ } from '~/locale'; import { __ } from '~/locale';
import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql';
import { toLabelGid } from '~/sidebar/utils';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
import { getIdFromGraphQLId, MutationOperationMode } from '~/graphql_shared/utils';
const mutationMap = {
[IssuableType.Issue]: {
mutation: updateIssueLabelsMutation,
mutationName: 'updateIssue',
},
[IssuableType.MergeRequest]: {
mutation: updateMergeRequestLabelsMutation,
mutationName: 'mergeRequestSetLabels',
},
};
export default { export default {
components: { components: {
...@@ -21,7 +36,6 @@ export default { ...@@ -21,7 +36,6 @@ export default {
'issuableType', 'issuableType',
'labelsFetchPath', 'labelsFetchPath',
'labelsManagePath', 'labelsManagePath',
'labelsUpdatePath',
'projectIssuesPath', 'projectIssuesPath',
'projectPath', 'projectPath',
], ],
...@@ -35,37 +49,79 @@ export default { ...@@ -35,37 +49,79 @@ export default {
handleDropdownClose() { handleDropdownClose() {
$(this.$el).trigger('hidden.gl.dropdown'); $(this.$el).trigger('hidden.gl.dropdown');
}, },
handleUpdateSelectedLabels(dropdownLabels) { getUpdateVariables(dropdownLabels) {
const currentLabelIds = this.selectedLabels.map(label => label.id); const currentLabelIds = this.selectedLabels.map(label => label.id);
const userAddedLabelIds = dropdownLabels.filter(label => label.set).map(label => label.id); const userAddedLabelIds = dropdownLabels.filter(label => label.set).map(label => label.id);
const userRemovedLabelIds = dropdownLabels.filter(label => !label.set).map(label => label.id); const userRemovedLabelIds = dropdownLabels.filter(label => !label.set).map(label => label.id);
const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds); const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds);
this.updateSelectedLabels(labelIds); switch (this.issuableType) {
case IssuableType.Issue:
return {
addLabelIds: userAddedLabelIds,
iid: this.iid,
projectPath: this.projectPath,
removeLabelIds: userRemovedLabelIds,
};
case IssuableType.MergeRequest:
return {
iid: this.iid,
labelIds: labelIds.map(toLabelGid),
operationMode: MutationOperationMode.Replace,
projectPath: this.projectPath,
};
default:
return {};
}
},
handleUpdateSelectedLabels(dropdownLabels) {
this.updateSelectedLabels(this.getUpdateVariables(dropdownLabels));
},
getRemoveVariables(labelId) {
switch (this.issuableType) {
case IssuableType.Issue:
return {
iid: this.iid,
projectPath: this.projectPath,
removeLabelIds: [labelId],
};
case IssuableType.MergeRequest:
return {
iid: this.iid,
labelIds: [toLabelGid(labelId)],
operationMode: MutationOperationMode.Remove,
projectPath: this.projectPath,
};
default:
return {};
}
}, },
handleLabelRemove(labelId) { handleLabelRemove(labelId) {
const currentLabelIds = this.selectedLabels.map(label => label.id); this.updateSelectedLabels(this.getRemoveVariables(labelId));
const labelIds = difference(currentLabelIds, [labelId]);
this.updateSelectedLabels(labelIds);
}, },
updateSelectedLabels(labelIds) { updateSelectedLabels(inputVariables) {
this.isLabelsSelectInProgress = true; this.isLabelsSelectInProgress = true;
axios({ this.$apollo
data: { .mutate({
[this.issuableType]: { mutation: mutationMap[this.issuableType].mutation,
label_ids: labelIds, variables: { input: inputVariables },
}, })
},
method: 'put',
url: this.labelsUpdatePath,
})
.then(({ data }) => { .then(({ data }) => {
this.selectedLabels = data.labels; const { mutationName } = mutationMap[this.issuableType];
if (data[mutationName]?.errors?.length) {
throw new Error();
}
const issuableType = camelCase(this.issuableType);
this.selectedLabels = data[mutationName]?.[issuableType]?.labels?.nodes?.map(label => ({
...label,
id: getIdFromGraphQLId(label.id),
}));
}) })
.catch(() => flash(__('An error occurred while updating labels.'))) .catch(() => createFlash({ message: __('An error occurred while updating labels.') }))
.finally(() => { .finally(() => {
this.isLabelsSelectInProgress = false; this.isLabelsSelectInProgress = false;
}); });
......
...@@ -91,8 +91,13 @@ export function mountSidebarLabels() { ...@@ -91,8 +91,13 @@ export function mountSidebarLabels() {
return false; return false;
} }
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({ return new Vue({
el, el,
apolloProvider,
provide: { provide: {
...el.dataset, ...el.dataset,
allowLabelCreate: parseBoolean(el.dataset.allowLabelCreate), allowLabelCreate: parseBoolean(el.dataset.allowLabelCreate),
......
mutation mergeRequestSetLabels($input: MergeRequestSetLabelsInput!) {
mergeRequestSetLabels(input: $input) {
errors
mergeRequest {
labels {
nodes {
color
description
id
title
}
}
}
}
}
export const toLabelGid = id => `gid://gitlab/Label/${id}`;
...@@ -492,6 +492,21 @@ module IssuablesHelper ...@@ -492,6 +492,21 @@ module IssuablesHelper
} }
end end
def sidebar_labels_data(issuable_sidebar, project)
{
allow_label_create: issuable_sidebar.dig(:current_user, :can_admin_label).to_s,
allow_scoped_labels: issuable_sidebar[:scoped_labels_available].to_s,
can_edit: issuable_sidebar.dig(:current_user, :can_edit).to_s,
iid: issuable_sidebar[:iid],
issuable_type: issuable_sidebar[:type],
labels_fetch_path: issuable_sidebar[:project_labels_path],
labels_manage_path: project_labels_path(project),
project_issues_path: issuable_sidebar[:project_issuables_path],
project_path: project.full_path,
selected_labels: issuable_sidebar[:labels].to_json
}
end
def parent def parent
@project || @group @project || @group
end end
......
...@@ -103,17 +103,7 @@ ...@@ -103,17 +103,7 @@
.js-due-date-calendar .js-due-date-calendar
.js-sidebar-labels{ data: { allow_label_create: issuable_sidebar.dig(:current_user, :can_admin_label).to_s, .js-sidebar-labels{ data: sidebar_labels_data(issuable_sidebar, @project) }
allow_scoped_labels: issuable_sidebar[:scoped_labels_available].to_s,
can_edit: can_edit_issuable.to_s,
iid: issuable_sidebar[:iid],
issuable_type: issuable_type,
labels_fetch_path: issuable_sidebar[:project_labels_path],
labels_manage_path: project_labels_path(@project),
labels_update_path: issuable_sidebar[:issuable_json_path],
project_issues_path: issuable_sidebar[:project_issuables_path],
project_path: @project.full_path,
selected_labels: issuable_sidebar[:labels].to_json } }
= render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar = render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar
......
---
title: Fix remove label inconsistency
merge_request: 46805
author:
type: fixed
...@@ -138,6 +138,33 @@ RSpec.describe "Issues > User edits issue", :js do ...@@ -138,6 +138,33 @@ RSpec.describe "Issues > User edits issue", :js do
expect(page).not_to have_text('verisimilitude') expect(page).not_to have_text('verisimilitude')
end end
end end
it 'can remove label without removing label added via quick action', :aggregate_failures do
# Add `syzygy` label with a quick action
note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('/label ~syzygy')
end
click_button 'Comment'
wait_for_requests
page.within '.block.labels' do
# Remove `verisimilitude` label
within '.gl-label' do
click_button
end
wait_for_requests
expect(page).to have_text('syzygy')
expect(page).not_to have_text('verisimilitude')
end
expect(page).to have_text('removed verisimilitude label')
expect(page).not_to have_text('removed syzygy verisimilitude labels')
expect(issue.reload.labels.map(&:title)).to contain_exactly('syzygy')
end
end end
describe 'update assignee' do describe 'update assignee' do
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { import {
mockLabels, mockLabels,
mockRegularLabel, mockRegularLabel,
} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
import axios from '~/lib/utils/axios_utils'; import updateIssueLabelsMutation from '~/boards/queries/issue_set_labels.mutation.graphql';
import { MutationOperationMode } from '~/graphql_shared/utils';
import { IssuableType } from '~/issue_show/constants';
import SidebarLabels from '~/sidebar/components/labels/sidebar_labels.vue'; import SidebarLabels from '~/sidebar/components/labels/sidebar_labels.vue';
import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql';
import { toLabelGid } from '~/sidebar/utils';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
describe('sidebar labels', () => { describe('sidebar labels', () => {
let axiosMock;
let wrapper; let wrapper;
const defaultProps = { const defaultProps = {
...@@ -23,29 +25,52 @@ describe('sidebar labels', () => { ...@@ -23,29 +25,52 @@ describe('sidebar labels', () => {
issuableType: 'issue', issuableType: 'issue',
labelsFetchPath: '/gitlab-org/gitlab-test/-/labels.json?include_ancestor_groups=true', labelsFetchPath: '/gitlab-org/gitlab-test/-/labels.json?include_ancestor_groups=true',
labelsManagePath: '/gitlab-org/gitlab-test/-/labels', labelsManagePath: '/gitlab-org/gitlab-test/-/labels',
labelsUpdatePath: '/gitlab-org/gitlab-test/-/issues/1.json',
projectIssuesPath: '/gitlab-org/gitlab-test/-/issues', projectIssuesPath: '/gitlab-org/gitlab-test/-/issues',
projectPath: 'gitlab-org/gitlab-test', projectPath: 'gitlab-org/gitlab-test',
}; };
const $apollo = {
mutate: jest.fn().mockResolvedValue(),
};
const userUpdatedLabels = [
{
...mockRegularLabel,
set: false,
},
{
id: 40,
title: 'Security',
color: '#ddd',
text_color: '#fff',
set: true,
},
{
id: 55,
title: 'Tooling',
color: '#ddd',
text_color: '#fff',
set: false,
},
];
const findLabelsSelect = () => wrapper.find(LabelsSelect); const findLabelsSelect = () => wrapper.find(LabelsSelect);
const mountComponent = () => { const mountComponent = (props = {}) => {
wrapper = shallowMount(SidebarLabels, { wrapper = shallowMount(SidebarLabels, {
provide: { provide: {
...defaultProps, ...defaultProps,
...props,
},
mocks: {
$apollo,
}, },
}); });
}; };
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
axiosMock.restore();
}); });
describe('LabelsSelect props', () => { describe('LabelsSelect props', () => {
...@@ -72,64 +97,94 @@ describe('sidebar labels', () => { ...@@ -72,64 +97,94 @@ describe('sidebar labels', () => {
}); });
}); });
describe('when labels are updated', () => { describe('when type is issue', () => {
beforeEach(() => { beforeEach(() => {
mountComponent(); mountComponent({ issuableType: IssuableType.Issue });
}); });
it('makes an API call to update labels', async () => { describe('when labels are updated', () => {
const labels = [ it('invokes a mutation', () => {
{ findLabelsSelect().vm.$emit('updateSelectedLabels', userUpdatedLabels);
...mockRegularLabel,
set: false, const expected = {
}, mutation: updateIssueLabelsMutation,
{ variables: {
id: 40, input: {
title: 'Security', addLabelIds: [40],
color: '#ddd', iid: defaultProps.iid,
text_color: '#fff', projectPath: defaultProps.projectPath,
set: true, removeLabelIds: [26, 55],
}, },
{ },
id: 55, };
title: 'Tooling',
color: '#ddd', expect($apollo.mutate).toHaveBeenCalledWith(expected);
text_color: '#fff', });
set: false, });
},
]; describe('when label `x` is clicked', () => {
it('invokes a mutation', () => {
findLabelsSelect().vm.$emit('updateSelectedLabels', labels); findLabelsSelect().vm.$emit('onLabelRemove', 27);
await axios.waitForAll(); const expected = {
mutation: updateIssueLabelsMutation,
const expected = { variables: {
[defaultProps.issuableType]: { input: {
label_ids: [27, 28, 29, 40], iid: defaultProps.iid,
}, projectPath: defaultProps.projectPath,
}; removeLabelIds: [27],
},
expect(axiosMock.history.put[0].data).toEqual(JSON.stringify(expected)); },
};
expect($apollo.mutate).toHaveBeenCalledWith(expected);
});
}); });
}); });
describe('when label `x` is clicked', () => { describe('when type is merge_request', () => {
beforeEach(() => { beforeEach(() => {
mountComponent(); mountComponent({ issuableType: IssuableType.MergeRequest });
}); });
it('makes an API call to update labels', async () => { describe('when labels are updated', () => {
findLabelsSelect().vm.$emit('onLabelRemove', 27); it('invokes a mutation', () => {
findLabelsSelect().vm.$emit('updateSelectedLabels', userUpdatedLabels);
await axios.waitForAll();
const expected = {
const expected = { mutation: updateMergeRequestLabelsMutation,
[defaultProps.issuableType]: { variables: {
label_ids: [26, 28, 29], input: {
}, iid: defaultProps.iid,
}; labelIds: [toLabelGid(27), toLabelGid(28), toLabelGid(29), toLabelGid(40)],
operationMode: MutationOperationMode.Replace,
projectPath: defaultProps.projectPath,
},
},
};
expect($apollo.mutate).toHaveBeenCalledWith(expected);
});
});
expect(axiosMock.history.put[0].data).toEqual(JSON.stringify(expected)); describe('when label `x` is clicked', () => {
it('invokes a mutation', () => {
findLabelsSelect().vm.$emit('onLabelRemove', 27);
const expected = {
mutation: updateMergeRequestLabelsMutation,
variables: {
input: {
iid: defaultProps.iid,
labelIds: [toLabelGid(27)],
operationMode: MutationOperationMode.Remove,
projectPath: defaultProps.projectPath,
},
},
};
expect($apollo.mutate).toHaveBeenCalledWith(expected);
});
}); });
}); });
}); });
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