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>
import { GlDropdownItem, GlDropdownDivider, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
import { GlDropdownItem } 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 SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { assigneesQueries, ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
import { assigneesQueries } from '~/sidebar/constants';
import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SidebarInviteMembers from './sidebar_invite_members.vue';
import SidebarParticipant from './sidebar_participant.vue';
export const assigneesWidget = Vue.observable({
updateAssignees: null,
......@@ -33,14 +31,10 @@ export default {
components: {
SidebarEditableItem,
IssuableAssignees,
MultiSelectDropdown,
GlDropdownItem,
GlDropdownDivider,
GlSearchBoxByType,
GlLoadingIcon,
SidebarInviteMembers,
SidebarParticipant,
SidebarAssigneesRealtime,
UserSelect,
},
mixins: [glFeatureFlagsMixin()],
inject: {
......@@ -75,7 +69,7 @@ export default {
required: false,
default: null,
},
multipleAssignees: {
allowMultipleAssignees: {
type: Boolean,
required: false,
default: true,
......@@ -83,12 +77,9 @@ export default {
},
data() {
return {
search: '',
issuable: {},
searchUsers: [],
selected: [],
isSettingAssignees: false,
isSearching: false,
isDirty: false,
};
},
......@@ -106,51 +97,13 @@ export default {
result({ data }) {
const issuable = data.workspace?.issuable;
if (issuable) {
this.selected = this.moveCurrentUserToStart(cloneDeep(issuable.assignees.nodes));
this.selected = cloneDeep(issuable.assignees.nodes);
}
},
error() {
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: {
shouldEnableRealtime() {
......@@ -169,13 +122,6 @@ export default {
: 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;
if (!items) {
......@@ -183,28 +129,8 @@ export default {
}
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 === '';
isAssigneesLoading() {
return !this.initialAssignees && this.$apollo.queries.issuable.loading;
},
currentUser() {
return {
......@@ -213,35 +139,9 @@ export default {
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() {
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() {
assigneesWidget.updateAssignees = this.updateAssignees;
......@@ -271,68 +171,31 @@ export default {
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() {
this.updateAssignees(this.currentUser.username);
},
clearSelected() {
this.selected = [];
this.updateAssignees([this.currentUser.username]);
},
saveAssignees() {
this.isDirty = false;
this.updateAssignees(this.selectedUserNames);
this.updateAssignees(this.selected.map(({ username }) => username));
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() {
this.$refs.toggle.collapse();
},
expandWidget() {
this.$refs.toggle.expand();
},
showDivider(list) {
return list.length > 0 && this.isSearchEmpty;
focusSearch() {
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 {
@expand-widget="expandWidget"
/>
</template>
<template #default>
<multi-select-dropdown
class="gl-w-full dropdown-menu-user"
<user-select
ref="userSelect"
v-model="selected"
:text="$options.i18n.assignees"
: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"
@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>
<gl-dropdown-item>
<sidebar-invite-members v-if="directlyInviteMembers" />
</gl-dropdown-item>
</template>
</multi-select-dropdown>
</gl-dropdown-item> </template
></user-select>
</template>
</sidebar-editable-item>
</div>
......
......@@ -3,6 +3,9 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
i18n: {
unassigned: __('Unassigned'),
},
components: { GlButton, GlLoadingIcon },
inject: {
canUpdate: {},
......
......@@ -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 updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.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 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';
import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
export const ASSIGNEES_DEBOUNCE_DELAY = 250;
export const assigneesQueries = {
[IssuableType.Issue]: {
query: getIssueParticipants,
query: getIssueAssignees,
subscription: issuableAssigneesSubscription,
mutation: updateAssigneesMutation,
mutation: updateIssueAssigneesMutation,
},
[IssuableType.MergeRequest]: {
query: getMergeRequestAssignees,
mutation: updateMergeRequestAssigneesMutation,
},
};
export const participantsQueries = {
[IssuableType.Issue]: {
query: issueParticipantsQuery,
},
[IssuableType.MergeRequest]: {
query: getMergeRequestParticipants,
mutation: updateMergeRequestParticipantsMutation,
},
};
......
......@@ -108,7 +108,7 @@ function mountAssigneesComponent() {
? IssuableType.Issue
: IssuableType.MergeRequest,
issuableId: id,
multipleAssignees: !el.dataset.maxAssignees,
allowMultipleAssignees: !el.dataset.maxAssignees,
},
scopedSlots: {
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!) {
...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!) {
...UserAvailability
}
}
assignees {
nodes {
...User
...UserAvailability
}
}
}
}
}
......@@ -13,12 +13,6 @@ mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullP
...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 ""
msgid "An error occurred while saving changes: %{error}"
msgstr ""
msgid "An error occurred while searching users."
msgstr ""
msgid "An error occurred while subscribing to notifications."
msgstr ""
......
......@@ -4,7 +4,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql';
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';
const localVue = createLocalVue();
......@@ -24,7 +24,7 @@ describe('Assignees Realtime', () => {
subscriptionHandler = subscriptionInitialHandler,
} = {}) => {
fakeApollo = createMockApollo([
[getIssueParticipantsQuery, issuableQueryHandler],
[getIssueAssigneesQuery, issuableQueryHandler],
[issuableAssigneesSubscription, subscriptionHandler],
]);
wrapper = shallowMount(AssigneesRealtime, {
......
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 createFlash from '~/flash';
import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql';
import { IssuableType } from '~/issue_show/constants';
import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarInviteMembers from '~/sidebar/components/assignees/sidebar_invite_members.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
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 updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import {
issuableQueryResponse,
searchQueryResponse,
updateIssueAssigneesMutationResponse,
} from '../../mock_data';
import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
import { issuableQueryResponse, updateIssueAssigneesMutationResponse } from '../../mock_data';
jest.mock('~/flash');
......@@ -50,31 +43,19 @@ describe('Sidebar assignees widget', () => {
const findAssignees = () => wrapper.findComponent(IssuableAssignees);
const findRealtimeAssignees = () => wrapper.findComponent(SidebarAssigneesRealtime);
const findEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findDropdown = () => wrapper.findComponent(MultiSelectDropdown);
const findInviteMembersLink = () => wrapper.findComponent(SidebarInviteMembers);
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 findUserSelect = () => wrapper.findComponent(UserSelect);
const expandDropdown = () => wrapper.vm.$refs.toggle.expand();
const createComponent = ({
search = '',
issuableQueryHandler = jest.fn().mockResolvedValue(issuableQueryResponse),
searchQueryHandler = jest.fn().mockResolvedValue(searchQueryResponse),
updateIssueAssigneesMutationHandler = updateIssueAssigneesMutationSuccess,
props = {},
provide = {},
} = {}) => {
fakeApollo = createMockApollo([
[getIssueParticipantsQuery, issuableQueryHandler],
[searchUsersQuery, searchQueryHandler],
[getIssueAssigneesQuery, issuableQueryHandler],
[updateIssueAssigneesMutation, updateIssueAssigneesMutationHandler],
]);
wrapper = shallowMount(SidebarAssigneesWidget, {
......@@ -82,15 +63,10 @@ describe('Sidebar assignees widget', () => {
apolloProvider: fakeApollo,
propsData: {
iid: '1',
issuableId: 0,
fullPath: '/mygroup/myProject',
...props,
},
data() {
return {
search,
selected: [],
};
},
provide: {
canUpdate: true,
rootPath: '/',
......@@ -98,7 +74,7 @@ describe('Sidebar assignees widget', () => {
},
stubs: {
SidebarEditableItem,
MultiSelectDropdown,
UserSelect,
GlSearchBoxByType,
GlDropdown,
},
......@@ -148,19 +124,6 @@ describe('Sidebar assignees widget', () => {
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', () => {
......@@ -198,7 +161,7 @@ describe('Sidebar assignees widget', () => {
findAssignees().vm.$emit('assign-self');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: 'root',
assigneeUsernames: ['root'],
fullPath: '/mygroup/myProject',
iid: '1',
});
......@@ -220,7 +183,7 @@ describe('Sidebar assignees widget', () => {
findAssignees().vm.$emit('assign-self');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: 'root',
assigneeUsernames: ['root'],
fullPath: '/mygroup/myProject',
iid: '1',
});
......@@ -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', () => {
beforeEach(async () => {
createComponent();
......@@ -264,27 +215,18 @@ describe('Sidebar assignees widget', () => {
expandDropdown();
});
it('collapses the widget on multiselect dropdown toggle event', async () => {
findDropdown().vm.$emit('toggle');
it('collapses the widget on user select toggle event', async () => {
findUserSelect().vm.$emit('toggle');
await nextTick();
expect(findDropdown().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);
expect(findUserSelect().isVisible()).toBe(false);
});
it('unassigns all participants when clicking on `Unassign`', () => {
findUnassignLink().vm.$emit('click');
it('calls an update mutation with correct variables on User Select input event', () => {
findUserSelect().vm.$emit('input', [{ username: 'root' }]);
findEditableItem().vm.$emit('close');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: [],
assigneeUsernames: ['root'],
fullPath: '/mygroup/myProject',
iid: '1',
});
......@@ -293,68 +235,38 @@ describe('Sidebar assignees widget', () => {
describe('when multiselect is disabled', () => {
beforeEach(async () => {
createComponent({ props: { multipleAssignees: false } });
createComponent({ props: { allowMultipleAssignees: false } });
await waitForPromises();
expandDropdown();
});
it('adds a single assignee when clicking on unselected user', async () => {
findUnselectedParticipants().at(0).vm.$emit('click');
it('closes a dropdown after User Select input event', async () => {
findUserSelect().vm.$emit('input', [{ username: 'root' }]);
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: ['root'],
fullPath: '/mygroup/myProject',
iid: '1',
});
});
it('removes an assignee when clicking on selected user', () => {
findSelectedParticipants().at(0).vm.$emit('click', new Event('click'));
await waitForPromises();
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: [],
fullPath: '/mygroup/myProject',
iid: '1',
});
expect(findUserSelect().isVisible()).toBe(false);
});
});
describe('when multiselect is enabled', () => {
beforeEach(async () => {
createComponent({ props: { multipleAssignees: true } });
createComponent({ props: { allowMultipleAssignees: true } });
await waitForPromises();
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', () => {
findUnselectedParticipants().at(0).vm.$emit('click');
findSelectedParticipants().at(0).vm.$emit('click', new Event('click'));
findUserSelect().vm.$emit('input', [{ username: 'root' }]);
expect(updateIssueAssigneesMutationSuccess).not.toHaveBeenCalled();
expect(findUserSelect().isVisible()).toBe(true);
});
});
......@@ -363,7 +275,7 @@ describe('Sidebar assignees widget', () => {
await waitForPromises();
expandDropdown();
findUnassignLink().vm.$emit('click');
findUserSelect().vm.$emit('input', []);
findEditableItem().vm.$emit('close');
await waitForPromises();
......@@ -372,95 +284,6 @@ describe('Sidebar assignees widget', () => {
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', () => {
......@@ -469,11 +292,6 @@ describe('Sidebar assignees widget', () => {
createComponent();
});
it('does not show current user in the dropdown', () => {
expandDropdown();
expect(findCurrentUser().exists()).toBe(false);
});
it('passes signedIn prop as false to IssuableAssignees', () => {
expect(findAssignees().props('signedIn')).toBe(false);
});
......@@ -487,9 +305,6 @@ describe('Sidebar assignees widget', () => {
it('when realtime feature flag is enabled', async () => {
createComponent({
props: {
issuableId: 1,
},
provide: {
glFeatures: {
realTimeIssueSidebar: true,
......@@ -510,17 +325,17 @@ describe('Sidebar assignees widget', () => {
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();
expect(findEditableItem().props('isDirty')).toBe(false);
findUnselectedParticipants().at(0).vm.$emit('click');
findUserSelect().vm.$emit('input', []);
await nextTick();
expect(findEditableItem().props('isDirty')).toBe(true);
});
it('passes falsy `isDirty` prop after dropdown is closed', async () => {
expandDropdown();
findUnselectedParticipants().at(0).vm.$emit('click');
findUserSelect().vm.$emit('input', []);
findEditableItem().vm.$emit('close');
await waitForPromises();
expect(findEditableItem().props('isDirty')).toBe(false);
......
......@@ -283,38 +283,6 @@ export const issuableQueryResponse = {
__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',
status: null,
},
{
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',
},
},
{
id: 'gid://gitlab/User/3',
avatarUrl: '/avatar',
name: 'John Doe',
username: 'johndoe',
webUrl: '/john',
status: null,
},
],
},
assignees: {
nodes: [
{
......@@ -386,10 +354,107 @@ export const updateIssueAssigneesMutationResponse = {
],
__typename: 'UserConnection',
},
__typename: 'Issue',
},
},
},
};
export const subscriptionNullResponse = {
data: {
issuableAssigneesUpdated: null,
},
};
export const searchResponse = {
data: {
workspace: {
__typename: 'Project',
users: {
nodes: [
{
user: {
id: '1',
avatarUrl: '/avatar',
name: 'root',
username: 'root',
webUrl: 'root',
status: null,
},
},
{
user: {
id: '2',
avatarUrl: '/avatar2',
name: 'rookie',
username: 'rookie',
webUrl: 'rookie',
status: null,
},
},
],
},
},
},
};
export const projectMembersResponse = {
data: {
workspace: {
__typename: 'Project',
users: {
nodes: [
{
user: {
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
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',
},
},
},
],
},
},
},
};
export const participantsQueryResponse = {
data: {
workspace: {
__typename: 'Project',
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/1',
iid: '1',
participants: {
nodes: [
{
__typename: 'User',
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
......@@ -399,28 +464,29 @@ export const updateIssueAssigneesMutationResponse = {
status: null,
},
{
__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',
status: {
availability: 'BUSY',
},
},
{
id: 'gid://gitlab/User/3',
avatarUrl: '/avatar',
name: 'John Doe',
username: 'rollie',
webUrl: '/john',
status: null,
},
],
__typename: 'UserConnection',
},
__typename: 'Issue',
},
},
},
};
export const subscriptionNullResponse = {
data: {
issuableAssigneesUpdated: null,
},
};
export default mockData;
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