diff options
Diffstat (limited to 'spec/frontend/work_items')
14 files changed, 1090 insertions, 280 deletions
diff --git a/spec/frontend/work_items/components/item_state_spec.js b/spec/frontend/work_items/components/item_state_spec.js index 79b76f3c061..c3cc2fbc556 100644 --- a/spec/frontend/work_items/components/item_state_spec.js +++ b/spec/frontend/work_items/components/item_state_spec.js @@ -1,3 +1,4 @@ +import { GlFormSelect } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { STATE_OPEN, STATE_CLOSED } from '~/work_items/constants'; import ItemState from '~/work_items/components/item_state.vue'; @@ -6,6 +7,7 @@ describe('ItemState', () => { let wrapper; const findLabel = () => wrapper.find('label').text(); + const findFormSelect = () => wrapper.findComponent(GlFormSelect); const selectedValue = () => wrapper.find('option:checked').element.value; const clickOpen = () => wrapper.findAll('option').at(0).setSelected(); @@ -51,4 +53,18 @@ describe('ItemState', () => { expect(wrapper.emitted('changed')).toBeUndefined(); }); + + describe('form select disabled prop', () => { + describe.each` + description | disabled | value + ${'when not disabled'} | ${false} | ${undefined} + ${'when disabled'} | ${true} | ${'disabled'} + `('$description', ({ disabled, value }) => { + it(`renders form select component with disabled=${value}`, () => { + createComponent({ disabled }); + + expect(findFormSelect().attributes('disabled')).toBe(value); + }); + }); + }); }); diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js index a55f448c9a2..de20369eb1b 100644 --- a/spec/frontend/work_items/components/item_title_spec.js +++ b/spec/frontend/work_items/components/item_title_spec.js @@ -37,7 +37,7 @@ describe('ItemTitle', () => { disabled: true, }); - expect(wrapper.classes()).toContain('gl-cursor-not-allowed'); + expect(wrapper.classes()).toContain('gl-cursor-text'); expect(findInputEl().attributes('contenteditable')).toBe('false'); }); diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js index 137a0a7326d..a1f1d47ab90 100644 --- a/spec/frontend/work_items/components/work_item_actions_spec.js +++ b/spec/frontend/work_items/components/work_item_actions_spec.js @@ -1,5 +1,5 @@ -import { GlDropdownItem, GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import WorkItemActions from '~/work_items/components/work_item_actions.vue'; describe('WorkItemActions component', () => { @@ -7,12 +7,19 @@ describe('WorkItemActions component', () => { let glModalDirective; const findModal = () => wrapper.findComponent(GlModal); - const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); + const findConfidentialityToggleButton = () => + wrapper.findByTestId('confidentiality-toggle-action'); + const findDeleteButton = () => wrapper.findByTestId('delete-action'); - const createComponent = ({ canDelete = true } = {}) => { + const createComponent = ({ + canUpdate = true, + canDelete = true, + isConfidential = false, + isParentConfidential = false, + } = {}) => { glModalDirective = jest.fn(); - wrapper = shallowMount(WorkItemActions, { - propsData: { workItemId: '123', canDelete }, + wrapper = shallowMountExtended(WorkItemActions, { + propsData: { workItemId: '123', canUpdate, canDelete, isConfidential, isParentConfidential }, directives: { glModal: { bind(_, { value }) { @@ -34,27 +41,69 @@ describe('WorkItemActions component', () => { expect(findModal().props('visible')).toBe(false); }); - it('shows confirm modal when clicking Delete work item', () => { + it('renders dropdown actions', () => { createComponent(); - findDeleteButton().vm.$emit('click'); - - expect(glModalDirective).toHaveBeenCalled(); + expect(findConfidentialityToggleButton().exists()).toBe(true); + expect(findDeleteButton().exists()).toBe(true); }); - it('emits event when clicking OK button', () => { - createComponent(); + describe('toggle confidentiality action', () => { + it.each` + isConfidential | buttonText + ${true} | ${'Turn off confidentiality'} + ${false} | ${'Turn on confidentiality'} + `( + 'renders confidentiality toggle button with text "$buttonText"', + ({ isConfidential, buttonText }) => { + createComponent({ isConfidential }); + + expect(findConfidentialityToggleButton().text()).toBe(buttonText); + }, + ); + + it('emits `toggleWorkItemConfidentiality` event when clicked', () => { + createComponent(); - findModal().vm.$emit('ok'); + findConfidentialityToggleButton().vm.$emit('click'); - expect(wrapper.emitted('deleteWorkItem')).toEqual([[]]); + expect(wrapper.emitted('toggleWorkItemConfidentiality')[0]).toEqual([true]); + }); + + it.each` + props | propName | value + ${{ isParentConfidential: true }} | ${'isParentConfidential'} | ${true} + ${{ canUpdate: false }} | ${'canUpdate'} | ${false} + `('does not render when $propName is $value', ({ props }) => { + createComponent(props); + + expect(findConfidentialityToggleButton().exists()).toBe(false); + }); }); - it('does not render when canDelete is false', () => { - createComponent({ - canDelete: false, + describe('delete action', () => { + it('shows confirm modal when clicked', () => { + createComponent(); + + findDeleteButton().vm.$emit('click'); + + expect(glModalDirective).toHaveBeenCalled(); + }); + + it('emits event when clicking OK button', () => { + createComponent(); + + findModal().vm.$emit('ok'); + + expect(wrapper.emitted('deleteWorkItem')).toEqual([[]]); }); - expect(wrapper.html()).toBe(''); + it('does not render when canDelete is false', () => { + createComponent({ + canDelete: false, + }); + + expect(wrapper.findByTestId('delete-action').exists()).toBe(false); + }); }); }); 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 299949a4baa..f0ef8aee7a9 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -5,14 +5,15 @@ 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 InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; import { i18n, TASK_TYPE_NAME, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; -import { temporaryConfig, resolvers } from '~/work_items/graphql/provider'; +import { temporaryConfig } from '~/work_items/graphql/provider'; import { projectMembersResponseWithCurrentUser, mockAssignees, @@ -20,6 +21,7 @@ import { currentUserResponse, currentUserNullResponse, projectMembersResponseWithoutCurrentUser, + updateWorkItemMutationResponse, } from '../mock_data'; Vue.use(VueApollo); @@ -33,6 +35,7 @@ describe('WorkItemAssignees component', () => { 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'); @@ -43,6 +46,9 @@ describe('WorkItemAssignees component', () => { .mockResolvedValue(projectMembersResponseWithCurrentUser); const successCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserResponse); const noCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserNullResponse); + const successUpdateWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(updateWorkItemMutationResponse); const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); @@ -50,15 +56,18 @@ describe('WorkItemAssignees component', () => { assignees = mockAssignees, searchQueryHandler = successSearchQueryHandler, currentUserQueryHandler = successCurrentUserQueryHandler, + updateWorkItemMutationHandler = successUpdateWorkItemMutationHandler, allowsMultipleAssignees = true, + canInviteMembers = false, canUpdate = true, } = {}) => { const apolloProvider = createMockApollo( [ [userSearchQuery, searchQueryHandler], [currentUserQuery, currentUserQueryHandler], + [updateWorkItemMutation, updateWorkItemMutationHandler], ], - resolvers, + {}, { typePolicies: temporaryConfig.cacheConfig.typePolicies, }, @@ -82,6 +91,7 @@ describe('WorkItemAssignees component', () => { allowsMultipleAssignees, workItemType: TASK_TYPE_NAME, canUpdate, + canInviteMembers, }, attachTo: document.body, apolloProvider, @@ -120,15 +130,6 @@ describe('WorkItemAssignees component', () => { 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(); @@ -141,6 +142,36 @@ describe('WorkItemAssignees component', () => { expect(findTokenSelector().props('viewOnly')).toBe(true); }); + 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(); @@ -204,7 +235,7 @@ describe('WorkItemAssignees component', () => { expect(findTokenSelector().props('dropdownItems')).toHaveLength(2); }); - it('should search for users with correct key after text input', async () => { + it('searches for users with correct key after text input', async () => { const searchKey = 'Hello'; findTokenSelector().vm.$emit('focus'); @@ -225,6 +256,18 @@ describe('WorkItemAssignees component', () => { 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 myself` button if current user is loading', () => { createComponent(); @@ -261,23 +304,21 @@ describe('WorkItemAssignees component', () => { 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()); - + 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(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith( - expect.objectContaining({ - variables: { - input: { - assignees: [stripTypenames(currentUserResponse.data.currentUser)], - id: workItemId, - }, + expect(findTokenSelector().props('selectedTokens')).toMatchObject([currentUser]); + expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + id: workItemId, + assigneesWidget: { + assigneeIds: [currentUser.id], }, - }), - ); + }, + }); }); }); @@ -286,9 +327,7 @@ describe('WorkItemAssignees component', () => { await waitForPromises(); expect(findTokenSelector().props('dropdownItems')[0]).toEqual( - expect.objectContaining({ - ...stripTypenames(currentUserResponse.data.currentUser), - }), + expect.objectContaining(currentUserResponse.data.currentUser), ); }); @@ -303,9 +342,10 @@ describe('WorkItemAssignees component', () => { }); it('adds current user to the top of dropdown items', () => { - expect(findTokenSelector().props('dropdownItems')[0]).toEqual( - stripTypenames(currentUserResponse.data.currentUser), - ); + expect(findTokenSelector().props('dropdownItems')[0]).toEqual({ + ...currentUserResponse.data.currentUser, + class: expect.anything(), + }); }); it('does not add current user if search is not empty', async () => { @@ -313,7 +353,7 @@ describe('WorkItemAssignees component', () => { await waitForPromises(); expect(findTokenSelector().props('dropdownItems')[0]).not.toEqual( - stripTypenames(currentUserResponse.data.currentUser), + currentUserResponse.data.currentUser, ); }); }); @@ -405,4 +445,18 @@ describe('WorkItemAssignees component', () => { }); }); }); + + 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); + }); + }); }); 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 70b1261bdb7..01891012f99 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 @@ -7,6 +7,13 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import deleteWorkItemFromTaskMutation from '~/work_items/graphql/delete_task_from_work_item.mutation.graphql'; +import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql'; +import { + deleteWorkItemFromTaskMutationErrorResponse, + deleteWorkItemFromTaskMutationResponse, + deleteWorkItemMutationErrorResponse, + deleteWorkItemResponse, +} from '../mock_data'; describe('WorkItemDetailModal component', () => { let wrapper; @@ -25,28 +32,38 @@ describe('WorkItemDetailModal component', () => { }, }; + const defaultPropsData = { + issueGid: 'gid://gitlab/WorkItem/1', + workItemId: 'gid://gitlab/WorkItem/2', + }; + const findModal = () => wrapper.findComponent(GlModal); const findAlert = () => wrapper.findComponent(GlAlert); const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail); - const createComponent = ({ workItemId = '1', issueGid = '2', error = false } = {}) => { + const createComponent = ({ + lockVersion, + lineNumberStart, + lineNumberEnd, + error = false, + deleteWorkItemFromTaskMutationHandler = jest + .fn() + .mockResolvedValue(deleteWorkItemFromTaskMutationResponse), + deleteWorkItemMutationHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse), + } = {}) => { const apolloProvider = createMockApollo([ - [ - deleteWorkItemFromTaskMutation, - jest.fn().mockResolvedValue({ - data: { - workItemDeleteTask: { - workItem: { id: 123, descriptionHtml: 'updated work item desc' }, - errors: [], - }, - }, - }), - ], + [deleteWorkItemFromTaskMutation, deleteWorkItemFromTaskMutationHandler], + [deleteWorkItemMutation, deleteWorkItemMutationHandler], ]); wrapper = shallowMount(WorkItemDetailModal, { apolloProvider, - propsData: { workItemId, issueGid }, + propsData: { + ...defaultPropsData, + lockVersion, + lineNumberStart, + lineNumberEnd, + }, data() { return { error, @@ -67,8 +84,8 @@ describe('WorkItemDetailModal component', () => { expect(findWorkItemDetail().props()).toEqual({ isModal: true, - workItemId: '1', - workItemParentId: '2', + workItemId: defaultPropsData.workItemId, + workItemParentId: defaultPropsData.issueGid, }); }); @@ -109,16 +126,85 @@ describe('WorkItemDetailModal component', () => { }); describe('delete work item', () => { - it('emits workItemDeleted and closes modal', async () => { - createComponent(); - const newDesc = 'updated work item desc'; - - findWorkItemDetail().vm.$emit('deleteWorkItem'); - - await waitForPromises(); + describe('when there is task data', () => { + it('emits workItemDeleted and closes modal', async () => { + const mutationMock = jest.fn().mockResolvedValue(deleteWorkItemFromTaskMutationResponse); + createComponent({ + lockVersion: 1, + lineNumberStart: '3', + lineNumberEnd: '3', + deleteWorkItemFromTaskMutationHandler: mutationMock, + }); + const newDesc = 'updated work item desc'; + + findWorkItemDetail().vm.$emit('deleteWorkItem'); + await waitForPromises(); + + expect(wrapper.emitted('workItemDeleted')).toEqual([[newDesc]]); + expect(hideModal).toHaveBeenCalled(); + expect(mutationMock).toHaveBeenCalledWith({ + input: { + id: defaultPropsData.issueGid, + lockVersion: 1, + taskData: { id: defaultPropsData.workItemId, lineNumberEnd: 3, lineNumberStart: 3 }, + }, + }); + }); + + it.each` + errorType | mutationMock | errorMessage + ${'an error in the mutation response'} | ${jest.fn().mockResolvedValue(deleteWorkItemFromTaskMutationErrorResponse)} | ${'Error'} + ${'a network error'} | ${jest.fn().mockRejectedValue(new Error('GraphQL networkError'))} | ${'GraphQL networkError'} + `( + 'shows an error message when there is $errorType', + async ({ mutationMock, errorMessage }) => { + createComponent({ + lockVersion: 1, + lineNumberStart: '3', + lineNumberEnd: '3', + deleteWorkItemFromTaskMutationHandler: mutationMock, + }); + + findWorkItemDetail().vm.$emit('deleteWorkItem'); + await waitForPromises(); + + expect(wrapper.emitted('workItemDeleted')).toBeUndefined(); + expect(hideModal).not.toHaveBeenCalled(); + expect(findAlert().text()).toBe(errorMessage); + }, + ); + }); - expect(wrapper.emitted('workItemDeleted')).toEqual([[newDesc]]); - expect(hideModal).toHaveBeenCalled(); + describe('when there is no task data', () => { + it('emits workItemDeleted and closes modal', async () => { + const mutationMock = jest.fn().mockResolvedValue(deleteWorkItemResponse); + createComponent({ deleteWorkItemMutationHandler: mutationMock }); + + findWorkItemDetail().vm.$emit('deleteWorkItem'); + await waitForPromises(); + + expect(wrapper.emitted('workItemDeleted')).toEqual([[defaultPropsData.workItemId]]); + expect(hideModal).toHaveBeenCalled(); + expect(mutationMock).toHaveBeenCalledWith({ input: { id: defaultPropsData.workItemId } }); + }); + + it.each` + errorType | mutationMock | errorMessage + ${'an error in the mutation response'} | ${jest.fn().mockResolvedValue(deleteWorkItemMutationErrorResponse)} | ${'Error'} + ${'a network error'} | ${jest.fn().mockRejectedValue(new Error('GraphQL networkError'))} | ${'GraphQL networkError'} + `( + 'shows an error message when there is $errorType', + async ({ mutationMock, errorMessage }) => { + createComponent({ deleteWorkItemMutationHandler: mutationMock }); + + findWorkItemDetail().vm.$emit('deleteWorkItem'); + await waitForPromises(); + + expect(wrapper.emitted('workItemDeleted')).toBeUndefined(); + expect(hideModal).not.toHaveBeenCalled(); + expect(findAlert().text()).toBe(errorMessage); + }, + ); }); }); }); 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 index 93bf7286aa7..434c1db8a2c 100644 --- 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 @@ -1,13 +1,20 @@ import Vue from 'vue'; -import { GlForm, GlFormCombobox } from '@gitlab/ui'; +import { GlForm, GlFormInput, 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 projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; +import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; -import { availableWorkItemsResponse, updateWorkItemMutationResponse } from '../../mock_data'; +import { + availableWorkItemsResponse, + projectWorkItemTypesQueryResponse, + createWorkItemMutationResponse, + updateWorkItemMutationResponse, +} from '../../mock_data'; Vue.use(VueApollo); @@ -15,14 +22,21 @@ describe('WorkItemLinksForm', () => { let wrapper; const updateMutationResolver = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); + const createMutationResolver = jest.fn().mockResolvedValue(createWorkItemMutationResponse); - const createComponent = async ({ listResponse = availableWorkItemsResponse } = {}) => { + const createComponent = async ({ + listResponse = availableWorkItemsResponse, + typesResponse = projectWorkItemTypesQueryResponse, + parentConfidential = false, + } = {}) => { wrapper = shallowMountExtended(WorkItemLinksForm, { apolloProvider: createMockApollo([ [projectWorkItemsQuery, jest.fn().mockResolvedValue(listResponse)], + [projectWorkItemTypesQuery, jest.fn().mockResolvedValue(typesResponse)], [updateWorkItemMutation, updateMutationResolver], + [createWorkItemMutation, createMutationResolver], ]), - propsData: { issuableGid: 'gid://gitlab/WorkItem/1' }, + propsData: { issuableGid: 'gid://gitlab/WorkItem/1', parentConfidential }, provide: { projectPath: 'project/path', }, @@ -33,6 +47,7 @@ describe('WorkItemLinksForm', () => { const findForm = () => wrapper.findComponent(GlForm); const findCombobox = () => wrapper.findComponent(GlFormCombobox); + const findInput = () => wrapper.findComponent(GlFormInput); const findAddChildButton = () => wrapper.findByTestId('add-child-button'); beforeEach(async () => { @@ -47,19 +62,73 @@ describe('WorkItemLinksForm', () => { expect(findForm().exists()).toBe(true); }); - it('passes available work items as prop when typing in combobox', async () => { - findCombobox().vm.$emit('input', 'Task'); + it('creates child task in non confidential parent', async () => { + findInput().vm.$emit('input', 'Create task test'); + + findForm().vm.$emit('submit', { + preventDefault: jest.fn(), + }); await waitForPromises(); + expect(createMutationResolver).toHaveBeenCalledWith({ + input: { + title: 'Create task test', + projectPath: 'project/path', + workItemTypeId: 'gid://gitlab/WorkItems::Type/3', + hierarchyWidget: { + parentId: 'gid://gitlab/WorkItem/1', + }, + confidential: false, + }, + }); + }); + + it('creates child task in confidential parent', async () => { + await createComponent({ parentConfidential: true }); + + findInput().vm.$emit('input', 'Create confidential task'); - expect(findCombobox().exists()).toBe(true); - expect(findCombobox().props('tokenList').length).toBe(2); + findForm().vm.$emit('submit', { + preventDefault: jest.fn(), + }); + await waitForPromises(); + expect(createMutationResolver).toHaveBeenCalledWith({ + input: { + title: 'Create confidential task', + projectPath: 'project/path', + workItemTypeId: 'gid://gitlab/WorkItems::Type/3', + hierarchyWidget: { + parentId: 'gid://gitlab/WorkItem/1', + }, + confidential: true, + }, + }); }); - it('selects and add child', async () => { + // Follow up issue to turn this functionality back on https://gitlab.com/gitlab-org/gitlab/-/issues/368757 + // eslint-disable-next-line jest/no-disabled-tests + it.skip('selects and add child', async () => { findCombobox().vm.$emit('input', availableWorkItemsResponse.data.workspace.workItems.edges[0]); findAddChildButton().vm.$emit('click'); await waitForPromises(); expect(updateMutationResolver).toHaveBeenCalled(); }); + + // eslint-disable-next-line jest/no-disabled-tests + describe.skip('when typing in combobox', () => { + beforeEach(async () => { + findCombobox().vm.$emit('input', 'Task'); + await waitForPromises(); + await jest.runOnlyPendingTimers(); + }); + + it('passes available work items as prop', () => { + expect(findCombobox().exists()).toBe(true); + expect(findCombobox().props('tokenList').length).toBe(2); + }); + + it('passes action to create task', () => { + expect(findCombobox().props('actionList').length).toBe(1); + }); + }); }); 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 index f8471b7f167..287ec022d3f 100644 --- 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 @@ -1,75 +1,24 @@ -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 createComponent = () => { + wrapper = shallowMountExtended(WorkItemLinksMenu); }; const findDropdown = () => wrapper.find(GlDropdown); const findRemoveDropdownItem = () => wrapper.find(GlDropdownItem); beforeEach(async () => { - await createComponent(); + createComponent(); }); afterEach(() => { wrapper.destroy(); - mockApollo = null; }); it('renders dropdown and dropdown items', () => { @@ -77,65 +26,9 @@ describe('WorkItemLinksMenu', () => { 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(); - + it('emits removeChild event on click Remove', () => { 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, - }), - ); + expect(wrapper.emitted('removeChild')).toHaveLength(1); }); }); 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 2ec9b1ec0ac..00f508f1548 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 @@ -1,34 +1,85 @@ import Vue, { nextTick } from 'vue'; -import { GlBadge } from '@gitlab/ui'; +import { GlButton, GlIcon, GlAlert } 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 SidebarEventHub from '~/sidebar/event_hub'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import WorkItemLinks from '~/work_items/components/work_item_links/work_item_links.vue'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import changeWorkItemParentMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql'; -import { workItemHierarchyResponse, workItemHierarchyEmptyResponse } from '../../mock_data'; +import { + workItemHierarchyResponse, + workItemHierarchyEmptyResponse, + workItemHierarchyNoUpdatePermissionResponse, + changeWorkItemParentMutationResponse, + workItemQueryResponse, +} from '../../mock_data'; Vue.use(VueApollo); describe('WorkItemLinks', () => { let wrapper; + let mockApollo; + + const WORK_ITEM_ID = 'gid://gitlab/WorkItem/2'; + + const $toast = { + show: jest.fn(), + }; + + const mutationChangeParentHandler = jest + .fn() + .mockResolvedValue(changeWorkItemParentMutationResponse); + + const childWorkItemQueryHandler = jest.fn().mockResolvedValue(workItemQueryResponse); + + const findChildren = () => wrapper.findAll('[data-testid="links-child"]'); + + const createComponent = async ({ + data = {}, + fetchHandler = jest.fn().mockResolvedValue(workItemHierarchyResponse), + mutationHandler = mutationChangeParentHandler, + } = {}) => { + mockApollo = createMockApollo( + [ + [getWorkItemLinksQuery, fetchHandler], + [changeWorkItemParentMutation, mutationHandler], + [workItemQuery, childWorkItemQueryHandler], + ], + {}, + { addTypename: true }, + ); - const createComponent = async ({ response = workItemHierarchyResponse } = {}) => { wrapper = shallowMountExtended(WorkItemLinks, { - apolloProvider: createMockApollo([ - [getWorkItemLinksQuery, jest.fn().mockResolvedValue(response)], - ]), + data() { + return { + ...data, + }; + }, + provide: { + projectPath: 'project/path', + }, propsData: { issuableId: 1 }, + apolloProvider: mockApollo, + mocks: { + $toast, + }, }); await waitForPromises(); }; + const findAlert = () => wrapper.findComponent(GlAlert); const findToggleButton = () => wrapper.findByTestId('toggle-links'); const findLinksBody = () => wrapper.findByTestId('links-body'); const findEmptyState = () => wrapper.findByTestId('links-empty'); const findToggleAddFormButton = () => wrapper.findByTestId('toggle-add-form'); const findAddLinksForm = () => wrapper.findByTestId('add-links-form'); + const findFirstLinksMenu = () => wrapper.findByTestId('links-menu'); + const findChildrenCount = () => wrapper.findByTestId('children-count'); beforeEach(async () => { await createComponent(); @@ -36,6 +87,7 @@ describe('WorkItemLinks', () => { afterEach(() => { wrapper.destroy(); + mockApollo = null; }); it('is expanded by default', () => { @@ -43,7 +95,7 @@ describe('WorkItemLinks', () => { expect(findLinksBody().exists()).toBe(true); }); - it('expands on click toggle button', async () => { + it('collapses on click toggle button', async () => { findToggleButton().vm.$emit('click'); await nextTick(); @@ -67,7 +119,9 @@ describe('WorkItemLinks', () => { describe('when no child links', () => { beforeEach(async () => { - await createComponent({ response: workItemHierarchyEmptyResponse }); + await createComponent({ + fetchHandler: jest.fn().mockResolvedValue(workItemHierarchyEmptyResponse), + }); }); it('displays empty state if there are no children', () => { @@ -78,9 +132,140 @@ describe('WorkItemLinks', () => { it('renders all hierarchy widget children', () => { expect(findLinksBody().exists()).toBe(true); + expect(findChildren()).toHaveLength(4); + expect(findFirstLinksMenu().exists()).toBe(true); + }); + + it('shows alert when list loading fails', async () => { + const errorMessage = 'Some error'; + await createComponent({ + fetchHandler: jest.fn().mockRejectedValue(new Error(errorMessage)), + }); + + await nextTick(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(errorMessage); + }); + + it('renders widget child icon and tooltip', () => { + expect(findChildren().at(0).findComponent(GlIcon).props('name')).toBe('issue-open-m'); + expect(findChildren().at(1).findComponent(GlIcon).props('name')).toBe('issue-close'); + }); + + it('renders confidentiality icon when child item is confidential', () => { const children = wrapper.findAll('[data-testid="links-child"]'); + const confidentialIcon = children.at(0).find('[data-testid="confidential-icon"]'); + + expect(confidentialIcon.exists()).toBe(true); + expect(confidentialIcon.props('name')).toBe('eye-slash'); + }); + + it('displays number if children', () => { + expect(findChildrenCount().exists()).toBe(true); + + expect(findChildrenCount().text()).toContain('4'); + }); + + it('refetches child items when `confidentialityUpdated` event is emitted on SidebarEventhub', async () => { + const fetchHandler = jest.fn().mockResolvedValue(workItemHierarchyResponse); + await createComponent({ + fetchHandler, + }); + await waitForPromises(); + + SidebarEventHub.$emit('confidentialityUpdated'); + await nextTick(); + + // First call is done on component mount. + // Second call is done on confidentialityUpdated event. + expect(fetchHandler).toHaveBeenCalledTimes(2); + }); + + describe('when no permission to update', () => { + beforeEach(async () => { + await createComponent({ + fetchHandler: jest.fn().mockResolvedValue(workItemHierarchyNoUpdatePermissionResponse), + }); + }); - expect(children).toHaveLength(4); - expect(children.at(0).findComponent(GlBadge).text()).toBe('Open'); + it('does not display button to toggle Add form', () => { + expect(findToggleAddFormButton().exists()).toBe(false); + }); + + it('does not display link menu on children', () => { + expect(findFirstLinksMenu().exists()).toBe(false); + }); + }); + + describe('remove child', () => { + beforeEach(async () => { + await createComponent({ mutationHandler: mutationChangeParentHandler }); + }); + + it('calls correct mutation with correct variables', async () => { + findFirstLinksMenu().vm.$emit('removeChild'); + + await waitForPromises(); + + expect(mutationChangeParentHandler).toHaveBeenCalledWith({ + input: { + id: WORK_ITEM_ID, + hierarchyWidget: { + parentId: null, + }, + }, + }); + }); + + it('shows toast when mutation succeeds', async () => { + findFirstLinksMenu().vm.$emit('removeChild'); + + await waitForPromises(); + + expect($toast.show).toHaveBeenCalledWith('Child removed', { + action: { onClick: expect.anything(), text: 'Undo' }, + }); + }); + + it('renders correct number of children after removal', async () => { + expect(findChildren()).toHaveLength(4); + + findFirstLinksMenu().vm.$emit('removeChild'); + await waitForPromises(); + + expect(findChildren()).toHaveLength(3); + }); + }); + + describe('prefetching child items', () => { + beforeEach(async () => { + await createComponent(); + }); + + const findChildLink = () => findChildren().at(0).findComponent(GlButton); + + it('does not fetch the child work item before hovering work item links', () => { + expect(childWorkItemQueryHandler).not.toHaveBeenCalled(); + }); + + it('fetches the child work item if link is hovered for 250+ ms', async () => { + findChildLink().vm.$emit('mouseover'); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await waitForPromises(); + + expect(childWorkItemQueryHandler).toHaveBeenCalledWith({ + id: 'gid://gitlab/WorkItem/2', + }); + }); + + it('does not fetch the child work item if link is hovered for less than 250 ms', async () => { + findChildLink().vm.$emit('mouseover'); + jest.advanceTimersByTime(200); + findChildLink().vm.$emit('mouseout'); + await waitForPromises(); + + expect(childWorkItemQueryHandler).not.toHaveBeenCalled(); + }); }); }); diff --git a/spec/frontend/work_items/components/work_item_state_spec.js b/spec/frontend/work_items/components/work_item_state_spec.js index b379d1fc846..6b23a6e4795 100644 --- a/spec/frontend/work_items/components/work_item_state_spec.js +++ b/spec/frontend/work_items/components/work_item_state_spec.js @@ -29,6 +29,7 @@ describe('WorkItemState component', () => { const createComponent = ({ state = STATE_OPEN, mutationHandler = mutationSuccessHandler, + canUpdate = true, } = {}) => { const { id, workItemType } = workItemQueryResponse.data.workItem; wrapper = shallowMount(WorkItemState, { @@ -39,6 +40,7 @@ describe('WorkItemState component', () => { state, workItemType, }, + canUpdate, }, }); }; @@ -53,6 +55,20 @@ describe('WorkItemState component', () => { expect(findItemState().props('state')).toBe(workItemQueryResponse.data.workItem.state); }); + describe('item state disabled prop', () => { + describe.each` + description | canUpdate | value + ${'when cannot update'} | ${false} | ${true} + ${'when can update'} | ${true} | ${false} + `('$description', ({ canUpdate, value }) => { + it(`renders item state component with disabled=${value}`, () => { + createComponent({ canUpdate }); + + expect(findItemState().props('disabled')).toBe(value); + }); + }); + }); + describe('when updating the state', () => { it('calls a mutation', () => { createComponent(); diff --git a/spec/frontend/work_items/components/work_item_title_spec.js b/spec/frontend/work_items/components/work_item_title_spec.js index a48449bb636..c0d966abab8 100644 --- a/spec/frontend/work_items/components/work_item_title_spec.js +++ b/spec/frontend/work_items/components/work_item_title_spec.js @@ -20,7 +20,11 @@ describe('WorkItemTitle component', () => { const findItemTitle = () => wrapper.findComponent(ItemTitle); - const createComponent = ({ workItemParentId, mutationHandler = mutationSuccessHandler } = {}) => { + const createComponent = ({ + workItemParentId, + mutationHandler = mutationSuccessHandler, + canUpdate = true, + } = {}) => { const { id, title, workItemType } = workItemQueryResponse.data.workItem; wrapper = shallowMount(WorkItemTitle, { apolloProvider: createMockApollo([ @@ -32,6 +36,7 @@ describe('WorkItemTitle component', () => { workItemTitle: title, workItemType: workItemType.name, workItemParentId, + canUpdate, }, }); }; @@ -46,6 +51,20 @@ describe('WorkItemTitle component', () => { expect(findItemTitle().props('title')).toBe(workItemQueryResponse.data.workItem.title); }); + describe('item title disabled prop', () => { + describe.each` + description | canUpdate | value + ${'when cannot update'} | ${false} | ${true} + ${'when can update'} | ${true} | ${false} + `('$description', ({ canUpdate, value }) => { + it(`renders item title component with disabled=${value}`, () => { + createComponent({ canUpdate }); + + expect(findItemTitle().props('disabled')).toBe(value); + }); + }); + }); + describe('when updating the title', () => { it('calls a mutation', () => { const title = 'new title!'; diff --git a/spec/frontend/work_items/components/work_item_type_icon_spec.js b/spec/frontend/work_items/components/work_item_type_icon_spec.js new file mode 100644 index 00000000000..85466578e18 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_type_icon_spec.js @@ -0,0 +1,47 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; + +let wrapper; + +function createComponent(propsData) { + wrapper = shallowMount(WorkItemTypeIcon, { propsData }); +} + +describe('Work Item type component', () => { + const findIcon = () => wrapper.findComponent(GlIcon); + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + workItemType | workItemIconName | iconName | text + ${'TASK'} | ${''} | ${'issue-type-task'} | ${'Task'} + ${''} | ${'issue-type-task'} | ${'issue-type-task'} | ${''} + ${'ISSUE'} | ${''} | ${'issue-type-issue'} | ${'Issue'} + ${''} | ${'issue-type-issue'} | ${'issue-type-issue'} | ${''} + ${'REQUIREMENTS'} | ${''} | ${'issue-type-requirements'} | ${'Requirements'} + ${'INCIDENT'} | ${''} | ${'issue-type-incident'} | ${'Incident'} + ${'TEST_CASE'} | ${''} | ${'issue-type-test-case'} | ${'Test case'} + ${'random-issue-type'} | ${''} | ${'issue-type-issue'} | ${''} + `( + 'with workItemType set to "$workItemType" and workItemIconName set to "$workItemIconName"', + ({ workItemType, workItemIconName, iconName, text }) => { + beforeEach(() => { + createComponent({ + workItemType, + workItemIconName, + }); + }); + + it(`renders icon with name '${iconName}'`, () => { + expect(findIcon().props('name')).toBe(iconName); + }); + + it(`renders correct text`, () => { + expect(wrapper.text()).toBe(text); + }); + }, + ); +}); 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 c3bbea26cda..94bdb336deb 100644 --- a/spec/frontend/work_items/components/work_item_weight_spec.js +++ b/spec/frontend/work_items/components/work_item_weight_spec.js @@ -1,16 +1,21 @@ import { GlForm, GlFormInput } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { mockTracking } from 'helpers/tracking_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; 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'; +import { i18n, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { updateWorkItemMutationResponse } from 'jest/work_items/mock_data'; describe('WorkItemWeight component', () => { + Vue.use(VueApollo); + let wrapper; - const mutateSpy = jest.fn(); const workItemId = 'gid://gitlab/WorkItem/1'; const workItemType = 'Task'; @@ -22,8 +27,10 @@ describe('WorkItemWeight component', () => { hasIssueWeightsFeature = true, isEditing = false, weight, + mutationHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse), } = {}) => { wrapper = mountExtended(WorkItemWeight, { + apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), propsData: { canUpdate, weight, @@ -33,11 +40,6 @@ describe('WorkItemWeight component', () => { provide: { hasIssueWeightsFeature, }, - mocks: { - $apollo: { - mutate: mutateSpy, - }, - }, }); if (isEditing) { @@ -131,26 +133,73 @@ describe('WorkItemWeight component', () => { }); describe('when blurred', () => { - it('calls a mutation to update the weight', () => { - const weight = 0; - createComponent({ isEditing: true, weight }); + it('calls a mutation to update the weight when the input value is different', () => { + const mutationSpy = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); + createComponent({ + isEditing: true, + weight: 0, + mutationHandler: mutationSpy, + canUpdate: true, + }); + + findInput().vm.$emit('blur', { target: { value: 1 } }); + + expect(mutationSpy).toHaveBeenCalledWith({ + input: { + id: workItemId, + weightWidget: { + weight: 1, + }, + }, + }); + }); + + it('does not call a mutation to update the weight when the input value is the same', () => { + const mutationSpy = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); + createComponent({ isEditing: true, mutationHandler: mutationSpy, canUpdate: true }); findInput().trigger('blur'); - expect(mutateSpy).toHaveBeenCalledWith({ - mutation: localUpdateWorkItemMutation, - variables: { - input: { - id: workItemId, - weight, + expect(mutationSpy).not.toHaveBeenCalledWith(); + }); + + it('emits an error when there is a GraphQL error', async () => { + const response = { + data: { + workItemUpdate: { + errors: ['Error!'], + workItem: {}, }, }, + }; + createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockResolvedValue(response), + canUpdate: true, + }); + + findInput().trigger('blur'); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]); + }); + + it('emits an error when there is a network error', async () => { + createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockRejectedValue(new Error()), + canUpdate: true, }); + + findInput().trigger('blur'); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]); }); it('tracks updating the weight', () => { const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - createComponent(); + createComponent({ canUpdate: true }); findInput().trigger('blur'); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 0359caf7116..d24ac2a9f93 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -25,10 +25,14 @@ export const workItemQueryResponse = { title: 'Test', state: 'OPEN', description: 'description', + confidential: false, + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', name: 'Task', + iconName: 'issue-type-task', }, userPermissions: { deleteWorkItem: false, @@ -46,6 +50,7 @@ export const workItemQueryResponse = { __typename: 'WorkItemWidgetAssignees', type: 'ASSIGNEES', allowsMultipleAssignees: true, + canInviteMembers: true, assignees: { nodes: mockAssignees, }, @@ -57,13 +62,14 @@ export const workItemQueryResponse = { id: 'gid://gitlab/Issue/1', iid: '5', title: 'Parent title', + confidential: false, }, children: { - edges: [ + nodes: [ { - node: { - id: 'gid://gitlab/WorkItem/444', - }, + id: 'gid://gitlab/WorkItem/444', + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, }, ], }, @@ -77,16 +83,21 @@ export const updateWorkItemMutationResponse = { data: { workItemUpdate: { __typename: 'WorkItemUpdatePayload', + errors: [], workItem: { __typename: 'WorkItem', id: 'gid://gitlab/WorkItem/1', title: 'Updated title', state: 'OPEN', description: 'description', + confidential: false, + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', name: 'Task', + iconName: 'issue-type-task', }, userPermissions: { deleteWorkItem: false, @@ -95,24 +106,46 @@ export const updateWorkItemMutationResponse = { widgets: [ { children: { - edges: [ + nodes: [ { - node: 'gid://gitlab/WorkItem/444', + id: 'gid://gitlab/WorkItem/444', }, ], }, }, + { + __typename: 'WorkItemWidgetAssignees', + type: 'ASSIGNEES', + allowsMultipleAssignees: true, + canInviteMembers: true, + assignees: { + nodes: [mockAssignees[0]], + }, + }, ], }, }, }, }; +export const mockParent = { + parent: { + id: 'gid://gitlab/Issue/1', + iid: '5', + title: 'Parent title', + confidential: false, + }, +}; + export const workItemResponseFactory = ({ canUpdate = false, + canDelete = false, allowsMultipleAssignees = true, assigneesWidgetPresent = true, - parent = null, + weightWidgetPresent = true, + confidential = false, + canInviteMembers = false, + parent = mockParent.parent, } = {}) => ({ data: { workItem: { @@ -121,13 +154,17 @@ export const workItemResponseFactory = ({ title: 'Updated title', state: 'OPEN', description: 'description', + confidential, + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', name: 'Task', + iconName: 'issue-type-task', }, userPermissions: { - deleteWorkItem: false, + deleteWorkItem: canDelete, updateWorkItem: canUpdate, }, widgets: [ @@ -143,20 +180,28 @@ export const workItemResponseFactory = ({ __typename: 'WorkItemWidgetAssignees', type: 'ASSIGNEES', allowsMultipleAssignees, + canInviteMembers, assignees: { nodes: mockAssignees, }, } : { type: 'MOCK TYPE' }, + weightWidgetPresent + ? { + __typename: 'WorkItemWidgetWeight', + type: 'WEIGHT', + weight: 0, + } + : { type: 'MOCK TYPE' }, { __typename: 'WorkItemWidgetHierarchy', type: 'HIERARCHY', children: { - edges: [ + nodes: [ { - node: { - id: 'gid://gitlab/WorkItem/444', - }, + id: 'gid://gitlab/WorkItem/444', + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, }, ], }, @@ -203,10 +248,14 @@ export const createWorkItemMutationResponse = { title: 'Updated title', state: 'OPEN', description: 'description', + confidential: false, + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', name: 'Task', + iconName: 'issue-type-task', }, userPermissions: { deleteWorkItem: false, @@ -214,6 +263,7 @@ export const createWorkItemMutationResponse = { }, widgets: [], }, + errors: [], }, }, }; @@ -229,10 +279,14 @@ export const createWorkItemFromTaskMutationResponse = { id: 'gid://gitlab/WorkItem/1', title: 'Updated title', state: 'OPEN', + confidential: false, + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', name: 'Task', + iconName: 'issue-type-task', }, userPermissions: { deleteWorkItem: false, @@ -252,11 +306,15 @@ export const createWorkItemFromTaskMutationResponse = { id: 'gid://gitlab/WorkItem/1000000', title: 'Updated title', state: 'OPEN', + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, description: '', + confidential: false, workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', name: 'Task', + iconName: 'issue-type-task', }, userPermissions: { deleteWorkItem: false, @@ -284,6 +342,32 @@ export const deleteWorkItemFailureResponse = { ], }; +export const deleteWorkItemMutationErrorResponse = { + data: { + workItemDelete: { + errors: ['Error'], + }, + }, +}; + +export const deleteWorkItemFromTaskMutationResponse = { + data: { + workItemDeleteTask: { + workItem: { id: 123, descriptionHtml: 'updated work item desc' }, + errors: [], + }, + }, +}; + +export const deleteWorkItemFromTaskMutationErrorResponse = { + data: { + workItemDeleteTask: { + workItem: { id: 123, descriptionHtml: 'updated work item desc' }, + errors: ['Error'], + }, + }, +}; + export const workItemTitleSubscriptionResponse = { data: { issuableTitleUpdated: { @@ -302,6 +386,13 @@ export const workItemHierarchyEmptyResponse = { __typename: 'WorkItemType', }, title: 'New title', + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, + userPermissions: { + deleteWorkItem: false, + updateWorkItem: false, + }, + confidential: false, widgets: [ { type: 'DESCRIPTION', @@ -322,6 +413,54 @@ export const workItemHierarchyEmptyResponse = { }, }; +export const workItemHierarchyNoUpdatePermissionResponse = { + data: { + workItem: { + id: 'gid://gitlab/WorkItem/1', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/6', + __typename: 'WorkItemType', + }, + title: 'New title', + userPermissions: { + deleteWorkItem: false, + updateWorkItem: false, + }, + confidential: false, + widgets: [ + { + type: 'DESCRIPTION', + __typename: 'WorkItemWidgetDescription', + }, + { + type: 'HIERARCHY', + parent: null, + children: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/2', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/5', + __typename: 'WorkItemType', + }, + title: 'xyz', + state: 'OPEN', + confidential: false, + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, + __typename: 'WorkItem', + }, + ], + __typename: 'WorkItemConnection', + }, + __typename: 'WorkItemWidgetHierarchy', + }, + ], + __typename: 'WorkItem', + }, + }, +}; + export const workItemHierarchyResponse = { data: { workItem: { @@ -331,6 +470,11 @@ export const workItemHierarchyResponse = { __typename: 'WorkItemType', }, title: 'New title', + userPermissions: { + deleteWorkItem: true, + updateWorkItem: true, + }, + confidential: false, widgets: [ { type: 'DESCRIPTION', @@ -349,6 +493,9 @@ export const workItemHierarchyResponse = { }, title: 'xyz', state: 'OPEN', + confidential: true, + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, __typename: 'WorkItem', }, { @@ -359,6 +506,9 @@ export const workItemHierarchyResponse = { }, title: 'abc', state: 'CLOSED', + confidential: false, + createdAt: '2022-08-03T12:41:54Z', + closedAt: '2022-08-12T13:07:52Z', __typename: 'WorkItem', }, { @@ -369,6 +519,9 @@ export const workItemHierarchyResponse = { }, title: 'bar', state: 'OPEN', + confidential: false, + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, __typename: 'WorkItem', }, { @@ -379,6 +532,9 @@ export const workItemHierarchyResponse = { }, title: 'foobar', state: 'OPEN', + confidential: false, + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, __typename: 'WorkItem', }, ], @@ -396,14 +552,34 @@ export const changeWorkItemParentMutationResponse = { data: { workItemUpdate: { workItem: { - id: 'gid://gitlab/WorkItem/2', + __typename: 'WorkItem', workItemType: { - id: 'gid://gitlab/WorkItems::Type/5', __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/1', + name: 'Issue', + iconName: 'issue-type-issue', }, - title: 'Foo', + userPermissions: { + deleteWorkItem: true, + updateWorkItem: true, + }, + description: null, + id: 'gid://gitlab/WorkItem/2', state: 'OPEN', - __typename: 'WorkItem', + title: 'Foo', + confidential: false, + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, + widgets: [ + { + __typename: 'WorkItemWidgetHierarchy', + type: 'HIERARCHY', + parent: null, + children: { + nodes: [], + }, + }, + ], }, errors: [], __typename: 'WorkItemUpdatePayload', @@ -423,6 +599,7 @@ export const availableWorkItemsResponse = { id: 'gid://gitlab/WorkItem/458', title: 'Task 1', state: 'OPEN', + createdAt: '2022-08-03T12:41:54Z', }, }, { @@ -430,6 +607,7 @@ export const availableWorkItemsResponse = { id: 'gid://gitlab/WorkItem/459', title: 'Task 2', state: 'OPEN', + createdAt: '2022-08-03T12:41:54Z', }, }, ], @@ -551,11 +729,3 @@ export const projectLabelsResponse = { }, }, }; - -export const mockParent = { - parent: { - id: 'gid://gitlab/Issue/1', - iid: '5', - title: 'Parent title', - }, -}; 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 43869468ad0..823981df880 100644 --- a/spec/frontend/work_items/pages/work_item_detail_spec.js +++ b/spec/frontend/work_items/pages/work_item_detail_spec.js @@ -1,11 +1,12 @@ -import { GlAlert, GlSkeletonLoader, GlButton } from '@gitlab/ui'; +import { GlAlert, GlBadge, GlLoadingIcon, GlSkeletonLoader, GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; +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 LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; +import WorkItemActions from '~/work_items/components/work_item_actions.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'; @@ -16,6 +17,8 @@ import WorkItemInformation from '~/work_items/components/work_item_information.v 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 updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql'; import { temporaryConfig } from '~/work_items/graphql/provider'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { @@ -30,12 +33,19 @@ describe('WorkItemDetail component', () => { Vue.use(VueApollo); - const workItemQueryResponse = workItemResponseFactory(); + const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true }); + const workItemQueryResponseWithoutParent = workItemResponseFactory({ + parent: null, + canUpdate: true, + canDelete: true, + }); const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse); const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); const findAlert = () => wrapper.findComponent(GlAlert); const findSkeleton = () => wrapper.findComponent(GlSkeletonLoader); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findWorkItemActions = () => wrapper.findComponent(WorkItemActions); const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle); const findWorkItemState = () => wrapper.findComponent(WorkItemState); const findWorkItemDescription = () => wrapper.findComponent(WorkItemDescription); @@ -51,17 +61,21 @@ describe('WorkItemDetail component', () => { const createComponent = ({ isModal = false, + updateInProgress = false, workItemId = workItemQueryResponse.data.workItem.id, handler = successHandler, subscriptionHandler = initialSubscriptionHandler, + confidentialityMock = [updateWorkItemMutation, jest.fn()], workItemsMvc2Enabled = false, includeWidgets = false, + error = undefined, } = {}) => { wrapper = shallowMount(WorkItemDetail, { apolloProvider: createMockApollo( [ [workItemQuery, handler], [workItemTitleSubscription, subscriptionHandler], + confidentialityMock, ], {}, { @@ -69,6 +83,12 @@ describe('WorkItemDetail component', () => { }, ), propsData: { isModal, workItemId }, + data() { + return { + updateInProgress, + error, + }; + }, provide: { glFeatures: { workItemsMvc2: workItemsMvc2Enabled, @@ -146,6 +166,148 @@ describe('WorkItemDetail component', () => { }); }); + describe('confidentiality', () => { + const errorMessage = 'Mutation failed'; + const confidentialWorkItem = workItemResponseFactory({ + confidential: true, + }); + + // Mocks for work item without parent + const withoutParentExpectedInputVars = { + id: workItemQueryResponse.data.workItem.id, + confidential: true, + }; + const toggleConfidentialityWithoutParentHandler = jest.fn().mockResolvedValue({ + data: { + workItemUpdate: { + workItem: confidentialWorkItem.data.workItem, + errors: [], + }, + }, + }); + const withoutParentHandlerMock = jest + .fn() + .mockResolvedValue(workItemQueryResponseWithoutParent); + const confidentialityWithoutParentMock = [ + updateWorkItemMutation, + toggleConfidentialityWithoutParentHandler, + ]; + const confidentialityWithoutParentFailureMock = [ + updateWorkItemMutation, + jest.fn().mockRejectedValue(new Error(errorMessage)), + ]; + + // Mocks for work item with parent + const withParentExpectedInputVars = { + id: mockParent.parent.id, + taskData: { id: workItemQueryResponse.data.workItem.id, confidential: true }, + }; + const toggleConfidentialityWithParentHandler = jest.fn().mockResolvedValue({ + data: { + workItemUpdate: { + workItem: { + id: confidentialWorkItem.data.workItem.id, + descriptionHtml: confidentialWorkItem.data.workItem.description, + }, + task: { + workItem: confidentialWorkItem.data.workItem, + confidential: true, + }, + errors: [], + }, + }, + }); + const confidentialityWithParentMock = [ + updateWorkItemTaskMutation, + toggleConfidentialityWithParentHandler, + ]; + const confidentialityWithParentFailureMock = [ + updateWorkItemTaskMutation, + jest.fn().mockRejectedValue(new Error(errorMessage)), + ]; + + describe.each` + context | handlerMock | confidentialityMock | confidentialityFailureMock | inputVariables + ${'no parent'} | ${withoutParentHandlerMock} | ${confidentialityWithoutParentMock} | ${confidentialityWithoutParentFailureMock} | ${withoutParentExpectedInputVars} + ${'parent'} | ${successHandler} | ${confidentialityWithParentMock} | ${confidentialityWithParentFailureMock} | ${withParentExpectedInputVars} + `( + 'when work item has $context', + ({ handlerMock, confidentialityMock, confidentialityFailureMock, inputVariables }) => { + it('renders confidential badge when work item is confidential', async () => { + createComponent({ + handler: jest.fn().mockResolvedValue(confidentialWorkItem), + confidentialityMock, + }); + + await waitForPromises(); + + const confidentialBadge = wrapper.findComponent(GlBadge); + expect(confidentialBadge.exists()).toBe(true); + expect(confidentialBadge.props()).toMatchObject({ + variant: 'warning', + icon: 'eye-slash', + }); + expect(confidentialBadge.attributes('title')).toBe( + 'Only project members with at least the Reporter role, the author, and assignees can view or be notified about this task.', + ); + expect(confidentialBadge.text()).toBe('Confidential'); + }); + + it('renders gl-loading-icon while update mutation is in progress', async () => { + createComponent({ + handler: handlerMock, + confidentialityMock, + }); + + await waitForPromises(); + + findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true); + + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('emits workItemUpdated and shows confidentiality badge when mutation is successful', async () => { + createComponent({ + handler: handlerMock, + confidentialityMock, + }); + + await waitForPromises(); + + findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true); + await waitForPromises(); + + expect(wrapper.emitted('workItemUpdated')).toEqual([[{ confidential: true }]]); + expect(confidentialityMock[1]).toHaveBeenCalledWith({ + input: inputVariables, + }); + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('shows alert message when mutation fails', async () => { + createComponent({ + handler: handlerMock, + confidentialityMock: confidentialityFailureMock, + }); + + await waitForPromises(); + findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true); + await waitForPromises(); + + expect(wrapper.emitted('workItemUpdated')).toBeFalsy(); + + await nextTick(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(errorMessage); + expect(findLoadingIcon().exists()).toBe(false); + }); + }, + ); + }); + describe('description', () => { it('does not show description widget if loading description fails', () => { createComponent(); @@ -169,7 +331,7 @@ describe('WorkItemDetail component', () => { }); it('does not show secondary breadcrumbs if there is not a parent', async () => { - createComponent(); + createComponent({ handler: jest.fn().mockResolvedValue(workItemQueryResponseWithoutParent) }); await waitForPromises(); @@ -177,7 +339,7 @@ describe('WorkItemDetail component', () => { }); it('shows work item type if there is not a parent', async () => { - createComponent(); + createComponent({ handler: jest.fn().mockResolvedValue(workItemQueryResponseWithoutParent) }); await waitForPromises(); expect(findWorkItemType().exists()).toBe(true); @@ -276,34 +438,29 @@ describe('WorkItemDetail component', () => { }); describe('weight widget', () => { - describe('when work_items_mvc_2 feature flag is enabled', () => { - describe.each` - description | includeWidgets | exists - ${'when widget is returned from API'} | ${true} | ${true} - ${'when widget is not returned from API'} | ${false} | ${false} - `('$description', ({ includeWidgets, exists }) => { - it(`${includeWidgets ? 'renders' : 'does not render'} weight component`, async () => { - createComponent({ includeWidgets, workItemsMvc2Enabled: true }); - await waitForPromises(); + describe.each` + description | weightWidgetPresent | exists + ${'when widget is returned from API'} | ${true} | ${true} + ${'when widget is not returned from API'} | ${false} | ${false} + `('$description', ({ weightWidgetPresent, exists }) => { + it(`${weightWidgetPresent ? 'renders' : 'does not render'} weight component`, async () => { + const response = workItemResponseFactory({ weightWidgetPresent }); + const handler = jest.fn().mockResolvedValue(response); + createComponent({ handler }); + await waitForPromises(); - expect(findWorkItemWeight().exists()).toBe(exists); - }); + expect(findWorkItemWeight().exists()).toBe(exists); }); }); - describe('when work_items_mvc_2 feature flag is disabled', () => { - describe.each` - description | includeWidgets | exists - ${'when widget is returned from API'} | ${true} | ${false} - ${'when widget is not returned from API'} | ${false} | ${false} - `('$description', ({ includeWidgets, exists }) => { - it(`${includeWidgets ? 'renders' : 'does not render'} weight component`, async () => { - createComponent({ includeWidgets, workItemsMvc2Enabled: false }); - await waitForPromises(); + it('shows an error message when it emits an `error` event', async () => { + createComponent({ workItemsMvc2Enabled: true }); + await waitForPromises(); - expect(findWorkItemWeight().exists()).toBe(exists); - }); - }); + findWorkItemWeight().vm.$emit('error', i18n.updateError); + await waitForPromises(); + + expect(findAlert().text()).toBe(i18n.updateError); }); }); |