Commit a6bfb251 authored by Axel García's avatar Axel García

Add labels selector to swimlanes sidebar

It uses the labels-select component to fetch
labels and handle the selection logic.
parent 77b5fbe7
<script>
import { mapGetters, mapActions } from 'vuex';
import { GlLabel } from '@gitlab/ui';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import { isScopedLabel } from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import { __ } from '~/locale';
export default {
components: {
BoardEditableItem,
LabelsSelect,
GlLabel,
},
data() {
return {
loading: false,
};
},
inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'],
computed: {
...mapGetters({ issue: 'getActiveIssue' }),
selectedLabels() {
const { labels = [] } = this.issue;
return labels.map(label => ({
...label,
id: getIdFromGraphQLId(label.id),
}));
},
issueLabels() {
const { labels = [] } = this.issue;
return labels.map(label => ({
...label,
scoped: isScopedLabel(label),
}));
},
projectPath() {
const { referencePath = '' } = this.issue;
return referencePath.slice(0, referencePath.indexOf('#'));
},
},
methods: {
...mapActions(['setActiveIssueLabels']),
async setLabels(payload) {
this.loading = true;
this.$refs.sidebarItem.collapse();
try {
const addLabelIds = payload.filter(label => label.set).map(label => label.id);
const removeLabelIds = this.selectedLabels
.filter(label => !payload.find(selected => selected.id === label.id))
.map(label => label.id);
const input = { addLabelIds, removeLabelIds, projectPath: this.projectPath };
await this.setActiveIssueLabels(input);
} catch (e) {
createFlash({ message: __('An error occurred while updating labels.') });
} finally {
this.loading = false;
}
},
async removeLabel(id) {
this.loading = true;
try {
const removeLabelIds = [getIdFromGraphQLId(id)];
const input = { removeLabelIds, projectPath: this.projectPath };
await this.setActiveIssueLabels(input);
} catch (e) {
createFlash({ message: __('An error occurred when removing the label.') });
} finally {
this.loading = false;
}
},
},
};
</script>
<template>
<board-editable-item ref="sidebarItem" :title="__('Labels')" :loading="loading">
<template #collapsed>
<gl-label
v-for="label in issueLabels"
:key="label.id"
:background-color="label.color"
:title="label.title"
:description="label.description"
:scoped="label.scoped"
:show-close-button="true"
:disabled="loading"
class="gl-mr-2 gl-mb-2"
@close="removeLabel(label.id)"
/>
</template>
<template>
<labels-select
ref="labelsSelect"
:allow-label-edit="false"
:allow-label-create="false"
:allow-multiselect="true"
:allow-scoped-labels="true"
:selected-labels="selectedLabels"
:labels-fetch-path="labelsFetchPath"
:labels-manage-path="labelsManagePath"
:labels-filter-base-path="labelsFilterBasePath"
:labels-list-title="__('Select label')"
:dropdown-button-text="__('Choose labels')"
variant="embedded"
class="gl-display-block labels gl-w-full"
@updateSelectedLabels="setLabels"
>
{{ __('None') }}
</labels-select>
</template>
</board-editable-item>
</template>
mutation issueSetLabels($input: UpdateIssueInput!) {
updateIssue(input: $input) {
issue {
labels {
nodes {
id
title
color
description
}
}
}
errors
}
}
...@@ -19,6 +19,7 @@ import boardListsQuery from '../queries/board_lists.query.graphql'; ...@@ -19,6 +19,7 @@ import boardListsQuery from '../queries/board_lists.query.graphql';
import createBoardListMutation from '../queries/board_list_create.mutation.graphql'; import createBoardListMutation from '../queries/board_list_create.mutation.graphql';
import updateBoardListMutation from '../queries/board_list_update.mutation.graphql'; import updateBoardListMutation from '../queries/board_list_update.mutation.graphql';
import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql'; import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql';
import issueSetLabels from '../queries/issue_set_labels.mutation.graphql';
const notImplemented = () => { const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */ /* eslint-disable-next-line @gitlab/require-i18n-strings */
...@@ -281,6 +282,31 @@ export default { ...@@ -281,6 +282,31 @@ export default {
commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issue }); commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issue });
}, },
setActiveIssueLabels: async ({ commit, getters }, input) => {
const activeIssue = getters.getActiveIssue;
const { data } = await gqlClient.mutate({
mutation: issueSetLabels,
variables: {
input: {
iid: String(activeIssue.iid),
addLabelIds: input.addLabelIds ?? [],
removeLabelIds: input.removeLabelIds ?? [],
projectPath: input.projectPath,
},
},
});
if (data.updateIssue?.errors?.length > 0) {
throw new Error(data.updateIssue.errors);
}
commit(types.UPDATE_ISSUE_BY_ID, {
issueId: activeIssue.id,
prop: 'labels',
value: data.updateIssue.issue.labels.nodes,
});
},
fetchBacklog: () => { fetchBacklog: () => {
notImplemented(); notImplemented();
}, },
......
...@@ -8,6 +8,7 @@ import IssuableTitle from '~/boards/components/issuable_title.vue'; ...@@ -8,6 +8,7 @@ import IssuableTitle from '~/boards/components/issuable_title.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import BoardSidebarEpicSelect from './sidebar/board_sidebar_epic_select.vue'; import BoardSidebarEpicSelect from './sidebar/board_sidebar_epic_select.vue';
import BoardSidebarWeightInput from './sidebar/board_sidebar_weight_input.vue'; import BoardSidebarWeightInput from './sidebar/board_sidebar_weight_input.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
export default { export default {
headerHeight: `${contentTop()}px`, headerHeight: `${contentTop()}px`,
...@@ -17,6 +18,7 @@ export default { ...@@ -17,6 +18,7 @@ export default {
IssuableTitle, IssuableTitle,
BoardSidebarEpicSelect, BoardSidebarEpicSelect,
BoardSidebarWeightInput, BoardSidebarWeightInput,
BoardSidebarLabelsSelect,
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
computed: { computed: {
...@@ -47,6 +49,7 @@ export default { ...@@ -47,6 +49,7 @@ export default {
<issuable-assignees :users="getActiveIssue.assignees" /> <issuable-assignees :users="getActiveIssue.assignees" />
<board-sidebar-epic-select /> <board-sidebar-epic-select />
<board-sidebar-weight-input v-if="glFeatures.issueWeights" /> <board-sidebar-weight-input v-if="glFeatures.issueWeights" />
<board-sidebar-labels-select />
</template> </template>
</gl-drawer> </gl-drawer>
</template> </template>
...@@ -101,9 +101,15 @@ ...@@ -101,9 +101,15 @@
} }
} }
.labels-select-wrapper.is-embedded { .new-epic-form .labels-select-wrapper.is-embedded {
width: $gl-dropdown-width; width: $gl-dropdown-width;
.labels-select-dropdown-contents {
min-height: 335px;
}
}
.labels-select-wrapper.is-embedded {
.labels-select-dropdown-button { .labels-select-dropdown-button {
@include gl-bg-white; @include gl-bg-white;
@include gl-font-regular; @include gl-font-regular;
...@@ -135,7 +141,7 @@ ...@@ -135,7 +141,7 @@
@include gl-shadow-x0-y2-b4-s0; @include gl-shadow-x0-y2-b4-s0;
width: 300px !important; width: 300px !important;
min-height: 335px; min-height: none;
max-height: none; max-height: none;
margin-bottom: $gl-spacing-scale-6 !important; margin-bottom: $gl-spacing-scale-6 !important;
......
...@@ -20,6 +20,7 @@ describe('ee/BoardContentSidebar', () => { ...@@ -20,6 +20,7 @@ describe('ee/BoardContentSidebar', () => {
stubs: { stubs: {
'board-sidebar-epic-select': '<div></div>', 'board-sidebar-epic-select': '<div></div>',
'board-sidebar-weight-input': '<div></div>', 'board-sidebar-weight-input': '<div></div>',
'board-sidebar-labels-select': '<div></div>',
}, },
}); });
}; };
......
...@@ -2815,6 +2815,9 @@ msgstr "" ...@@ -2815,6 +2815,9 @@ msgstr ""
msgid "An error occurred previewing the blob" msgid "An error occurred previewing the blob"
msgstr "" msgstr ""
msgid "An error occurred when removing the label."
msgstr ""
msgid "An error occurred when toggling the notification subscription" msgid "An error occurred when toggling the notification subscription"
msgstr "" msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import { GlLabel } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants';
import { labels as TEST_LABELS, mockIssue as TEST_ISSUE } from 'jest/boards/mock_data';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { createStore } from '~/boards/stores';
import createFlash from '~/flash';
jest.mock('~/flash');
const TEST_LABELS_PAYLOAD = TEST_LABELS.map(label => ({ ...label, set: true }));
const TEST_LABELS_TITLES = TEST_LABELS.map(label => label.title);
describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => {
let wrapper;
let store;
afterEach(() => {
wrapper.destroy();
store = null;
wrapper = null;
});
const createWrapper = ({ labels = [] } = {}) => {
store = createStore();
store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, labels } };
store.state.activeId = TEST_ISSUE.id;
wrapper = shallowMount(BoardSidebarLabelsSelect, {
store,
provide: {
canUpdate: true,
labelsFetchPath: TEST_HOST,
labelsManagePath: TEST_HOST,
labelsFilterBasePath: TEST_HOST,
},
stubs: {
'board-editable-item': BoardEditableItem,
'labels-select': '<div></div>',
},
});
};
const findLabelsSelect = () => wrapper.find({ ref: 'labelsSelect' });
const findLabelsTitles = () => wrapper.findAll(GlLabel).wrappers.map(item => item.props('title'));
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
it('renders "None" when no labels are selected', () => {
createWrapper();
expect(findCollapsed().text()).toBe('None');
});
it('renders labels when set', () => {
createWrapper({ labels: TEST_LABELS });
expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES);
});
describe('when labels are submitted', () => {
beforeEach(async () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => TEST_LABELS);
findLabelsSelect().vm.$emit('updateSelectedLabels', TEST_LABELS_PAYLOAD);
store.state.issues[TEST_ISSUE.id].labels = TEST_LABELS;
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders labels', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES);
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveIssueLabels).toHaveBeenCalledWith({
addLabelIds: TEST_LABELS.map(label => label.id),
projectPath: 'gitlab-org/test-subgroup/gitlab-test',
removeLabelIds: [],
});
});
});
describe('when labels are updated over existing labels', () => {
const testLabelsPayload = [{ id: 5, set: true }, { id: 7, set: true }];
const expectedLabels = [{ id: 5 }, { id: 7 }];
beforeEach(async () => {
createWrapper({ labels: TEST_LABELS });
jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => expectedLabels);
findLabelsSelect().vm.$emit('updateSelectedLabels', testLabelsPayload);
await wrapper.vm.$nextTick();
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveIssueLabels).toHaveBeenCalledWith({
addLabelIds: [5, 7],
removeLabelIds: [6],
projectPath: 'gitlab-org/test-subgroup/gitlab-test',
});
});
});
describe('when removing individual labels', () => {
const testLabel = TEST_LABELS[0];
beforeEach(async () => {
createWrapper({ labels: [testLabel] });
jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => {});
});
it('commits change to the server', () => {
wrapper.find(GlLabel).vm.$emit('close', testLabel);
expect(wrapper.vm.setActiveIssueLabels).toHaveBeenCalledWith({
removeLabelIds: [getIdFromGraphQLId(testLabel.id)],
projectPath: 'gitlab-org/test-subgroup/gitlab-test',
});
});
});
describe('when the mutation fails', () => {
beforeEach(async () => {
createWrapper({ labels: TEST_LABELS });
jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => {
throw new Error(['failed mutation']);
});
findLabelsSelect().vm.$emit('updateSelectedLabels', [{ id: '?' }]);
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders former issue weight', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES);
expect(createFlash).toHaveBeenCalled();
});
});
});
...@@ -108,13 +108,19 @@ const assignees = [ ...@@ -108,13 +108,19 @@ const assignees = [
}, },
]; ];
const labels = [ export const labels = [
{ {
id: 'gid://gitlab/GroupLabel/5', id: 'gid://gitlab/GroupLabel/5',
title: 'Cosync', title: 'Cosync',
color: '#34ebec', color: '#34ebec',
description: null, description: null,
}, },
{
id: 'gid://gitlab/GroupLabel/6',
title: 'Brock',
color: '#e082b6',
description: null,
},
]; ];
export const rawIssue = { export const rawIssue = {
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
mockIssue2WithModel, mockIssue2WithModel,
rawIssue, rawIssue,
mockIssues, mockIssues,
labels,
} from '../mock_data'; } from '../mock_data';
import actions, { gqlClient } from '~/boards/stores/actions'; import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types'; import * as types from '~/boards/stores/mutation_types';
...@@ -526,6 +527,51 @@ describe('addListIssueFailure', () => { ...@@ -526,6 +527,51 @@ describe('addListIssueFailure', () => {
}); });
}); });
describe('setActiveIssueLabels', () => {
const state = { issues: { [mockIssue.id]: mockIssue } };
const getters = { getActiveIssue: mockIssue };
const testLabelIds = labels.map(label => label.id);
const input = {
addLabelIds: testLabelIds,
removeLabelIds: [],
projectPath: 'h/b',
};
it('should assign labels on success', done => {
jest
.spyOn(gqlClient, 'mutate')
.mockResolvedValue({ data: { updateIssue: { issue: { labels: { nodes: labels } } } } });
const payload = {
issueId: getters.getActiveIssue.id,
prop: 'labels',
value: labels,
};
testAction(
actions.setActiveIssueLabels,
input,
{ ...state, ...getters },
[
{
type: types.UPDATE_ISSUE_BY_ID,
payload,
},
],
[],
done,
);
});
it('throws error if fails', async () => {
jest
.spyOn(gqlClient, 'mutate')
.mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } });
await expect(actions.setActiveIssueLabels({ getters }, input)).rejects.toThrow(Error);
});
});
describe('fetchBacklog', () => { describe('fetchBacklog', () => {
expectNotImplemented(actions.fetchBacklog); expectNotImplemented(actions.fetchBacklog);
}); });
......
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