diff options
Diffstat (limited to 'spec/frontend/work_items')
12 files changed, 734 insertions, 76 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 28231fad108..1b204b6fd60 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -157,6 +157,14 @@ describe('WorkItemAssignees component', () => { expect(findTokenSelector().props('viewOnly')).toBe(true); }); + it('has a label', () => { + createComponent(); + + expect(findTokenSelector().props('ariaLabelledby')).toEqual( + findAssigneesTitle().attributes('id'), + ); + }); + describe('when clicking outside the token selector', () => { function arrange(args) { createComponent(args); 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 d3165d8dc26..0691fe25e0d 100644 --- a/spec/frontend/work_items/components/work_item_description_spec.js +++ b/spec/frontend/work_items/components/work_item_description_spec.js @@ -4,6 +4,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mockTracking } from 'helpers/tracking_helper'; import waitForPromises from 'helpers/wait_for_promises'; +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'; @@ -35,6 +36,7 @@ describe('WorkItemDescription', () => { const findEditButton = () => wrapper.find('[data-testid="edit-description"]'); const findMarkdownField = () => wrapper.findComponent(MarkdownField); + const findEditedAt = () => wrapper.findComponent(EditedAt); const editDescription = (newText) => wrapper.find('textarea').setValue(newText); @@ -44,9 +46,9 @@ describe('WorkItemDescription', () => { const createComponent = async ({ mutationHandler = mutationSuccessHandler, canUpdate = true, + workItemResponse = workItemResponseFactory({ canUpdate }), isEditing = false, } = {}) => { - const workItemResponse = workItemResponseFactory({ canUpdate }); const workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse); const { id } = workItemQueryResponse.data.workItem; @@ -100,6 +102,33 @@ describe('WorkItemDescription', () => { }); 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, + }), + }); + + expect(findEditedAt().props()).toEqual({ + updatedAt: lastEditedAt, + updatedByName: lastEditedBy.name, + updatedByPath: lastEditedBy.webPath, + }); + }); + + it('does not show edited by text', async () => { + await createComponent(); + + expect(findEditedAt().exists()).toBe(false); + }); + it('cancels when clicking cancel', async () => { await createComponent({ isEditing: true, 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 b047e0dc8d7..aae61b11196 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -1,8 +1,14 @@ -import { GlAlert, GlBadge, GlLoadingIcon, GlSkeletonLoader, GlButton } from '@gitlab/ui'; +import { + GlAlert, + GlBadge, + GlLoadingIcon, + GlSkeletonLoader, + GlButton, + GlEmptyState, +} from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import workItemWeightSubscription from 'ee_component/work_items/graphql/work_item_weight.subscription.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; @@ -14,11 +20,13 @@ import WorkItemState from '~/work_items/components/work_item_state.vue'; import WorkItemTitle from '~/work_items/components/work_item_title.vue'; import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; +import 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 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 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'; @@ -28,7 +36,7 @@ import { workItemDatesSubscriptionResponse, workItemResponseFactory, workItemTitleSubscriptionResponse, - workItemWeightSubscriptionResponse, + workItemAssigneesSubscriptionResponse, } from '../mock_data'; describe('WorkItemDetail component', () => { @@ -46,9 +54,12 @@ describe('WorkItemDetail component', () => { const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse); const datesSubscriptionHandler = jest.fn().mockResolvedValue(workItemDatesSubscriptionResponse); const titleSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); - const weightSubscriptionHandler = jest.fn().mockResolvedValue(workItemWeightSubscriptionResponse); + const assigneesSubscriptionHandler = jest + .fn() + .mockResolvedValue(workItemAssigneesSubscriptionResponse); const findAlert = () => wrapper.findComponent(GlAlert); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findSkeleton = () => wrapper.findComponent(GlSkeletonLoader); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findWorkItemActions = () => wrapper.findComponent(WorkItemActions); @@ -58,6 +69,7 @@ describe('WorkItemDetail component', () => { const findWorkItemDueDate = () => wrapper.findComponent(WorkItemDueDate); const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees); const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels); + const findWorkItemMilestone = () => wrapper.findComponent(WorkItemMilestone); const findParent = () => wrapper.find('[data-testid="work-item-parent"]'); const findParentButton = () => findParent().findComponent(GlButton); const findCloseButton = () => wrapper.find('[data-testid="work-item-close"]'); @@ -72,21 +84,18 @@ describe('WorkItemDetail component', () => { handler = successHandler, subscriptionHandler = titleSubscriptionHandler, confidentialityMock = [updateWorkItemMutation, jest.fn()], - workItemsMvc2Enabled = false, - includeWidgets = false, error = undefined, + includeWidgets = false, + workItemsMvc2Enabled = false, } = {}) => { const handlers = [ [workItemQuery, handler], [workItemTitleSubscription, subscriptionHandler], [workItemDatesSubscription, datesSubscriptionHandler], + [workItemAssigneesSubscription, assigneesSubscriptionHandler], confidentialityMock, ]; - if (IS_EE) { - handlers.push([workItemWeightSubscription, weightSubscriptionHandler]); - } - wrapper = shallowMount(WorkItemDetail, { apolloProvider: createMockApollo( handlers, @@ -107,6 +116,12 @@ describe('WorkItemDetail component', () => { workItemsMvc2: workItemsMvc2Enabled, }, hasIssueWeightsFeature: true, + hasIterationsFeature: true, + projectNamespace: 'namespace', + }, + stubs: { + WorkItemWeight: true, + WorkItemIteration: true, }, }); }; @@ -384,13 +399,14 @@ describe('WorkItemDetail component', () => { }); }); - it('shows an error message when the work item query was unsuccessful', async () => { + it('shows empty state with an error message when the work item query was unsuccessful', async () => { const errorHandler = jest.fn().mockRejectedValue('Oops'); createComponent({ handler: errorHandler }); await waitForPromises(); expect(errorHandler).toHaveBeenCalled(); - expect(findAlert().text()).toBe(i18n.fetchError); + expect(findEmptyState().props('description')).toBe(i18n.fetchError); + expect(findWorkItemTitle().exists()).toBe(false); }); it('shows an error message when WorkItemTitle emits an `error` event', async () => { @@ -413,6 +429,30 @@ describe('WorkItemDetail component', () => { }); }); + describe('assignees subscription', () => { + describe('when the assignees widget exists', () => { + it('calls the assignees subscription', async () => { + createComponent(); + await waitForPromises(); + + expect(assigneesSubscriptionHandler).toHaveBeenCalledWith({ + issuableId: workItemQueryResponse.data.workItem.id, + }); + }); + }); + + describe('when the assignees widget does not exist', () => { + it('does not call the assignees subscription', async () => { + const response = workItemResponseFactory({ assigneesWidgetPresent: false }); + const handler = jest.fn().mockResolvedValue(response); + createComponent({ handler }); + await waitForPromises(); + + expect(assigneesSubscriptionHandler).not.toHaveBeenCalled(); + }); + }); + }); + describe('dates subscription', () => { describe('when the due date widget exists', () => { it('calls the dates subscription', async () => { @@ -429,7 +469,7 @@ describe('WorkItemDetail component', () => { it('does not call the dates subscription', async () => { const response = workItemResponseFactory({ datesWidgetPresent: false }); const handler = jest.fn().mockResolvedValue(response); - createComponent({ handler, workItemsMvc2Enabled: true }); + createComponent({ handler }); await waitForPromises(); expect(datesSubscriptionHandler).not.toHaveBeenCalled(); @@ -440,9 +480,7 @@ describe('WorkItemDetail component', () => { describe('assignees widget', () => { it('renders assignees component when widget is returned from the API', async () => { - createComponent({ - workItemsMvc2Enabled: true, - }); + createComponent(); await waitForPromises(); expect(findWorkItemAssignees().exists()).toBe(true); @@ -450,7 +488,6 @@ describe('WorkItemDetail component', () => { it('does not render assignees component when widget is not returned from the API', async () => { createComponent({ - workItemsMvc2Enabled: true, handler: jest .fn() .mockResolvedValue(workItemResponseFactory({ assigneesWidgetPresent: false })), @@ -463,11 +500,13 @@ describe('WorkItemDetail component', () => { describe('labels widget', () => { it.each` - description | includeWidgets | exists - ${'renders when widget is returned from API'} | ${true} | ${true} - ${'does not render when widget is not returned from API'} | ${false} | ${false} - `('$description', async ({ includeWidgets, exists }) => { - createComponent({ includeWidgets, workItemsMvc2Enabled: true }); + description | labelsWidgetPresent | exists + ${'renders when widget is returned from API'} | ${true} | ${true} + ${'does not render when widget is not returned from API'} | ${false} | ${false} + `('$description', async ({ labelsWidgetPresent, exists }) => { + const response = workItemResponseFactory({ labelsWidgetPresent }); + const handler = jest.fn().mockResolvedValue(response); + createComponent({ handler }); await waitForPromises(); expect(findWorkItemLabels().exists()).toBe(exists); @@ -483,7 +522,7 @@ describe('WorkItemDetail component', () => { it(`${datesWidgetPresent ? 'renders' : 'does not render'} due date component`, async () => { const response = workItemResponseFactory({ datesWidgetPresent }); const handler = jest.fn().mockResolvedValue(response); - createComponent({ handler, workItemsMvc2Enabled: true }); + createComponent({ handler }); await waitForPromises(); expect(findWorkItemDueDate().exists()).toBe(exists); @@ -491,7 +530,7 @@ describe('WorkItemDetail component', () => { }); it('shows an error message when it emits an `error` event', async () => { - createComponent({ workItemsMvc2Enabled: true }); + createComponent(); await waitForPromises(); const updateError = 'Failed to update'; @@ -502,6 +541,19 @@ 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 }); + await waitForPromises(); + + expect(findWorkItemMilestone().exists()).toBe(exists); + }); + }); + describe('work item information', () => { beforeEach(() => { createComponent(); 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 1d76154a1f0..701406b9588 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 @@ -62,7 +62,7 @@ describe('WorkItemDueDate component', () => { createComponent({ canUpdate: true, startDate }); }); - it(exists ? 'renders' : 'does not render', () => { + it(`${exists ? 'renders' : 'does not render'}`, () => { expect(findStartDateButton().exists()).toBe(exists); }); }); @@ -172,7 +172,7 @@ describe('WorkItemDueDate component', () => { createComponent({ canUpdate: true, dueDate }); }); - it(exists ? 'renders' : 'does not render', () => { + it(`${exists ? 'renders' : 'does not render'}`, () => { expect(findDueDateButton().exists()).toBe(exists); }); }); 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 1d976897c15..e6ff7e8502d 100644 --- a/spec/frontend/work_items/components/work_item_labels_spec.js +++ b/spec/frontend/work_items/components/work_item_labels_spec.js @@ -7,10 +7,18 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import 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 WorkItemLabels from '~/work_items/components/work_item_labels.vue'; -import { i18n } from '~/work_items/constants'; -import { temporaryConfig, resolvers } from '~/graphql_shared/issuable_client'; -import { projectLabelsResponse, mockLabels, workItemQueryResponse } from '../mock_data'; +import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS } from '~/work_items/constants'; +import { + projectLabelsResponse, + mockLabels, + workItemQueryResponse, + workItemResponseFactory, + updateWorkItemMutationResponse, + workItemLabelsSubscriptionResponse, +} from '../mock_data'; Vue.use(VueApollo); @@ -21,32 +29,32 @@ describe('WorkItemLabels component', () => { const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); - const findEmptyState = () => wrapper.findByTestId('empty-state'); + const findLabelsTitle = () => wrapper.findByTestId('labels-title'); + const workItemQuerySuccess = jest.fn().mockResolvedValue(workItemQueryResponse); const successSearchQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse); + const successUpdateWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(updateWorkItemMutationResponse); + const subscriptionHandler = jest.fn().mockResolvedValue(workItemLabelsSubscriptionResponse); const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); const createComponent = ({ - labels = mockLabels, canUpdate = true, + workItemQueryHandler = workItemQuerySuccess, searchQueryHandler = successSearchQueryHandler, + updateWorkItemMutationHandler = successUpdateWorkItemMutationHandler, } = {}) => { - const apolloProvider = createMockApollo([[labelSearchQuery, searchQueryHandler]], resolvers, { - typePolicies: temporaryConfig.cacheConfig.typePolicies, - }); - - apolloProvider.clients.defaultClient.writeQuery({ - query: workItemQuery, - variables: { - id: workItemId, - }, - data: workItemQueryResponse.data, - }); + const apolloProvider = createMockApollo([ + [workItemQuery, workItemQueryHandler], + [labelSearchQuery, searchQueryHandler], + [updateWorkItemMutation, updateWorkItemMutationHandler], + [workItemLabelsSubscription, subscriptionHandler], + ]); wrapper = mountExtended(WorkItemLabels, { propsData: { - labels, workItemId, canUpdate, fullPath: 'test-project-path', @@ -60,6 +68,12 @@ describe('WorkItemLabels component', () => { wrapper.destroy(); }); + it('has a label', () => { + createComponent(); + + expect(findTokenSelector().props('ariaLabelledby')).toEqual(findLabelsTitle().attributes('id')); + }); + it('focuses token selector on token selector input event', async () => { createComponent(); findTokenSelector().vm.$emit('input', [mockLabels[0]]); @@ -151,7 +165,7 @@ describe('WorkItemLabels component', () => { findTokenSelector().vm.$emit('focus'); await waitForPromises(); - expect(wrapper.emitted('error')).toEqual([[i18n.fetchError]]); + expect(wrapper.emitted('error')).toEqual([[I18N_WORK_ITEM_ERROR_FETCHING_LABELS]]); }); it('should search for with correct key after text input', async () => { @@ -163,7 +177,53 @@ describe('WorkItemLabels component', () => { await waitForPromises(); expect(successSearchQueryHandler).toHaveBeenCalledWith( - expect.objectContaining({ search: searchKey }), + expect.objectContaining({ searchTerm: searchKey }), ); }); + + describe('when clicking outside the token selector', () => { + it('calls a mutation with correct variables', () => { + createComponent(); + + findTokenSelector().vm.$emit('input', [mockLabels[0]]); + findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + + expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + labelsWidget: { addLabelIds: [mockLabels[0].id], removeLabelIds: [] }, + id: 'gid://gitlab/WorkItem/1', + }, + }); + }); + + it('emits an error and resets labels if mutation was rejected', async () => { + const workItemQueryHandler = jest.fn().mockResolvedValue(workItemResponseFactory()); + + createComponent({ updateWorkItemMutationHandler: errorHandler, workItemQueryHandler }); + + await waitForPromises(); + + const initialLabels = findTokenSelector().props('selectedTokens'); + + findTokenSelector().vm.$emit('input', [mockLabels[0]]); + findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + + await waitForPromises(); + + const updatedLabels = findTokenSelector().props('selectedTokens'); + + expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]); + expect(updatedLabels).toEqual(initialLabels); + }); + + it('has a subscription', async () => { + createComponent(); + + await waitForPromises(); + + expect(subscriptionHandler).toHaveBeenCalledWith({ + issuableId: workItemId, + }); + }); + }); }); 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 434c1db8a2c..ab3ea623e3e 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 @@ -28,6 +28,7 @@ describe('WorkItemLinksForm', () => { listResponse = availableWorkItemsResponse, typesResponse = projectWorkItemTypesQueryResponse, parentConfidential = false, + hasIterationsFeature = false, } = {}) => { wrapper = shallowMountExtended(WorkItemLinksForm, { apolloProvider: createMockApollo([ @@ -39,6 +40,7 @@ describe('WorkItemLinksForm', () => { propsData: { issuableGid: 'gid://gitlab/WorkItem/1', parentConfidential }, provide: { projectPath: 'project/path', + hasIterationsFeature, }, }); 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 287ec022d3f..e3f3b74f296 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 @@ -10,8 +10,8 @@ describe('WorkItemLinksMenu', () => { wrapper = shallowMountExtended(WorkItemLinksMenu); }; - const findDropdown = () => wrapper.find(GlDropdown); - const findRemoveDropdownItem = () => wrapper.find(GlDropdownItem); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findRemoveDropdownItem = () => wrapper.findComponent(GlDropdownItem); beforeEach(async () => { createComponent(); 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 876aedff08b..6961996f912 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 @@ -5,7 +5,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; +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 workItemQuery from '~/work_items/graphql/work_item.query.graphql'; @@ -21,16 +21,29 @@ import { Vue.use(VueApollo); -const issueConfidentialityResponse = (confidential = false) => ({ +const issueDetailsResponse = (confidential = false) => ({ data: { workspace: { - id: '1', - __typename: 'Project', + id: 'gid://gitlab/Project/1', issuable: { - __typename: 'Issue', id: 'gid://gitlab/Issue/4', confidential, + iteration: { + id: 'gid://gitlab/Iteration/1124', + title: null, + startDate: '2022-06-22', + dueDate: '2022-07-19', + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/-/iterations/1124', + iterationCadence: { + id: 'gid://gitlab/Iterations::Cadence/1101', + title: 'Quod voluptates quidem ea eaque eligendi ex corporis.', + __typename: 'IterationCadence', + }, + __typename: 'Iteration', + }, + __typename: 'Issue', }, + __typename: 'Project', }, }, }); @@ -55,14 +68,15 @@ describe('WorkItemLinks', () => { data = {}, fetchHandler = jest.fn().mockResolvedValue(workItemHierarchyResponse), mutationHandler = mutationChangeParentHandler, - confidentialQueryHandler = jest.fn().mockResolvedValue(issueConfidentialityResponse()), + issueDetailsQueryHandler = jest.fn().mockResolvedValue(issueDetailsResponse()), + hasIterationsFeature = false, } = {}) => { mockApollo = createMockApollo( [ [getWorkItemLinksQuery, fetchHandler], [changeWorkItemParentMutation, mutationHandler], [workItemQuery, childWorkItemQueryHandler], - [issueConfidentialQuery, confidentialQueryHandler], + [issueDetailsQuery, issueDetailsQueryHandler], ], {}, { addTypename: true }, @@ -77,6 +91,7 @@ describe('WorkItemLinks', () => { provide: { projectPath: 'project/path', iid: '1', + hasIterationsFeature, }, propsData: { issuableId: 1 }, apolloProvider: mockApollo, @@ -266,7 +281,7 @@ describe('WorkItemLinks', () => { describe('when parent item is confidential', () => { it('passes correct confidentiality status to form', async () => { await createComponent({ - confidentialQueryHandler: jest.fn().mockResolvedValue(issueConfidentialityResponse(true)), + issueDetailsQueryHandler: jest.fn().mockResolvedValue(issueDetailsResponse(true)), }); 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 new file mode 100644 index 00000000000..08cdf62ae52 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_milestone_spec.js @@ -0,0 +1,247 @@ +import { + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + GlSkeletonLoader, + GlFormGroup, + GlDropdownText, +} from '@gitlab/ui'; +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 createMockApollo from 'helpers/mock_apollo_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; +import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql'; +import { + projectMilestonesResponse, + projectMilestonesResponseWithNoMilestones, + mockMilestoneWidgetResponse, + workItemResponseFactory, + updateWorkItemMutationErrorResponse, +} from 'jest/work_items/mock_data'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; + +describe('WorkItemMilestone component', () => { + Vue.use(VueApollo); + + let wrapper; + + const workItemId = 'gid://gitlab/WorkItem/1'; + const workItemType = 'Task'; + const fullPath = 'full-path'; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findNoMilestoneDropdownItem = () => wrapper.findByTestId('no-milestone'); + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findFirstDropdownItem = () => findDropdownItems().at(0); + const findDropdownTexts = () => wrapper.findAllComponents(GlDropdownText); + const findDropdownItemAtIndex = (index) => findDropdownItems().at(index); + const findDisabledTextSpan = () => wrapper.findByTestId('disabled-text'); + const findDropdownTextAtIndex = (index) => findDropdownTexts().at(index); + const findInputGroup = () => wrapper.findComponent(GlFormGroup); + + const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true }); + + const networkResolvedValue = new Error(); + + const successSearchQueryHandler = jest.fn().mockResolvedValue(projectMilestonesResponse); + const successSearchWithNoMatchingMilestones = jest + .fn() + .mockResolvedValue(projectMilestonesResponseWithNoMilestones); + + const showDropdown = () => { + findDropdown().vm.$emit('shown'); + }; + + const hideDropdown = () => { + findDropdown().vm.$emit('hide'); + }; + + const createComponent = ({ + canUpdate = true, + milestone = mockMilestoneWidgetResponse, + searchQueryHandler = successSearchQueryHandler, + } = {}) => { + const apolloProvider = createMockApollo( + [[projectMilestonesQuery, searchQueryHandler]], + resolvers, + { + typePolicies: temporaryConfig.cacheConfig.typePolicies, + }, + ); + + apolloProvider.clients.defaultClient.writeQuery({ + query: workItemQuery, + variables: { + id: workItemId, + }, + data: workItemQueryResponse.data, + }); + + wrapper = shallowMountExtended(WorkItemMilestone, { + apolloProvider, + propsData: { + canUpdate, + workItemMilestone: milestone, + workItemId, + workItemType, + fullPath, + }, + stubs: { + GlDropdown, + GlSearchBoxByType, + }, + }); + }; + + it('has "Milestone" label', () => { + createComponent(); + + expect(findInputGroup().exists()).toBe(true); + expect(findInputGroup().attributes('label')).toBe(WorkItemMilestone.i18n.MILESTONE); + }); + + describe('Default text with canUpdate false and milestone value', () => { + describe.each` + description | milestone | value + ${'when no milestone'} | ${null} | ${WorkItemMilestone.i18n.NONE} + ${'when milestone set'} | ${mockMilestoneWidgetResponse} | ${mockMilestoneWidgetResponse.title} + `('$description', ({ milestone, value }) => { + it(`has a value of "${value}"`, () => { + createComponent({ canUpdate: false, milestone }); + + expect(findDisabledTextSpan().text()).toBe(value); + expect(findDropdown().exists()).toBe(false); + }); + }); + }); + + describe('Default text value when canUpdate true and no milestone set', () => { + it(`has a value of "Add to milestone"`, () => { + createComponent({ canUpdate: true, milestone: null }); + + expect(findDropdown().props('text')).toBe(WorkItemMilestone.i18n.MILESTONE_PLACEHOLDER); + }); + }); + + describe('Dropdown search', () => { + it('has the search box', () => { + createComponent(); + + expect(findSearchBox().exists()).toBe(true); + }); + + it('shows no matching results when no items', () => { + createComponent({ + searchQueryHandler: successSearchWithNoMatchingMilestones, + }); + + expect(findDropdownTextAtIndex(0).text()).toBe(WorkItemMilestone.i18n.NO_MATCHING_RESULTS); + expect(findDropdownItems()).toHaveLength(1); + expect(findDropdownTexts()).toHaveLength(1); + }); + }); + + describe('Dropdown options', () => { + beforeEach(() => { + createComponent({ canUpdate: true }); + }); + + it('shows the skeleton loader when the items are being fetched on click', async () => { + showDropdown(); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('shows the milestones in dropdown when the items have finished fetching', async () => { + showDropdown(); + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findNoMilestoneDropdownItem().exists()).toBe(true); + expect(findDropdownItems()).toHaveLength( + projectMilestonesResponse.data.workspace.attributes.nodes.length + 1, + ); + }); + + it('changes the milestone to null when clicked on no milestone', async () => { + showDropdown(); + findFirstDropdownItem().vm.$emit('click'); + + hideDropdown(); + await nextTick(); + expect(findDropdown().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findDropdown().props('loading')).toBe(false); + expect(findDropdown().props('text')).toBe(WorkItemMilestone.i18n.MILESTONE_PLACEHOLDER); + }); + + it('changes the milestone to the selected milestone', async () => { + const milestoneIndex = 1; + /** the index is -1 since no matching results is also a dropdown item */ + const milestoneAtIndex = + projectMilestonesResponse.data.workspace.attributes.nodes[milestoneIndex - 1]; + showDropdown(); + + await waitForPromises(); + findDropdownItemAtIndex(milestoneIndex).vm.$emit('click'); + + hideDropdown(); + await waitForPromises(); + + expect(findDropdown().props('text')).toBe(milestoneAtIndex.title); + }); + }); + + describe('Error handlers', () => { + it.each` + errorType | expectedErrorMessage | mockValue | resolveFunction + ${'graphql error'} | ${'Something went wrong while updating the task. Please try again.'} | ${updateWorkItemMutationErrorResponse} | ${'mockResolvedValue'} + ${'network error'} | ${'Something went wrong while updating the task. Please try again.'} | ${networkResolvedValue} | ${'mockRejectedValue'} + `( + 'emits an error when there is a $errorType', + async ({ mockValue, expectedErrorMessage, resolveFunction }) => { + createComponent({ + mutationHandler: jest.fn()[resolveFunction](mockValue), + canUpdate: true, + }); + + showDropdown(); + findFirstDropdownItem().vm.$emit('click'); + hideDropdown(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[expectedErrorMessage]]); + }, + ); + }); + + describe('Tracking event', () => { + it('tracks updating the milestone', async () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + createComponent({ canUpdate: true }); + + showDropdown(); + findFirstDropdownItem().vm.$emit('click'); + hideDropdown(); + + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_milestone', { + category: TRACKING_CATEGORY_SHOW, + label: 'item_milestone', + property: 'type_Task', + }); + }); + }); +}); 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 index 95ddfc3980e..182fb0f8cb6 100644 --- a/spec/frontend/work_items/components/work_item_type_icon_spec.js +++ b/spec/frontend/work_items/components/work_item_type_icon_spec.js @@ -51,7 +51,7 @@ describe('Work Item type component', () => { }); it('renders the icon in gray color', () => { - expect(findIcon().classes()).toContain('gl-text-gray-500'); + expect(findIcon().classes()).toContain('gl-text-secondary'); }); it('shows tooltip on hover when props passed', () => { diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index e1bc8d2f6b7..ed90b11222a 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -17,6 +17,25 @@ export const mockAssignees = [ }, ]; +export const mockLabels = [ + { + __typename: 'Label', + id: 'gid://gitlab/Label/1', + title: 'Label 1', + description: '', + color: '#f00', + textColor: '#00f', + }, + { + __typename: 'Label', + id: 'gid://gitlab/Label/2', + title: 'Label::2', + description: '', + color: '#b00', + textColor: '#00b', + }, +]; + export const workItemQueryResponse = { data: { workItem: { @@ -50,6 +69,8 @@ export const workItemQueryResponse = { description: 'some **great** text', descriptionHtml: '<p data-sourcepos="1:1-1:19" dir="auto">some <strong>great</strong> text</p>', + lastEditedAt: null, + lastEditedBy: null, }, { __typename: 'WorkItemWidgetAssignees', @@ -163,9 +184,15 @@ export const workItemResponseFactory = ({ allowsMultipleAssignees = true, assigneesWidgetPresent = true, datesWidgetPresent = true, + labelsWidgetPresent = true, weightWidgetPresent = true, + milestoneWidgetPresent = true, + iterationWidgetPresent = true, confidential = false, canInviteMembers = false, + allowsScopedLabels = false, + lastEditedAt = null, + lastEditedBy = null, parent = mockParent.parent, } = {}) => ({ data: { @@ -200,6 +227,8 @@ export const workItemResponseFactory = ({ description: 'some **great** text', descriptionHtml: '<p data-sourcepos="1:1-1:19" dir="auto">some <strong>great</strong> text</p>', + lastEditedAt, + lastEditedBy, }, assigneesWidgetPresent ? { @@ -212,6 +241,16 @@ export const workItemResponseFactory = ({ }, } : { type: 'MOCK TYPE' }, + labelsWidgetPresent + ? { + __typename: 'WorkItemWidgetLabels', + type: 'LABELS', + allowsScopedLabels, + labels: { + nodes: mockLabels, + }, + } + : { type: 'MOCK TYPE' }, datesWidgetPresent ? { __typename: 'WorkItemWidgetStartAndDueDate', @@ -227,6 +266,30 @@ export const workItemResponseFactory = ({ weight: 0, } : { type: 'MOCK TYPE' }, + iterationWidgetPresent + ? { + __typename: 'WorkItemWidgetIteration', + type: 'ITERATION', + iteration: { + description: null, + id: 'gid://gitlab/Iteration/1215', + iid: '182', + title: 'Iteration default title', + startDate: '2022-09-22', + dueDate: '2022-09-30', + }, + } + : { type: 'MOCK TYPE' }, + milestoneWidgetPresent + ? { + __typename: 'WorkItemWidgetMilestone', + dueDate: null, + expired: false, + id: 'gid://gitlab/Milestone/30', + title: 'v4.0', + type: 'MILESTONE', + } + : { type: 'MOCK TYPE' }, { __typename: 'WorkItemWidgetHierarchy', type: 'HIERARCHY', @@ -331,6 +394,11 @@ export const createWorkItemFromTaskMutationResponse = { type: 'DESCRIPTION', description: 'New description', descriptionHtml: '<p>New description</p>', + lastEditedAt: '2022-09-21T06:18:42Z', + lastEditedBy: { + name: 'Administrator', + webPath: '/root', + }, }, ], }, @@ -444,6 +512,61 @@ export const workItemWeightSubscriptionResponse = { }, }; +export const workItemAssigneesSubscriptionResponse = { + data: { + issuableAssigneesUpdated: { + id: 'gid://gitlab/WorkItem/1', + widgets: [ + { + __typename: 'WorkItemAssigneesWeight', + assignees: { + nodes: [mockAssignees[0]], + }, + }, + ], + }, + }, +}; + +export const workItemLabelsSubscriptionResponse = { + data: { + issuableLabelsUpdated: { + id: 'gid://gitlab/WorkItem/1', + widgets: [ + { + __typename: 'WorkItemWidgetLabels', + type: 'LABELS', + allowsScopedLabels: false, + labels: { + nodes: mockLabels, + }, + }, + ], + }, + }, +}; + +export const workItemIterationSubscriptionResponse = { + data: { + issuableIterationUpdated: { + id: 'gid://gitlab/WorkItem/1', + widgets: [ + { + __typename: 'WorkItemWidgetIteration', + iteration: { + description: 'Iteration description', + dueDate: '2022-07-29', + id: 'gid://gitlab/Iteration/1125', + iid: '95', + startDate: '2022-06-22', + title: 'Iteration subcription title', + }, + }, + ], + }, + }, +}; + export const workItemHierarchyEmptyResponse = { data: { workItem: { @@ -857,25 +980,6 @@ export const currentUserNullResponse = { }, }; -export const mockLabels = [ - { - __typename: 'Label', - id: 'gid://gitlab/Label/1', - title: 'Label 1', - description: '', - color: '#f00', - textColor: '#00f', - }, - { - __typename: 'Label', - id: 'gid://gitlab/Label/2', - title: 'Label 2', - description: '', - color: '#b00', - textColor: '#00b', - }, -]; - export const projectLabelsResponse = { data: { workspace: { @@ -887,3 +991,134 @@ export const projectLabelsResponse = { }, }, }; + +export const mockIterationWidgetResponse = { + description: 'Iteration description', + dueDate: '2022-07-19', + id: 'gid://gitlab/Iteration/1124', + iid: '91', + startDate: '2022-06-22', + title: 'Iteration title widget', +}; + +export const groupIterationsResponse = { + data: { + workspace: { + id: 'gid://gitlab/Group/22', + attributes: { + nodes: [ + { + id: 'gid://gitlab/Iteration/1124', + title: null, + startDate: '2022-06-22', + dueDate: '2022-07-19', + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/-/iterations/1124', + iterationCadence: { + id: 'gid://gitlab/Iterations::Cadence/1101', + title: 'Quod voluptates quidem ea eaque eligendi ex corporis.', + __typename: 'IterationCadence', + }, + __typename: 'Iteration', + state: 'current', + }, + { + id: 'gid://gitlab/Iteration/1185', + title: null, + startDate: '2022-07-06', + dueDate: '2022-07-19', + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/-/iterations/1185', + iterationCadence: { + id: 'gid://gitlab/Iterations::Cadence/1144', + title: 'Quo velit perspiciatis saepe aut omnis voluptas ab eos.', + __typename: 'IterationCadence', + }, + __typename: 'Iteration', + state: 'current', + }, + { + id: 'gid://gitlab/Iteration/1194', + title: null, + startDate: '2022-07-06', + dueDate: '2022-07-19', + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/-/iterations/1194', + iterationCadence: { + id: 'gid://gitlab/Iterations::Cadence/1152', + title: + 'Minima aut consequatur magnam vero doloremque accusamus maxime repellat voluptatem qui.', + __typename: 'IterationCadence', + }, + __typename: 'Iteration', + state: 'current', + }, + ], + __typename: 'IterationConnection', + }, + __typename: 'Group', + }, + }, +}; + +export const groupIterationsResponseWithNoIterations = { + data: { + workspace: { + id: 'gid://gitlab/Group/22', + attributes: { + nodes: [], + __typename: 'IterationConnection', + }, + __typename: 'Group', + }, + }, +}; + +export const mockMilestoneWidgetResponse = { + dueDate: null, + expired: false, + id: 'gid://gitlab/Milestone/30', + title: 'v4.0', +}; + +export const projectMilestonesResponse = { + data: { + workspace: { + id: 'gid://gitlab/Project/1', + attributes: { + nodes: [ + { + id: 'gid://gitlab/Milestone/5', + title: 'v4.0', + webUrl: '/gitlab-org/gitlab-test/-/milestones/5', + dueDate: null, + expired: false, + __typename: 'Milestone', + state: 'active', + }, + { + id: 'gid://gitlab/Milestone/4', + title: 'v3.0', + webUrl: '/gitlab-org/gitlab-test/-/milestones/4', + dueDate: null, + expired: false, + __typename: 'Milestone', + state: 'active', + }, + ], + __typename: 'MilestoneConnection', + }, + __typename: 'Project', + }, + }, +}; + +export const projectMilestonesResponseWithNoMilestones = { + data: { + workspace: { + id: 'gid://gitlab/Project/1', + attributes: { + nodes: [], + __typename: 'MilestoneConnection', + }, + __typename: 'Project', + }, + }, +}; diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js index ab370e2ca8b..66a917d8052 100644 --- a/spec/frontend/work_items/router_spec.js +++ b/spec/frontend/work_items/router_spec.js @@ -4,15 +4,19 @@ import VueApollo from 'vue-apollo'; import workItemWeightSubscription from 'ee_component/work_items/graphql/work_item_weight.subscription.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import { + workItemAssigneesSubscriptionResponse, workItemDatesSubscriptionResponse, workItemResponseFactory, workItemTitleSubscriptionResponse, workItemWeightSubscriptionResponse, + workItemLabelsSubscriptionResponse, } from 'jest/work_items/mock_data'; import App from '~/work_items/components/app.vue'; import workItemQuery from '~/work_items/graphql/work_item.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 workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import WorkItemsRoot from '~/work_items/pages/work_item_root.vue'; import { createRouter } from '~/work_items/router'; @@ -26,6 +30,10 @@ describe('Work items router', () => { const datesSubscriptionHandler = jest.fn().mockResolvedValue(workItemDatesSubscriptionResponse); const titleSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); const weightSubscriptionHandler = jest.fn().mockResolvedValue(workItemWeightSubscriptionResponse); + const assigneesSubscriptionHandler = jest + .fn() + .mockResolvedValue(workItemAssigneesSubscriptionResponse); + const labelsSubscriptionHandler = jest.fn().mockResolvedValue(workItemLabelsSubscriptionResponse); const createComponent = async (routeArg) => { const router = createRouter('/work_item'); @@ -37,6 +45,8 @@ describe('Work items router', () => { [workItemQuery, workItemQueryHandler], [workItemDatesSubscription, datesSubscriptionHandler], [workItemTitleSubscription, titleSubscriptionHandler], + [workItemAssigneesSubscription, assigneesSubscriptionHandler], + [workItemLabelsSubscription, labelsSubscriptionHandler], ]; if (IS_EE) { |