diff options
Diffstat (limited to 'spec/frontend/work_items/components/work_item_assignees_inline_spec.js')
-rw-r--r-- | spec/frontend/work_items/components/work_item_assignees_inline_spec.js | 585 |
1 files changed, 585 insertions, 0 deletions
diff --git a/spec/frontend/work_items/components/work_item_assignees_inline_spec.js b/spec/frontend/work_items/components/work_item_assignees_inline_spec.js new file mode 100644 index 00000000000..ae7828b6c48 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_assignees_inline_spec.js @@ -0,0 +1,585 @@ +import { GlLink, GlTokenSelector, GlSkeletonLoader, GlIntersectionObserver } from '@gitlab/ui'; +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 { mountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import groupUsersSearchQuery from '~/graphql_shared/queries/group_users_search.query.graphql'; +import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql'; +import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import WorkItemAssigneesInline from '~/work_items/components/work_item_assignees_inline.vue'; +import { + i18n, + DEFAULT_PAGE_SIZE_ASSIGNEES, + TRACKING_CATEGORY_SHOW, + WORK_ITEM_TYPE_VALUE_TASK, +} from '~/work_items/constants'; +import { + projectMembersResponseWithCurrentUser, + mockAssignees, + currentUserResponse, + currentUserNullResponse, + projectMembersResponseWithoutCurrentUser, + updateWorkItemMutationResponse, + projectMembersResponseWithCurrentUserWithNextPage, + projectMembersResponseWithNoMatchingUsers, + projectMembersResponseWithDuplicates, +} from '../mock_data'; + +Vue.use(VueApollo); + +const workItemId = 'gid://gitlab/WorkItem/1'; +const dropdownItems = projectMembersResponseWithCurrentUser.data.workspace.users.nodes; + +describe('WorkItemAssigneesInline component', () => { + let wrapper; + + const findAssigneeLinks = () => wrapper.findAllComponents(GlLink); + const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findInviteMembersTrigger = () => wrapper.findComponent(InviteMembersTrigger); + + const findEmptyState = () => wrapper.findByTestId('empty-state'); + const findAssignSelfButton = () => wrapper.findByTestId('assign-self'); + const findAssigneesTitle = () => wrapper.findByTestId('assignees-title'); + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); + + const triggerInfiniteScroll = () => + wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); + + const successSearchQueryHandler = jest + .fn() + .mockResolvedValue(projectMembersResponseWithCurrentUser); + const successGroupSearchQueryHandler = jest + .fn() + .mockResolvedValue(projectMembersResponseWithCurrentUser); + const successSearchQueryHandlerWithMoreAssignees = jest + .fn() + .mockResolvedValue(projectMembersResponseWithCurrentUserWithNextPage); + const successCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserResponse); + const noCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserNullResponse); + const successUpdateWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(updateWorkItemMutationResponse); + const successSearchWithNoMatchingUsers = jest + .fn() + .mockResolvedValue(projectMembersResponseWithNoMatchingUsers); + + const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); + + const createComponent = ({ + assignees = mockAssignees, + searchQueryHandler = successSearchQueryHandler, + currentUserQueryHandler = successCurrentUserQueryHandler, + updateWorkItemMutationHandler = successUpdateWorkItemMutationHandler, + allowsMultipleAssignees = true, + canInviteMembers = false, + canUpdate = true, + isGroup = false, + } = {}) => { + const apolloProvider = createMockApollo([ + [usersSearchQuery, searchQueryHandler], + [groupUsersSearchQuery, successGroupSearchQueryHandler], + [currentUserQuery, currentUserQueryHandler], + [updateWorkItemMutation, updateWorkItemMutationHandler], + ]); + + wrapper = mountExtended(WorkItemAssigneesInline, { + provide: { + isGroup, + }, + propsData: { + assignees, + fullPath: 'test-project-path', + workItemId, + allowsMultipleAssignees, + workItemType: WORK_ITEM_TYPE_VALUE_TASK, + canUpdate, + canInviteMembers, + }, + attachTo: document.body, + apolloProvider, + stubs: { + GlEmoji: { template: '<div/>' }, + }, + }); + }; + + it('passes the correct data-user-id attribute', () => { + createComponent(); + + expect(findAssigneeLinks().at(0).attributes('data-user-id')).toBe('1'); + }); + + it('container does not have shadow by default', () => { + createComponent(); + expect(findTokenSelector().props('containerClass')).toContain('gl-shadow-none!'); + }); + + it('container has shadow after focusing token selector', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findTokenSelector().props('containerClass')).toBe(''); + }); + + it('focuses token selector on token selector input event', async () => { + createComponent(); + findTokenSelector().vm.$emit('input', [mockAssignees[0]]); + await nextTick(); + + expect(findEmptyState().exists()).toBe(false); + expect(findTokenSelector().element.contains(document.activeElement)).toBe(true); + }); + + it('passes `false` to `viewOnly` token selector prop if user can update assignees', () => { + createComponent(); + + expect(findTokenSelector().props('viewOnly')).toBe(false); + }); + + it('passes `true` to `viewOnly` token selector prop if user can not update assignees', () => { + createComponent({ canUpdate: false }); + + expect(findTokenSelector().props('viewOnly')).toBe(true); + }); + + it('has a label', () => { + createComponent(); + + expect(findTokenSelector().props('ariaLabelledby')).toEqual( + findAssigneesTitle().attributes('id'), + ); + }); + + describe('when clicking outside the token selector', () => { + function arrange(args) { + createComponent(args); + findTokenSelector().vm.$emit('input', [mockAssignees[0]]); + findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + } + + it('calls a mutation with correct variables', () => { + arrange({ assignees: [] }); + + expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + assigneesWidget: { assigneeIds: [mockAssignees[0].id] }, + id: 'gid://gitlab/WorkItem/1', + }, + }); + }); + + it('emits an error and resets assignees if mutation was rejected', async () => { + arrange({ updateWorkItemMutationHandler: errorHandler, assignees: [mockAssignees[1]] }); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]); + expect(findTokenSelector().props('selectedTokens')).toEqual([ + { ...mockAssignees[1], class: expect.anything() }, + ]); + }); + }); + + describe('when searching for users', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not start user search by default', () => { + expect(findTokenSelector().props('loading')).toBe(false); + expect(findTokenSelector().props('dropdownItems')).toEqual([]); + }); + + it('starts user search on hovering for more than 250ms', async () => { + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(true); + }); + + it('starts user search on focusing token selector', async () => { + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(true); + }); + + it('does not start searching if token-selector was hovered for less than 250ms', async () => { + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(100); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(false); + }); + + it('does not start searching if cursor was moved out from token selector before 250ms passed', async () => { + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(100); + + findTokenSelector().trigger('mouseout'); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(false); + }); + + it('shows skeleton loader on dropdown when loading users', async () => { + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('shows correct users list in dropdown when loaded', async () => { + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findTokenSelector().props('dropdownItems')).toHaveLength(2); + }); + + it('searches for users with correct key after text input', async () => { + const searchKey = 'Hello'; + + findTokenSelector().vm.$emit('focus'); + findTokenSelector().vm.$emit('text-input', searchKey); + await waitForPromises(); + + expect(successSearchQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ search: searchKey }), + ); + }); + }); + + it('emits error event if search users query fails', async () => { + createComponent({ searchQueryHandler: errorHandler }); + findTokenSelector().vm.$emit('focus'); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[i18n.fetchError]]); + }); + + it('updates localAssignees when assignees prop is updated', async () => { + createComponent({ assignees: [] }); + + expect(findTokenSelector().props('selectedTokens')).toEqual([]); + + await wrapper.setProps({ assignees: [mockAssignees[0]] }); + + expect(findTokenSelector().props('selectedTokens')).toEqual([ + { ...mockAssignees[0], class: expect.anything() }, + ]); + }); + + describe('when assigning to current user', () => { + it('does not show `Assign yourself` button if current user is loading', () => { + createComponent(); + findTokenSelector().trigger('mouseover'); + + expect(findAssignSelfButton().exists()).toBe(false); + }); + + it('does not show `Assign yourself` button if work item has assignees', async () => { + createComponent(); + await waitForPromises(); + findTokenSelector().trigger('mouseover'); + + expect(findAssignSelfButton().exists()).toBe(false); + }); + + it('does now show `Assign yourself` button if user is not logged in', async () => { + createComponent({ currentUserQueryHandler: noCurrentUserQueryHandler, assignees: [] }); + await waitForPromises(); + findTokenSelector().trigger('mouseover'); + + expect(findAssignSelfButton().exists()).toBe(false); + }); + }); + + describe('when user is logged in and there are no assignees', () => { + beforeEach(() => { + createComponent({ assignees: [] }); + return waitForPromises(); + }); + + it('renders `Assign yourself` button', () => { + findTokenSelector().trigger('mouseover'); + expect(findAssignSelfButton().exists()).toBe(true); + }); + + it('calls update work item assignees mutation with current user as a variable on button click', async () => { + const { currentUser } = currentUserResponse.data; + findTokenSelector().trigger('mouseover'); + findAssignSelfButton().vm.$emit('click', new MouseEvent('click')); + await nextTick(); + + expect(findTokenSelector().props('selectedTokens')).toMatchObject([currentUser]); + expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + id: workItemId, + assigneesWidget: { + assigneeIds: [currentUser.id], + }, + }, + }); + }); + }); + + it('moves current user to the top of dropdown items if user is a project member', async () => { + createComponent(); + await waitForPromises(); + + expect(findTokenSelector().props('dropdownItems')[0]).toEqual( + expect.objectContaining(currentUserResponse.data.currentUser), + ); + }); + + describe('when current user is not in the list of project members', () => { + const searchQueryHandler = jest + .fn() + .mockResolvedValue(projectMembersResponseWithoutCurrentUser); + + beforeEach(() => { + createComponent({ searchQueryHandler }); + return waitForPromises(); + }); + + it('adds current user to the top of dropdown items', () => { + expect(findTokenSelector().props('dropdownItems')[0]).toEqual({ + ...currentUserResponse.data.currentUser, + class: expect.anything(), + }); + }); + + it('does not add current user if search is not empty', async () => { + findTokenSelector().vm.$emit('text-input', 'test'); + await waitForPromises(); + + expect(findTokenSelector().props('dropdownItems')[0]).not.toEqual( + currentUserResponse.data.currentUser, + ); + }); + }); + + it('has `Assignee` label when only one assignee is present', () => { + createComponent({ assignees: [mockAssignees[0]] }); + + expect(findAssigneesTitle().text()).toBe('Assignee'); + }); + + it('has `Assignees` label if more than one assignee is present', () => { + createComponent(); + + expect(findAssigneesTitle().text()).toBe('Assignees'); + }); + + describe('when multiple assignees are allowed', () => { + beforeEach(() => { + createComponent({ allowsMultipleAssignees: true, assignees: [] }); + return waitForPromises(); + }); + + it('has `Add assignees` text on placeholder', () => { + expect(findEmptyState().text()).toContain('Add assignees'); + }); + + it('adds multiple assignees when token-selector provides multiple values', async () => { + findTokenSelector().vm.$emit('input', dropdownItems); + await nextTick(); + + expect(findTokenSelector().props('selectedTokens')).toHaveLength(2); + }); + }); + + describe('when multiple assignees are not allowed', () => { + beforeEach(() => { + createComponent({ allowsMultipleAssignees: false, assignees: [] }); + return waitForPromises(); + }); + + it('has `Add assignee` text on placeholder', () => { + expect(findEmptyState().text()).toContain('Add assignee'); + expect(findEmptyState().text()).not.toContain('Add assignees'); + }); + + it('adds a single assignee token-selector provides multiple values', async () => { + findTokenSelector().vm.$emit('input', dropdownItems); + await nextTick(); + + expect(findTokenSelector().props('selectedTokens')).toHaveLength(1); + }); + + it('removes shadow after token-selector input', async () => { + findTokenSelector().vm.$emit('input', dropdownItems); + await nextTick(); + + expect(findTokenSelector().props('containerClass')).toContain('gl-shadow-none!'); + }); + + it('calls the mutation for updating assignees with the correct input', async () => { + findTokenSelector().vm.$emit('input', [mockAssignees[1]]); + await waitForPromises(); + + expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + assigneesWidget: { + assigneeIds: [mockAssignees[1].id], + }, + id: 'gid://gitlab/WorkItem/1', + }, + }); + }); + }); + + describe('tracking', () => { + let trackingSpy; + + beforeEach(() => { + createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + trackingSpy = null; + }); + + it('does not track updating assignees until token selector blur event', async () => { + findTokenSelector().vm.$emit('input', [mockAssignees[0]]); + await waitForPromises(); + + expect(trackingSpy).not.toHaveBeenCalled(); + }); + + it('tracks editing the assignees on token selector blur', async () => { + findTokenSelector().vm.$emit('input', [mockAssignees[0]]); + findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_assignees', { + category: TRACKING_CATEGORY_SHOW, + label: 'item_assignees', + property: 'type_Task', + }); + }); + }); + + describe('invite members', () => { + it('does not render `Invite members` link if user has no permission to invite members', () => { + createComponent(); + + expect(findInviteMembersTrigger().exists()).toBe(false); + }); + + it('renders `Invite members` link if user has a permission to invite members', () => { + createComponent({ canInviteMembers: true }); + + expect(findInviteMembersTrigger().exists()).toBe(true); + }); + }); + + describe('load more assignees', () => { + it('does not have intersection observer when no matching users', async () => { + createComponent({ searchQueryHandler: successSearchWithNoMatchingUsers }); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findIntersectionObserver().exists()).toBe(false); + }); + + it('does not trigger load more when does not have next page', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + + expect(findIntersectionObserver().exists()).toBe(false); + }); + + it('triggers load more when there are more users', async () => { + createComponent({ searchQueryHandler: successSearchQueryHandlerWithMoreAssignees }); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findIntersectionObserver().exists()).toBe(true); + + triggerInfiniteScroll(); + + expect(successSearchQueryHandlerWithMoreAssignees).toHaveBeenCalledWith({ + first: DEFAULT_PAGE_SIZE_ASSIGNEES, + after: + projectMembersResponseWithCurrentUserWithNextPage.data.workspace.users.pageInfo.endCursor, + search: '', + fullPath: 'test-project-path', + }); + }); + }); + + it('filters out the users with the same ID from the list of project members', async () => { + createComponent({ + searchQueryHandler: jest.fn().mockResolvedValue(projectMembersResponseWithDuplicates), + }); + findTokenSelector().vm.$emit('focus'); + await waitForPromises(); + + expect(findTokenSelector().props('dropdownItems')).toHaveLength(2); + }); + + describe('when project context', () => { + beforeEach(() => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + findTokenSelector().vm.$emit('text-input', 'jane'); + }); + + it('calls the project users search query', () => { + expect(successSearchQueryHandler).toHaveBeenCalled(); + }); + + it('does not call the group users search query', () => { + expect(successGroupSearchQueryHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when group context', () => { + beforeEach(() => { + createComponent({ isGroup: true }); + findTokenSelector().vm.$emit('focus'); + findTokenSelector().vm.$emit('text-input', 'jane'); + }); + + it('does not call the project users search query', () => { + expect(successSearchQueryHandler).not.toHaveBeenCalled(); + }); + + it('calls the group users search query', () => { + expect(successGroupSearchQueryHandler).toHaveBeenCalled(); + }); + }); +}); |