debian-mirror-gitlab/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js

559 lines
18 KiB
JavaScript
Raw Normal View History

2021-04-29 21:17:54 +05:30
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';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql';
import { IssuableType } from '~/issue_show/constants';
import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarInviteMembers from '~/sidebar/components/assignees/sidebar_invite_members.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';
import {
issuableQueryResponse,
searchQueryResponse,
updateIssueAssigneesMutationResponse,
} from '../../mock_data';
jest.mock('~/flash');
const updateIssueAssigneesMutationSuccess = jest
.fn()
.mockResolvedValue(updateIssueAssigneesMutationResponse);
const mockError = jest.fn().mockRejectedValue('Error!');
const localVue = createLocalVue();
localVue.use(VueApollo);
const initialAssignees = [
{
id: 'some-user',
avatarUrl: 'some-user-avatar',
name: 'test',
username: 'test',
webUrl: '/test',
},
];
describe('Sidebar assignees widget', () => {
let wrapper;
let fakeApollo;
const findAssignees = () => wrapper.findComponent(IssuableAssignees);
const findRealtimeAssignees = () => wrapper.findComponent(SidebarAssigneesRealtime);
const findEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findDropdown = () => wrapper.findComponent(MultiSelectDropdown);
const findInviteMembersLink = () => wrapper.findComponent(SidebarInviteMembers);
const findSearchField = () => wrapper.findComponent(GlSearchBoxByType);
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 findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]');
const expandDropdown = () => wrapper.vm.$refs.toggle.expand();
const createComponent = ({
search = '',
issuableQueryHandler = jest.fn().mockResolvedValue(issuableQueryResponse),
searchQueryHandler = jest.fn().mockResolvedValue(searchQueryResponse),
updateIssueAssigneesMutationHandler = updateIssueAssigneesMutationSuccess,
props = {},
provide = {},
} = {}) => {
fakeApollo = createMockApollo([
[getIssueParticipantsQuery, issuableQueryHandler],
[searchUsersQuery, searchQueryHandler],
[updateIssueAssigneesMutation, updateIssueAssigneesMutationHandler],
]);
wrapper = shallowMount(SidebarAssigneesWidget, {
localVue,
apolloProvider: fakeApollo,
propsData: {
iid: '1',
fullPath: '/mygroup/myProject',
...props,
},
data() {
return {
search,
selected: [],
};
},
provide: {
canUpdate: true,
rootPath: '/',
...provide,
},
stubs: {
SidebarEditableItem,
MultiSelectDropdown,
GlSearchBoxByType,
GlDropdown,
},
});
};
beforeEach(() => {
gon.current_username = 'root';
gon.current_user_fullname = 'Administrator';
gon.current_user_avatar_url = '/root';
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
fakeApollo = null;
delete gon.current_username;
});
describe('with passed initial assignees', () => {
it('passes `initialLoading` as false to editable item', () => {
createComponent({
props: {
initialAssignees,
},
});
expect(findEditableItem().props('initialLoading')).toBe(false);
});
it('renders an initial assignees list with initialAssignees prop', () => {
createComponent({
props: {
initialAssignees,
},
});
expect(findAssignees().props('users')).toEqual(initialAssignees);
});
it('renders a collapsible item title calculated with initial assignees length', () => {
createComponent({
props: {
initialAssignees,
},
});
expect(findEditableItem().props('title')).toBe('Assignee');
});
describe('when expanded', () => {
it('renders a loading spinner if participants are loading', () => {
createComponent({
props: {
initialAssignees,
},
});
expandDropdown();
expect(findParticipantsLoading().exists()).toBe(true);
});
});
});
describe('without passed initial assignees', () => {
it('passes `initialLoading` as true to editable item', () => {
createComponent();
expect(findEditableItem().props('initialLoading')).toBe(true);
});
it('renders assignees list from API response when resolved', async () => {
createComponent();
await waitForPromises();
expect(findAssignees().props('users')).toEqual(
issuableQueryResponse.data.workspace.issuable.assignees.nodes,
);
});
it('renders an error when issuable query is rejected', async () => {
createComponent({
issuableQueryHandler: mockError,
});
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occurred while fetching participants.',
});
});
it('assigns current user when clicking `Assign self`', async () => {
createComponent();
await waitForPromises();
findAssignees().vm.$emit('assign-self');
expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({
assigneeUsernames: 'root',
fullPath: '/mygroup/myProject',
iid: '1',
});
await waitForPromises();
expect(
findAssignees()
.props('users')
.some((user) => user.username === 'root'),
).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',
status: null,
},
],
],
]);
});
it('renders current user if they are not in participants or assignees', async () => {
gon.current_username = 'random';
gon.current_user_fullname = 'Mr Random';
gon.current_user_avatar_url = '/random';
createComponent();
await waitForPromises();
expandDropdown();
expect(findCurrentUser().exists()).toBe(true);
});
describe('when expanded', () => {
beforeEach(async () => {
createComponent();
await waitForPromises();
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(2);
});
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: [],
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',
});
});
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: ['francina.skiles', 'root', 'johndoe'],
fullPath: '/mygroup/myProject',
iid: '1',
});
});
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({
assigneeUsernames: [],
fullPath: '/mygroup/myProject',
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 () => {
createComponent({ updateIssueAssigneesMutationHandler: mockError });
await waitForPromises();
expandDropdown();
findUnassignLink().vm.$emit('click');
findEditableItem().vm.$emit('close');
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occurred while updating assignees.',
});
});
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(ASSIGNEES_DEBOUNCE_DELAY);
await nextTick();
expect(findParticipantsLoading().exists()).toBe(true);
});
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',
status: null,
});
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();
jest.advanceTimersByTime(250);
await nextTick();
await waitForPromises();
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();
expandDropdown();
jest.advanceTimersByTime(250);
await nextTick();
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occurred while searching users.',
});
});
});
});
describe('when user is not signed in', () => {
beforeEach(() => {
gon.current_username = undefined;
createComponent();
});
it('does not show current user in the dropdown', () => {
expandDropdown();
expect(findCurrentUser().exists()).toBe(false);
});
it('passes signedIn prop as false to IssuableAssignees', () => {
expect(findAssignees().props('signedIn')).toBe(false);
});
});
it('when realtime feature flag is disabled', async () => {
createComponent();
await waitForPromises();
expect(findRealtimeAssignees().exists()).toBe(false);
});
it('when realtime feature flag is enabled', async () => {
createComponent({
provide: {
glFeatures: {
realTimeIssueSidebar: true,
},
},
});
await waitForPromises();
expect(findRealtimeAssignees().exists()).toBe(true);
});
describe('when making changes to participants list', () => {
beforeEach(async () => {
createComponent();
});
it('passes falsy `isDirty` prop to editable item if no changes to selected users were made', () => {
expandDropdown();
expect(findEditableItem().props('isDirty')).toBe(false);
});
it('passes truthy `isDirty` prop if selected users list was changed', async () => {
expandDropdown();
expect(findEditableItem().props('isDirty')).toBe(false);
findUnselectedParticipants().at(0).vm.$emit('click');
await nextTick();
expect(findEditableItem().props('isDirty')).toBe(true);
});
it('passes falsy `isDirty` prop after dropdown is closed', async () => {
expandDropdown();
findUnselectedParticipants().at(0).vm.$emit('click');
findEditableItem().vm.$emit('close');
await waitForPromises();
expect(findEditableItem().props('isDirty')).toBe(false);
});
});
it('does not render invite members link on non-issue sidebar', async () => {
createComponent({ props: { issuableType: IssuableType.MergeRequest } });
await waitForPromises();
expect(findInviteMembersLink().exists()).toBe(false);
});
it('does not render invite members link if `directlyInviteMembers` and `indirectlyInviteMembers` were not passed', async () => {
createComponent();
await waitForPromises();
expect(findInviteMembersLink().exists()).toBe(false);
});
it('renders invite members link if `directlyInviteMembers` is true', async () => {
createComponent({
provide: {
directlyInviteMembers: true,
},
});
await waitForPromises();
expect(findInviteMembersLink().exists()).toBe(true);
});
it('renders invite members link if `indirectlyInviteMembers` is true', async () => {
createComponent({
provide: {
indirectlyInviteMembers: true,
},
});
await waitForPromises();
expect(findInviteMembersLink().exists()).toBe(true);
});
});