diff options
Diffstat (limited to 'spec/frontend/work_items/components')
10 files changed, 588 insertions, 218 deletions
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 1b204b6fd60..7367212e49f 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -8,7 +8,7 @@ import { mockTracking } from 'helpers/tracking_helper'; 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 { temporaryConfig } from '~/graphql_shared/issuable_client'; +import { config } from '~/graphql_shared/issuable_client'; 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'; @@ -86,7 +86,7 @@ describe('WorkItemAssignees component', () => { ], {}, { - typePolicies: temporaryConfig.cacheConfig.typePolicies, + typePolicies: config.cacheConfig.typePolicies, }, ); diff --git a/spec/frontend/work_items/components/work_item_description_rendered_spec.js b/spec/frontend/work_items/components/work_item_description_rendered_spec.js new file mode 100644 index 00000000000..01ab7824975 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_description_rendered_spec.js @@ -0,0 +1,108 @@ +import { shallowMount } from '@vue/test-utils'; +import $ from 'jquery'; +import { nextTick } from 'vue'; +import WorkItemDescriptionRendered from '~/work_items/components/work_item_description_rendered.vue'; +import { descriptionTextWithCheckboxes, descriptionHtmlWithCheckboxes } from '../mock_data'; + +describe('WorkItemDescription', () => { + let wrapper; + + const findEditButton = () => wrapper.find('[data-testid="edit-description"]'); + const findCheckboxAtIndex = (index) => wrapper.findAll('input[type="checkbox"]').at(index); + + const defaultWorkItemDescription = { + description: descriptionTextWithCheckboxes, + descriptionHtml: descriptionHtmlWithCheckboxes, + }; + + const createComponent = ({ + workItemDescription = defaultWorkItemDescription, + canEdit = false, + } = {}) => { + wrapper = shallowMount(WorkItemDescriptionRendered, { + propsData: { + workItemDescription, + canEdit, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders gfm', async () => { + const renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); + + createComponent(); + + await nextTick(); + + expect(renderGFMSpy).toHaveBeenCalled(); + }); + + describe('with checkboxes', () => { + beforeEach(() => { + createComponent({ + canEdit: true, + workItemDescription: { + description: `- [x] todo 1\n- [ ] todo 2`, + descriptionHtml: `<ul dir="auto" class="task-list" data-sourcepos="1:1-4:0"> +<li class="task-list-item" data-sourcepos="1:1-2:15"> +<input checked="" class="task-list-item-checkbox" type="checkbox"> todo 1</li> +<li class="task-list-item" data-sourcepos="2:1-2:15"> +<input class="task-list-item-checkbox" type="checkbox"> todo 2</li> +</ul>`, + }, + }); + }); + + it('checks unchecked checkbox', async () => { + findCheckboxAtIndex(1).setChecked(); + + await nextTick(); + + const updatedDescription = `- [x] todo 1\n- [x] todo 2`; + expect(wrapper.emitted('descriptionUpdated')).toEqual([[updatedDescription]]); + }); + + it('disables checkbox while updating', async () => { + findCheckboxAtIndex(1).setChecked(); + + await nextTick(); + + expect(findCheckboxAtIndex(1).attributes().disabled).toBeDefined(); + }); + + it('unchecks checked checkbox', async () => { + findCheckboxAtIndex(0).setChecked(false); + + await nextTick(); + + const updatedDescription = `- [ ] todo 1\n- [ ] todo 2`; + expect(wrapper.emitted('descriptionUpdated')).toEqual([[updatedDescription]]); + }); + }); + + describe('Edit button', () => { + it('is not visible when canUpdate = false', async () => { + await createComponent({ + canUpdate: false, + }); + + expect(findEditButton().exists()).toBe(false); + }); + + it('toggles edit mode', async () => { + createComponent({ + canEdit: true, + }); + + findEditButton().vm.$emit('click'); + + await nextTick(); + + expect(wrapper.emitted('startEditing')).toEqual([[]]); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js index 0691fe25e0d..c79b049442d 100644 --- a/spec/frontend/work_items/components/work_item_description_spec.js +++ b/spec/frontend/work_items/components/work_item_description_spec.js @@ -8,21 +8,23 @@ import EditedAt from '~/issues/show/components/edited.vue'; import { updateDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import WorkItemDescription from '~/work_items/components/work_item_description.vue'; +import WorkItemDescriptionRendered from '~/work_items/components/work_item_description_rendered.vue'; import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import workItemDescriptionSubscription from '~/work_items/graphql/work_item_description.subscription.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { updateWorkItemMutationResponse, + workItemDescriptionSubscriptionResponse, workItemResponseFactory, workItemQueryResponse, + projectWorkItemResponse, } from '../mock_data'; -jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => { - return { - confirmAction: jest.fn(), - }; -}); +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); jest.mock('~/lib/utils/autosave'); const workItemId = workItemQueryResponse.data.workItem.id; @@ -33,12 +35,22 @@ describe('WorkItemDescription', () => { Vue.use(VueApollo); const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); + const subscriptionHandler = jest.fn().mockResolvedValue(workItemDescriptionSubscriptionResponse); + const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse); + let workItemResponseHandler; + let workItemsMvc2; - const findEditButton = () => wrapper.find('[data-testid="edit-description"]'); const findMarkdownField = () => wrapper.findComponent(MarkdownField); + const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); + const findRenderedDescription = () => wrapper.findComponent(WorkItemDescriptionRendered); const findEditedAt = () => wrapper.findComponent(EditedAt); - const editDescription = (newText) => wrapper.find('textarea').setValue(newText); + const editDescription = (newText) => { + if (workItemsMvc2) { + return findMarkdownEditor().vm.$emit('input', newText); + } + return wrapper.find('textarea').setValue(newText); + }; const clickCancel = () => wrapper.find('[data-testid="cancel"]').vm.$emit('click'); const clickSave = () => wrapper.find('[data-testid="save-description"]').vm.$emit('click', {}); @@ -48,18 +60,30 @@ describe('WorkItemDescription', () => { canUpdate = true, workItemResponse = workItemResponseFactory({ canUpdate }), isEditing = false, + fetchByIid = false, } = {}) => { - const workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse); + workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse); const { id } = workItemQueryResponse.data.workItem; wrapper = shallowMount(WorkItemDescription, { apolloProvider: createMockApollo([ [workItemQuery, workItemResponseHandler], [updateWorkItemMutation, mutationHandler], + [workItemDescriptionSubscription, subscriptionHandler], + [workItemByIidQuery, workItemByIidResponseHandler], ]), propsData: { workItemId: id, fullPath: 'test-project-path', + queryVariables: { + id: workItemId, + }, + fetchByIid, + }, + provide: { + glFeatures: { + workItemsMvc2, + }, }, stubs: { MarkdownField, @@ -69,7 +93,7 @@ describe('WorkItemDescription', () => { await waitForPromises(); if (isEditing) { - findEditButton().vm.$emit('click'); + findRenderedDescription().vm.$emit('startEditing'); await nextTick(); } @@ -79,171 +103,178 @@ describe('WorkItemDescription', () => { wrapper.destroy(); }); - describe('Edit button', () => { - it('is not visible when canUpdate = false', async () => { - await createComponent({ - canUpdate: false, + describe.each([true, false])( + 'editing description with workItemsMvc2 %workItemsMvc2Enabled', + (workItemsMvc2Enabled) => { + beforeEach(() => { + beforeEach(() => { + workItemsMvc2 = workItemsMvc2Enabled; + }); }); - expect(findEditButton().exists()).toBe(false); - }); + describe('editing description', () => { + it('shows edited by text', async () => { + const lastEditedAt = '2022-09-21T06:18:42Z'; + const lastEditedBy = { + name: 'Administrator', + webPath: '/root', + }; - it('toggles edit mode', async () => { - await createComponent({ - canUpdate: true, - }); + await createComponent({ + workItemResponse: workItemResponseFactory({ + lastEditedAt, + lastEditedBy, + }), + }); - findEditButton().vm.$emit('click'); + expect(findEditedAt().props()).toEqual({ + updatedAt: lastEditedAt, + updatedByName: lastEditedBy.name, + updatedByPath: lastEditedBy.webPath, + }); + }); - await nextTick(); + it('does not show edited by text', async () => { + await createComponent(); - expect(findMarkdownField().exists()).toBe(true); - }); - }); + expect(findEditedAt().exists()).toBe(false); + }); - describe('editing description', () => { - it('shows edited by text', async () => { - const lastEditedAt = '2022-09-21T06:18:42Z'; - const lastEditedBy = { - name: 'Administrator', - webPath: '/root', - }; - - await createComponent({ - workItemResponse: workItemResponseFactory({ - lastEditedAt, - lastEditedBy, - }), - }); + it('cancels when clicking cancel', async () => { + await createComponent({ + isEditing: true, + }); - expect(findEditedAt().props()).toEqual({ - updatedAt: lastEditedAt, - updatedByName: lastEditedBy.name, - updatedByPath: lastEditedBy.webPath, - }); - }); + clickCancel(); - it('does not show edited by text', async () => { - await createComponent(); + await nextTick(); - expect(findEditedAt().exists()).toBe(false); - }); + expect(confirmAction).not.toHaveBeenCalled(); + expect(findMarkdownField().exists()).toBe(false); + }); - it('cancels when clicking cancel', async () => { - await createComponent({ - isEditing: true, - }); + it('prompts for confirmation when clicking cancel after changes', async () => { + await createComponent({ + isEditing: true, + }); - clickCancel(); + editDescription('updated desc'); - await nextTick(); + clickCancel(); - expect(confirmAction).not.toHaveBeenCalled(); - expect(findMarkdownField().exists()).toBe(false); - }); + await nextTick(); - it('prompts for confirmation when clicking cancel after changes', async () => { - await createComponent({ - isEditing: true, - }); + expect(confirmAction).toHaveBeenCalled(); + }); - editDescription('updated desc'); + it('calls update widgets mutation', async () => { + const updatedDesc = 'updated desc'; - clickCancel(); + await createComponent({ + isEditing: true, + }); - await nextTick(); + editDescription(updatedDesc); - expect(confirmAction).toHaveBeenCalled(); - }); + clickSave(); - it('calls update widgets mutation', async () => { - await createComponent({ - isEditing: true, - }); + await waitForPromises(); - editDescription('updated desc'); + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + id: workItemId, + descriptionWidget: { + description: updatedDesc, + }, + }, + }); + }); - clickSave(); + it('tracks editing description', async () => { + await createComponent({ + isEditing: true, + markdownPreviewPath: '/preview', + }); + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - await waitForPromises(); + clickSave(); - expect(mutationSuccessHandler).toHaveBeenCalledWith({ - input: { - id: workItemId, - descriptionWidget: { - description: 'updated desc', - }, - }, - }); - }); + await waitForPromises(); - it('tracks editing description', async () => { - await createComponent({ - isEditing: true, - markdownPreviewPath: '/preview', - }); - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_description', { + category: TRACKING_CATEGORY_SHOW, + label: 'item_description', + property: 'type_Task', + }); + }); - clickSave(); + it('emits error when mutation returns error', async () => { + const error = 'eror'; - await waitForPromises(); + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockResolvedValue({ + data: { + workItemUpdate: { + workItem: {}, + errors: [error], + }, + }, + }), + }); - expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_description', { - category: TRACKING_CATEGORY_SHOW, - label: 'item_description', - property: 'type_Task', - }); - }); + editDescription('updated desc'); - it('emits error when mutation returns error', async () => { - const error = 'eror'; + clickSave(); - await createComponent({ - isEditing: true, - mutationHandler: jest.fn().mockResolvedValue({ - data: { - workItemUpdate: { - workItem: {}, - errors: [error], - }, - }, - }), - }); + await waitForPromises(); - editDescription('updated desc'); + expect(wrapper.emitted('error')).toEqual([[error]]); + }); - clickSave(); + it('emits error when mutation fails', async () => { + const error = 'eror'; - await waitForPromises(); + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockRejectedValue(new Error(error)), + }); - expect(wrapper.emitted('error')).toEqual([[error]]); - }); + editDescription('updated desc'); - it('emits error when mutation fails', async () => { - const error = 'eror'; + clickSave(); - await createComponent({ - isEditing: true, - mutationHandler: jest.fn().mockRejectedValue(new Error(error)), - }); + await waitForPromises(); - editDescription('updated desc'); + expect(wrapper.emitted('error')).toEqual([[error]]); + }); - clickSave(); + it('autosaves description', async () => { + await createComponent({ + isEditing: true, + }); - await waitForPromises(); + editDescription('updated desc'); - expect(wrapper.emitted('error')).toEqual([[error]]); - }); + expect(updateDraft).toHaveBeenCalled(); + }); + }); + + it('calls the global ID work item query when `fetchByIid` prop is false', async () => { + createComponent({ fetchByIid: false }); + await waitForPromises(); - it('autosaves description', async () => { - await createComponent({ - isEditing: true, + expect(workItemResponseHandler).toHaveBeenCalled(); + expect(workItemByIidResponseHandler).not.toHaveBeenCalled(); }); - editDescription('updated desc'); + it('calls the IID work item query when when `fetchByIid` prop is true', async () => { + createComponent({ fetchByIid: true }); + await waitForPromises(); - expect(updateDraft).toHaveBeenCalled(); - }); - }); + expect(workItemResponseHandler).not.toHaveBeenCalled(); + expect(workItemByIidResponseHandler).toHaveBeenCalled(); + }); + }, + ); }); 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 6b1ef8971d3..4029e47c390 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 @@ -86,6 +86,7 @@ describe('WorkItemDetailModal component', () => { isModal: true, workItemId: defaultPropsData.workItemId, workItemParentId: defaultPropsData.issueGid, + iid: null, }); }); diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index aae61b11196..26777b57797 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -24,12 +24,13 @@ import WorkItemMilestone from '~/work_items/components/work_item_milestone.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 workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import workItemDatesSubscription from '~/work_items/graphql/work_item_dates.subscription.graphql'; import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql'; import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assignees.subscription.graphql'; +import workItemMilestoneSubscription from '~/work_items/graphql/work_item_milestone.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 '~/graphql_shared/issuable_client'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { mockParent, @@ -37,6 +38,8 @@ import { workItemResponseFactory, workItemTitleSubscriptionResponse, workItemAssigneesSubscriptionResponse, + workItemMilestoneSubscriptionResponse, + projectWorkItemResponse, } from '../mock_data'; describe('WorkItemDetail component', () => { @@ -52,8 +55,12 @@ describe('WorkItemDetail component', () => { canDelete: true, }); const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse); + const successByIidHandler = jest.fn().mockResolvedValue(projectWorkItemResponse); const datesSubscriptionHandler = jest.fn().mockResolvedValue(workItemDatesSubscriptionResponse); const titleSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); + const milestoneSubscriptionHandler = jest + .fn() + .mockResolvedValue(workItemMilestoneSubscriptionResponse); const assigneesSubscriptionHandler = jest .fn() .mockResolvedValue(workItemAssigneesSubscriptionResponse); @@ -85,26 +92,23 @@ describe('WorkItemDetail component', () => { subscriptionHandler = titleSubscriptionHandler, confidentialityMock = [updateWorkItemMutation, jest.fn()], error = undefined, - includeWidgets = false, workItemsMvc2Enabled = false, + fetchByIid = false, + iidPathQueryParam = undefined, } = {}) => { const handlers = [ [workItemQuery, handler], [workItemTitleSubscription, subscriptionHandler], [workItemDatesSubscription, datesSubscriptionHandler], [workItemAssigneesSubscription, assigneesSubscriptionHandler], + [workItemMilestoneSubscription, milestoneSubscriptionHandler], + [workItemByIidQuery, successByIidHandler], confidentialityMock, ]; wrapper = shallowMount(WorkItemDetail, { - apolloProvider: createMockApollo( - handlers, - {}, - { - typePolicies: includeWidgets ? temporaryConfig.cacheConfig.typePolicies : {}, - }, - ), - propsData: { isModal, workItemId }, + apolloProvider: createMockApollo(handlers), + propsData: { isModal, workItemId, iid: '1' }, data() { return { updateInProgress, @@ -114,15 +118,24 @@ describe('WorkItemDetail component', () => { provide: { glFeatures: { workItemsMvc2: workItemsMvc2Enabled, + useIidInWorkItemsPath: fetchByIid, }, hasIssueWeightsFeature: true, hasIterationsFeature: true, projectNamespace: 'namespace', + fullPath: 'group/project', }, stubs: { WorkItemWeight: true, WorkItemIteration: true, }, + mocks: { + $route: { + query: { + iid_path: iidPathQueryParam, + }, + }, + }, }); }; @@ -421,8 +434,9 @@ describe('WorkItemDetail component', () => { }); describe('subscriptions', () => { - it('calls the title subscription', () => { + it('calls the title subscription', async () => { createComponent(); + await waitForPromises(); expect(titleSubscriptionHandler).toHaveBeenCalledWith({ issuableId: workItemQueryResponse.data.workItem.id, @@ -543,15 +557,41 @@ describe('WorkItemDetail component', () => { describe('milestone 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 }); + description | milestoneWidgetPresent | exists + ${'renders when widget is returned from API'} | ${true} | ${true} + ${'does not render when widget is not returned from API'} | ${false} | ${false} + `('$description', async ({ milestoneWidgetPresent, exists }) => { + const response = workItemResponseFactory({ milestoneWidgetPresent }); + const handler = jest.fn().mockResolvedValue(response); + createComponent({ handler, workItemsMvc2Enabled: true }); await waitForPromises(); expect(findWorkItemMilestone().exists()).toBe(exists); }); + + describe('milestone subscription', () => { + describe('when the milestone widget exists', () => { + it('calls the milestone subscription', async () => { + createComponent(); + await waitForPromises(); + + expect(milestoneSubscriptionHandler).toHaveBeenCalledWith({ + issuableId: workItemQueryResponse.data.workItem.id, + }); + }); + }); + + describe('when the assignees widget does not exist', () => { + it('does not call the milestone subscription', async () => { + const response = workItemResponseFactory({ milestoneWidgetPresent: false }); + const handler = jest.fn().mockResolvedValue(response); + createComponent({ handler }); + await waitForPromises(); + + expect(milestoneSubscriptionHandler).not.toHaveBeenCalled(); + }); + }); + }); }); describe('work item information', () => { @@ -571,4 +611,35 @@ describe('WorkItemDetail component', () => { expect(findWorkItemInformationAlert().exists()).toBe(false); }); }); + + it('calls the global ID work item query when `useIidInWorkItemsPath` feature flag is false', async () => { + createComponent(); + await waitForPromises(); + + expect(successHandler).toHaveBeenCalledWith({ + id: workItemQueryResponse.data.workItem.id, + }); + expect(successByIidHandler).not.toHaveBeenCalled(); + }); + + it('calls the global ID work item query when `useIidInWorkItemsPath` feature flag is true but there is no `iid_path` parameter in URL', async () => { + createComponent({ fetchByIid: true }); + await waitForPromises(); + + expect(successHandler).toHaveBeenCalledWith({ + id: workItemQueryResponse.data.workItem.id, + }); + expect(successByIidHandler).not.toHaveBeenCalled(); + }); + + it('calls the IID work item query when `useIidInWorkItemsPath` feature flag is true and `iid_path` route parameter is present', async () => { + createComponent({ fetchByIid: true, iidPathQueryParam: 'true' }); + await waitForPromises(); + + expect(successHandler).not.toHaveBeenCalled(); + expect(successByIidHandler).toHaveBeenCalledWith({ + fullPath: 'group/project', + iid: '1', + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_due_date_spec.js b/spec/frontend/work_items/components/work_item_due_date_spec.js index 701406b9588..7ebaf8209c7 100644 --- a/spec/frontend/work_items/components/work_item_due_date_spec.js +++ b/spec/frontend/work_items/components/work_item_due_date_spec.js @@ -140,7 +140,7 @@ describe('WorkItemDueDate component', () => { beforeEach(() => { createComponent({ canUpdate: true, dueDate: '2022-12-31', startDate: '2022-12-31' }); - datePickerOpenSpy = jest.spyOn(wrapper.vm.$refs.dueDatePicker.calendar, 'show'); + datePickerOpenSpy = jest.spyOn(wrapper.vm.$refs.dueDatePicker, 'show'); findStartDatePicker().vm.$emit('input', startDate); findStartDatePicker().vm.$emit('close'); }); diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js index e6ff7e8502d..9f7659b3f8d 100644 --- a/spec/frontend/work_items/components/work_item_labels_spec.js +++ b/spec/frontend/work_items/components/work_item_labels_spec.js @@ -9,6 +9,7 @@ import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widg import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS } from '~/work_items/constants'; import { @@ -18,6 +19,7 @@ import { workItemResponseFactory, updateWorkItemMutationResponse, workItemLabelsSubscriptionResponse, + projectWorkItemResponse, } from '../mock_data'; Vue.use(VueApollo); @@ -33,6 +35,7 @@ describe('WorkItemLabels component', () => { const findLabelsTitle = () => wrapper.findByTestId('labels-title'); const workItemQuerySuccess = jest.fn().mockResolvedValue(workItemQueryResponse); + const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse); const successSearchQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse); const successUpdateWorkItemMutationHandler = jest .fn() @@ -45,12 +48,14 @@ describe('WorkItemLabels component', () => { workItemQueryHandler = workItemQuerySuccess, searchQueryHandler = successSearchQueryHandler, updateWorkItemMutationHandler = successUpdateWorkItemMutationHandler, + fetchByIid = false, } = {}) => { const apolloProvider = createMockApollo([ [workItemQuery, workItemQueryHandler], [labelSearchQuery, searchQueryHandler], [updateWorkItemMutation, updateWorkItemMutationHandler], [workItemLabelsSubscription, subscriptionHandler], + [workItemByIidQuery, workItemByIidResponseHandler], ]); wrapper = mountExtended(WorkItemLabels, { @@ -58,6 +63,10 @@ describe('WorkItemLabels component', () => { workItemId, canUpdate, fullPath: 'test-project-path', + queryVariables: { + id: workItemId, + }, + fetchByIid, }, attachTo: document.body, apolloProvider, @@ -226,4 +235,20 @@ describe('WorkItemLabels component', () => { }); }); }); + + it('calls the global ID work item query when `fetchByIid` prop is false', async () => { + createComponent({ fetchByIid: false }); + await waitForPromises(); + + expect(workItemQuerySuccess).toHaveBeenCalled(); + expect(workItemByIidResponseHandler).not.toHaveBeenCalled(); + }); + + it('calls the IID work item query when when `fetchByIid` prop is true', async () => { + createComponent({ fetchByIid: true }); + await waitForPromises(); + + expect(workItemQuerySuccess).not.toHaveBeenCalled(); + expect(workItemByIidResponseHandler).toHaveBeenCalled(); + }); }); 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 ab3ea623e3e..071d5fb715a 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,10 +1,11 @@ import Vue from 'vue'; -import { GlForm, GlFormInput, GlFormCombobox } from '@gitlab/ui'; +import { GlForm, GlFormInput, GlTokenSelector } 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 { FORM_TYPES } from '~/work_items/constants'; 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'; @@ -14,6 +15,7 @@ import { projectWorkItemTypesQueryResponse, createWorkItemMutationResponse, updateWorkItemMutationResponse, + mockIterationWidgetResponse, } from '../../mock_data'; Vue.use(VueApollo); @@ -23,22 +25,35 @@ describe('WorkItemLinksForm', () => { const updateMutationResolver = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); const createMutationResolver = jest.fn().mockResolvedValue(createWorkItemMutationResponse); + const availableWorkItemsResolver = jest.fn().mockResolvedValue(availableWorkItemsResponse); + + const mockParentIteration = mockIterationWidgetResponse; const createComponent = async ({ - listResponse = availableWorkItemsResponse, typesResponse = projectWorkItemTypesQueryResponse, parentConfidential = false, hasIterationsFeature = false, + workItemsMvc2Enabled = false, + parentIteration = null, + formType = FORM_TYPES.create, } = {}) => { wrapper = shallowMountExtended(WorkItemLinksForm, { apolloProvider: createMockApollo([ - [projectWorkItemsQuery, jest.fn().mockResolvedValue(listResponse)], + [projectWorkItemsQuery, availableWorkItemsResolver], [projectWorkItemTypesQuery, jest.fn().mockResolvedValue(typesResponse)], [updateWorkItemMutation, updateMutationResolver], [createWorkItemMutation, createMutationResolver], ]), - propsData: { issuableGid: 'gid://gitlab/WorkItem/1', parentConfidential }, + propsData: { + issuableGid: 'gid://gitlab/WorkItem/1', + parentConfidential, + parentIteration, + formType, + }, provide: { + glFeatures: { + workItemsMvc2: workItemsMvc2Enabled, + }, projectPath: 'project/path', hasIterationsFeature, }, @@ -48,89 +63,155 @@ describe('WorkItemLinksForm', () => { }; const findForm = () => wrapper.findComponent(GlForm); - const findCombobox = () => wrapper.findComponent(GlFormCombobox); + const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); const findInput = () => wrapper.findComponent(GlFormInput); const findAddChildButton = () => wrapper.findByTestId('add-child-button'); - beforeEach(async () => { - await createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); - it('renders form', () => { - expect(findForm().exists()).toBe(true); - }); - - it('creates child task in non confidential parent', async () => { - findInput().vm.$emit('input', 'Create task test'); + describe('creating a new work item', () => { + beforeEach(async () => { + await createComponent(); + }); - findForm().vm.$emit('submit', { - preventDefault: jest.fn(), + it('renders create form', () => { + expect(findForm().exists()).toBe(true); + expect(findInput().exists()).toBe(true); + expect(findAddChildButton().text()).toBe('Create task'); + expect(findTokenSelector().exists()).toBe(false); }); - 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', + + 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, }, - confidential: false, - }, + }); }); - }); - it('creates child task in confidential parent', async () => { - await createComponent({ parentConfidential: true }); + it('creates child task in confidential parent', async () => { + await createComponent({ parentConfidential: true }); - findInput().vm.$emit('input', 'Create confidential task'); + findInput().vm.$emit('input', 'Create confidential task'); - 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', + 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, }, - confidential: true, - }, + }); }); }); - // 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]); + describe('adding an existing work item', () => { + beforeEach(async () => { + await createComponent({ formType: FORM_TYPES.add }); + }); - findAddChildButton().vm.$emit('click'); - await waitForPromises(); - expect(updateMutationResolver).toHaveBeenCalled(); - }); + it('renders add form', () => { + expect(findForm().exists()).toBe(true); + expect(findTokenSelector().exists()).toBe(true); + expect(findAddChildButton().text()).toBe('Add task'); + expect(findInput().exists()).toBe(false); + }); - // eslint-disable-next-line jest/no-disabled-tests - describe.skip('when typing in combobox', () => { - beforeEach(async () => { - findCombobox().vm.$emit('input', 'Task'); + it('searches for available work items as prop when typing in input', async () => { + findTokenSelector().vm.$emit('focus'); + findTokenSelector().vm.$emit('text-input', 'Task'); await waitForPromises(); - await jest.runOnlyPendingTimers(); + + expect(availableWorkItemsResolver).toHaveBeenCalled(); }); - it('passes available work items as prop', () => { - expect(findCombobox().exists()).toBe(true); - expect(findCombobox().props('tokenList').length).toBe(2); + it('selects and adds children', async () => { + findTokenSelector().vm.$emit( + 'input', + availableWorkItemsResponse.data.workspace.workItems.nodes, + ); + findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + + await waitForPromises(); + + expect(findAddChildButton().text()).toBe('Add tasks'); + findForm().vm.$emit('submit', { + preventDefault: jest.fn(), + }); + await waitForPromises(); + expect(updateMutationResolver).toHaveBeenCalled(); }); + }); + + describe('associate iteration with task', () => { + it('does not update iteration when mvc2 feature flag is not enabled', async () => { + await createComponent({ + hasIterationsFeature: true, + parentIteration: mockParentIteration, + }); - it('passes action to create task', () => { - expect(findCombobox().props('actionList').length).toBe(1); + findInput().vm.$emit('input', 'Create task test'); + + findForm().vm.$emit('submit', { + preventDefault: jest.fn(), + }); + await waitForPromises(); + expect(updateMutationResolver).not.toHaveBeenCalled(); + }); + it('updates when parent has an iteration associated', async () => { + await createComponent({ + workItemsMvc2Enabled: true, + hasIterationsFeature: true, + parentIteration: mockParentIteration, + }); + findInput().vm.$emit('input', 'Create task test'); + + findForm().vm.$emit('submit', { + preventDefault: jest.fn(), + }); + await waitForPromises(); + expect(updateMutationResolver).toHaveBeenCalledWith({ + input: { + id: 'gid://gitlab/WorkItem/1', + iterationWidget: { + iterationId: mockParentIteration.id, + }, + }, + }); + }); + it('does not update when parent has no iteration associated', async () => { + await createComponent({ + workItemsMvc2Enabled: true, + hasIterationsFeature: true, + }); + findInput().vm.$emit('input', 'Create task test'); + + findForm().vm.$emit('submit', { + preventDefault: jest.fn(), + }); + await waitForPromises(); + expect(updateMutationResolver).not.toHaveBeenCalled(); }); }); }); 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 6961996f912..66ce2c1becf 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 @@ -8,6 +8,7 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import issueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; import WorkItemLinks from '~/work_items/components/work_item_links/work_item_links.vue'; import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; +import { FORM_TYPES } from '~/work_items/constants'; 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'; @@ -41,6 +42,13 @@ const issueDetailsResponse = (confidential = false) => ({ }, __typename: 'Iteration', }, + milestone: { + dueDate: null, + expired: false, + id: 'gid://gitlab/Milestone/28', + title: 'v2.0', + __typename: 'Milestone', + }, __typename: 'Issue', }, __typename: 'Project', @@ -107,7 +115,9 @@ describe('WorkItemLinks', () => { const findToggleButton = () => wrapper.findByTestId('toggle-links'); const findLinksBody = () => wrapper.findByTestId('links-body'); const findEmptyState = () => wrapper.findByTestId('links-empty'); + const findToggleFormDropdown = () => wrapper.findByTestId('toggle-form'); const findToggleAddFormButton = () => wrapper.findByTestId('toggle-add-form'); + const findToggleCreateFormButton = () => wrapper.findByTestId('toggle-create-form'); const findWorkItemLinkChildItems = () => wrapper.findAllComponents(WorkItemLinkChild); const findFirstWorkItemLinkChild = () => findWorkItemLinkChildItems().at(0); const findAddLinksForm = () => wrapper.findByTestId('add-links-form'); @@ -136,11 +146,27 @@ describe('WorkItemLinks', () => { }); describe('add link form', () => { - it('displays form on click add button and hides form on cancel', async () => { + it('displays add work item form on click add dropdown then add existing button and hides form on cancel', async () => { + findToggleFormDropdown().vm.$emit('click'); findToggleAddFormButton().vm.$emit('click'); await nextTick(); expect(findAddLinksForm().exists()).toBe(true); + expect(findAddLinksForm().props('formType')).toBe(FORM_TYPES.add); + + findAddLinksForm().vm.$emit('cancel'); + await nextTick(); + + expect(findAddLinksForm().exists()).toBe(false); + }); + + it('displays create work item form on click add dropdown then create button and hides form on cancel', async () => { + findToggleFormDropdown().vm.$emit('click'); + findToggleCreateFormButton().vm.$emit('click'); + await nextTick(); + + expect(findAddLinksForm().exists()).toBe(true); + expect(findAddLinksForm().props('formType')).toBe(FORM_TYPES.create); findAddLinksForm().vm.$emit('cancel'); await nextTick(); @@ -193,7 +219,7 @@ describe('WorkItemLinks', () => { }); it('does not display button to toggle Add form', () => { - expect(findToggleAddFormButton().exists()).toBe(false); + expect(findToggleFormDropdown().exists()).toBe(false); }); it('does not display link menu on children', () => { @@ -283,6 +309,7 @@ describe('WorkItemLinks', () => { await createComponent({ issueDetailsQueryHandler: jest.fn().mockResolvedValue(issueDetailsResponse(true)), }); + findToggleFormDropdown().vm.$emit('click'); findToggleAddFormButton().vm.$emit('click'); await nextTick(); diff --git a/spec/frontend/work_items/components/work_item_milestone_spec.js b/spec/frontend/work_items/components/work_item_milestone_spec.js index 08cdf62ae52..60ba2b55f76 100644 --- a/spec/frontend/work_items/components/work_item_milestone_spec.js +++ b/spec/frontend/work_items/components/work_item_milestone_spec.js @@ -9,7 +9,7 @@ import { import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue'; -import { resolvers, temporaryConfig } from '~/graphql_shared/issuable_client'; +import { resolvers, config } from '~/graphql_shared/issuable_client'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -22,8 +22,14 @@ import { mockMilestoneWidgetResponse, workItemResponseFactory, updateWorkItemMutationErrorResponse, + workItemMilestoneSubscriptionResponse, + projectWorkItemResponse, + updateWorkItemMutationResponse, } from 'jest/work_items/mock_data'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; +import workItemMilestoneSubscription from '~/work_items/graphql/work_item_milestone.subscription.graphql'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; describe('WorkItemMilestone component', () => { Vue.use(VueApollo); @@ -47,6 +53,8 @@ describe('WorkItemMilestone component', () => { const findInputGroup = () => wrapper.findComponent(GlFormGroup); const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true }); + const workItemQueryHandler = jest.fn().mockResolvedValue(workItemQueryResponse); + const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse); const networkResolvedValue = new Error(); @@ -54,6 +62,12 @@ describe('WorkItemMilestone component', () => { const successSearchWithNoMatchingMilestones = jest .fn() .mockResolvedValue(projectMilestonesResponseWithNoMilestones); + const milestoneSubscriptionHandler = jest + .fn() + .mockResolvedValue(workItemMilestoneSubscriptionResponse); + const successUpdateWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(updateWorkItemMutationResponse); const showDropdown = () => { findDropdown().vm.$emit('shown'); @@ -67,12 +81,20 @@ describe('WorkItemMilestone component', () => { canUpdate = true, milestone = mockMilestoneWidgetResponse, searchQueryHandler = successSearchQueryHandler, + fetchByIid = false, + mutationHandler = successUpdateWorkItemMutationHandler, } = {}) => { const apolloProvider = createMockApollo( - [[projectMilestonesQuery, searchQueryHandler]], + [ + [workItemQuery, workItemQueryHandler], + [workItemMilestoneSubscription, milestoneSubscriptionHandler], + [projectMilestonesQuery, searchQueryHandler], + [updateWorkItemMutation, mutationHandler], + [workItemByIidQuery, workItemByIidResponseHandler], + ], resolvers, { - typePolicies: temporaryConfig.cacheConfig.typePolicies, + typePolicies: config.cacheConfig.typePolicies, }, ); @@ -92,6 +114,10 @@ describe('WorkItemMilestone component', () => { workItemId, workItemType, fullPath, + queryVariables: { + id: workItemId, + }, + fetchByIid, }, stubs: { GlDropdown, |