Commit 64be78ba authored by Simon Knox's avatar Simon Knox

Merge branch 'ss/add-assignee-dropdown' into 'master'

Add assignee dropdown to Epic Boards sidebar

See merge request gitlab-org/gitlab!44830
parents 48c1a849 7ef0808f
<script>
import { mapActions, mapGetters } from 'vuex';
import { GlDropdownItem, GlDropdownDivider, GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui';
import { __, n__ } from '~/locale';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import AssigneesDropdown from '~/vue_shared/components/sidebar/assignees_dropdown.vue';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql';
export default {
i18n: {
unassigned: __('Unassigned'),
assignee: __('Assignee'),
assignees: __('Assignees'),
assignTo: __('Assign to'),
},
components: {
BoardEditableItem,
IssuableAssignees,
AssigneesDropdown,
GlDropdownItem,
GlDropdownDivider,
GlAvatarLabeled,
GlAvatarLink,
},
data() {
return {
participants: [],
selected: this.$store.getters.getActiveIssue.assignees,
};
},
apollo: {
participants: {
query: getIssueParticipants,
variables() {
return {
id: `gid://gitlab/Issue/${this.getActiveIssue.iid}`,
};
},
update(data) {
return data.issue?.participants?.nodes || [];
},
},
},
computed: {
...mapGetters(['getActiveIssue']),
assigneeText() {
return n__('Assignee', '%d Assignees', this.selected.length);
},
unSelectedFiltered() {
return this.participants.filter(({ username }) => {
return !this.selectedUserNames.includes(username);
});
},
selectedIsEmpty() {
return this.selected.length === 0;
},
selectedUserNames() {
return this.selected.map(({ username }) => username);
},
},
methods: {
...mapActions(['setAssignees']),
clearSelected() {
this.selected = [];
},
selectAssignee(name) {
if (name === undefined) {
this.clearSelected();
return;
}
this.selected = this.selected.concat(name);
},
unselect(name) {
this.selected = this.selected.filter(user => user.username !== name);
},
saveAssignees() {
this.setAssignees(this.selectedUserNames);
},
isChecked(id) {
return this.selectedUserNames.includes(id);
},
},
};
</script>
<template>
<board-editable-item :title="assigneeText" @close="saveAssignees">
<template #collapsed>
<issuable-assignees :users="getActiveIssue.assignees" />
</template>
<template #default>
<assignees-dropdown
class="w-100"
:text="$options.i18n.assignees"
:header-text="$options.i18n.assignTo"
>
<template #items>
<gl-dropdown-item
:is-checked="selectedIsEmpty"
data-testid="unassign"
class="mt-2"
@click="selectAssignee()"
>{{ $options.i18n.unassigned }}</gl-dropdown-item
>
<gl-dropdown-divider data-testid="unassign-divider" />
<gl-dropdown-item
v-for="item in selected"
:key="item.id"
:is-checked="isChecked(item.username)"
@click="unselect(item.username)"
>
<gl-avatar-link>
<gl-avatar-labeled
:size="32"
:label="item.name"
:sub-label="item.username"
:src="item.avatarUrl || item.avatar"
/>
</gl-avatar-link>
</gl-dropdown-item>
<gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" />
<gl-dropdown-item
v-for="unselectedUser in unSelectedFiltered"
:key="unselectedUser.id"
:data-testid="`item_${unselectedUser.name}`"
@click="selectAssignee(unselectedUser)"
>
<gl-avatar-link>
<gl-avatar-labeled
:size="32"
:label="unselectedUser.name"
:sub-label="unselectedUser.username"
:src="unselectedUser.avatarUrl || unselectedUser.avatar"
/>
</gl-avatar-link>
</gl-dropdown-item>
</template>
</assignees-dropdown>
</template>
</board-editable-item>
</template>
...@@ -205,7 +205,7 @@ export default { ...@@ -205,7 +205,7 @@ export default {
:key="assignee.id" :key="assignee.id"
:link-href="assigneeUrl(assignee)" :link-href="assigneeUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)" :img-alt="avatarUrlTitle(assignee)"
:img-src="assignee.avatar || assignee.avatar_url" :img-src="assignee.avatarUrl || assignee.avatar || assignee.avatar_url"
:img-size="24" :img-size="24"
class="js-no-trigger" class="js-no-trigger"
tooltip-placement="bottom" tooltip-placement="bottom"
......
...@@ -18,6 +18,7 @@ import boardLabelsQuery from '../queries/board_labels.query.graphql'; ...@@ -18,6 +18,7 @@ import boardLabelsQuery from '../queries/board_labels.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 updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
import issueSetLabels from '../queries/issue_set_labels.mutation.graphql'; import issueSetLabels from '../queries/issue_set_labels.mutation.graphql';
import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql'; import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql';
...@@ -291,6 +292,25 @@ export default { ...@@ -291,6 +292,25 @@ export default {
); );
}, },
setAssignees: ({ commit, getters }, assigneeUsernames) => {
return gqlClient
.mutate({
mutation: updateAssignees,
variables: {
iid: getters.getActiveIssue.iid,
projectPath: getters.getActiveIssue.referencePath.split('#')[0],
assigneeUsernames,
},
})
.then(({ data }) => {
commit('UPDATE_ISSUE_BY_ID', {
issueId: getters.getActiveIssue.id,
prop: 'assignees',
value: data.issueSetAssignees.issue.assignees.nodes,
});
});
},
createNewIssue: () => { createNewIssue: () => {
notImplemented(); notImplemented();
}, },
......
...@@ -22,7 +22,9 @@ export default { ...@@ -22,7 +22,9 @@ export default {
return sprintf(__("%{userName}'s avatar"), { userName: this.user.name }); return sprintf(__("%{userName}'s avatar"), { userName: this.user.name });
}, },
avatarUrl() { avatarUrl() {
return this.user.avatar || this.user.avatar_url || gon.default_avatar_url; return (
this.user.avatarUrl || this.user.avatar || this.user.avatar_url || gon.default_avatar_url
);
}, },
isMergeRequest() { isMergeRequest() {
return this.issuableType === 'merge_request'; return this.issuableType === 'merge_request';
......
...@@ -26,7 +26,6 @@ export default { ...@@ -26,7 +26,6 @@ export default {
<template> <template>
<div class="gl-display-flex gl-flex-direction-column"> <div class="gl-display-flex gl-flex-direction-column">
<label data-testid="assigneeLabel">{{ assigneesText }}</label>
<div v-if="emptyUsers" data-testid="none"> <div v-if="emptyUsers" data-testid="none">
<span> <span>
{{ __('None') }} {{ __('None') }}
......
query issueParticipants($id: IssueID!) {
issue(id: $id) {
participants {
nodes {
username
name
webUrl
avatarUrl
id
}
}
}
}
mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $projectPath: ID!) {
issueSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath }
) {
issue {
assignees {
nodes {
username
id
name
webUrl
avatarUrl
}
}
}
}
}
---
title: Add assignee dropdown to group issue boards
merge_request: 44830
author:
type: added
...@@ -3,10 +3,10 @@ import { mapState, mapActions, mapGetters } from 'vuex'; ...@@ -3,10 +3,10 @@ import { mapState, mapActions, mapGetters } from 'vuex';
import { GlDrawer } from '@gitlab/ui'; import { GlDrawer } from '@gitlab/ui';
import { ISSUABLE } from '~/boards/constants'; import { ISSUABLE } from '~/boards/constants';
import { contentTop } from '~/lib/utils/common_utils'; import { contentTop } from '~/lib/utils/common_utils';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import IssuableTitle from '~/boards/components/issuable_title.vue'; 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 BoardAssigneeDropdown from '~/boards/components/board_assignee_dropdown.vue';
import BoardSidebarTimeTracker from './sidebar/board_sidebar_time_tracker.vue'; import BoardSidebarTimeTracker from './sidebar/board_sidebar_time_tracker.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'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
...@@ -15,10 +15,10 @@ import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_d ...@@ -15,10 +15,10 @@ import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_d
export default { export default {
headerHeight: `${contentTop()}px`, headerHeight: `${contentTop()}px`,
components: { components: {
IssuableAssignees,
GlDrawer, GlDrawer,
IssuableTitle, IssuableTitle,
BoardSidebarEpicSelect, BoardSidebarEpicSelect,
BoardAssigneeDropdown,
BoardSidebarTimeTracker, BoardSidebarTimeTracker,
BoardSidebarWeightInput, BoardSidebarWeightInput,
BoardSidebarLabelsSelect, BoardSidebarLabelsSelect,
...@@ -50,7 +50,7 @@ export default { ...@@ -50,7 +50,7 @@ export default {
</template> </template>
<template> <template>
<issuable-assignees :users="getActiveIssue.assignees" /> <board-assignee-dropdown />
<board-sidebar-epic-select /> <board-sidebar-epic-select />
<board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" /> <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" />
<board-sidebar-weight-input v-if="glFeatures.issueWeights" /> <board-sidebar-weight-input v-if="glFeatures.issueWeights" />
......
...@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'; ...@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils';
import { GlDrawer } from '@gitlab/ui'; import { GlDrawer } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import BoardContentSidebar from 'ee_component/boards/components/board_content_sidebar.vue'; import BoardContentSidebar from 'ee_component/boards/components/board_content_sidebar.vue';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; import BoardAssigneeDropdown from '~/boards/components/board_assignee_dropdown.vue';
import IssuableTitle from '~/boards/components/issuable_title.vue'; import IssuableTitle from '~/boards/components/issuable_title.vue';
import { createStore } from '~/boards/stores'; import { createStore } from '~/boards/stores';
import { ISSUABLE } from '~/boards/constants'; import { ISSUABLE } from '~/boards/constants';
...@@ -14,6 +14,7 @@ describe('ee/BoardContentSidebar', () => { ...@@ -14,6 +14,7 @@ describe('ee/BoardContentSidebar', () => {
const createComponent = () => { const createComponent = () => {
wrapper = mount(BoardContentSidebar, { wrapper = mount(BoardContentSidebar, {
provide: { provide: {
canUpdate: true,
rootPath: '', rootPath: '',
}, },
store, store,
...@@ -54,7 +55,7 @@ describe('ee/BoardContentSidebar', () => { ...@@ -54,7 +55,7 @@ describe('ee/BoardContentSidebar', () => {
}); });
it('renders IssuableAssignees', () => { it('renders IssuableAssignees', () => {
expect(wrapper.find(IssuableAssignees).exists()).toBe(true); expect(wrapper.find(BoardAssigneeDropdown).exists()).toBe(true);
}); });
describe('when we emit close', () => { describe('when we emit close', () => {
......
import { mount } from '@vue/test-utils';
import { GlDropdownItem, GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui';
import BoardAssigneeDropdown from '~/boards/components/board_assignee_dropdown.vue';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import AssigneesDropdown from '~/vue_shared/components/sidebar/assignees_dropdown.vue';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import store from '~/boards/stores';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql';
import { participants } from '../mock_data';
describe('BoardCardAssigneeDropdown', () => {
let wrapper;
const iid = '111';
const activeIssueName = 'test';
const anotherIssueName = 'hello';
const createComponent = () => {
wrapper = mount(BoardAssigneeDropdown, {
data() {
return {
selected: store.getters.getActiveIssue.assignees,
participants,
};
},
store,
provide: {
canUpdate: true,
rootPath: '',
},
});
};
const unassign = async () => {
wrapper.find('[data-testid="unassign"]').trigger('click');
await wrapper.vm.$nextTick();
};
const openDropdown = async () => {
wrapper.find('[data-testid="edit-button"]').trigger('click');
await wrapper.vm.$nextTick();
};
const findByText = text => {
return wrapper.findAll(GlDropdownItem).wrappers.find(x => x.text().indexOf(text) === 0);
};
beforeEach(() => {
store.state.activeId = '1';
store.state.issues = {
'1': {
iid,
assignees: [{ username: activeIssueName, name: activeIssueName, id: activeIssueName }],
},
};
jest.spyOn(store, 'dispatch').mockResolvedValue();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when mounted', () => {
beforeEach(() => {
createComponent();
});
it.each`
text
${anotherIssueName}
${activeIssueName}
`('finds item with $text', ({ text }) => {
const item = findByText(text);
expect(item.exists()).toBe(true);
});
it('renders gl-avatar-link in gl-dropdown-item', () => {
const item = findByText('hello');
expect(item.find(GlAvatarLink).exists()).toBe(true);
});
it('renders gl-avatar-labeled in gl-avatar-link', () => {
const item = findByText('hello');
expect(
item
.find(GlAvatarLink)
.find(GlAvatarLabeled)
.exists(),
).toBe(true);
});
});
describe('when selected users are present', () => {
it('renders a divider', () => {
createComponent();
expect(wrapper.find('[data-testid="selected-user-divider"]').exists()).toBe(true);
});
});
describe('when collapsed', () => {
it('renders IssuableAssignees', () => {
createComponent();
expect(wrapper.find(IssuableAssignees).isVisible()).toBe(true);
expect(wrapper.find(AssigneesDropdown).isVisible()).toBe(false);
});
});
describe('when dropdown is open', () => {
beforeEach(async () => {
createComponent();
await openDropdown();
});
it('shows assignees dropdown', async () => {
expect(wrapper.find(IssuableAssignees).isVisible()).toBe(false);
expect(wrapper.find(AssigneesDropdown).isVisible()).toBe(true);
});
it('shows the issue returned as the activeIssue', async () => {
expect(findByText(activeIssueName).props('isChecked')).toBe(true);
});
describe('when "Unassign" is clicked', () => {
it('unassigns assignees', async () => {
await unassign();
expect(findByText('Unassign').props('isChecked')).toBe(true);
});
});
describe('when an unselected item is clicked', () => {
beforeEach(async () => {
await unassign();
});
it('assigns assignee in the dropdown', async () => {
wrapper.find('[data-testid="item_test"]').trigger('click');
await wrapper.vm.$nextTick();
expect(findByText(activeIssueName).props('isChecked')).toBe(true);
});
it('calls setAssignees with username list', async () => {
wrapper.find('[data-testid="item_test"]').trigger('click');
await wrapper.vm.$nextTick();
document.body.click();
await wrapper.vm.$nextTick();
expect(store.dispatch).toHaveBeenCalledWith('setAssignees', [activeIssueName]);
});
});
describe('when the user off clicks', () => {
beforeEach(async () => {
await unassign();
document.body.click();
await wrapper.vm.$nextTick();
});
it('calls setAssignees with username list', async () => {
expect(store.dispatch).toHaveBeenCalledWith('setAssignees', []);
});
it('closes the dropdown', async () => {
expect(wrapper.find(IssuableAssignees).isVisible()).toBe(true);
});
});
});
it('renders divider after unassign', () => {
createComponent();
expect(wrapper.find('[data-testid="unassign-divider"]').exists()).toBe(true);
});
it.each`
assignees | expected
${[{ id: 5, username: '', name: '' }]} | ${'Assignee'}
${[{ id: 6, username: '', name: '' }, { id: 7, username: '', name: '' }]} | ${'2 Assignees'}
`(
'when assignees have a length of $assignees.length, it renders $expected',
({ assignees, expected }) => {
store.state.issues['1'].assignees = assignees;
createComponent();
expect(wrapper.find(BoardEditableItem).props('title')).toBe(expected);
},
);
describe('Apollo Schema', () => {
beforeEach(() => {
createComponent();
});
it('returns the correct query', () => {
expect(wrapper.vm.$options.apollo.participants.query).toEqual(getIssueParticipants);
});
it('contains the correct variables', () => {
const { variables } = wrapper.vm.$options.apollo.participants;
const boundVariable = variables.bind(wrapper.vm);
expect(boundVariable()).toEqual({ id: 'gid://gitlab/Issue/111' });
});
it('returns the correct data from update', () => {
const node = { test: 1 };
const { update } = wrapper.vm.$options.apollo.participants;
expect(update({ issue: { participants: { nodes: [node] } } })).toEqual([node]);
});
});
});
...@@ -319,6 +319,23 @@ export const mockIssuesByListId = { ...@@ -319,6 +319,23 @@ export const mockIssuesByListId = {
'gid://gitlab/List/2': mockIssues.map(({ id }) => id), 'gid://gitlab/List/2': mockIssues.map(({ id }) => id),
}; };
export const participants = [
{
id: '1',
username: 'test',
name: 'test',
avatar: '',
avatarUrl: '',
},
{
id: '2',
username: 'hello',
name: 'hello',
avatar: '',
avatarUrl: '',
},
];
export const issues = { export const issues = {
[mockIssue.id]: mockIssue, [mockIssue.id]: mockIssue,
[mockIssue2.id]: mockIssue2, [mockIssue2.id]: mockIssue2,
......
...@@ -13,6 +13,7 @@ import actions, { gqlClient } from '~/boards/stores/actions'; ...@@ -13,6 +13,7 @@ import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types'; import * as types from '~/boards/stores/mutation_types';
import { inactiveId } from '~/boards/constants'; import { inactiveId } from '~/boards/constants';
import issueMoveListMutation from '~/boards/queries/issue_move_list.mutation.graphql'; import issueMoveListMutation from '~/boards/queries/issue_move_list.mutation.graphql';
import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
import { fullBoardId, formatListIssues, formatBoardLists } from '~/boards/boards_util'; import { fullBoardId, formatListIssues, formatBoardLists } from '~/boards/boards_util';
const expectNotImplemented = action => { const expectNotImplemented = action => {
...@@ -554,6 +555,48 @@ describe('moveIssue', () => { ...@@ -554,6 +555,48 @@ describe('moveIssue', () => {
}); });
}); });
describe('setAssignees', () => {
const node = { username: 'name' };
const name = 'username';
const projectPath = 'h/h';
const refPath = `${projectPath}#3`;
const iid = '1';
beforeEach(() => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: { issueSetAssignees: { issue: { assignees: { nodes: [{ ...node }] } } } },
});
});
it('calls mutate with the correct values', async () => {
await actions.setAssignees(
{ commit: () => {}, getters: { getActiveIssue: { iid, referencePath: refPath } } },
[name],
);
expect(gqlClient.mutate).toHaveBeenCalledWith({
mutation: updateAssignees,
variables: { iid, assigneeUsernames: [name], projectPath },
});
});
it('calls the correct mutation with the correct values', done => {
testAction(
actions.setAssignees,
{},
{ getActiveIssue: { iid, referencePath: refPath }, commit: () => {} },
[
{
type: 'UPDATE_ISSUE_BY_ID',
payload: { prop: 'assignees', issueId: undefined, value: [node] },
},
],
[],
done,
);
});
});
describe('createNewIssue', () => { describe('createNewIssue', () => {
expectNotImplemented(actions.createNewIssue); expectNotImplemented(actions.createNewIssue);
}); });
......
...@@ -13,7 +13,6 @@ describe('IssuableAssignees', () => { ...@@ -13,7 +13,6 @@ describe('IssuableAssignees', () => {
propsData: { ...props }, propsData: { ...props },
}); });
}; };
const findLabel = () => wrapper.find('[data-testid="assigneeLabel"');
const findUncollapsedAssigneeList = () => wrapper.find(UncollapsedAssigneeList); const findUncollapsedAssigneeList = () => wrapper.find(UncollapsedAssigneeList);
const findEmptyAssignee = () => wrapper.find('[data-testid="none"]'); const findEmptyAssignee = () => wrapper.find('[data-testid="none"]');
...@@ -30,10 +29,6 @@ describe('IssuableAssignees', () => { ...@@ -30,10 +29,6 @@ describe('IssuableAssignees', () => {
it('renders "None"', () => { it('renders "None"', () => {
expect(findEmptyAssignee().text()).toBe('None'); expect(findEmptyAssignee().text()).toBe('None');
}); });
it('renders "0 assignees"', () => {
expect(findLabel().text()).toBe('0 Assignees');
});
}); });
describe('when assignees are present', () => { describe('when assignees are present', () => {
...@@ -42,18 +37,5 @@ describe('IssuableAssignees', () => { ...@@ -42,18 +37,5 @@ describe('IssuableAssignees', () => {
expect(findUncollapsedAssigneeList().exists()).toBe(true); expect(findUncollapsedAssigneeList().exists()).toBe(true);
}); });
it.each`
assignees | expected
${[{ id: 1 }]} | ${'Assignee'}
${[{ id: 1 }, { id: 2 }]} | ${'2 Assignees'}
`(
'when assignees have a length of $assignees.length, it renders $expected',
({ assignees, expected }) => {
createComponent({ users: assignees });
expect(findLabel().text()).toBe(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