Commit 7ef0808f authored by Scott Stern's avatar Scott Stern Committed by Simon Knox

Add assignee dropdown to Epic Boards sidebar

parent 1c777ad3
<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 {
:key="assignee.id"
:link-href="assigneeUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)"
:img-src="assignee.avatar || assignee.avatar_url"
:img-src="assignee.avatarUrl || assignee.avatar || assignee.avatar_url"
:img-size="24"
class="js-no-trigger"
tooltip-placement="bottom"
......
......@@ -18,6 +18,7 @@ import boardLabelsQuery from '../queries/board_labels.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 updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
import issueSetLabels from '../queries/issue_set_labels.mutation.graphql';
import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql';
......@@ -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: () => {
notImplemented();
},
......
......@@ -22,7 +22,9 @@ export default {
return sprintf(__("%{userName}'s avatar"), { userName: this.user.name });
},
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() {
return this.issuableType === 'merge_request';
......
......@@ -26,7 +26,6 @@ export default {
<template>
<div class="gl-display-flex gl-flex-direction-column">
<label data-testid="assigneeLabel">{{ assigneesText }}</label>
<div v-if="emptyUsers" data-testid="none">
<span>
{{ __('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';
import { GlDrawer } from '@gitlab/ui';
import { ISSUABLE } from '~/boards/constants';
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 glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
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 BoardSidebarWeightInput from './sidebar/board_sidebar_weight_input.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
export default {
headerHeight: `${contentTop()}px`,
components: {
IssuableAssignees,
GlDrawer,
IssuableTitle,
BoardSidebarEpicSelect,
BoardAssigneeDropdown,
BoardSidebarTimeTracker,
BoardSidebarWeightInput,
BoardSidebarLabelsSelect,
......@@ -50,7 +50,7 @@ export default {
</template>
<template>
<issuable-assignees :users="getActiveIssue.assignees" />
<board-assignee-dropdown />
<board-sidebar-epic-select />
<board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" />
<board-sidebar-weight-input v-if="glFeatures.issueWeights" />
......
......@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils';
import { GlDrawer } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
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 { createStore } from '~/boards/stores';
import { ISSUABLE } from '~/boards/constants';
......@@ -14,6 +14,7 @@ describe('ee/BoardContentSidebar', () => {
const createComponent = () => {
wrapper = mount(BoardContentSidebar, {
provide: {
canUpdate: true,
rootPath: '',
},
store,
......@@ -54,7 +55,7 @@ describe('ee/BoardContentSidebar', () => {
});
it('renders IssuableAssignees', () => {
expect(wrapper.find(IssuableAssignees).exists()).toBe(true);
expect(wrapper.find(BoardAssigneeDropdown).exists()).toBe(true);
});
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 = {
'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 = {
[mockIssue.id]: mockIssue,
[mockIssue2.id]: mockIssue2,
......
......@@ -13,6 +13,7 @@ import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
import { inactiveId } from '~/boards/constants';
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';
const expectNotImplemented = action => {
......@@ -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', () => {
expectNotImplemented(actions.createNewIssue);
});
......
......@@ -13,7 +13,6 @@ describe('IssuableAssignees', () => {
propsData: { ...props },
});
};
const findLabel = () => wrapper.find('[data-testid="assigneeLabel"');
const findUncollapsedAssigneeList = () => wrapper.find(UncollapsedAssigneeList);
const findEmptyAssignee = () => wrapper.find('[data-testid="none"]');
......@@ -30,10 +29,6 @@ describe('IssuableAssignees', () => {
it('renders "None"', () => {
expect(findEmptyAssignee().text()).toBe('None');
});
it('renders "0 assignees"', () => {
expect(findLabel().text()).toBe('0 Assignees');
});
});
describe('when assignees are present', () => {
......@@ -42,18 +37,5 @@ describe('IssuableAssignees', () => {
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