Commit e3c41933 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '292035-convert-the-assignees-feature-into-a-widget' into 'master'

[RUN-AS-IF-FOSS] POC: Sidebar Assignees Widget

See merge request gitlab-org/gitlab!50054
parents d6bc39c2 33c12b9a
<script>
import {
GlDropdownItem,
GlDropdownDivider,
GlAvatarLabeled,
GlAvatarLink,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import searchUsers from '~/boards/graphql/users_search.query.graphql';
import { __, n__ } from '~/locale';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql';
export default {
noSearchDelay: 0,
searchDelay: 250,
i18n: {
unassigned: __('Unassigned'),
assignee: __('Assignee'),
assignees: __('Assignees'),
assignTo: __('Assign to'),
},
components: {
BoardEditableItem,
IssuableAssignees,
MultiSelectDropdown,
GlDropdownItem,
GlDropdownDivider,
GlAvatarLabeled,
GlAvatarLink,
GlSearchBoxByType,
GlLoadingIcon,
},
data() {
return {
search: '',
issueParticipants: [],
selected: [],
};
},
apollo: {
issueParticipants: {
query: getIssueParticipants,
variables() {
return {
id: `gid://gitlab/Issue/${this.activeIssue.iid}`,
};
},
update(data) {
return data.issue?.participants?.nodes || [];
},
},
searchUsers: {
query: searchUsers,
variables() {
return {
search: this.search,
};
},
update: (data) => data.users?.nodes || [],
skip() {
return this.isSearchEmpty;
},
debounce: 250,
},
},
computed: {
...mapGetters(['activeIssue']),
...mapState(['isSettingAssignees']),
participants() {
return this.isSearchEmpty ? this.issueParticipants : this.searchUsers;
},
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);
},
isSearchEmpty() {
return this.search === '';
},
currentUser() {
return gon?.current_username;
},
isLoading() {
return (
this.$apollo.queries.issueParticipants?.loading || this.$apollo.queries.searchUsers?.loading
);
},
},
created() {
this.selected = cloneDeep(this.activeIssue.assignees);
},
methods: {
...mapActions(['setAssignees']),
async assignSelf() {
const [currentUserObject] = await this.setAssignees(this.currentUser);
this.selectAssignee(currentUserObject);
},
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 :loading="isSettingAssignees" :title="assigneeText" @close="saveAssignees">
<template #collapsed>
<issuable-assignees :users="activeIssue.assignees" @assign-self="assignSelf" />
</template>
<template #default>
<multi-select-dropdown
class="w-100"
:text="$options.i18n.assignees"
:header-text="$options.i18n.assignTo"
>
<template #search>
<gl-search-box-by-type v-model.trim="search" />
</template>
<template #items>
<gl-loading-icon v-if="isLoading" size="lg" />
<template v-else>
<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>
</template>
</multi-select-dropdown>
</template>
</board-editable-item>
</template>
......@@ -7,7 +7,6 @@ import { GlLabel } from '@gitlab/ui';
import $ from 'jquery';
import Vue from 'vue';
import DueDateSelectors from '~/due_date_select';
import { deprecatedCreateFlash as Flash } from '~/flash';
import IssuableContext from '~/issuable_context';
import LabelsSelect from '~/labels_select';
import { isScopedLabel } from '~/lib/utils/common_utils';
......@@ -16,6 +15,7 @@ import MilestoneSelect from '~/milestone_select';
import Sidebar from '~/right_sidebar';
import AssigneeTitle from '~/sidebar/components/assignees/assignee_title.vue';
import Assignees from '~/sidebar/components/assignees/assignees.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
import eventHub from '~/sidebar/event_hub';
......@@ -32,6 +32,7 @@ export default Vue.extend({
RemoveBtn,
Subscriptions,
TimeTracker,
SidebarAssigneesWidget,
},
props: {
currentUser: {
......@@ -78,12 +79,6 @@ export default Vue.extend({
detail: {
handler() {
if (this.issue.id !== this.detail.issue.id) {
$('.block.assignee')
.find('input:not(.js-vue)[name="issue[assignee_ids][]"]')
.each((i, el) => {
$(el).remove();
});
$('.js-issue-board-sidebar', this.$el).each((i, el) => {
$(el).data('deprecatedJQueryDropdown').clearMenu();
});
......@@ -96,18 +91,9 @@ export default Vue.extend({
},
},
created() {
// Get events from deprecatedJQueryDropdown
eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
eventHub.$on('sidebar.addAssignee', this.addAssignee);
eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
eventHub.$on('sidebar.closeAll', this.closeSidebar);
},
beforeDestroy() {
eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
eventHub.$off('sidebar.addAssignee', this.addAssignee);
eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
eventHub.$off('sidebar.closeAll', this.closeSidebar);
},
mounted() {
......@@ -121,34 +107,8 @@ export default Vue.extend({
closeSidebar() {
this.detail.issue = {};
},
assignSelf() {
// Notify gl dropdown that we are now assigning to current user
this.$refs.assigneeBlock.dispatchEvent(new Event('assignYourself'));
this.addAssignee(this.currentUser);
this.saveAssignees();
},
removeAssignee(a) {
boardsStore.detail.issue.removeAssignee(a);
},
addAssignee(a) {
boardsStore.detail.issue.addAssignee(a);
},
removeAllAssignees() {
boardsStore.detail.issue.removeAllAssignees();
},
saveAssignees() {
this.loadingAssignees = true;
boardsStore.detail.issue
.update()
.then(() => {
this.loadingAssignees = false;
})
.catch(() => {
this.loadingAssignees = false;
Flash(__('An error occurred while saving assignees'));
});
setAssignees(data) {
boardsStore.detail.issue.setAssignees(data.issueSetAssignees.issue.assignees.nodes);
},
showScopedLabels(label) {
return boardsStore.scopedLabels.enabled && isScopedLabel(label);
......
......@@ -86,7 +86,7 @@ export default () => {
groupId: Number($boardApp.dataset.groupId),
rootPath: $boardApp.dataset.rootPath,
currentUserId: gon.current_user_id || null,
canUpdate: $boardApp.dataset.canUpdate,
canUpdate: parseBoolean($boardApp.dataset.canUpdate),
labelsFetchPath: $boardApp.dataset.labelsFetchPath,
labelsManagePath: $boardApp.dataset.labelsManagePath,
labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath,
......
......@@ -53,6 +53,10 @@ class ListIssue {
return boardsStore.findIssueAssignee(this, findAssignee);
}
setAssignees(assignees) {
boardsStore.setIssueAssignees(this, assignees);
}
removeAssignee(removeAssignee) {
boardsStore.removeIssueAssignee(this, removeAssignee);
}
......
import { pick } from 'lodash';
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
import { BoardType, ListType, inactiveId, flashAnimationDuration } from '~/boards/constants';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
import {
formatBoardLists,
formatListIssues,
......@@ -333,34 +329,11 @@ export default {
},
setAssignees: ({ commit, getters }, assigneeUsernames) => {
commit(types.SET_ASSIGNEE_LOADING, true);
return gqlClient
.mutate({
mutation: updateAssigneesMutation,
variables: {
iid: getters.activeIssue.iid,
projectPath: getters.activeIssue.referencePath.split('#')[0],
assigneeUsernames,
},
})
.then(({ data }) => {
const { nodes } = data.issueSetAssignees?.issue?.assignees || [];
commit('UPDATE_ISSUE_BY_ID', {
issueId: getters.activeIssue.id,
prop: 'assignees',
value: nodes,
});
return nodes;
})
.catch(() => {
createFlash({ message: __('An error occurred while updating assignees.') });
})
.finally(() => {
commit(types.SET_ASSIGNEE_LOADING, false);
});
commit('UPDATE_ISSUE_BY_ID', {
issueId: getters.activeIssue.id,
prop: 'assignees',
value: assigneeUsernames,
});
},
setActiveIssueMilestone: async ({ commit, getters }, input) => {
......
......@@ -724,6 +724,10 @@ const boardsStore = {
}
},
setIssueAssignees(issue, assignees) {
issue.assignees = [...assignees];
},
removeIssueLabels(issue, labels) {
labels.forEach(issue.removeLabel.bind(issue));
},
......
......@@ -3,6 +3,7 @@
query getProjectIssue($iid: String!, $fullPath: ID!) {
project(fullPath: $fullPath) {
issue(iid: $iid) {
id
assignees {
nodes {
...Author
......
......@@ -11,10 +11,6 @@ export default {
UncollapsedAssigneeList,
},
props: {
rootPath: {
type: String,
required: true,
},
users: {
type: Array,
required: true,
......@@ -53,7 +49,7 @@ export default {
<div data-testid="expanded-assignee" class="value hide-collapsed">
<template v-if="hasNoUsers">
<span class="assign-yourself no-value qa-assign-yourself">
<span class="assign-yourself no-value">
{{ __('None') }}
<template v-if="editable">
-
......@@ -64,12 +60,7 @@ export default {
</span>
</template>
<uncollapsed-assignee-list
v-else
:users="sortedAssigness"
:root-path="rootPath"
:issuable-type="issuableType"
/>
<uncollapsed-assignee-list v-else :users="sortedAssigness" :issuable-type="issuableType" />
</div>
</div>
</template>
......@@ -8,12 +8,16 @@ export default {
GlButton,
UncollapsedAssigneeList,
},
inject: ['rootPath'],
props: {
users: {
type: Array,
required: true,
},
issuableType: {
type: String,
required: false,
default: 'issue',
},
},
computed: {
assigneesText() {
......@@ -36,9 +40,9 @@ export default {
variant="link"
@click="$emit('assign-self')"
>
<span class="gl-text-gray-400">{{ __('assign yourself') }}</span>
<span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span>
</gl-button>
</div>
<uncollapsed-assignee-list v-else :users="users" :root-path="rootPath" />
<uncollapsed-assignee-list v-else :users="users" :issuable-type="issuableType" />
</div>
</template>
<script>
import {
GlDropdownItem,
GlDropdownDivider,
GlAvatarLabeled,
GlAvatarLink,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import Vue from 'vue';
import createFlash from '~/flash';
import searchUsers from '~/graphql_shared/queries/users_search.query.graphql';
import { IssuableType } from '~/issue_show/constants';
import { __, n__ } from '~/locale';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { assigneesQueries } from '~/sidebar/constants';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
export const assigneesWidget = Vue.observable({
updateAssignees: null,
});
export default {
i18n: {
unassigned: __('Unassigned'),
assignee: __('Assignee'),
assignees: __('Assignees'),
assignTo: __('Assign to'),
},
assigneesQueries,
components: {
SidebarEditableItem,
IssuableAssignees,
MultiSelectDropdown,
GlDropdownItem,
GlDropdownDivider,
GlAvatarLabeled,
GlAvatarLink,
GlSearchBoxByType,
GlLoadingIcon,
},
props: {
iid: {
type: String,
required: true,
},
fullPath: {
type: String,
required: true,
},
initialAssignees: {
type: Array,
required: false,
default: null,
},
issuableType: {
type: String,
required: false,
default: IssuableType.Issue,
validator(value) {
return [IssuableType.Issue, IssuableType.MergeRequest].includes(value);
},
},
multipleAssignees: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
search: '',
issuable: {},
searchUsers: [],
selected: [],
isSettingAssignees: false,
isSearching: false,
};
},
apollo: {
issuable: {
query() {
return this.$options.assigneesQueries[this.issuableType].query;
},
variables() {
return this.queryVariables;
},
update(data) {
return data.issuable || data.project?.issuable;
},
result({ data }) {
const issuable = data.issuable || data.project?.issuable;
if (issuable) {
this.selected = this.moveCurrentUserToStart(cloneDeep(issuable.assignees.nodes));
}
},
error() {
createFlash({ message: __('An error occurred while fetching participants.') });
},
},
searchUsers: {
query: searchUsers,
variables() {
return {
search: this.search,
};
},
update(data) {
return data.users?.nodes || [];
},
debounce: 250,
skip() {
return this.isSearchEmpty;
},
error() {
createFlash({ message: __('An error occurred while searching users.') });
this.isSearching = false;
},
result() {
this.isSearching = false;
},
},
},
computed: {
queryVariables() {
return {
iid: this.iid,
fullPath: this.fullPath,
};
},
assignees() {
const currentAssignees = this.$apollo.queries.issuable.loading
? this.initialAssignees
: this.issuable?.assignees?.nodes;
return currentAssignees || [];
},
participants() {
const users =
this.isSearchEmpty || this.isSearching
? this.issuable?.participants?.nodes
: this.searchUsers;
return this.moveCurrentUserToStart(users);
},
assigneeText() {
const items = this.$apollo.queries.issuable.loading ? this.initialAssignees : this.selected;
return n__('Assignee', '%d Assignees', items.length);
},
selectedFiltered() {
if (this.isSearchEmpty || this.isSearching) {
return this.selected;
}
const foundUsernames = this.searchUsers.map(({ username }) => username);
return this.selected.filter(({ username }) => foundUsernames.includes(username));
},
unselectedFiltered() {
return (
this.participants?.filter(({ username }) => !this.selectedUserNames.includes(username)) ||
[]
);
},
selectedIsEmpty() {
return this.selectedFiltered.length === 0;
},
selectedUserNames() {
return this.selected.map(({ username }) => username);
},
isSearchEmpty() {
return this.search === '';
},
currentUser() {
return {
username: gon?.current_username,
name: gon?.current_user_fullname,
avatarUrl: gon?.current_user_avatar_url,
};
},
isAssigneesLoading() {
return !this.initialAssignees && this.$apollo.queries.issuable.loading;
},
isCurrentUserInParticipants() {
const isCurrentUser = (user) => user.username === this.currentUser.username;
return this.selected.some(isCurrentUser) || this.participants.some(isCurrentUser);
},
noUsersFound() {
return !this.isSearchEmpty && this.unselectedFiltered.length === 0;
},
showCurrentUser() {
return !this.isCurrentUserInParticipants && (this.isSearchEmpty || this.isSearching);
},
},
watch: {
// We need to add this watcher to track the moment when user is alredy typing
// but query is still not started due to debounce
search(newVal) {
if (newVal) {
this.isSearching = true;
}
},
},
created() {
assigneesWidget.updateAssignees = this.updateAssignees;
},
destroyed() {
assigneesWidget.updateAssignees = null;
},
methods: {
updateAssignees(assigneeUsernames) {
this.isSettingAssignees = true;
return this.$apollo
.mutate({
mutation: this.$options.assigneesQueries[this.issuableType].mutation,
variables: {
...this.queryVariables,
assigneeUsernames,
},
})
.then(({ data }) => {
this.$emit('assignees-updated', data);
return data;
})
.catch(() => {
createFlash({ message: __('An error occurred while updating assignees.') });
})
.finally(() => {
this.isSettingAssignees = false;
});
},
selectAssignee(name) {
if (name === undefined) {
this.clearSelected();
return;
}
if (!this.multipleAssignees) {
this.selected = [name];
this.collapseWidget();
} else {
this.selected = this.selected.concat(name);
}
},
unselect(name) {
this.selected = this.selected.filter((user) => user.username !== name);
if (!this.multipleAssignees) {
this.collapseWidget();
}
},
assignSelf() {
this.updateAssignees(this.currentUser.username);
},
clearSelected() {
this.selected = [];
},
saveAssignees() {
this.updateAssignees(this.selectedUserNames);
},
isChecked(id) {
return this.selectedUserNames.includes(id);
},
async focusSearch() {
await this.$nextTick();
this.$refs.search.focusInput();
},
moveCurrentUserToStart(users) {
if (!users) {
return [];
}
const usersCopy = [...users];
const currentUser = usersCopy.find((user) => user.username === this.currentUser.username);
if (currentUser) {
const index = usersCopy.indexOf(currentUser);
usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]);
}
return usersCopy;
},
collapseWidget() {
this.$refs.toggle.collapse();
},
},
};
</script>
<template>
<div
v-if="isAssigneesLoading"
class="gl-display-flex gl-align-items-center assignee"
data-testid="loading-assignees"
>
{{ __('Assignee') }}
<gl-loading-icon size="sm" class="gl-ml-2" />
</div>
<sidebar-editable-item
v-else
ref="toggle"
:loading="isSettingAssignees"
:title="assigneeText"
@open="focusSearch"
@close="saveAssignees"
>
<template #collapsed>
<issuable-assignees
:users="assignees"
:issuable-type="issuableType"
@assign-self="assignSelf"
/>
</template>
<template #default>
<multi-select-dropdown
class="gl-w-full dropdown-menu-user"
:text="$options.i18n.assignees"
:header-text="$options.i18n.assignTo"
@toggle="collapseWidget"
>
<template #search>
<gl-search-box-by-type ref="search" v-model.trim="search" />
</template>
<template #items>
<gl-loading-icon
v-if="$apollo.queries.searchUsers.loading || $apollo.queries.issuable.loading"
data-testid="loading-participants"
size="lg"
/>
<template v-else>
<template v-if="isSearchEmpty || isSearching">
<gl-dropdown-item
:is-checked="selectedIsEmpty"
:is-check-centered="true"
data-testid="unassign"
@click="selectAssignee()"
>
<span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'">{{
$options.i18n.unassigned
}}</span></gl-dropdown-item
>
<gl-dropdown-divider data-testid="unassign-divider" />
</template>
<gl-dropdown-item
v-for="item in selectedFiltered"
:key="item.id"
:is-checked="isChecked(item.username)"
:is-check-centered="true"
data-testid="selected-participant"
@click.stop="unselect(item.username)"
>
<gl-avatar-link>
<gl-avatar-labeled
:size="32"
:label="item.name"
:sub-label="item.username"
:src="item.avatarUrl || item.avatar || item.avatar_url"
class="gl-align-items-center"
/>
</gl-avatar-link>
</gl-dropdown-item>
<gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" />
<template v-if="showCurrentUser">
<gl-dropdown-item
data-testid="unselected-participant"
@click.stop="selectAssignee(currentUser)"
>
<gl-avatar-link>
<gl-avatar-labeled
:size="32"
:label="currentUser.name"
:sub-label="currentUser.username"
:src="currentUser.avatarUrl"
class="gl-align-items-center"
/>
</gl-avatar-link>
</gl-dropdown-item>
<gl-dropdown-divider />
</template>
<gl-dropdown-item
v-for="unselectedUser in unselectedFiltered"
:key="unselectedUser.id"
data-testid="unselected-participant"
@click="selectAssignee(unselectedUser)"
>
<gl-avatar-link class="gl-pl-6!">
<gl-avatar-labeled
:size="32"
:label="unselectedUser.name"
:sub-label="unselectedUser.username"
:src="unselectedUser.avatarUrl || unselectedUser.avatar"
class="gl-align-items-center"
/>
</gl-avatar-link>
</gl-dropdown-item>
<gl-dropdown-item v-if="noUsersFound && !isSearching">
{{ __('No matching results') }}
</gl-dropdown-item>
</template>
</template>
</multi-select-dropdown>
</template>
</sidebar-editable-item>
</template>
......@@ -59,7 +59,7 @@ export default {
<div class="value hide-collapsed">
<template v-if="hasNoUsers">
<span class="assign-yourself no-value qa-assign-yourself">
<span class="assign-yourself no-value">
{{ __('None') }}
</span>
</template>
......
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
export default {
components: { GlButton, GlLoadingIcon },
inject: ['canUpdate'],
props: {
title: {
type: String,
required: false,
default: '',
},
loading: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
edit: false,
};
},
destroyed() {
window.removeEventListener('click', this.collapseWhenOffClick);
window.removeEventListener('keyup', this.collapseOnEscape);
},
methods: {
collapseWhenOffClick({ target }) {
if (!this.$el.contains(target)) {
this.collapse();
}
},
collapseOnEscape({ key }) {
if (key === 'Escape') {
this.collapse();
}
},
expand() {
if (this.edit) {
return;
}
this.edit = true;
this.$emit('open');
window.addEventListener('click', this.collapseWhenOffClick);
window.addEventListener('keyup', this.collapseOnEscape);
},
collapse({ emitEvent = true } = {}) {
if (!this.edit) {
return;
}
this.edit = false;
if (emitEvent) {
this.$emit('close');
}
window.removeEventListener('click', this.collapseWhenOffClick);
window.removeEventListener('keyup', this.collapseOnEscape);
},
toggle({ emitEvent = true } = {}) {
if (this.edit) {
this.collapse({ emitEvent });
} else {
this.expand();
}
},
},
};
</script>
<template>
<div>
<div class="gl-display-flex gl-align-items-center gl-mb-3" @click.self="collapse">
<span data-testid="title">{{ title }}</span>
<gl-loading-icon v-if="loading" inline class="gl-ml-2" />
<gl-button
v-if="canUpdate"
variant="link"
class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto js-sidebar-dropdown-toggle"
data-testid="edit-button"
@keyup.esc="toggle"
@click="toggle"
>
{{ __('Edit') }}
</gl-button>
</div>
<div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content">
<slot name="collapsed">{{ __('None') }}</slot>
</div>
<div v-show="edit" data-testid="expanded-content">
<slot :edit="edit"></slot>
</div>
</div>
</template>
import { IssuableType } from '~/issue_show/constants';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql';
import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import updateMergeRequestParticipantsMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
export const assigneesQueries = {
[IssuableType.Issue]: {
query: getIssueParticipants,
mutation: updateAssigneesMutation,
},
[IssuableType.MergeRequest]: {
query: getMergeRequestParticipants,
mutation: updateMergeRequestParticipantsMutation,
},
};
......@@ -20,7 +20,7 @@ export default {
</script>
<template>
<gl-dropdown class="show" :text="text" :header-text="headerText">
<gl-dropdown class="show" :text="text" :header-text="headerText" @toggle="$emit('toggle')">
<slot name="search"></slot>
<gl-dropdown-form>
<slot name="items"></slot>
......
query issueParticipants($id: IssueID!) {
issue(id: $id) {
participants {
nodes {
username
name
webUrl
avatarUrl
id
}
}
}
}
#import "~/graphql_shared/fragments/user.fragment.graphql"
query issueParticipants($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
issuable: issue(iid: $iid) {
id
participants {
nodes {
...User
}
}
assignees {
nodes {
...User
}
}
}
}
}
#import "~/graphql_shared/fragments/user.fragment.graphql"
query getMrParticipants($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
issuable: mergeRequest(iid: $iid) {
id
participants {
nodes {
...User
}
}
assignees {
nodes {
...User
}
}
}
}
}
mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $projectPath: ID!) {
#import "~/graphql_shared/fragments/user.fragment.graphql"
mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
issueSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath }
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
) {
issue {
id
assignees {
nodes {
username
id
name
webUrl
avatarUrl
...User
}
}
participants {
nodes {
...User
}
}
}
......
#import "~/graphql_shared/fragments/user.fragment.graphql"
mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
mergeRequestSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
) {
mergeRequest {
id
assignees {
nodes {
...User
}
}
participants {
nodes {
...User
}
}
}
}
}
......@@ -100,23 +100,6 @@ module BoardsHelper
}
end
def board_sidebar_user_data
dropdown_options = assignees_dropdown_options('issue')
{
toggle: 'dropdown',
field_name: 'issue[assignee_ids][]',
first_user: current_user&.username,
current_user: 'true',
project_id: @project&.id,
group_id: @group&.id,
null_user: 'true',
multi_select: 'true',
'dropdown-header': dropdown_options[:data][:'dropdown-header'],
'max-select': dropdown_options[:data][:'max-select']
}
end
def boards_link_text
if current_board_parent.multiple_issue_boards_available?
s_("IssueBoards|Boards")
......
- dropdown_options = assignees_dropdown_options('issue')
.block.assignee{ ref: "assigneeBlock" }
%template{ "v-if" => "issue.assignees" }
%assignee-title{ ":number-of-assignees" => "issue.assignees.length",
":loading" => "loadingAssignees",
":editable" => can_admin_issue? }
%assignees.value{ "root-path" => "#{root_url}",
":users" => "issue.assignees",
":editable" => can_admin_issue?,
"@assign-self" => "assignSelf" }
- if can_admin_issue?
.selectbox.hide-collapsed
%input.js-vue{ type: "hidden",
name: "issue[assignee_ids][]",
":value" => "assignee.id",
"v-if" => "issue.assignees",
"v-for" => "assignee in issue.assignees",
":data-avatar_url" => "assignee.avatar",
":data-name" => "assignee.name",
":data-username" => "assignee.username" }
.dropdown
- dropdown_options = assignees_dropdown_options('issue')
%button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: 'button', ref: 'assigneeDropdown', data: board_sidebar_user_data,
":data-issuable-id" => "issue.iid" }
= dropdown_options[:title]
= sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
.dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
= dropdown_title("Assign to")
= dropdown_filter("Search users")
= dropdown_content
= dropdown_loading
%sidebar-assignees-widget{ ":iid" => "String(issue.iid)",
":full-path" => "issue.path.split('/-/')[0].substring(1)",
":initial-assignees" => "issue.assignees",
":multiple-assignees" => "!Boolean(#{dropdown_options[:data][:"max-select"]})",
"@assignees-updated" => "setAssignees" }
---
title: Create new assignees widget for boards
merge_request: 50054
author:
type: changed
<script>
import { GlDrawer } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import BoardAssigneeDropdown from '~/boards/components/board_assignee_dropdown.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
......@@ -9,6 +8,7 @@ import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sideb
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
import { ISSUABLE } from '~/boards/constants';
import { contentTop } from '~/lib/utils/common_utils';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import BoardSidebarEpicSelect from './sidebar/board_sidebar_epic_select.vue';
import BoardSidebarTimeTracker from './sidebar/board_sidebar_time_tracker.vue';
......@@ -20,7 +20,7 @@ export default {
GlDrawer,
BoardSidebarIssueTitle,
BoardSidebarEpicSelect,
BoardAssigneeDropdown,
SidebarAssigneesWidget,
BoardSidebarTimeTracker,
BoardSidebarWeightInput,
BoardSidebarLabelsSelect,
......@@ -37,7 +37,11 @@ export default {
},
},
methods: {
...mapActions(['unsetActiveId']),
...mapActions(['unsetActiveId', 'setAssignees']),
updateAssignees(data) {
const assignees = data.issueSetAssignees?.issue?.assignees?.nodes || [];
this.setAssignees(assignees);
},
},
};
</script>
......@@ -51,14 +55,21 @@ export default {
>
<template #header>{{ __('Issue details') }}</template>
<board-sidebar-issue-title />
<board-assignee-dropdown />
<board-sidebar-epic-select />
<board-sidebar-milestone-select />
<board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" />
<board-sidebar-due-date />
<board-sidebar-labels-select />
<board-sidebar-weight-input v-if="glFeatures.issueWeights" />
<board-sidebar-subscription />
<template #default>
<board-sidebar-issue-title />
<sidebar-assignees-widget
:iid="activeIssue.iid"
:full-path="activeIssue.referencePath.split('#')[0]"
:initial-assignees="activeIssue.assignees"
@assignees-updated="updateAssignees"
/>
<board-sidebar-epic-select />
<board-sidebar-milestone-select />
<board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" />
<board-sidebar-due-date />
<board-sidebar-labels-select />
<board-sidebar-weight-input v-if="glFeatures.issueWeights" />
<board-sidebar-subscription />
</template>
</gl-drawer>
</template>
......@@ -42,63 +42,68 @@ RSpec.describe 'Issue Boards', :js do
click_card(card2)
page.within('.assignee') do
click_link 'Edit'
click_button('Edit')
wait_for_requests
page.within('.dropdown-menu-user') do
click_link user.name
assignee = first('.gl-avatar-labeled').find('.gl-avatar-labeled-label').text
wait_for_requests
page.within('.dropdown-menu-user') do
first('.gl-avatar-labeled').click
end
expect(page).to have_content(user.name)
click_button('Edit')
wait_for_requests
expect(page).to have_content(assignee)
end
expect(card2).to have_selector('.avatar')
end
it 'adds multiple assignees' do
click_card(card2)
click_card(card1)
page.within('.assignee') do
click_link 'Edit'
click_button('Edit')
wait_for_requests
assignee = all('.gl-avatar-labeled')[1].find('.gl-avatar-labeled-label').text
page.within('.dropdown-menu-user') do
click_link user.name
click_link user2.name
find('[data-testid="unassign"]').click
all('.gl-avatar-labeled')[0].click
all('.gl-avatar-labeled')[1].click
end
expect(page).to have_content(user.name)
expect(page).to have_content(user2.name)
click_button('Edit')
wait_for_requests
expect(page).to have_link(nil, title: user.name)
expect(page).to have_link(nil, title: assignee)
end
expect(card2.all('.avatar').length).to eq(2)
expect(card1.all('.avatar').length).to eq(2)
end
it 'removes the assignee' do
card_two = find('.board:nth-child(2)').find('.board-card:nth-child(2)')
click_card(card_two)
click_card(card1)
page.within('.assignee') do
click_link 'Edit'
click_button('Edit')
wait_for_requests
page.within('.dropdown-menu-user') do
click_link 'Unassigned'
find('[data-testid="unassign"]').click
end
find('.dropdown-menu-toggle').click
click_button('Edit')
wait_for_requests
expect(find('.qa-assign-yourself')).to have_content('None')
expect(page).to have_content('None')
end
expect(card_two).not_to have_selector('.avatar')
expect(card1).not_to have_selector('.avatar')
end
it 'assignees to current user' do
......@@ -107,7 +112,7 @@ RSpec.describe 'Issue Boards', :js do
wait_for_requests
page.within(find('.assignee')) do
expect(find('.qa-assign-yourself')).to have_content('None')
expect(page).to have_content('None')
click_button 'assign yourself'
......@@ -123,17 +128,19 @@ RSpec.describe 'Issue Boards', :js do
click_card(card2)
page.within('.assignee') do
click_link 'Edit'
click_button('Edit')
wait_for_requests
page.within('.dropdown-menu-user') do
click_link user.name
assignee = first('.gl-avatar-labeled').find('.gl-avatar-labeled-label').text
wait_for_requests
page.within('.dropdown-menu-user') do
first('.gl-avatar-labeled').click
end
expect(page).to have_content(user.name)
click_button('Edit')
wait_for_requests
expect(page).to have_content(assignee)
end
page.within(find('.board:nth-child(2)')) do
......@@ -141,9 +148,9 @@ RSpec.describe 'Issue Boards', :js do
end
page.within('.assignee') do
click_link 'Edit'
click_button('Edit')
expect(find('.dropdown-menu')).to have_selector('.is-active')
expect(find('.dropdown-menu')).to have_selector('.gl-new-dropdown-item-check-icon')
end
end
end
......
......@@ -3,7 +3,6 @@ import { shallowMount } from '@vue/test-utils';
import BoardContentSidebar from 'ee_component/boards/components/board_content_sidebar.vue';
import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import BoardAssigneeDropdown from '~/boards/components/board_assignee_dropdown.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
......@@ -44,7 +43,8 @@ describe('ee/BoardContentSidebar', () => {
beforeEach(() => {
store = createStore();
store.state.sidebarType = ISSUABLE;
store.state.issues = { 1: { title: 'One', referencePath: 'path', assignees: [] } };
store.state.issues = { 1: { title: 'One', referencePath: 'path#2', assignees: [], iid: '2' } };
store.state.activeIssue = { title: 'One', referencePath: 'path#2', assignees: [], iid: '2' };
store.state.activeId = '1';
createComponent();
......@@ -63,10 +63,6 @@ describe('ee/BoardContentSidebar', () => {
expect(wrapper.find(GlDrawer).props('open')).toBe(true);
});
it('renders BoardAssigneeDropdown', () => {
expect(wrapper.find(BoardAssigneeDropdown).exists()).toBe(true);
});
it('renders BoardSidebarLabelsSelect', () => {
expect(wrapper.find(BoardSidebarLabelsSelect).exists()).toBe(true);
});
......
import { GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import {
issuableQueryResponse,
searchQueryResponse,
updateIssueAssigneesMutationResponse,
} from '../../mock_data';
jest.mock('~/flash');
const updateIssueAssigneesMutationSuccess = jest
.fn()
.mockResolvedValue(updateIssueAssigneesMutationResponse);
const mockError = jest.fn().mockRejectedValue('Error!');
const localVue = createLocalVue();
localVue.use(VueApollo);
const initialAssignees = [
{
id: 'some-user',
avatarUrl: 'some-user-avatar',
name: 'test',
username: 'test',
webUrl: '/test',
},
];
describe('BoardCardAssigneeDropdown', () => {
let wrapper;
let fakeApollo;
const findAssignees = () => wrapper.findComponent(IssuableAssignees);
const findEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findAssigneesLoading = () => wrapper.find('[data-testid="loading-assignees"]');
const findParticipantsLoading = () => wrapper.find('[data-testid="loading-participants"]');
const findSelectedParticipants = () => wrapper.findAll('[data-testid="selected-participant"]');
const findUnselectedParticipants = () =>
wrapper.findAll('[data-testid="unselected-participant"]');
const findUnassignLink = () => wrapper.find('[data-testid="unassign"]');
const expandDropdown = () => wrapper.vm.$refs.toggle.expand();
const createComponent = ({
search = '',
issuableQueryHandler = jest.fn().mockResolvedValue(issuableQueryResponse),
searchQueryHandler = jest.fn().mockResolvedValue(searchQueryResponse),
updateIssueAssigneesMutationHandler = updateIssueAssigneesMutationSuccess,
props = {},
} = {}) => {
fakeApollo = createMockApollo([
[getIssueParticipantsQuery, issuableQueryHandler],
[searchUsersQuery, searchQueryHandler],
[updateIssueAssigneesMutation, updateIssueAssigneesMutationHandler],
]);
wrapper = shallowMount(SidebarAssigneesWidget, {
localVue,
apolloProvider: fakeApollo,
propsData: {
iid: '1',
fullPath: '/mygroup/myProject',
...props,
},
data() {
return {
search,
selected: [],
};
},
provide: {
canUpdate: true,
rootPath: '/',
},
stubs: {
SidebarEditableItem,
MultiSelectDropdown,
GlSearchBoxByType,
},
});
};
beforeEach(() => {
window.gon = window.gon || {};
window.gon.current_username = 'root';
window.gon.current_user_fullname = 'Administrator';
window.gon.current_user_avatar_url = '/root';
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
delete window.gon.current_username;
});
describe('with passed initial assignees', () => {
it('does not show loading state when query is loading', () => {
createComponent({
props: {
initialAssignees,
},
});
expect(findAssigneesLoading().exists()).toBe(false);
});
it('renders an initial assignees list with initialAssignees prop', () => {
createComponent({
props: {
initialAssignees,
},
});
expect(findAssignees().props('users')).toEqual(initialAssignees);
});
it('renders a collapsible item title calculated with initial assignees length', () => {
createComponent({
props: {
initialAssignees,
},
});
expect(findEditableItem().props('title')).toBe('Assignee');
});
describe('when expanded', () => {
it('renders a loading spinner if participants are loading', () => {
createComponent({
props: {
initialAssignees,
},
});
expandDropdown();
expect(findParticipantsLoading().exists()).toBe(true);
});
});
});
describe('without passed initial assignees', () => {
it('shows loading state when query is loading', () => {
createComponent();
expect(findAssigneesLoading().exists()).toBe(true);
});
it('renders assignees list from API response when resolved', async () => {
createComponent();
await waitForPromises();
expect(findAssignees().props('users')).toEqual(
issuableQueryResponse.data.project.issuable.assignees.nodes,
);
});
it('renders an error when issuable query is rejected', async () => {
createComponent({
issuableQueryHandler: mockError,
});
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occurred while fetching participants.',
});
});
it('assigns current user when clicking `Assign self`', async () => {
createComponent();
await waitForPromises();
findAssignees().vm.$emit('assign-self');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: 'root',
fullPath: '/mygroup/myProject',
iid: '1',
});
await waitForPromises();
expect(
findAssignees()
.props('users')
.some((user) => user.username === 'root'),
).toBe(true);
});
describe('when expanded', () => {
beforeEach(async () => {
createComponent();
await waitForPromises();
expandDropdown();
});
it('renders participants list with correct amount of selected and unselected', async () => {
expect(findSelectedParticipants()).toHaveLength(1);
expect(findUnselectedParticipants()).toHaveLength(1);
});
it('adds an assignee when clicking on unselected user', () => {
findUnselectedParticipants().at(0).vm.$emit('click');
findEditableItem().vm.$emit('close');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: expect.arrayContaining(['root', 'francina.skiles']),
fullPath: '/mygroup/myProject',
iid: '1',
});
});
it('removes an assignee when clicking on selected user', () => {
findSelectedParticipants().at(0).vm.$emit('click', new Event('click'));
findEditableItem().vm.$emit('close');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: [],
fullPath: '/mygroup/myProject',
iid: '1',
});
});
it('unassigns all participants when clicking on `Unassign`', () => {
findUnassignLink().vm.$emit('click');
findEditableItem().vm.$emit('close');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: [],
fullPath: '/mygroup/myProject',
iid: '1',
});
});
});
it('shows an error if update assignees mutation is rejected', async () => {
createComponent({ updateIssueAssigneesMutationHandler: mockError });
await waitForPromises();
expandDropdown();
findUnassignLink().vm.$emit('click');
findEditableItem().vm.$emit('close');
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occurred while updating assignees.',
});
});
describe('when searching', () => {
it('shows loading spinner when searching for users', async () => {
createComponent({ search: 'roo' });
await waitForPromises();
expandDropdown();
jest.advanceTimersByTime(250);
await nextTick();
expect(findParticipantsLoading().exists()).toBe(true);
});
it('renders a list of found users', async () => {
createComponent({ search: 'roo' });
await waitForPromises();
expandDropdown();
jest.advanceTimersByTime(250);
await nextTick();
await waitForPromises();
expect(findUnselectedParticipants()).toHaveLength(2);
});
it('shows an error if search query was rejected', async () => {
createComponent({ search: 'roo', searchQueryHandler: mockError });
await waitForPromises();
expandDropdown();
jest.advanceTimersByTime(250);
await nextTick();
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occurred while searching users.',
});
});
});
});
});
......@@ -73,3 +73,120 @@ export const mockMutationResponse = {
},
},
};
export const issuableQueryResponse = {
data: {
project: {
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/1',
iid: '1',
participants: {
nodes: [
{
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
},
{
id: 'gid://gitlab/User/2',
avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
name: 'Jacki Kub',
username: 'francina.skiles',
webUrl: '/franc',
},
],
},
assignees: {
nodes: [
{
id: 'gid://gitlab/User/2',
avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
name: 'Jacki Kub',
username: 'francina.skiles',
webUrl: '/franc',
},
],
},
},
},
},
};
export const searchQueryResponse = {
data: {
users: {
nodes: [
{
id: '1',
avatarUrl: '/avatar',
name: 'root',
username: 'root',
webUrl: 'root',
},
{
id: '3',
avatarUrl: '/avatar',
name: 'rookie',
username: 'rookie',
webUrl: 'rookie',
},
],
},
},
};
export const updateIssueAssigneesMutationResponse = {
data: {
issueSetAssignees: {
issue: {
id: 'gid://gitlab/Issue/1',
iid: '1',
assignees: {
nodes: [
{
__typename: 'User',
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
},
],
__typename: 'UserConnection',
},
participants: {
nodes: [
{
__typename: 'User',
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
},
{
__typename: 'User',
id: 'gid://gitlab/User/2',
avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
name: 'Jacki Kub',
username: 'francina.skiles',
webUrl: '/franc',
},
],
__typename: 'UserConnection',
},
__typename: 'Issue',
},
__typename: 'IssueSetAssigneesPayload',
},
},
};
......@@ -3295,6 +3295,9 @@ msgstr ""
msgid "An error occurred while fetching markdown preview"
msgstr ""
msgid "An error occurred while fetching participants."
msgstr ""
msgid "An error occurred while fetching pending comments"
msgstr ""
......@@ -3463,7 +3466,7 @@ msgstr ""
msgid "An error occurred while retrieving projects."
msgstr ""
msgid "An error occurred while saving assignees"
msgid "An error occurred while searching users."
msgstr ""
msgid "An error occurred while subscribing to notifications."
......
......@@ -107,17 +107,20 @@ RSpec.describe 'Issue Boards', :js do
click_card(card)
page.within('.assignee') do
click_link 'Edit'
click_button('Edit')
wait_for_requests
page.within('.dropdown-menu-user') do
click_link user.name
assignee = first('.gl-avatar-labeled').find('.gl-avatar-labeled-label').text
wait_for_requests
page.within('.dropdown-menu-user') do
first('.gl-avatar-labeled').click
end
expect(page).to have_content(user.name)
click_button('Edit')
wait_for_requests
expect(page).to have_content(assignee)
end
expect(card).to have_selector('.avatar')
......@@ -128,15 +131,15 @@ RSpec.describe 'Issue Boards', :js do
click_card(card_two)
page.within('.assignee') do
click_link 'Edit'
click_button('Edit')
wait_for_requests
page.within('.dropdown-menu-user') do
click_link 'Unassigned'
find('[data-testid="unassign"]').click
end
close_dropdown_menu_if_visible
click_button('Edit')
wait_for_requests
expect(page).to have_content('None')
......@@ -165,17 +168,20 @@ RSpec.describe 'Issue Boards', :js do
click_card(card)
page.within('.assignee') do
click_link 'Edit'
click_button('Edit')
wait_for_requests
page.within('.dropdown-menu-user') do
click_link user.name
assignee = first('.gl-avatar-labeled').find('.gl-avatar-labeled-label').text
wait_for_requests
page.within('.dropdown-menu-user') do
first('.gl-avatar-labeled').click
end
expect(page).to have_content(user.name)
click_button('Edit')
wait_for_requests
expect(page).to have_content(assignee)
end
page.within(find('.board:nth-child(2)')) do
......@@ -183,9 +189,9 @@ RSpec.describe 'Issue Boards', :js do
end
page.within('.assignee') do
click_link 'Edit'
click_button('Edit')
expect(find('.dropdown-menu')).to have_selector('.is-active')
expect(find('.dropdown-menu')).to have_selector('.gl-new-dropdown-item-check-icon')
end
end
end
......
import {
GlDropdownItem,
GlAvatarLink,
GlAvatarLabeled,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import BoardAssigneeDropdown from '~/boards/components/board_assignee_dropdown.vue';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import searchUsers from '~/boards/graphql/users_search.query.graphql';
import store from '~/boards/stores';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql';
import { participants } from '../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('BoardCardAssigneeDropdown', () => {
let wrapper;
let fakeApollo;
let getIssueParticipantsSpy;
let getSearchUsersSpy;
let dispatchSpy;
const iid = '111';
const activeIssueName = 'test';
const anotherIssueName = 'hello';
const createComponent = (search = '', loading = false) => {
wrapper = mount(BoardAssigneeDropdown, {
data() {
return {
search,
selected: [],
issueParticipants: participants,
};
},
store,
provide: {
canUpdate: true,
rootPath: '',
},
mocks: {
$apollo: {
queries: {
searchUsers: {
loading,
},
},
},
},
});
};
const createComponentWithApollo = (search = '') => {
fakeApollo = createMockApollo([
[getIssueParticipants, getIssueParticipantsSpy],
[searchUsers, getSearchUsersSpy],
]);
wrapper = mount(BoardAssigneeDropdown, {
localVue,
apolloProvider: fakeApollo,
data() {
return {
search,
selected: [],
};
},
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((node) => node.text().indexOf(text) === 0);
};
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
beforeEach(() => {
store.state.activeId = '1';
store.state.issues = {
1: {
iid,
assignees: [{ username: activeIssueName, name: activeIssueName, id: activeIssueName }],
},
};
dispatchSpy = jest.spyOn(store, 'dispatch').mockResolvedValue();
});
afterEach(() => {
window.gon = {};
jest.restoreAllMocks();
});
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(MultiSelectDropdown).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(MultiSelectDropdown).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('when searching users is loading', () => {
it('finds a loading icon in the dropdown', () => {
createComponent('test', true);
expect(findLoadingIcon().exists()).toBe(true);
});
});
describe('when participants loading is false', () => {
beforeEach(() => {
createComponent();
});
it('does not find GlLoading icon in the dropdown', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('finds at least 1 GlDropdownItem', () => {
expect(wrapper.findAll(GlDropdownItem).length).toBeGreaterThan(0);
});
});
describe('Apollo', () => {
beforeEach(() => {
getIssueParticipantsSpy = jest.fn().mockResolvedValue({
data: {
issue: {
participants: {
nodes: [
{
username: 'participant',
name: 'participant',
webUrl: '',
avatarUrl: '',
id: '',
},
],
},
},
},
});
getSearchUsersSpy = jest.fn().mockResolvedValue({
data: {
users: {
nodes: [{ username: 'root', name: 'root', webUrl: '', avatarUrl: '', id: '' }],
},
},
});
});
describe('when search is empty', () => {
beforeEach(() => {
createComponentWithApollo();
});
it('calls getIssueParticipants', async () => {
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(getIssueParticipantsSpy).toHaveBeenCalledWith({ id: 'gid://gitlab/Issue/111' });
});
});
describe('when search is not empty', () => {
beforeEach(() => {
createComponentWithApollo('search term');
});
it('calls searchUsers', async () => {
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(getSearchUsersSpy).toHaveBeenCalledWith({ search: 'search term' });
});
});
});
it('finds GlSearchBoxByType', async () => {
createComponent();
await openDropdown();
expect(wrapper.find(GlSearchBoxByType).exists()).toBe(true);
});
describe('when assign-self is emitted from IssuableAssignees', () => {
const currentUser = { username: 'self', name: '', id: '' };
beforeEach(() => {
window.gon = { current_username: currentUser.username };
dispatchSpy.mockResolvedValue([currentUser]);
createComponent();
wrapper.find(IssuableAssignees).vm.$emit('assign-self');
});
it('calls setAssignees with currentUser', () => {
expect(store.dispatch).toHaveBeenCalledWith('setAssignees', currentUser.username);
});
it('adds the user to the selected list', async () => {
expect(findByText(currentUser.username).exists()).toBe(true);
});
});
describe('when setting an assignee', () => {
beforeEach(() => {
createComponent();
});
it('passes loading state from Vuex to BoardEditableItem', async () => {
store.state.isSettingAssignees = true;
await wrapper.vm.$nextTick();
expect(wrapper.find(BoardEditableItem).props('loading')).toBe(true);
});
});
});
......@@ -11,8 +11,6 @@ import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'
import issueMoveListMutation from '~/boards/graphql/issue_move_list.mutation.graphql';
import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
import createFlash from '~/flash';
import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
import {
mockLists,
mockListsById,
......@@ -726,65 +724,27 @@ describe('moveIssue', () => {
describe('setAssignees', () => {
const node = { username: 'name' };
const name = 'username';
const projectPath = 'h/h';
const refPath = `${projectPath}#3`;
const iid = '1';
describe('when succeeds', () => {
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: { activeIssue: { 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,
{},
[node],
{ activeIssue: { iid, referencePath: refPath }, commit: () => {} },
[
{ type: types.SET_ASSIGNEE_LOADING, payload: true },
{
type: 'UPDATE_ISSUE_BY_ID',
payload: { prop: 'assignees', issueId: undefined, value: [node] },
},
{ type: types.SET_ASSIGNEE_LOADING, payload: false },
],
[],
done,
);
});
});
describe('when fails', () => {
beforeEach(() => {
jest.spyOn(gqlClient, 'mutate').mockRejectedValue();
});
it('calls createFlash', async () => {
await actions.setAssignees({
commit: () => {},
getters: { activeIssue: { iid, referencePath: refPath } },
});
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occurred while updating assignees.',
});
});
});
});
describe('createNewIssue', () => {
......
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
describe('boards sidebar remove issue', () => {
let wrapper;
const findLoader = () => wrapper.find(GlLoadingIcon);
const findEditButton = () => wrapper.find('[data-testid="edit-button"]');
const findTitle = () => wrapper.find('[data-testid="title"]');
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
const findExpanded = () => wrapper.find('[data-testid="expanded-content"]');
const createComponent = ({ props = {}, slots = {}, canUpdate = false } = {}) => {
wrapper = shallowMount(SidebarEditableItem, {
attachTo: document.body,
provide: { canUpdate },
propsData: props,
slots,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('template', () => {
it('renders title', () => {
const title = 'Sidebar item title';
createComponent({ props: { title } });
expect(findTitle().text()).toBe(title);
});
it('hides edit button, loader and expanded content by default', () => {
createComponent();
expect(findEditButton().exists()).toBe(false);
expect(findLoader().exists()).toBe(false);
expect(findExpanded().isVisible()).toBe(false);
});
it('shows "None" if empty collapsed slot', () => {
createComponent();
expect(findCollapsed().text()).toBe('None');
});
it('renders collapsed content by default', () => {
const slots = { collapsed: '<div>Collapsed content</div>' };
createComponent({ slots });
expect(findCollapsed().text()).toBe('Collapsed content');
});
it('shows edit button if can update', () => {
createComponent({ canUpdate: true });
expect(findEditButton().exists()).toBe(true);
});
it('shows loading icon if loading', () => {
createComponent({ props: { loading: true } });
expect(findLoader().exists()).toBe(true);
});
it('shows expanded content and hides collapsed content when clicking edit button', async () => {
const slots = { default: '<div>Select item</div>' };
createComponent({ canUpdate: true, slots });
findEditButton().vm.$emit('click');
await wrapper.vm.$nextTick;
expect(findCollapsed().isVisible()).toBe(false);
expect(findExpanded().isVisible()).toBe(true);
});
});
describe('collapsing an item by offclicking', () => {
beforeEach(async () => {
createComponent({ canUpdate: true });
findEditButton().vm.$emit('click');
await wrapper.vm.$nextTick();
});
it('hides expanded section and displays collapsed section', async () => {
expect(findExpanded().isVisible()).toBe(true);
document.body.click();
await wrapper.vm.$nextTick();
expect(findCollapsed().isVisible()).toBe(true);
expect(findExpanded().isVisible()).toBe(false);
});
});
it('emits open when edit button is clicked and edit is initailized to false', async () => {
createComponent({ canUpdate: true });
findEditButton().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(wrapper.emitted().open.length).toBe(1);
});
it('does not emits events when collapsing with false `emitEvent`', async () => {
createComponent({ canUpdate: true });
findEditButton().vm.$emit('click');
await wrapper.vm.$nextTick();
wrapper.vm.collapse({ emitEvent: false });
expect(wrapper.emitted().close).toBeUndefined();
});
});
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