diff options
Diffstat (limited to 'spec/frontend/issues')
-rw-r--r-- | spec/frontend/issues/create_merge_request_dropdown_spec.js | 122 | ||||
-rw-r--r-- | spec/frontend/issues/list/components/issue_card_time_info_spec.js | 122 | ||||
-rw-r--r-- | spec/frontend/issues/list/components/issues_list_app_spec.js | 829 | ||||
-rw-r--r-- | spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js | 117 | ||||
-rw-r--r-- | spec/frontend/issues/list/components/new_issue_dropdown_spec.js | 131 | ||||
-rw-r--r-- | spec/frontend/issues/list/mock_data.js | 310 | ||||
-rw-r--r-- | spec/frontend/issues/list/utils_spec.js | 127 | ||||
-rw-r--r-- | spec/frontend/issues/new/components/title_suggestions_spec.js | 14 | ||||
-rw-r--r-- | spec/frontend/issues/show/components/fields/type_spec.js | 18 | ||||
-rw-r--r-- | spec/frontend/issues/show/components/header_actions_spec.js | 19 | ||||
-rw-r--r-- | spec/frontend/issues/show/components/sentry_error_stack_trace_spec.js (renamed from spec/frontend/issues/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js) | 11 | ||||
-rw-r--r-- | spec/frontend/issues/show/issue_spec.js | 6 |
12 files changed, 1798 insertions, 28 deletions
diff --git a/spec/frontend/issues/create_merge_request_dropdown_spec.js b/spec/frontend/issues/create_merge_request_dropdown_spec.js new file mode 100644 index 00000000000..fdc0bd7d72e --- /dev/null +++ b/spec/frontend/issues/create_merge_request_dropdown_spec.js @@ -0,0 +1,122 @@ +import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'helpers/test_constants'; +import confidentialState from '~/confidential_merge_request/state'; +import CreateMergeRequestDropdown from '~/issues/create_merge_request_dropdown'; +import axios from '~/lib/utils/axios_utils'; + +describe('CreateMergeRequestDropdown', () => { + let axiosMock; + let dropdown; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + + document.body.innerHTML = ` + <div id="dummy-wrapper-element"> + <div class="available"></div> + <div class="unavailable"> + <div class="gl-spinner"></div> + <div class="text"></div> + </div> + <div class="js-ref"></div> + <div class="js-create-mr"></div> + <div class="js-create-merge-request"> + <span class="js-spinner"></span> + </div> + <div class="js-create-target"></div> + <div class="js-dropdown-toggle"></div> + </div> + `; + + const dummyElement = document.getElementById('dummy-wrapper-element'); + dropdown = new CreateMergeRequestDropdown(dummyElement); + dropdown.refsPath = `${TEST_HOST}/dummy/refs?search=`; + }); + + afterEach(() => { + axiosMock.restore(); + }); + + describe('getRef', () => { + it('escapes branch names correctly', (done) => { + const endpoint = `${dropdown.refsPath}contains%23hash`; + jest.spyOn(axios, 'get'); + axiosMock.onGet(endpoint).replyOnce({}); + + dropdown + .getRef('contains#hash') + .then(() => { + expect(axios.get).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ cancelToken: expect.anything() }), + ); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateCreatePaths', () => { + it('escapes branch names correctly', () => { + dropdown.createBranchPath = `${TEST_HOST}/branches?branch_name=some-branch&issue=42`; + dropdown.createMrPath = `${TEST_HOST}/create_merge_request?branch_name=some-branch&ref=main`; + + dropdown.updateCreatePaths('branch', 'contains#hash'); + + expect(dropdown.createBranchPath).toBe( + `${TEST_HOST}/branches?branch_name=contains%23hash&issue=42`, + ); + + expect(dropdown.createMrPath).toBe( + `${TEST_HOST}/create_merge_request?branch_name=contains%23hash&ref=main`, + ); + }); + }); + + describe('enable', () => { + beforeEach(() => { + dropdown.createMergeRequestButton.classList.add('disabled'); + }); + + afterEach(() => { + confidentialState.selectedProject = {}; + }); + + it('enables button when not confidential issue', () => { + dropdown.enable(); + + expect(dropdown.createMergeRequestButton.classList).not.toContain('disabled'); + }); + + it('enables when can create confidential issue', () => { + document.querySelector('.js-create-mr').setAttribute('data-is-confidential', 'true'); + confidentialState.selectedProject = { name: 'test' }; + + dropdown.enable(); + + expect(dropdown.createMergeRequestButton.classList).not.toContain('disabled'); + }); + + it('does not enable when can not create confidential issue', () => { + document.querySelector('.js-create-mr').setAttribute('data-is-confidential', 'true'); + + dropdown.enable(); + + expect(dropdown.createMergeRequestButton.classList).toContain('disabled'); + }); + }); + + describe('setLoading', () => { + it.each` + loading | hasClass + ${true} | ${false} + ${false} | ${true} + `('it toggle loading spinner when loading is $loading', ({ loading, hasClass }) => { + dropdown.setLoading(loading); + + expect(document.querySelector('.js-spinner').classList.contains('gl-display-none')).toEqual( + hasClass, + ); + }); + }); +}); diff --git a/spec/frontend/issues/list/components/issue_card_time_info_spec.js b/spec/frontend/issues/list/components/issue_card_time_info_spec.js new file mode 100644 index 00000000000..e9c48b60da4 --- /dev/null +++ b/spec/frontend/issues/list/components/issue_card_time_info_spec.js @@ -0,0 +1,122 @@ +import { GlIcon, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { useFakeDate } from 'helpers/fake_date'; +import IssueCardTimeInfo from '~/issues/list/components/issue_card_time_info.vue'; + +describe('CE IssueCardTimeInfo component', () => { + useFakeDate(2020, 11, 11); + + let wrapper; + + const issue = { + milestone: { + dueDate: '2020-12-17', + startDate: '2020-12-10', + title: 'My milestone', + webPath: '/milestone/webPath', + }, + dueDate: '2020-12-12', + humanTimeEstimate: '1w', + }; + + const findMilestone = () => wrapper.find('[data-testid="issuable-milestone"]'); + const findMilestoneTitle = () => findMilestone().find(GlLink).attributes('title'); + const findDueDate = () => wrapper.find('[data-testid="issuable-due-date"]'); + + const mountComponent = ({ + closedAt = null, + dueDate = issue.dueDate, + milestoneDueDate = issue.milestone.dueDate, + milestoneStartDate = issue.milestone.startDate, + } = {}) => + shallowMount(IssueCardTimeInfo, { + propsData: { + issue: { + ...issue, + milestone: { + ...issue.milestone, + dueDate: milestoneDueDate, + startDate: milestoneStartDate, + }, + closedAt, + dueDate, + }, + }, + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('milestone', () => { + it('renders', () => { + wrapper = mountComponent(); + + const milestone = findMilestone(); + + expect(milestone.text()).toBe(issue.milestone.title); + expect(milestone.find(GlIcon).props('name')).toBe('clock'); + expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webPath); + }); + + describe.each` + time | text | milestoneDueDate | milestoneStartDate | expected + ${'due date is in past'} | ${'Past due'} | ${'2020-09-09'} | ${null} | ${'Sep 9, 2020 (Past due)'} + ${'due date is today'} | ${'Today'} | ${'2020-12-11'} | ${null} | ${'Dec 11, 2020 (Today)'} + ${'start date is in future'} | ${'Upcoming'} | ${'2021-03-01'} | ${'2021-02-01'} | ${'Mar 1, 2021 (Upcoming)'} + ${'due date is in future'} | ${'2 weeks remaining'} | ${'2020-12-25'} | ${null} | ${'Dec 25, 2020 (2 weeks remaining)'} + `('when $description', ({ text, milestoneDueDate, milestoneStartDate, expected }) => { + it(`renders with "${text}"`, () => { + wrapper = mountComponent({ milestoneDueDate, milestoneStartDate }); + + expect(findMilestoneTitle()).toBe(expected); + }); + }); + }); + + describe('due date', () => { + describe('when upcoming', () => { + it('renders', () => { + wrapper = mountComponent(); + + const dueDate = findDueDate(); + + expect(dueDate.text()).toBe('Dec 12, 2020'); + expect(dueDate.attributes('title')).toBe('Due date'); + expect(dueDate.find(GlIcon).props('name')).toBe('calendar'); + expect(dueDate.classes()).not.toContain('gl-text-red-500'); + }); + }); + + describe('when in the past', () => { + describe('when issue is open', () => { + it('renders in red', () => { + wrapper = mountComponent({ dueDate: new Date('2020-10-10') }); + + expect(findDueDate().classes()).toContain('gl-text-red-500'); + }); + }); + + describe('when issue is closed', () => { + it('does not render in red', () => { + wrapper = mountComponent({ + dueDate: new Date('2020-10-10'), + closedAt: '2020-09-05T13:06:25Z', + }); + + expect(findDueDate().classes()).not.toContain('gl-text-red-500'); + }); + }); + }); + }); + + it('renders time estimate', () => { + wrapper = mountComponent(); + + const timeEstimate = wrapper.find('[data-testid="time-estimate"]'); + + expect(timeEstimate.text()).toBe(issue.humanTimeEstimate); + expect(timeEstimate.attributes('title')).toBe('Estimate'); + expect(timeEstimate.find(GlIcon).props('name')).toBe('timer'); + }); +}); diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js new file mode 100644 index 00000000000..66428ee0492 --- /dev/null +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -0,0 +1,829 @@ +import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { mount, shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import { cloneDeep } from 'lodash'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; +import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { TEST_HOST } from 'helpers/test_constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import { + getIssuesCountsQueryResponse, + getIssuesQueryResponse, + filteredTokens, + locationSearch, + urlParams, +} from 'jest/issues/list/mock_data'; +import createFlash, { FLASH_TYPES } from '~/flash'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; +import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; +import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; +import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; +import IssuesListApp from '~/issues/list/components/issues_list_app.vue'; +import NewIssueDropdown from '~/issues/list/components/new_issue_dropdown.vue'; +import { + CREATED_DESC, + DUE_DATE_OVERDUE, + PARAM_DUE_DATE, + RELATIVE_POSITION, + RELATIVE_POSITION_ASC, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_TYPE, + urlSortParams, +} from '~/issues/list/constants'; +import eventHub from '~/issues/list/eventhub'; +import { getSortOptions } from '~/issues/list/utils'; +import axios from '~/lib/utils/axios_utils'; +import { scrollUp } from '~/lib/utils/scroll_utils'; +import { joinPaths } from '~/lib/utils/url_utility'; + +jest.mock('@sentry/browser'); +jest.mock('~/flash'); +jest.mock('~/lib/utils/scroll_utils', () => ({ + scrollUp: jest.fn().mockName('scrollUpMock'), +})); + +describe('CE IssuesListApp component', () => { + let axiosMock; + let wrapper; + + Vue.use(VueApollo); + + const defaultProvide = { + calendarPath: 'calendar/path', + canBulkUpdate: false, + emptyStateSvgPath: 'empty-state.svg', + exportCsvPath: 'export/csv/path', + fullPath: 'path/to/project', + hasAnyIssues: true, + hasAnyProjects: true, + hasBlockedIssuesFeature: true, + hasIssuableHealthStatusFeature: true, + hasIssueWeightsFeature: true, + hasIterationsFeature: true, + isProject: true, + isSignedIn: true, + jiraIntegrationPath: 'jira/integration/path', + newIssuePath: 'new/issue/path', + rssPath: 'rss/path', + showNewIssueLink: true, + signInPath: 'sign/in/path', + }; + + let defaultQueryResponse = getIssuesQueryResponse; + if (IS_EE) { + defaultQueryResponse = cloneDeep(getIssuesQueryResponse); + defaultQueryResponse.data.project.issues.nodes[0].blockingCount = 1; + defaultQueryResponse.data.project.issues.nodes[0].healthStatus = null; + defaultQueryResponse.data.project.issues.nodes[0].weight = 5; + } + + const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons); + const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail); + const findGlButton = () => wrapper.findComponent(GlButton); + const findGlButtons = () => wrapper.findAllComponents(GlButton); + const findGlButtonAt = (index) => findGlButtons().at(index); + const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); + const findGlLink = () => wrapper.findComponent(GlLink); + const findIssuableList = () => wrapper.findComponent(IssuableList); + const findNewIssueDropdown = () => wrapper.findComponent(NewIssueDropdown); + + const mountComponent = ({ + provide = {}, + issuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse), + issuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse), + mountFn = shallowMount, + } = {}) => { + const requestHandlers = [ + [getIssuesQuery, issuesQueryResponse], + [getIssuesCountsQuery, issuesCountsQueryResponse], + ]; + const apolloProvider = createMockApollo(requestHandlers); + + return mountFn(IssuesListApp, { + apolloProvider, + provide: { + ...defaultProvide, + ...provide, + }, + }); + }; + + beforeEach(() => { + setWindowLocation(TEST_HOST); + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + axiosMock.reset(); + wrapper.destroy(); + }); + + describe('IssuableList', () => { + beforeEach(() => { + wrapper = mountComponent(); + jest.runOnlyPendingTimers(); + }); + + it('renders', () => { + expect(findIssuableList().props()).toMatchObject({ + namespace: defaultProvide.fullPath, + recentSearchesStorageKey: 'issues', + searchInputPlaceholder: IssuesListApp.i18n.searchPlaceholder, + sortOptions: getSortOptions(true, true), + initialSortBy: CREATED_DESC, + issuables: getIssuesQueryResponse.data.project.issues.nodes, + tabs: IssuableListTabs, + currentTab: IssuableStates.Opened, + tabCounts: { + opened: 1, + closed: 1, + all: 1, + }, + issuablesLoading: false, + isManualOrdering: false, + showBulkEditSidebar: false, + showPaginationControls: true, + useKeysetPagination: true, + hasPreviousPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasPreviousPage, + hasNextPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasNextPage, + urlParams: { + sort: urlSortParams[CREATED_DESC], + state: IssuableStates.Opened, + }, + }); + }); + }); + + describe('header action buttons', () => { + it('renders rss button', () => { + wrapper = mountComponent({ mountFn: mount }); + + expect(findGlButtonAt(0).props('icon')).toBe('rss'); + expect(findGlButtonAt(0).attributes()).toMatchObject({ + href: defaultProvide.rssPath, + 'aria-label': IssuesListApp.i18n.rssLabel, + }); + }); + + it('renders calendar button', () => { + wrapper = mountComponent({ mountFn: mount }); + + expect(findGlButtonAt(1).props('icon')).toBe('calendar'); + expect(findGlButtonAt(1).attributes()).toMatchObject({ + href: defaultProvide.calendarPath, + 'aria-label': IssuesListApp.i18n.calendarLabel, + }); + }); + + describe('csv import/export component', () => { + describe('when user is signed in', () => { + const search = '?search=refactor&sort=created_date&state=opened'; + + beforeEach(() => { + setWindowLocation(search); + + wrapper = mountComponent({ provide: { isSignedIn: true }, mountFn: mount }); + + jest.runOnlyPendingTimers(); + }); + + it('renders', () => { + expect(findCsvImportExportButtons().props()).toMatchObject({ + exportCsvPath: `${defaultProvide.exportCsvPath}${search}`, + issuableCount: 1, + }); + }); + }); + + describe('when user is not signed in', () => { + it('does not render', () => { + wrapper = mountComponent({ provide: { isSignedIn: false }, mountFn: mount }); + + expect(findCsvImportExportButtons().exists()).toBe(false); + }); + }); + + describe('when in a group context', () => { + it('does not render', () => { + wrapper = mountComponent({ provide: { isProject: false }, mountFn: mount }); + + expect(findCsvImportExportButtons().exists()).toBe(false); + }); + }); + }); + + describe('bulk edit button', () => { + it('renders when user has permissions', () => { + wrapper = mountComponent({ provide: { canBulkUpdate: true }, mountFn: mount }); + + expect(findGlButtonAt(2).text()).toBe('Edit issues'); + }); + + it('does not render when user does not have permissions', () => { + wrapper = mountComponent({ provide: { canBulkUpdate: false }, mountFn: mount }); + + expect(findGlButtons().filter((button) => button.text() === 'Edit issues')).toHaveLength(0); + }); + + it('emits "issuables:enableBulkEdit" event to legacy bulk edit class', async () => { + wrapper = mountComponent({ provide: { canBulkUpdate: true }, mountFn: mount }); + + jest.spyOn(eventHub, '$emit'); + + findGlButtonAt(2).vm.$emit('click'); + + await waitForPromises(); + + expect(eventHub.$emit).toHaveBeenCalledWith('issuables:enableBulkEdit'); + }); + }); + + describe('new issue button', () => { + it('renders when user has permissions', () => { + wrapper = mountComponent({ provide: { showNewIssueLink: true }, mountFn: mount }); + + expect(findGlButtonAt(2).text()).toBe('New issue'); + expect(findGlButtonAt(2).attributes('href')).toBe(defaultProvide.newIssuePath); + }); + + it('does not render when user does not have permissions', () => { + wrapper = mountComponent({ provide: { showNewIssueLink: false }, mountFn: mount }); + + expect(findGlButtons().filter((button) => button.text() === 'New issue')).toHaveLength(0); + }); + }); + + describe('new issue split dropdown', () => { + it('does not render in a project context', () => { + wrapper = mountComponent({ provide: { isProject: true }, mountFn: mount }); + + expect(findNewIssueDropdown().exists()).toBe(false); + }); + + it('renders in a group context', () => { + wrapper = mountComponent({ provide: { isProject: false }, mountFn: mount }); + + expect(findNewIssueDropdown().exists()).toBe(true); + }); + }); + }); + + describe('initial url params', () => { + describe('due_date', () => { + it('is set from the url params', () => { + setWindowLocation(`?${PARAM_DUE_DATE}=${DUE_DATE_OVERDUE}`); + + wrapper = mountComponent(); + + expect(findIssuableList().props('urlParams')).toMatchObject({ due_date: DUE_DATE_OVERDUE }); + }); + }); + + describe('search', () => { + it('is set from the url params', () => { + setWindowLocation(locationSearch); + + wrapper = mountComponent(); + + expect(findIssuableList().props('urlParams')).toMatchObject({ search: 'find issues' }); + }); + }); + + describe('sort', () => { + it.each(Object.keys(urlSortParams))('is set as %s from the url params', (sortKey) => { + setWindowLocation(`?sort=${urlSortParams[sortKey]}`); + + wrapper = mountComponent(); + + expect(findIssuableList().props()).toMatchObject({ + initialSortBy: sortKey, + urlParams: { + sort: urlSortParams[sortKey], + }, + }); + }); + + describe('when issue repositioning is disabled and the sort is manual', () => { + beforeEach(() => { + setWindowLocation(`?sort=${RELATIVE_POSITION}`); + wrapper = mountComponent({ provide: { isIssueRepositioningDisabled: true } }); + }); + + it('changes the sort to the default of created descending', () => { + expect(findIssuableList().props()).toMatchObject({ + initialSortBy: CREATED_DESC, + urlParams: { + sort: urlSortParams[CREATED_DESC], + }, + }); + }); + + it('shows an alert to tell the user that manual reordering is disabled', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: IssuesListApp.i18n.issueRepositioningMessage, + type: FLASH_TYPES.NOTICE, + }); + }); + }); + }); + + describe('state', () => { + it('is set from the url params', () => { + const initialState = IssuableStates.All; + + setWindowLocation(`?state=${initialState}`); + + wrapper = mountComponent(); + + expect(findIssuableList().props('currentTab')).toBe(initialState); + }); + }); + + describe('filter tokens', () => { + it('is set from the url params', () => { + setWindowLocation(locationSearch); + + wrapper = mountComponent(); + + expect(findIssuableList().props('initialFilterValue')).toEqual(filteredTokens); + }); + + describe('when anonymous searching is performed', () => { + beforeEach(() => { + setWindowLocation(locationSearch); + + wrapper = mountComponent({ + provide: { isAnonymousSearchDisabled: true, isSignedIn: false }, + }); + }); + + it('is not set from url params', () => { + expect(findIssuableList().props('initialFilterValue')).toEqual([]); + }); + + it('shows an alert to tell the user they must be signed in to search', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: IssuesListApp.i18n.anonymousSearchingMessage, + type: FLASH_TYPES.NOTICE, + }); + }); + }); + }); + }); + + describe('bulk edit', () => { + describe.each([true, false])( + 'when "issuables:toggleBulkEdit" event is received with payload `%s`', + (isBulkEdit) => { + beforeEach(() => { + wrapper = mountComponent(); + + eventHub.$emit('issuables:toggleBulkEdit', isBulkEdit); + }); + + it(`${isBulkEdit ? 'enables' : 'disables'} bulk edit`, () => { + expect(findIssuableList().props('showBulkEditSidebar')).toBe(isBulkEdit); + }); + }, + ); + }); + + describe('IssuableByEmail component', () => { + describe.each([true, false])(`when issue creation by email is enabled=%s`, (enabled) => { + it(`${enabled ? 'renders' : 'does not render'}`, () => { + wrapper = mountComponent({ provide: { initialEmail: enabled } }); + + expect(findIssuableByEmail().exists()).toBe(enabled); + }); + }); + }); + + describe('empty states', () => { + describe('when there are issues', () => { + describe('when search returns no results', () => { + beforeEach(() => { + setWindowLocation(`?search=no+results`); + + wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + description: IssuesListApp.i18n.noSearchResultsDescription, + title: IssuesListApp.i18n.noSearchResultsTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + }); + + describe('when "Open" tab has no issues', () => { + beforeEach(() => { + wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + description: IssuesListApp.i18n.noOpenIssuesDescription, + title: IssuesListApp.i18n.noOpenIssuesTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + }); + + describe('when "Closed" tab has no issues', () => { + beforeEach(() => { + setWindowLocation(`?state=${IssuableStates.Closed}`); + + wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + title: IssuesListApp.i18n.noClosedIssuesTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + }); + }); + + describe('when there are no issues', () => { + describe('when user is logged in', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { hasAnyIssues: false, isSignedIn: true }, + mountFn: mount, + }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + description: IssuesListApp.i18n.noIssuesSignedInDescription, + title: IssuesListApp.i18n.noIssuesSignedInTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + + it('shows "New issue" and import/export buttons', () => { + expect(findGlButton().text()).toBe(IssuesListApp.i18n.newIssueLabel); + expect(findGlButton().attributes('href')).toBe(defaultProvide.newIssuePath); + expect(findCsvImportExportButtons().props()).toMatchObject({ + exportCsvPath: defaultProvide.exportCsvPath, + issuableCount: 0, + }); + }); + + it('shows Jira integration information', () => { + const paragraphs = wrapper.findAll('p'); + expect(paragraphs.at(1).text()).toContain(IssuesListApp.i18n.jiraIntegrationTitle); + expect(paragraphs.at(2).text()).toContain( + 'Enable the Jira integration to view your Jira issues in GitLab.', + ); + expect(paragraphs.at(3).text()).toContain( + IssuesListApp.i18n.jiraIntegrationSecondaryMessage, + ); + expect(findGlLink().text()).toBe('Enable the Jira integration'); + expect(findGlLink().attributes('href')).toBe(defaultProvide.jiraIntegrationPath); + }); + }); + + describe('when user is logged out', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { hasAnyIssues: false, isSignedIn: false }, + }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + description: IssuesListApp.i18n.noIssuesSignedOutDescription, + title: IssuesListApp.i18n.noIssuesSignedOutTitle, + svgPath: defaultProvide.emptyStateSvgPath, + primaryButtonText: IssuesListApp.i18n.noIssuesSignedOutButtonText, + primaryButtonLink: defaultProvide.signInPath, + }); + }); + }); + }); + }); + + describe('tokens', () => { + const mockCurrentUser = { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: 'avatar/url', + }; + + describe('when user is signed out', () => { + beforeEach(() => { + wrapper = mountComponent({ provide: { isSignedIn: false } }); + }); + + it('does not render My-Reaction or Confidential tokens', () => { + expect(findIssuableList().props('searchTokens')).not.toMatchObject([ + { type: TOKEN_TYPE_AUTHOR, preloadedAuthors: [mockCurrentUser] }, + { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors: [mockCurrentUser] }, + { type: TOKEN_TYPE_MY_REACTION }, + { type: TOKEN_TYPE_CONFIDENTIAL }, + ]); + }); + }); + + describe('when all tokens are available', () => { + const originalGon = window.gon; + + beforeEach(() => { + window.gon = { + ...originalGon, + current_user_id: mockCurrentUser.id, + current_user_fullname: mockCurrentUser.name, + current_username: mockCurrentUser.username, + current_user_avatar_url: mockCurrentUser.avatar_url, + }; + + wrapper = mountComponent({ provide: { isSignedIn: true } }); + }); + + afterEach(() => { + window.gon = originalGon; + }); + + it('renders all tokens alphabetically', () => { + const preloadedAuthors = [ + { ...mockCurrentUser, id: convertToGraphQLId('User', mockCurrentUser.id) }, + ]; + + expect(findIssuableList().props('searchTokens')).toMatchObject([ + { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors }, + { type: TOKEN_TYPE_AUTHOR, preloadedAuthors }, + { type: TOKEN_TYPE_CONFIDENTIAL }, + { type: TOKEN_TYPE_LABEL }, + { type: TOKEN_TYPE_MILESTONE }, + { type: TOKEN_TYPE_MY_REACTION }, + { type: TOKEN_TYPE_RELEASE }, + { type: TOKEN_TYPE_TYPE }, + ]); + }); + }); + }); + + describe('errors', () => { + describe.each` + error | mountOption | message + ${'fetching issues'} | ${'issuesQueryResponse'} | ${IssuesListApp.i18n.errorFetchingIssues} + ${'fetching issue counts'} | ${'issuesCountsQueryResponse'} | ${IssuesListApp.i18n.errorFetchingCounts} + `('when there is an error $error', ({ mountOption, message }) => { + beforeEach(() => { + wrapper = mountComponent({ + [mountOption]: jest.fn().mockRejectedValue(new Error('ERROR')), + }); + jest.runOnlyPendingTimers(); + }); + + it('shows an error message', () => { + expect(findIssuableList().props('error')).toBe(message); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Network error: ERROR')); + }); + }); + + it('clears error message when "dismiss-alert" event is emitted from IssuableList', () => { + wrapper = mountComponent({ issuesQueryResponse: jest.fn().mockRejectedValue(new Error()) }); + + findIssuableList().vm.$emit('dismiss-alert'); + + expect(findIssuableList().props('error')).toBeNull(); + }); + }); + + describe('events', () => { + describe('when "click-tab" event is emitted by IssuableList', () => { + beforeEach(() => { + wrapper = mountComponent(); + + findIssuableList().vm.$emit('click-tab', IssuableStates.Closed); + }); + + it('updates to the new tab', () => { + expect(findIssuableList().props('currentTab')).toBe(IssuableStates.Closed); + }); + }); + + describe.each(['next-page', 'previous-page'])( + 'when "%s" event is emitted by IssuableList', + (event) => { + beforeEach(() => { + wrapper = mountComponent(); + + findIssuableList().vm.$emit(event); + }); + + it('scrolls to the top', () => { + expect(scrollUp).toHaveBeenCalled(); + }); + }, + ); + + describe('when "reorder" event is emitted by IssuableList', () => { + const issueOne = { + ...defaultQueryResponse.data.project.issues.nodes[0], + id: 'gid://gitlab/Issue/1', + iid: '101', + reference: 'group/project#1', + webPath: '/group/project/-/issues/1', + }; + const issueTwo = { + ...defaultQueryResponse.data.project.issues.nodes[0], + id: 'gid://gitlab/Issue/2', + iid: '102', + reference: 'group/project#2', + webPath: '/group/project/-/issues/2', + }; + const issueThree = { + ...defaultQueryResponse.data.project.issues.nodes[0], + id: 'gid://gitlab/Issue/3', + iid: '103', + reference: 'group/project#3', + webPath: '/group/project/-/issues/3', + }; + const issueFour = { + ...defaultQueryResponse.data.project.issues.nodes[0], + id: 'gid://gitlab/Issue/4', + iid: '104', + reference: 'group/project#4', + webPath: '/group/project/-/issues/4', + }; + const response = (isProject = true) => ({ + data: { + [isProject ? 'project' : 'group']: { + id: '1', + issues: { + ...defaultQueryResponse.data.project.issues, + nodes: [issueOne, issueTwo, issueThree, issueFour], + }, + }, + }, + }); + + describe('when successful', () => { + describe.each([true, false])('when isProject=%s', (isProject) => { + describe.each` + description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId + ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id} + ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id} + ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id} + ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null} + `( + 'when moving issue $description', + ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { isProject }, + issuesQueryResponse: jest.fn().mockResolvedValue(response(isProject)), + }); + jest.runOnlyPendingTimers(); + }); + + it('makes API call to reorder the issue', async () => { + findIssuableList().vm.$emit('reorder', { oldIndex, newIndex }); + + await waitForPromises(); + + expect(axiosMock.history.put[0]).toMatchObject({ + url: joinPaths(issueToMove.webPath, 'reorder'), + data: JSON.stringify({ + move_before_id: getIdFromGraphQLId(moveBeforeId), + move_after_id: getIdFromGraphQLId(moveAfterId), + group_full_path: isProject ? undefined : defaultProvide.fullPath, + }), + }); + }); + }, + ); + }); + }); + + describe('when unsuccessful', () => { + beforeEach(() => { + wrapper = mountComponent({ + issuesQueryResponse: jest.fn().mockResolvedValue(response()), + }); + jest.runOnlyPendingTimers(); + }); + + it('displays an error message', async () => { + axiosMock.onPut(joinPaths(issueOne.webPath, 'reorder')).reply(500); + + findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 }); + + await waitForPromises(); + + expect(findIssuableList().props('error')).toBe(IssuesListApp.i18n.reorderError); + expect(Sentry.captureException).toHaveBeenCalledWith( + new Error('Request failed with status code 500'), + ); + }); + }); + }); + + describe('when "sort" event is emitted by IssuableList', () => { + it.each(Object.keys(urlSortParams))( + 'updates to the new sort when payload is `%s`', + async (sortKey) => { + wrapper = mountComponent(); + + findIssuableList().vm.$emit('sort', sortKey); + + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(findIssuableList().props('urlParams')).toMatchObject({ + sort: urlSortParams[sortKey], + }); + }, + ); + + describe('when issue repositioning is disabled', () => { + const initialSort = CREATED_DESC; + + beforeEach(() => { + setWindowLocation(`?sort=${initialSort}`); + wrapper = mountComponent({ provide: { isIssueRepositioningDisabled: true } }); + + findIssuableList().vm.$emit('sort', RELATIVE_POSITION_ASC); + }); + + it('does not update the sort to manual', () => { + expect(findIssuableList().props('urlParams')).toMatchObject({ + sort: urlSortParams[initialSort], + }); + }); + + it('shows an alert to tell the user that manual reordering is disabled', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: IssuesListApp.i18n.issueRepositioningMessage, + type: FLASH_TYPES.NOTICE, + }); + }); + }); + }); + + describe('when "update-legacy-bulk-edit" event is emitted by IssuableList', () => { + beforeEach(() => { + wrapper = mountComponent(); + jest.spyOn(eventHub, '$emit'); + + findIssuableList().vm.$emit('update-legacy-bulk-edit'); + }); + + it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit'); + }); + }); + + describe('when "filter" event is emitted by IssuableList', () => { + it('updates IssuableList with url params', async () => { + wrapper = mountComponent(); + + findIssuableList().vm.$emit('filter', filteredTokens); + await nextTick(); + + expect(findIssuableList().props('urlParams')).toMatchObject(urlParams); + }); + + describe('when anonymous searching is performed', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { isAnonymousSearchDisabled: true, isSignedIn: false }, + }); + + findIssuableList().vm.$emit('filter', filteredTokens); + }); + + it('does not update IssuableList with url params ', async () => { + const defaultParams = { sort: 'created_date', state: 'opened' }; + + expect(findIssuableList().props('urlParams')).toEqual(defaultParams); + }); + + it('shows an alert to tell the user they must be signed in to search', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: IssuesListApp.i18n.anonymousSearchingMessage, + type: FLASH_TYPES.NOTICE, + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js b/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js new file mode 100644 index 00000000000..d6d6bb14e9d --- /dev/null +++ b/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js @@ -0,0 +1,117 @@ +import { GlAlert, GlLabel } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import JiraIssuesImportStatus from '~/issues/list/components/jira_issues_import_status_app.vue'; + +describe('JiraIssuesImportStatus', () => { + const issuesPath = 'gitlab-org/gitlab-test/-/issues'; + const label = { + color: '#333', + title: 'jira-import::MTG-3', + }; + let wrapper; + + const findAlert = () => wrapper.find(GlAlert); + + const findAlertLabel = () => wrapper.find(GlAlert).find(GlLabel); + + const mountComponent = ({ + shouldShowFinishedAlert = false, + shouldShowInProgressAlert = false, + } = {}) => + shallowMount(JiraIssuesImportStatus, { + propsData: { + canEdit: true, + isJiraConfigured: true, + issuesPath, + projectPath: 'gitlab-org/gitlab-test', + }, + data() { + return { + jiraImport: { + importedIssuesCount: 1, + label, + shouldShowFinishedAlert, + shouldShowInProgressAlert, + }, + }; + }, + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when Jira import is neither in progress nor finished', () => { + beforeEach(() => { + wrapper = mountComponent(); + }); + + it('does not show an alert', () => { + expect(wrapper.find(GlAlert).exists()).toBe(false); + }); + }); + + describe('when Jira import is in progress', () => { + it('shows an alert that tells the user a Jira import is in progress', () => { + wrapper = mountComponent({ + shouldShowInProgressAlert: true, + }); + + expect(findAlert().text()).toBe( + 'Import in progress. Refresh page to see newly added issues.', + ); + }); + }); + + describe('when Jira import has finished', () => { + beforeEach(() => { + wrapper = mountComponent({ + shouldShowFinishedAlert: true, + }); + }); + + describe('shows an alert', () => { + it('tells the user the Jira import has finished', () => { + expect(findAlert().text()).toBe('1 issue successfully imported with the label'); + }); + + it('contains the label title associated with the Jira import', () => { + const alertLabelTitle = findAlertLabel().props('title'); + + expect(alertLabelTitle).toBe(label.title); + }); + + it('contains the correct label color', () => { + const alertLabelTitle = findAlertLabel().props('backgroundColor'); + + expect(alertLabelTitle).toBe(label.color); + }); + + it('contains a link within the label', () => { + const alertLabelTarget = findAlertLabel().props('target'); + + expect(alertLabelTarget).toBe( + `${issuesPath}?label_name[]=${encodeURIComponent(label.title)}`, + ); + }); + }); + }); + + describe('alert message', () => { + it('is hidden when dismissed', () => { + wrapper = mountComponent({ + shouldShowInProgressAlert: true, + }); + + expect(wrapper.find(GlAlert).exists()).toBe(true); + + findAlert().vm.$emit('dismiss'); + + return Vue.nextTick(() => { + expect(wrapper.find(GlAlert).exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/issues/list/components/new_issue_dropdown_spec.js b/spec/frontend/issues/list/components/new_issue_dropdown_spec.js new file mode 100644 index 00000000000..0c52e66ff14 --- /dev/null +++ b/spec/frontend/issues/list/components/new_issue_dropdown_spec.js @@ -0,0 +1,131 @@ +import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import NewIssueDropdown from '~/issues/list/components/new_issue_dropdown.vue'; +import searchProjectsQuery from '~/issues/list/queries/search_projects.query.graphql'; +import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; +import { + emptySearchProjectsQueryResponse, + project1, + project3, + searchProjectsQueryResponse, +} from '../mock_data'; + +describe('NewIssueDropdown component', () => { + let wrapper; + + const localVue = createLocalVue(); + localVue.use(VueApollo); + + const mountComponent = ({ + search = '', + queryResponse = searchProjectsQueryResponse, + mountFn = shallowMount, + } = {}) => { + const requestHandlers = [[searchProjectsQuery, jest.fn().mockResolvedValue(queryResponse)]]; + const apolloProvider = createMockApollo(requestHandlers); + + return mountFn(NewIssueDropdown, { + localVue, + apolloProvider, + provide: { + fullPath: 'mushroom-kingdom', + }, + data() { + return { search }; + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findInput = () => wrapper.findComponent(GlSearchBoxByType); + const showDropdown = async () => { + findDropdown().vm.$emit('shown'); + await wrapper.vm.$apollo.queries.projects.refetch(); + jest.runOnlyPendingTimers(); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a split dropdown', () => { + wrapper = mountComponent(); + + expect(findDropdown().props('split')).toBe(true); + }); + + it('renders a label for the dropdown toggle button', () => { + wrapper = mountComponent(); + + expect(findDropdown().attributes('toggle-text')).toBe(NewIssueDropdown.i18n.toggleButtonLabel); + }); + + it('focuses on input when dropdown is shown', async () => { + wrapper = mountComponent({ mountFn: mount }); + + const inputSpy = jest.spyOn(findInput().vm, 'focusInput'); + + await showDropdown(); + + expect(inputSpy).toHaveBeenCalledTimes(1); + }); + + it('renders projects with issues enabled', async () => { + wrapper = mountComponent({ mountFn: mount }); + + await showDropdown(); + + const listItems = wrapper.findAll('li'); + + expect(listItems.at(0).text()).toBe(project1.nameWithNamespace); + expect(listItems.at(1).text()).toBe(project3.nameWithNamespace); + }); + + it('renders `No matches found` when there are no matches', async () => { + wrapper = mountComponent({ + search: 'no matches', + queryResponse: emptySearchProjectsQueryResponse, + mountFn: mount, + }); + + await showDropdown(); + + expect(wrapper.find('li').text()).toBe(NewIssueDropdown.i18n.noMatchesFound); + }); + + describe('when no project is selected', () => { + beforeEach(() => { + wrapper = mountComponent(); + }); + + it('dropdown button is not a link', () => { + expect(findDropdown().attributes('split-href')).toBeUndefined(); + }); + + it('displays default text on the dropdown button', () => { + expect(findDropdown().props('text')).toBe(NewIssueDropdown.i18n.defaultDropdownText); + }); + }); + + describe('when a project is selected', () => { + beforeEach(async () => { + wrapper = mountComponent({ mountFn: mount }); + + await showDropdown(); + + wrapper.findComponent(GlDropdownItem).vm.$emit('click', project1); + }); + + it('dropdown button is a link', () => { + const href = joinPaths(project1.webUrl, DASH_SCOPE, 'issues/new'); + + expect(findDropdown().attributes('split-href')).toBe(href); + }); + + it('displays project name on the dropdown button', () => { + expect(findDropdown().props('text')).toBe(`New issue in ${project1.name}`); + }); + }); +}); diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js new file mode 100644 index 00000000000..948699876ce --- /dev/null +++ b/spec/frontend/issues/list/mock_data.js @@ -0,0 +1,310 @@ +import { + OPERATOR_IS, + OPERATOR_IS_NOT, +} from '~/vue_shared/components/filtered_search_bar/constants'; + +export const getIssuesQueryResponse = { + data: { + project: { + id: '1', + issues: { + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'startcursor', + endCursor: 'endcursor', + }, + nodes: [ + { + id: 'gid://gitlab/Issue/123456', + iid: '789', + closedAt: null, + confidential: false, + createdAt: '2021-05-22T04:08:01Z', + downvotes: 2, + dueDate: '2021-05-29', + hidden: false, + humanTimeEstimate: null, + mergeRequestsCount: false, + moved: false, + title: 'Issue title', + updatedAt: '2021-05-22T04:08:01Z', + upvotes: 3, + userDiscussionsCount: 4, + webPath: 'project/-/issues/789', + webUrl: 'project/-/issues/789', + assignees: { + nodes: [ + { + id: 'gid://gitlab/User/234', + avatarUrl: 'avatar/url', + name: 'Marge Simpson', + username: 'msimpson', + webUrl: 'url/msimpson', + }, + ], + }, + author: { + id: 'gid://gitlab/User/456', + avatarUrl: 'avatar/url', + name: 'Homer Simpson', + username: 'hsimpson', + webUrl: 'url/hsimpson', + }, + labels: { + nodes: [ + { + id: 'gid://gitlab/ProjectLabel/456', + color: '#333', + title: 'Label title', + description: 'Label description', + }, + ], + }, + milestone: null, + taskCompletionStatus: { + completedCount: 1, + count: 2, + }, + }, + ], + }, + }, + }, +}; + +export const getIssuesCountsQueryResponse = { + data: { + project: { + id: '1', + openedIssues: { + count: 1, + }, + closedIssues: { + count: 1, + }, + allIssues: { + count: 1, + }, + }, + }, +}; + +export const locationSearch = [ + '?search=find+issues', + 'author_username=homer', + 'not[author_username]=marge', + 'assignee_username[]=bart', + 'assignee_username[]=lisa', + 'not[assignee_username][]=patty', + 'not[assignee_username][]=selma', + 'milestone_title=season+3', + 'milestone_title=season+4', + 'not[milestone_title]=season+20', + 'not[milestone_title]=season+30', + 'label_name[]=cartoon', + 'label_name[]=tv', + 'not[label_name][]=live action', + 'not[label_name][]=drama', + 'release_tag=v3', + 'release_tag=v4', + 'not[release_tag]=v20', + 'not[release_tag]=v30', + 'type[]=issue', + 'type[]=feature', + 'not[type][]=bug', + 'not[type][]=incident', + 'my_reaction_emoji=thumbsup', + 'not[my_reaction_emoji]=thumbsdown', + 'confidential=yes', + 'iteration_id=4', + 'iteration_id=12', + 'not[iteration_id]=20', + 'not[iteration_id]=42', + 'epic_id=12', + 'not[epic_id]=34', + 'weight=1', + 'not[weight]=3', +].join('&'); + +export const locationSearchWithSpecialValues = [ + 'assignee_id=123', + 'assignee_username=bart', + 'my_reaction_emoji=None', + 'iteration_id=Current', + 'label_name[]=None', + 'release_tag=None', + 'milestone_title=Upcoming', + 'epic_id=None', + 'weight=None', +].join('&'); + +export const filteredTokens = [ + { type: 'author_username', value: { data: 'homer', operator: OPERATOR_IS } }, + { type: 'author_username', value: { data: 'marge', operator: OPERATOR_IS_NOT } }, + { type: 'assignee_username', value: { data: 'bart', operator: OPERATOR_IS } }, + { type: 'assignee_username', value: { data: 'lisa', operator: OPERATOR_IS } }, + { type: 'assignee_username', value: { data: 'patty', operator: OPERATOR_IS_NOT } }, + { type: 'assignee_username', value: { data: 'selma', operator: OPERATOR_IS_NOT } }, + { type: 'milestone', value: { data: 'season 3', operator: OPERATOR_IS } }, + { type: 'milestone', value: { data: 'season 4', operator: OPERATOR_IS } }, + { type: 'milestone', value: { data: 'season 20', operator: OPERATOR_IS_NOT } }, + { type: 'milestone', value: { data: 'season 30', operator: OPERATOR_IS_NOT } }, + { type: 'labels', value: { data: 'cartoon', operator: OPERATOR_IS } }, + { type: 'labels', value: { data: 'tv', operator: OPERATOR_IS } }, + { type: 'labels', value: { data: 'live action', operator: OPERATOR_IS_NOT } }, + { type: 'labels', value: { data: 'drama', operator: OPERATOR_IS_NOT } }, + { type: 'release', value: { data: 'v3', operator: OPERATOR_IS } }, + { type: 'release', value: { data: 'v4', operator: OPERATOR_IS } }, + { type: 'release', value: { data: 'v20', operator: OPERATOR_IS_NOT } }, + { type: 'release', value: { data: 'v30', operator: OPERATOR_IS_NOT } }, + { type: 'type', value: { data: 'issue', operator: OPERATOR_IS } }, + { type: 'type', value: { data: 'feature', operator: OPERATOR_IS } }, + { type: 'type', value: { data: 'bug', operator: OPERATOR_IS_NOT } }, + { type: 'type', value: { data: 'incident', operator: OPERATOR_IS_NOT } }, + { type: 'my_reaction_emoji', value: { data: 'thumbsup', operator: OPERATOR_IS } }, + { type: 'my_reaction_emoji', value: { data: 'thumbsdown', operator: OPERATOR_IS_NOT } }, + { type: 'confidential', value: { data: 'yes', operator: OPERATOR_IS } }, + { type: 'iteration', value: { data: '4', operator: OPERATOR_IS } }, + { type: 'iteration', value: { data: '12', operator: OPERATOR_IS } }, + { type: 'iteration', value: { data: '20', operator: OPERATOR_IS_NOT } }, + { type: 'iteration', value: { data: '42', operator: OPERATOR_IS_NOT } }, + { type: 'epic_id', value: { data: '12', operator: OPERATOR_IS } }, + { type: 'epic_id', value: { data: '34', operator: OPERATOR_IS_NOT } }, + { type: 'weight', value: { data: '1', operator: OPERATOR_IS } }, + { type: 'weight', value: { data: '3', operator: OPERATOR_IS_NOT } }, + { type: 'filtered-search-term', value: { data: 'find' } }, + { type: 'filtered-search-term', value: { data: 'issues' } }, +]; + +export const filteredTokensWithSpecialValues = [ + { type: 'assignee_username', value: { data: '123', operator: OPERATOR_IS } }, + { type: 'assignee_username', value: { data: 'bart', operator: OPERATOR_IS } }, + { type: 'my_reaction_emoji', value: { data: 'None', operator: OPERATOR_IS } }, + { type: 'iteration', value: { data: 'Current', operator: OPERATOR_IS } }, + { type: 'labels', value: { data: 'None', operator: OPERATOR_IS } }, + { type: 'release', value: { data: 'None', operator: OPERATOR_IS } }, + { type: 'milestone', value: { data: 'Upcoming', operator: OPERATOR_IS } }, + { type: 'epic_id', value: { data: 'None', operator: OPERATOR_IS } }, + { type: 'weight', value: { data: 'None', operator: OPERATOR_IS } }, +]; + +export const apiParams = { + authorUsername: 'homer', + assigneeUsernames: ['bart', 'lisa'], + milestoneTitle: ['season 3', 'season 4'], + labelName: ['cartoon', 'tv'], + releaseTag: ['v3', 'v4'], + types: ['ISSUE', 'FEATURE'], + myReactionEmoji: 'thumbsup', + confidential: true, + iterationId: ['4', '12'], + epicId: '12', + weight: '1', + not: { + authorUsername: 'marge', + assigneeUsernames: ['patty', 'selma'], + milestoneTitle: ['season 20', 'season 30'], + labelName: ['live action', 'drama'], + releaseTag: ['v20', 'v30'], + types: ['BUG', 'INCIDENT'], + myReactionEmoji: 'thumbsdown', + iterationId: ['20', '42'], + epicId: '34', + weight: '3', + }, +}; + +export const apiParamsWithSpecialValues = { + assigneeId: '123', + assigneeUsernames: 'bart', + labelName: 'None', + myReactionEmoji: 'None', + releaseTagWildcardId: 'NONE', + iterationWildcardId: 'CURRENT', + milestoneWildcardId: 'UPCOMING', + epicId: 'None', + weight: 'None', +}; + +export const urlParams = { + author_username: 'homer', + 'not[author_username]': 'marge', + 'assignee_username[]': ['bart', 'lisa'], + 'not[assignee_username][]': ['patty', 'selma'], + milestone_title: ['season 3', 'season 4'], + 'not[milestone_title]': ['season 20', 'season 30'], + 'label_name[]': ['cartoon', 'tv'], + 'not[label_name][]': ['live action', 'drama'], + release_tag: ['v3', 'v4'], + 'not[release_tag]': ['v20', 'v30'], + 'type[]': ['issue', 'feature'], + 'not[type][]': ['bug', 'incident'], + my_reaction_emoji: 'thumbsup', + 'not[my_reaction_emoji]': 'thumbsdown', + confidential: 'yes', + iteration_id: ['4', '12'], + 'not[iteration_id]': ['20', '42'], + epic_id: '12', + 'not[epic_id]': '34', + weight: '1', + 'not[weight]': '3', +}; + +export const urlParamsWithSpecialValues = { + assignee_id: '123', + 'assignee_username[]': 'bart', + 'label_name[]': 'None', + release_tag: 'None', + my_reaction_emoji: 'None', + iteration_id: 'Current', + milestone_title: 'Upcoming', + epic_id: 'None', + weight: 'None', +}; + +export const project1 = { + id: 'gid://gitlab/Group/26', + issuesEnabled: true, + name: 'Super Mario Project', + nameWithNamespace: 'Mushroom Kingdom / Super Mario Project', + webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/super-mario-project', +}; + +export const project2 = { + id: 'gid://gitlab/Group/59', + issuesEnabled: false, + name: 'Mario Kart Project', + nameWithNamespace: 'Mushroom Kingdom / Mario Kart Project', + webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/mario-kart-project', +}; + +export const project3 = { + id: 'gid://gitlab/Group/103', + issuesEnabled: true, + name: 'Mario Party Project', + nameWithNamespace: 'Mushroom Kingdom / Mario Party Project', + webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/mario-party-project', +}; + +export const searchProjectsQueryResponse = { + data: { + group: { + id: '1', + projects: { + nodes: [project1, project2, project3], + }, + }, + }, +}; + +export const emptySearchProjectsQueryResponse = { + data: { + group: { + id: '1', + projects: { + nodes: [], + }, + }, + }, +}; diff --git a/spec/frontend/issues/list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js new file mode 100644 index 00000000000..0e4979fd7b4 --- /dev/null +++ b/spec/frontend/issues/list/utils_spec.js @@ -0,0 +1,127 @@ +import { + apiParams, + apiParamsWithSpecialValues, + filteredTokens, + filteredTokensWithSpecialValues, + locationSearch, + locationSearchWithSpecialValues, + urlParams, + urlParamsWithSpecialValues, +} from 'jest/issues/list/mock_data'; +import { + defaultPageSizeParams, + DUE_DATE_VALUES, + largePageSizeParams, + RELATIVE_POSITION_ASC, + urlSortParams, +} from '~/issues/list/constants'; +import { + convertToApiParams, + convertToSearchQuery, + convertToUrlParams, + getDueDateValue, + getFilterTokens, + getInitialPageParams, + getSortKey, + getSortOptions, +} from '~/issues/list/utils'; + +describe('getInitialPageParams', () => { + it.each(Object.keys(urlSortParams))( + 'returns the correct page params for sort key %s', + (sortKey) => { + const expectedPageParams = + sortKey === RELATIVE_POSITION_ASC ? largePageSizeParams : defaultPageSizeParams; + + expect(getInitialPageParams(sortKey)).toBe(expectedPageParams); + }, + ); +}); + +describe('getSortKey', () => { + it.each(Object.keys(urlSortParams))('returns %s given the correct inputs', (sortKey) => { + const sort = urlSortParams[sortKey]; + expect(getSortKey(sort)).toBe(sortKey); + }); +}); + +describe('getDueDateValue', () => { + it.each(DUE_DATE_VALUES)('returns the argument when it is `%s`', (value) => { + expect(getDueDateValue(value)).toBe(value); + }); + + it('returns undefined when the argument is invalid', () => { + expect(getDueDateValue('invalid value')).toBeUndefined(); + }); +}); + +describe('getSortOptions', () => { + describe.each` + hasIssueWeightsFeature | hasBlockedIssuesFeature | length | containsWeight | containsBlocking + ${false} | ${false} | ${9} | ${false} | ${false} + ${true} | ${false} | ${10} | ${true} | ${false} + ${false} | ${true} | ${10} | ${false} | ${true} + ${true} | ${true} | ${11} | ${true} | ${true} + `( + 'when hasIssueWeightsFeature=$hasIssueWeightsFeature and hasBlockedIssuesFeature=$hasBlockedIssuesFeature', + ({ + hasIssueWeightsFeature, + hasBlockedIssuesFeature, + length, + containsWeight, + containsBlocking, + }) => { + const sortOptions = getSortOptions(hasIssueWeightsFeature, hasBlockedIssuesFeature); + + it('returns the correct length of sort options', () => { + expect(sortOptions).toHaveLength(length); + }); + + it(`${containsWeight ? 'contains' : 'does not contain'} weight option`, () => { + expect(sortOptions.some((option) => option.title === 'Weight')).toBe(containsWeight); + }); + + it(`${containsBlocking ? 'contains' : 'does not contain'} blocking option`, () => { + expect(sortOptions.some((option) => option.title === 'Blocking')).toBe(containsBlocking); + }); + }, + ); +}); + +describe('getFilterTokens', () => { + it('returns filtered tokens given "window.location.search"', () => { + expect(getFilterTokens(locationSearch)).toEqual(filteredTokens); + }); + + it('returns filtered tokens given "window.location.search" with special values', () => { + expect(getFilterTokens(locationSearchWithSpecialValues)).toEqual( + filteredTokensWithSpecialValues, + ); + }); +}); + +describe('convertToApiParams', () => { + it('returns api params given filtered tokens', () => { + expect(convertToApiParams(filteredTokens)).toEqual(apiParams); + }); + + it('returns api params given filtered tokens with special values', () => { + expect(convertToApiParams(filteredTokensWithSpecialValues)).toEqual(apiParamsWithSpecialValues); + }); +}); + +describe('convertToUrlParams', () => { + it('returns url params given filtered tokens', () => { + expect(convertToUrlParams(filteredTokens)).toEqual(urlParams); + }); + + it('returns url params given filtered tokens with special values', () => { + expect(convertToUrlParams(filteredTokensWithSpecialValues)).toEqual(urlParamsWithSpecialValues); + }); +}); + +describe('convertToSearchQuery', () => { + it('returns search string given filtered tokens', () => { + expect(convertToSearchQuery(filteredTokens)).toBe('find issues'); + }); +}); diff --git a/spec/frontend/issues/new/components/title_suggestions_spec.js b/spec/frontend/issues/new/components/title_suggestions_spec.js index 984d0c9d25b..f6b93cc5a62 100644 --- a/spec/frontend/issues/new/components/title_suggestions_spec.js +++ b/spec/frontend/issues/new/components/title_suggestions_spec.js @@ -38,6 +38,8 @@ describe('Issue title suggestions component', () => { }); it('renders component', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); return wrapper.vm.$nextTick(() => { @@ -47,6 +49,8 @@ describe('Issue title suggestions component', () => { it('does not render with empty search', () => { wrapper.setProps({ search: '' }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); return wrapper.vm.$nextTick(() => { @@ -55,6 +59,8 @@ describe('Issue title suggestions component', () => { }); it('does not render when loading', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ ...data, loading: 1, @@ -66,6 +72,8 @@ describe('Issue title suggestions component', () => { }); it('does not render with empty issues data', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ issues: [] }); return wrapper.vm.$nextTick(() => { @@ -74,6 +82,8 @@ describe('Issue title suggestions component', () => { }); it('renders list of issues', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); return wrapper.vm.$nextTick(() => { @@ -82,6 +92,8 @@ describe('Issue title suggestions component', () => { }); it('adds margin class to first item', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); return wrapper.vm.$nextTick(() => { @@ -90,6 +102,8 @@ describe('Issue title suggestions component', () => { }); it('does not add margin class to last item', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); return wrapper.vm.$nextTick(() => { diff --git a/spec/frontend/issues/show/components/fields/type_spec.js b/spec/frontend/issues/show/components/fields/type_spec.js index 3ece10e70db..7f7b16583e6 100644 --- a/spec/frontend/issues/show/components/fields/type_spec.js +++ b/spec/frontend/issues/show/components/fields/type_spec.js @@ -4,7 +4,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import IssueTypeField, { i18n } from '~/issues/show/components/fields/type.vue'; -import { IssuableTypes } from '~/issues/show/constants'; +import { issuableTypes } from '~/issues/show/constants'; import { getIssueStateQueryResponse, updateIssueStateQueryResponse, @@ -69,8 +69,8 @@ describe('Issue type field component', () => { it.each` at | text | icon - ${0} | ${IssuableTypes[0].text} | ${IssuableTypes[0].icon} - ${1} | ${IssuableTypes[1].text} | ${IssuableTypes[1].icon} + ${0} | ${issuableTypes[0].text} | ${issuableTypes[0].icon} + ${1} | ${issuableTypes[1].text} | ${issuableTypes[1].icon} `(`renders the issue type $text with an icon in the dropdown`, ({ at, text, icon }) => { expect(findTypeFromDropDownItemIconAt(at).attributes('name')).toBe(icon); expect(findTypeFromDropDownItemAt(at).text()).toBe(text); @@ -81,20 +81,20 @@ describe('Issue type field component', () => { }); it('renders a form select with the `issue_type` value', () => { - expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + expect(findTypeFromDropDown().attributes('value')).toBe(issuableTypes.issue); }); describe('with Apollo cache mock', () => { it('renders the selected issueType', async () => { mockIssueStateData.mockResolvedValue(getIssueStateQueryResponse); await waitForPromises(); - expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + expect(findTypeFromDropDown().attributes('value')).toBe(issuableTypes.issue); }); it('updates the `issue_type` in the apollo cache when the value is changed', async () => { - findTypeFromDropDownItems().at(1).vm.$emit('click', IssuableTypes.incident); + findTypeFromDropDownItems().at(1).vm.$emit('click', issuableTypes.incident); await wrapper.vm.$nextTick(); - expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.incident); + expect(findTypeFromDropDown().attributes('value')).toBe(issuableTypes.incident); }); describe('when user is a guest', () => { @@ -104,7 +104,7 @@ describe('Issue type field component', () => { expect(findTypeFromDropDownItemAt(0).isVisible()).toBe(true); expect(findTypeFromDropDownItemAt(1).isVisible()).toBe(false); - expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + expect(findTypeFromDropDown().attributes('value')).toBe(issuableTypes.issue); }); it('and incident is selected, includes incident in the dropdown', async () => { @@ -113,7 +113,7 @@ describe('Issue type field component', () => { expect(findTypeFromDropDownItemAt(0).isVisible()).toBe(true); expect(findTypeFromDropDownItemAt(1).isVisible()).toBe(true); - expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.incident); + expect(findTypeFromDropDown().attributes('value')).toBe(issuableTypes.incident); }); }); }); diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js index 2a16c699c4d..d09bf6faa13 100644 --- a/spec/frontend/issues/show/components/header_actions_spec.js +++ b/spec/frontend/issues/show/components/header_actions_spec.js @@ -4,11 +4,10 @@ import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import { mockTracking } from 'helpers/tracking_helper'; import createFlash, { FLASH_TYPES } from '~/flash'; -import { IssuableType } from '~/vue_shared/issuable/show/constants'; +import { IssuableStatus, IssueType } from '~/issues/constants'; import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue'; import HeaderActions from '~/issues/show/components/header_actions.vue'; -import { IssuableStatus } from '~/issues/constants'; -import { IssueStateEvent } from '~/issues/show/constants'; +import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants'; import promoteToEpicMutation from '~/issues/show/queries/promote_to_epic.mutation.graphql'; import * as urlUtility from '~/lib/utils/url_utility'; import eventHub from '~/notes/event_hub'; @@ -36,7 +35,7 @@ describe('HeaderActions component', () => { iid: '32', isIssueAuthor: true, issuePath: 'gitlab-org/gitlab-test/-/issues/1', - issueType: IssuableType.Issue, + issueType: IssueType.Issue, newIssuePath: 'gitlab-org/gitlab-test/-/issues/new', projectPath: 'gitlab-org/gitlab-test', reportAbusePath: @@ -112,14 +111,14 @@ describe('HeaderActions component', () => { describe.each` issueType - ${IssuableType.Issue} - ${IssuableType.Incident} + ${IssueType.Issue} + ${IssueType.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} + ${`when the ${issueType} is open`} | ${IssuableStatus.Open} | ${`Close ${issueType}`} | ${ISSUE_STATE_EVENT_CLOSE} + ${`when the ${issueType} is closed`} | ${IssuableStatus.Closed} | ${`Reopen ${issueType}`} | ${ISSUE_STATE_EVENT_REOPEN} `('$description', ({ issueState, buttonText, newIssueState }) => { beforeEach(() => { dispatchEventSpy = jest.spyOn(document, 'dispatchEvent'); @@ -306,7 +305,7 @@ describe('HeaderActions component', () => { input: { iid: defaultProps.iid, projectPath: defaultProps.projectPath, - stateEvent: IssueStateEvent.Close, + stateEvent: ISSUE_STATE_EVENT_CLOSE, }, }, }), @@ -345,7 +344,7 @@ describe('HeaderActions component', () => { input: { iid: defaultProps.iid.toString(), projectPath: defaultProps.projectPath, - stateEvent: IssueStateEvent.Close, + stateEvent: ISSUE_STATE_EVENT_CLOSE, }, }, }), diff --git a/spec/frontend/issues/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js b/spec/frontend/issues/show/components/sentry_error_stack_trace_spec.js index 5a51ae3cfe0..b38d2b60057 100644 --- a/spec/frontend/issues/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js +++ b/spec/frontend/issues/show/components/sentry_error_stack_trace_spec.js @@ -1,11 +1,9 @@ +import Vue from 'vue'; import { GlLoadingIcon } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import Stacktrace from '~/error_tracking/components/stacktrace.vue'; -import SentryErrorStackTrace from '~/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue'; - -const localVue = createLocalVue(); -localVue.use(Vuex); +import SentryErrorStackTrace from '~/issues/show/components/sentry_error_stack_trace.vue'; describe('Sentry Error Stack Trace', () => { let actions; @@ -13,13 +11,14 @@ describe('Sentry Error Stack Trace', () => { let store; let wrapper; + Vue.use(Vuex); + function mountComponent({ stubs = { stacktrace: Stacktrace, }, } = {}) { wrapper = shallowMount(SentryErrorStackTrace, { - localVue, stubs, store, propsData: { diff --git a/spec/frontend/issues/show/issue_spec.js b/spec/frontend/issues/show/issue_spec.js index 6d7a31a6c8c..68c2e3768c7 100644 --- a/spec/frontend/issues/show/issue_spec.js +++ b/spec/frontend/issues/show/issue_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; -import { initIssuableApp } from '~/issues/show/issue'; +import { initIssueApp } from '~/issues/show'; import * as parseData from '~/issues/show/utils/parse_data'; import axios from '~/lib/utils/axios_utils'; import createStore from '~/notes/stores'; @@ -17,7 +17,7 @@ const setupHTML = (initialData) => { }; describe('Issue show index', () => { - describe('initIssuableApp', () => { + describe('initIssueApp', () => { it('should initialize app with no potential XSS attack', async () => { const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {}); const parseDataSpy = jest.spyOn(parseData, 'parseIssuableData'); @@ -29,7 +29,7 @@ describe('Issue show index', () => { const initialDataEl = document.getElementById('js-issuable-app'); const issuableData = parseData.parseIssuableData(initialDataEl); - initIssuableApp(issuableData, createStore()); + initIssueApp(issuableData, createStore()); await waitForPromises(); |