diff options
Diffstat (limited to 'spec/frontend/issue_show')
-rw-r--r-- | spec/frontend/issue_show/components/app_spec.js | 50 | ||||
-rw-r--r-- | spec/frontend/issue_show/components/header_actions_spec.js | 328 | ||||
-rw-r--r-- | spec/frontend/issue_show/issue_spec.js | 5 |
3 files changed, 369 insertions, 14 deletions
diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js index f4095d4de96..dde4e8458d5 100644 --- a/spec/frontend/issue_show/components/app_spec.js +++ b/spec/frontend/issue_show/components/app_spec.js @@ -17,6 +17,7 @@ import { import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue'; import DescriptionComponent from '~/issue_show/components/description.vue'; import PinnedLinks from '~/issue_show/components/pinned_links.vue'; +import { IssuableStatus, IssuableStatusText } from '~/issue_show/constants'; function formatText(text) { return text.trim().replace(/\s\s+/g, ' '); @@ -36,6 +37,10 @@ describe('Issuable output', () => { const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]'); + const findLockedBadge = () => wrapper.find('[data-testid="locked"]'); + + const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]'); + const mountComponent = (props = {}, options = {}) => { wrapper = mount(IssuableApp, { propsData: { ...appProps, ...props }, @@ -532,7 +537,7 @@ describe('Issuable output', () => { describe('sticky header', () => { describe('when title is in view', () => { it('is not shown', () => { - expect(wrapper.find('.issue-sticky-header').exists()).toBe(false); + expect(findStickyHeader().exists()).toBe(false); }); }); @@ -542,24 +547,45 @@ describe('Issuable output', () => { wrapper.find(GlIntersectionObserver).vm.$emit('disappear'); }); - it('is shown with title', () => { + it('shows with title', () => { expect(findStickyHeader().text()).toContain('Sticky header title'); }); - it('is shown with Open when status is opened', () => { - wrapper.setProps({ issuableStatus: 'opened' }); + it.each` + title | state + ${'shows with Open when status is opened'} | ${IssuableStatus.Open} + ${'shows with Closed when status is closed'} | ${IssuableStatus.Closed} + ${'shows with Open when status is reopened'} | ${IssuableStatus.Reopened} + `('$title', async ({ state }) => { + wrapper.setProps({ issuableStatus: state }); - return wrapper.vm.$nextTick(() => { - expect(findStickyHeader().text()).toContain('Open'); - }); + await wrapper.vm.$nextTick(); + + expect(findStickyHeader().text()).toContain(IssuableStatusText[state]); }); - it('is shown with Closed when status is closed', () => { - wrapper.setProps({ issuableStatus: 'closed' }); + it.each` + title | isConfidential + ${'does not show confidential badge when issue is not confidential'} | ${true} + ${'shows confidential badge when issue is confidential'} | ${false} + `('$title', async ({ isConfidential }) => { + wrapper.setProps({ isConfidential }); - return wrapper.vm.$nextTick(() => { - expect(findStickyHeader().text()).toContain('Closed'); - }); + await wrapper.vm.$nextTick(); + + expect(findConfidentialBadge().exists()).toBe(isConfidential); + }); + + it.each` + title | isLocked + ${'does not show locked badge when issue is not locked'} | ${true} + ${'shows locked badge when issue is locked'} | ${false} + `('$title', async ({ isLocked }) => { + wrapper.setProps({ isLocked }); + + await wrapper.vm.$nextTick(); + + expect(findLockedBadge().exists()).toBe(isLocked); }); }); }); diff --git a/spec/frontend/issue_show/components/header_actions_spec.js b/spec/frontend/issue_show/components/header_actions_spec.js new file mode 100644 index 00000000000..67b8665a889 --- /dev/null +++ b/spec/frontend/issue_show/components/header_actions_spec.js @@ -0,0 +1,328 @@ +import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import createFlash, { FLASH_TYPES } from '~/flash'; +import { IssuableType } from '~/issuable_show/constants'; +import HeaderActions from '~/issue_show/components/header_actions.vue'; +import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants'; +import promoteToEpicMutation from '~/issue_show/queries/promote_to_epic.mutation.graphql'; +import * as urlUtility from '~/lib/utils/url_utility'; +import createStore from '~/notes/stores'; + +jest.mock('~/flash'); + +describe('HeaderActions component', () => { + let dispatchEventSpy; + let mutateMock; + let wrapper; + let visitUrlSpy; + + const localVue = createLocalVue(); + localVue.use(Vuex); + const store = createStore(); + + const defaultProps = { + canCreateIssue: true, + canPromoteToEpic: true, + canReopenIssue: true, + canReportSpam: true, + canUpdateIssue: true, + iid: '32', + isIssueAuthor: true, + issueType: IssuableType.Issue, + newIssuePath: 'gitlab-org/gitlab-test/-/issues/new', + projectPath: 'gitlab-org/gitlab-test', + reportAbusePath: + '-/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%2Fgitlab-org%2Fgitlab-test%2F-%2Fissues%2F32&user_id=1', + submitAsSpamPath: 'gitlab-org/gitlab-test/-/issues/32/submit_as_spam', + }; + + const updateIssueMutationResponse = { data: { updateIssue: { errors: [] } } }; + + const promoteToEpicMutationResponse = { + data: { + promoteToEpic: { + errors: [], + epic: { + webPath: '/groups/gitlab-org/-/epics/1', + }, + }, + }, + }; + + const promoteToEpicMutationErrorResponse = { + data: { + promoteToEpic: { + errors: ['The issue has already been promoted to an epic.'], + epic: {}, + }, + }, + }; + + const findToggleIssueStateButton = () => wrapper.find(GlButton); + + const findDropdownAt = index => wrapper.findAll(GlDropdown).at(index); + + const findMobileDropdownItems = () => findDropdownAt(0).findAll(GlDropdownItem); + + const findDesktopDropdownItems = () => findDropdownAt(1).findAll(GlDropdownItem); + + const findModal = () => wrapper.find(GlModal); + + const findModalLinkAt = index => + findModal() + .findAll(GlLink) + .at(index); + + const mountComponent = ({ + props = {}, + issueState = IssuableStatus.Open, + blockedByIssues = [], + mutateResponse = {}, + } = {}) => { + mutateMock = jest.fn().mockResolvedValue(mutateResponse); + + store.getters.getNoteableData.state = issueState; + store.getters.getNoteableData.blocked_by_issues = blockedByIssues; + + return shallowMount(HeaderActions, { + localVue, + store, + provide: { + ...defaultProps, + ...props, + }, + mocks: { + $apollo: { + mutate: mutateMock, + }, + }, + }); + }; + + afterEach(() => { + if (dispatchEventSpy) { + dispatchEventSpy.mockRestore(); + } + if (visitUrlSpy) { + visitUrlSpy.mockRestore(); + } + wrapper.destroy(); + }); + + describe.each` + issueType + ${IssuableType.Issue} + ${IssuableType.Incident} + `('when issue type is $issueType', ({ issueType }) => { + describe('close/reopen button', () => { + describe.each` + description | issueState | buttonText | newIssueState + ${`when the ${issueType} is open`} | ${IssuableStatus.Open} | ${`Close ${issueType}`} | ${IssueStateEvent.Close} + ${`when the ${issueType} is closed`} | ${IssuableStatus.Closed} | ${`Reopen ${issueType}`} | ${IssueStateEvent.Reopen} + `('$description', ({ issueState, buttonText, newIssueState }) => { + beforeEach(() => { + dispatchEventSpy = jest.spyOn(document, 'dispatchEvent'); + + wrapper = mountComponent({ + props: { issueType }, + issueState, + mutateResponse: updateIssueMutationResponse, + }); + }); + + it(`has text "${buttonText}"`, () => { + expect(findToggleIssueStateButton().text()).toBe(buttonText); + }); + + it('calls apollo mutation', () => { + findToggleIssueStateButton().vm.$emit('click'); + + expect(mutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + iid: defaultProps.iid, + projectPath: defaultProps.projectPath, + stateEvent: newIssueState, + }, + }, + }), + ); + }); + + it('dispatches a custom event to update the issue page', async () => { + findToggleIssueStateButton().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(dispatchEventSpy).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe.each` + description | isCloseIssueItemVisible | findDropdownItems + ${'mobile dropdown'} | ${true} | ${findMobileDropdownItems} + ${'desktop dropdown'} | ${false} | ${findDesktopDropdownItems} + `('$description', ({ isCloseIssueItemVisible, findDropdownItems }) => { + describe.each` + description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic + ${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true} + ${`when user can create ${issueType}`} | ${`New ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user cannot create ${issueType}`} | ${`New ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true} + ${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} + ${'when user can report abuse'} | ${'Report abuse'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} + ${'when user cannot report abuse'} | ${'Report abuse'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} + ${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true} + `( + '$description', + ({ + itemText, + isItemVisible, + canUpdateIssue, + canCreateIssue, + isIssueAuthor, + canReportSpam, + canPromoteToEpic, + }) => { + beforeEach(() => { + wrapper = mountComponent({ + props: { + canUpdateIssue, + canCreateIssue, + isIssueAuthor, + issueType, + canReportSpam, + canPromoteToEpic, + }, + }); + }); + + it(`${isItemVisible ? 'shows' : 'hides'} "${itemText}" item`, () => { + expect( + findDropdownItems() + .filter(item => item.text() === itemText) + .exists(), + ).toBe(isItemVisible); + }); + }, + ); + }); + }); + + describe('when "Promote to epic" button is clicked', () => { + describe('when response is successful', () => { + beforeEach(() => { + visitUrlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({}); + + wrapper = mountComponent({ + mutateResponse: promoteToEpicMutationResponse, + }); + + wrapper.find('[data-testid="promote-button"]').vm.$emit('click'); + }); + + it('invokes GraphQL mutation when clicked', () => { + expect(mutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + mutation: promoteToEpicMutation, + variables: { + input: { + iid: defaultProps.iid, + projectPath: defaultProps.projectPath, + }, + }, + }), + ); + }); + + it('shows a success message and tells the user they are being redirected', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: 'The issue was successfully promoted to an epic. Redirecting to epic...', + type: FLASH_TYPES.SUCCESS, + }); + }); + + it('redirects to newly created epic path', () => { + expect(visitUrlSpy).toHaveBeenCalledWith( + promoteToEpicMutationResponse.data.promoteToEpic.epic.webPath, + ); + }); + }); + + describe('when response contains errors', () => { + beforeEach(() => { + visitUrlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({}); + + wrapper = mountComponent({ + mutateResponse: promoteToEpicMutationErrorResponse, + }); + + wrapper.find('[data-testid="promote-button"]').vm.$emit('click'); + }); + + it('shows an error message', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: promoteToEpicMutationErrorResponse.data.promoteToEpic.errors.join('; '), + }); + }); + }); + }); + + describe('modal', () => { + const blockedByIssues = [ + { iid: 13, web_url: 'gitlab-org/gitlab-test/-/issues/13' }, + { iid: 79, web_url: 'gitlab-org/gitlab-test/-/issues/79' }, + ]; + + beforeEach(() => { + wrapper = mountComponent({ blockedByIssues }); + }); + + it('has title text', () => { + expect(findModal().attributes('title')).toBe( + 'Are you sure you want to close this blocked issue?', + ); + }); + + it('has body text', () => { + expect(findModal().text()).toContain( + 'This issue is currently blocked by the following issues:', + ); + }); + + it('calls apollo mutation when primary button is clicked', () => { + findModal().vm.$emit('primary'); + + expect(mutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + iid: defaultProps.iid.toString(), + projectPath: defaultProps.projectPath, + stateEvent: IssueStateEvent.Close, + }, + }, + }), + ); + }); + + describe.each` + ordinal | index + ${'first'} | ${0} + ${'second'} | ${1} + `('$ordinal blocked-by issue link', ({ index }) => { + it('has link text', () => { + expect(findModalLinkAt(index).text()).toBe(`#${blockedByIssues[index].iid}`); + }); + + it('has url', () => { + expect(findModalLinkAt(index).attributes('href')).toBe(blockedByIssues[index].web_url); + }); + }); + }); +}); diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issue_show/issue_spec.js index c0175e774a2..7a48353af94 100644 --- a/spec/frontend/issue_show/issue_spec.js +++ b/spec/frontend/issue_show/issue_spec.js @@ -2,9 +2,10 @@ import MockAdapter from 'axios-mock-adapter'; import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; -import initIssuableApp from '~/issue_show/issue'; +import { initIssuableApp } from '~/issue_show/issue'; import * as parseData from '~/issue_show/utils/parse_data'; import { appProps } from './mock_data'; +import createStore from '~/notes/stores'; const mock = new MockAdapter(axios); mock.onGet().reply(200); @@ -30,7 +31,7 @@ describe('Issue show index', () => { }); const issuableData = parseData.parseIssuableData(); - initIssuableApp(issuableData); + initIssuableApp(issuableData, createStore()); await waitForPromises(); |