Commit 439dd31c authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '325944-abstract-participants-dropdown-to-a-shared-component' into 'master'

[RUN-AS-IF-FOSS] Resolve "Abstract participants dropdown to a shared component"

See merge request gitlab-org/gitlab!59358
parents 96865818 efff24ac
<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