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