Commit 3756964b authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '216881-add-close-button-to-sidebar-labels-to-remove' into 'master'

Add close button to issue, MR and epic sidebar labels

See merge request gitlab-org/gitlab!42703
parents b6c0b3c8 bd8edb93
<script>
import $ from 'jquery';
import { difference, union } from 'lodash';
import { mapState, mapActions } from 'vuex';
import flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
......@@ -26,47 +25,49 @@ export default {
'projectIssuesPath',
'projectPath',
],
data: () => ({
labelsSelectInProgress: false,
}),
computed: {
...mapState(['selectedLabels']),
},
mounted() {
this.setInitialState({
data() {
return {
isLabelsSelectInProgress: false,
selectedLabels: this.initiallySelectedLabels,
});
};
},
methods: {
...mapActions(['setInitialState', 'replaceSelectedLabels']),
handleDropdownClose() {
$(this.$el).trigger('hidden.gl.dropdown');
},
handleUpdateSelectedLabels(labels) {
handleUpdateSelectedLabels(dropdownLabels) {
const currentLabelIds = this.selectedLabels.map(label => label.id);
const userAddedLabelIds = labels.filter(label => label.set).map(label => label.id);
const userRemovedLabelIds = labels.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 issuableLabels = difference(
union(currentLabelIds, userAddedLabelIds),
userRemovedLabelIds,
);
const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds);
this.labelsSelectInProgress = true;
this.updateSelectedLabels(labelIds);
},
handleLabelRemove(labelId) {
const currentLabelIds = this.selectedLabels.map(label => label.id);
const labelIds = difference(currentLabelIds, [labelId]);
this.updateSelectedLabels(labelIds);
},
updateSelectedLabels(labelIds) {
this.isLabelsSelectInProgress = true;
axios({
data: {
[this.issuableType]: {
label_ids: issuableLabels,
label_ids: labelIds,
},
},
method: 'put',
url: this.labelsUpdatePath,
})
.then(({ data }) => this.replaceSelectedLabels(data.labels))
.then(({ data }) => {
this.selectedLabels = data.labels;
})
.catch(() => flash(__('An error occurred while updating labels.')))
.finally(() => {
this.labelsSelectInProgress = false;
this.isLabelsSelectInProgress = false;
});
},
},
......@@ -76,6 +77,7 @@ export default {
<template>
<labels-select
class="block labels js-labels-block"
:allow-label-remove="true"
:allow-label-create="allowLabelCreate"
:allow-label-edit="allowLabelEdit"
:allow-multiselect="true"
......@@ -86,11 +88,12 @@ export default {
:labels-fetch-path="labelsFetchPath"
:labels-filter-base-path="projectIssuesPath"
:labels-manage-path="labelsManagePath"
:labels-select-in-progress="labelsSelectInProgress"
:labels-select-in-progress="isLabelsSelectInProgress"
:selected-labels="selectedLabels"
:variant="$options.sidebar"
data-qa-selector="labels_block"
@onDropdownClose="handleDropdownClose"
@onLabelRemove="handleLabelRemove"
@updateSelectedLabels="handleUpdateSelectedLabels"
>
{{ __('None') }}
......
import $ from 'jquery';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import SidebarLabels from './components/labels/sidebar_labels.vue';
......@@ -17,11 +16,9 @@ import createDefaultClient from '~/lib/graphql';
import { isInIssuePage, isInIncidentPage, parseBoolean } from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import { __ } from '~/locale';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
Vue.use(Translate);
Vue.use(VueApollo);
Vue.use(Vuex);
function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-options')) {
return JSON.parse(sidebarOptEl.innerHTML);
......@@ -94,8 +91,6 @@ export function mountSidebarLabels() {
return false;
}
const labelsStore = new Vuex.Store(labelsSelectModule());
return new Vue({
el,
provide: {
......@@ -105,7 +100,6 @@ export function mountSidebarLabels() {
allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels),
initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels),
},
store: labelsStore,
render: createElement => createElement(SidebarLabels),
});
}
......
......@@ -8,8 +8,20 @@ export default {
components: {
GlLabel,
},
props: {
disableLabels: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapState(['selectedLabels', 'allowScopedLabels', 'labelsFilterBasePath']),
...mapState([
'selectedLabels',
'allowLabelRemove',
'allowScopedLabels',
'labelsFilterBasePath',
]),
},
methods: {
labelFilterUrl(label) {
......@@ -42,7 +54,10 @@ export default {
:background-color="label.color"
:target="labelFilterUrl(label)"
:scoped="scopedLabel(label)"
:show-close-button="allowLabelRemove"
:disabled="disableLabels"
tooltip-placement="top"
@close="$emit('onLabelRemove', label.id)"
/>
</template>
</div>
......
......@@ -28,6 +28,11 @@ export default {
DropdownValueCollapsed,
},
props: {
allowLabelRemove: {
type: Boolean,
required: false,
default: false,
},
allowLabelEdit: {
type: Boolean,
required: true,
......@@ -130,6 +135,7 @@ export default {
mounted() {
this.setInitialState({
variant: this.variant,
allowLabelRemove: this.allowLabelRemove,
allowLabelEdit: this.allowLabelEdit,
allowLabelCreate: this.allowLabelCreate,
allowMultiselect: this.allowMultiselect,
......@@ -252,7 +258,10 @@ export default {
:allow-label-edit="allowLabelEdit"
:labels-select-in-progress="labelsSelectInProgress"
/>
<dropdown-value>
<dropdown-value
:disable-labels="labelsSelectInProgress"
@onLabelRemove="$emit('onLabelRemove', $event)"
>
<slot></slot>
</dropdown-value>
<dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" />
......
......@@ -54,8 +54,5 @@ export const createLabel = ({ state, dispatch }, label) => {
});
};
export const replaceSelectedLabels = ({ commit }, selectedLabels) =>
commit(types.REPLACE_SELECTED_LABELS, selectedLabels);
export const updateSelectedLabels = ({ commit }, labels) =>
commit(types.UPDATE_SELECTED_LABELS, { labels });
......@@ -15,7 +15,6 @@ export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE';
export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY';
export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS';
export const REPLACE_SELECTED_LABELS = 'REPLACE_SELECTED_LABELS';
export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS';
export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW';
......@@ -57,10 +57,6 @@ export default {
state.labelCreateInProgress = false;
},
[types.REPLACE_SELECTED_LABELS](state, selectedLabels = []) {
state.selectedLabels = selectedLabels;
},
[types.UPDATE_SELECTED_LABELS](state, { labels }) {
// Find the label to update from all the labels
// and change `set` prop value to represent their current state.
......
......@@ -15,6 +15,7 @@ export default () => ({
// UI Flags
variant: '',
allowLabelRemove: false,
allowLabelCreate: false,
allowLabelEdit: false,
allowScopedLabels: false,
......
---
title: Add close button to issue, MR, and epic sidebar labels
merge_request: 42703
author:
type: added
......@@ -30,18 +30,25 @@ There are two types of labels in GitLab:
## Assign and unassign labels
Every issue, merge request and epic can be assigned any number of labels. The labels are
> Unassigning labels with the **X** button [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216881) in GitLab 13.5.
Every issue, merge request, and epic can be assigned any number of labels. The labels are
managed in the right sidebar, where you can assign or unassign labels as needed.
To assign a label to an issue, merge request or epic:
To assign or unassign a label:
1. In the **Labels** section of the sidebar, click **Edit**.
1. In the **Assign labels** list, search for labels by typing their names.
You can search repeatedly to add more labels.
The selected labels are marked with a checkmark.
1. Click the labels you want to assign or unassign.
1. To apply your changes to labels, click **X** next to **Assign labels** or anywhere outside the
label section.
1. In the label section of the sidebar, click **Edit**, then:
- In the list, click the labels you want. Each label is flagged with a checkmark.
- Find labels by entering a search query and clicking search (**{search}**), then
click on them. You can search repeatedly and add more labels.
1. Click **X** or anywhere outside the label section and the labels are applied.
Alternatively, to unassign a label, click the **X** on the label you want to unassign.
You can also assign a label with the [`/label ~label1 ~label2` quick action](quick_actions.md).
You can also assign a label with the `/label` [quick action](quick_actions.md),
remove labels with `/unlabel`, and reassign labels (remove all and assign new ones) with `/relabel`.
## Label management
......
......@@ -100,6 +100,10 @@ export default {
}
}
},
handleLabelRemove(labelId) {
const labelToRemove = [{ id: labelId, set: false }];
this.updateEpicLabels(labelToRemove);
},
handleUpdateSelectedLabels(labels) {
// Iterate over selection and check if labels which were
// either selected or removed aren't leading to same selection
......@@ -124,6 +128,7 @@ export default {
<template>
<labels-select-vue
:allow-label-remove="true"
:allow-label-edit="canUpdate"
:allow-label-create="true"
:allow-multiselect="true"
......@@ -137,6 +142,7 @@ export default {
class="block labels js-labels-block"
@updateSelectedLabels="handleUpdateSelectedLabels"
@onDropdownClose="handleDropdownClose"
@onLabelRemove="handleLabelRemove"
@toggleCollapse="toggleSidebarRevealLabelsDropdown"
>{{ __('None') }}</labels-select-vue
>
......
......@@ -118,6 +118,22 @@ describe('SidebarLabelsComponent', () => {
});
});
describe('handleLabelRemove', () => {
it('calls action `updateEpicLabels` with the label ID to remove', () => {
const labelIdToRemove = 9;
jest.spyOn(wrapper.vm, 'updateEpicLabels').mockImplementation();
store.state.labels = mockLabels;
wrapper.vm.handleLabelRemove(labelIdToRemove);
expect(wrapper.vm.updateEpicLabels).toHaveBeenCalledWith(
expect.arrayContaining([{ id: labelIdToRemove, set: false }]),
);
});
});
describe('handleUpdateSelectedLabels', () => {
const updatingLabel = {
id: 1,
......
......@@ -6,9 +6,10 @@ RSpec.describe "Issues > User edits issue", :js do
let_it_be(:project) { create(:project_empty_repo, :public) }
let_it_be(:project_with_milestones) { create(:project_empty_repo, :public) }
let_it_be(:user) { create(:user) }
let_it_be(:issue) { create(:issue, project: project, author: user, assignees: [user]) }
let_it_be(:label_assigned) { create(:label, project: project, title: 'verisimilitude') }
let_it_be(:label_unassigned) { create(:label, project: project, title: 'syzygy') }
let_it_be(:issue) { create(:issue, project: project, author: user, assignees: [user], labels: [label_assigned]) }
let_it_be(:issue_with_milestones) { create(:issue, project: project_with_milestones, author: user, assignees: [user]) }
let_it_be(:label) { create(:label, project: project) }
let_it_be(:milestone) { create(:milestone, project: project) }
let_it_be(:milestones) { create_list(:milestone, 25, project: project_with_milestones) }
......@@ -103,6 +104,39 @@ RSpec.describe "Issues > User edits issue", :js do
expect(page).not_to have_selector('.gl-spinner')
end
end
it 'can add label to issue' do
page.within '.block.labels' do
expect(page).to have_text('verisimilitude')
expect(page).not_to have_text('syzygy')
click_on 'Edit'
wait_for_requests
click_on 'syzygy'
find('.dropdown-header-button').click
wait_for_requests
expect(page).to have_text('verisimilitude')
expect(page).to have_text('syzygy')
end
end
it 'can remove label from issue by clicking on the label `x` button' do
page.within '.block.labels' do
expect(page).to have_text('verisimilitude')
within '.gl-label' do
click_button
end
wait_for_requests
expect(page).not_to have_text('verisimilitude')
end
end
end
describe 'update assignee' do
......
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import Vuex from 'vuex';
import {
mockLabels,
mockRegularLabel,
......@@ -9,17 +8,11 @@ import axios from '~/lib/utils/axios_utils';
import SidebarLabels from '~/sidebar/components/labels/sidebar_labels.vue';
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 labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('sidebar labels', () => {
let axiosMock;
let wrapper;
const store = new Vuex.Store(labelsSelectModule());
const defaultProps = {
allowLabelCreate: true,
allowLabelEdit: true,
......@@ -39,11 +32,9 @@ describe('sidebar labels', () => {
const mountComponent = () => {
wrapper = shallowMount(SidebarLabels, {
localVue,
provide: {
...defaultProps,
},
store,
});
};
......@@ -81,7 +72,7 @@ describe('sidebar labels', () => {
});
});
describe('when labels are changed', () => {
describe('when labels are updated', () => {
beforeEach(() => {
mountComponent();
});
......@@ -121,4 +112,24 @@ describe('sidebar labels', () => {
expect(axiosMock.history.put[0].data).toEqual(JSON.stringify(expected));
});
});
describe('when label `x` is clicked', () => {
beforeEach(() => {
mountComponent();
});
it('makes an API call to update labels', async () => {
findLabelsSelect().vm.$emit('onLabelRemove', 27);
await axios.waitForAll();
const expected = {
[defaultProps.issuableType]: {
label_ids: [26, 28, 29],
},
};
expect(axiosMock.history.put[0].data).toEqual(JSON.stringify(expected));
});
});
});
......@@ -259,21 +259,6 @@ describe('LabelsSelect Actions', () => {
});
});
describe('replaceSelectedLabels', () => {
it('replaces `state.selectedLabels`', done => {
const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
testAction(
actions.replaceSelectedLabels,
selectedLabels,
state,
[{ type: types.REPLACE_SELECTED_LABELS, payload: selectedLabels }],
[],
done,
);
});
});
describe('updateSelectedLabels', () => {
it('updates `state.labels` based on provided `labels` param', done => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
......
......@@ -152,19 +152,6 @@ describe('LabelsSelect Mutations', () => {
});
});
describe(`${types.REPLACE_SELECTED_LABELS}`, () => {
it('replaces `state.selectedLabels`', () => {
const state = {
selectedLabels: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }],
};
const newSelectedLabels = [{ id: 2 }, { id: 5 }];
mutations[types.REPLACE_SELECTED_LABELS](state, newSelectedLabels);
expect(state.selectedLabels).toEqual(newSelectedLabels);
});
});
describe(`${types.UPDATE_SELECTED_LABELS}`, () => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
......
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