diff options
Diffstat (limited to 'spec/frontend/issues_list/components')
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¬[label_name][]=Afterpod¬[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); + }); + }); + }); +}); |