Commit efff24ac authored by Natalia Tepluhina's avatar Natalia Tepluhina

Scaffolded select component

- added a new shared component
- refactored assignees widget
Fixed searching

Fixed apply button

Refactored moving user to start

Managed to show project members

Fixed displaying project members

Fixed assigning self

Regenerated translations file

Started fixing Jest tests

Fixed assignees widget spec

Scaffolded user select test

Added a changelog entry

Added test for loading and current user

Added spec for unassign link

Added tests for selecting users

Added error tests

Changed imports order

Renamed multipleAssignees prop

Added watchLoading property

Added shouldShowParticipants computed

Fix test cases names

Fixed styling for loader

Fixed sidebar assignees test

Fixed assignees widget spec

Fixed assignees realtime spec

Abstracted participants query

Fixed MR-related queries

Added missing fragments

Removed a query copy
parent ad2cb86b
<script> <script>
import { GlDropdownItem, GlDropdownDivider, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; import { GlDropdownItem } from '@gitlab/ui';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import Vue from 'vue'; import Vue from 'vue';
import createFlash from '~/flash'; import createFlash from '~/flash';
import searchUsers from '~/graphql_shared/queries/users_search.query.graphql';
import { IssuableType } from '~/issue_show/constants'; import { IssuableType } from '~/issue_show/constants';
import { __, n__ } from '~/locale'; import { __, n__ } from '~/locale';
import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { assigneesQueries, ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; import { assigneesQueries } from '~/sidebar/constants';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SidebarInviteMembers from './sidebar_invite_members.vue'; import SidebarInviteMembers from './sidebar_invite_members.vue';
import SidebarParticipant from './sidebar_participant.vue';
export const assigneesWidget = Vue.observable({ export const assigneesWidget = Vue.observable({
updateAssignees: null, updateAssignees: null,
...@@ -33,14 +31,10 @@ export default { ...@@ -33,14 +31,10 @@ export default {
components: { components: {
SidebarEditableItem, SidebarEditableItem,
IssuableAssignees, IssuableAssignees,
MultiSelectDropdown,
GlDropdownItem, GlDropdownItem,
GlDropdownDivider,
GlSearchBoxByType,
GlLoadingIcon,
SidebarInviteMembers, SidebarInviteMembers,
SidebarParticipant,
SidebarAssigneesRealtime, SidebarAssigneesRealtime,
UserSelect,
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
inject: { inject: {
...@@ -75,7 +69,7 @@ export default { ...@@ -75,7 +69,7 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
multipleAssignees: { allowMultipleAssignees: {
type: Boolean, type: Boolean,
required: false, required: false,
default: true, default: true,
...@@ -83,12 +77,9 @@ export default { ...@@ -83,12 +77,9 @@ export default {
}, },
data() { data() {
return { return {
search: '',
issuable: {}, issuable: {},
searchUsers: [],
selected: [], selected: [],
isSettingAssignees: false, isSettingAssignees: false,
isSearching: false,
isDirty: false, isDirty: false,
}; };
}, },
...@@ -106,51 +97,13 @@ export default { ...@@ -106,51 +97,13 @@ export default {
result({ data }) { result({ data }) {
const issuable = data.workspace?.issuable; const issuable = data.workspace?.issuable;
if (issuable) { if (issuable) {
this.selected = this.moveCurrentUserToStart(cloneDeep(issuable.assignees.nodes)); this.selected = cloneDeep(issuable.assignees.nodes);
} }
}, },
error() { error() {
createFlash({ message: __('An error occurred while fetching participants.') }); createFlash({ message: __('An error occurred while fetching participants.') });
}, },
}, },
searchUsers: {
query: searchUsers,
variables() {
return {
fullPath: this.fullPath,
search: this.search,
};
},
update(data) {
const searchResults = data.workspace?.users?.nodes.map(({ user }) => user) || [];
const filteredParticipants = this.participants.filter(
(user) =>
user.name.toLowerCase().includes(this.search.toLowerCase()) ||
user.username.toLowerCase().includes(this.search.toLowerCase()),
);
const mergedSearchResults = searchResults.reduce((acc, current) => {
// Some users are duplicated in the query result:
// https://gitlab.com/gitlab-org/gitlab/-/issues/327822
if (!acc.some((user) => current.username === user.username)) {
acc.push(current);
}
return acc;
}, filteredParticipants);
return mergedSearchResults;
},
debounce: ASSIGNEES_DEBOUNCE_DELAY,
skip() {
return this.isSearchEmpty;
},
error() {
createFlash({ message: __('An error occurred while searching users.') });
this.isSearching = false;
},
result() {
this.isSearching = false;
},
},
}, },
computed: { computed: {
shouldEnableRealtime() { shouldEnableRealtime() {
...@@ -169,13 +122,6 @@ export default { ...@@ -169,13 +122,6 @@ export default {
: this.issuable?.assignees?.nodes; : this.issuable?.assignees?.nodes;
return currentAssignees || []; return currentAssignees || [];
}, },
participants() {
const users =
this.isSearchEmpty || this.isSearching
? this.issuable?.participants?.nodes
: this.searchUsers;
return this.moveCurrentUserToStart(users);
},
assigneeText() { assigneeText() {
const items = this.$apollo.queries.issuable.loading ? this.initialAssignees : this.selected; const items = this.$apollo.queries.issuable.loading ? this.initialAssignees : this.selected;
if (!items) { if (!items) {
...@@ -183,28 +129,8 @@ export default { ...@@ -183,28 +129,8 @@ export default {
} }
return n__('Assignee', '%d Assignees', items.length); return n__('Assignee', '%d Assignees', items.length);
}, },
selectedFiltered() { isAssigneesLoading() {
if (this.isSearchEmpty || this.isSearching) { return !this.initialAssignees && this.$apollo.queries.issuable.loading;
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() { currentUser() {
return { return {
...@@ -213,35 +139,9 @@ export default { ...@@ -213,35 +139,9 @@ export default {
avatarUrl: gon?.current_user_avatar_url, 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.searchUsers.length === 0;
},
signedIn() { signedIn() {
return this.currentUser.username !== undefined; return this.currentUser.username !== undefined;
}, },
showCurrentUser() {
return (
this.signedIn &&
!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() { created() {
assigneesWidget.updateAssignees = this.updateAssignees; assigneesWidget.updateAssignees = this.updateAssignees;
...@@ -271,68 +171,31 @@ export default { ...@@ -271,68 +171,31 @@ export default {
this.isSettingAssignees = false; this.isSettingAssignees = false;
}); });
}, },
selectAssignee(name) {
this.isDirty = true;
if (!this.multipleAssignees) {
this.selected = name ? [name] : [];
this.collapseWidget();
return;
}
if (name === undefined) {
this.clearSelected();
return;
}
this.selected = this.selected.concat(name);
},
unselect(name) {
this.selected = this.selected.filter((user) => user.username !== name);
this.isDirty = true;
if (!this.multipleAssignees) {
this.collapseWidget();
}
},
assignSelf() { assignSelf() {
this.updateAssignees(this.currentUser.username); this.updateAssignees([this.currentUser.username]);
},
clearSelected() {
this.selected = [];
}, },
saveAssignees() { saveAssignees() {
this.isDirty = false; this.isDirty = false;
this.updateAssignees(this.selectedUserNames); this.updateAssignees(this.selected.map(({ username }) => username));
this.$el.dispatchEvent(hideDropdownEvent); this.$el.dispatchEvent(hideDropdownEvent);
}, },
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() { collapseWidget() {
this.$refs.toggle.collapse(); this.$refs.toggle.collapse();
}, },
expandWidget() { expandWidget() {
this.$refs.toggle.expand(); this.$refs.toggle.expand();
}, },
showDivider(list) { focusSearch() {
return list.length > 0 && this.isSearchEmpty; this.$refs.userSelect.focusSearch();
},
showError() {
createFlash({ message: __('An error occurred while fetching participants.') });
},
setDirtyState() {
this.isDirty = true;
if (!this.allowMultipleAssignees) {
this.collapseWidget();
}
}, },
}, },
}; };
...@@ -365,86 +228,27 @@ export default { ...@@ -365,86 +228,27 @@ export default {
@expand-widget="expandWidget" @expand-widget="expandWidget"
/> />
</template> </template>
<template #default> <template #default>
<multi-select-dropdown <user-select
class="gl-w-full dropdown-menu-user" ref="userSelect"
v-model="selected"
:text="$options.i18n.assignees" :text="$options.i18n.assignees"
:header-text="$options.i18n.assignTo" :header-text="$options.i18n.assignTo"
:iid="iid"
:full-path="fullPath"
:allow-multiple-assignees="allowMultipleAssignees"
:current-user="currentUser"
:issuable-type="issuableType"
class="gl-w-full dropdown-menu-user"
@toggle="collapseWidget" @toggle="collapseWidget"
@error="showError"
@input="setDirtyState"
> >
<template #search>
<gl-search-box-by-type
ref="search"
v-model.trim="search"
class="js-dropdown-input-field"
/>
</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'"
class="gl-font-weight-bold"
>{{ $options.i18n.unassigned }}</span
></gl-dropdown-item
>
</template>
<gl-dropdown-divider v-if="showDivider(selectedFiltered)" />
<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)"
>
<sidebar-participant :user="item" />
</gl-dropdown-item>
<template v-if="showCurrentUser">
<gl-dropdown-divider />
<gl-dropdown-item
data-testid="current-user"
@click.stop="selectAssignee(currentUser)"
>
<sidebar-participant :user="currentUser" class="gl-pl-6!" />
</gl-dropdown-item>
</template>
<gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
<gl-dropdown-item
v-for="unselectedUser in unselectedFiltered"
:key="unselectedUser.id"
data-testid="unselected-participant"
@click="selectAssignee(unselectedUser)"
>
<sidebar-participant :user="unselectedUser" class="gl-pl-6!" />
</gl-dropdown-item>
<gl-dropdown-item
v-if="noUsersFound && !isSearching"
data-testid="empty-results"
class="gl-pl-6!"
>
{{ __('No matching results') }}
</gl-dropdown-item>
</template>
</template>
<template #footer> <template #footer>
<gl-dropdown-item> <gl-dropdown-item>
<sidebar-invite-members v-if="directlyInviteMembers" /> <sidebar-invite-members v-if="directlyInviteMembers" />
</gl-dropdown-item> </gl-dropdown-item> </template
</template> ></user-select>
</multi-select-dropdown>
</template> </template>
</sidebar-editable-item> </sidebar-editable-item>
</div> </div>
......
...@@ -3,6 +3,9 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui'; ...@@ -3,6 +3,9 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
export default { export default {
i18n: {
unassigned: __('Unassigned'),
},
components: { GlButton, GlLoadingIcon }, components: { GlButton, GlLoadingIcon },
inject: { inject: {
canUpdate: {}, canUpdate: {},
......
...@@ -12,22 +12,33 @@ import updateEpicDueDateMutation from '~/sidebar/queries/update_epic_due_date.mu ...@@ -12,22 +12,33 @@ import updateEpicDueDateMutation from '~/sidebar/queries/update_epic_due_date.mu
import updateEpicStartDateMutation from '~/sidebar/queries/update_epic_start_date.mutation.graphql'; import updateEpicStartDateMutation from '~/sidebar/queries/update_epic_start_date.mutation.graphql';
import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql'; import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql';
import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql'; import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql';
import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import getMergeRequestAssignees from '~/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql';
import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_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 updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import updateMergeRequestParticipantsMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql'; import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
export const ASSIGNEES_DEBOUNCE_DELAY = 250; export const ASSIGNEES_DEBOUNCE_DELAY = 250;
export const assigneesQueries = { export const assigneesQueries = {
[IssuableType.Issue]: { [IssuableType.Issue]: {
query: getIssueParticipants, query: getIssueAssignees,
subscription: issuableAssigneesSubscription, subscription: issuableAssigneesSubscription,
mutation: updateAssigneesMutation, mutation: updateIssueAssigneesMutation,
},
[IssuableType.MergeRequest]: {
query: getMergeRequestAssignees,
mutation: updateMergeRequestAssigneesMutation,
},
};
export const participantsQueries = {
[IssuableType.Issue]: {
query: issueParticipantsQuery,
}, },
[IssuableType.MergeRequest]: { [IssuableType.MergeRequest]: {
query: getMergeRequestParticipants, query: getMergeRequestParticipants,
mutation: updateMergeRequestParticipantsMutation,
}, },
}; };
......
...@@ -108,7 +108,7 @@ function mountAssigneesComponent() { ...@@ -108,7 +108,7 @@ function mountAssigneesComponent() {
? IssuableType.Issue ? IssuableType.Issue
: IssuableType.MergeRequest, : IssuableType.MergeRequest,
issuableId: id, issuableId: id,
multipleAssignees: !el.dataset.maxAssignees, allowMultipleAssignees: !el.dataset.maxAssignees,
}, },
scopedSlots: { scopedSlots: {
collapsed: ({ users, onClick }) => collapsed: ({ users, onClick }) =>
......
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query issueParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
issuable: issue(iid: $iid) {
__typename
id
assignees {
nodes {
...User
...UserAvailability
}
}
}
}
}
...@@ -13,12 +13,6 @@ query issueParticipants($fullPath: ID!, $iid: String!) { ...@@ -13,12 +13,6 @@ query issueParticipants($fullPath: ID!, $iid: String!) {
...UserAvailability ...UserAvailability
} }
} }
assignees {
nodes {
...User
...UserAvailability
}
}
} }
} }
} }
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query getMrAssignees($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
issuable: mergeRequest(iid: $iid) {
id
assignees {
nodes {
...User
...UserAvailability
}
}
}
}
}
...@@ -11,12 +11,6 @@ query getMrParticipants($fullPath: ID!, $iid: String!) { ...@@ -11,12 +11,6 @@ query getMrParticipants($fullPath: ID!, $iid: String!) {
...UserAvailability ...UserAvailability
} }
} }
assignees {
nodes {
...User
...UserAvailability
}
}
} }
} }
} }
...@@ -13,12 +13,6 @@ mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullP ...@@ -13,12 +13,6 @@ mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullP
...UserAvailability ...UserAvailability
} }
} }
participants {
nodes {
...User
...UserAvailability
}
}
} }
} }
} }
<script>
import {
GlDropdown,
GlDropdownForm,
GlDropdownDivider,
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import searchUsers from '~/graphql_shared/queries/users_search.query.graphql';
import { __ } from '~/locale';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
import { ASSIGNEES_DEBOUNCE_DELAY, participantsQueries } from '~/sidebar/constants';
export default {
i18n: {
unassigned: __('Unassigned'),
},
components: {
GlDropdownForm,
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlSearchBoxByType,
SidebarParticipant,
GlLoadingIcon,
},
props: {
headerText: {
type: String,
required: true,
},
text: {
type: String,
required: true,
},
fullPath: {
type: String,
required: true,
},
iid: {
type: String,
required: true,
},
value: {
type: Array,
required: true,
},
allowMultipleAssignees: {
type: Boolean,
required: false,
default: false,
},
currentUser: {
type: Object,
required: true,
},
issuableType: {
type: String,
required: false,
default: 'issue',
},
},
data() {
return {
search: '',
participants: [],
searchUsers: [],
isSearching: false,
};
},
apollo: {
participants: {
query() {
return participantsQueries[this.issuableType].query;
},
variables() {
return {
iid: this.iid,
fullPath: this.fullPath,
};
},
update(data) {
return data.workspace?.issuable?.participants.nodes;
},
error() {
this.$emit('error');
},
},
searchUsers: {
query: searchUsers,
variables() {
return {
fullPath: this.fullPath,
search: this.search,
first: 20,
};
},
update(data) {
return data.workspace?.users?.nodes.map(({ user }) => user) || [];
},
debounce: ASSIGNEES_DEBOUNCE_DELAY,
error() {
this.$emit('error');
this.isSearching = false;
},
result() {
this.isSearching = false;
},
},
},
computed: {
isLoading() {
return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading;
},
users() {
if (!this.participants) {
return [];
}
const mergedSearchResults = this.participants.reduce((acc, current) => {
if (
!acc.some((user) => current.username === user.username) &&
(current.name.includes(this.search) || current.username.includes(this.search))
) {
acc.push(current);
}
return acc;
}, this.searchUsers);
return this.moveCurrentUserToStart(mergedSearchResults);
},
isSearchEmpty() {
return this.search === '';
},
shouldShowParticipants() {
return this.isSearchEmpty || this.isSearching;
},
isCurrentUserInList() {
const isCurrentUser = (user) => user.username === this.currentUser.username;
return this.users.some(isCurrentUser);
},
noUsersFound() {
return !this.isSearchEmpty && this.users.length === 0;
},
showCurrentUser() {
return this.currentUser.username && !this.isCurrentUserInList && this.isSearchEmpty;
},
selectedFiltered() {
if (this.shouldShowParticipants) {
return this.moveCurrentUserToStart(this.value);
}
const foundUsernames = this.users.map(({ username }) => username);
const filtered = this.value.filter(({ username }) => foundUsernames.includes(username));
return this.moveCurrentUserToStart(filtered);
},
selectedUserNames() {
return this.value.map(({ username }) => username);
},
unselectedFiltered() {
return this.users?.filter(({ username }) => !this.selectedUserNames.includes(username)) || [];
},
selectedIsEmpty() {
return this.selectedFiltered.length === 0;
},
},
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;
}
},
},
methods: {
selectAssignee(user) {
let selected = [...this.value];
if (!this.allowMultipleAssignees) {
selected = [user];
} else {
selected.push(user);
}
this.$emit('input', selected);
},
unselect(name) {
const selected = this.value.filter((user) => user.username !== name);
this.$emit('input', selected);
},
focusSearch() {
this.$refs.search.focusInput();
},
showDivider(list) {
return list.length > 0 && this.isSearchEmpty;
},
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;
},
},
};
</script>
<template>
<gl-dropdown class="show" :text="text" @toggle="$emit('toggle')">
<template #header>
<p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p>
<gl-dropdown-divider />
<gl-search-box-by-type ref="search" v-model.trim="search" class="js-dropdown-input-field" />
</template>
<gl-dropdown-form class="gl-relative gl-min-h-7">
<gl-loading-icon
v-if="isLoading"
data-testid="loading-participants"
size="md"
class="gl-absolute gl-left-0 gl-top-0 gl-right-0"
/>
<template v-else>
<template v-if="shouldShowParticipants">
<gl-dropdown-item
v-if="isSearchEmpty"
:is-checked="selectedIsEmpty"
:is-check-centered="true"
data-testid="unassign"
@click="$emit('input', [])"
>
<span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{
$options.i18n.unassigned
}}</span></gl-dropdown-item
>
</template>
<gl-dropdown-divider v-if="showDivider(selectedFiltered)" />
<gl-dropdown-item
v-for="item in selectedFiltered"
:key="item.id"
is-checked
is-check-centered
data-testid="selected-participant"
@click.stop="unselect(item.username)"
>
<sidebar-participant :user="item" />
</gl-dropdown-item>
<template v-if="showCurrentUser">
<gl-dropdown-divider />
<gl-dropdown-item data-testid="current-user" @click.stop="selectAssignee(currentUser)">
<sidebar-participant :user="currentUser" class="gl-pl-6!" />
</gl-dropdown-item>
</template>
<gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
<gl-dropdown-item
v-for="unselectedUser in unselectedFiltered"
:key="unselectedUser.id"
data-testid="unselected-participant"
@click="selectAssignee(unselectedUser)"
>
<sidebar-participant :user="unselectedUser" class="gl-pl-6!" />
</gl-dropdown-item>
<gl-dropdown-item v-if="noUsersFound" data-testid="empty-results" class="gl-pl-6!">
{{ __('No matching results') }}
</gl-dropdown-item>
</template>
</gl-dropdown-form>
<template #footer>
<slot name="footer"></slot>
</template>
</gl-dropdown>
</template>
---
title: Resolve Abstract participants dropdown to a shared component
merge_request: 59358
author:
type: changed
...@@ -3673,9 +3673,6 @@ msgstr "" ...@@ -3673,9 +3673,6 @@ msgstr ""
msgid "An error occurred while saving changes: %{error}" msgid "An error occurred while saving changes: %{error}"
msgstr "" msgstr ""
msgid "An error occurred while searching users."
msgstr ""
msgid "An error occurred while subscribing to notifications." msgid "An error occurred while subscribing to notifications."
msgstr "" msgstr ""
......
...@@ -4,7 +4,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; ...@@ -4,7 +4,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql'; import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql';
import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarMediator from '~/sidebar/sidebar_mediator';
import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; import getIssueAssigneesQuery from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql';
import Mock, { issuableQueryResponse, subscriptionNullResponse } from './mock_data'; import Mock, { issuableQueryResponse, subscriptionNullResponse } from './mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -24,7 +24,7 @@ describe('Assignees Realtime', () => { ...@@ -24,7 +24,7 @@ describe('Assignees Realtime', () => {
subscriptionHandler = subscriptionInitialHandler, subscriptionHandler = subscriptionInitialHandler,
} = {}) => { } = {}) => {
fakeApollo = createMockApollo([ fakeApollo = createMockApollo([
[getIssueParticipantsQuery, issuableQueryHandler], [getIssueAssigneesQuery, issuableQueryHandler],
[issuableAssigneesSubscription, subscriptionHandler], [issuableAssigneesSubscription, subscriptionHandler],
]); ]);
wrapper = shallowMount(AssigneesRealtime, { wrapper = shallowMount(AssigneesRealtime, {
......
import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui'; import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash'; import createFlash from '~/flash';
import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql';
import { IssuableType } from '~/issue_show/constants'; import { IssuableType } from '~/issue_show/constants';
import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarInviteMembers from '~/sidebar/components/assignees/sidebar_invite_members.vue'; import SidebarInviteMembers from '~/sidebar/components/assignees/sidebar_invite_members.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; import getIssueAssigneesQuery from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql';
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 updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import { import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
issuableQueryResponse, import { issuableQueryResponse, updateIssueAssigneesMutationResponse } from '../../mock_data';
searchQueryResponse,
updateIssueAssigneesMutationResponse,
} from '../../mock_data';
jest.mock('~/flash'); jest.mock('~/flash');
...@@ -50,31 +43,19 @@ describe('Sidebar assignees widget', () => { ...@@ -50,31 +43,19 @@ describe('Sidebar assignees widget', () => {
const findAssignees = () => wrapper.findComponent(IssuableAssignees); const findAssignees = () => wrapper.findComponent(IssuableAssignees);
const findRealtimeAssignees = () => wrapper.findComponent(SidebarAssigneesRealtime); const findRealtimeAssignees = () => wrapper.findComponent(SidebarAssigneesRealtime);
const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); const findEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findDropdown = () => wrapper.findComponent(MultiSelectDropdown);
const findInviteMembersLink = () => wrapper.findComponent(SidebarInviteMembers); const findInviteMembersLink = () => wrapper.findComponent(SidebarInviteMembers);
const findSearchField = () => wrapper.findComponent(GlSearchBoxByType); const findUserSelect = () => wrapper.findComponent(UserSelect);
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 findCurrentUser = () => wrapper.findAll('[data-testid="current-user"]');
const findUnassignLink = () => wrapper.find('[data-testid="unassign"]');
const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]');
const expandDropdown = () => wrapper.vm.$refs.toggle.expand(); const expandDropdown = () => wrapper.vm.$refs.toggle.expand();
const createComponent = ({ const createComponent = ({
search = '',
issuableQueryHandler = jest.fn().mockResolvedValue(issuableQueryResponse), issuableQueryHandler = jest.fn().mockResolvedValue(issuableQueryResponse),
searchQueryHandler = jest.fn().mockResolvedValue(searchQueryResponse),
updateIssueAssigneesMutationHandler = updateIssueAssigneesMutationSuccess, updateIssueAssigneesMutationHandler = updateIssueAssigneesMutationSuccess,
props = {}, props = {},
provide = {}, provide = {},
} = {}) => { } = {}) => {
fakeApollo = createMockApollo([ fakeApollo = createMockApollo([
[getIssueParticipantsQuery, issuableQueryHandler], [getIssueAssigneesQuery, issuableQueryHandler],
[searchUsersQuery, searchQueryHandler],
[updateIssueAssigneesMutation, updateIssueAssigneesMutationHandler], [updateIssueAssigneesMutation, updateIssueAssigneesMutationHandler],
]); ]);
wrapper = shallowMount(SidebarAssigneesWidget, { wrapper = shallowMount(SidebarAssigneesWidget, {
...@@ -82,15 +63,10 @@ describe('Sidebar assignees widget', () => { ...@@ -82,15 +63,10 @@ describe('Sidebar assignees widget', () => {
apolloProvider: fakeApollo, apolloProvider: fakeApollo,
propsData: { propsData: {
iid: '1', iid: '1',
issuableId: 0,
fullPath: '/mygroup/myProject', fullPath: '/mygroup/myProject',
...props, ...props,
}, },
data() {
return {
search,
selected: [],
};
},
provide: { provide: {
canUpdate: true, canUpdate: true,
rootPath: '/', rootPath: '/',
...@@ -98,7 +74,7 @@ describe('Sidebar assignees widget', () => { ...@@ -98,7 +74,7 @@ describe('Sidebar assignees widget', () => {
}, },
stubs: { stubs: {
SidebarEditableItem, SidebarEditableItem,
MultiSelectDropdown, UserSelect,
GlSearchBoxByType, GlSearchBoxByType,
GlDropdown, GlDropdown,
}, },
...@@ -148,19 +124,6 @@ describe('Sidebar assignees widget', () => { ...@@ -148,19 +124,6 @@ describe('Sidebar assignees widget', () => {
expect(findEditableItem().props('title')).toBe('Assignee'); 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', () => { describe('without passed initial assignees', () => {
...@@ -198,7 +161,7 @@ describe('Sidebar assignees widget', () => { ...@@ -198,7 +161,7 @@ describe('Sidebar assignees widget', () => {
findAssignees().vm.$emit('assign-self'); findAssignees().vm.$emit('assign-self');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: 'root', assigneeUsernames: ['root'],
fullPath: '/mygroup/myProject', fullPath: '/mygroup/myProject',
iid: '1', iid: '1',
}); });
...@@ -220,7 +183,7 @@ describe('Sidebar assignees widget', () => { ...@@ -220,7 +183,7 @@ describe('Sidebar assignees widget', () => {
findAssignees().vm.$emit('assign-self'); findAssignees().vm.$emit('assign-self');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: 'root', assigneeUsernames: ['root'],
fullPath: '/mygroup/myProject', fullPath: '/mygroup/myProject',
iid: '1', iid: '1',
}); });
...@@ -245,18 +208,6 @@ describe('Sidebar assignees widget', () => { ...@@ -245,18 +208,6 @@ describe('Sidebar assignees widget', () => {
]); ]);
}); });
it('renders current user if they are not in participants or assignees', async () => {
gon.current_username = 'random';
gon.current_user_fullname = 'Mr Random';
gon.current_user_avatar_url = '/random';
createComponent();
await waitForPromises();
expandDropdown();
expect(findCurrentUser().exists()).toBe(true);
});
describe('when expanded', () => { describe('when expanded', () => {
beforeEach(async () => { beforeEach(async () => {
createComponent(); createComponent();
...@@ -264,27 +215,18 @@ describe('Sidebar assignees widget', () => { ...@@ -264,27 +215,18 @@ describe('Sidebar assignees widget', () => {
expandDropdown(); expandDropdown();
}); });
it('collapses the widget on multiselect dropdown toggle event', async () => { it('collapses the widget on user select toggle event', async () => {
findDropdown().vm.$emit('toggle'); findUserSelect().vm.$emit('toggle');
await nextTick(); await nextTick();
expect(findDropdown().isVisible()).toBe(false); expect(findUserSelect().isVisible()).toBe(false);
});
it('renders participants list with correct amount of selected and unselected', async () => {
expect(findSelectedParticipants()).toHaveLength(1);
expect(findUnselectedParticipants()).toHaveLength(2);
});
it('does not render current user if they are in participants', () => {
expect(findCurrentUser().exists()).toBe(false);
}); });
it('unassigns all participants when clicking on `Unassign`', () => { it('calls an update mutation with correct variables on User Select input event', () => {
findUnassignLink().vm.$emit('click'); findUserSelect().vm.$emit('input', [{ username: 'root' }]);
findEditableItem().vm.$emit('close'); findEditableItem().vm.$emit('close');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: [], assigneeUsernames: ['root'],
fullPath: '/mygroup/myProject', fullPath: '/mygroup/myProject',
iid: '1', iid: '1',
}); });
...@@ -293,68 +235,38 @@ describe('Sidebar assignees widget', () => { ...@@ -293,68 +235,38 @@ describe('Sidebar assignees widget', () => {
describe('when multiselect is disabled', () => { describe('when multiselect is disabled', () => {
beforeEach(async () => { beforeEach(async () => {
createComponent({ props: { multipleAssignees: false } }); createComponent({ props: { allowMultipleAssignees: false } });
await waitForPromises(); await waitForPromises();
expandDropdown(); expandDropdown();
}); });
it('adds a single assignee when clicking on unselected user', async () => { it('closes a dropdown after User Select input event', async () => {
findUnselectedParticipants().at(0).vm.$emit('click'); findUserSelect().vm.$emit('input', [{ username: 'root' }]);
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: ['root'], assigneeUsernames: ['root'],
fullPath: '/mygroup/myProject', fullPath: '/mygroup/myProject',
iid: '1', iid: '1',
}); });
});
it('removes an assignee when clicking on selected user', () => { await waitForPromises();
findSelectedParticipants().at(0).vm.$emit('click', new Event('click'));
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ expect(findUserSelect().isVisible()).toBe(false);
assigneeUsernames: [],
fullPath: '/mygroup/myProject',
iid: '1',
});
}); });
}); });
describe('when multiselect is enabled', () => { describe('when multiselect is enabled', () => {
beforeEach(async () => { beforeEach(async () => {
createComponent({ props: { multipleAssignees: true } }); createComponent({ props: { allowMultipleAssignees: true } });
await waitForPromises(); await waitForPromises();
expandDropdown(); expandDropdown();
}); });
it('adds a few assignees after clicking on unselected users and closing a dropdown', () => {
findUnselectedParticipants().at(0).vm.$emit('click');
findUnselectedParticipants().at(1).vm.$emit('click');
findEditableItem().vm.$emit('close');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: ['francina.skiles', 'root', 'johndoe'],
fullPath: '/mygroup/myProject',
iid: '1',
});
});
it('removes an assignee when clicking on selected user and then closing dropdown', () => {
findSelectedParticipants().at(0).vm.$emit('click', new Event('click'));
findEditableItem().vm.$emit('close');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: [],
fullPath: '/mygroup/myProject',
iid: '1',
});
});
it('does not call a mutation when clicking on participants until dropdown is closed', () => { it('does not call a mutation when clicking on participants until dropdown is closed', () => {
findUnselectedParticipants().at(0).vm.$emit('click'); findUserSelect().vm.$emit('input', [{ username: 'root' }]);
findSelectedParticipants().at(0).vm.$emit('click', new Event('click'));
expect(updateIssueAssigneesMutationSuccess).not.toHaveBeenCalled(); expect(updateIssueAssigneesMutationSuccess).not.toHaveBeenCalled();
expect(findUserSelect().isVisible()).toBe(true);
}); });
}); });
...@@ -363,7 +275,7 @@ describe('Sidebar assignees widget', () => { ...@@ -363,7 +275,7 @@ describe('Sidebar assignees widget', () => {
await waitForPromises(); await waitForPromises();
expandDropdown(); expandDropdown();
findUnassignLink().vm.$emit('click'); findUserSelect().vm.$emit('input', []);
findEditableItem().vm.$emit('close'); findEditableItem().vm.$emit('close');
await waitForPromises(); await waitForPromises();
...@@ -372,95 +284,6 @@ describe('Sidebar assignees widget', () => { ...@@ -372,95 +284,6 @@ describe('Sidebar assignees widget', () => {
message: 'An error occurred while updating assignees.', message: 'An error occurred while updating assignees.',
}); });
}); });
describe('when searching', () => {
it('does not show loading spinner when debounce timer is still running', async () => {
createComponent({ search: 'roo' });
await waitForPromises();
expandDropdown();
expect(findParticipantsLoading().exists()).toBe(false);
});
it('shows loading spinner when searching for users', async () => {
createComponent({ search: 'roo' });
await waitForPromises();
expandDropdown();
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
await nextTick();
expect(findParticipantsLoading().exists()).toBe(true);
});
it('renders a list of found users and external participants matching search term', async () => {
const responseCopy = cloneDeep(issuableQueryResponse);
responseCopy.data.workspace.issuable.participants.nodes.push({
id: 'gid://gitlab/User/5',
avatarUrl: '/someavatar',
name: 'Roodie',
username: 'roodie',
webUrl: '/roodie',
status: null,
});
const issuableQueryHandler = jest.fn().mockResolvedValue(responseCopy);
createComponent({ issuableQueryHandler });
await waitForPromises();
expandDropdown();
findSearchField().vm.$emit('input', 'roo');
await nextTick();
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
await nextTick();
await waitForPromises();
expect(findUnselectedParticipants()).toHaveLength(3);
});
it('renders a list of found users only if no external participants match search term', async () => {
createComponent({ search: 'roo' });
await waitForPromises();
expandDropdown();
jest.advanceTimersByTime(250);
await nextTick();
await waitForPromises();
expect(findUnselectedParticipants()).toHaveLength(2);
});
it('shows a message about no matches if search returned an empty list', async () => {
const responseCopy = cloneDeep(searchQueryResponse);
responseCopy.data.workspace.users.nodes = [];
createComponent({
search: 'roo',
searchQueryHandler: jest.fn().mockResolvedValue(responseCopy),
});
await waitForPromises();
expandDropdown();
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
await nextTick();
await waitForPromises();
expect(findUnselectedParticipants()).toHaveLength(0);
expect(findEmptySearchResults().exists()).toBe(true);
});
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.',
});
});
});
}); });
describe('when user is not signed in', () => { describe('when user is not signed in', () => {
...@@ -469,11 +292,6 @@ describe('Sidebar assignees widget', () => { ...@@ -469,11 +292,6 @@ describe('Sidebar assignees widget', () => {
createComponent(); createComponent();
}); });
it('does not show current user in the dropdown', () => {
expandDropdown();
expect(findCurrentUser().exists()).toBe(false);
});
it('passes signedIn prop as false to IssuableAssignees', () => { it('passes signedIn prop as false to IssuableAssignees', () => {
expect(findAssignees().props('signedIn')).toBe(false); expect(findAssignees().props('signedIn')).toBe(false);
}); });
...@@ -487,9 +305,6 @@ describe('Sidebar assignees widget', () => { ...@@ -487,9 +305,6 @@ describe('Sidebar assignees widget', () => {
it('when realtime feature flag is enabled', async () => { it('when realtime feature flag is enabled', async () => {
createComponent({ createComponent({
props: {
issuableId: 1,
},
provide: { provide: {
glFeatures: { glFeatures: {
realTimeIssueSidebar: true, realTimeIssueSidebar: true,
...@@ -510,17 +325,17 @@ describe('Sidebar assignees widget', () => { ...@@ -510,17 +325,17 @@ describe('Sidebar assignees widget', () => {
expect(findEditableItem().props('isDirty')).toBe(false); expect(findEditableItem().props('isDirty')).toBe(false);
}); });
it('passes truthy `isDirty` prop if selected users list was changed', async () => { it('passes truthy `isDirty` prop after User Select component emitted an input event', async () => {
expandDropdown(); expandDropdown();
expect(findEditableItem().props('isDirty')).toBe(false); expect(findEditableItem().props('isDirty')).toBe(false);
findUnselectedParticipants().at(0).vm.$emit('click'); findUserSelect().vm.$emit('input', []);
await nextTick(); await nextTick();
expect(findEditableItem().props('isDirty')).toBe(true); expect(findEditableItem().props('isDirty')).toBe(true);
}); });
it('passes falsy `isDirty` prop after dropdown is closed', async () => { it('passes falsy `isDirty` prop after dropdown is closed', async () => {
expandDropdown(); expandDropdown();
findUnselectedParticipants().at(0).vm.$emit('click'); findUserSelect().vm.$emit('input', []);
findEditableItem().vm.$emit('close'); findEditableItem().vm.$emit('close');
await waitForPromises(); await waitForPromises();
expect(findEditableItem().props('isDirty')).toBe(false); expect(findEditableItem().props('isDirty')).toBe(false);
......
...@@ -283,17 +283,8 @@ export const issuableQueryResponse = { ...@@ -283,17 +283,8 @@ export const issuableQueryResponse = {
__typename: 'Issue', __typename: 'Issue',
id: 'gid://gitlab/Issue/1', id: 'gid://gitlab/Issue/1',
iid: '1', iid: '1',
participants: { assignees: {
nodes: [ nodes: [
{
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
status: null,
},
{ {
id: 'gid://gitlab/User/2', id: 'gid://gitlab/User/2',
avatarUrl: avatarUrl:
...@@ -301,39 +292,81 @@ export const issuableQueryResponse = { ...@@ -301,39 +292,81 @@ export const issuableQueryResponse = {
name: 'Jacki Kub', name: 'Jacki Kub',
username: 'francina.skiles', username: 'francina.skiles',
webUrl: '/franc', webUrl: '/franc',
status: { status: null,
availability: 'BUSY', },
],
},
},
}, },
}, },
};
export const searchQueryResponse = {
data: {
workspace: {
__typename: 'Project',
users: {
nodes: [
{ {
id: 'gid://gitlab/User/3', user: {
id: '1',
avatarUrl: '/avatar', avatarUrl: '/avatar',
name: 'John Doe', name: 'root',
username: 'johndoe', username: 'root',
webUrl: '/john', webUrl: 'root',
status: null, status: null,
}, },
},
{
user: {
id: '2',
avatarUrl: '/avatar2',
name: 'rookie',
username: 'rookie',
webUrl: 'rookie',
status: null,
},
},
], ],
}, },
},
},
};
export const updateIssueAssigneesMutationResponse = {
data: {
issuableSetAssignees: {
issuable: {
id: 'gid://gitlab/Issue/1',
iid: '1',
assignees: { assignees: {
nodes: [ nodes: [
{ {
id: 'gid://gitlab/User/2', __typename: 'User',
id: 'gid://gitlab/User/1',
avatarUrl: avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon', 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Jacki Kub', name: 'Administrator',
username: 'francina.skiles', username: 'root',
webUrl: '/franc', webUrl: '/root',
status: null, status: null,
}, },
], ],
__typename: 'UserConnection',
}, },
__typename: 'Issue',
}, },
}, },
}, },
}; };
export const searchQueryResponse = { export const subscriptionNullResponse = {
data: {
issuableAssigneesUpdated: null,
},
};
export const searchResponse = {
data: { data: {
workspace: { workspace: {
__typename: 'Project', __typename: 'Project',
...@@ -365,16 +398,14 @@ export const searchQueryResponse = { ...@@ -365,16 +398,14 @@ export const searchQueryResponse = {
}, },
}; };
export const updateIssueAssigneesMutationResponse = { export const projectMembersResponse = {
data: { data: {
issuableSetAssignees: { workspace: {
issuable: { __typename: 'Project',
id: 'gid://gitlab/Issue/1', users: {
iid: '1',
assignees: {
nodes: [ nodes: [
{ {
__typename: 'User', user: {
id: 'gid://gitlab/User/1', id: 'gid://gitlab/User/1',
avatarUrl: avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
...@@ -383,13 +414,47 @@ export const updateIssueAssigneesMutationResponse = { ...@@ -383,13 +414,47 @@ export const updateIssueAssigneesMutationResponse = {
webUrl: '/root', webUrl: '/root',
status: null, status: null,
}, },
},
{
user: {
id: '2',
avatarUrl: '/avatar2',
name: 'rookie',
username: 'rookie',
webUrl: 'rookie',
status: null,
},
},
{
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',
status: {
availability: 'BUSY',
},
},
},
], ],
__typename: 'UserConnection',
}, },
},
},
};
export const participantsQueryResponse = {
data: {
workspace: {
__typename: 'Project',
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/1',
iid: '1',
participants: { participants: {
nodes: [ nodes: [
{ {
__typename: 'User',
id: 'gid://gitlab/User/1', id: 'gid://gitlab/User/1',
avatarUrl: avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
...@@ -399,27 +464,28 @@ export const updateIssueAssigneesMutationResponse = { ...@@ -399,27 +464,28 @@ export const updateIssueAssigneesMutationResponse = {
status: null, status: null,
}, },
{ {
__typename: 'User',
id: 'gid://gitlab/User/2', id: 'gid://gitlab/User/2',
avatarUrl: avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon', 'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
name: 'Jacki Kub', name: 'Jacki Kub',
username: 'francina.skiles', username: 'francina.skiles',
webUrl: '/franc', webUrl: '/franc',
status: {
availability: 'BUSY',
},
},
{
id: 'gid://gitlab/User/3',
avatarUrl: '/avatar',
name: 'John Doe',
username: 'rollie',
webUrl: '/john',
status: null, status: null,
}, },
], ],
__typename: 'UserConnection',
},
__typename: 'Issue',
}, },
}, },
}, },
};
export const subscriptionNullResponse = {
data: {
issuableAssigneesUpdated: null,
}, },
}; };
......
import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql';
import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
import {
searchResponse,
projectMembersResponse,
participantsQueryResponse,
} from '../../sidebar/mock_data';
const assignee = {
id: 'gid://gitlab/User/4',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Developer',
username: 'dev',
webUrl: '/dev',
status: null,
};
const mockError = jest.fn().mockRejectedValue('Error!');
const waitForSearch = async () => {
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
await nextTick();
await waitForPromises();
};
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('User select dropdown', () => {
let wrapper;
let fakeApollo;
const findSearchField = () => wrapper.findComponent(GlSearchBoxByType);
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 findCurrentUser = () => wrapper.findAll('[data-testid="current-user"]');
const findUnassignLink = () => wrapper.find('[data-testid="unassign"]');
const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]');
const createComponent = ({
props = {},
searchQueryHandler = jest.fn().mockResolvedValue(projectMembersResponse),
participantsQueryHandler = jest.fn().mockResolvedValue(participantsQueryResponse),
} = {}) => {
fakeApollo = createMockApollo([
[searchUsersQuery, searchQueryHandler],
[getIssueParticipantsQuery, participantsQueryHandler],
]);
wrapper = shallowMount(UserSelect, {
localVue,
apolloProvider: fakeApollo,
propsData: {
headerText: 'test',
text: 'test-text',
fullPath: '/project',
iid: '1',
value: [],
currentUser: {
username: 'random',
name: 'Mr. Random',
},
allowMultipleAssignees: false,
...props,
},
stubs: {
GlDropdown,
},
});
};
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
});
it('renders a loading spinner if participants are loading', () => {
createComponent();
expect(findParticipantsLoading().exists()).toBe(true);
});
it('emits an `error` event if participants query was rejected', async () => {
createComponent({ participantsQueryHandler: mockError });
await waitForPromises();
expect(wrapper.emitted('error')).toBeTruthy();
});
it('emits an `error` event if search query was rejected', async () => {
createComponent({ searchQueryHandler: mockError });
await waitForSearch();
expect(wrapper.emitted('error')).toBeTruthy();
});
it('renders current user if they are not in participants or assignees', async () => {
createComponent();
await waitForPromises();
expect(findCurrentUser().exists()).toBe(true);
});
it('displays correct amount of selected users', async () => {
createComponent({
props: {
value: [assignee],
},
});
await waitForPromises();
expect(findSelectedParticipants()).toHaveLength(1);
});
describe('when search is empty', () => {
it('renders a merged list of participants and project members', async () => {
createComponent();
await waitForPromises();
expect(findUnselectedParticipants()).toHaveLength(3);
});
it('renders `Unassigned` link with the checkmark when there are no selected users', async () => {
createComponent();
await waitForPromises();
expect(findUnassignLink().props('isChecked')).toBe(true);
});
it('renders `Unassigned` link without the checkmark when there are selected users', async () => {
createComponent({
props: {
value: [assignee],
},
});
await waitForPromises();
expect(findUnassignLink().props('isChecked')).toBe(false);
});
it('emits an input event with empty array after clicking on `Unassigned`', async () => {
createComponent({
props: {
value: [assignee],
},
});
await waitForPromises();
findUnassignLink().vm.$emit('click');
expect(wrapper.emitted('input')).toEqual([[[]]]);
});
it('emits an empty array after unselecting the only selected assignee', async () => {
createComponent({
props: {
value: [assignee],
},
});
await waitForPromises();
findSelectedParticipants().at(0).vm.$emit('click', new Event('click'));
expect(wrapper.emitted('input')).toEqual([[[]]]);
});
it('allows only one user to be selected if `allowMultipleAssignees` is false', async () => {
createComponent({
props: {
value: [assignee],
},
});
await waitForPromises();
findUnselectedParticipants().at(0).vm.$emit('click');
expect(wrapper.emitted('input')).toEqual([
[
[
{
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
id: 'gid://gitlab/User/1',
name: 'Administrator',
status: null,
username: 'root',
webUrl: '/root',
},
],
],
]);
});
it('adds user to selected if `allowMultipleAssignees` is true', async () => {
createComponent({
props: {
value: [assignee],
allowMultipleAssignees: true,
},
});
await waitForPromises();
findUnselectedParticipants().at(0).vm.$emit('click');
expect(wrapper.emitted('input')[0][0]).toHaveLength(2);
});
});
describe('when searching', () => {
it('does not show loading spinner when debounce timer is still running', async () => {
createComponent();
await waitForPromises();
findSearchField().vm.$emit('input', 'roo');
expect(findParticipantsLoading().exists()).toBe(false);
});
it('shows loading spinner when searching for users', async () => {
createComponent();
await waitForPromises();
findSearchField().vm.$emit('input', 'roo');
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
await nextTick();
expect(findParticipantsLoading().exists()).toBe(true);
});
it('renders a list of found users and external participants matching search term', async () => {
createComponent({ searchQueryHandler: jest.fn().mockResolvedValue(searchResponse) });
await waitForPromises();
findSearchField().vm.$emit('input', 'ro');
await waitForSearch();
expect(findUnselectedParticipants()).toHaveLength(3);
});
it('renders a list of found users only if no external participants match search term', async () => {
createComponent({ searchQueryHandler: jest.fn().mockResolvedValue(searchResponse) });
await waitForPromises();
findSearchField().vm.$emit('input', 'roo');
await waitForSearch();
expect(findUnselectedParticipants()).toHaveLength(2);
});
it('shows a message about no matches if search returned an empty list', async () => {
const responseCopy = cloneDeep(searchResponse);
responseCopy.data.workspace.users.nodes = [];
createComponent({
searchQueryHandler: jest.fn().mockResolvedValue(responseCopy),
});
await waitForPromises();
findSearchField().vm.$emit('input', 'tango');
await waitForSearch();
expect(findUnselectedParticipants()).toHaveLength(0);
expect(findEmptySearchResults().exists()).toBe(true);
});
});
});
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