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
......@@ -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, {
......
......@@ -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