summaryrefslogtreecommitdiff
path: root/spec/frontend/issues_list/components/issues_list_app_spec.js
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/issues_list/components/issues_list_app_spec.js')
-rw-r--r--spec/frontend/issues_list/components/issues_list_app_spec.js540
1 files changed, 494 insertions, 46 deletions
diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js
index 1053e8934c9..476804bda12 100644
--- a/spec/frontend/issues_list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues_list/components/issues_list_app_spec.js
@@ -1,19 +1,59 @@
-import { shallowMount } from '@vue/test-utils';
+import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui';
+import { mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
+import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
+import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
+
+import {
+ CREATED_DESC,
+ PAGE_SIZE,
+ PAGE_SIZE_MANUAL,
+ RELATIVE_POSITION_ASC,
+ sortOptions,
+ sortParams,
+} from '~/issues_list/constants';
+import eventHub from '~/issues_list/eventhub';
import axios from '~/lib/utils/axios_utils';
+import { setUrlParams } from '~/lib/utils/url_utility';
+
+jest.mock('~/flash');
describe('IssuesListApp component', () => {
+ const originalWindowLocation = window.location;
let axiosMock;
let wrapper;
- const fullPath = 'path/to/project';
- const endpoint = 'api/endpoint';
+ const defaultProvide = {
+ calendarPath: 'calendar/path',
+ canBulkUpdate: false,
+ emptyStateSvgPath: 'empty-state.svg',
+ endpoint: 'api/endpoint',
+ exportCsvPath: 'export/csv/path',
+ fullPath: 'path/to/project',
+ hasIssues: true,
+ isSignedIn: false,
+ issuesPath: 'path/to/issues',
+ jiraIntegrationPath: 'jira/integration/path',
+ newIssuePath: 'new/issue/path',
+ rssPath: 'rss/path',
+ showImportButton: true,
+ showNewIssueLink: true,
+ signInPath: 'sign/in/path',
+ };
+
const state = 'opened';
const xPage = 1;
const xTotal = 25;
+ const tabCounts = {
+ opened: xTotal,
+ closed: undefined,
+ all: undefined,
+ };
const fetchIssuesResponse = {
data: [],
headers: {
@@ -22,76 +62,484 @@ describe('IssuesListApp component', () => {
},
};
+ const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
+ 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 mountComponent = () =>
- shallowMount(IssuesListApp, {
+ const mountComponent = ({ provide = {}, mountFn = shallowMount } = {}) =>
+ mountFn(IssuesListApp, {
provide: {
- endpoint,
- fullPath,
+ ...defaultProvide,
+ ...provide,
},
});
- beforeEach(async () => {
+ beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
- axiosMock.onGet(endpoint).reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers);
- wrapper = mountComponent();
- await waitForPromises();
+ axiosMock
+ .onGet(defaultProvide.endpoint)
+ .reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers);
});
afterEach(() => {
+ window.location = originalWindowLocation;
axiosMock.reset();
wrapper.destroy();
});
- it('renders IssuableList', () => {
- expect(findIssuableList().props()).toMatchObject({
- namespace: fullPath,
- recentSearchesStorageKey: 'issues',
- searchInputPlaceholder: 'Search or filter results…',
- showPaginationControls: true,
- issuables: [],
- totalItems: xTotal,
- currentPage: xPage,
- previousPage: xPage - 1,
- nextPage: xPage + 1,
- urlParams: { page: xPage, state },
+ describe('IssuableList', () => {
+ beforeEach(async () => {
+ wrapper = mountComponent();
+ await waitForPromises();
+ });
+
+ it('renders', () => {
+ expect(findIssuableList().props()).toMatchObject({
+ namespace: defaultProvide.fullPath,
+ recentSearchesStorageKey: 'issues',
+ searchInputPlaceholder: 'Search or filter results…',
+ sortOptions,
+ initialSortBy: CREATED_DESC,
+ tabs: IssuableListTabs,
+ currentTab: IssuableStates.Opened,
+ tabCounts,
+ showPaginationControls: false,
+ issuables: [],
+ totalItems: xTotal,
+ currentPage: xPage,
+ previousPage: xPage - 1,
+ nextPage: xPage + 1,
+ urlParams: { page: xPage, state },
+ });
});
});
- describe('when "page-change" event is emitted', () => {
- const data = [{ id: 10, title: 'title', state }];
- const page = 2;
- const totalItems = 21;
+ describe('header action buttons', () => {
+ it('renders rss button', () => {
+ wrapper = mountComponent();
- beforeEach(async () => {
- axiosMock.onGet(endpoint).reply(200, data, {
- 'x-page': page,
- 'x-total': totalItems,
+ expect(findGlButtonAt(0).attributes()).toMatchObject({
+ href: defaultProvide.rssPath,
+ icon: 'rss',
+ 'aria-label': IssuesListApp.i18n.rssLabel,
+ });
+ });
+
+ it('renders calendar button', () => {
+ wrapper = mountComponent();
+
+ expect(findGlButtonAt(1).attributes()).toMatchObject({
+ href: defaultProvide.calendarPath,
+ icon: 'calendar',
+ 'aria-label': IssuesListApp.i18n.calendarLabel,
+ });
+ });
+
+ it('renders csv import/export component', async () => {
+ const search = '?page=1&search=refactor';
+
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { search },
});
- findIssuableList().vm.$emit('page-change', page);
+ wrapper = mountComponent();
await waitForPromises();
+
+ expect(findCsvImportExportButtons().props()).toMatchObject({
+ exportCsvPath: `${defaultProvide.exportCsvPath}${search}`,
+ issuableCount: xTotal,
+ });
});
- it('fetches issues with expected params', async () => {
- expect(axiosMock.history.get[1].params).toEqual({
- page,
- per_page: 20,
- state,
- with_labels_details: true,
+ describe('bulk edit button', () => {
+ it('renders when user has permissions', () => {
+ wrapper = mountComponent({ provide: { canBulkUpdate: true } });
+
+ expect(findGlButtonAt(2).text()).toBe('Edit issues');
+ });
+
+ it('does not render when user does not have permissions', () => {
+ wrapper = mountComponent({ provide: { canBulkUpdate: false } });
+
+ expect(findGlButtons().filter((button) => button.text() === 'Edit issues')).toHaveLength(0);
+ });
+
+ it('emits "issuables:enableBulkEdit" event to legacy bulk edit class', () => {
+ wrapper = mountComponent({ provide: { canBulkUpdate: true } });
+
+ jest.spyOn(eventHub, '$emit');
+
+ findGlButtonAt(2).vm.$emit('click');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('issuables:enableBulkEdit');
});
});
- it('updates IssuableList with response data', () => {
- expect(findIssuableList().props()).toMatchObject({
- issuables: data,
- totalItems,
- currentPage: page,
- previousPage: page - 1,
- nextPage: page + 1,
- urlParams: { page, state },
+ describe('new issue button', () => {
+ it('renders when user has permissions', () => {
+ wrapper = mountComponent({ provide: { showNewIssueLink: true } });
+
+ 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 } });
+
+ expect(findGlButtons().filter((button) => button.text() === 'New issue')).toHaveLength(0);
+ });
+ });
+ });
+
+ describe('initial url params', () => {
+ describe('page', () => {
+ it('is set from the url params', () => {
+ const page = 5;
+
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { href: setUrlParams({ page }, TEST_HOST) },
+ });
+
+ wrapper = mountComponent();
+
+ expect(findIssuableList().props('currentPage')).toBe(page);
+ });
+ });
+
+ describe('sort', () => {
+ it.each(Object.keys(sortParams))('is set as %s from the url params', (sortKey) => {
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { href: setUrlParams(sortParams[sortKey], TEST_HOST) },
+ });
+
+ wrapper = mountComponent();
+
+ expect(findIssuableList().props()).toMatchObject({
+ initialSortBy: sortKey,
+ urlParams: sortParams[sortKey],
+ });
+ });
+ });
+
+ describe('state', () => {
+ it('is set from the url params', () => {
+ const initialState = IssuableStates.All;
+
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { href: setUrlParams({ state: initialState }, TEST_HOST) },
+ });
+
+ wrapper = mountComponent();
+
+ expect(findIssuableList().props('currentTab')).toBe(initialState);
+ });
+ });
+ });
+
+ 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('empty states', () => {
+ describe('when there are issues', () => {
+ describe('when search returns no results', () => {
+ beforeEach(async () => {
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { href: setUrlParams({ search: 'no results' }, TEST_HOST) },
+ });
+
+ wrapper = mountComponent({ provide: { hasIssues: true } });
+
+ await waitForPromises();
+ });
+
+ 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: { hasIssues: true } });
+ });
+
+ 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(async () => {
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { href: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST) },
+ });
+
+ wrapper = mountComponent({ provide: { hasIssues: true } });
+ });
+
+ 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: { hasIssues: 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(2).text()).toContain(IssuesListApp.i18n.jiraIntegrationTitle);
+ expect(paragraphs.at(3).text()).toContain(
+ 'Enable the Jira integration to view your Jira issues in GitLab.',
+ );
+ expect(paragraphs.at(4).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: { hasIssues: 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('events', () => {
+ describe('when "click-tab" event is emitted by IssuableList', () => {
+ beforeEach(() => {
+ axiosMock.onGet(defaultProvide.endpoint).reply(200, fetchIssuesResponse.data, {
+ 'x-page': 2,
+ 'x-total': xTotal,
+ });
+
+ wrapper = mountComponent();
+
+ findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
+ });
+
+ it('makes API call to filter the list by the new state and resets the page to 1', () => {
+ expect(axiosMock.history.get[1].params).toMatchObject({
+ page: 1,
+ state: IssuableStates.Closed,
+ });
+ });
+ });
+
+ describe('when "page-change" event is emitted by IssuableList', () => {
+ const data = [{ id: 10, title: 'title', state }];
+ const page = 2;
+ const totalItems = 21;
+
+ beforeEach(async () => {
+ axiosMock.onGet(defaultProvide.endpoint).reply(200, data, {
+ 'x-page': page,
+ 'x-total': totalItems,
+ });
+
+ wrapper = mountComponent();
+
+ findIssuableList().vm.$emit('page-change', page);
+
+ await waitForPromises();
+ });
+
+ it('fetches issues with expected params', () => {
+ expect(axiosMock.history.get[1].params).toEqual({
+ page,
+ per_page: PAGE_SIZE,
+ state,
+ with_labels_details: true,
+ });
+ });
+
+ it('updates IssuableList with response data', () => {
+ expect(findIssuableList().props()).toMatchObject({
+ issuables: data,
+ totalItems,
+ currentPage: page,
+ previousPage: page - 1,
+ nextPage: page + 1,
+ urlParams: { page, state },
+ });
+ });
+ });
+
+ describe('when "reorder" event is emitted by IssuableList', () => {
+ const issueOne = { id: 1, iid: 101, title: 'Issue one' };
+ const issueTwo = { id: 2, iid: 102, title: 'Issue two' };
+ const issueThree = { id: 3, iid: 103, title: 'Issue three' };
+ const issueFour = { id: 4, iid: 104, title: 'Issue four' };
+ const issues = [issueOne, issueTwo, issueThree, issueFour];
+
+ beforeEach(async () => {
+ axiosMock.onGet(defaultProvide.endpoint).reply(200, issues, fetchIssuesResponse.headers);
+ wrapper = mountComponent();
+ await waitForPromises();
+ });
+
+ describe('when successful', () => {
+ 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 }) => {
+ it('makes API call to reorder the issue', async () => {
+ findIssuableList().vm.$emit('reorder', { oldIndex, newIndex });
+
+ await waitForPromises();
+
+ expect(axiosMock.history.put[0]).toMatchObject({
+ url: `${defaultProvide.issuesPath}/${issueToMove.iid}/reorder`,
+ data: JSON.stringify({ move_before_id: moveBeforeId, move_after_id: moveAfterId }),
+ });
+ });
+ },
+ );
+ });
+
+ describe('when unsuccessful', () => {
+ it('displays an error message', async () => {
+ axiosMock.onPut(`${defaultProvide.issuesPath}/${issueOne.iid}/reorder`).reply(500);
+
+ findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 });
+
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith({ message: IssuesListApp.i18n.reorderError });
+ });
+ });
+ });
+
+ describe('when "sort" event is emitted by IssuableList', () => {
+ it.each(Object.keys(sortParams))(
+ 'fetches issues with correct params with payload `%s`',
+ async (sortKey) => {
+ wrapper = mountComponent();
+
+ findIssuableList().vm.$emit('sort', sortKey);
+
+ await waitForPromises();
+
+ expect(axiosMock.history.get[1].params).toEqual({
+ page: xPage,
+ per_page: sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
+ state,
+ with_labels_details: true,
+ ...sortParams[sortKey],
+ });
+ },
+ );
+ });
+
+ describe('when "update-legacy-bulk-edit" event is emitted by IssuableList', () => {
+ beforeEach(() => {
+ wrapper = mountComponent();
+ jest.spyOn(eventHub, '$emit');
+ });
+
+ it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', async () => {
+ findIssuableList().vm.$emit('update-legacy-bulk-edit');
+
+ await waitForPromises();
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
+ });
+ });
+
+ describe('when "filter" event is emitted by IssuableList', () => {
+ beforeEach(async () => {
+ wrapper = mountComponent();
+
+ const payload = [
+ { type: 'filtered-search-term', value: { data: 'no' } },
+ { type: 'filtered-search-term', value: { data: 'issues' } },
+ ];
+
+ findIssuableList().vm.$emit('filter', payload);
+
+ await waitForPromises();
+ });
+
+ it('makes an API call to search for issues with the search term', () => {
+ expect(axiosMock.history.get[1].params).toMatchObject({ search: 'no issues' });
});
});
});