diff options
Diffstat (limited to 'spec/frontend/issuable')
5 files changed, 416 insertions, 321 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/popover/components/issue_popover_spec.js b/spec/frontend/issuable/popover/components/issue_popover_spec.js index 3e77e750f3a..444165f61c7 100644 --- a/spec/frontend/issuable/popover/components/issue_popover_spec.js +++ b/spec/frontend/issuable/popover/components/issue_popover_spec.js @@ -1,33 +1,23 @@ -import { GlSkeletonLoader } from '@gitlab/ui'; +import { GlIcon, GlSkeletonLoader } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import issueQueryResponse from 'test_fixtures/graphql/issuable/popover/queries/issue.query.graphql.json'; +import issueQuery from 'ee_else_ce/issuable/popover/queries/issue.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import IssueDueDate from '~/boards/components/issue_due_date.vue'; +import IssueMilestone from '~/issuable/components/issue_milestone.vue'; import StatusBox from '~/issuable/components/status_box.vue'; import IssuePopover from '~/issuable/popover/components/issue_popover.vue'; -import issueQuery from '~/issuable/popover/queries/issue.query.graphql'; +import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; describe('Issue Popover', () => { let wrapper; Vue.use(VueApollo); - const issueQueryResponse = { - data: { - project: { - __typename: 'Project', - id: '1', - issue: { - __typename: 'Issue', - id: 'gid://gitlab/Issue/1', - createdAt: '2020-07-01T04:08:01Z', - state: 'opened', - title: 'Issue title', - }, - }, - }, - }; + const findWorkItemIcon = () => wrapper.findComponent(WorkItemTypeIcon); const mountComponent = ({ queryResponse = jest.fn().mockResolvedValue(issueQueryResponse), @@ -53,6 +43,12 @@ describe('Issue Popover', () => { expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); }); + it('should not show any work item icon while apollo is loading', () => { + mountComponent(); + + expect(findWorkItemIcon().exists()).toBe(false); + }); + describe('when loaded', () => { beforeEach(() => { mountComponent(); @@ -74,8 +70,40 @@ describe('Issue Popover', () => { expect(wrapper.find('h5').text()).toBe(issueQueryResponse.data.project.issue.title); }); + it('shows the work type icon', () => { + expect(findWorkItemIcon().props('workItemType')).toBe( + issueQueryResponse.data.project.issue.type, + ); + }); + it('shows reference', () => { expect(wrapper.text()).toContain('foo/bar#1'); }); + + it('shows confidential icon', () => { + const icon = wrapper.findComponent(GlIcon); + + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('eye-slash'); + }); + + it('shows due date', () => { + const component = wrapper.findComponent(IssueDueDate); + + expect(component.exists()).toBe(true); + expect(component.props('date')).toBe('2020-07-05'); + expect(component.props('closed')).toBe(false); + }); + + it('shows milestone', () => { + const component = wrapper.findComponent(IssueMilestone); + + expect(component.exists()).toBe(true); + expect(component.props('milestone')).toMatchObject({ + title: '15.2', + startDate: '2020-07-01', + dueDate: '2020-07-30', + }); + }); }); }); diff --git a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js index ce98a16dbb7..16d4459f597 100644 --- a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js +++ b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js @@ -157,8 +157,8 @@ describe('AddIssuableForm', () => { describe('categorized issuables', () => { it.each` issuableType | pathIdSeparator | contextHeader | contextFooter - ${issuableTypesMap.ISSUE} | ${PathIdSeparator.Issue} | ${'The current issue'} | ${'the following issue(s)'} - ${issuableTypesMap.EPIC} | ${PathIdSeparator.Epic} | ${'The current epic'} | ${'the following epic(s)'} + ${issuableTypesMap.ISSUE} | ${PathIdSeparator.Issue} | ${'The current issue'} | ${'the following issues'} + ${issuableTypesMap.EPIC} | ${PathIdSeparator.Epic} | ${'The current epic'} | ${'the following epics'} `( 'show header text as "$contextHeader" and footer text as "$contextFooter" issuableType is set to $issuableType', ({ issuableType, contextHeader, contextFooter }) => { diff --git a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js index 7a350df0ba6..772cc75a205 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js @@ -1,5 +1,6 @@ -import { GlButton, GlIcon } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { GlIcon } from '@gitlab/ui'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { issuable1, issuable2, @@ -17,7 +18,9 @@ import { describe('RelatedIssuesBlock', () => { let wrapper; - const findIssueCountBadgeAddButton = () => wrapper.find(GlButton); + const findToggleButton = () => wrapper.findByTestId('toggle-links'); + const findRelatedIssuesBody = () => wrapper.findByTestId('related-issues-body'); + const findIssueCountBadgeAddButton = () => wrapper.findByTestId('related-issues-plus-button'); afterEach(() => { if (wrapper) { @@ -28,7 +31,7 @@ describe('RelatedIssuesBlock', () => { describe('with defaults', () => { beforeEach(() => { - wrapper = mount(RelatedIssuesBlock, { + wrapper = mountExtended(RelatedIssuesBlock, { propsData: { pathIdSeparator: PathIdSeparator.Issue, issuableType: issuableTypesMap.ISSUE, @@ -37,13 +40,13 @@ describe('RelatedIssuesBlock', () => { }); it.each` - issuableType | pathIdSeparator | titleText | helpLinkText | addButtonText - ${'issue'} | ${PathIdSeparator.Issue} | ${'Linked issues'} | ${'Read more about related issues'} | ${'Add a related issue'} - ${'epic'} | ${PathIdSeparator.Epic} | ${'Linked epics'} | ${'Read more about related epics'} | ${'Add a related epic'} + issuableType | pathIdSeparator | titleText | helpLinkText | addButtonText + ${'issue'} | ${PathIdSeparator.Issue} | ${'Linked items'} | ${'Read more about related issues'} | ${'Add a related issue'} + ${'epic'} | ${PathIdSeparator.Epic} | ${'Linked epics'} | ${'Read more about related epics'} | ${'Add a related epic'} `( 'displays "$titleText" in the header, "$helpLinkText" aria-label for help link, and "$addButtonText" aria-label for add button when issuableType is set to "$issuableType"', ({ issuableType, pathIdSeparator, titleText, helpLinkText, addButtonText }) => { - wrapper = mount(RelatedIssuesBlock, { + wrapper = mountExtended(RelatedIssuesBlock, { propsData: { pathIdSeparator, issuableType, @@ -73,7 +76,7 @@ describe('RelatedIssuesBlock', () => { it('displays header text slot data', () => { const headerText = '<div>custom header text</div>'; - wrapper = shallowMount(RelatedIssuesBlock, { + wrapper = shallowMountExtended(RelatedIssuesBlock, { propsData: { pathIdSeparator: PathIdSeparator.Issue, issuableType: 'issue', @@ -89,7 +92,7 @@ describe('RelatedIssuesBlock', () => { it('displays header actions slot data', () => { const headerActions = '<button data-testid="custom-button">custom button</button>'; - wrapper = shallowMount(RelatedIssuesBlock, { + wrapper = shallowMountExtended(RelatedIssuesBlock, { propsData: { pathIdSeparator: PathIdSeparator.Issue, issuableType: 'issue', @@ -103,7 +106,7 @@ describe('RelatedIssuesBlock', () => { describe('with isFetching=true', () => { beforeEach(() => { - wrapper = mount(RelatedIssuesBlock, { + wrapper = mountExtended(RelatedIssuesBlock, { propsData: { pathIdSeparator: PathIdSeparator.Issue, isFetching: true, @@ -119,7 +122,7 @@ describe('RelatedIssuesBlock', () => { describe('with canAddRelatedIssues=true', () => { beforeEach(() => { - wrapper = mount(RelatedIssuesBlock, { + wrapper = mountExtended(RelatedIssuesBlock, { propsData: { pathIdSeparator: PathIdSeparator.Issue, canAdmin: true, @@ -135,7 +138,7 @@ describe('RelatedIssuesBlock', () => { describe('with isFormVisible=true', () => { beforeEach(() => { - wrapper = mount(RelatedIssuesBlock, { + wrapper = mountExtended(RelatedIssuesBlock, { propsData: { pathIdSeparator: PathIdSeparator.Issue, isFormVisible: true, @@ -159,7 +162,7 @@ describe('RelatedIssuesBlock', () => { const categorizedHeadings = () => wrapper.findAll('h4'); const headingTextAt = (index) => categorizedHeadings().at(index).text(); const mountComponent = (showCategorizedIssues) => { - wrapper = mount(RelatedIssuesBlock, { + wrapper = mountExtended(RelatedIssuesBlock, { propsData: { pathIdSeparator: PathIdSeparator.Issue, relatedIssues: [issuable1, issuable2, issuable3], @@ -217,7 +220,7 @@ describe('RelatedIssuesBlock', () => { }, ].forEach(({ issuableType, icon }) => { it(`issuableType=${issuableType} is passed`, () => { - wrapper = shallowMount(RelatedIssuesBlock, { + wrapper = shallowMountExtended(RelatedIssuesBlock, { propsData: { pathIdSeparator: PathIdSeparator.Issue, issuableType, @@ -230,4 +233,42 @@ describe('RelatedIssuesBlock', () => { }); }); }); + + describe('toggle', () => { + beforeEach(() => { + wrapper = shallowMountExtended(RelatedIssuesBlock, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + relatedIssues: [issuable1, issuable2, issuable3], + issuableType: issuableTypesMap.ISSUE, + }, + }); + }); + + it('is expanded by default', () => { + expect(findToggleButton().props('icon')).toBe('chevron-lg-up'); + expect(findToggleButton().props('disabled')).toBe(false); + expect(findRelatedIssuesBody().exists()).toBe(true); + }); + + it('expands on click toggle button', async () => { + findToggleButton().vm.$emit('click'); + await nextTick(); + + expect(findToggleButton().props('icon')).toBe('chevron-lg-down'); + expect(findRelatedIssuesBody().exists()).toBe(false); + }); + }); + + it('toggle button is disabled when issue has no related items', () => { + wrapper = shallowMountExtended(RelatedIssuesBlock, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + relatedIssues: [], + issuableType: 'issue', + }, + }); + + expect(findToggleButton().props('disabled')).toBe(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); }); }); }); |