Commit c764596a authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '37104-sidebar-labels-should-in-vue' into 'master'

Add labels component to swimlanes board sidebar

See merge request gitlab-org/gitlab!40999
parents 80d0c67c a6bfb251
<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>
...@@ -87,6 +87,9 @@ export default () => { ...@@ -87,6 +87,9 @@ export default () => {
groupId: Number($boardApp.dataset.groupId), groupId: Number($boardApp.dataset.groupId),
rootPath: $boardApp.dataset.rootPath, rootPath: $boardApp.dataset.rootPath,
canUpdate: $boardApp.dataset.canUpdate, canUpdate: $boardApp.dataset.canUpdate,
labelsFetchPath: $boardApp.dataset.labelsFetchPath,
labelsManagePath: $boardApp.dataset.labelsManagePath,
labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath,
}, },
store, store,
apolloProvider, apolloProvider,
......
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();
}, },
......
...@@ -18,7 +18,10 @@ module BoardsHelper ...@@ -18,7 +18,10 @@ module BoardsHelper
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s, time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s,
recent_boards_endpoint: recent_boards_path, recent_boards_endpoint: recent_boards_path,
parent: current_board_parent.model_name.param_key, parent: current_board_parent.model_name.param_key,
group_id: @group&.id group_id: @group&.id,
labels_filter_base_path: build_issue_link_base,
labels_fetch_path: labels_fetch_path,
labels_manage_path: labels_manage_path
} }
end end
...@@ -38,6 +41,22 @@ module BoardsHelper ...@@ -38,6 +41,22 @@ module BoardsHelper
end end
end end
def labels_fetch_path
if board.group_board?
group_labels_path(@group, format: :json, only_group_labels: true, include_ancestor_groups: true)
else
project_labels_path(@project, format: :json, include_ancestor_groups: true)
end
end
def labels_manage_path
if board.group_board?
group_labels_path(@group)
else
project_labels_path(@project)
end
end
def board_base_url def board_base_url
if board.group_board? if board.group_board?
group_boards_url(@group) group_boards_url(@group)
......
...@@ -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>',
}, },
}); });
}; };
......
...@@ -2820,6 +2820,9 @@ msgstr "" ...@@ -2820,6 +2820,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);
}); });
......
...@@ -34,12 +34,13 @@ RSpec.describe BoardsHelper do ...@@ -34,12 +34,13 @@ RSpec.describe BoardsHelper do
end end
describe '#board_data' do describe '#board_data' do
let(:user) { create(:user) } let_it_be(:user) { create(:user) }
let(:board) { create(:board, project: project) } let_it_be(:board) { create(:board, project: project) }
context 'project_board' do
before do before do
assign(:board, board)
assign(:project, project) assign(:project, project)
assign(:board, board)
allow(helper).to receive(:current_user) { user } allow(helper).to receive(:current_user) { user }
allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, board).and_return(true) allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, board).and_return(true)
...@@ -57,6 +58,35 @@ RSpec.describe BoardsHelper do ...@@ -57,6 +58,35 @@ RSpec.describe BoardsHelper do
it 'returns can_update for user permissions on the board' do it 'returns can_update for user permissions on the board' do
expect(helper.board_data[:can_update]).to eq('true') expect(helper.board_data[:can_update]).to eq('true')
end end
it 'returns required label endpoints' do
expect(helper.board_data[:labels_fetch_path]).to eq("/#{project.full_path}/-/labels.json?include_ancestor_groups=true")
expect(helper.board_data[:labels_manage_path]).to eq("/#{project.full_path}/-/labels")
end
end
context 'group board' do
let_it_be(:group) { create(:group, path: 'base') }
let_it_be(:board) { create(:board, group: group) }
before do
assign(:group, group)
assign(:board, board)
allow(helper).to receive(:current_user) { user }
allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, board).and_return(true)
allow(helper).to receive(:can?).with(user, :admin_issue, board).and_return(true)
end
it 'returns correct path for base group' do
expect(helper.build_issue_link_base).to eq('/base/:project_path/issues')
end
it 'returns required label endpoints' do
expect(helper.board_data[:labels_fetch_path]).to eq("/groups/base/-/labels.json?include_ancestor_groups=true&only_group_labels=true")
expect(helper.board_data[:labels_manage_path]).to eq("/groups/base/-/labels")
end
end
end end
describe '#current_board_json' do describe '#current_board_json' do
......
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