diff options
Diffstat (limited to 'spec/frontend/work_items')
13 files changed, 1365 insertions, 143 deletions
diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js index 2c3f6ef8634..a55f448c9a2 100644 --- a/spec/frontend/work_items/components/item_title_spec.js +++ b/spec/frontend/work_items/components/item_title_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import { escape } from 'lodash'; import ItemTitle from '~/work_items/components/item_title.vue'; jest.mock('lodash/escape', () => jest.fn((fn) => fn)); @@ -51,6 +50,5 @@ describe('ItemTitle', () => { await findInputEl().trigger(sourceEvent); expect(wrapper.emitted(eventName)).toBeTruthy(); - expect(escape).toHaveBeenCalledWith(mockUpdatedTitle); }); }); diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js index 0552fe5050e..299949a4baa 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -1,52 +1,90 @@ -import { GlLink, GlTokenSelector } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import { GlLink, GlTokenSelector, GlSkeletonLoader } 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 { stripTypenames } from 'helpers/graphql_helpers'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; -import localUpdateWorkItemMutation from '~/work_items/graphql/local_update_work_item.mutation.graphql'; - -const mockAssignees = [ - { - __typename: 'UserCore', - id: 'gid://gitlab/User/1', - avatarUrl: '', - webUrl: '', - name: 'John Doe', - username: 'doe_I', - }, - { - __typename: 'UserCore', - id: 'gid://gitlab/User/2', - avatarUrl: '', - webUrl: '', - name: 'Marcus Rutherford', - username: 'ruthfull', - }, -]; +import { i18n, TASK_TYPE_NAME, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; +import { temporaryConfig, resolvers } from '~/work_items/graphql/provider'; +import { + projectMembersResponseWithCurrentUser, + mockAssignees, + workItemQueryResponse, + currentUserResponse, + currentUserNullResponse, + projectMembersResponseWithoutCurrentUser, +} from '../mock_data'; -const workItemId = 'gid://gitlab/WorkItem/1'; +Vue.use(VueApollo); -const mutate = jest.fn(); +const workItemId = 'gid://gitlab/WorkItem/1'; +const dropdownItems = projectMembersResponseWithCurrentUser.data.workspace.users.nodes; describe('WorkItemAssignees component', () => { let wrapper; const findAssigneeLinks = () => wrapper.findAllComponents(GlLink); const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findEmptyState = () => wrapper.findByTestId('empty-state'); + const findAssignSelfButton = () => wrapper.findByTestId('assign-self'); + const findAssigneesTitle = () => wrapper.findByTestId('assignees-title'); + + const successSearchQueryHandler = jest + .fn() + .mockResolvedValue(projectMembersResponseWithCurrentUser); + const successCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserResponse); + const noCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserNullResponse); + + const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); + + const createComponent = ({ + assignees = mockAssignees, + searchQueryHandler = successSearchQueryHandler, + currentUserQueryHandler = successCurrentUserQueryHandler, + allowsMultipleAssignees = true, + canUpdate = true, + } = {}) => { + const apolloProvider = createMockApollo( + [ + [userSearchQuery, searchQueryHandler], + [currentUserQuery, currentUserQueryHandler], + ], + resolvers, + { + typePolicies: temporaryConfig.cacheConfig.typePolicies, + }, + ); + + apolloProvider.clients.defaultClient.writeQuery({ + query: workItemQuery, + variables: { + id: workItemId, + }, + data: workItemQueryResponse.data, + }); - const createComponent = ({ assignees = mockAssignees } = {}) => { wrapper = mountExtended(WorkItemAssignees, { + provide: { + fullPath: 'test-project-path', + }, propsData: { assignees, workItemId, - }, - mocks: { - $apollo: { - mutate, - }, + allowsMultipleAssignees, + workItemType: TASK_TYPE_NAME, + canUpdate, }, attachTo: document.body, + apolloProvider, }); }; @@ -54,39 +92,316 @@ describe('WorkItemAssignees component', () => { wrapper.destroy(); }); - it('should pass the correct data-user-id attribute', () => { + it('passes the correct data-user-id attribute', () => { createComponent(); expect(findAssigneeLinks().at(0).attributes('data-user-id')).toBe('1'); }); - describe('when there are assignees', () => { + it('container does not have shadow by default', () => { + createComponent(); + expect(findTokenSelector().props('containerClass')).toBe('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('calls a mutation on clicking outside the token selector', async () => { + createComponent(); + findTokenSelector().vm.$emit('input', [mockAssignees[0]]); + findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + await waitForPromises(); + + expect(findTokenSelector().props('selectedTokens')).toEqual([mockAssignees[0]]); + }); + + 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); + }); + + describe('when searching for users', () => { beforeEach(() => { createComponent(); }); - it('should focus token selector on token removal', async () => { - findTokenSelector().vm.$emit('token-remove', mockAssignees[0].id); + 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(findEmptyState().exists()).toBe(false); - expect(findTokenSelector().element.contains(document.activeElement)).toBe(true); + expect(findTokenSelector().props('loading')).toBe(true); }); - it('should call a mutation on clicking outside the token selector', async () => { - findTokenSelector().vm.$emit('input', [mockAssignees[0]]); - findTokenSelector().vm.$emit('token-remove'); + it('starts user search on focusing token selector', async () => { + findTokenSelector().vm.$emit('focus'); await nextTick(); - expect(mutate).not.toHaveBeenCalled(); - findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + 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(mutate).toHaveBeenCalledWith({ - mutation: localUpdateWorkItemMutation, - variables: { - input: { id: workItemId, assigneeIds: [mockAssignees[0].id] }, - }, + 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('should search 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]]); + }); + + describe('when assigning to current user', () => { + it('does not show `Assign myself` button if current user is loading', () => { + createComponent(); + findTokenSelector().trigger('mouseover'); + + expect(findAssignSelfButton().exists()).toBe(false); + }); + + it('does not show `Assign myself` button if work item has assignees', async () => { + createComponent(); + await waitForPromises(); + findTokenSelector().trigger('mouseover'); + + expect(findAssignSelfButton().exists()).toBe(false); + }); + + it('does now show `Assign myself` 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 myself` button', async () => { + findTokenSelector().trigger('mouseover'); + expect(findAssignSelfButton().exists()).toBe(true); + }); + + it('calls update work item assignees mutation with current user as a variable on button click', () => { + // TODO: replace this test as soon as we have a real mutation implemented + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockImplementation(jest.fn()); + + findTokenSelector().trigger('mouseover'); + findAssignSelfButton().vm.$emit('click', new MouseEvent('click')); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + assignees: [stripTypenames(currentUserResponse.data.currentUser)], + id: workItemId, + }, + }, + }), + ); + }); + }); + + 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({ + ...stripTypenames(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( + stripTypenames(currentUserResponse.data.currentUser), + ); + }); + + 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( + stripTypenames(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')).toBe('gl-shadow-none!'); + }); + }); + + 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', }); }); }); diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js index d55ba318e46..70b1261bdb7 100644 --- a/spec/frontend/work_items/components/work_item_detail_modal_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js @@ -66,6 +66,7 @@ describe('WorkItemDetailModal component', () => { createComponent(); expect(findWorkItemDetail().props()).toEqual({ + isModal: true, workItemId: '1', workItemParentId: '2', }); @@ -98,6 +99,15 @@ describe('WorkItemDetailModal component', () => { expect(wrapper.emitted('close')).toBeTruthy(); }); + it('hides the modal when WorkItemDetail emits `close` event', () => { + createComponent(); + const closeSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide'); + + findWorkItemDetail().vm.$emit('close'); + + expect(closeSpy).toHaveBeenCalled(); + }); + describe('delete work item', () => { it('emits workItemDeleted and closes modal', async () => { createComponent(); diff --git a/spec/frontend/work_items/components/work_item_information_spec.js b/spec/frontend/work_items/components/work_item_information_spec.js new file mode 100644 index 00000000000..d5f6921c2bc --- /dev/null +++ b/spec/frontend/work_items/components/work_item_information_spec.js @@ -0,0 +1,48 @@ +import { mount } from '@vue/test-utils'; +import { GlAlert, GlLink } from '@gitlab/ui'; +import WorkItemInformation from '~/work_items/components/work_item_information.vue'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +const createComponent = () => mount(WorkItemInformation); + +describe('Work item information alert', () => { + let wrapper; + const tasksHelpPath = helpPagePath('user/tasks'); + const workItemsHelpPath = helpPagePath('development/work_items'); + + const findAlert = () => wrapper.findComponent(GlAlert); + const findHelpLink = () => wrapper.findComponent(GlLink); + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should be visible', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('should emit `work-item-banner-dismissed` event when cross icon is clicked', () => { + findAlert().vm.$emit('dismiss'); + expect(wrapper.emitted('work-item-banner-dismissed').length).toBe(1); + }); + + it('the alert variant should be tip', () => { + expect(findAlert().props('variant')).toBe('tip'); + }); + + it('should have the correct text for primary button and link', () => { + expect(findAlert().props('title')).toBe(WorkItemInformation.i18n.tasksInformationTitle); + expect(findAlert().props('primaryButtonText')).toBe( + WorkItemInformation.i18n.learnTasksButtonText, + ); + expect(findAlert().props('primaryButtonLink')).toBe(tasksHelpPath); + }); + + it('should have the correct link to work item link', () => { + expect(findHelpLink().exists()).toBe(true); + expect(findHelpLink().attributes('href')).toBe(workItemsHelpPath); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js new file mode 100644 index 00000000000..1734b901d1a --- /dev/null +++ b/spec/frontend/work_items/components/work_item_labels_spec.js @@ -0,0 +1,171 @@ +import { GlTokenSelector, GlSkeletonLoader } 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 { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; +import { i18n } from '~/work_items/constants'; +import { temporaryConfig, resolvers } from '~/work_items/graphql/provider'; +import { projectLabelsResponse, mockLabels, workItemQueryResponse } from '../mock_data'; + +Vue.use(VueApollo); + +const workItemId = 'gid://gitlab/WorkItem/1'; + +describe('WorkItemLabels component', () => { + let wrapper; + + const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + + const findEmptyState = () => wrapper.findByTestId('empty-state'); + + const successSearchQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse); + const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); + + const createComponent = ({ + labels = mockLabels, + canUpdate = true, + searchQueryHandler = successSearchQueryHandler, + } = {}) => { + const apolloProvider = createMockApollo([[labelSearchQuery, searchQueryHandler]], resolvers, { + typePolicies: temporaryConfig.cacheConfig.typePolicies, + }); + + apolloProvider.clients.defaultClient.writeQuery({ + query: workItemQuery, + variables: { + id: workItemId, + }, + data: workItemQueryResponse.data, + }); + + wrapper = mountExtended(WorkItemLabels, { + provide: { + fullPath: 'test-project-path', + }, + propsData: { + labels, + workItemId, + canUpdate, + }, + attachTo: document.body, + apolloProvider, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('focuses token selector on token selector input event', async () => { + createComponent(); + findTokenSelector().vm.$emit('input', [mockLabels[0]]); + await nextTick(); + + expect(findEmptyState().exists()).toBe(false); + expect(findTokenSelector().element.contains(document.activeElement)).toBe(true); + }); + + it('does not start search by default', () => { + createComponent(); + + expect(findTokenSelector().props('loading')).toBe(false); + expect(findTokenSelector().props('dropdownItems')).toEqual([]); + }); + + it('starts search on hovering for more than 250ms', async () => { + createComponent(); + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(true); + }); + + it('starts search on focusing token selector', async () => { + createComponent(); + 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 () => { + createComponent(); + 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 () => { + createComponent(); + 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', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('shows list in dropdown when loaded', async () => { + createComponent(); + 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.each([true, false])( + 'passes canUpdate=%s prop to view-only of token-selector', + async (canUpdate) => { + createComponent({ canUpdate }); + + await waitForPromises(); + + expect(findTokenSelector().props('viewOnly')).toBe(!canUpdate); + }, + ); + + it('emits error event if search query fails', async () => { + createComponent({ searchQueryHandler: errorHandler }); + findTokenSelector().vm.$emit('focus'); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[i18n.fetchError]]); + }); + + it('should search for with correct key after text input', async () => { + const searchKey = 'Hello'; + + createComponent(); + findTokenSelector().vm.$emit('focus'); + findTokenSelector().vm.$emit('text-input', searchKey); + await waitForPromises(); + + expect(successSearchQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ search: searchKey }), + ); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js new file mode 100644 index 00000000000..93bf7286aa7 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js @@ -0,0 +1,65 @@ +import Vue from 'vue'; +import { GlForm, GlFormCombobox } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue'; +import projectWorkItemsQuery from '~/work_items/graphql/project_work_items.query.graphql'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { availableWorkItemsResponse, updateWorkItemMutationResponse } from '../../mock_data'; + +Vue.use(VueApollo); + +describe('WorkItemLinksForm', () => { + let wrapper; + + const updateMutationResolver = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); + + const createComponent = async ({ listResponse = availableWorkItemsResponse } = {}) => { + wrapper = shallowMountExtended(WorkItemLinksForm, { + apolloProvider: createMockApollo([ + [projectWorkItemsQuery, jest.fn().mockResolvedValue(listResponse)], + [updateWorkItemMutation, updateMutationResolver], + ]), + propsData: { issuableGid: 'gid://gitlab/WorkItem/1' }, + provide: { + projectPath: 'project/path', + }, + }); + + await waitForPromises(); + }; + + const findForm = () => wrapper.findComponent(GlForm); + const findCombobox = () => wrapper.findComponent(GlFormCombobox); + const findAddChildButton = () => wrapper.findByTestId('add-child-button'); + + beforeEach(async () => { + await createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders form', () => { + expect(findForm().exists()).toBe(true); + }); + + it('passes available work items as prop when typing in combobox', async () => { + findCombobox().vm.$emit('input', 'Task'); + await waitForPromises(); + + expect(findCombobox().exists()).toBe(true); + expect(findCombobox().props('tokenList').length).toBe(2); + }); + + it('selects and add child', async () => { + findCombobox().vm.$emit('input', availableWorkItemsResponse.data.workspace.workItems.edges[0]); + + findAddChildButton().vm.$emit('click'); + await waitForPromises(); + expect(updateMutationResolver).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js new file mode 100644 index 00000000000..f8471b7f167 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js @@ -0,0 +1,141 @@ +import Vue from 'vue'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { cloneDeep } from 'lodash'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import WorkItemLinksMenu from '~/work_items/components/work_item_links/work_item_links_menu.vue'; +import changeWorkItemParentMutation from '~/work_items/graphql/change_work_item_parent_link.mutation.graphql'; +import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql'; +import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants'; +import { workItemHierarchyResponse, changeWorkItemParentMutationResponse } from '../../mock_data'; + +Vue.use(VueApollo); + +const PARENT_ID = 'gid://gitlab/WorkItem/1'; +const WORK_ITEM_ID = 'gid://gitlab/WorkItem/3'; + +describe('WorkItemLinksMenu', () => { + let wrapper; + let mockApollo; + + const $toast = { + show: jest.fn(), + }; + + const createComponent = async ({ + data = {}, + mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse), + } = {}) => { + mockApollo = createMockApollo([ + [getWorkItemLinksQuery, jest.fn().mockResolvedValue(workItemHierarchyResponse)], + [changeWorkItemParentMutation, mutationHandler], + ]); + + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getWorkItemLinksQuery, + variables: { + id: PARENT_ID, + }, + data: workItemHierarchyResponse.data, + }); + + wrapper = shallowMountExtended(WorkItemLinksMenu, { + data() { + return { + ...data, + }; + }, + propsData: { + workItemId: WORK_ITEM_ID, + parentWorkItemId: PARENT_ID, + }, + apolloProvider: mockApollo, + mocks: { + $toast, + }, + }); + + await waitForPromises(); + }; + + const findDropdown = () => wrapper.find(GlDropdown); + const findRemoveDropdownItem = () => wrapper.find(GlDropdownItem); + + beforeEach(async () => { + await createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + mockApollo = null; + }); + + it('renders dropdown and dropdown items', () => { + expect(findDropdown().exists()).toBe(true); + expect(findRemoveDropdownItem().exists()).toBe(true); + }); + + it('calls correct mutation with correct variables', async () => { + const mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse); + + createComponent({ mutationHandler }); + + findRemoveDropdownItem().vm.$emit('click'); + + await waitForPromises(); + + expect(mutationHandler).toHaveBeenCalledWith({ + id: WORK_ITEM_ID, + parentId: null, + }); + }); + + it('shows toast when mutation succeeds', async () => { + const mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse); + + createComponent({ mutationHandler }); + + findRemoveDropdownItem().vm.$emit('click'); + + await waitForPromises(); + + expect($toast.show).toHaveBeenCalledWith('Child removed', { + action: { onClick: expect.anything(), text: 'Undo' }, + }); + }); + + it('updates the cache when mutation succeeds', async () => { + const mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse); + + createComponent({ mutationHandler }); + + mockApollo.clients.defaultClient.cache.readQuery = jest.fn( + () => workItemHierarchyResponse.data, + ); + + mockApollo.clients.defaultClient.cache.writeQuery = jest.fn(); + + findRemoveDropdownItem().vm.$emit('click'); + + await waitForPromises(); + + // Remove the work item from parent's children + const resp = cloneDeep(workItemHierarchyResponse); + const index = resp.data.workItem.widgets + .find((widget) => widget.type === WIDGET_TYPE_HIERARCHY) + .children.nodes.findIndex((child) => child.id === WORK_ITEM_ID); + resp.data.workItem.widgets + .find((widget) => widget.type === WIDGET_TYPE_HIERARCHY) + .children.nodes.splice(index, 1); + + expect(mockApollo.clients.defaultClient.cache.writeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.anything(), + variables: { id: PARENT_ID }, + data: resp.data, + }), + ); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js index 774e9198992..2ec9b1ec0ac 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js @@ -51,6 +51,20 @@ describe('WorkItemLinks', () => { expect(findLinksBody().exists()).toBe(false); }); + describe('add link form', () => { + it('displays form on click add button and hides form on cancel', async () => { + findToggleAddFormButton().vm.$emit('click'); + await nextTick(); + + expect(findAddLinksForm().exists()).toBe(true); + + findAddLinksForm().vm.$emit('cancel'); + await nextTick(); + + expect(findAddLinksForm().exists()).toBe(false); + }); + }); + describe('when no child links', () => { beforeEach(async () => { await createComponent({ response: workItemHierarchyEmptyResponse }); @@ -59,22 +73,6 @@ describe('WorkItemLinks', () => { it('displays empty state if there are no children', () => { expect(findEmptyState().exists()).toBe(true); }); - - describe('add link form', () => { - it('displays form on click add button and hides form on cancel', async () => { - expect(findEmptyState().exists()).toBe(true); - - findToggleAddFormButton().vm.$emit('click'); - await nextTick(); - - expect(findAddLinksForm().exists()).toBe(true); - - findAddLinksForm().vm.$emit('cancel'); - await nextTick(); - - expect(findAddLinksForm().exists()).toBe(false); - }); - }); }); it('renders all hierarchy widget children', () => { diff --git a/spec/frontend/work_items/components/work_item_weight_spec.js b/spec/frontend/work_items/components/work_item_weight_spec.js index 80a1d032ad7..c3bbea26cda 100644 --- a/spec/frontend/work_items/components/work_item_weight_spec.js +++ b/spec/frontend/work_items/components/work_item_weight_spec.js @@ -1,21 +1,51 @@ -import { shallowMount } from '@vue/test-utils'; +import { GlForm, GlFormInput } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { mockTracking } from 'helpers/tracking_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { __ } from '~/locale'; import WorkItemWeight from '~/work_items/components/work_item_weight.vue'; +import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; +import localUpdateWorkItemMutation from '~/work_items/graphql/local_update_work_item.mutation.graphql'; -describe('WorkItemAssignees component', () => { +describe('WorkItemWeight component', () => { let wrapper; - const createComponent = ({ weight, hasIssueWeightsFeature = true } = {}) => { - wrapper = shallowMount(WorkItemWeight, { + const mutateSpy = jest.fn(); + const workItemId = 'gid://gitlab/WorkItem/1'; + const workItemType = 'Task'; + + const findForm = () => wrapper.findComponent(GlForm); + const findInput = () => wrapper.findComponent(GlFormInput); + + const createComponent = ({ + canUpdate = false, + hasIssueWeightsFeature = true, + isEditing = false, + weight, + } = {}) => { + wrapper = mountExtended(WorkItemWeight, { propsData: { + canUpdate, weight, + workItemId, + workItemType, }, provide: { hasIssueWeightsFeature, }, + mocks: { + $apollo: { + mutate: mutateSpy, + }, + }, }); + + if (isEditing) { + findInput().vm.$emit('focus'); + } }; - describe('weight licensed feature', () => { + describe('`issue_weights` licensed feature', () => { describe.each` description | hasIssueWeightsFeature | exists ${'when available'} | ${true} | ${true} @@ -24,23 +54,111 @@ describe('WorkItemAssignees component', () => { it(hasIssueWeightsFeature ? 'renders component' : 'does not render component', () => { createComponent({ hasIssueWeightsFeature }); - expect(wrapper.find('div').exists()).toBe(exists); + expect(findForm().exists()).toBe(exists); }); }); }); - describe('weight text', () => { - describe.each` - description | weight | text - ${'renders 1'} | ${1} | ${'1'} - ${'renders 0'} | ${0} | ${'0'} - ${'renders None'} | ${null} | ${'None'} - ${'renders None'} | ${undefined} | ${'None'} - `('when weight is $weight', ({ description, weight, text }) => { - it(description, () => { - createComponent({ weight }); - - expect(wrapper.text()).toContain(text); + describe('weight input', () => { + it('has "Weight" label', () => { + createComponent(); + + expect(wrapper.findByLabelText(__('Weight')).exists()).toBe(true); + }); + + describe('placeholder attribute', () => { + describe.each` + description | isEditing | canUpdate | value + ${'when not editing and cannot update'} | ${false} | ${false} | ${__('None')} + ${'when editing and cannot update'} | ${true} | ${false} | ${__('None')} + ${'when not editing and can update'} | ${false} | ${true} | ${__('None')} + ${'when editing and can update'} | ${true} | ${true} | ${__('Enter a number')} + `('$description', ({ isEditing, canUpdate, value }) => { + it(`has a value of "${value}"`, async () => { + createComponent({ canUpdate, isEditing }); + await nextTick(); + + expect(findInput().attributes('placeholder')).toBe(value); + }); + }); + }); + + describe('readonly attribute', () => { + describe.each` + description | canUpdate | value + ${'when cannot update'} | ${false} | ${'readonly'} + ${'when can update'} | ${true} | ${undefined} + `('$description', ({ canUpdate, value }) => { + it(`renders readonly=${value}`, () => { + createComponent({ canUpdate }); + + expect(findInput().attributes('readonly')).toBe(value); + }); + }); + }); + + describe('type attribute', () => { + describe.each` + description | isEditing | canUpdate | type + ${'when not editing and cannot update'} | ${false} | ${false} | ${'text'} + ${'when editing and cannot update'} | ${true} | ${false} | ${'text'} + ${'when not editing and can update'} | ${false} | ${true} | ${'text'} + ${'when editing and can update'} | ${true} | ${true} | ${'number'} + `('$description', ({ isEditing, canUpdate, type }) => { + it(`has a value of "${type}"`, async () => { + createComponent({ canUpdate, isEditing }); + await nextTick(); + + expect(findInput().attributes('type')).toBe(type); + }); + }); + }); + + describe('value attribute', () => { + describe.each` + weight | value + ${1} | ${'1'} + ${0} | ${'0'} + ${null} | ${''} + ${undefined} | ${''} + `('when `weight` prop is "$weight"', ({ weight, value }) => { + it(`value is "${value}"`, () => { + createComponent({ weight }); + + expect(findInput().element.value).toBe(value); + }); + }); + }); + + describe('when blurred', () => { + it('calls a mutation to update the weight', () => { + const weight = 0; + createComponent({ isEditing: true, weight }); + + findInput().trigger('blur'); + + expect(mutateSpy).toHaveBeenCalledWith({ + mutation: localUpdateWorkItemMutation, + variables: { + input: { + id: workItemId, + weight, + }, + }, + }); + }); + + it('tracks updating the weight', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + createComponent(); + + findInput().trigger('blur'); + + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_weight', { + category: TRACKING_CATEGORY_SHOW, + label: 'item_weight', + property: 'type_Task', + }); }); }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index bf3f4e1364d..0359caf7116 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -1,3 +1,22 @@ +export const mockAssignees = [ + { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + avatarUrl: '', + webUrl: '', + name: 'John Doe', + username: 'doe_I', + }, + { + __typename: 'UserCore', + id: 'gid://gitlab/User/2', + avatarUrl: '', + webUrl: '', + name: 'Marcus Rutherford', + username: 'ruthfull', + }, +]; + export const workItemQueryResponse = { data: { workItem: { @@ -23,6 +42,32 @@ export const workItemQueryResponse = { descriptionHtml: '<p data-sourcepos="1:1-1:19" dir="auto">some <strong>great</strong> text</p>', }, + { + __typename: 'WorkItemWidgetAssignees', + type: 'ASSIGNEES', + allowsMultipleAssignees: true, + assignees: { + nodes: mockAssignees, + }, + }, + { + __typename: 'WorkItemWidgetHierarchy', + type: 'HIERARCHY', + parent: { + id: 'gid://gitlab/Issue/1', + iid: '5', + title: 'Parent title', + }, + children: { + edges: [ + { + node: { + id: 'gid://gitlab/WorkItem/444', + }, + }, + ], + }, + }, ], }, }, @@ -47,13 +92,28 @@ export const updateWorkItemMutationResponse = { deleteWorkItem: false, updateWorkItem: false, }, - widgets: [], + widgets: [ + { + children: { + edges: [ + { + node: 'gid://gitlab/WorkItem/444', + }, + ], + }, + }, + ], }, }, }, }; -export const workItemResponseFactory = ({ canUpdate } = {}) => ({ +export const workItemResponseFactory = ({ + canUpdate = false, + allowsMultipleAssignees = true, + assigneesWidgetPresent = true, + parent = null, +} = {}) => ({ data: { workItem: { __typename: 'WorkItem', @@ -78,6 +138,30 @@ export const workItemResponseFactory = ({ canUpdate } = {}) => ({ descriptionHtml: '<p data-sourcepos="1:1-1:19" dir="auto">some <strong>great</strong> text</p>', }, + assigneesWidgetPresent + ? { + __typename: 'WorkItemWidgetAssignees', + type: 'ASSIGNEES', + allowsMultipleAssignees, + assignees: { + nodes: mockAssignees, + }, + } + : { type: 'MOCK TYPE' }, + { + __typename: 'WorkItemWidgetHierarchy', + type: 'HIERARCHY', + children: { + edges: [ + { + node: { + id: 'gid://gitlab/WorkItem/444', + }, + }, + ], + }, + parent, + }, ], }, }, @@ -140,13 +224,45 @@ export const createWorkItemFromTaskMutationResponse = { __typename: 'WorkItemCreateFromTaskPayload', errors: [], workItem: { - descriptionHtml: '<p>New description</p>', - id: 'gid://gitlab/WorkItem/13', __typename: 'WorkItem', + description: 'New description', + id: 'gid://gitlab/WorkItem/1', + title: 'Updated title', + state: 'OPEN', + workItemType: { + __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + }, userPermissions: { deleteWorkItem: false, updateWorkItem: false, }, + widgets: [ + { + __typename: 'WorkItemWidgetDescription', + type: 'DESCRIPTION', + description: 'New description', + descriptionHtml: '<p>New description</p>', + }, + ], + }, + newWorkItem: { + __typename: 'WorkItem', + id: 'gid://gitlab/WorkItem/1000000', + title: 'Updated title', + state: 'OPEN', + description: '', + workItemType: { + __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + }, + userPermissions: { + deleteWorkItem: false, + updateWorkItem: false, + }, + widgets: [], }, }, }, @@ -275,3 +391,171 @@ export const workItemHierarchyResponse = { }, }, }; + +export const changeWorkItemParentMutationResponse = { + data: { + workItemUpdate: { + workItem: { + id: 'gid://gitlab/WorkItem/2', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/5', + __typename: 'WorkItemType', + }, + title: 'Foo', + state: 'OPEN', + __typename: 'WorkItem', + }, + errors: [], + __typename: 'WorkItemUpdatePayload', + }, + }, +}; + +export const availableWorkItemsResponse = { + data: { + workspace: { + __typename: 'Project', + id: 'gid://gitlab/Project/2', + workItems: { + edges: [ + { + node: { + id: 'gid://gitlab/WorkItem/458', + title: 'Task 1', + state: 'OPEN', + }, + }, + { + node: { + id: 'gid://gitlab/WorkItem/459', + title: 'Task 2', + state: 'OPEN', + }, + }, + ], + }, + }, + }, +}; + +export const projectMembersResponseWithCurrentUser = { + data: { + workspace: { + id: '1', + __typename: 'Project', + users: { + nodes: [ + { + id: 'user-2', + user: { + __typename: 'UserCore', + id: 'gid://gitlab/User/5', + avatarUrl: '/avatar2', + name: 'rookie', + username: 'rookie', + webUrl: 'rookie', + status: null, + }, + }, + { + id: 'user-1', + user: { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + }, + ], + }, + }, + }, +}; + +export const projectMembersResponseWithoutCurrentUser = { + data: { + workspace: { + id: '1', + __typename: 'Project', + users: { + nodes: [ + { + id: 'user-2', + user: { + __typename: 'UserCore', + id: 'gid://gitlab/User/5', + avatarUrl: '/avatar2', + name: 'rookie', + username: 'rookie', + webUrl: 'rookie', + status: null, + }, + }, + ], + }, + }, + }, +}; + +export const currentUserResponse = { + data: { + currentUser: { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + }, + }, +}; + +export const currentUserNullResponse = { + data: { + currentUser: null, + }, +}; + +export const mockLabels = [ + { + __typename: 'Label', + id: 'gid://gitlab/Label/1', + title: 'Label 1', + description: '', + color: '#f00', + textColor: '#00f', + }, + { + __typename: 'Label', + id: 'gid://gitlab/Label/2', + title: 'Label 2', + description: '', + color: '#b00', + textColor: '#00b', + }, +]; + +export const projectLabelsResponse = { + data: { + workspace: { + id: '1', + __typename: 'Project', + labels: { + nodes: mockLabels, + }, + }, + }, +}; + +export const mockParent = { + parent: { + id: 'gid://gitlab/Issue/1', + iid: '5', + title: 'Parent title', + }, +}; diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js index e89477ed599..fed8be3783a 100644 --- a/spec/frontend/work_items/pages/create_work_item_spec.js +++ b/spec/frontend/work_items/pages/create_work_item_spec.js @@ -9,11 +9,7 @@ import ItemTitle from '~/work_items/components/item_title.vue'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql'; -import { - projectWorkItemTypesQueryResponse, - createWorkItemMutationResponse, - createWorkItemFromTaskMutationResponse, -} from '../mock_data'; +import { projectWorkItemTypesQueryResponse, createWorkItemMutationResponse } from '../mock_data'; jest.mock('~/lib/utils/uuids', () => ({ uuids: () => ['testuuid'] })); @@ -25,9 +21,6 @@ describe('Create work item component', () => { const querySuccessHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse); const createWorkItemSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse); - const createWorkItemFromTaskSuccessHandler = jest - .fn() - .mockResolvedValue(createWorkItemFromTaskMutationResponse); const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); const findAlert = () => wrapper.findComponent(GlAlert); @@ -122,49 +115,6 @@ describe('Create work item component', () => { }); }); - describe('when displayed in a modal', () => { - beforeEach(() => { - createComponent({ - props: { - isModal: true, - }, - mutationHandler: createWorkItemFromTaskSuccessHandler, - }); - }); - - it('emits `closeModal` event on Cancel button click', () => { - findCancelButton().vm.$emit('click'); - - expect(wrapper.emitted('closeModal')).toEqual([[]]); - }); - - it('emits `onCreate` on successful mutation', async () => { - findTitleInput().vm.$emit('title-input', 'Test title'); - - wrapper.find('form').trigger('submit'); - await waitForPromises(); - - expect(wrapper.emitted('onCreate')).toEqual([['<p>New description</p>']]); - }); - - it('does not right margin for create button', () => { - expect(findCreateButton().classes()).not.toContain('gl-mr-3'); - }); - - it('adds right margin for cancel button', () => { - expect(findCancelButton().classes()).toContain('gl-mr-3'); - }); - - it('adds padding for content', () => { - expect(findContent().classes('gl-px-5')).toBe(true); - }); - - it('defaults type to `Task`', async () => { - await waitForPromises(); - expect(findSelect().attributes('value')).toBe('gid://gitlab/WorkItems::Type/3'); - }); - }); - it('displays a loading icon inside dropdown when work items query is loading', () => { createComponent(); diff --git a/spec/frontend/work_items/pages/work_item_detail_spec.js b/spec/frontend/work_items/pages/work_item_detail_spec.js index b9724034cb4..43869468ad0 100644 --- a/spec/frontend/work_items/pages/work_item_detail_spec.js +++ b/spec/frontend/work_items/pages/work_item_detail_spec.js @@ -1,26 +1,36 @@ -import { GlAlert, GlSkeletonLoader } from '@gitlab/ui'; +import { GlAlert, GlSkeletonLoader, GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import WorkItemDescription from '~/work_items/components/work_item_description.vue'; import WorkItemState from '~/work_items/components/work_item_state.vue'; import WorkItemTitle from '~/work_items/components/work_item_title.vue'; import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; +import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; import WorkItemWeight from '~/work_items/components/work_item_weight.vue'; +import WorkItemInformation from '~/work_items/components/work_item_information.vue'; import { i18n } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql'; import { temporaryConfig } from '~/work_items/graphql/provider'; -import { workItemTitleSubscriptionResponse, workItemQueryResponse } from '../mock_data'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { + workItemTitleSubscriptionResponse, + workItemResponseFactory, + mockParent, +} from '../mock_data'; describe('WorkItemDetail component', () => { let wrapper; + useLocalStorageSpy(); Vue.use(VueApollo); + const workItemQueryResponse = workItemResponseFactory(); const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse); const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); @@ -30,9 +40,17 @@ describe('WorkItemDetail component', () => { const findWorkItemState = () => wrapper.findComponent(WorkItemState); const findWorkItemDescription = () => wrapper.findComponent(WorkItemDescription); const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees); + const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels); const findWorkItemWeight = () => wrapper.findComponent(WorkItemWeight); + const findParent = () => wrapper.find('[data-testid="work-item-parent"]'); + const findParentButton = () => findParent().findComponent(GlButton); + const findCloseButton = () => wrapper.find('[data-testid="work-item-close"]'); + const findWorkItemType = () => wrapper.find('[data-testid="work-item-type"]'); + const findWorkItemInformationAlert = () => wrapper.findComponent(WorkItemInformation); + const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); const createComponent = ({ + isModal = false, workItemId = workItemQueryResponse.data.workItem.id, handler = successHandler, subscriptionHandler = initialSubscriptionHandler, @@ -50,7 +68,7 @@ describe('WorkItemDetail component', () => { typePolicies: includeWidgets ? temporaryConfig.cacheConfig.typePolicies : {}, }, ), - propsData: { workItemId }, + propsData: { isModal, workItemId }, provide: { glFeatures: { workItemsMvc2: workItemsMvc2Enabled, @@ -98,6 +116,36 @@ describe('WorkItemDetail component', () => { }); }); + describe('close button', () => { + describe('when isModal prop is false', () => { + it('does not render', async () => { + createComponent({ isModal: false }); + await waitForPromises(); + + expect(findCloseButton().exists()).toBe(false); + }); + }); + + describe('when isModal prop is true', () => { + it('renders', async () => { + createComponent({ isModal: true }); + await waitForPromises(); + + expect(findCloseButton().props('icon')).toBe('close'); + expect(findCloseButton().attributes('aria-label')).toBe('Close'); + }); + + it('emits `close` event when clicked', async () => { + createComponent({ isModal: true }); + await waitForPromises(); + + findCloseButton().vm.$emit('click'); + + expect(wrapper.emitted('close')).toEqual([[]]); + }); + }); + }); + describe('description', () => { it('does not show description widget if loading description fails', () => { createComponent(); @@ -107,13 +155,56 @@ describe('WorkItemDetail component', () => { it('shows description widget if description loads', async () => { createComponent(); - await waitForPromises(); expect(findWorkItemDescription().exists()).toBe(true); }); }); + describe('secondary breadcrumbs', () => { + it('does not show secondary breadcrumbs by default', () => { + createComponent(); + + expect(findParent().exists()).toBe(false); + }); + + it('does not show secondary breadcrumbs if there is not a parent', async () => { + createComponent(); + + await waitForPromises(); + + expect(findParent().exists()).toBe(false); + }); + + it('shows work item type if there is not a parent', async () => { + createComponent(); + + await waitForPromises(); + expect(findWorkItemType().exists()).toBe(true); + }); + + describe('with parent', () => { + beforeEach(() => { + const parentResponse = workItemResponseFactory(mockParent); + createComponent({ handler: jest.fn().mockResolvedValue(parentResponse) }); + + return waitForPromises(); + }); + + it('shows secondary breadcrumbs if there is a parent', () => { + expect(findParent().exists()).toBe(true); + }); + + it('does not show work item type', async () => { + expect(findWorkItemType().exists()).toBe(false); + }); + + it('sets the parent breadcrumb URL', () => { + expect(findParentButton().attributes().href).toBe('../../issues/5'); + }); + }); + }); + it('shows an error message when the work item query was unsuccessful', async () => { const errorHandler = jest.fn().mockRejectedValue('Oops'); createComponent({ handler: errorHandler }); @@ -145,7 +236,6 @@ describe('WorkItemDetail component', () => { it('renders assignees component when assignees widget is returned from the API', async () => { createComponent({ workItemsMvc2Enabled: true, - includeWidgets: true, }); await waitForPromises(); @@ -155,7 +245,9 @@ describe('WorkItemDetail component', () => { it('does not render assignees component when assignees widget is not returned from the API', async () => { createComponent({ workItemsMvc2Enabled: true, - includeWidgets: false, + handler: jest + .fn() + .mockResolvedValue(workItemResponseFactory({ assigneesWidgetPresent: false })), }); await waitForPromises(); @@ -170,6 +262,19 @@ describe('WorkItemDetail component', () => { expect(findWorkItemAssignees().exists()).toBe(false); }); + describe('labels widget', () => { + it.each` + description | includeWidgets | exists + ${'renders when widget is returned from API'} | ${true} | ${true} + ${'does not render when widget is not returned from API'} | ${false} | ${false} + `('$description', async ({ includeWidgets, exists }) => { + createComponent({ includeWidgets, workItemsMvc2Enabled: true }); + await waitForPromises(); + + expect(findWorkItemLabels().exists()).toBe(exists); + }); + }); + describe('weight widget', () => { describe('when work_items_mvc_2 feature flag is enabled', () => { describe.each` @@ -201,4 +306,22 @@ describe('WorkItemDetail component', () => { }); }); }); + + describe('work item information', () => { + beforeEach(() => { + createComponent(); + return waitForPromises(); + }); + + it('is visible when viewed for the first time and sets localStorage value', async () => { + localStorage.clear(); + expect(findWorkItemInformationAlert().exists()).toBe(true); + expect(findLocalStorageSync().props('value')).toBe(true); + }); + + it('is not visible after reading local storage input', async () => { + await findLocalStorageSync().vm.$emit('input', false); + expect(findWorkItemInformationAlert().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js index 3c5da94114e..d9372f2bcf0 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -52,6 +52,7 @@ describe('Work items root component', () => { createComponent(); expect(findWorkItemDetail().props()).toEqual({ + isModal: false, workItemId: 'gid://gitlab/WorkItem/1', workItemParentId: null, }); |