import { GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import Vue, { 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 searchUsersQueryOnMR from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql';
import { IssuableType } from '~/issues/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
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,
searchResponseOnMR,
projectMembersResponse,
participantsQueryResponse,
mockUser1,
mockUser2,
} from 'jest/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(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
await nextTick();
await waitForPromises();
};
Vue.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 findSelectedParticipantByIndex = (index) =>
findSelectedParticipants().at(index).findComponent(SidebarParticipant);
const findUnselectedParticipants = () =>
wrapper.findAll('[data-testid="unselected-participant"]');
const findUnselectedParticipantByIndex = (index) =>
findUnselectedParticipants().at(index).findComponent(SidebarParticipant);
const findCurrentUser = () => wrapper.findAll('[data-testid="current-user"]');
const findIssuableAuthor = () => wrapper.findAll('[data-testid="issuable-author"]');
const findUnassignLink = () => wrapper.find('[data-testid="unassign"]');
const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]');
const searchQueryHandlerSuccess = jest.fn().mockResolvedValue(projectMembersResponse);
const participantsQueryHandlerSuccess = jest.fn().mockResolvedValue(participantsQueryResponse);
const createComponent = ({
props = {},
searchQueryHandler = searchQueryHandlerSuccess,
participantsQueryHandler = participantsQueryHandlerSuccess,
} = {}) => {
fakeApollo = createMockApollo([
[searchUsersQuery, searchQueryHandler],
[searchUsersQueryOnMR, jest.fn().mockResolvedValue(searchResponseOnMR)],
[getIssueParticipantsQuery, participantsQueryHandler],
]);
wrapper = shallowMount(UserSelect, {
apolloProvider: fakeApollo,
propsData: {
headerText: 'test',
text: 'test-text',
fullPath: '/project',
iid: '1',
value: [],
currentUser: {
username: 'random',
name: 'Mr. Random',
},
allowMultipleAssignees: false,
...props,
},
stubs: {
GlDropdown: {
template: `
`,
methods: {
hide: jest.fn(),
},
},
},
});
};
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
});
it('renders a loading spinner if participants are loading', () => {
createComponent();
expect(findParticipantsLoading().exists()).toBe(true);
});
it('skips the queries if `isEditing` prop is false', () => {
createComponent({ props: { isEditing: false } });
expect(findParticipantsLoading().exists()).toBe(false);
expect(searchQueryHandlerSuccess).not.toHaveBeenCalled();
expect(participantsQueryHandlerSuccess).not.toHaveBeenCalled();
});
it('emits an `error` event if participants query was rejected', async () => {
createComponent({ participantsQueryHandler: mockError });
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[]]);
});
it('emits an `error` event if search query was rejected', async () => {
createComponent({ searchQueryHandler: mockError });
await waitForSearch();
expect(wrapper.emitted('error')).toEqual([[]]);
});
it('renders current user if they are not in participants or assignees', async () => {
createComponent();
await waitForPromises();
expect(findCurrentUser().exists()).toBe(true);
});
it('does not render current user if user is not logged in', async () => {
createComponent({
props: {
currentUser: {},
},
});
await waitForPromises();
expect(findCurrentUser().exists()).toBe(false);
});
it('does not render issuable author if author is not passed as a prop', async () => {
createComponent();
await waitForPromises();
expect(findIssuableAuthor().exists()).toBe(false);
});
describe('when issuable author is passed as a prop', () => {
it('moves issuable author on top of assigned list, if author is assigned', async () => {
createComponent({
props: {
value: [assignee, mockUser2],
issuableAuthor: mockUser2,
},
});
await waitForPromises();
expect(findSelectedParticipantByIndex(0).props('user')).toEqual(mockUser2);
});
it('moves issuable author on top of assigned list after current user, if author and current user are assigned', async () => {
const currentUser = mockUser1;
const issuableAuthor = mockUser2;
createComponent({
props: {
value: [assignee, issuableAuthor, currentUser],
issuableAuthor,
currentUser,
},
});
await waitForPromises();
expect(findSelectedParticipantByIndex(0).props('user')).toEqual(currentUser);
expect(findSelectedParticipantByIndex(1).props('user')).toEqual(issuableAuthor);
});
it('moves issuable author on top of unassigned list, if author is unassigned project member', async () => {
createComponent({
props: {
issuableAuthor: mockUser2,
},
});
await waitForPromises();
expect(findUnselectedParticipantByIndex(0).props('user')).toEqual(mockUser2);
});
it('moves issuable author on top of unassigned list after current user, if author and current user are unassigned project members', async () => {
const currentUser = mockUser2;
const issuableAuthor = mockUser1;
createComponent({
props: {
issuableAuthor,
currentUser,
},
});
await waitForPromises();
expect(findUnselectedParticipantByIndex(0).props('user')).toEqual(currentUser);
expect(findUnselectedParticipantByIndex(1).props('user')).toMatchObject(issuableAuthor);
});
it('displays author in a designated position if author is not assigned and not a project member', async () => {
createComponent({
props: {
issuableAuthor: assignee,
},
});
await waitForPromises();
expect(findIssuableAuthor().exists()).toBe(true);
});
});
it('displays correct amount of selected users', async () => {
createComponent({
props: {
value: [assignee],
},
});
await waitForPromises();
expect(findSelectedParticipants()).toHaveLength(1);
});
it('does not render a `Cannot merge` tooltip', async () => {
createComponent();
await waitForPromises();
expect(findUnselectedParticipants().at(0).attributes('title')).toBe('');
});
describe('when search is empty', () => {
it('renders a merged list of participants and project members', async () => {
createComponent();
await waitForPromises();
expect(findUnselectedParticipants()).toHaveLength(4);
});
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().trigger('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).trigger('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).trigger('click');
expect(wrapper.emitted('input')).toMatchObject([
[
[
{
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).trigger('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(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
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);
});
});
describe('when on merge request sidebar', () => {
beforeEach(() => {
createComponent({ props: { issuableType: IssuableType.MergeRequest, issuableId: 1 } });
return waitForPromises();
});
it('does not render a `Cannot merge` tooltip for a user that has merge permission', () => {
expect(findUnselectedParticipants().at(0).attributes('title')).toBe('');
});
it('renders a `Cannot merge` tooltip for a user that does not have merge permission', () => {
expect(findUnselectedParticipants().at(1).attributes('title')).toBe('Cannot merge');
});
});
});