Commit 33c12b9a authored by Natalia Tepluhina's avatar Natalia Tepluhina Committed by Nicolò Maria Mezzopera

Created the assignees widget

Revert "Created the first exposed method"

This reverts commit 097a584f99be3aad4c793133684183c43a029a3d.
Replaced search query with shared one

Abstracted parent entity assignees

Abstracted setting assignees

Abstracted widget in shared component

- reverted changes to BoardAssigneesDropDown
- fixed a few styles
Added hardcoded widget to issue

Passed correct injections

Added a watcher to hande initial state

Fixed participants loading state

Finished issue sidebar

Renamed issue assignees queries

- changed issue and mutation name to snake_case
Connected a correct query for MR

Added MR assignees mutation

Fixed update for MRs

Fixed MR avatarUrl casing

Added an exposed method and demo

Changed assignees to participants

Removed rebase artifacts

Apply 1 suggestion(s) to 1 file(s)
Started widget refactoring

Fixed sidebar issue mutation

Added ID to queries

Added fixes for MR assignees

Adjusted the code to swimlanes

Exposed assignees state

Nullified observable on destroyed

Fixed a race condition

Added a fallback with initial assignees

Reverted changes with assinees state

Silenced loading state with initial assignees

Splited widget for CE and EE

Fixed emitted event parameter

Passed issuable type to assignees component

Uncommented fetch policies
Added default slot directive
Removed board_assignees_dropdown
Fixed actions spec
Added temporary translation

Fixed sidebar assignees spec
Fixed an import

Removed doubled imports

Refactored a component to have a mixin

Removed changed to Noteable note

Removed sidebar widget for issues and MRs

Fixed linter errors

Reverted changes to sidebar assignees spec

Refactored widget to use issuable types

- moved issuable types queries to constants
- changed query variables
Added a widget to board sidebar

Fixed project path to be calculated from issue

Fixed board to use widget

Fixed assignees text

Fixed board sidebar spec

Fixed translations file

Fixed unassigning and assign self

Fixed feature spec for boards

Fixed ee board spec

Fixed ee sidebar spec
Created tests for widget

Added tests for initial prop

Fixed initial assignees tests

Added spec for fetching assignees

Added todos for tests

Added error handling and test

Added a test for mutation

Fixed loading participants test

Added tests for selected and unselected

Added tests for selecting

Tested a mutation error

Fixed finder helpers

Finished test abstraction

Regenerated a translation file
Added participants to mutation responses

Updated a test with successful mutation

Apply 1 suggestion(s) to 1 file(s)
Apply 1 suggestion(s) to 1 file(s)
Apply 1 suggestion(s) to 1 file(s)
Added issuable type import

Fixed computed property and validator

Removed unnecessary root path injection

Added changelog entry

Fixed a spec

Apply 1 suggestion(s) to 1 file(s)
Added searchUsers to data

Apply 1 suggestion(s) to 1 file(s)
Renamed assigneesWidget exposed state

Removed an unused method from boards helper

Apply 1 suggestion(s) to 1 file(s)
Apply 1 suggestion(s) to 1 file(s)
Fixed a default value for createComponent

Removed jquery code for dropdown

Added a logic for escape button

Added focusing a search
parent 64b3494c
<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 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