summaryrefslogtreecommitdiff
path: root/spec/frontend/issues_list/components
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/issues_list/components')
-rw-r--r--spec/frontend/issues_list/components/__snapshots__/issuables_list_app_spec.js.snap14
-rw-r--r--spec/frontend/issues_list/components/issuable_spec.js492
-rw-r--r--spec/frontend/issues_list/components/issuables_list_app_spec.js595
-rw-r--r--spec/frontend/issues_list/components/jira_issues_list_root_spec.js115
4 files changed, 1216 insertions, 0 deletions
diff --git a/spec/frontend/issues_list/components/__snapshots__/issuables_list_app_spec.js.snap b/spec/frontend/issues_list/components/__snapshots__/issuables_list_app_spec.js.snap
new file mode 100644
index 00000000000..c327b7de827
--- /dev/null
+++ b/spec/frontend/issues_list/components/__snapshots__/issuables_list_app_spec.js.snap
@@ -0,0 +1,14 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Issuables list component with empty issues response with all state should display a catch-all if there are no issues to show 1`] = `
+<gl-empty-state-stub
+ svgpath="/emptySvg"
+ title="There are no issues to show"
+/>
+`;
+
+exports[`Issuables list component with empty issues response with closed state should display a message "There are no closed issues" if there are no closed issues 1`] = `"There are no closed issues"`;
+
+exports[`Issuables list component with empty issues response with empty query should display the message "There are no open issues" 1`] = `"There are no open issues"`;
+
+exports[`Issuables list component with empty issues response with query in window location should display "Sorry, your filter produced no results" if filters are too specific 1`] = `"Sorry, your filter produced no results"`;
diff --git a/spec/frontend/issues_list/components/issuable_spec.js b/spec/frontend/issues_list/components/issuable_spec.js
new file mode 100644
index 00000000000..c20684cc385
--- /dev/null
+++ b/spec/frontend/issues_list/components/issuable_spec.js
@@ -0,0 +1,492 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlSprintf, GlLabel, GlIcon } from '@gitlab/ui';
+import { TEST_HOST } from 'helpers/test_constants';
+import { trimText } from 'helpers/text_helper';
+import initUserPopovers from '~/user_popovers';
+import { formatDate } from '~/lib/utils/datetime_utility';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+import Issuable from '~/issues_list/components/issuable.vue';
+import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
+import { simpleIssue, testAssignees, testLabels } from '../issuable_list_test_data';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+
+jest.mock('~/user_popovers');
+
+const TEST_NOW = '2019-08-28T20:03:04.713Z';
+const TEST_MONTH_AGO = '2019-07-28';
+const TEST_MONTH_LATER = '2019-09-30';
+const DATE_FORMAT = 'mmm d, yyyy';
+const TEST_USER_NAME = 'Tyler Durden';
+const TEST_BASE_URL = `${TEST_HOST}/issues`;
+const TEST_TASK_STATUS = '50 of 100 tasks completed';
+const TEST_MILESTONE = {
+ title: 'Milestone title',
+ web_url: `${TEST_HOST}/milestone/1`,
+};
+const TEXT_CLOSED = 'CLOSED';
+const TEST_META_COUNT = 100;
+
+// Use FixedDate so that time sensitive info in snapshots don't fail
+class FixedDate extends Date {
+ constructor(date = TEST_NOW) {
+ super(date);
+ }
+}
+
+describe('Issuable component', () => {
+ let issuable;
+ let DateOrig;
+ let wrapper;
+
+ const factory = (props = {}, scopedLabels = false) => {
+ wrapper = shallowMount(Issuable, {
+ propsData: {
+ issuable: simpleIssue,
+ baseUrl: TEST_BASE_URL,
+ ...props,
+ },
+ provide: {
+ glFeatures: {
+ scopedLabels,
+ },
+ },
+ stubs: {
+ 'gl-sprintf': GlSprintf,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ issuable = { ...simpleIssue };
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ beforeAll(() => {
+ DateOrig = window.Date;
+ window.Date = FixedDate;
+ });
+
+ afterAll(() => {
+ window.Date = DateOrig;
+ });
+
+ const checkExists = findFn => () => findFn().exists();
+ const hasIcon = (iconName, iconWrapper = wrapper) =>
+ iconWrapper.findAll(GlIcon).wrappers.some(icon => icon.props('name') === iconName);
+ const hasConfidentialIcon = () => hasIcon('eye-slash');
+ const findTaskStatus = () => wrapper.find('.task-status');
+ const findOpenedAgoContainer = () => wrapper.find('[data-testid="openedByMessage"]');
+ const findAuthor = () => wrapper.find({ ref: 'openedAgoByContainer' });
+ const findMilestone = () => wrapper.find('.js-milestone');
+ const findMilestoneTooltip = () => findMilestone().attributes('title');
+ const findDueDate = () => wrapper.find('.js-due-date');
+ const findLabels = () => wrapper.findAll(GlLabel);
+ const findWeight = () => wrapper.find('[data-testid="weight"]');
+ const findAssignees = () => wrapper.find(IssueAssignees);
+ const findBlockingIssuesCount = () => wrapper.find('[data-testid="blocking-issues"]');
+ const findMergeRequestsCount = () => wrapper.find('[data-testid="merge-requests"]');
+ const findUpvotes = () => wrapper.find('[data-testid="upvotes"]');
+ const findDownvotes = () => wrapper.find('[data-testid="downvotes"]');
+ const findNotes = () => wrapper.find('[data-testid="notes-count"]');
+ const findBulkCheckbox = () => wrapper.find('input.selected-issuable');
+ const findScopedLabels = () => findLabels().filter(w => isScopedLabel({ title: w.text() }));
+ const findUnscopedLabels = () => findLabels().filter(w => !isScopedLabel({ title: w.text() }));
+ const findIssuableTitle = () => wrapper.find('[data-testid="issuable-title"]');
+ const findIssuableStatus = () => wrapper.find('[data-testid="issuable-status"]');
+ const containsJiraLogo = () => wrapper.find('[data-testid="jira-logo"]').exists();
+ const findHealthStatus = () => wrapper.find('.health-status');
+
+ describe('when mounted', () => {
+ it('initializes user popovers', () => {
+ expect(initUserPopovers).not.toHaveBeenCalled();
+
+ factory();
+
+ expect(initUserPopovers).toHaveBeenCalledWith([wrapper.vm.$refs.openedAgoByContainer.$el]);
+ });
+ });
+
+ describe('when scopedLabels feature is available', () => {
+ beforeEach(() => {
+ issuable.labels = [...testLabels];
+
+ factory({ issuable }, true);
+ });
+
+ describe('when label is scoped', () => {
+ it('returns label with correct props', () => {
+ const scopedLabel = findScopedLabels().at(0);
+
+ expect(scopedLabel.props('scoped')).toBe(true);
+ });
+ });
+
+ describe('when label is not scoped', () => {
+ it('returns label with correct props', () => {
+ const notScopedLabel = findUnscopedLabels().at(0);
+
+ expect(notScopedLabel.props('scoped')).toBe(false);
+ });
+ });
+ });
+
+ describe('when scopedLabels feature is not available', () => {
+ beforeEach(() => {
+ issuable.labels = [...testLabels];
+
+ factory({ issuable });
+ });
+
+ describe('when label is scoped', () => {
+ it('label scoped props is false', () => {
+ const scopedLabel = findScopedLabels().at(0);
+
+ expect(scopedLabel.props('scoped')).toBe(false);
+ });
+ });
+
+ describe('when label is not scoped', () => {
+ it('label scoped props is false', () => {
+ const notScopedLabel = findUnscopedLabels().at(0);
+
+ expect(notScopedLabel.props('scoped')).toBe(false);
+ });
+ });
+ });
+
+ describe('with simple issuable', () => {
+ beforeEach(() => {
+ Object.assign(issuable, {
+ has_tasks: false,
+ task_status: TEST_TASK_STATUS,
+ created_at: TEST_MONTH_AGO,
+ author: {
+ ...issuable.author,
+ name: TEST_USER_NAME,
+ },
+ labels: [],
+ });
+
+ factory({ issuable });
+ });
+
+ it.each`
+ desc | check
+ ${'bulk editing checkbox'} | ${checkExists(findBulkCheckbox)}
+ ${'confidential icon'} | ${hasConfidentialIcon}
+ ${'task status'} | ${checkExists(findTaskStatus)}
+ ${'milestone'} | ${checkExists(findMilestone)}
+ ${'due date'} | ${checkExists(findDueDate)}
+ ${'labels'} | ${checkExists(findLabels)}
+ ${'weight'} | ${checkExists(findWeight)}
+ ${'blocking issues count'} | ${checkExists(findBlockingIssuesCount)}
+ ${'merge request count'} | ${checkExists(findMergeRequestsCount)}
+ ${'upvotes'} | ${checkExists(findUpvotes)}
+ ${'downvotes'} | ${checkExists(findDownvotes)}
+ `('does not render $desc', ({ check }) => {
+ expect(check()).toBe(false);
+ });
+
+ it('show relative reference path', () => {
+ expect(wrapper.find('.js-ref-path').text()).toBe(issuable.references.relative);
+ });
+
+ it('does not have closed text', () => {
+ expect(wrapper.text()).not.toContain(TEXT_CLOSED);
+ });
+
+ it('does not have closed class', () => {
+ expect(wrapper.classes('closed')).toBe(false);
+ });
+
+ it('renders fuzzy opened date and author', () => {
+ expect(trimText(findOpenedAgoContainer().text())).toContain(
+ `opened 1 month ago by ${TEST_USER_NAME}`,
+ );
+ });
+
+ it('renders no comments', () => {
+ expect(findNotes().classes('no-comments')).toBe(true);
+ });
+ });
+
+ describe('with confidential issuable', () => {
+ beforeEach(() => {
+ issuable.confidential = true;
+
+ factory({ issuable });
+ });
+
+ it('renders the confidential icon', () => {
+ expect(hasConfidentialIcon()).toBe(true);
+ });
+ });
+
+ describe('with Jira issuable', () => {
+ beforeEach(() => {
+ issuable.external_tracker = 'jira';
+
+ factory({ issuable });
+ });
+
+ it('renders the Jira icon', () => {
+ expect(containsJiraLogo()).toBe(true);
+ });
+
+ it('opens issuable in a new tab', () => {
+ expect(findIssuableTitle().props('target')).toBe('_blank');
+ });
+
+ it('opens author in a new tab', () => {
+ expect(findAuthor().props('target')).toBe('_blank');
+ });
+
+ describe('with Jira status', () => {
+ const expectedStatus = 'In Progress';
+
+ beforeEach(() => {
+ issuable.status = expectedStatus;
+
+ factory({ issuable });
+ });
+
+ it('renders the Jira status', () => {
+ expect(findIssuableStatus().text()).toBe(expectedStatus);
+ });
+ });
+ });
+
+ describe('with task status', () => {
+ beforeEach(() => {
+ Object.assign(issuable, {
+ has_tasks: true,
+ task_status: TEST_TASK_STATUS,
+ });
+
+ factory({ issuable });
+ });
+
+ it('renders task status', () => {
+ expect(findTaskStatus().exists()).toBe(true);
+ expect(findTaskStatus().text()).toBe(TEST_TASK_STATUS);
+ });
+ });
+
+ describe.each`
+ desc | dueDate | expectedTooltipPart
+ ${'past due'} | ${TEST_MONTH_AGO} | ${'Past due'}
+ ${'future due'} | ${TEST_MONTH_LATER} | ${'1 month remaining'}
+ `('with milestone with $desc', ({ dueDate, expectedTooltipPart }) => {
+ beforeEach(() => {
+ issuable.milestone = { ...TEST_MILESTONE, due_date: dueDate };
+
+ factory({ issuable });
+ });
+
+ it('renders milestone', () => {
+ expect(findMilestone().exists()).toBe(true);
+ expect(hasIcon('clock', findMilestone())).toBe(true);
+ expect(findMilestone().text()).toEqual(TEST_MILESTONE.title);
+ });
+
+ it('renders tooltip', () => {
+ expect(findMilestoneTooltip()).toBe(
+ `${formatDate(dueDate, DATE_FORMAT)} (${expectedTooltipPart})`,
+ );
+ });
+
+ it('renders milestone with the correct href', () => {
+ const { title } = issuable.milestone;
+ const expected = mergeUrlParams({ milestone_title: title }, TEST_BASE_URL);
+
+ expect(findMilestone().attributes('href')).toBe(expected);
+ });
+ });
+
+ describe.each`
+ dueDate | hasClass | desc
+ ${TEST_MONTH_LATER} | ${false} | ${'with future due date'}
+ ${TEST_MONTH_AGO} | ${true} | ${'with past due date'}
+ `('$desc', ({ dueDate, hasClass }) => {
+ beforeEach(() => {
+ issuable.due_date = dueDate;
+
+ factory({ issuable });
+ });
+
+ it('renders due date', () => {
+ expect(findDueDate().exists()).toBe(true);
+ expect(findDueDate().text()).toBe(formatDate(dueDate, DATE_FORMAT));
+ });
+
+ it(hasClass ? 'has cred class' : 'does not have cred class', () => {
+ expect(findDueDate().classes('cred')).toEqual(hasClass);
+ });
+ });
+
+ describe('with labels', () => {
+ beforeEach(() => {
+ issuable.labels = [...testLabels];
+
+ factory({ issuable });
+ });
+
+ it('renders labels', () => {
+ factory({ issuable });
+
+ const labels = findLabels().wrappers.map(label => ({
+ href: label.props('target'),
+ text: label.text(),
+ tooltip: label.attributes('description'),
+ }));
+
+ const expected = testLabels.map(label => ({
+ href: mergeUrlParams({ 'label_name[]': label.name }, TEST_BASE_URL),
+ text: label.name,
+ tooltip: label.description,
+ }));
+
+ expect(labels).toEqual(expected);
+ });
+ });
+
+ describe('with labels for Jira issuable', () => {
+ beforeEach(() => {
+ issuable.labels = [...testLabels];
+ issuable.external_tracker = 'jira';
+
+ factory({ issuable });
+ });
+
+ it('renders labels', () => {
+ factory({ issuable });
+
+ const labels = findLabels().wrappers.map(label => ({
+ href: label.props('target'),
+ text: label.text(),
+ tooltip: label.attributes('description'),
+ }));
+
+ const expected = testLabels.map(label => ({
+ href: mergeUrlParams({ 'labels[]': label.name }, TEST_BASE_URL),
+ text: label.name,
+ tooltip: label.description,
+ }));
+
+ expect(labels).toEqual(expected);
+ });
+ });
+
+ describe.each`
+ weight
+ ${0}
+ ${10}
+ ${12345}
+ `('with weight $weight', ({ weight }) => {
+ beforeEach(() => {
+ issuable.weight = weight;
+
+ factory({ issuable });
+ });
+
+ it('renders weight', () => {
+ expect(findWeight().exists()).toBe(true);
+ expect(findWeight().text()).toEqual(weight.toString());
+ });
+ });
+
+ describe('with closed state', () => {
+ beforeEach(() => {
+ issuable.state = 'closed';
+
+ factory({ issuable });
+ });
+
+ it('renders closed text', () => {
+ expect(wrapper.text()).toContain(TEXT_CLOSED);
+ });
+
+ it('has closed class', () => {
+ expect(wrapper.classes('closed')).toBe(true);
+ });
+ });
+
+ describe('with assignees', () => {
+ beforeEach(() => {
+ issuable.assignees = testAssignees;
+
+ factory({ issuable });
+ });
+
+ it('renders assignees', () => {
+ expect(findAssignees().exists()).toBe(true);
+ expect(findAssignees().props('assignees')).toEqual(testAssignees);
+ });
+ });
+
+ describe.each`
+ desc | key | finder
+ ${'with blocking issues count'} | ${'blocking_issues_count'} | ${findBlockingIssuesCount}
+ ${'with merge requests count'} | ${'merge_requests_count'} | ${findMergeRequestsCount}
+ ${'with upvote count'} | ${'upvotes'} | ${findUpvotes}
+ ${'with downvote count'} | ${'downvotes'} | ${findDownvotes}
+ ${'with notes count'} | ${'user_notes_count'} | ${findNotes}
+ `('$desc', ({ key, finder }) => {
+ beforeEach(() => {
+ issuable[key] = TEST_META_COUNT;
+
+ factory({ issuable });
+ });
+
+ it('renders correct count', () => {
+ expect(finder().exists()).toBe(true);
+ expect(finder().text()).toBe(TEST_META_COUNT.toString());
+ expect(finder().classes('no-comments')).toBe(false);
+ });
+ });
+
+ describe('with bulk editing', () => {
+ describe.each`
+ selected | desc
+ ${true} | ${'when selected'}
+ ${false} | ${'when unselected'}
+ `('$desc', ({ selected }) => {
+ beforeEach(() => {
+ factory({ isBulkEditing: true, selected });
+ });
+
+ it(`renders checked is ${selected}`, () => {
+ expect(findBulkCheckbox().element.checked).toBe(selected);
+ });
+
+ it('emits select when clicked', () => {
+ expect(wrapper.emitted().select).toBeUndefined();
+
+ findBulkCheckbox().trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted().select).toEqual([[{ issuable, selected: !selected }]]);
+ });
+ });
+ });
+ });
+
+ if (IS_EE) {
+ describe('with health status', () => {
+ it('renders health status tag', () => {
+ factory({ issuable });
+ expect(findHealthStatus().exists()).toBe(true);
+ });
+
+ it('does not render when health status is absent', () => {
+ issuable.health_status = null;
+ factory({ issuable });
+ expect(findHealthStatus().exists()).toBe(false);
+ });
+ });
+ }
+});
diff --git a/spec/frontend/issues_list/components/issuables_list_app_spec.js b/spec/frontend/issues_list/components/issuables_list_app_spec.js
new file mode 100644
index 00000000000..1f80b4fc54a
--- /dev/null
+++ b/spec/frontend/issues_list/components/issuables_list_app_spec.js
@@ -0,0 +1,595 @@
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { shallowMount } from '@vue/test-utils';
+import {
+ GlEmptyState,
+ GlPagination,
+ GlDeprecatedSkeletonLoading as GlSkeletonLoading,
+} from '@gitlab/ui';
+import waitForPromises from 'helpers/wait_for_promises';
+import { TEST_HOST } from 'helpers/test_constants';
+import { deprecatedCreateFlash as flash } from '~/flash';
+import IssuablesListApp from '~/issues_list/components/issuables_list_app.vue';
+import Issuable from '~/issues_list/components/issuable.vue';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import issueablesEventBus from '~/issues_list/eventhub';
+import { PAGE_SIZE, PAGE_SIZE_MANUAL, RELATIVE_POSITION } from '~/issues_list/constants';
+
+jest.mock('~/flash');
+jest.mock('~/issues_list/eventhub');
+jest.mock('~/lib/utils/common_utils', () => ({
+ ...jest.requireActual('~/lib/utils/common_utils'),
+ scrollToElement: () => {},
+}));
+
+const TEST_LOCATION = `${TEST_HOST}/issues`;
+const TEST_ENDPOINT = '/issues';
+const TEST_CREATE_ISSUES_PATH = '/createIssue';
+const TEST_SVG_PATH = '/emptySvg';
+
+const setUrl = query => {
+ window.location.href = `${TEST_LOCATION}${query}`;
+ window.location.search = query;
+};
+
+const MOCK_ISSUES = Array(PAGE_SIZE_MANUAL)
+ .fill(0)
+ .map((_, i) => ({
+ id: i,
+ web_url: `url${i}`,
+ }));
+
+describe('Issuables list component', () => {
+ let oldLocation;
+ let mockAxios;
+ let wrapper;
+ let apiSpy;
+
+ const setupApiMock = cb => {
+ apiSpy = jest.fn(cb);
+
+ mockAxios.onGet(TEST_ENDPOINT).reply(cfg => apiSpy(cfg));
+ };
+
+ const factory = (props = { sortKey: 'priority' }) => {
+ const emptyStateMeta = {
+ createIssuePath: TEST_CREATE_ISSUES_PATH,
+ svgPath: TEST_SVG_PATH,
+ };
+
+ wrapper = shallowMount(IssuablesListApp, {
+ propsData: {
+ endpoint: TEST_ENDPOINT,
+ emptyStateMeta,
+ ...props,
+ },
+ });
+ };
+
+ const findLoading = () => wrapper.find(GlSkeletonLoading);
+ const findIssuables = () => wrapper.findAll(Issuable);
+ const findFilteredSearchBar = () => wrapper.find(FilteredSearchBar);
+ const findFirstIssuable = () => findIssuables().wrappers[0];
+ const findEmptyState = () => wrapper.find(GlEmptyState);
+
+ beforeEach(() => {
+ mockAxios = new MockAdapter(axios);
+
+ oldLocation = window.location;
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { href: '', search: '' },
+ });
+ window.location.href = TEST_LOCATION;
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ mockAxios.restore();
+ window.location = oldLocation;
+ });
+
+ describe('with failed issues response', () => {
+ beforeEach(() => {
+ setupApiMock(() => [500]);
+
+ factory();
+
+ return waitForPromises();
+ });
+
+ it('does not show loading', () => {
+ expect(wrapper.vm.loading).toBe(false);
+ });
+
+ it('flashes an error', () => {
+ expect(flash).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('with successful issues response', () => {
+ beforeEach(() => {
+ setupApiMock(() => [
+ 200,
+ MOCK_ISSUES.slice(0, PAGE_SIZE),
+ {
+ 'x-total': 100,
+ 'x-page': 2,
+ },
+ ]);
+ });
+
+ it('has default props and data', () => {
+ factory();
+ expect(wrapper.vm).toMatchObject({
+ // Props
+ canBulkEdit: false,
+ emptyStateMeta: {
+ createIssuePath: TEST_CREATE_ISSUES_PATH,
+ svgPath: TEST_SVG_PATH,
+ },
+ // Data
+ filters: {
+ state: 'opened',
+ },
+ isBulkEditing: false,
+ issuables: [],
+ loading: true,
+ page: 1,
+ selection: {},
+ totalItems: 0,
+ });
+ });
+
+ it('does not call API until mounted', () => {
+ factory();
+ expect(apiSpy).not.toHaveBeenCalled();
+ });
+
+ describe('when mounted', () => {
+ beforeEach(() => {
+ factory();
+ });
+
+ it('calls API', () => {
+ expect(apiSpy).toHaveBeenCalled();
+ });
+
+ it('shows loading', () => {
+ expect(findLoading().exists()).toBe(true);
+ expect(findIssuables().length).toBe(0);
+ expect(findEmptyState().exists()).toBe(false);
+ });
+ });
+
+ describe('when finished loading', () => {
+ beforeEach(() => {
+ factory();
+
+ return waitForPromises();
+ });
+
+ it('does not display empty state', () => {
+ expect(wrapper.vm.issuables.length).toBeGreaterThan(0);
+ expect(wrapper.vm.emptyState).toEqual({});
+ expect(wrapper.find(GlEmptyState).exists()).toBe(false);
+ });
+
+ it('sets the proper page and total items', () => {
+ expect(wrapper.vm.totalItems).toBe(100);
+ expect(wrapper.vm.page).toBe(2);
+ });
+
+ it('renders one page of issuables and pagination', () => {
+ expect(findIssuables().length).toBe(PAGE_SIZE);
+ expect(wrapper.find(GlPagination).exists()).toBe(true);
+ });
+ });
+
+ it('does not render FilteredSearchBar', () => {
+ factory();
+
+ expect(findFilteredSearchBar().exists()).toBe(false);
+ });
+ });
+
+ describe('with bulk editing enabled', () => {
+ beforeEach(() => {
+ issueablesEventBus.$on.mockReset();
+ issueablesEventBus.$emit.mockReset();
+
+ setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
+ factory({ canBulkEdit: true });
+
+ return waitForPromises();
+ });
+
+ it('is not enabled by default', () => {
+ expect(wrapper.vm.isBulkEditing).toBe(false);
+ });
+
+ it('does not select issues by default', () => {
+ expect(wrapper.vm.selection).toEqual({});
+ });
+
+ it('"Select All" checkbox toggles all visible issuables"', () => {
+ wrapper.vm.onSelectAll();
+ expect(wrapper.vm.selection).toEqual(
+ wrapper.vm.issuables.reduce((acc, i) => ({ ...acc, [i.id]: true }), {}),
+ );
+
+ wrapper.vm.onSelectAll();
+ expect(wrapper.vm.selection).toEqual({});
+ });
+
+ it('"Select All checkbox" selects all issuables if only some are selected"', () => {
+ wrapper.vm.selection = { [wrapper.vm.issuables[0].id]: true };
+ wrapper.vm.onSelectAll();
+ expect(wrapper.vm.selection).toEqual(
+ wrapper.vm.issuables.reduce((acc, i) => ({ ...acc, [i.id]: true }), {}),
+ );
+ });
+
+ it('selects and deselects issuables', () => {
+ const [i0, i1, i2] = wrapper.vm.issuables;
+
+ expect(wrapper.vm.selection).toEqual({});
+ wrapper.vm.onSelectIssuable({ issuable: i0, selected: false });
+ expect(wrapper.vm.selection).toEqual({});
+ wrapper.vm.onSelectIssuable({ issuable: i1, selected: true });
+ expect(wrapper.vm.selection).toEqual({ '1': true });
+ wrapper.vm.onSelectIssuable({ issuable: i0, selected: true });
+ expect(wrapper.vm.selection).toEqual({ '1': true, '0': true });
+ wrapper.vm.onSelectIssuable({ issuable: i2, selected: true });
+ expect(wrapper.vm.selection).toEqual({ '1': true, '0': true, '2': true });
+ wrapper.vm.onSelectIssuable({ issuable: i2, selected: true });
+ expect(wrapper.vm.selection).toEqual({ '1': true, '0': true, '2': true });
+ wrapper.vm.onSelectIssuable({ issuable: i0, selected: false });
+ expect(wrapper.vm.selection).toEqual({ '1': true, '2': true });
+ });
+
+ it('broadcasts a message to the bulk edit sidebar when a value is added to selection', () => {
+ issueablesEventBus.$emit.mockReset();
+ const i1 = wrapper.vm.issuables[1];
+
+ wrapper.vm.onSelectIssuable({ issuable: i1, selected: true });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(issueablesEventBus.$emit).toHaveBeenCalledTimes(1);
+ expect(issueablesEventBus.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
+ });
+ });
+
+ it('does not broadcast a message to the bulk edit sidebar when a value is not added to selection', () => {
+ issueablesEventBus.$emit.mockReset();
+
+ return wrapper.vm
+ .$nextTick()
+ .then(waitForPromises)
+ .then(() => {
+ const i1 = wrapper.vm.issuables[1];
+
+ wrapper.vm.onSelectIssuable({ issuable: i1, selected: false });
+ })
+ .then(wrapper.vm.$nextTick)
+ .then(() => {
+ expect(issueablesEventBus.$emit).toHaveBeenCalledTimes(0);
+ });
+ });
+
+ it('listens to a message to toggle bulk editing', () => {
+ expect(wrapper.vm.isBulkEditing).toBe(false);
+ expect(issueablesEventBus.$on.mock.calls[0][0]).toBe('issuables:toggleBulkEdit');
+ issueablesEventBus.$on.mock.calls[0][1](true); // Call the message handler
+
+ return waitForPromises()
+ .then(() => {
+ expect(wrapper.vm.isBulkEditing).toBe(true);
+ issueablesEventBus.$on.mock.calls[0][1](false);
+ })
+ .then(() => {
+ expect(wrapper.vm.isBulkEditing).toBe(false);
+ });
+ });
+ });
+
+ describe('with query params in window.location', () => {
+ const expectedFilters = {
+ assignee_username: 'root',
+ author_username: 'root',
+ confidential: 'yes',
+ my_reaction_emoji: 'airplane',
+ scope: 'all',
+ state: 'opened',
+ utf8: '✓',
+ weight: '0',
+ milestone: 'v3.0',
+ labels: 'Aquapod,Astro',
+ order_by: 'milestone_due',
+ sort: 'desc',
+ };
+
+ describe('when page is not present in params', () => {
+ const query =
+ '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&utf8=%E2%9C%93&weight=0&not[label_name][]=Afterpod&not[milestone_title][]=13';
+
+ beforeEach(() => {
+ setUrl(query);
+
+ setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
+ factory({ sortKey: 'milestone_due_desc' });
+
+ return waitForPromises();
+ });
+
+ afterEach(() => {
+ apiSpy.mockClear();
+ });
+
+ it('applies filters and sorts', () => {
+ expect(wrapper.vm.hasFilters).toBe(true);
+ expect(wrapper.vm.filters).toEqual({
+ ...expectedFilters,
+ 'not[milestone]': ['13'],
+ 'not[labels]': ['Afterpod'],
+ });
+
+ expect(apiSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ params: {
+ ...expectedFilters,
+ with_labels_details: true,
+ page: 1,
+ per_page: PAGE_SIZE,
+ 'not[milestone]': ['13'],
+ 'not[labels]': ['Afterpod'],
+ },
+ }),
+ );
+ });
+
+ it('passes the base url to issuable', () => {
+ expect(findFirstIssuable().props('baseUrl')).toBe(TEST_LOCATION);
+ });
+ });
+
+ describe('when page is present in the param', () => {
+ const query =
+ '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&utf8=%E2%9C%93&weight=0&page=3';
+
+ beforeEach(() => {
+ setUrl(query);
+
+ setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
+ factory({ sortKey: 'milestone_due_desc' });
+
+ return waitForPromises();
+ });
+
+ afterEach(() => {
+ apiSpy.mockClear();
+ });
+
+ it('applies filters and sorts', () => {
+ expect(apiSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ params: {
+ ...expectedFilters,
+ with_labels_details: true,
+ page: 3,
+ per_page: PAGE_SIZE,
+ },
+ }),
+ );
+ });
+ });
+ });
+
+ describe('with hash in window.location', () => {
+ beforeEach(() => {
+ window.location.href = `${TEST_LOCATION}#stuff`;
+ setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
+ factory();
+ return waitForPromises();
+ });
+
+ it('passes the base url to issuable', () => {
+ expect(findFirstIssuable().props('baseUrl')).toBe(TEST_LOCATION);
+ });
+ });
+
+ describe('with manual sort', () => {
+ beforeEach(() => {
+ setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
+ factory({ sortKey: RELATIVE_POSITION });
+ });
+
+ it('uses manual page size', () => {
+ expect(apiSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ params: expect.objectContaining({
+ per_page: PAGE_SIZE_MANUAL,
+ }),
+ }),
+ );
+ });
+ });
+
+ describe('with empty issues response', () => {
+ beforeEach(() => {
+ setupApiMock(() => [200, []]);
+ });
+
+ describe('with query in window location', () => {
+ beforeEach(() => {
+ window.location.search = '?weight=Any';
+
+ factory();
+
+ return waitForPromises().then(() => wrapper.vm.$nextTick());
+ });
+
+ it('should display "Sorry, your filter produced no results" if filters are too specific', () => {
+ expect(findEmptyState().props('title')).toMatchSnapshot();
+ });
+ });
+
+ describe('with closed state', () => {
+ beforeEach(() => {
+ window.location.search = '?state=closed';
+
+ factory();
+
+ return waitForPromises().then(() => wrapper.vm.$nextTick());
+ });
+
+ it('should display a message "There are no closed issues" if there are no closed issues', () => {
+ expect(findEmptyState().props('title')).toMatchSnapshot();
+ });
+ });
+
+ describe('with all state', () => {
+ beforeEach(() => {
+ window.location.search = '?state=all';
+
+ factory();
+
+ return waitForPromises().then(() => wrapper.vm.$nextTick());
+ });
+
+ it('should display a catch-all if there are no issues to show', () => {
+ expect(findEmptyState().element).toMatchSnapshot();
+ });
+ });
+
+ describe('with empty query', () => {
+ beforeEach(() => {
+ factory();
+
+ return wrapper.vm.$nextTick().then(waitForPromises);
+ });
+
+ it('should display the message "There are no open issues"', () => {
+ expect(findEmptyState().props('title')).toMatchSnapshot();
+ });
+ });
+ });
+
+ describe('when paginates', () => {
+ const newPage = 3;
+
+ describe('when total-items is defined in response headers', () => {
+ beforeEach(() => {
+ window.history.pushState = jest.fn();
+ setupApiMock(() => [
+ 200,
+ MOCK_ISSUES.slice(0, PAGE_SIZE),
+ {
+ 'x-total': 100,
+ 'x-page': 2,
+ },
+ ]);
+
+ factory();
+
+ return waitForPromises();
+ });
+
+ afterEach(() => {
+ // reset to original value
+ window.history.pushState.mockRestore();
+ });
+
+ it('calls window.history.pushState one time', () => {
+ // Trigger pagination
+ wrapper.find(GlPagination).vm.$emit('input', newPage);
+
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
+ });
+
+ it('sets params in the url', () => {
+ // Trigger pagination
+ wrapper.find(GlPagination).vm.$emit('input', newPage);
+
+ expect(window.history.pushState).toHaveBeenCalledWith(
+ {},
+ '',
+ `${TEST_LOCATION}?state=opened&order_by=priority&sort=asc&page=${newPage}`,
+ );
+ });
+ });
+
+ describe('when total-items is not defined in the headers', () => {
+ const page = 2;
+ const prevPage = page - 1;
+ const nextPage = page + 1;
+
+ beforeEach(() => {
+ setupApiMock(() => [
+ 200,
+ MOCK_ISSUES.slice(0, PAGE_SIZE),
+ {
+ 'x-page': page,
+ },
+ ]);
+
+ factory();
+
+ return waitForPromises();
+ });
+
+ it('finds the correct props applied to GlPagination', () => {
+ expect(wrapper.find(GlPagination).props()).toMatchObject({
+ nextPage,
+ prevPage,
+ value: page,
+ });
+ });
+ });
+ });
+
+ describe('when type is "jira"', () => {
+ it('renders FilteredSearchBar', () => {
+ factory({ type: 'jira' });
+
+ expect(findFilteredSearchBar().exists()).toBe(true);
+ });
+
+ describe('initialSortBy', () => {
+ const query = '?sort=updated_asc';
+
+ it('sets default value', () => {
+ factory({ type: 'jira' });
+
+ expect(findFilteredSearchBar().props('initialSortBy')).toBe('created_desc');
+ });
+
+ it('sets value according to query', () => {
+ setUrl(query);
+
+ factory({ type: 'jira' });
+
+ expect(findFilteredSearchBar().props('initialSortBy')).toBe('updated_asc');
+ });
+ });
+
+ describe('initialFilterValue', () => {
+ it('does not set value when no query', () => {
+ factory({ type: 'jira' });
+
+ expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([]);
+ });
+
+ it('sets value according to query', () => {
+ const query = '?search=free+text';
+
+ setUrl(query);
+
+ factory({ type: 'jira' });
+
+ expect(findFilteredSearchBar().props('initialFilterValue')).toEqual(['free text']);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issues_list/components/jira_issues_list_root_spec.js b/spec/frontend/issues_list/components/jira_issues_list_root_spec.js
new file mode 100644
index 00000000000..eecb092a330
--- /dev/null
+++ b/spec/frontend/issues_list/components/jira_issues_list_root_spec.js
@@ -0,0 +1,115 @@
+import { GlAlert, GlLabel } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import JiraIssuesListRoot from '~/issues_list/components/jira_issues_list_root.vue';
+
+describe('JiraIssuesListRoot', () => {
+ 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(JiraIssuesListRoot, {
+ 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 not in progress', () => {
+ it('does not show an alert', () => {
+ wrapper = mountComponent();
+
+ 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);
+ });
+ });
+ });
+});