Commit 8cc69ea9 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '321666-improve-unit-tests-for-assignees-widget-component' into 'master'

Resolve "Improve unit tests for assignees widget component"

See merge request gitlab-org/gitlab!55504
parents 15a3481e 9056505a
...@@ -107,8 +107,8 @@ export default Vue.extend({ ...@@ -107,8 +107,8 @@ export default Vue.extend({
closeSidebar() { closeSidebar() {
this.detail.issue = {}; this.detail.issue = {};
}, },
setAssignees(data) { setAssignees(assignees) {
boardsStore.detail.issue.setAssignees(data.issueSetAssignees.issue.assignees.nodes); boardsStore.detail.issue.setAssignees(assignees);
}, },
showScopedLabels(label) { showScopedLabels(label) {
return boardsStore.scopedLabels.enabled && isScopedLabel(label); return boardsStore.scopedLabels.enabled && isScopedLabel(label);
......
#import "../fragments/user.fragment.graphql" #import "../fragments/user.fragment.graphql"
query usersSearch($search: String!, $fullPath: ID!) { query usersSearch($search: String!, $fullPath: ID!) {
issuable: project(fullPath: $fullPath) { workspace: project(fullPath: $fullPath) {
users: projectMembers(search: $search) { users: projectMembers(search: $search) {
nodes { nodes {
user { user {
......
...@@ -15,13 +15,12 @@ import { IssuableType } from '~/issue_show/constants'; ...@@ -15,13 +15,12 @@ import { IssuableType } from '~/issue_show/constants';
import { __, n__ } from '~/locale'; import { __, n__ } from '~/locale';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { assigneesQueries } from '~/sidebar/constants'; import { assigneesQueries, ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
export const assigneesWidget = Vue.observable({ export const assigneesWidget = Vue.observable({
updateAssignees: null, updateAssignees: null,
}); });
export default { export default {
i18n: { i18n: {
unassigned: __('Unassigned'), unassigned: __('Unassigned'),
...@@ -88,10 +87,10 @@ export default { ...@@ -88,10 +87,10 @@ export default {
return this.queryVariables; return this.queryVariables;
}, },
update(data) { update(data) {
return data.issuable || data.project?.issuable; return data.workspace?.issuable;
}, },
result({ data }) { result({ data }) {
const issuable = data.issuable || data.project?.issuable; const issuable = data.workspace?.issuable;
if (issuable) { if (issuable) {
this.selected = this.moveCurrentUserToStart(cloneDeep(issuable.assignees.nodes)); this.selected = this.moveCurrentUserToStart(cloneDeep(issuable.assignees.nodes));
} }
...@@ -109,7 +108,7 @@ export default { ...@@ -109,7 +108,7 @@ export default {
}; };
}, },
update(data) { update(data) {
const searchResults = data.issuable?.users?.nodes.map(({ user }) => user) || []; const searchResults = data.workspace?.users?.nodes.map(({ user }) => user) || [];
const mergedSearchResults = this.participants.reduce((acc, current) => { const mergedSearchResults = this.participants.reduce((acc, current) => {
if ( if (
!acc.some((user) => current.username === user.username) && !acc.some((user) => current.username === user.username) &&
...@@ -121,7 +120,7 @@ export default { ...@@ -121,7 +120,7 @@ export default {
}, searchResults); }, searchResults);
return mergedSearchResults; return mergedSearchResults;
}, },
debounce: 250, debounce: ASSIGNEES_DEBOUNCE_DELAY,
skip() { skip() {
return this.isSearchEmpty; return this.isSearchEmpty;
}, },
...@@ -229,7 +228,7 @@ export default { ...@@ -229,7 +228,7 @@ export default {
}, },
}) })
.then(({ data }) => { .then(({ data }) => {
this.$emit('assignees-updated', data); this.$emit('assignees-updated', data.issuableSetAssignees.issuable.assignees.nodes);
return data; return data;
}) })
.catch(() => { .catch(() => {
...@@ -378,7 +377,7 @@ export default { ...@@ -378,7 +377,7 @@ export default {
<template v-if="showCurrentUser"> <template v-if="showCurrentUser">
<gl-dropdown-divider /> <gl-dropdown-divider />
<gl-dropdown-item <gl-dropdown-item
data-testid="unselected-participant" data-testid="current-user"
@click.stop="selectAssignee(currentUser)" @click.stop="selectAssignee(currentUser)"
> >
<gl-avatar-link> <gl-avatar-link>
...@@ -409,7 +408,7 @@ export default { ...@@ -409,7 +408,7 @@ export default {
/> />
</gl-avatar-link> </gl-avatar-link>
</gl-dropdown-item> </gl-dropdown-item>
<gl-dropdown-item v-if="noUsersFound && !isSearching"> <gl-dropdown-item v-if="noUsersFound && !isSearching" data-testid="empty-results">
{{ __('No matching results') }} {{ __('No matching results') }}
</gl-dropdown-item> </gl-dropdown-item>
</template> </template>
......
...@@ -6,6 +6,8 @@ import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries ...@@ -6,6 +6,8 @@ import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries
import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.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 updateMergeRequestParticipantsMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
export const ASSIGNEES_DEBOUNCE_DELAY = 250;
export const assigneesQueries = { export const assigneesQueries = {
[IssuableType.Issue]: { [IssuableType.Issue]: {
query: getIssueParticipants, query: getIssueParticipants,
......
#import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user.fragment.graphql"
query issueParticipants($fullPath: ID!, $iid: String!) { query issueParticipants($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) { workspace: project(fullPath: $fullPath) {
__typename
issuable: issue(iid: $iid) { issuable: issue(iid: $iid) {
__typename
id id
participants { participants {
nodes { nodes {
......
#import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user.fragment.graphql"
query getMrParticipants($fullPath: ID!, $iid: String!) { query getMrParticipants($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) { workspace: project(fullPath: $fullPath) {
issuable: mergeRequest(iid: $iid) { issuable: mergeRequest(iid: $iid) {
id id
participants { participants {
......
#import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user.fragment.graphql"
mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) { mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
issueSetAssignees( issuableSetAssignees: issueSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath } input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
) { ) {
issue { issuable: issue {
id id
assignees { assignees {
nodes { nodes {
......
import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui'; import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
...@@ -9,6 +10,7 @@ import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphq ...@@ -9,6 +10,7 @@ import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphq
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
...@@ -44,12 +46,17 @@ describe('BoardCardAssigneeDropdown', () => { ...@@ -44,12 +46,17 @@ describe('BoardCardAssigneeDropdown', () => {
const findAssignees = () => wrapper.findComponent(IssuableAssignees); const findAssignees = () => wrapper.findComponent(IssuableAssignees);
const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); const findEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findDropdown = () => wrapper.findComponent(MultiSelectDropdown);
const findAssigneesLoading = () => wrapper.find('[data-testid="loading-assignees"]'); const findAssigneesLoading = () => wrapper.find('[data-testid="loading-assignees"]');
const findParticipantsLoading = () => wrapper.find('[data-testid="loading-participants"]'); const findParticipantsLoading = () => wrapper.find('[data-testid="loading-participants"]');
const findSelectedParticipants = () => wrapper.findAll('[data-testid="selected-participant"]'); const findSelectedParticipants = () => wrapper.findAll('[data-testid="selected-participant"]');
const findUnselectedParticipants = () => const findUnselectedParticipants = () =>
wrapper.findAll('[data-testid="unselected-participant"]'); wrapper.findAll('[data-testid="unselected-participant"]');
const findCurrentUser = () => wrapper.findAll('[data-testid="current-user"]');
const findUnassignLink = () => wrapper.find('[data-testid="unassign"]'); const findUnassignLink = () => wrapper.find('[data-testid="unassign"]');
const findSearchField = () => wrapper.findComponent(GlSearchBoxByType);
const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]');
const expandDropdown = () => wrapper.vm.$refs.toggle.expand(); const expandDropdown = () => wrapper.vm.$refs.toggle.expand();
const createComponent = ({ const createComponent = ({
...@@ -162,7 +169,7 @@ describe('BoardCardAssigneeDropdown', () => { ...@@ -162,7 +169,7 @@ describe('BoardCardAssigneeDropdown', () => {
await waitForPromises(); await waitForPromises();
expect(findAssignees().props('users')).toEqual( expect(findAssignees().props('users')).toEqual(
issuableQueryResponse.data.project.issuable.assignees.nodes, issuableQueryResponse.data.workspace.issuable.assignees.nodes,
); );
}); });
...@@ -199,6 +206,50 @@ describe('BoardCardAssigneeDropdown', () => { ...@@ -199,6 +206,50 @@ describe('BoardCardAssigneeDropdown', () => {
).toBe(true); ).toBe(true);
}); });
it('emits an event with assignees list on successful mutation', async () => {
createComponent();
await waitForPromises();
findAssignees().vm.$emit('assign-self');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: 'root',
fullPath: '/mygroup/myProject',
iid: '1',
});
await waitForPromises();
expect(wrapper.emitted('assignees-updated')).toEqual([
[
[
{
__typename: 'User',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
id: 'gid://gitlab/User/1',
name: 'Administrator',
username: 'root',
webUrl: '/root',
},
],
],
]);
});
it('renders current user if they are not in participants or assignees', async () => {
window.gon.current_username = 'random';
window.gon.current_user_fullname = 'Mr Random';
window.gon.current_user_avatar_url = '/random';
createComponent();
await waitForPromises();
expandDropdown();
expect(findCurrentUser().exists()).toBe(true);
});
describe('when expanded', () => { describe('when expanded', () => {
beforeEach(async () => { beforeEach(async () => {
createComponent(); createComponent();
...@@ -206,17 +257,45 @@ describe('BoardCardAssigneeDropdown', () => { ...@@ -206,17 +257,45 @@ describe('BoardCardAssigneeDropdown', () => {
expandDropdown(); expandDropdown();
}); });
it('collapses the widget on multiselect dropdown toggle event', async () => {
findDropdown().vm.$emit('toggle');
await nextTick();
expect(findDropdown().isVisible()).toBe(false);
});
it('renders participants list with correct amount of selected and unselected', async () => { it('renders participants list with correct amount of selected and unselected', async () => {
expect(findSelectedParticipants()).toHaveLength(1); expect(findSelectedParticipants()).toHaveLength(1);
expect(findUnselectedParticipants()).toHaveLength(1); expect(findUnselectedParticipants()).toHaveLength(2);
}); });
it('adds an assignee when clicking on unselected user', () => { it('does not render current user if they are in participants', () => {
findUnselectedParticipants().at(0).vm.$emit('click'); expect(findCurrentUser().exists()).toBe(false);
});
it('unassigns all participants when clicking on `Unassign`', () => {
findUnassignLink().vm.$emit('click');
findEditableItem().vm.$emit('close'); findEditableItem().vm.$emit('close');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: expect.arrayContaining(['root', 'francina.skiles']), assigneeUsernames: [],
fullPath: '/mygroup/myProject',
iid: '1',
});
});
});
describe('when multiselect is disabled', () => {
beforeEach(async () => {
createComponent({ props: { multipleAssignees: false } });
await waitForPromises();
expandDropdown();
});
it('adds a single assignee when clicking on unselected user', async () => {
findUnselectedParticipants().at(0).vm.$emit('click');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: ['root'],
fullPath: '/mygroup/myProject', fullPath: '/mygroup/myProject',
iid: '1', iid: '1',
}); });
...@@ -225,17 +304,36 @@ describe('BoardCardAssigneeDropdown', () => { ...@@ -225,17 +304,36 @@ describe('BoardCardAssigneeDropdown', () => {
it('removes an assignee when clicking on selected user', () => { it('removes an assignee when clicking on selected user', () => {
findSelectedParticipants().at(0).vm.$emit('click', new Event('click')); findSelectedParticipants().at(0).vm.$emit('click', new Event('click'));
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: [],
fullPath: '/mygroup/myProject',
iid: '1',
});
});
});
describe('when multiselect is enabled', () => {
beforeEach(async () => {
createComponent({ props: { multipleAssignees: 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'); findEditableItem().vm.$emit('close');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: [], assigneeUsernames: ['francina.skiles', 'root', 'johndoe'],
fullPath: '/mygroup/myProject', fullPath: '/mygroup/myProject',
iid: '1', iid: '1',
}); });
}); });
it('unassigns all participants when clicking on `Unassign`', () => { it('removes an assignee when clicking on selected user and then closing dropdown', () => {
findUnassignLink().vm.$emit('click'); findSelectedParticipants().at(0).vm.$emit('click', new Event('click'));
findEditableItem().vm.$emit('close'); findEditableItem().vm.$emit('close');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
...@@ -244,6 +342,13 @@ describe('BoardCardAssigneeDropdown', () => { ...@@ -244,6 +342,13 @@ describe('BoardCardAssigneeDropdown', () => {
iid: '1', 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'));
expect(updateIssueAssigneesMutationSuccess).not.toHaveBeenCalled();
});
}); });
it('shows an error if update assignees mutation is rejected', async () => { it('shows an error if update assignees mutation is rejected', async () => {
...@@ -262,17 +367,51 @@ describe('BoardCardAssigneeDropdown', () => { ...@@ -262,17 +367,51 @@ describe('BoardCardAssigneeDropdown', () => {
}); });
describe('when searching', () => { 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 () => { it('shows loading spinner when searching for users', async () => {
createComponent({ search: 'roo' }); createComponent({ search: 'roo' });
await waitForPromises(); await waitForPromises();
expandDropdown(); expandDropdown();
jest.advanceTimersByTime(250); jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
await nextTick(); await nextTick();
expect(findParticipantsLoading().exists()).toBe(true); expect(findParticipantsLoading().exists()).toBe(true);
}); });
it('renders a list of found users', async () => { 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',
});
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' }); createComponent({ search: 'roo' });
await waitForPromises(); await waitForPromises();
expandDropdown(); expandDropdown();
...@@ -283,6 +422,24 @@ describe('BoardCardAssigneeDropdown', () => { ...@@ -283,6 +422,24 @@ describe('BoardCardAssigneeDropdown', () => {
expect(findUnselectedParticipants()).toHaveLength(2); 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 () => { it('shows an error if search query was rejected', async () => {
createComponent({ search: 'roo', searchQueryHandler: mockError }); createComponent({ search: 'roo', searchQueryHandler: mockError });
await waitForPromises(); await waitForPromises();
......
...@@ -86,7 +86,8 @@ export const mockMutationResponse = { ...@@ -86,7 +86,8 @@ export const mockMutationResponse = {
export const issuableQueryResponse = { export const issuableQueryResponse = {
data: { data: {
project: { workspace: {
__typename: 'Project',
issuable: { issuable: {
__typename: 'Issue', __typename: 'Issue',
id: 'gid://gitlab/Issue/1', id: 'gid://gitlab/Issue/1',
...@@ -109,6 +110,13 @@ export const issuableQueryResponse = { ...@@ -109,6 +110,13 @@ export const issuableQueryResponse = {
username: 'francina.skiles', username: 'francina.skiles',
webUrl: '/franc', webUrl: '/franc',
}, },
{
id: 'gid://gitlab/User/3',
avatarUrl: '/avatar',
name: 'John Doe',
username: 'johndoe',
webUrl: '/john',
},
], ],
}, },
assignees: { assignees: {
...@@ -130,7 +138,8 @@ export const issuableQueryResponse = { ...@@ -130,7 +138,8 @@ export const issuableQueryResponse = {
export const searchQueryResponse = { export const searchQueryResponse = {
data: { data: {
issuable: { workspace: {
__typename: 'Project',
users: { users: {
nodes: [ nodes: [
{ {
...@@ -144,8 +153,8 @@ export const searchQueryResponse = { ...@@ -144,8 +153,8 @@ export const searchQueryResponse = {
}, },
{ {
user: { user: {
id: '3', id: '2',
avatarUrl: '/avatar', avatarUrl: '/avatar2',
name: 'rookie', name: 'rookie',
username: 'rookie', username: 'rookie',
webUrl: 'rookie', webUrl: 'rookie',
...@@ -159,8 +168,8 @@ export const searchQueryResponse = { ...@@ -159,8 +168,8 @@ export const searchQueryResponse = {
export const updateIssueAssigneesMutationResponse = { export const updateIssueAssigneesMutationResponse = {
data: { data: {
issueSetAssignees: { issuableSetAssignees: {
issue: { issuable: {
id: 'gid://gitlab/Issue/1', id: 'gid://gitlab/Issue/1',
iid: '1', iid: '1',
assignees: { assignees: {
...@@ -202,7 +211,6 @@ export const updateIssueAssigneesMutationResponse = { ...@@ -202,7 +211,6 @@ export const updateIssueAssigneesMutationResponse = {
}, },
__typename: 'Issue', __typename: 'Issue',
}, },
__typename: 'IssueSetAssigneesPayload',
}, },
}, },
}; };
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