Commit 9056505a authored by Natalia Tepluhina's avatar Natalia Tepluhina Committed by Nicolò Maria Mezzopera

Added more tests for assignees widget

Aliased the query correctly

Added tests for current user

Refactored queries to be aliased

Fixed structure.sql

Expanded search tests

Added test for emitted event

Added a changelog entry

Fixed delay and removed changelog
parent b6eaef6f
......@@ -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