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({
closeSidebar() {
this.detail.issue = {};
},
setAssignees(data) {
boardsStore.detail.issue.setAssignees(data.issueSetAssignees.issue.assignees.nodes);
setAssignees(assignees) {
boardsStore.detail.issue.setAssignees(assignees);
},
showScopedLabels(label) {
return boardsStore.scopedLabels.enabled && isScopedLabel(label);
......
#import "../fragments/user.fragment.graphql"
query usersSearch($search: String!, $fullPath: ID!) {
issuable: project(fullPath: $fullPath) {
workspace: project(fullPath: $fullPath) {
users: projectMembers(search: $search) {
nodes {
user {
......
......@@ -15,13 +15,12 @@ import { IssuableType } from '~/issue_show/constants';
import { __, n__ } from '~/locale';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.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';
export const assigneesWidget = Vue.observable({
updateAssignees: null,
});
export default {
i18n: {
unassigned: __('Unassigned'),
......@@ -88,10 +87,10 @@ export default {
return this.queryVariables;
},
update(data) {
return data.issuable || data.project?.issuable;
return data.workspace?.issuable;
},
result({ data }) {
const issuable = data.issuable || data.project?.issuable;
const issuable = data.workspace?.issuable;
if (issuable) {
this.selected = this.moveCurrentUserToStart(cloneDeep(issuable.assignees.nodes));
}
......@@ -109,7 +108,7 @@ export default {
};
},
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) => {
if (
!acc.some((user) => current.username === user.username) &&
......@@ -121,7 +120,7 @@ export default {
}, searchResults);
return mergedSearchResults;
},
debounce: 250,
debounce: ASSIGNEES_DEBOUNCE_DELAY,
skip() {
return this.isSearchEmpty;
},
......@@ -229,7 +228,7 @@ export default {
},
})
.then(({ data }) => {
this.$emit('assignees-updated', data);
this.$emit('assignees-updated', data.issuableSetAssignees.issuable.assignees.nodes);
return data;
})
.catch(() => {
......@@ -378,7 +377,7 @@ export default {
<template v-if="showCurrentUser">
<gl-dropdown-divider />
<gl-dropdown-item
data-testid="unselected-participant"
data-testid="current-user"
@click.stop="selectAssignee(currentUser)"
>
<gl-avatar-link>
......@@ -409,7 +408,7 @@ export default {
/>
</gl-avatar-link>
</gl-dropdown-item>
<gl-dropdown-item v-if="noUsersFound && !isSearching">
<gl-dropdown-item v-if="noUsersFound && !isSearching" data-testid="empty-results">
{{ __('No matching results') }}
</gl-dropdown-item>
</template>
......
......@@ -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 updateMergeRequestParticipantsMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
export const ASSIGNEES_DEBOUNCE_DELAY = 250;
export const assigneesQueries = {
[IssuableType.Issue]: {
query: getIssueParticipants,
......
#import "~/graphql_shared/fragments/user.fragment.graphql"
query issueParticipants($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
workspace: project(fullPath: $fullPath) {
__typename
issuable: issue(iid: $iid) {
__typename
id
participants {
nodes {
......
#import "~/graphql_shared/fragments/user.fragment.graphql"
query getMrParticipants($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
workspace: project(fullPath: $fullPath) {
issuable: mergeRequest(iid: $iid) {
id
participants {
......
#import "~/graphql_shared/fragments/user.fragment.graphql"
mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
issueSetAssignees(
issuableSetAssignees: issueSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
) {
issue {
issuable: issue {
id
assignees {
nodes {
......
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';
......@@ -9,6 +10,7 @@ import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphq
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.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 updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
......@@ -44,12 +46,17 @@ describe('BoardCardAssigneeDropdown', () => {
const findAssignees = () => wrapper.findComponent(IssuableAssignees);
const findEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findDropdown = () => wrapper.findComponent(MultiSelectDropdown);
const findAssigneesLoading = () => wrapper.find('[data-testid="loading-assignees"]');
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 findSearchField = () => wrapper.findComponent(GlSearchBoxByType);
const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]');
const expandDropdown = () => wrapper.vm.$refs.toggle.expand();
const createComponent = ({
......@@ -162,7 +169,7 @@ describe('BoardCardAssigneeDropdown', () => {
await waitForPromises();
expect(findAssignees().props('users')).toEqual(
issuableQueryResponse.data.project.issuable.assignees.nodes,
issuableQueryResponse.data.workspace.issuable.assignees.nodes,
);
});
......@@ -199,6 +206,50 @@ describe('BoardCardAssigneeDropdown', () => {
).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', () => {
beforeEach(async () => {
createComponent();
......@@ -206,17 +257,45 @@ describe('BoardCardAssigneeDropdown', () => {
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 () => {
expect(findSelectedParticipants()).toHaveLength(1);
expect(findUnselectedParticipants()).toHaveLength(1);
expect(findUnselectedParticipants()).toHaveLength(2);
});
it('adds an assignee when clicking on unselected user', () => {
findUnselectedParticipants().at(0).vm.$emit('click');
it('does not render current user if they are in participants', () => {
expect(findCurrentUser().exists()).toBe(false);
});
it('unassigns all participants when clicking on `Unassign`', () => {
findUnassignLink().vm.$emit('click');
findEditableItem().vm.$emit('close');
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',
iid: '1',
});
......@@ -225,17 +304,36 @@ describe('BoardCardAssigneeDropdown', () => {
it('removes an assignee when clicking on selected user', () => {
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');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: [],
assigneeUsernames: ['francina.skiles', 'root', 'johndoe'],
fullPath: '/mygroup/myProject',
iid: '1',
});
});
it('unassigns all participants when clicking on `Unassign`', () => {
findUnassignLink().vm.$emit('click');
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({
......@@ -244,6 +342,13 @@ describe('BoardCardAssigneeDropdown', () => {
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 () => {
......@@ -262,17 +367,51 @@ describe('BoardCardAssigneeDropdown', () => {
});
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(250);
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
await nextTick();
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' });
await waitForPromises();
expandDropdown();
......@@ -283,6 +422,24 @@ describe('BoardCardAssigneeDropdown', () => {
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();
......
......@@ -86,7 +86,8 @@ export const mockMutationResponse = {
export const issuableQueryResponse = {
data: {
project: {
workspace: {
__typename: 'Project',
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/1',
......@@ -109,6 +110,13 @@ export const issuableQueryResponse = {
username: 'francina.skiles',
webUrl: '/franc',
},
{
id: 'gid://gitlab/User/3',
avatarUrl: '/avatar',
name: 'John Doe',
username: 'johndoe',
webUrl: '/john',
},
],
},
assignees: {
......@@ -130,7 +138,8 @@ export const issuableQueryResponse = {
export const searchQueryResponse = {
data: {
issuable: {
workspace: {
__typename: 'Project',
users: {
nodes: [
{
......@@ -144,8 +153,8 @@ export const searchQueryResponse = {
},
{
user: {
id: '3',
avatarUrl: '/avatar',
id: '2',
avatarUrl: '/avatar2',
name: 'rookie',
username: 'rookie',
webUrl: 'rookie',
......@@ -159,8 +168,8 @@ export const searchQueryResponse = {
export const updateIssueAssigneesMutationResponse = {
data: {
issueSetAssignees: {
issue: {
issuableSetAssignees: {
issuable: {
id: 'gid://gitlab/Issue/1',
iid: '1',
assignees: {
......@@ -202,7 +211,6 @@ export const updateIssueAssigneesMutationResponse = {
},
__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