diff options
Diffstat (limited to 'spec/frontend/issues/list/components')
4 files changed, 1199 insertions, 0 deletions
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}`); + }); + }); +}); |