diff options
Diffstat (limited to 'spec/frontend/work_items/components')
15 files changed, 1203 insertions, 309 deletions
diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js index de20369eb1b..13e04ef6671 100644 --- a/spec/frontend/work_items/components/item_title_spec.js +++ b/spec/frontend/work_items/components/item_title_spec.js @@ -49,6 +49,6 @@ describe('ItemTitle', () => { findInputEl().element.innerText = mockUpdatedTitle; await findInputEl().trigger(sourceEvent); - expect(wrapper.emitted(eventName)).toBeTruthy(); + expect(wrapper.emitted(eventName)).toBeDefined(); }); }); 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 a1f1d47ab90..3c312fb4552 100644 --- a/spec/frontend/work_items/components/work_item_actions_spec.js +++ b/spec/frontend/work_items/components/work_item_actions_spec.js @@ -1,15 +1,30 @@ -import { GlModal } from '@gitlab/ui'; +import { GlDropdownDivider, GlModal } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import WorkItemActions from '~/work_items/components/work_item_actions.vue'; +const TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION = 'confidentiality-toggle-action'; +const TEST_ID_DELETE_ACTION = 'delete-action'; + describe('WorkItemActions component', () => { let wrapper; let glModalDirective; const findModal = () => wrapper.findComponent(GlModal); const findConfidentialityToggleButton = () => - wrapper.findByTestId('confidentiality-toggle-action'); - const findDeleteButton = () => wrapper.findByTestId('delete-action'); + wrapper.findByTestId(TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION); + const findDeleteButton = () => wrapper.findByTestId(TEST_ID_DELETE_ACTION); + const findDropdownItems = () => wrapper.findAll('[data-testid="work-item-actions-dropdown"] > *'); + const findDropdownItemsActual = () => + findDropdownItems().wrappers.map((x) => { + if (x.is(GlDropdownDivider)) { + return { divider: true }; + } + + return { + testId: x.attributes('data-testid'), + text: x.text(), + }; + }); const createComponent = ({ canUpdate = true, @@ -19,7 +34,14 @@ describe('WorkItemActions component', () => { } = {}) => { glModalDirective = jest.fn(); wrapper = shallowMountExtended(WorkItemActions, { - propsData: { workItemId: '123', canUpdate, canDelete, isConfidential, isParentConfidential }, + propsData: { + workItemId: '123', + canUpdate, + canDelete, + isConfidential, + isParentConfidential, + workItemType: 'Task', + }, directives: { glModal: { bind(_, { value }) { @@ -44,8 +66,19 @@ describe('WorkItemActions component', () => { it('renders dropdown actions', () => { createComponent(); - expect(findConfidentialityToggleButton().exists()).toBe(true); - expect(findDeleteButton().exists()).toBe(true); + expect(findDropdownItemsActual()).toEqual([ + { + testId: TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION, + text: 'Turn on confidentiality', + }, + { + divider: true, + }, + { + testId: TEST_ID_DELETE_ACTION, + text: 'Delete task', + }, + ]); }); describe('toggle confidentiality action', () => { @@ -103,7 +136,8 @@ describe('WorkItemActions component', () => { canDelete: false, }); - expect(wrapper.findByTestId('delete-action').exists()).toBe(false); + expect(findDeleteButton().exists()).toBe(false); + expect(wrapper.findComponent(GlDropdownDivider).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 f0ef8aee7a9..28231fad108 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -1,4 +1,4 @@ -import { GlLink, GlTokenSelector, GlSkeletonLoader } from '@gitlab/ui'; +import { GlLink, GlTokenSelector, GlSkeletonLoader, GlIntersectionObserver } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -8,12 +8,17 @@ 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 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 } from '~/work_items/graphql/provider'; +import { + i18n, + TASK_TYPE_NAME, + TRACKING_CATEGORY_SHOW, + DEFAULT_PAGE_SIZE_ASSIGNEES, +} from '~/work_items/constants'; import { projectMembersResponseWithCurrentUser, mockAssignees, @@ -22,6 +27,8 @@ import { currentUserNullResponse, projectMembersResponseWithoutCurrentUser, updateWorkItemMutationResponse, + projectMembersResponseWithCurrentUserWithNextPage, + projectMembersResponseWithNoMatchingUsers, } from '../mock_data'; Vue.use(VueApollo); @@ -40,15 +47,25 @@ describe('WorkItemAssignees component', () => { const findEmptyState = () => wrapper.findByTestId('empty-state'); const findAssignSelfButton = () => wrapper.findByTestId('assign-self'); const findAssigneesTitle = () => wrapper.findByTestId('assignees-title'); + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); + + const triggerInfiniteScroll = () => + wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); const successSearchQueryHandler = jest .fn() .mockResolvedValue(projectMembersResponseWithCurrentUser); + const successSearchQueryHandlerWithMoreAssignees = jest + .fn() + .mockResolvedValue(projectMembersResponseWithCurrentUserWithNextPage); const successCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserResponse); const noCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserNullResponse); const successUpdateWorkItemMutationHandler = jest .fn() .mockResolvedValue(updateWorkItemMutationResponse); + const successSearchWithNoMatchingUsers = jest + .fn() + .mockResolvedValue(projectMembersResponseWithNoMatchingUsers); const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); @@ -82,9 +99,6 @@ describe('WorkItemAssignees component', () => { }); wrapper = mountExtended(WorkItemAssignees, { - provide: { - fullPath: 'test-project-path', - }, propsData: { assignees, workItemId, @@ -92,6 +106,7 @@ describe('WorkItemAssignees component', () => { workItemType: TASK_TYPE_NAME, canUpdate, canInviteMembers, + fullPath: 'test-project-path', }, attachTo: document.body, apolloProvider, @@ -459,4 +474,56 @@ describe('WorkItemAssignees component', () => { expect(findInviteMembersTrigger().exists()).toBe(true); }); }); + + describe('load more assignees', () => { + it('does not have intersection observer when no matching users', async () => { + createComponent({ searchQueryHandler: successSearchWithNoMatchingUsers }); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findIntersectionObserver().exists()).toBe(false); + }); + + it('does not trigger load more when does not have next page', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + + expect(findIntersectionObserver().exists()).toBe(false); + }); + + it('triggers load more when there are more users', async () => { + createComponent({ searchQueryHandler: successSearchQueryHandlerWithMoreAssignees }); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findIntersectionObserver().exists()).toBe(true); + + triggerInfiniteScroll(); + + expect(successSearchQueryHandlerWithMoreAssignees).toHaveBeenCalledWith({ + first: DEFAULT_PAGE_SIZE_ASSIGNEES, + after: + projectMembersResponseWithCurrentUserWithNextPage.data.workspace.users.pageInfo.endCursor, + search: '', + fullPath: 'test-project-path', + }); + }); + }); }); 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 8017c46dea8..d3165d8dc26 100644 --- a/spec/frontend/work_items/components/work_item_description_spec.js +++ b/spec/frontend/work_items/components/work_item_description_spec.js @@ -10,9 +10,9 @@ import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import WorkItemDescription from '~/work_items/components/work_item_description.vue'; import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; -import updateWorkItemWidgetsMutation from '~/work_items/graphql/update_work_item_widgets.mutation.graphql'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import { - updateWorkItemWidgetsResponse, + updateWorkItemMutationResponse, workItemResponseFactory, workItemQueryResponse, } from '../mock_data'; @@ -31,7 +31,7 @@ describe('WorkItemDescription', () => { Vue.use(VueApollo); - const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemWidgetsResponse); + const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); const findEditButton = () => wrapper.find('[data-testid="edit-description"]'); const findMarkdownField = () => wrapper.findComponent(MarkdownField); @@ -53,13 +53,11 @@ describe('WorkItemDescription', () => { wrapper = shallowMount(WorkItemDescription, { apolloProvider: createMockApollo([ [workItemQuery, workItemResponseHandler], - [updateWorkItemWidgetsMutation, mutationHandler], + [updateWorkItemMutation, mutationHandler], ]), propsData: { workItemId: id, - }, - provide: { - fullPath: '/group/project', + fullPath: 'test-project-path', }, stubs: { MarkdownField, @@ -175,7 +173,7 @@ describe('WorkItemDescription', () => { isEditing: true, mutationHandler: jest.fn().mockResolvedValue({ data: { - workItemUpdateWidgets: { + workItemUpdate: { workItem: {}, errors: [error], }, 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 01891012f99..6b1ef8971d3 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 @@ -113,7 +113,7 @@ describe('WorkItemDetailModal component', () => { createComponent(); findModal().vm.$emit('hide'); - expect(wrapper.emitted('close')).toBeTruthy(); + expect(wrapper.emitted('close')).toHaveLength(1); }); it('hides the modal when WorkItemDetail emits `close` event', () => { diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js new file mode 100644 index 00000000000..b047e0dc8d7 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -0,0 +1,522 @@ +import { GlAlert, GlBadge, GlLoadingIcon, GlSkeletonLoader, GlButton } 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'; +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 WorkItemDueDate from '~/work_items/components/work_item_due_date.vue'; +import WorkItemState from '~/work_items/components/work_item_state.vue'; +import WorkItemTitle from '~/work_items/components/work_item_title.vue'; +import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; +import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; +import 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 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, + workItemDatesSubscriptionResponse, + workItemResponseFactory, + workItemTitleSubscriptionResponse, + workItemWeightSubscriptionResponse, +} from '../mock_data'; + +describe('WorkItemDetail component', () => { + let wrapper; + useLocalStorageSpy(); + + Vue.use(VueApollo); + + const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true }); + const workItemQueryResponseWithoutParent = workItemResponseFactory({ + parent: null, + canUpdate: true, + canDelete: true, + }); + 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 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); + const findWorkItemDueDate = () => wrapper.findComponent(WorkItemDueDate); + const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees); + const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels); + const findParent = () => wrapper.find('[data-testid="work-item-parent"]'); + const findParentButton = () => findParent().findComponent(GlButton); + const findCloseButton = () => wrapper.find('[data-testid="work-item-close"]'); + const findWorkItemType = () => wrapper.find('[data-testid="work-item-type"]'); + const findWorkItemInformationAlert = () => wrapper.findComponent(WorkItemInformation); + const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); + + const createComponent = ({ + isModal = false, + updateInProgress = false, + workItemId = workItemQueryResponse.data.workItem.id, + handler = successHandler, + subscriptionHandler = titleSubscriptionHandler, + confidentialityMock = [updateWorkItemMutation, jest.fn()], + workItemsMvc2Enabled = false, + includeWidgets = false, + error = undefined, + } = {}) => { + const handlers = [ + [workItemQuery, handler], + [workItemTitleSubscription, subscriptionHandler], + [workItemDatesSubscription, datesSubscriptionHandler], + confidentialityMock, + ]; + + if (IS_EE) { + handlers.push([workItemWeightSubscription, weightSubscriptionHandler]); + } + + wrapper = shallowMount(WorkItemDetail, { + apolloProvider: createMockApollo( + handlers, + {}, + { + typePolicies: includeWidgets ? temporaryConfig.cacheConfig.typePolicies : {}, + }, + ), + propsData: { isModal, workItemId }, + data() { + return { + updateInProgress, + error, + }; + }, + provide: { + glFeatures: { + workItemsMvc2: workItemsMvc2Enabled, + }, + hasIssueWeightsFeature: true, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when there is no `workItemId` prop', () => { + beforeEach(() => { + createComponent({ workItemId: null }); + }); + + it('skips the work item query', () => { + expect(successHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when loading', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders skeleton loader', () => { + expect(findSkeleton().exists()).toBe(true); + expect(findWorkItemState().exists()).toBe(false); + expect(findWorkItemTitle().exists()).toBe(false); + }); + }); + + describe('when loaded', () => { + beforeEach(() => { + createComponent(); + return waitForPromises(); + }); + + it('does not render skeleton', () => { + expect(findSkeleton().exists()).toBe(false); + expect(findWorkItemState().exists()).toBe(true); + expect(findWorkItemTitle().exists()).toBe(true); + }); + + it('updates the document title', () => { + expect(document.title).toEqual('Updated title · Task · test-project-path'); + }); + }); + + describe('close button', () => { + describe('when isModal prop is false', () => { + it('does not render', async () => { + createComponent({ isModal: false }); + await waitForPromises(); + + expect(findCloseButton().exists()).toBe(false); + }); + }); + + describe('when isModal prop is true', () => { + it('renders', async () => { + createComponent({ isModal: true }); + await waitForPromises(); + + expect(findCloseButton().props('icon')).toBe('close'); + expect(findCloseButton().attributes('aria-label')).toBe('Close'); + }); + + it('emits `close` event when clicked', async () => { + createComponent({ isModal: true }); + await waitForPromises(); + + findCloseButton().vm.$emit('click'); + + expect(wrapper.emitted('close')).toEqual([[]]); + }); + }); + }); + + describe('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')).toBeUndefined(); + + 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(); + + expect(findWorkItemDescription().exists()).toBe(false); + }); + + it('shows description widget if description loads', async () => { + createComponent(); + await waitForPromises(); + + expect(findWorkItemDescription().exists()).toBe(true); + }); + }); + + describe('secondary breadcrumbs', () => { + it('does not show secondary breadcrumbs by default', () => { + createComponent(); + + expect(findParent().exists()).toBe(false); + }); + + it('does not show secondary breadcrumbs if there is not a parent', async () => { + createComponent({ handler: jest.fn().mockResolvedValue(workItemQueryResponseWithoutParent) }); + + await waitForPromises(); + + expect(findParent().exists()).toBe(false); + }); + + it('shows work item type if there is not a parent', async () => { + createComponent({ handler: jest.fn().mockResolvedValue(workItemQueryResponseWithoutParent) }); + + await waitForPromises(); + expect(findWorkItemType().exists()).toBe(true); + }); + + describe('with parent', () => { + beforeEach(() => { + const parentResponse = workItemResponseFactory(mockParent); + createComponent({ handler: jest.fn().mockResolvedValue(parentResponse) }); + + return waitForPromises(); + }); + + it('shows secondary breadcrumbs if there is a parent', () => { + expect(findParent().exists()).toBe(true); + }); + + it('does not show work item type', async () => { + expect(findWorkItemType().exists()).toBe(false); + }); + + it('sets the parent breadcrumb URL', () => { + expect(findParentButton().attributes().href).toBe('../../issues/5'); + }); + }); + }); + + it('shows an error message when the work item query was unsuccessful', async () => { + const errorHandler = jest.fn().mockRejectedValue('Oops'); + createComponent({ handler: errorHandler }); + await waitForPromises(); + + expect(errorHandler).toHaveBeenCalled(); + expect(findAlert().text()).toBe(i18n.fetchError); + }); + + it('shows an error message when WorkItemTitle emits an `error` event', async () => { + createComponent(); + await waitForPromises(); + const updateError = 'Failed to update'; + + findWorkItemTitle().vm.$emit('error', updateError); + await waitForPromises(); + + expect(findAlert().text()).toBe(updateError); + }); + + describe('subscriptions', () => { + it('calls the title subscription', () => { + createComponent(); + + expect(titleSubscriptionHandler).toHaveBeenCalledWith({ + issuableId: workItemQueryResponse.data.workItem.id, + }); + }); + + describe('dates subscription', () => { + describe('when the due date widget exists', () => { + it('calls the dates subscription', async () => { + createComponent(); + await waitForPromises(); + + expect(datesSubscriptionHandler).toHaveBeenCalledWith({ + issuableId: workItemQueryResponse.data.workItem.id, + }); + }); + }); + + describe('when the due date widget does not exist', () => { + it('does not call the dates subscription', async () => { + const response = workItemResponseFactory({ datesWidgetPresent: false }); + const handler = jest.fn().mockResolvedValue(response); + createComponent({ handler, workItemsMvc2Enabled: true }); + await waitForPromises(); + + expect(datesSubscriptionHandler).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('assignees widget', () => { + it('renders assignees component when widget is returned from the API', async () => { + createComponent({ + workItemsMvc2Enabled: true, + }); + await waitForPromises(); + + expect(findWorkItemAssignees().exists()).toBe(true); + }); + + 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 })), + }); + await waitForPromises(); + + expect(findWorkItemAssignees().exists()).toBe(false); + }); + }); + + describe('labels widget', () => { + it.each` + description | includeWidgets | exists + ${'renders when widget is returned from API'} | ${true} | ${true} + ${'does not render when widget is not returned from API'} | ${false} | ${false} + `('$description', async ({ includeWidgets, exists }) => { + createComponent({ includeWidgets, workItemsMvc2Enabled: true }); + await waitForPromises(); + + expect(findWorkItemLabels().exists()).toBe(exists); + }); + }); + + describe('dates widget', () => { + describe.each` + description | datesWidgetPresent | exists + ${'when widget is returned from API'} | ${true} | ${true} + ${'when widget is not returned from API'} | ${false} | ${false} + `('$description', ({ datesWidgetPresent, exists }) => { + it(`${datesWidgetPresent ? 'renders' : 'does not render'} due date component`, async () => { + const response = workItemResponseFactory({ datesWidgetPresent }); + const handler = jest.fn().mockResolvedValue(response); + createComponent({ handler, workItemsMvc2Enabled: true }); + await waitForPromises(); + + expect(findWorkItemDueDate().exists()).toBe(exists); + }); + }); + + it('shows an error message when it emits an `error` event', async () => { + createComponent({ workItemsMvc2Enabled: true }); + await waitForPromises(); + const updateError = 'Failed to update'; + + findWorkItemDueDate().vm.$emit('error', updateError); + await waitForPromises(); + + expect(findAlert().text()).toBe(updateError); + }); + }); + + describe('work item information', () => { + beforeEach(() => { + createComponent(); + return waitForPromises(); + }); + + it('is visible when viewed for the first time and sets localStorage value', async () => { + localStorage.clear(); + expect(findWorkItemInformationAlert().exists()).toBe(true); + expect(findLocalStorageSync().props('value')).toBe(true); + }); + + it('is not visible after reading local storage input', async () => { + await findLocalStorageSync().vm.$emit('input', false); + expect(findWorkItemInformationAlert().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_due_date_spec.js b/spec/frontend/work_items/components/work_item_due_date_spec.js new file mode 100644 index 00000000000..1d76154a1f0 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_due_date_spec.js @@ -0,0 +1,346 @@ +import { GlFormGroup, GlDatepicker } from '@gitlab/ui'; +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 WorkItemDueDate from '~/work_items/components/work_item_due_date.vue'; +import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { updateWorkItemMutationResponse, updateWorkItemMutationErrorResponse } from '../mock_data'; + +describe('WorkItemDueDate component', () => { + let wrapper; + + Vue.use(VueApollo); + + const workItemId = 'gid://gitlab/WorkItem/1'; + const updateWorkItemMutationHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); + + const findStartDateButton = () => + wrapper.findByRole('button', { name: WorkItemDueDate.i18n.addStartDate }); + const findStartDateInput = () => wrapper.findByLabelText(WorkItemDueDate.i18n.startDate); + const findStartDatePicker = () => wrapper.findComponent(GlDatepicker); + const findDueDateButton = () => + wrapper.findByRole('button', { name: WorkItemDueDate.i18n.addDueDate }); + const findDueDateInput = () => wrapper.findByLabelText(WorkItemDueDate.i18n.dueDate); + const findDueDatePicker = () => wrapper.findAllComponents(GlDatepicker).at(1); + const findGlFormGroup = () => wrapper.findComponent(GlFormGroup); + + const createComponent = ({ + canUpdate = false, + dueDate = null, + startDate = null, + mutationHandler = updateWorkItemMutationHandler, + } = {}) => { + wrapper = mountExtended(WorkItemDueDate, { + apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), + propsData: { + canUpdate, + dueDate, + startDate, + workItemId, + workItemType: 'Task', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when can update', () => { + describe('start date', () => { + describe('`Add start date` button', () => { + describe.each` + description | startDate | exists + ${'when there is no start date'} | ${null} | ${true} + ${'when there is a start date'} | ${'2022-01-01'} | ${false} + `('$description', ({ startDate, exists }) => { + beforeEach(() => { + createComponent({ canUpdate: true, startDate }); + }); + + it(exists ? 'renders' : 'does not render', () => { + expect(findStartDateButton().exists()).toBe(exists); + }); + }); + + describe('when it emits `click` event', () => { + beforeEach(() => { + createComponent({ canUpdate: true, startDate: null }); + findStartDateButton().vm.$emit('click'); + }); + + it('renders start date picker', () => { + expect(findStartDateInput().exists()).toBe(true); + }); + + it('hides itself', () => { + expect(findStartDateButton().exists()).toBe(false); + }); + }); + }); + + describe('date picker', () => { + describe('when it emits a `clear` event', () => { + beforeEach(() => { + createComponent({ canUpdate: true, dueDate: '2022-01-01', startDate: '2022-01-01' }); + findStartDatePicker().vm.$emit('clear'); + }); + + it('hides the date picker', () => { + expect(findStartDateInput().exists()).toBe(false); + }); + + it('shows the `Add start date` button', () => { + expect(findStartDateButton().exists()).toBe(true); + }); + + it('calls a mutation to update the dates', () => { + expect(updateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + id: workItemId, + startAndDueDateWidget: { + dueDate: new Date('2022-01-01T00:00:00.000Z'), + startDate: null, + }, + }, + }); + }); + }); + + describe('when it emits a `close` event', () => { + describe('when the start date is earlier than the due date', () => { + const startDate = new Date('2022-01-01T00:00:00.000Z'); + + beforeEach(() => { + createComponent({ canUpdate: true, dueDate: '2022-12-31', startDate: '2022-12-31' }); + findStartDatePicker().vm.$emit('input', startDate); + findStartDatePicker().vm.$emit('close'); + }); + + it('calls a mutation to update the dates', () => { + expect(updateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + id: workItemId, + startAndDueDateWidget: { + dueDate: new Date('2022-12-31T00:00:00.000Z'), + startDate, + }, + }, + }); + }); + }); + + describe('when the start date is later than the due date', () => { + const startDate = new Date('2030-01-01T00:00:00.000Z'); + let datePickerOpenSpy; + + beforeEach(() => { + createComponent({ canUpdate: true, dueDate: '2022-12-31', startDate: '2022-12-31' }); + datePickerOpenSpy = jest.spyOn(wrapper.vm.$refs.dueDatePicker.calendar, 'show'); + findStartDatePicker().vm.$emit('input', startDate); + findStartDatePicker().vm.$emit('close'); + }); + + it('does not call a mutation to update the dates', () => { + expect(updateWorkItemMutationHandler).not.toHaveBeenCalled(); + }); + + it('updates the due date picker to the same date', () => { + expect(findDueDatePicker().props('value')).toEqual(startDate); + }); + + it('opens the due date picker', () => { + expect(datePickerOpenSpy).toHaveBeenCalled(); + }); + }); + }); + }); + }); + + describe('due date', () => { + describe('`Add due date` button', () => { + describe.each` + description | dueDate | exists + ${'when there is no due date'} | ${null} | ${true} + ${'when there is a due date'} | ${'2022-01-01'} | ${false} + `('$description', ({ dueDate, exists }) => { + beforeEach(() => { + createComponent({ canUpdate: true, dueDate }); + }); + + it(exists ? 'renders' : 'does not render', () => { + expect(findDueDateButton().exists()).toBe(exists); + }); + }); + + describe('when it emits `click` event', () => { + beforeEach(() => { + createComponent({ canUpdate: true, dueDate: null }); + findDueDateButton().vm.$emit('click'); + }); + + it('renders due date picker', () => { + expect(findDueDateInput().exists()).toBe(true); + }); + + it('hides itself', () => { + expect(findDueDateButton().exists()).toBe(false); + }); + }); + }); + + describe('date picker', () => { + describe('when it emits a `clear` event', () => { + beforeEach(() => { + createComponent({ canUpdate: true, dueDate: '2022-01-01', startDate: '2022-01-01' }); + findDueDatePicker().vm.$emit('clear'); + }); + + it('hides the date picker', () => { + expect(findDueDateInput().exists()).toBe(false); + }); + + it('shows the `Add due date` button', () => { + expect(findDueDateButton().exists()).toBe(true); + }); + + it('calls a mutation to update the dates', () => { + expect(updateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + id: workItemId, + startAndDueDateWidget: { + dueDate: null, + startDate: new Date('2022-01-01T00:00:00.000Z'), + }, + }, + }); + }); + }); + + describe('when it emits a `close` event', () => { + const dueDate = new Date('2022-12-31T00:00:00.000Z'); + + beforeEach(() => { + createComponent({ canUpdate: true, dueDate: '2022-01-01', startDate: '2022-01-01' }); + findDueDatePicker().vm.$emit('input', dueDate); + findDueDatePicker().vm.$emit('close'); + }); + + it('calls a mutation to update the dates', () => { + expect(updateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + id: workItemId, + startAndDueDateWidget: { + dueDate, + startDate: new Date('2022-01-01T00:00:00.000Z'), + }, + }, + }); + }); + }); + }); + }); + + describe('when updating date', () => { + describe('when dates are changed', () => { + let trackingSpy; + + beforeEach(() => { + createComponent({ canUpdate: true, dueDate: '2022-12-31', startDate: '2022-12-31' }); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + findStartDatePicker().vm.$emit('input', new Date('2022-01-01T00:00:00.000Z')); + findStartDatePicker().vm.$emit('close'); + }); + + it('mutation is called to update dates', () => { + expect(updateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + id: workItemId, + startAndDueDateWidget: { + dueDate: new Date('2022-12-31T00:00:00.000Z'), + startDate: new Date('2022-01-01T00:00:00.000Z'), + }, + }, + }); + }); + + it('start date input is disabled', () => { + expect(findStartDatePicker().props('disabled')).toBe(true); + }); + + it('due date input is disabled', () => { + expect(findDueDatePicker().props('disabled')).toBe(true); + }); + + it('tracks updating the dates', () => { + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_dates', { + category: TRACKING_CATEGORY_SHOW, + label: 'item_dates', + property: 'type_Task', + }); + }); + }); + + describe('when dates are unchanged', () => { + beforeEach(() => { + createComponent({ canUpdate: true, dueDate: '2022-12-31', startDate: '2022-12-31' }); + + findStartDatePicker().vm.$emit('input', new Date('2022-12-31T00:00:00.000Z')); + findStartDatePicker().vm.$emit('close'); + }); + + it('mutation is not called to update dates', () => { + expect(updateWorkItemMutationHandler).not.toHaveBeenCalled(); + }); + }); + + describe.each` + description | mutationHandler + ${'when there is a GraphQL error'} | ${jest.fn().mockResolvedValue(updateWorkItemMutationErrorResponse)} + ${'when there is a network error'} | ${jest.fn().mockRejectedValue(new Error())} + `('$description', ({ mutationHandler }) => { + beforeEach(() => { + createComponent({ + canUpdate: true, + dueDate: '2022-12-31', + startDate: '2022-12-31', + mutationHandler, + }); + + findStartDatePicker().vm.$emit('input', new Date('2022-01-01T00:00:00.000Z')); + findStartDatePicker().vm.$emit('close'); + return waitForPromises(); + }); + + it('emits an error', () => { + expect(wrapper.emitted('error')).toEqual([ + ['Something went wrong while updating the task. Please try again.'], + ]); + }); + }); + }); + }); + + describe('when cannot update', () => { + it('start and due date inputs are disabled', async () => { + createComponent({ canUpdate: false, dueDate: '2022-01-01', startDate: '2022-01-01' }); + await nextTick(); + + expect(findStartDateInput().props('disabled')).toBe(true); + expect(findDueDateInput().props('disabled')).toBe(true); + }); + + describe('when there is no start and due date', () => { + it('shows None', () => { + createComponent({ canUpdate: false, dueDate: null, startDate: null }); + + expect(findGlFormGroup().text()).toContain(WorkItemDueDate.i18n.none); + }); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_information_spec.js b/spec/frontend/work_items/components/work_item_information_spec.js index d5f6921c2bc..887c5f615e9 100644 --- a/spec/frontend/work_items/components/work_item_information_spec.js +++ b/spec/frontend/work_items/components/work_item_information_spec.js @@ -8,7 +8,6 @@ const createComponent = () => mount(WorkItemInformation); describe('Work item information alert', () => { let wrapper; const tasksHelpPath = helpPagePath('user/tasks'); - const workItemsHelpPath = helpPagePath('development/work_items'); const findAlert = () => wrapper.findComponent(GlAlert); const findHelpLink = () => wrapper.findComponent(GlLink); @@ -33,16 +32,12 @@ describe('Work item information alert', () => { expect(findAlert().props('variant')).toBe('tip'); }); - it('should have the correct text for primary button and link', () => { + it('should have the correct text for title', () => { expect(findAlert().props('title')).toBe(WorkItemInformation.i18n.tasksInformationTitle); - expect(findAlert().props('primaryButtonText')).toBe( - WorkItemInformation.i18n.learnTasksButtonText, - ); - expect(findAlert().props('primaryButtonLink')).toBe(tasksHelpPath); }); it('should have the correct link to work item link', () => { expect(findHelpLink().exists()).toBe(true); - expect(findHelpLink().attributes('href')).toBe(workItemsHelpPath); + expect(findHelpLink().attributes('href')).toBe(tasksHelpPath); }); }); 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 1734b901d1a..1d976897c15 100644 --- a/spec/frontend/work_items/components/work_item_labels_spec.js +++ b/spec/frontend/work_items/components/work_item_labels_spec.js @@ -9,7 +9,7 @@ import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widg import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; import { i18n } from '~/work_items/constants'; -import { temporaryConfig, resolvers } from '~/work_items/graphql/provider'; +import { temporaryConfig, resolvers } from '~/graphql_shared/issuable_client'; import { projectLabelsResponse, mockLabels, workItemQueryResponse } from '../mock_data'; Vue.use(VueApollo); @@ -45,13 +45,11 @@ describe('WorkItemLabels component', () => { }); wrapper = mountExtended(WorkItemLabels, { - provide: { - fullPath: 'test-project-path', - }, propsData: { labels, workItemId, canUpdate, + fullPath: 'test-project-path', }, attachTo: document.body, apolloProvider, diff --git a/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js new file mode 100644 index 00000000000..1d5472a0473 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js @@ -0,0 +1,122 @@ +import { GlButton, GlIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; + +import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; +import WorkItemLinksMenu from '~/work_items/components/work_item_links/work_item_links_menu.vue'; + +import { workItemTask, confidentialWorkItemTask, closedWorkItemTask } from '../../mock_data'; + +describe('WorkItemLinkChild', () => { + const WORK_ITEM_ID = 'gid://gitlab/WorkItem/2'; + let wrapper; + + const createComponent = ({ + projectPath = 'gitlab-org/gitlab-test', + canUpdate = true, + issuableGid = WORK_ITEM_ID, + childItem = workItemTask, + } = {}) => { + wrapper = shallowMountExtended(WorkItemLinkChild, { + propsData: { + projectPath, + canUpdate, + issuableGid, + childItem, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + status | childItem | statusIconName | statusIconColorClass | rawTimestamp | tooltipContents + ${'open'} | ${workItemTask} | ${'issue-open-m'} | ${'gl-text-green-500'} | ${workItemTask.createdAt} | ${'Created'} + ${'closed'} | ${closedWorkItemTask} | ${'issue-close'} | ${'gl-text-blue-500'} | ${closedWorkItemTask.closedAt} | ${'Closed'} + `( + 'renders item status icon and tooltip when item status is `$status`', + ({ childItem, statusIconName, statusIconColorClass, rawTimestamp, tooltipContents }) => { + createComponent({ childItem }); + + const statusIcon = wrapper.findByTestId('item-status-icon').findComponent(GlIcon); + const statusTooltip = wrapper.findComponent(RichTimestampTooltip); + + expect(statusIcon.props('name')).toBe(statusIconName); + expect(statusIcon.classes()).toContain(statusIconColorClass); + expect(statusTooltip.props('rawTimestamp')).toBe(rawTimestamp); + expect(statusTooltip.props('timestampTypeText')).toContain(tooltipContents); + }, + ); + + it('renders confidential icon when item is confidential', () => { + createComponent({ childItem: confidentialWorkItemTask }); + + const confidentialIcon = wrapper.findByTestId('confidential-icon'); + + expect(confidentialIcon.props('name')).toBe('eye-slash'); + expect(confidentialIcon.attributes('title')).toBe('Confidential'); + }); + + describe('item title', () => { + let titleEl; + + beforeEach(() => { + createComponent(); + + titleEl = wrapper.findComponent(GlButton); + }); + + it('renders item title', () => { + expect(titleEl.attributes('href')).toBe('/gitlab-org/gitlab-test/-/work_items/4'); + expect(titleEl.text()).toBe(workItemTask.title); + }); + + it.each` + action | event | emittedEvent + ${'clicking'} | ${'click'} | ${'click'} + ${'doing mouseover on'} | ${'mouseover'} | ${'mouseover'} + ${'doing mouseout on'} | ${'mouseout'} | ${'mouseout'} + `('$action item title emit `$emittedEvent` event', ({ event, emittedEvent }) => { + const eventObj = { + preventDefault: jest.fn(), + }; + titleEl.vm.$emit(event, eventObj); + + expect(wrapper.emitted(emittedEvent)).toEqual([[workItemTask.id, eventObj]]); + }); + }); + + describe('item menu', () => { + let itemMenuEl; + + beforeEach(() => { + createComponent(); + + itemMenuEl = wrapper.findComponent(WorkItemLinksMenu); + }); + + it('renders work-item-links-menu', () => { + expect(itemMenuEl.exists()).toBe(true); + + expect(itemMenuEl.attributes()).toMatchObject({ + 'work-item-id': workItemTask.id, + 'parent-work-item-id': WORK_ITEM_ID, + }); + }); + + it('does not render work-item-links-menu when canUpdate is false', () => { + createComponent({ canUpdate: false }); + + expect(wrapper.findComponent(WorkItemLinksMenu).exists()).toBe(false); + }); + + it('removeChild event on menu triggers `click-remove-child` event', () => { + itemMenuEl.vm.$emit('removeChild'); + + expect(wrapper.emitted('remove')).toEqual([[workItemTask.id]]); + }); + }); +}); 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 00f508f1548..876aedff08b 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,12 +1,13 @@ import Vue, { nextTick } from 'vue'; -import { GlButton, GlIcon, GlAlert } from '@gitlab/ui'; +import { 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 issueConfidentialQuery from '~/sidebar/queries/issue_confidential.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'; import changeWorkItemParentMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql'; @@ -20,6 +21,20 @@ import { Vue.use(VueApollo); +const issueConfidentialityResponse = (confidential = false) => ({ + data: { + workspace: { + id: '1', + __typename: 'Project', + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/4', + confidential, + }, + }, + }, +}); + describe('WorkItemLinks', () => { let wrapper; let mockApollo; @@ -36,18 +51,18 @@ describe('WorkItemLinks', () => { 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, + confidentialQueryHandler = jest.fn().mockResolvedValue(issueConfidentialityResponse()), } = {}) => { mockApollo = createMockApollo( [ [getWorkItemLinksQuery, fetchHandler], [changeWorkItemParentMutation, mutationHandler], [workItemQuery, childWorkItemQueryHandler], + [issueConfidentialQuery, confidentialQueryHandler], ], {}, { addTypename: true }, @@ -61,6 +76,7 @@ describe('WorkItemLinks', () => { }, provide: { projectPath: 'project/path', + iid: '1', }, propsData: { issuableId: 1 }, apolloProvider: mockApollo, @@ -77,8 +93,9 @@ describe('WorkItemLinks', () => { const findLinksBody = () => wrapper.findByTestId('links-body'); const findEmptyState = () => wrapper.findByTestId('links-empty'); const findToggleAddFormButton = () => wrapper.findByTestId('toggle-add-form'); + const findWorkItemLinkChildItems = () => wrapper.findAllComponents(WorkItemLinkChild); + const findFirstWorkItemLinkChild = () => findWorkItemLinkChildItems().at(0); const findAddLinksForm = () => wrapper.findByTestId('add-links-form'); - const findFirstLinksMenu = () => wrapper.findByTestId('links-menu'); const findChildrenCount = () => wrapper.findByTestId('children-count'); beforeEach(async () => { @@ -132,8 +149,7 @@ describe('WorkItemLinks', () => { it('renders all hierarchy widget children', () => { expect(findLinksBody().exists()).toBe(true); - expect(findChildren()).toHaveLength(4); - expect(findFirstLinksMenu().exists()).toBe(true); + expect(findWorkItemLinkChildItems()).toHaveLength(4); }); it('shows alert when list loading fails', async () => { @@ -148,40 +164,12 @@ describe('WorkItemLinks', () => { 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({ @@ -194,17 +182,21 @@ describe('WorkItemLinks', () => { }); it('does not display link menu on children', () => { - expect(findFirstLinksMenu().exists()).toBe(false); + expect(findWorkItemLinkChildItems().at(0).props('canUpdate')).toBe(false); }); }); describe('remove child', () => { + let firstChild; + beforeEach(async () => { await createComponent({ mutationHandler: mutationChangeParentHandler }); + + firstChild = findFirstWorkItemLinkChild(); }); it('calls correct mutation with correct variables', async () => { - findFirstLinksMenu().vm.$emit('removeChild'); + firstChild.vm.$emit('remove', firstChild.vm.childItem.id); await waitForPromises(); @@ -219,7 +211,7 @@ describe('WorkItemLinks', () => { }); it('shows toast when mutation succeeds', async () => { - findFirstLinksMenu().vm.$emit('removeChild'); + firstChild.vm.$emit('remove', firstChild.vm.childItem.id); await waitForPromises(); @@ -229,28 +221,30 @@ describe('WorkItemLinks', () => { }); it('renders correct number of children after removal', async () => { - expect(findChildren()).toHaveLength(4); + expect(findWorkItemLinkChildItems()).toHaveLength(4); - findFirstLinksMenu().vm.$emit('removeChild'); + firstChild.vm.$emit('remove', firstChild.vm.childItem.id); await waitForPromises(); - expect(findChildren()).toHaveLength(3); + expect(findWorkItemLinkChildItems()).toHaveLength(3); }); }); describe('prefetching child items', () => { + let firstChild; + beforeEach(async () => { await createComponent(); - }); - const findChildLink = () => findChildren().at(0).findComponent(GlButton); + firstChild = findFirstWorkItemLinkChild(); + }); 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'); + firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id); jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); await waitForPromises(); @@ -260,12 +254,24 @@ describe('WorkItemLinks', () => { }); it('does not fetch the child work item if link is hovered for less than 250 ms', async () => { - findChildLink().vm.$emit('mouseover'); + firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id); jest.advanceTimersByTime(200); - findChildLink().vm.$emit('mouseout'); + firstChild.vm.$emit('mouseout', firstChild.vm.childItem.id); await waitForPromises(); expect(childWorkItemQueryHandler).not.toHaveBeenCalled(); }); }); + + describe('when parent item is confidential', () => { + it('passes correct confidentiality status to form', async () => { + await createComponent({ + confidentialQueryHandler: jest.fn().mockResolvedValue(issueConfidentialityResponse(true)), + }); + findToggleAddFormButton().vm.$emit('click'); + await nextTick(); + + expect(findAddLinksForm().props('parentConfidential')).toBe(true); + }); + }); }); 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 6b23a6e4795..b24d940d56a 100644 --- a/spec/frontend/work_items/components/work_item_state_spec.js +++ b/spec/frontend/work_items/components/work_item_state_spec.js @@ -7,7 +7,6 @@ import waitForPromises from 'helpers/wait_for_promises'; import ItemState from '~/work_items/components/item_state.vue'; import WorkItemState from '~/work_items/components/work_item_state.vue'; import { - i18n, STATE_OPEN, STATE_CLOSED, STATE_EVENT_CLOSE, @@ -104,7 +103,9 @@ describe('WorkItemState component', () => { findItemState().vm.$emit('changed', STATE_CLOSED); await waitForPromises(); - expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]); + expect(wrapper.emitted('error')).toEqual([ + ['Something went wrong while updating the task. Please try again.'], + ]); }); it('tracks editing the state', async () => { 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 c0d966abab8..a549aad5cd8 100644 --- a/spec/frontend/work_items/components/work_item_title_spec.js +++ b/spec/frontend/work_items/components/work_item_title_spec.js @@ -6,7 +6,7 @@ import { mockTracking } from 'helpers/tracking_helper'; import waitForPromises from 'helpers/wait_for_promises'; import ItemTitle from '~/work_items/components/item_title.vue'; import WorkItemTitle from '~/work_items/components/work_item_title.vue'; -import { i18n, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; +import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql'; import { updateWorkItemMutationResponse, workItemQueryResponse } from '../mock_data'; @@ -116,7 +116,9 @@ describe('WorkItemTitle component', () => { findItemTitle().vm.$emit('title-changed', 'new title'); await waitForPromises(); - expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]); + expect(wrapper.emitted('error')).toEqual([ + ['Something went wrong while updating the task. Please try again.'], + ]); }); it('tracks editing the title', async () => { 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 85466578e18..95ddfc3980e 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 @@ -1,11 +1,17 @@ import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; let wrapper; function createComponent(propsData) { - wrapper = shallowMount(WorkItemTypeIcon, { propsData }); + wrapper = shallowMount(WorkItemTypeIcon, { + propsData, + directives: { + GlTooltip: createMockDirective(), + }, + }); } describe('Work Item type component', () => { @@ -16,22 +22,23 @@ describe('Work Item type component', () => { }); 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'} | ${''} + workItemType | workItemIconName | iconName | text | showTooltipOnHover + ${'TASK'} | ${''} | ${'issue-type-task'} | ${'Task'} | ${false} + ${''} | ${'issue-type-task'} | ${'issue-type-task'} | ${''} | ${true} + ${'ISSUE'} | ${''} | ${'issue-type-issue'} | ${'Issue'} | ${true} + ${''} | ${'issue-type-issue'} | ${'issue-type-issue'} | ${''} | ${true} + ${'REQUIREMENTS'} | ${''} | ${'issue-type-requirements'} | ${'Requirements'} | ${true} + ${'INCIDENT'} | ${''} | ${'issue-type-incident'} | ${'Incident'} | ${false} + ${'TEST_CASE'} | ${''} | ${'issue-type-test-case'} | ${'Test case'} | ${true} + ${'random-issue-type'} | ${''} | ${'issue-type-issue'} | ${''} | ${true} `( 'with workItemType set to "$workItemType" and workItemIconName set to "$workItemIconName"', - ({ workItemType, workItemIconName, iconName, text }) => { + ({ workItemType, workItemIconName, iconName, text, showTooltipOnHover }) => { beforeEach(() => { createComponent({ workItemType, workItemIconName, + showTooltipOnHover, }); }); @@ -42,6 +49,16 @@ describe('Work Item type component', () => { it(`renders correct text`, () => { expect(wrapper.text()).toBe(text); }); + + it('renders the icon in gray color', () => { + expect(findIcon().classes()).toContain('gl-text-gray-500'); + }); + + it('shows tooltip on hover when props passed', () => { + const tooltip = getBinding(findIcon().element, 'gl-tooltip'); + + expect(tooltip.value).toBe(showTooltipOnHover); + }); }, ); }); diff --git a/spec/frontend/work_items/components/work_item_weight_spec.js b/spec/frontend/work_items/components/work_item_weight_spec.js deleted file mode 100644 index 94bdb336deb..00000000000 --- a/spec/frontend/work_items/components/work_item_weight_spec.js +++ /dev/null @@ -1,214 +0,0 @@ -import { GlForm, GlFormInput } from '@gitlab/ui'; -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 { 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 workItemId = 'gid://gitlab/WorkItem/1'; - const workItemType = 'Task'; - - const findForm = () => wrapper.findComponent(GlForm); - const findInput = () => wrapper.findComponent(GlFormInput); - - const createComponent = ({ - canUpdate = false, - hasIssueWeightsFeature = true, - isEditing = false, - weight, - mutationHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse), - } = {}) => { - wrapper = mountExtended(WorkItemWeight, { - apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), - propsData: { - canUpdate, - weight, - workItemId, - workItemType, - }, - provide: { - hasIssueWeightsFeature, - }, - }); - - if (isEditing) { - findInput().vm.$emit('focus'); - } - }; - - describe('`issue_weights` licensed feature', () => { - describe.each` - description | hasIssueWeightsFeature | exists - ${'when available'} | ${true} | ${true} - ${'when not available'} | ${false} | ${false} - `('$description', ({ hasIssueWeightsFeature, exists }) => { - it(hasIssueWeightsFeature ? 'renders component' : 'does not render component', () => { - createComponent({ hasIssueWeightsFeature }); - - expect(findForm().exists()).toBe(exists); - }); - }); - }); - - describe('weight input', () => { - it('has "Weight" label', () => { - createComponent(); - - expect(wrapper.findByLabelText(__('Weight')).exists()).toBe(true); - }); - - describe('placeholder attribute', () => { - describe.each` - description | isEditing | canUpdate | value - ${'when not editing and cannot update'} | ${false} | ${false} | ${__('None')} - ${'when editing and cannot update'} | ${true} | ${false} | ${__('None')} - ${'when not editing and can update'} | ${false} | ${true} | ${__('None')} - ${'when editing and can update'} | ${true} | ${true} | ${__('Enter a number')} - `('$description', ({ isEditing, canUpdate, value }) => { - it(`has a value of "${value}"`, async () => { - createComponent({ canUpdate, isEditing }); - await nextTick(); - - expect(findInput().attributes('placeholder')).toBe(value); - }); - }); - }); - - describe('readonly attribute', () => { - describe.each` - description | canUpdate | value - ${'when cannot update'} | ${false} | ${'readonly'} - ${'when can update'} | ${true} | ${undefined} - `('$description', ({ canUpdate, value }) => { - it(`renders readonly=${value}`, () => { - createComponent({ canUpdate }); - - expect(findInput().attributes('readonly')).toBe(value); - }); - }); - }); - - describe('type attribute', () => { - describe.each` - description | isEditing | canUpdate | type - ${'when not editing and cannot update'} | ${false} | ${false} | ${'text'} - ${'when editing and cannot update'} | ${true} | ${false} | ${'text'} - ${'when not editing and can update'} | ${false} | ${true} | ${'text'} - ${'when editing and can update'} | ${true} | ${true} | ${'number'} - `('$description', ({ isEditing, canUpdate, type }) => { - it(`has a value of "${type}"`, async () => { - createComponent({ canUpdate, isEditing }); - await nextTick(); - - expect(findInput().attributes('type')).toBe(type); - }); - }); - }); - - describe('value attribute', () => { - describe.each` - weight | value - ${1} | ${'1'} - ${0} | ${'0'} - ${null} | ${''} - ${undefined} | ${''} - `('when `weight` prop is "$weight"', ({ weight, value }) => { - it(`value is "${value}"`, () => { - createComponent({ weight }); - - expect(findInput().element.value).toBe(value); - }); - }); - }); - - describe('when blurred', () => { - it('calls a mutation to update the weight 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(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({ canUpdate: true }); - - findInput().trigger('blur'); - - expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_weight', { - category: TRACKING_CATEGORY_SHOW, - label: 'item_weight', - property: 'type_Task', - }); - }); - }); - }); -}); |