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 () => {
groupId: Number($boardApp.dataset.groupId),
rootPath: $boardApp.dataset.rootPath,
canUpdate: $boardApp.dataset.canUpdate,
labelsFetchPath: $boardApp.dataset.labelsFetchPath,
labelsManagePath: $boardApp.dataset.labelsManagePath,
labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath,
},
store,
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';
import createBoardListMutation from '../queries/board_list_create.mutation.graphql';
import updateBoardListMutation from '../queries/board_list_update.mutation.graphql';
import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql';
import issueSetLabels from '../queries/issue_set_labels.mutation.graphql';
const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
......@@ -281,6 +282,31 @@ export default {
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: () => {
notImplemented();
},
......
......@@ -18,7 +18,10 @@ module BoardsHelper
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s,
recent_boards_endpoint: recent_boards_path,
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
......@@ -38,6 +41,22 @@ module BoardsHelper
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
if board.group_board?
group_boards_url(@group)
......
......@@ -8,6 +8,7 @@ import IssuableTitle from '~/boards/components/issuable_title.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import BoardSidebarEpicSelect from './sidebar/board_sidebar_epic_select.vue';
import BoardSidebarWeightInput from './sidebar/board_sidebar_weight_input.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
export default {
headerHeight: `${contentTop()}px`,
......@@ -17,6 +18,7 @@ export default {
IssuableTitle,
BoardSidebarEpicSelect,
BoardSidebarWeightInput,
BoardSidebarLabelsSelect,
},
mixins: [glFeatureFlagsMixin()],
computed: {
......@@ -47,6 +49,7 @@ export default {
<issuable-assignees :users="getActiveIssue.assignees" />
<board-sidebar-epic-select />
<board-sidebar-weight-input v-if="glFeatures.issueWeights" />
<board-sidebar-labels-select />
</template>
</gl-drawer>
</template>
......@@ -101,9 +101,15 @@
}
}
.labels-select-wrapper.is-embedded {
.new-epic-form .labels-select-wrapper.is-embedded {
width: $gl-dropdown-width;
.labels-select-dropdown-contents {
min-height: 335px;
}
}
.labels-select-wrapper.is-embedded {
.labels-select-dropdown-button {
@include gl-bg-white;
@include gl-font-regular;
......@@ -135,7 +141,7 @@
@include gl-shadow-x0-y2-b4-s0;
width: 300px !important;
min-height: 335px;
min-height: none;
max-height: none;
margin-bottom: $gl-spacing-scale-6 !important;
......
......@@ -20,6 +20,7 @@ describe('ee/BoardContentSidebar', () => {
stubs: {
'board-sidebar-epic-select': '<div></div>',
'board-sidebar-weight-input': '<div></div>',
'board-sidebar-labels-select': '<div></div>',
},
});
};
......
......@@ -2820,6 +2820,9 @@ msgstr ""
msgid "An error occurred previewing the blob"
msgstr ""
msgid "An error occurred when removing the label."
msgstr ""
msgid "An error occurred when toggling the notification subscription"
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 = [
},
];
const labels = [
export const labels = [
{
id: 'gid://gitlab/GroupLabel/5',
title: 'Cosync',
color: '#34ebec',
description: null,
},
{
id: 'gid://gitlab/GroupLabel/6',
title: 'Brock',
color: '#e082b6',
description: null,
},
];
export const rawIssue = {
......
......@@ -7,6 +7,7 @@ import {
mockIssue2WithModel,
rawIssue,
mockIssues,
labels,
} from '../mock_data';
import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
......@@ -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', () => {
expectNotImplemented(actions.fetchBacklog);
});
......
......@@ -34,28 +34,58 @@ RSpec.describe BoardsHelper do
end
describe '#board_data' do
let(:user) { create(:user) }
let(:board) { create(:board, project: project) }
let_it_be(:user) { create(:user) }
let_it_be(:board) { create(:board, project: project) }
before do
assign(:board, board)
assign(:project, project)
context 'project_board' do
before do
assign(:project, project)
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
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 a board_lists_path as lists_endpoint' do
expect(helper.board_data[:lists_endpoint]).to eq(board_lists_path(board))
end
it 'returns a board_lists_path as lists_endpoint' do
expect(helper.board_data[:lists_endpoint]).to eq(board_lists_path(board))
end
it 'returns board type as parent' do
expect(helper.board_data[:parent]).to eq('project')
it 'returns board type as parent' do
expect(helper.board_data[:parent]).to eq('project')
end
it 'returns can_update for user permissions on the board' do
expect(helper.board_data[:can_update]).to eq('true')
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
it 'returns can_update for user permissions on the board' do
expect(helper.board_data[:can_update]).to eq('true')
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
......
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