diff options
Diffstat (limited to 'spec')
5 files changed, 480 insertions, 312 deletions
diff --git a/spec/frontend/issuable/components/related_issuable_item_spec.js b/spec/frontend/issuable/components/related_issuable_item_spec.js index 6b48f83041a..3f9f048605a 100644 --- a/spec/frontend/issuable/components/related_issuable_item_spec.js +++ b/spec/frontend/issuable/components/related_issuable_item_spec.js @@ -1,23 +1,25 @@ -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import { GlIcon, GlLink, GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; import { formatDate } from '~/lib/utils/datetime_utility'; +import { updateHistory } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue'; +import IssueMilestone from '~/issuable/components/issue_milestone.vue'; +import IssueAssignees from '~/issuable/components/issue_assignees.vue'; +import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data'; +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); + describe('RelatedIssuableItem', () => { let wrapper; - function mountComponent({ mountMethod = mount, stubs = {}, props = {}, slots = {} } = {}) { - wrapper = mountMethod(RelatedIssuableItem, { - propsData: props, - slots, - stubs, - }); - } - - const props = { + const defaultProps = { idKey: 1, displayReference: 'gitlab-org/gitlab-test#1', pathIdSeparator: '#', @@ -31,84 +33,94 @@ describe('RelatedIssuableItem', () => { assignees: defaultAssignees, eventNamespace: 'relatedIssue', }; - const slots = { - dueDate: '<div class="js-due-date-slot"></div>', - weight: '<div class="js-weight-slot"></div>', - }; - - const findRemoveButton = () => wrapper.find({ ref: 'removeButton' }); - const findLockIcon = () => wrapper.find({ ref: 'lockIcon' }); - beforeEach(() => { - mountComponent({ props, slots }); - }); + const findIcon = () => wrapper.findComponent(GlIcon); + const findIssueDueDate = () => wrapper.findComponent(IssueDueDate); + const findLockIcon = () => wrapper.find('[data-testid="lockIcon"]'); + const findRemoveButton = () => wrapper.findComponent(GlButton); + const findTitleLink = () => wrapper.findComponent(GlLink); + const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal); + + function mountComponent({ data = {}, props = {} } = {}) { + wrapper = shallowMount(RelatedIssuableItem, { + propsData: { + ...defaultProps, + ...props, + }, + data() { + return data; + }, + }); + } afterEach(() => { wrapper.destroy(); }); it('contains issuable-info-container class when canReorder is false', () => { - expect(wrapper.props('canReorder')).toBe(false); - expect(wrapper.find('.issuable-info-container').exists()).toBe(true); + mountComponent({ props: { canReorder: false } }); + + expect(wrapper.classes('issuable-info-container')).toBe(true); }); it('does not render token state', () => { + mountComponent(); + expect(wrapper.find('.text-secondary svg').exists()).toBe(false); }); it('does not render remove button', () => { - expect(wrapper.find({ ref: 'removeButton' }).exists()).toBe(false); + mountComponent(); + + expect(findRemoveButton().exists()).toBe(false); }); describe('token title', () => { + beforeEach(() => { + mountComponent(); + }); + it('links to computedPath', () => { - expect(wrapper.find('.item-title a').attributes('href')).toEqual(wrapper.props('path')); + expect(findTitleLink().attributes('href')).toBe(defaultProps.path); }); it('renders confidential icon', () => { - expect(wrapper.find('.confidential-icon').exists()).toBe(true); + expect(findIcon().attributes('title')).toBe(__('Confidential')); }); it('renders title', () => { - expect(wrapper.find('.item-title a').text()).toEqual(props.title); + expect(findTitleLink().text()).toBe(defaultProps.title); }); }); describe('token state', () => { - const tokenState = () => wrapper.find({ ref: 'iconElementXL' }); - - beforeEach(() => { - wrapper.setProps({ state: 'opened' }); - }); - - it('renders if hasState', () => { - expect(tokenState().exists()).toBe(true); - }); - it('renders state title', () => { - const stateTitle = tokenState().attributes('title'); - const formattedCreateDate = formatDate(props.createdAt); + mountComponent({ props: { state: 'opened' } }); + const stateTitle = findIcon().attributes('title'); + const formattedCreateDate = formatDate(defaultProps.createdAt); expect(stateTitle).toContain('<span class="bold">Created</span>'); expect(stateTitle).toContain(`<span class="text-tertiary">${formattedCreateDate}</span>`); }); it('renders aria label', () => { - expect(tokenState().attributes('aria-label')).toEqual('opened'); + mountComponent({ props: { state: 'opened' } }); + + expect(findIcon().attributes('arialabel')).toBe('opened'); }); it('renders open icon when open state', () => { - expect(tokenState().classes('issue-token-state-icon-open')).toBe(true); + mountComponent({ props: { state: 'opened' } }); + + expect(findIcon().props('name')).toBe('issue-open-m'); + expect(findIcon().classes('issue-token-state-icon-open')).toBe(true); }); - it('renders close icon when close state', async () => { - wrapper.setProps({ - state: 'closed', - closedAt: '2018-12-01T00:00:00.00Z', - }); - await nextTick(); + it('renders close icon when close state', () => { + mountComponent({ props: { state: 'closed', closedAt: '2018-12-01T00:00:00.00Z' } }); - expect(tokenState().classes('issue-token-state-icon-closed')).toBe(true); + expect(findIcon().props('name')).toBe('issue-close'); + expect(findIcon().classes('issue-token-state-icon-closed')).toBe(true); }); }); @@ -116,75 +128,66 @@ describe('RelatedIssuableItem', () => { const tokenMetadata = () => wrapper.find('.item-meta'); it('renders item path and ID', () => { + mountComponent(); const pathAndID = tokenMetadata().find('.item-path-id').text(); expect(pathAndID).toContain('gitlab-org/gitlab-test'); expect(pathAndID).toContain('#1'); }); - it('renders milestone icon and name', () => { - const milestoneIcon = tokenMetadata().find('.item-milestone svg'); - const milestoneTitle = tokenMetadata().find('.item-milestone .milestone-title'); + it('renders milestone', () => { + mountComponent(); - expect(milestoneIcon.attributes('data-testid')).toBe('clock-icon'); - expect(milestoneTitle.text()).toContain('Milestone title'); + expect(wrapper.findComponent(IssueMilestone).props('milestone')).toEqual( + defaultProps.milestone, + ); }); it('renders due date component with correct due date', () => { - expect(wrapper.find(IssueDueDate).props('date')).toBe(props.dueDate); + mountComponent(); + + expect(findIssueDueDate().props('date')).toBe(defaultProps.dueDate); }); - it('does not render red icon for overdue issue that is closed', async () => { - mountComponent({ - props: { - ...props, - closedAt: '2018-12-01T00:00:00.00Z', - }, - }); - await nextTick(); + it('does not render red icon for overdue issue that is closed', () => { + mountComponent({ props: { closedAt: '2018-12-01T00:00:00.00Z' } }); - expect(wrapper.find(IssueDueDate).props('closed')).toBe(true); + expect(findIssueDueDate().props('closed')).toBe(true); }); }); describe('token assignees', () => { it('renders assignees avatars', () => { - // Expect 2 times 2 because assignees are rendered twice, due to layout issues - expect(wrapper.findAll('.item-assignees .user-avatar-link').length).toBeDefined(); + mountComponent(); - expect(wrapper.find('.item-assignees .avatar-counter').text()).toContain('+2'); + expect(wrapper.findComponent(IssueAssignees).props('assignees')).toEqual( + defaultProps.assignees, + ); }); }); describe('remove button', () => { beforeEach(() => { - wrapper.setProps({ canRemove: true }); + mountComponent({ props: { canRemove: true }, data: { removeDisabled: true } }); }); it('renders if canRemove', () => { - expect(findRemoveButton().exists()).toBe(true); + expect(findRemoveButton().props('icon')).toBe('close'); + expect(findRemoveButton().attributes('aria-label')).toBe(__('Remove')); }); it('does not render the lock icon', () => { expect(findLockIcon().exists()).toBe(false); }); - it('renders disabled button when removeDisabled', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ removeDisabled: true }); - await nextTick(); - - expect(findRemoveButton().attributes('disabled')).toEqual('disabled'); + it('renders disabled button when removeDisabled', () => { + expect(findRemoveButton().attributes('disabled')).toBe('true'); }); - it('triggers onRemoveRequest when clicked', async () => { - findRemoveButton().trigger('click'); - await nextTick(); - const { relatedIssueRemoveRequest } = wrapper.emitted(); + it('triggers onRemoveRequest when clicked', () => { + findRemoveButton().vm.$emit('click'); - expect(relatedIssueRemoveRequest.length).toBe(1); - expect(relatedIssueRemoveRequest[0]).toEqual([props.idKey]); + expect(wrapper.emitted('relatedIssueRemoveRequest')).toEqual([[defaultProps.idKey]]); }); }); @@ -192,10 +195,7 @@ describe('RelatedIssuableItem', () => { const lockedMessage = 'Issues created from a vulnerability cannot be removed'; beforeEach(() => { - wrapper.setProps({ - isLocked: true, - lockedMessage, - }); + mountComponent({ props: { isLocked: true, lockedMessage } }); }); it('does not render the remove button', () => { @@ -206,4 +206,67 @@ describe('RelatedIssuableItem', () => { expect(findLockIcon().attributes('title')).toBe(lockedMessage); }); }); + + describe('work item modal', () => { + const workItem = 'gid://gitlab/WorkItem/1'; + + it('renders', () => { + mountComponent(); + + expect(findWorkItemDetailModal().props('workItemId')).toBe(workItem); + }); + + describe('when work item is issue and the related issue title is clicked', () => { + it('does not open', () => { + mountComponent({ props: { workItemType: 'ISSUE' } }); + wrapper.vm.$refs.modal.show = jest.fn(); + + findTitleLink().vm.$emit('click', { preventDefault: () => {} }); + + expect(wrapper.vm.$refs.modal.show).not.toHaveBeenCalled(); + }); + }); + + describe('when work item is task and the related issue title is clicked', () => { + beforeEach(() => { + mountComponent({ props: { workItemType: 'TASK' } }); + wrapper.vm.$refs.modal.show = jest.fn(); + findTitleLink().vm.$emit('click', { preventDefault: () => {} }); + }); + + it('opens', () => { + expect(wrapper.vm.$refs.modal.show).toHaveBeenCalled(); + }); + + it('updates the url params with the work item id', () => { + expect(updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/?work_item_id=1`, + replace: true, + }); + }); + }); + + describe('when it emits "workItemDeleted" event', () => { + it('emits "relatedIssueRemoveRequest" event', () => { + mountComponent(); + + findWorkItemDetailModal().vm.$emit('workItemDeleted', workItem); + + expect(wrapper.emitted('relatedIssueRemoveRequest')).toEqual([[workItem]]); + }); + }); + + describe('when it emits "close" event', () => { + it('removes the work item id from the url params', () => { + mountComponent(); + + findWorkItemDetailModal().vm.$emit('close'); + + expect(updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/`, + replace: true, + }); + }); + }); + }); }); diff --git a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js index 1a03ea58b60..b518d2fbdec 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js @@ -1,4 +1,4 @@ -import { mount, shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; @@ -9,8 +9,9 @@ import { } from 'jest/issuable/components/related_issuable_mock_data'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; import { linkedIssueTypesMap } from '~/related_issues/constants'; +import RelatedIssuesBlock from '~/related_issues/components/related_issues_block.vue'; +import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; import relatedIssuesService from '~/related_issues/services/related_issues_service'; jest.mock('~/flash'); @@ -19,6 +20,8 @@ describe('RelatedIssuesRoot', () => { let wrapper; let mock; + const findRelatedIssuesBlock = () => wrapper.findComponent(RelatedIssuesBlock); + beforeEach(() => { mock = new MockAdapter(axios); mock.onGet(defaultProps.endpoint).reply(200, []); @@ -26,100 +29,114 @@ describe('RelatedIssuesRoot', () => { afterEach(() => { mock.restore(); - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); - const createComponent = (mountFn = mount) => { - wrapper = mountFn(RelatedIssuesRoot, { - propsData: defaultProps, + const createComponent = ({ props = {}, data = {} } = {}) => { + wrapper = mount(RelatedIssuesRoot, { + propsData: { + ...defaultProps, + ...props, + }, + data() { + return data; + }, }); // Wait for fetch request `fetchRelatedIssues` to complete before starting to test return waitForPromises(); }; - describe('methods', () => { - describe('onRelatedIssueRemoveRequest', () => { - beforeEach(() => { - jest - .spyOn(relatedIssuesService.prototype, 'fetchRelatedIssues') - .mockReturnValue(Promise.reject()); - - return createComponent().then(() => { + describe('events', () => { + describe('when "relatedIssueRemoveRequest" event is emitted', () => { + describe('when emitted value is a numerical issue', () => { + beforeEach(async () => { + jest + .spyOn(relatedIssuesService.prototype, 'fetchRelatedIssues') + .mockReturnValue(Promise.reject()); + await createComponent(); wrapper.vm.store.setRelatedIssues([issuable1]); }); - }); - it('remove related issue and succeeds', () => { - mock.onDelete(issuable1.referencePath).reply(200, { issues: [] }); + it('removes related issue on API success', async () => { + mock.onDelete(issuable1.referencePath).reply(200, { issues: [] }); + + findRelatedIssuesBlock().vm.$emit('relatedIssueRemoveRequest', issuable1.id); + await axios.waitForAll(); + + expect(findRelatedIssuesBlock().props('relatedIssues')).toEqual([]); + }); + + it('does not remove related issue on API error', async () => { + mock.onDelete(issuable1.referencePath).reply(422, {}); - wrapper.vm.onRelatedIssueRemoveRequest(issuable1.id); + findRelatedIssuesBlock().vm.$emit('relatedIssueRemoveRequest', issuable1.id); + await axios.waitForAll(); - return axios.waitForAll().then(() => { - expect(wrapper.vm.state.relatedIssues).toEqual([]); + expect(findRelatedIssuesBlock().props('relatedIssues')).toEqual([ + expect.objectContaining({ id: issuable1.id }), + ]); }); }); - it('remove related issue, fails, and restores to related issues', () => { - mock.onDelete(issuable1.referencePath).reply(422, {}); + describe('when emitted value is a work item id', () => { + it('removes related issue', async () => { + const workItem = `gid://gitlab/WorkItem/${issuable1.id}`; + createComponent({ data: { state: { relatedIssues: [issuable1] } } }); - wrapper.vm.onRelatedIssueRemoveRequest(issuable1.id); + findRelatedIssuesBlock().vm.$emit('relatedIssueRemoveRequest', workItem); + await nextTick(); - return axios.waitForAll().then(() => { - expect(wrapper.vm.state.relatedIssues).toHaveLength(1); - expect(wrapper.vm.state.relatedIssues[0].id).toEqual(issuable1.id); + expect(findRelatedIssuesBlock().props('relatedIssues')).toEqual([]); }); }); }); - describe('onToggleAddRelatedIssuesForm', () => { - beforeEach(() => createComponent(shallowMount)); + describe('when "toggleAddRelatedIssuesForm" event is emitted', () => { + it('toggles related issues form to visible from hidden', async () => { + createComponent(); - it('toggle related issues form to visible', () => { - wrapper.vm.onToggleAddRelatedIssuesForm(); + findRelatedIssuesBlock().vm.$emit('toggleAddRelatedIssuesForm'); + await nextTick(); - expect(wrapper.vm.isFormVisible).toEqual(true); + expect(findRelatedIssuesBlock().props('isFormVisible')).toBe(true); }); - it('show add related issues form to hidden', () => { - wrapper.vm.isFormVisible = true; + it('toggles related issues form to hidden from visible', async () => { + createComponent({ data: { isFormVisible: true } }); - wrapper.vm.onToggleAddRelatedIssuesForm(); + findRelatedIssuesBlock().vm.$emit('toggleAddRelatedIssuesForm'); + await nextTick(); - expect(wrapper.vm.isFormVisible).toEqual(false); + expect(findRelatedIssuesBlock().props('isFormVisible')).toBe(false); }); }); - describe('onPendingIssueRemoveRequest', () => { - beforeEach(() => - createComponent().then(() => { - wrapper.vm.store.setPendingReferences([issuable1.reference]); - }), - ); + describe('when "pendingIssuableRemoveRequest" event is emitted', () => { + beforeEach(() => { + createComponent(); + wrapper.vm.store.setPendingReferences([issuable1.reference]); + }); - it('remove pending related issue', () => { - expect(wrapper.vm.state.pendingReferences).toHaveLength(1); + it('removes pending related issue', async () => { + expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(1); - wrapper.vm.onPendingIssueRemoveRequest(0); + findRelatedIssuesBlock().vm.$emit('pendingIssuableRemoveRequest', 0); + await nextTick(); - expect(wrapper.vm.state.pendingReferences).toHaveLength(0); + expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(0); }); }); - describe('onPendingFormSubmit', () => { - beforeEach(() => { + describe('when "addIssuableFormSubmit" event is emitted', () => { + beforeEach(async () => { jest .spyOn(relatedIssuesService.prototype, 'fetchRelatedIssues') .mockReturnValue(Promise.reject()); - - return createComponent().then(() => { - jest.spyOn(wrapper.vm, 'processAllReferences'); - jest.spyOn(wrapper.vm.service, 'addRelatedIssues'); - createFlash.mockClear(); - }); + await createComponent(); + jest.spyOn(wrapper.vm, 'processAllReferences'); + jest.spyOn(wrapper.vm.service, 'addRelatedIssues'); + createFlash.mockClear(); }); it('processes references before submitting', () => { @@ -130,23 +147,22 @@ describe('RelatedIssuesRoot', () => { linkedIssueType, }; - wrapper.vm.onPendingFormSubmit(emitObj); + findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', emitObj); expect(wrapper.vm.processAllReferences).toHaveBeenCalledWith(input); expect(wrapper.vm.service.addRelatedIssues).toHaveBeenCalledWith([input], linkedIssueType); }); - it('submit zero pending issue as related issue', () => { + it('submits zero pending issues as related issue', () => { wrapper.vm.store.setPendingReferences([]); - wrapper.vm.onPendingFormSubmit({}); - return waitForPromises().then(() => { - expect(wrapper.vm.state.pendingReferences).toHaveLength(0); - expect(wrapper.vm.state.relatedIssues).toHaveLength(0); - }); + findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', {}); + + expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(0); + expect(findRelatedIssuesBlock().props('relatedIssues')).toHaveLength(0); }); - it('submit pending issue as related issue', () => { + it('submits pending issue as related issue', async () => { mock.onPost(defaultProps.endpoint).reply(200, { issuables: [issuable1], result: { @@ -154,18 +170,18 @@ describe('RelatedIssuesRoot', () => { status: 'success', }, }); - wrapper.vm.store.setPendingReferences([issuable1.reference]); - wrapper.vm.onPendingFormSubmit({}); - return waitForPromises().then(() => { - expect(wrapper.vm.state.pendingReferences).toHaveLength(0); - expect(wrapper.vm.state.relatedIssues).toHaveLength(1); - expect(wrapper.vm.state.relatedIssues[0].id).toEqual(issuable1.id); - }); + findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', {}); + await waitForPromises(); + + expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(0); + expect(findRelatedIssuesBlock().props('relatedIssues')).toEqual([ + expect.objectContaining({ id: issuable1.id }), + ]); }); - it('submit multiple pending issues as related issues', () => { + it('submits multiple pending issues as related issues', async () => { mock.onPost(defaultProps.endpoint).reply(200, { issuables: [issuable1, issuable2], result: { @@ -173,201 +189,148 @@ describe('RelatedIssuesRoot', () => { status: 'success', }, }); - wrapper.vm.store.setPendingReferences([issuable1.reference, issuable2.reference]); - wrapper.vm.onPendingFormSubmit({}); - return waitForPromises().then(() => { - expect(wrapper.vm.state.pendingReferences).toHaveLength(0); - expect(wrapper.vm.state.relatedIssues).toHaveLength(2); - expect(wrapper.vm.state.relatedIssues[0].id).toEqual(issuable1.id); - expect(wrapper.vm.state.relatedIssues[1].id).toEqual(issuable2.id); - }); + findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', {}); + await waitForPromises(); + + expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(0); + expect(findRelatedIssuesBlock().props('relatedIssues')).toEqual([ + expect.objectContaining({ id: issuable1.id }), + expect.objectContaining({ id: issuable2.id }), + ]); }); - it('displays a message from the backend upon error', () => { + it('displays a message from the backend upon error', async () => { const input = '#123'; const message = 'error'; - mock.onPost(defaultProps.endpoint).reply(409, { message }); wrapper.vm.store.setPendingReferences([issuable1.reference, issuable2.reference]); expect(createFlash).not.toHaveBeenCalled(); - wrapper.vm.onPendingFormSubmit(input); - - return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith({ - message, - }); - }); - }); - }); - describe('onPendingFormCancel', () => { - beforeEach(() => - createComponent().then(() => { - wrapper.vm.isFormVisible = true; - wrapper.vm.inputValue = 'foo'; - }), - ); - - it('when canceling and hiding add issuable form', async () => { - wrapper.vm.onPendingFormCancel(); + findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', input); + await waitForPromises(); - await nextTick(); - expect(wrapper.vm.isFormVisible).toEqual(false); - expect(wrapper.vm.inputValue).toEqual(''); - expect(wrapper.vm.state.pendingReferences).toHaveLength(0); + expect(createFlash).toHaveBeenCalledWith({ message }); }); }); - describe('fetchRelatedIssues', () => { - beforeEach(() => createComponent()); - - it('sets isFetching while fetching', async () => { - wrapper.vm.fetchRelatedIssues(); + describe('when "addIssuableFormCancel" event is emitted', () => { + beforeEach(() => createComponent({ data: { isFormVisible: true, inputValue: 'foo' } })); - expect(wrapper.vm.isFetching).toEqual(true); + it('hides form and resets input', async () => { + findRelatedIssuesBlock().vm.$emit('addIssuableFormCancel'); + await nextTick(); - await waitForPromises(); - expect(wrapper.vm.isFetching).toEqual(false); - }); - - it('should fetch related issues', async () => { - mock.onGet(defaultProps.endpoint).reply(200, [issuable1, issuable2]); - - wrapper.vm.fetchRelatedIssues(); - - await waitForPromises(); - expect(wrapper.vm.state.relatedIssues).toHaveLength(2); - expect(wrapper.vm.state.relatedIssues[0].id).toEqual(issuable1.id); - expect(wrapper.vm.state.relatedIssues[1].id).toEqual(issuable2.id); + expect(findRelatedIssuesBlock().props('isFormVisible')).toBe(false); + expect(findRelatedIssuesBlock().props('inputValue')).toBe(''); + expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(0); }); }); - describe('onInput', () => { - beforeEach(() => createComponent()); - - it('fill in issue number reference and adds to pending related issues', () => { + describe('when "addIssuableFormInput" event is emitted', () => { + it('updates pending references with issue reference', async () => { const input = '#123 '; - wrapper.vm.onInput({ + createComponent(); + + findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { untouchedRawReferences: [input.trim()], touchedReference: input, }); + await nextTick(); - expect(wrapper.vm.state.pendingReferences).toHaveLength(1); - expect(wrapper.vm.state.pendingReferences[0]).toEqual('#123'); + expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([input.trim()]); }); - it('fill in with full reference', () => { + it('updates pending references with full reference', async () => { const input = 'asdf/qwer#444 '; - wrapper.vm.onInput({ untouchedRawReferences: [input.trim()], touchedReference: input }); + createComponent(); + + findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { + untouchedRawReferences: [input.trim()], + touchedReference: input, + }); + await nextTick(); - expect(wrapper.vm.state.pendingReferences).toHaveLength(1); - expect(wrapper.vm.state.pendingReferences[0]).toEqual('asdf/qwer#444'); + expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([input.trim()]); }); - it('fill in with issue link', () => { + it('updates pending references with issue link', async () => { const link = 'http://localhost:3000/foo/bar/issues/111'; const input = `${link} `; - wrapper.vm.onInput({ untouchedRawReferences: [input.trim()], touchedReference: input }); + createComponent(); - expect(wrapper.vm.state.pendingReferences).toHaveLength(1); - expect(wrapper.vm.state.pendingReferences[0]).toEqual(link); + findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { + untouchedRawReferences: [input.trim()], + touchedReference: input, + }); + await nextTick(); + + expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([link]); }); - it('fill in with multiple references', () => { + it('updates pending references with multiple references', async () => { const input = 'asdf/qwer#444 #12 '; - wrapper.vm.onInput({ + createComponent(); + + findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { untouchedRawReferences: input.trim().split(/\s/), touchedReference: '2', }); + await nextTick(); - expect(wrapper.vm.state.pendingReferences).toHaveLength(2); - expect(wrapper.vm.state.pendingReferences[0]).toEqual('asdf/qwer#444'); - expect(wrapper.vm.state.pendingReferences[1]).toEqual('#12'); + expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([ + 'asdf/qwer#444', + '#12', + ]); }); - it('fill in with some invalid things', () => { + it('updates pending references with invalid values', async () => { const input = 'something random '; - wrapper.vm.onInput({ + createComponent(); + + findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { untouchedRawReferences: input.trim().split(/\s/), touchedReference: '2', }); + await nextTick(); - expect(wrapper.vm.state.pendingReferences).toHaveLength(2); - expect(wrapper.vm.state.pendingReferences[0]).toEqual('something'); - expect(wrapper.vm.state.pendingReferences[1]).toEqual('random'); + expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([ + 'something', + 'random', + ]); }); - it.each` - pathIdSeparator - ${'#'} - ${'&'} - `( - 'prepends $pathIdSeparator when user enters a numeric value [0-9]', - async ({ pathIdSeparator }) => { + it.each(['#', '&'])( + 'prepends %s when user enters a numeric value [0-9]', + async (pathIdSeparator) => { const input = '23'; + createComponent({ props: { pathIdSeparator } }); - await wrapper.setProps({ - pathIdSeparator, - }); - - wrapper.vm.onInput({ + findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { untouchedRawReferences: input.trim().split(/\s/), touchedReference: input, }); + await nextTick(); - expect(wrapper.vm.inputValue).toBe(`${pathIdSeparator}${input}`); + expect(findRelatedIssuesBlock().props('inputValue')).toBe(`${pathIdSeparator}${input}`); }, ); - - it('prepends # when user enters a number', async () => { - const input = 23; - - wrapper.vm.onInput({ - untouchedRawReferences: String(input).trim().split(/\s/), - touchedReference: input, - }); - - expect(wrapper.vm.inputValue).toBe(`#${input}`); - }); }); - describe('onBlur', () => { - beforeEach(() => - createComponent().then(() => { - jest.spyOn(wrapper.vm, 'processAllReferences').mockImplementation(() => {}); - }), - ); - - it('add any references to pending when blurring', () => { - const input = '#123'; - - wrapper.vm.onBlur(input); - - expect(wrapper.vm.processAllReferences).toHaveBeenCalledWith(input); + describe('when "addIssuableFormBlur" event is emitted', () => { + beforeEach(() => { + createComponent(); + jest.spyOn(wrapper.vm, 'processAllReferences').mockImplementation(() => {}); }); - }); - - describe('processAllReferences', () => { - beforeEach(() => createComponent()); - it('add valid reference to pending', () => { + it('adds any references to pending when blurring', () => { const input = '#123'; - wrapper.vm.processAllReferences(input); - expect(wrapper.vm.state.pendingReferences).toHaveLength(1); - expect(wrapper.vm.state.pendingReferences[0]).toEqual('#123'); - }); + findRelatedIssuesBlock().vm.$emit('addIssuableFormBlur', input); - it('add any valid references to pending', () => { - const input = 'asdf #123'; - wrapper.vm.processAllReferences(input); - - expect(wrapper.vm.state.pendingReferences).toHaveLength(2); - expect(wrapper.vm.state.pendingReferences[0]).toEqual('asdf'); - expect(wrapper.vm.state.pendingReferences[1]).toEqual('#123'); + expect(wrapper.vm.processAllReferences).toHaveBeenCalledWith(input); }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js index f4e6d43812d..ec2e833552a 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js @@ -17,6 +17,12 @@ import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import waitForPromises from 'helpers/wait_for_promises'; import getPackagePipelines from '~/packages_and_registries/package_registry/graphql/queries/get_package_pipelines.query.graphql'; +import Tracking from '~/tracking'; +import { + TRACKING_ACTION_CLICK_PIPELINE_LINK, + TRACKING_ACTION_CLICK_COMMIT_LINK, + TRACKING_LABEL_PACKAGE_HISTORY, +} from '~/packages_and_registries/package_registry/constants'; Vue.use(VueApollo); @@ -181,7 +187,6 @@ describe('Package History', () => { it('link', () => { const linkElement = findElementLink(element); const exist = Boolean(link); - expect(linkElement.exists()).toBe(exist); if (exist) { expect(linkElement.attributes('href')).toBe(link); @@ -189,4 +194,29 @@ describe('Package History', () => { }); }, ); + describe('tracking', () => { + let eventSpy; + const category = 'UI::Packages'; + + beforeEach(() => { + mountComponent(); + eventSpy = jest.spyOn(Tracking, 'event'); + }); + + it('clicking pipeline link tracks the right action', () => { + wrapper.vm.trackPipelineClick(); + expect(eventSpy).toHaveBeenCalledWith(category, TRACKING_ACTION_CLICK_PIPELINE_LINK, { + category, + label: TRACKING_LABEL_PACKAGE_HISTORY, + }); + }); + + it('clicking commit link tracks the right action', () => { + wrapper.vm.trackCommitClick(); + expect(eventSpy).toHaveBeenCalledWith(category, TRACKING_ACTION_CLICK_COMMIT_LINK, { + category, + label: TRACKING_LABEL_PACKAGE_HISTORY, + }); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js index 70b1261bdb7..01891012f99 100644 --- a/spec/frontend/work_items/components/work_item_detail_modal_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js @@ -7,6 +7,13 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import deleteWorkItemFromTaskMutation from '~/work_items/graphql/delete_task_from_work_item.mutation.graphql'; +import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql'; +import { + deleteWorkItemFromTaskMutationErrorResponse, + deleteWorkItemFromTaskMutationResponse, + deleteWorkItemMutationErrorResponse, + deleteWorkItemResponse, +} from '../mock_data'; describe('WorkItemDetailModal component', () => { let wrapper; @@ -25,28 +32,38 @@ describe('WorkItemDetailModal component', () => { }, }; + const defaultPropsData = { + issueGid: 'gid://gitlab/WorkItem/1', + workItemId: 'gid://gitlab/WorkItem/2', + }; + const findModal = () => wrapper.findComponent(GlModal); const findAlert = () => wrapper.findComponent(GlAlert); const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail); - const createComponent = ({ workItemId = '1', issueGid = '2', error = false } = {}) => { + const createComponent = ({ + lockVersion, + lineNumberStart, + lineNumberEnd, + error = false, + deleteWorkItemFromTaskMutationHandler = jest + .fn() + .mockResolvedValue(deleteWorkItemFromTaskMutationResponse), + deleteWorkItemMutationHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse), + } = {}) => { const apolloProvider = createMockApollo([ - [ - deleteWorkItemFromTaskMutation, - jest.fn().mockResolvedValue({ - data: { - workItemDeleteTask: { - workItem: { id: 123, descriptionHtml: 'updated work item desc' }, - errors: [], - }, - }, - }), - ], + [deleteWorkItemFromTaskMutation, deleteWorkItemFromTaskMutationHandler], + [deleteWorkItemMutation, deleteWorkItemMutationHandler], ]); wrapper = shallowMount(WorkItemDetailModal, { apolloProvider, - propsData: { workItemId, issueGid }, + propsData: { + ...defaultPropsData, + lockVersion, + lineNumberStart, + lineNumberEnd, + }, data() { return { error, @@ -67,8 +84,8 @@ describe('WorkItemDetailModal component', () => { expect(findWorkItemDetail().props()).toEqual({ isModal: true, - workItemId: '1', - workItemParentId: '2', + workItemId: defaultPropsData.workItemId, + workItemParentId: defaultPropsData.issueGid, }); }); @@ -109,16 +126,85 @@ describe('WorkItemDetailModal component', () => { }); describe('delete work item', () => { - it('emits workItemDeleted and closes modal', async () => { - createComponent(); - const newDesc = 'updated work item desc'; - - findWorkItemDetail().vm.$emit('deleteWorkItem'); - - await waitForPromises(); + describe('when there is task data', () => { + it('emits workItemDeleted and closes modal', async () => { + const mutationMock = jest.fn().mockResolvedValue(deleteWorkItemFromTaskMutationResponse); + createComponent({ + lockVersion: 1, + lineNumberStart: '3', + lineNumberEnd: '3', + deleteWorkItemFromTaskMutationHandler: mutationMock, + }); + const newDesc = 'updated work item desc'; + + findWorkItemDetail().vm.$emit('deleteWorkItem'); + await waitForPromises(); + + expect(wrapper.emitted('workItemDeleted')).toEqual([[newDesc]]); + expect(hideModal).toHaveBeenCalled(); + expect(mutationMock).toHaveBeenCalledWith({ + input: { + id: defaultPropsData.issueGid, + lockVersion: 1, + taskData: { id: defaultPropsData.workItemId, lineNumberEnd: 3, lineNumberStart: 3 }, + }, + }); + }); + + it.each` + errorType | mutationMock | errorMessage + ${'an error in the mutation response'} | ${jest.fn().mockResolvedValue(deleteWorkItemFromTaskMutationErrorResponse)} | ${'Error'} + ${'a network error'} | ${jest.fn().mockRejectedValue(new Error('GraphQL networkError'))} | ${'GraphQL networkError'} + `( + 'shows an error message when there is $errorType', + async ({ mutationMock, errorMessage }) => { + createComponent({ + lockVersion: 1, + lineNumberStart: '3', + lineNumberEnd: '3', + deleteWorkItemFromTaskMutationHandler: mutationMock, + }); + + findWorkItemDetail().vm.$emit('deleteWorkItem'); + await waitForPromises(); + + expect(wrapper.emitted('workItemDeleted')).toBeUndefined(); + expect(hideModal).not.toHaveBeenCalled(); + expect(findAlert().text()).toBe(errorMessage); + }, + ); + }); - expect(wrapper.emitted('workItemDeleted')).toEqual([[newDesc]]); - expect(hideModal).toHaveBeenCalled(); + describe('when there is no task data', () => { + it('emits workItemDeleted and closes modal', async () => { + const mutationMock = jest.fn().mockResolvedValue(deleteWorkItemResponse); + createComponent({ deleteWorkItemMutationHandler: mutationMock }); + + findWorkItemDetail().vm.$emit('deleteWorkItem'); + await waitForPromises(); + + expect(wrapper.emitted('workItemDeleted')).toEqual([[defaultPropsData.workItemId]]); + expect(hideModal).toHaveBeenCalled(); + expect(mutationMock).toHaveBeenCalledWith({ input: { id: defaultPropsData.workItemId } }); + }); + + it.each` + errorType | mutationMock | errorMessage + ${'an error in the mutation response'} | ${jest.fn().mockResolvedValue(deleteWorkItemMutationErrorResponse)} | ${'Error'} + ${'a network error'} | ${jest.fn().mockRejectedValue(new Error('GraphQL networkError'))} | ${'GraphQL networkError'} + `( + 'shows an error message when there is $errorType', + async ({ mutationMock, errorMessage }) => { + createComponent({ deleteWorkItemMutationHandler: mutationMock }); + + findWorkItemDetail().vm.$emit('deleteWorkItem'); + await waitForPromises(); + + expect(wrapper.emitted('workItemDeleted')).toBeUndefined(); + expect(hideModal).not.toHaveBeenCalled(); + expect(findAlert().text()).toBe(errorMessage); + }, + ); }); }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index aa0f7317bd0..7232d588add 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -289,6 +289,32 @@ export const deleteWorkItemFailureResponse = { ], }; +export const deleteWorkItemMutationErrorResponse = { + data: { + workItemDelete: { + errors: ['Error'], + }, + }, +}; + +export const deleteWorkItemFromTaskMutationResponse = { + data: { + workItemDeleteTask: { + workItem: { id: 123, descriptionHtml: 'updated work item desc' }, + errors: [], + }, + }, +}; + +export const deleteWorkItemFromTaskMutationErrorResponse = { + data: { + workItemDeleteTask: { + workItem: { id: 123, descriptionHtml: 'updated work item desc' }, + errors: ['Error'], + }, + }, +}; + export const workItemTitleSubscriptionResponse = { data: { issuableTitleUpdated: { |