diff options
Diffstat (limited to 'spec/frontend/issues_list')
-rw-r--r-- | spec/frontend/issues_list/components/issues_list_app_spec.js | 191 | ||||
-rw-r--r-- | spec/frontend/issues_list/mock_data.js | 127 | ||||
-rw-r--r-- | spec/frontend/issues_list/utils_spec.js | 109 |
3 files changed, 356 insertions, 71 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 476804bda12..5d83bf0142f 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -3,45 +3,51 @@ 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 { apiParams, filteredTokens, locationSearch, urlParams } from 'jest/issues_list/mock_data'; import createFlash from '~/flash'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; +import IssuableByEmail from '~/issuable/components/issuable_by_email.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 { + apiSortParams, CREATED_DESC, + DUE_DATE_OVERDUE, PAGE_SIZE, PAGE_SIZE_MANUAL, - RELATIVE_POSITION_ASC, - sortOptions, - sortParams, + PARAM_DUE_DATE, + RELATIVE_POSITION_DESC, + 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 { setUrlParams } from '~/lib/utils/url_utility'; jest.mock('~/flash'); describe('IssuesListApp component', () => { - const originalWindowLocation = window.location; let axiosMock; let wrapper; const defaultProvide = { + autocompleteUsersPath: 'autocomplete/users/path', calendarPath: 'calendar/path', canBulkUpdate: false, emptyStateSvgPath: 'empty-state.svg', endpoint: 'api/endpoint', exportCsvPath: 'export/csv/path', - fullPath: 'path/to/project', + hasBlockedIssuesFeature: true, hasIssues: true, + hasIssueWeightsFeature: true, isSignedIn: false, issuesPath: 'path/to/issues', jiraIntegrationPath: 'jira/integration/path', newIssuePath: 'new/issue/path', + projectLabelsPath: 'project/labels/path', + projectPath: 'path/to/project', rssPath: 'rss/path', - showImportButton: true, showNewIssueLink: true, signInPath: 'sign/in/path', }; @@ -63,6 +69,7 @@ describe('IssuesListApp component', () => { }; 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); @@ -86,7 +93,7 @@ describe('IssuesListApp component', () => { }); afterEach(() => { - window.location = originalWindowLocation; + global.jsdom.reconfigure({ url: TEST_HOST }); axiosMock.reset(); wrapper.destroy(); }); @@ -99,10 +106,10 @@ describe('IssuesListApp component', () => { it('renders', () => { expect(findIssuableList().props()).toMatchObject({ - namespace: defaultProvide.fullPath, + namespace: defaultProvide.projectPath, recentSearchesStorageKey: 'issues', searchInputPlaceholder: 'Search or filter results…', - sortOptions, + sortOptions: getSortOptions(true, true), initialSortBy: CREATED_DESC, tabs: IssuableListTabs, currentTab: IssuableStates.Opened, @@ -120,46 +127,58 @@ describe('IssuesListApp component', () => { describe('header action buttons', () => { it('renders rss button', () => { - wrapper = mountComponent(); + wrapper = mountComponent({ mountFn: mount }); + expect(findGlButtonAt(0).props('icon')).toBe('rss'); expect(findGlButtonAt(0).attributes()).toMatchObject({ href: defaultProvide.rssPath, - icon: 'rss', 'aria-label': IssuesListApp.i18n.rssLabel, }); }); it('renders calendar button', () => { - wrapper = mountComponent(); + wrapper = mountComponent({ mountFn: mount }); + expect(findGlButtonAt(1).props('icon')).toBe('calendar'); 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'; + describe('csv import/export component', () => { + describe('when user is signed in', () => { + it('renders', async () => { + const search = '?page=1&search=refactor&state=opened&sort=created_date'; - Object.defineProperty(window, 'location', { - writable: true, - value: { search }, - }); + global.jsdom.reconfigure({ url: `${TEST_HOST}${search}` }); - wrapper = mountComponent(); + wrapper = mountComponent({ + provide: { ...defaultProvide, isSignedIn: true }, + mountFn: mount, + }); - await waitForPromises(); + await waitForPromises(); + + expect(findCsvImportExportButtons().props()).toMatchObject({ + exportCsvPath: `${defaultProvide.exportCsvPath}${search}`, + issuableCount: xTotal, + }); + }); + }); - expect(findCsvImportExportButtons().props()).toMatchObject({ - exportCsvPath: `${defaultProvide.exportCsvPath}${search}`, - issuableCount: xTotal, + describe('when user is not signed in', () => { + it('does not render', () => { + wrapper = mountComponent({ provide: { ...defaultProvide, isSignedIn: false } }); + + expect(findCsvImportExportButtons().exists()).toBe(false); + }); }); }); describe('bulk edit button', () => { it('renders when user has permissions', () => { - wrapper = mountComponent({ provide: { canBulkUpdate: true } }); + wrapper = mountComponent({ provide: { canBulkUpdate: true }, mountFn: mount }); expect(findGlButtonAt(2).text()).toBe('Edit issues'); }); @@ -170,20 +189,22 @@ describe('IssuesListApp component', () => { 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 } }); + 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 } }); + wrapper = mountComponent({ provide: { showNewIssueLink: true }, mountFn: mount }); expect(findGlButtonAt(2).text()).toBe('New issue'); expect(findGlButtonAt(2).attributes('href')).toBe(defaultProvide.newIssuePath); @@ -198,14 +219,21 @@ describe('IssuesListApp component', () => { }); describe('initial url params', () => { + describe('due_date', () => { + it('is set from the url params', () => { + global.jsdom.reconfigure({ url: `${TEST_HOST}?${PARAM_DUE_DATE}=${DUE_DATE_OVERDUE}` }); + + wrapper = mountComponent(); + + expect(findIssuableList().props('urlParams')).toMatchObject({ due_date: DUE_DATE_OVERDUE }); + }); + }); + describe('page', () => { it('is set from the url params', () => { const page = 5; - Object.defineProperty(window, 'location', { - writable: true, - value: { href: setUrlParams({ page }, TEST_HOST) }, - }); + global.jsdom.reconfigure({ url: setUrlParams({ page }, TEST_HOST) }); wrapper = mountComponent(); @@ -213,18 +241,25 @@ describe('IssuesListApp component', () => { }); }); + describe('search', () => { + it('is set from the url params', () => { + global.jsdom.reconfigure({ url: `${TEST_HOST}${locationSearch}` }); + + wrapper = mountComponent(); + + expect(findIssuableList().props('urlParams')).toMatchObject({ search: 'find issues' }); + }); + }); + 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) }, - }); + it.each(Object.keys(urlSortParams))('is set as %s from the url params', (sortKey) => { + global.jsdom.reconfigure({ url: setUrlParams(urlSortParams[sortKey], TEST_HOST) }); wrapper = mountComponent(); expect(findIssuableList().props()).toMatchObject({ initialSortBy: sortKey, - urlParams: sortParams[sortKey], + urlParams: urlSortParams[sortKey], }); }); }); @@ -233,16 +268,23 @@ describe('IssuesListApp component', () => { it('is set from the url params', () => { const initialState = IssuableStates.All; - Object.defineProperty(window, 'location', { - writable: true, - value: { href: setUrlParams({ state: initialState }, TEST_HOST) }, - }); + global.jsdom.reconfigure({ url: setUrlParams({ state: initialState }, TEST_HOST) }); wrapper = mountComponent(); expect(findIssuableList().props('currentTab')).toBe(initialState); }); }); + + describe('filter tokens', () => { + it('is set from the url params', () => { + global.jsdom.reconfigure({ url: `${TEST_HOST}${locationSearch}` }); + + wrapper = mountComponent(); + + expect(findIssuableList().props('initialFilterValue')).toEqual(filteredTokens); + }); + }); }); describe('bulk edit', () => { @@ -262,16 +304,23 @@ describe('IssuesListApp component', () => { ); }); + 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(async () => { - Object.defineProperty(window, 'location', { - writable: true, - value: { href: setUrlParams({ search: 'no results' }, TEST_HOST) }, - }); + global.jsdom.reconfigure({ url: `${TEST_HOST}?search=no+results` }); - wrapper = mountComponent({ provide: { hasIssues: true } }); + wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount }); await waitForPromises(); }); @@ -286,8 +335,10 @@ describe('IssuesListApp component', () => { }); describe('when "Open" tab has no issues', () => { - beforeEach(() => { - wrapper = mountComponent({ provide: { hasIssues: true } }); + beforeEach(async () => { + wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount }); + + await waitForPromises(); }); it('shows empty state', () => { @@ -301,12 +352,13 @@ describe('IssuesListApp component', () => { describe('when "Closed" tab has no issues', () => { beforeEach(async () => { - Object.defineProperty(window, 'location', { - writable: true, - value: { href: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST) }, + global.jsdom.reconfigure({ + url: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST), }); - wrapper = mountComponent({ provide: { hasIssues: true } }); + wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount }); + + await waitForPromises(); }); it('shows empty state', () => { @@ -346,11 +398,11 @@ describe('IssuesListApp component', () => { 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( + 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(4).text()).toContain( + expect(paragraphs.at(3).text()).toContain( IssuesListApp.i18n.jiraIntegrationSecondaryMessage, ); expect(findGlLink().text()).toBe('Enable the Jira integration'); @@ -418,7 +470,7 @@ describe('IssuesListApp component', () => { }); it('fetches issues with expected params', () => { - expect(axiosMock.history.get[1].params).toEqual({ + expect(axiosMock.history.get[1].params).toMatchObject({ page, per_page: PAGE_SIZE, state, @@ -489,7 +541,7 @@ describe('IssuesListApp component', () => { }); describe('when "sort" event is emitted by IssuableList', () => { - it.each(Object.keys(sortParams))( + it.each(Object.keys(apiSortParams))( 'fetches issues with correct params with payload `%s`', async (sortKey) => { wrapper = mountComponent(); @@ -500,10 +552,10 @@ describe('IssuesListApp component', () => { expect(axiosMock.history.get[1].params).toEqual({ page: xPage, - per_page: sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE, + per_page: sortKey === RELATIVE_POSITION_DESC ? PAGE_SIZE_MANUAL : PAGE_SIZE, state, with_labels_details: true, - ...sortParams[sortKey], + ...apiSortParams[sortKey], }); }, ); @@ -525,21 +577,18 @@ describe('IssuesListApp component', () => { }); describe('when "filter" event is emitted by IssuableList', () => { - beforeEach(async () => { + beforeEach(() => { 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(); + findIssuableList().vm.$emit('filter', filteredTokens); }); it('makes an API call to search for issues with the search term', () => { - expect(axiosMock.history.get[1].params).toMatchObject({ search: 'no issues' }); + expect(axiosMock.history.get[1].params).toMatchObject(apiParams); + }); + + it('updates IssuableList with url params', () => { + expect(findIssuableList().props('urlParams')).toMatchObject(urlParams); }); }); }); diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js new file mode 100644 index 00000000000..ce2880d177a --- /dev/null +++ b/spec/frontend/issues_list/mock_data.js @@ -0,0 +1,127 @@ +import { + OPERATOR_IS, + OPERATOR_IS_NOT, +} from '~/vue_shared/components/filtered_search_bar/constants'; + +export const locationSearch = [ + '?search=find+issues', + 'author_username=homer', + 'not[author_username]=marge', + 'assignee_username[]=bart', + 'assignee_username[]=lisa', + 'not[assignee_username][]=patty', + 'not[assignee_username][]=selma', + 'milestone_title=season+4', + 'not[milestone_title]=season+20', + 'label_name[]=cartoon', + 'label_name[]=tv', + 'not[label_name][]=live action', + 'not[label_name][]=drama', + 'my_reaction_emoji=thumbsup', + 'confidential=no', + 'iteration_title=season:+%234', + 'not[iteration_title]=season:+%2320', + 'epic_id=12', + 'not[epic_id]=34', + 'weight=1', + 'not[weight]=3', +].join('&'); + +export const locationSearchWithSpecialValues = [ + 'assignee_id=123', + 'assignee_username=bart', + 'my_reaction_emoji=None', + 'iteration_id=Current', + 'epic_id=None', + 'weight=None', +].join('&'); + +export const filteredTokens = [ + { type: 'author_username', value: { data: 'homer', operator: OPERATOR_IS } }, + { type: 'author_username', value: { data: 'marge', operator: OPERATOR_IS_NOT } }, + { type: 'assignee_username', value: { data: 'bart', operator: OPERATOR_IS } }, + { type: 'assignee_username', value: { data: 'lisa', operator: OPERATOR_IS } }, + { type: 'assignee_username', value: { data: 'patty', operator: OPERATOR_IS_NOT } }, + { type: 'assignee_username', value: { data: 'selma', operator: OPERATOR_IS_NOT } }, + { type: 'milestone', value: { data: 'season 4', operator: OPERATOR_IS } }, + { type: 'milestone', value: { data: 'season 20', operator: OPERATOR_IS_NOT } }, + { type: 'labels', value: { data: 'cartoon', operator: OPERATOR_IS } }, + { type: 'labels', value: { data: 'tv', operator: OPERATOR_IS } }, + { type: 'labels', value: { data: 'live action', operator: OPERATOR_IS_NOT } }, + { type: 'labels', value: { data: 'drama', operator: OPERATOR_IS_NOT } }, + { type: 'my_reaction_emoji', value: { data: 'thumbsup', operator: OPERATOR_IS } }, + { type: 'confidential', value: { data: 'no', operator: OPERATOR_IS } }, + { type: 'iteration', value: { data: 'season: #4', operator: OPERATOR_IS } }, + { type: 'iteration', value: { data: 'season: #20', operator: OPERATOR_IS_NOT } }, + { type: 'epic_id', value: { data: '12', operator: OPERATOR_IS } }, + { type: 'epic_id', value: { data: '34', operator: OPERATOR_IS_NOT } }, + { type: 'weight', value: { data: '1', operator: OPERATOR_IS } }, + { type: 'weight', value: { data: '3', operator: OPERATOR_IS_NOT } }, + { type: 'filtered-search-term', value: { data: 'find' } }, + { type: 'filtered-search-term', value: { data: 'issues' } }, +]; + +export const filteredTokensWithSpecialValues = [ + { type: 'assignee_username', value: { data: '123', operator: OPERATOR_IS } }, + { type: 'assignee_username', value: { data: 'bart', operator: OPERATOR_IS } }, + { type: 'my_reaction_emoji', value: { data: 'None', operator: OPERATOR_IS } }, + { type: 'iteration', value: { data: 'Current', operator: OPERATOR_IS } }, + { type: 'epic_id', value: { data: 'None', operator: OPERATOR_IS } }, + { type: 'weight', value: { data: 'None', operator: OPERATOR_IS } }, +]; + +export const apiParams = { + author_username: 'homer', + 'not[author_username]': 'marge', + assignee_username: ['bart', 'lisa'], + 'not[assignee_username]': ['patty', 'selma'], + milestone: 'season 4', + 'not[milestone]': 'season 20', + labels: ['cartoon', 'tv'], + 'not[labels]': ['live action', 'drama'], + my_reaction_emoji: 'thumbsup', + confidential: 'no', + iteration_title: 'season: #4', + 'not[iteration_title]': 'season: #20', + epic_id: '12', + 'not[epic_id]': '34', + weight: '1', + 'not[weight]': '3', +}; + +export const apiParamsWithSpecialValues = { + assignee_id: '123', + assignee_username: 'bart', + my_reaction_emoji: 'None', + iteration_id: 'Current', + epic_id: 'None', + weight: 'None', +}; + +export const urlParams = { + author_username: 'homer', + 'not[author_username]': 'marge', + 'assignee_username[]': ['bart', 'lisa'], + 'not[assignee_username][]': ['patty', 'selma'], + milestone_title: 'season 4', + 'not[milestone_title]': 'season 20', + 'label_name[]': ['cartoon', 'tv'], + 'not[label_name][]': ['live action', 'drama'], + my_reaction_emoji: 'thumbsup', + confidential: 'no', + iteration_title: 'season: #4', + 'not[iteration_title]': 'season: #20', + epic_id: '12', + 'not[epic_id]': '34', + weight: '1', + 'not[weight]': '3', +}; + +export const urlParamsWithSpecialValues = { + assignee_id: '123', + 'assignee_username[]': 'bart', + my_reaction_emoji: 'None', + iteration_id: 'Current', + epic_id: 'None', + weight: 'None', +}; diff --git a/spec/frontend/issues_list/utils_spec.js b/spec/frontend/issues_list/utils_spec.js new file mode 100644 index 00000000000..17127753972 --- /dev/null +++ b/spec/frontend/issues_list/utils_spec.js @@ -0,0 +1,109 @@ +import { + apiParams, + apiParamsWithSpecialValues, + filteredTokens, + filteredTokensWithSpecialValues, + locationSearch, + locationSearchWithSpecialValues, + urlParams, + urlParamsWithSpecialValues, +} from 'jest/issues_list/mock_data'; +import { API_PARAM, DUE_DATE_VALUES, URL_PARAM, urlSortParams } from '~/issues_list/constants'; +import { + convertToParams, + convertToSearchQuery, + getDueDateValue, + getFilterTokens, + getSortKey, + getSortOptions, +} from '~/issues_list/utils'; + +describe('getSortKey', () => { + it.each(Object.keys(urlSortParams))('returns %s given the correct inputs', (sortKey) => { + const { sort } = urlSortParams[sortKey]; + expect(getSortKey(sort)).toBe(sortKey); + }); +}); + +describe('getDueDateValue', () => { + it.each(DUE_DATE_VALUES)('returns the argument when it is `%s`', (value) => { + expect(getDueDateValue(value)).toBe(value); + }); + + it('returns undefined when the argument is invalid', () => { + expect(getDueDateValue('invalid value')).toBeUndefined(); + }); +}); + +describe('getSortOptions', () => { + describe.each` + hasIssueWeightsFeature | hasBlockedIssuesFeature | length | containsWeight | containsBlocking + ${false} | ${false} | ${8} | ${false} | ${false} + ${true} | ${false} | ${9} | ${true} | ${false} + ${false} | ${true} | ${9} | ${false} | ${true} + ${true} | ${true} | ${10} | ${true} | ${true} + `( + 'when hasIssueWeightsFeature=$hasIssueWeightsFeature and hasBlockedIssuesFeature=$hasBlockedIssuesFeature', + ({ + hasIssueWeightsFeature, + hasBlockedIssuesFeature, + length, + containsWeight, + containsBlocking, + }) => { + const sortOptions = getSortOptions(hasIssueWeightsFeature, hasBlockedIssuesFeature); + + it('returns the correct length of sort options', () => { + expect(sortOptions).toHaveLength(length); + }); + + it(`${containsWeight ? 'contains' : 'does not contain'} weight option`, () => { + expect(sortOptions.some((option) => option.title === 'Weight')).toBe(containsWeight); + }); + + it(`${containsBlocking ? 'contains' : 'does not contain'} blocking option`, () => { + expect(sortOptions.some((option) => option.title === 'Blocking')).toBe(containsBlocking); + }); + }, + ); +}); + +describe('getFilterTokens', () => { + it('returns filtered tokens given "window.location.search"', () => { + expect(getFilterTokens(locationSearch)).toEqual(filteredTokens); + }); + + it('returns filtered tokens given "window.location.search" with special values', () => { + expect(getFilterTokens(locationSearchWithSpecialValues)).toEqual( + filteredTokensWithSpecialValues, + ); + }); +}); + +describe('convertToParams', () => { + it('returns api params given filtered tokens', () => { + expect(convertToParams(filteredTokens, API_PARAM)).toEqual(apiParams); + }); + + it('returns api params given filtered tokens with special values', () => { + expect(convertToParams(filteredTokensWithSpecialValues, API_PARAM)).toEqual( + apiParamsWithSpecialValues, + ); + }); + + it('returns url params given filtered tokens', () => { + expect(convertToParams(filteredTokens, URL_PARAM)).toEqual(urlParams); + }); + + it('returns url params given filtered tokens with special values', () => { + expect(convertToParams(filteredTokensWithSpecialValues, URL_PARAM)).toEqual( + urlParamsWithSpecialValues, + ); + }); +}); + +describe('convertToSearchQuery', () => { + it('returns search string given filtered tokens', () => { + expect(convertToSearchQuery(filteredTokens)).toBe('find issues'); + }); +}); |