diff options
Diffstat (limited to 'spec/frontend/issues')
-rw-r--r-- | spec/frontend/issues/issue_spec.js | 13 | ||||
-rw-r--r-- | spec/frontend/issues/list/components/issues_list_app_spec.js | 175 | ||||
-rw-r--r-- | spec/frontend/issues/list/mock_data.js | 14 | ||||
-rw-r--r-- | spec/frontend/issues/list/utils_spec.js | 29 | ||||
-rw-r--r-- | spec/frontend/issues/show/components/app_spec.js | 53 | ||||
-rw-r--r-- | spec/frontend/issues/show/components/description_spec.js | 91 | ||||
-rw-r--r-- | spec/frontend/issues/show/components/fields/description_spec.js | 1 | ||||
-rw-r--r-- | spec/frontend/issues/show/components/title_spec.js | 7 | ||||
-rw-r--r-- | spec/frontend/issues/show/mock_data/mock_data.js | 17 | ||||
-rw-r--r-- | spec/frontend/issues/show/utils_spec.js | 40 |
10 files changed, 329 insertions, 111 deletions
diff --git a/spec/frontend/issues/issue_spec.js b/spec/frontend/issues/issue_spec.js index 8a089b372ff..089ea8dbbad 100644 --- a/spec/frontend/issues/issue_spec.js +++ b/spec/frontend/issues/issue_spec.js @@ -1,5 +1,6 @@ import { getByText } from '@testing-library/dom'; import MockAdapter from 'axios-mock-adapter'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import Issue from '~/issues/issue'; import axios from '~/lib/utils/axios_utils'; @@ -24,11 +25,11 @@ describe('Issue', () => { const getIssueCounter = () => document.querySelector('.issue_counter'); const getOpenStatusBox = () => getByText(document, (_, el) => el.textContent.match(/Open/), { - selector: '.status-box-open', + selector: '.issuable-status-badge-open', }); const getClosedStatusBox = () => getByText(document, (_, el) => el.textContent.match(/Closed/), { - selector: '.status-box-issue-closed', + selector: '.issuable-status-badge-closed', }); describe.each` @@ -38,9 +39,9 @@ describe('Issue', () => { `('$desc', ({ isIssueInitiallyOpen, expectedCounterText }) => { beforeEach(() => { if (isIssueInitiallyOpen) { - loadFixtures('issues/open-issue.html'); + loadHTMLFixture('issues/open-issue.html'); } else { - loadFixtures('issues/closed-issue.html'); + loadHTMLFixture('issues/closed-issue.html'); } testContext.issueCounter = getIssueCounter(); @@ -50,6 +51,10 @@ describe('Issue', () => { testContext.issueCounter.textContent = '1,001'; }); + afterEach(() => { + resetHTMLFixture(); + }); + it(`has the proper visible status box when ${isIssueInitiallyOpen ? 'open' : 'closed'}`, () => { if (isIssueInitiallyOpen) { expect(testContext.statusBoxClosed).toHaveClass('hidden'); 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 5a9bd1ff8e4..d92ba527b5c 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -5,8 +5,11 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import { cloneDeep } from 'lodash'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; +import getIssuesWithoutCrmQuery from 'ee_else_ce/issues/list/queries/get_issues_without_crm.query.graphql'; +import getIssuesCountsWithoutCrmQuery from 'ee_else_ce/issues/list/queries/get_issues_counts_without_crm.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; @@ -58,6 +61,7 @@ describe('CE IssuesListApp component', () => { let wrapper; Vue.use(VueApollo); + Vue.use(VueRouter); const defaultProvide = { autocompleteAwardEmojisPath: 'autocomplete/award/emojis/path', @@ -78,6 +82,7 @@ describe('CE IssuesListApp component', () => { isAnonymousSearchDisabled: false, isIssueRepositioningDisabled: false, isProject: true, + isPublicVisibilityRestricted: false, isSignedIn: true, jiraIntegrationPath: 'jira/integration/path', newIssuePath: 'new/issue/path', @@ -107,6 +112,7 @@ describe('CE IssuesListApp component', () => { const mountComponent = ({ provide = {}, + data = {}, issuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse), issuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse), sortPreferenceMutationResponse = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse), @@ -115,16 +121,21 @@ describe('CE IssuesListApp component', () => { const requestHandlers = [ [getIssuesQuery, issuesQueryResponse], [getIssuesCountsQuery, issuesCountsQueryResponse], + [getIssuesWithoutCrmQuery, issuesQueryResponse], + [getIssuesCountsWithoutCrmQuery, issuesCountsQueryResponse], [setSortPreferenceMutation, sortPreferenceMutationResponse], ]; - const apolloProvider = createMockApollo(requestHandlers); return mountFn(IssuesListApp, { - apolloProvider, + apolloProvider: createMockApollo(requestHandlers), + router: new VueRouter({ mode: 'history' }), provide: { ...defaultProvide, ...provide, }, + data() { + return data; + }, }); }; @@ -139,10 +150,10 @@ describe('CE IssuesListApp component', () => { }); describe('IssuableList', () => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent(); jest.runOnlyPendingTimers(); - await waitForPromises(); + return waitForPromises(); }); it('renders', () => { @@ -167,10 +178,6 @@ describe('CE IssuesListApp component', () => { useKeysetPagination: true, hasPreviousPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasPreviousPage, hasNextPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasNextPage, - urlParams: { - sort: urlSortParams[CREATED_DESC], - state: IssuableStates.Opened, - }, }); }); }); @@ -200,7 +207,7 @@ describe('CE IssuesListApp component', () => { describe('csv import/export component', () => { describe('when user is signed in', () => { - beforeEach(async () => { + beforeEach(() => { setWindowLocation('?search=refactor&state=opened'); wrapper = mountComponent({ @@ -209,12 +216,12 @@ describe('CE IssuesListApp component', () => { }); jest.runOnlyPendingTimers(); - await waitForPromises(); + return waitForPromises(); }); it('renders', () => { expect(findCsvImportExportButtons().props()).toMatchObject({ - exportCsvPath: `${defaultProvide.exportCsvPath}?search=refactor&sort=created_date&state=opened`, + exportCsvPath: `${defaultProvide.exportCsvPath}?search=refactor&state=opened`, issuableCount: 1, }); }); @@ -252,11 +259,9 @@ describe('CE IssuesListApp component', () => { 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'); @@ -297,32 +302,25 @@ describe('CE IssuesListApp component', () => { describe('page', () => { it('page_after is set from the url params', () => { setWindowLocation('?page_after=randomCursorString'); - wrapper = mountComponent(); - expect(findIssuableList().props('urlParams')).toMatchObject({ - page_after: 'randomCursorString', - }); + expect(wrapper.vm.$route.query).toMatchObject({ page_after: 'randomCursorString' }); }); it('page_before is set from the url params', () => { setWindowLocation('?page_before=anotherRandomCursorString'); - wrapper = mountComponent(); - expect(findIssuableList().props('urlParams')).toMatchObject({ - page_before: 'anotherRandomCursorString', - }); + expect(wrapper.vm.$route.query).toMatchObject({ page_before: 'anotherRandomCursorString' }); }); }); describe('search', () => { it('is set from the url params', () => { setWindowLocation(locationSearch); - wrapper = mountComponent(); - expect(findIssuableList().props('urlParams')).toMatchObject({ search: 'find issues' }); + expect(wrapper.vm.$route.query).toMatchObject({ search: 'find issues' }); }); }); @@ -333,10 +331,7 @@ describe('CE IssuesListApp component', () => { it.each(oldEnumSortValues)('initial sort is set with value %s', (sort) => { wrapper = mountComponent({ provide: { initialSort: sort } }); - expect(findIssuableList().props()).toMatchObject({ - initialSortBy: getSortKey(sort), - urlParams: { sort }, - }); + expect(findIssuableList().props('initialSortBy')).toBe(getSortKey(sort)); }); }); @@ -346,10 +341,7 @@ describe('CE IssuesListApp component', () => { it.each(graphQLEnumSortValues)('initial sort is set with value %s', (sort) => { wrapper = mountComponent({ provide: { initialSort: sort.toLowerCase() } }); - expect(findIssuableList().props()).toMatchObject({ - initialSortBy: sort, - urlParams: { sort: urlSortParams[sort] }, - }); + expect(findIssuableList().props('initialSortBy')).toBe(sort); }); }); @@ -359,10 +351,7 @@ describe('CE IssuesListApp component', () => { (sort) => { wrapper = mountComponent({ provide: { initialSort: sort } }); - expect(findIssuableList().props()).toMatchObject({ - initialSortBy: CREATED_DESC, - urlParams: { sort: urlSortParams[CREATED_DESC] }, - }); + expect(findIssuableList().props('initialSortBy')).toBe(CREATED_DESC); }, ); }); @@ -375,10 +364,7 @@ describe('CE IssuesListApp component', () => { }); it('changes the sort to the default of created descending', () => { - expect(findIssuableList().props()).toMatchObject({ - initialSortBy: CREATED_DESC, - urlParams: { sort: urlSortParams[CREATED_DESC] }, - }); + expect(findIssuableList().props('initialSortBy')).toBe(CREATED_DESC); }); it('shows an alert to tell the user that manual reordering is disabled', () => { @@ -393,9 +379,7 @@ describe('CE IssuesListApp component', () => { describe('state', () => { it('is set from the url params', () => { const initialState = IssuableStates.All; - setWindowLocation(`?state=${initialState}`); - wrapper = mountComponent(); expect(findIssuableList().props('currentTab')).toBe(initialState); @@ -405,7 +389,6 @@ describe('CE IssuesListApp component', () => { describe('filter tokens', () => { it('is set from the url params', () => { setWindowLocation(locationSearch); - wrapper = mountComponent(); expect(findIssuableList().props('initialFilterValue')).toEqual(filteredTokens); @@ -414,7 +397,6 @@ describe('CE IssuesListApp component', () => { describe('when anonymous searching is performed', () => { beforeEach(() => { setWindowLocation(locationSearch); - wrapper = mountComponent({ provide: { isAnonymousSearchDisabled: true, isSignedIn: false }, }); @@ -649,12 +631,12 @@ describe('CE IssuesListApp component', () => { ${'fetching issues'} | ${'issuesQueryResponse'} | ${IssuesListApp.i18n.errorFetchingIssues} ${'fetching issue counts'} | ${'issuesCountsQueryResponse'} | ${IssuesListApp.i18n.errorFetchingCounts} `('when there is an error $error', ({ mountOption, message }) => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent({ [mountOption]: jest.fn().mockRejectedValue(new Error('ERROR')), }); jest.runOnlyPendingTimers(); - await waitForPromises(); + return waitForPromises(); }); it('shows an error message', () => { @@ -676,29 +658,51 @@ describe('CE IssuesListApp component', () => { describe('when "click-tab" event is emitted by IssuableList', () => { beforeEach(() => { wrapper = mountComponent(); + jest.spyOn(wrapper.vm.$router, 'push'); findIssuableList().vm.$emit('click-tab', IssuableStates.Closed); }); - it('updates to the new tab', () => { + it('updates ui to the new tab', () => { expect(findIssuableList().props('currentTab')).toBe(IssuableStates.Closed); }); - }); - describe.each(['next-page', 'previous-page'])( - 'when "%s" event is emitted by IssuableList', - (event) => { - beforeEach(() => { - wrapper = mountComponent(); + it('updates url to the new tab', () => { + expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + query: expect.objectContaining({ state: IssuableStates.Closed }), + }); + }); + }); - findIssuableList().vm.$emit(event); + describe.each` + event | paramName | paramValue + ${'next-page'} | ${'page_after'} | ${'endCursor'} + ${'previous-page'} | ${'page_before'} | ${'startCursor'} + `('when "$event" event is emitted by IssuableList', ({ event, paramName, paramValue }) => { + beforeEach(() => { + wrapper = mountComponent({ + data: { + pageInfo: { + endCursor: 'endCursor', + startCursor: 'startCursor', + }, + }, }); + jest.spyOn(wrapper.vm.$router, 'push'); + + findIssuableList().vm.$emit(event); + }); + + it('scrolls to the top', () => { + expect(scrollUp).toHaveBeenCalled(); + }); - it('scrolls to the top', () => { - expect(scrollUp).toHaveBeenCalled(); + it(`updates url with "${paramName}" param`, () => { + expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + query: expect.objectContaining({ [paramName]: paramValue }), }); - }, - ); + }); + }); describe('when "reorder" event is emitted by IssuableList', () => { const issueOne = { @@ -752,18 +756,17 @@ describe('CE IssuesListApp component', () => { `( 'when moving issue $description', ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent({ provide: { isProject }, issuesQueryResponse: jest.fn().mockResolvedValue(response(isProject)), }); jest.runOnlyPendingTimers(); - await waitForPromises(); + return waitForPromises(); }); it('makes API call to reorder the issue', async () => { findIssuableList().vm.$emit('reorder', { oldIndex, newIndex }); - await waitForPromises(); expect(axiosMock.history.put[0]).toMatchObject({ @@ -780,19 +783,18 @@ describe('CE IssuesListApp component', () => { }); describe('when unsuccessful', () => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent({ issuesQueryResponse: jest.fn().mockResolvedValue(response()), }); jest.runOnlyPendingTimers(); - await waitForPromises(); + return waitForPromises(); }); it('displays an error message', async () => { axiosMock.onPut(joinPaths(issueOne.webPath, 'reorder')).reply(500); findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 }); - await waitForPromises(); expect(findIssuableList().props('error')).toBe(IssuesListApp.i18n.reorderError); @@ -808,14 +810,14 @@ describe('CE IssuesListApp component', () => { 'updates to the new sort when payload is `%s`', async (sortKey) => { wrapper = mountComponent(); + jest.spyOn(wrapper.vm.$router, 'push'); findIssuableList().vm.$emit('sort', sortKey); - jest.runOnlyPendingTimers(); await nextTick(); - expect(findIssuableList().props('urlParams')).toMatchObject({ - sort: urlSortParams[sortKey], + expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + query: expect.objectContaining({ sort: urlSortParams[sortKey] }), }); }, ); @@ -827,14 +829,13 @@ describe('CE IssuesListApp component', () => { wrapper = mountComponent({ provide: { initialSort, isIssueRepositioningDisabled: true }, }); + jest.spyOn(wrapper.vm.$router, 'push'); findIssuableList().vm.$emit('sort', RELATIVE_POSITION_ASC); }); it('does not update the sort to manual', () => { - expect(findIssuableList().props('urlParams')).toMatchObject({ - sort: urlSortParams[initialSort], - }); + expect(wrapper.vm.$router.push).not.toHaveBeenCalled(); }); it('shows an alert to tell the user that manual reordering is disabled', () => { @@ -899,11 +900,14 @@ describe('CE IssuesListApp component', () => { describe('when "filter" event is emitted by IssuableList', () => { it('updates IssuableList with url params', async () => { wrapper = mountComponent(); + jest.spyOn(wrapper.vm.$router, 'push'); findIssuableList().vm.$emit('filter', filteredTokens); await nextTick(); - expect(findIssuableList().props('urlParams')).toMatchObject(urlParams); + expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + query: expect.objectContaining(urlParams), + }); }); describe('when anonymous searching is performed', () => { @@ -911,19 +915,13 @@ describe('CE IssuesListApp component', () => { wrapper = mountComponent({ provide: { isAnonymousSearchDisabled: true, isSignedIn: false }, }); + jest.spyOn(wrapper.vm.$router, 'push'); findIssuableList().vm.$emit('filter', filteredTokens); }); - it('does not update IssuableList with url params ', async () => { - const defaultParams = { - page_after: null, - page_before: null, - sort: 'created_date', - state: 'opened', - }; - - expect(findIssuableList().props('urlParams')).toEqual(defaultParams); + it('does not update url params', () => { + expect(wrapper.vm.$router.push).not.toHaveBeenCalled(); }); it('shows an alert to tell the user they must be signed in to search', () => { @@ -935,4 +933,23 @@ describe('CE IssuesListApp component', () => { }); }); }); + + describe('public visibility', () => { + it.each` + description | isPublicVisibilityRestricted | isSignedIn | hideUsers + ${'shows users when public visibility is not restricted and is not signed in'} | ${false} | ${false} | ${false} + ${'shows users when public visibility is not restricted and is signed in'} | ${false} | ${true} | ${false} + ${'hides users when public visibility is restricted and is not signed in'} | ${true} | ${false} | ${true} + ${'shows users when public visibility is restricted and is signed in'} | ${true} | ${true} | ${false} + `('$description', ({ isPublicVisibilityRestricted, isSignedIn, hideUsers }) => { + const mockQuery = jest.fn().mockResolvedValue(defaultQueryResponse); + wrapper = mountComponent({ + provide: { isPublicVisibilityRestricted, isSignedIn }, + issuesQueryResponse: mockQuery, + }); + jest.runOnlyPendingTimers(); + + expect(mockQuery).toHaveBeenCalledWith(expect.objectContaining({ hideUsers })); + }); + }); }); diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js index b1a135ceb18..42f2d08082e 100644 --- a/spec/frontend/issues/list/mock_data.js +++ b/spec/frontend/issues/list/mock_data.js @@ -117,6 +117,7 @@ export const locationSearch = [ 'not[author_username]=marge', 'assignee_username[]=bart', 'assignee_username[]=lisa', + 'assignee_username[]=5', 'not[assignee_username][]=patty', 'not[assignee_username][]=selma', 'milestone_title=season+3', @@ -146,6 +147,8 @@ export const locationSearch = [ 'not[epic_id]=34', 'weight=1', 'not[weight]=3', + 'crm_contact_id=123', + 'crm_organization_id=456', ].join('&'); export const locationSearchWithSpecialValues = [ @@ -165,6 +168,7 @@ export const filteredTokens = [ { 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: '5', 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 3', operator: OPERATOR_IS } }, @@ -194,6 +198,8 @@ export const filteredTokens = [ { 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: 'crm_contact', value: { data: '123', operator: OPERATOR_IS } }, + { type: 'crm_organization', value: { data: '456', operator: OPERATOR_IS } }, { type: 'filtered-search-term', value: { data: 'find' } }, { type: 'filtered-search-term', value: { data: 'issues' } }, ]; @@ -212,7 +218,7 @@ export const filteredTokensWithSpecialValues = [ export const apiParams = { authorUsername: 'homer', - assigneeUsernames: ['bart', 'lisa'], + assigneeUsernames: ['bart', 'lisa', '5'], milestoneTitle: ['season 3', 'season 4'], labelName: ['cartoon', 'tv'], releaseTag: ['v3', 'v4'], @@ -222,6 +228,8 @@ export const apiParams = { iterationId: ['4', '12'], epicId: '12', weight: '1', + crmContactId: '123', + crmOrganizationId: '456', not: { authorUsername: 'marge', assigneeUsernames: ['patty', 'selma'], @@ -251,7 +259,7 @@ export const apiParamsWithSpecialValues = { export const urlParams = { author_username: 'homer', 'not[author_username]': 'marge', - 'assignee_username[]': ['bart', 'lisa'], + 'assignee_username[]': ['bart', 'lisa', '5'], 'not[assignee_username][]': ['patty', 'selma'], milestone_title: ['season 3', 'season 4'], 'not[milestone_title]': ['season 20', 'season 30'], @@ -270,6 +278,8 @@ export const urlParams = { 'not[epic_id]': '34', weight: '1', 'not[weight]': '3', + crm_contact_id: '123', + crm_organization_id: '456', }; export const urlParamsWithSpecialValues = { diff --git a/spec/frontend/issues/list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js index a60350d91c5..ce0477883d7 100644 --- a/spec/frontend/issues/list/utils_spec.js +++ b/spec/frontend/issues/list/utils_spec.js @@ -1,3 +1,5 @@ +import setWindowLocation from 'helpers/set_window_location_helper'; +import { TEST_HOST } from 'helpers/test_constants'; import { apiParams, apiParamsWithSpecialValues, @@ -24,6 +26,7 @@ import { getSortOptions, isSortKey, } from '~/issues/list/utils'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; describe('getInitialPageParams', () => { it.each(Object.keys(urlSortParams))( @@ -124,24 +127,50 @@ describe('getFilterTokens', () => { filteredTokensWithSpecialValues, ); }); + + it.each` + description | argument + ${'an undefined value'} | ${undefined} + ${'an irrelevant value'} | ${'?unrecognised=parameter'} + `('returns an empty filtered search term given $description', ({ argument }) => { + expect(getFilterTokens(argument)).toEqual([ + { + id: expect.any(String), + type: FILTERED_SEARCH_TERM, + value: { data: '' }, + }, + ]); + }); }); describe('convertToApiParams', () => { + beforeEach(() => { + setWindowLocation(TEST_HOST); + }); + it('returns api params given filtered tokens', () => { expect(convertToApiParams(filteredTokens)).toEqual(apiParams); }); it('returns api params given filtered tokens with special values', () => { + setWindowLocation('?assignee_id=123'); + expect(convertToApiParams(filteredTokensWithSpecialValues)).toEqual(apiParamsWithSpecialValues); }); }); describe('convertToUrlParams', () => { + beforeEach(() => { + setWindowLocation(TEST_HOST); + }); + it('returns url params given filtered tokens', () => { expect(convertToUrlParams(filteredTokens)).toEqual(urlParams); }); it('returns url params given filtered tokens with special values', () => { + setWindowLocation('?assignee_id=123'); + expect(convertToUrlParams(filteredTokensWithSpecialValues)).toEqual(urlParamsWithSpecialValues); }); }); diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js index 5ab64d8e9ca..27604b8ccf3 100644 --- a/spec/frontend/issues/show/components/app_spec.js +++ b/spec/frontend/issues/show/components/app_spec.js @@ -1,10 +1,12 @@ -import { GlIntersectionObserver } from '@gitlab/ui'; +import { GlIcon, GlIntersectionObserver } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import '~/behaviors/markdown/render_gfm'; -import { IssuableStatus, IssuableStatusText } from '~/issues/constants'; +import { IssuableStatus, IssuableStatusText, IssuableType } from '~/issues/constants'; import IssuableApp from '~/issues/show/components/app.vue'; import DescriptionComponent from '~/issues/show/components/description.vue'; import EditedComponent from '~/issues/show/components/edited.vue'; @@ -70,7 +72,7 @@ describe('Issuable output', () => { }; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div> <title>Title</title> <div class="detail-page-description content-block"> @@ -105,6 +107,7 @@ describe('Issuable output', () => { realtimeRequestCount = 0; wrapper.vm.poll.stop(); wrapper.destroy(); + resetHTMLFixture(); }); it('should render a title/description/edited and update title/description/edited on update', () => { @@ -465,6 +468,31 @@ describe('Issuable output', () => { expect(findStickyHeader().text()).toContain('Sticky header title'); }); + it('shows with title for an epic', async () => { + wrapper.setProps({ issuableType: 'epic' }); + + await nextTick(); + + expect(findStickyHeader().text()).toContain('Sticky header title'); + }); + + it.each` + issuableType | issuableStatus | statusIcon + ${IssuableType.Issue} | ${IssuableStatus.Open} | ${'issues'} + ${IssuableType.Issue} | ${IssuableStatus.Closed} | ${'issue-closed'} + ${IssuableType.Epic} | ${IssuableStatus.Open} | ${'epic'} + ${IssuableType.Epic} | ${IssuableStatus.Closed} | ${'epic-closed'} + `( + 'shows with state icon "$statusIcon" for $issuableType when status is $issuableStatus', + async ({ issuableType, issuableStatus, statusIcon }) => { + wrapper.setProps({ issuableType, issuableStatus }); + + await nextTick(); + + expect(findStickyHeader().findComponent(GlIcon).props('name')).toBe(statusIcon); + }, + ); + it.each` title | state ${'shows with Open when status is opened'} | ${IssuableStatus.Open} @@ -487,7 +515,14 @@ describe('Issuable output', () => { await nextTick(); - expect(findConfidentialBadge().exists()).toBe(isConfidential); + const confidentialEl = findConfidentialBadge(); + expect(confidentialEl.exists()).toBe(isConfidential); + if (isConfidential) { + expect(confidentialEl.props()).toMatchObject({ + workspaceType: 'project', + issuableType: 'issue', + }); + } }); it.each` @@ -613,4 +648,14 @@ describe('Issuable output', () => { expect(wrapper.vm.updateStoreState).toHaveBeenCalled(); }); }); + + describe('listItemReorder event', () => { + it('makes request to update issue', async () => { + const description = 'I have been updated!'; + findDescription().vm.$emit('listItemReorder', description); + await waitForPromises(); + + expect(mock.history.put[0].data).toContain(description); + }); + }); }); diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js index 0b3daadae1d..1ae04531a6b 100644 --- a/spec/frontend/issues/show/components/description_spec.js +++ b/spec/frontend/issues/show/components/description_spec.js @@ -1,14 +1,20 @@ import $ from 'jquery'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import '~/behaviors/markdown/render_gfm'; import { GlTooltip, GlModal } from '@gitlab/ui'; + import setWindowLocation from 'helpers/set_window_location_helper'; import { stubComponent } from 'helpers/stub_component'; import { TEST_HOST } from 'helpers/test_constants'; import { mockTracking } from 'helpers/tracking_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + import Description from '~/issues/show/components/description.vue'; import { updateHistory } from '~/lib/utils/url_utility'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import TaskList from '~/task_list'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; @@ -27,17 +33,29 @@ jest.mock('~/task_list'); const showModal = jest.fn(); const hideModal = jest.fn(); +const showDetailsModal = jest.fn(); const $toast = { show: jest.fn(), }; +const workItemQueryResponse = { + data: { + workItem: null, + }, +}; + +const queryHandler = jest.fn().mockResolvedValue(workItemQueryResponse); + describe('Description component', () => { let wrapper; + Vue.use(VueApollo); + const findGfmContent = () => wrapper.find('[data-testid="gfm-content"]'); const findTextarea = () => wrapper.find('[data-testid="textarea"]'); const findTaskActionButtons = () => wrapper.findAll('.js-add-task'); const findConvertToTaskButton = () => wrapper.find('.js-add-task'); + const findTaskLink = () => wrapper.find('a.gfm-issue'); const findTooltips = () => wrapper.findAllComponents(GlTooltip); const findModal = () => wrapper.findComponent(GlModal); @@ -52,6 +70,7 @@ describe('Description component', () => { ...props, }, provide, + apolloProvider: createMockApollo([[workItemQuery, queryHandler]]), mocks: { $toast, }, @@ -62,6 +81,11 @@ describe('Description component', () => { hide: hideModal, }, }), + WorkItemDetailModal: stubComponent(WorkItemDetailModal, { + methods: { + show: showDetailsModal, + }, + }), }, }); } @@ -296,15 +320,15 @@ describe('Description component', () => { }); it('shows toast after delete success', async () => { - findWorkItemDetailModal().vm.$emit('workItemDeleted'); + const newDesc = 'description'; + findWorkItemDetailModal().vm.$emit('workItemDeleted', newDesc); + expect(wrapper.emitted('updateDescription')).toEqual([[newDesc]]); expect($toast.show).toHaveBeenCalledWith('Work item deleted'); }); }); describe('work items detail', () => { - const findTaskLink = () => wrapper.find('a.gfm-issue'); - describe('when opening and closing', () => { beforeEach(() => { createComponent({ @@ -319,11 +343,9 @@ describe('Description component', () => { }); it('opens when task button is clicked', async () => { - expect(findWorkItemDetailModal().props('visible')).toBe(false); - await findTaskLink().trigger('click'); - expect(findWorkItemDetailModal().props('visible')).toBe(true); + expect(showDetailsModal).toHaveBeenCalled(); expect(updateHistory).toHaveBeenCalledWith({ url: `${TEST_HOST}/?work_item_id=2`, replace: true, @@ -333,12 +355,9 @@ describe('Description component', () => { it('closes from an open state', async () => { await findTaskLink().trigger('click'); - expect(findWorkItemDetailModal().props('visible')).toBe(true); - findWorkItemDetailModal().vm.$emit('close'); await nextTick(); - expect(findWorkItemDetailModal().props('visible')).toBe(false); expect(updateHistory).toHaveBeenLastCalledWith({ url: `${TEST_HOST}/`, replace: true, @@ -364,16 +383,17 @@ describe('Description component', () => { describe('when url query `work_item_id` exists', () => { it.each` - behavior | workItemId | visible - ${'opens'} | ${'123'} | ${true} - ${'does not open'} | ${'123e'} | ${false} - ${'does not open'} | ${'12e3'} | ${false} - ${'does not open'} | ${'1e23'} | ${false} - ${'does not open'} | ${'x'} | ${false} - ${'does not open'} | ${'undefined'} | ${false} + behavior | workItemId | modalOpened + ${'opens'} | ${'2'} | ${1} + ${'does not open'} | ${'123'} | ${0} + ${'does not open'} | ${'123e'} | ${0} + ${'does not open'} | ${'12e3'} | ${0} + ${'does not open'} | ${'1e23'} | ${0} + ${'does not open'} | ${'x'} | ${0} + ${'does not open'} | ${'undefined'} | ${0} `( '$behavior when url contains `work_item_id=$workItemId`', - async ({ workItemId, visible }) => { + async ({ workItemId, modalOpened }) => { setWindowLocation(`?work_item_id=${workItemId}`); createComponent({ @@ -381,10 +401,43 @@ describe('Description component', () => { provide: { glFeatures: { workItems: true } }, }); - expect(findWorkItemDetailModal().props('visible')).toBe(visible); + expect(showDetailsModal).toHaveBeenCalledTimes(modalOpened); }, ); }); }); + + describe('when hovering task links', () => { + beforeEach(() => { + createComponent({ + props: { + descriptionHtml: descriptionHtmlWithTask, + }, + provide: { + glFeatures: { workItems: true }, + }, + }); + return nextTick(); + }); + + it('prefetches work item detail after work item link is hovered for 150ms', async () => { + await findTaskLink().trigger('mouseover'); + jest.advanceTimersByTime(150); + await waitForPromises(); + + expect(queryHandler).toHaveBeenCalledWith({ + id: 'gid://gitlab/WorkItem/2', + }); + }); + + it('does not work item detail after work item link is hovered for less than 150ms', async () => { + await findTaskLink().trigger('mouseover'); + await findTaskLink().trigger('mouseout'); + jest.advanceTimersByTime(150); + await waitForPromises(); + + expect(queryHandler).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/spec/frontend/issues/show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js index 0dcd70ac19b..d0e33f0b980 100644 --- a/spec/frontend/issues/show/components/fields/description_spec.js +++ b/spec/frontend/issues/show/components/fields/description_spec.js @@ -24,7 +24,6 @@ describe('Description field component', () => { beforeEach(() => { jest.spyOn(eventHub, '$emit'); - gon.features = { markdownContinueLists: true }; }); afterEach(() => { diff --git a/spec/frontend/issues/show/components/title_spec.js b/spec/frontend/issues/show/components/title_spec.js index 29b5353ef1c..7560b733ae6 100644 --- a/spec/frontend/issues/show/components/title_spec.js +++ b/spec/frontend/issues/show/components/title_spec.js @@ -1,4 +1,5 @@ import Vue, { nextTick } from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import titleComponent from '~/issues/show/components/title.vue'; import eventHub from '~/issues/show/event_hub'; import Store from '~/issues/show/stores'; @@ -6,7 +7,7 @@ import Store from '~/issues/show/stores'; describe('Title component', () => { let vm; beforeEach(() => { - setFixtures(`<title />`); + setHTMLFixture(`<title />`); const Component = Vue.extend(titleComponent); const store = new Store({ @@ -25,6 +26,10 @@ describe('Title component', () => { }).$mount(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('renders title HTML', () => { expect(vm.$el.querySelector('.title').innerHTML.trim()).toBe('Testing <img>'); }); diff --git a/spec/frontend/issues/show/mock_data/mock_data.js b/spec/frontend/issues/show/mock_data/mock_data.js index 7b0b8ca686a..909789b7a0f 100644 --- a/spec/frontend/issues/show/mock_data/mock_data.js +++ b/spec/frontend/issues/show/mock_data/mock_data.js @@ -77,7 +77,22 @@ export const descriptionHtmlWithTask = ` <ul data-sourcepos="1:1-3:7" class="task-list" dir="auto"> <li data-sourcepos="1:1-1:10" class="task-list-item"> <input type="checkbox" class="task-list-item-checkbox" disabled> - <a href="/gitlab-org/gitlab-test/-/issues/48" data-original="#48+" data-link="false" data-link-reference="false" data-project="1" data-issue="2" data-reference-format="+" data-reference-type="task" data-container="body" data-placement="top" title="1" class="gfm gfm-issue has-tooltip">1 (#48)</a> + <a href="/gitlab-org/gitlab-test/-/issues/48" data-original="#48+" data-link="false" data-link-reference="false" data-project="1" data-issue="2" data-reference-format="+" data-reference-type="task" data-container="body" data-placement="top" title="1" class="gfm gfm-issue has-tooltip" data-issue-type="task">1 (#48)</a> + </li> + <li data-sourcepos="2:1-2:7" class="task-list-item"> + <input type="checkbox" class="task-list-item-checkbox" disabled> 2 + </li> + <li data-sourcepos="3:1-3:7" class="task-list-item"> + <input type="checkbox" class="task-list-item-checkbox" disabled> 3 + </li> + </ul> +`; + +export const descriptionHtmlWithIssue = ` + <ul data-sourcepos="1:1-3:7" class="task-list" dir="auto"> + <li data-sourcepos="1:1-1:10" class="task-list-item"> + <input type="checkbox" class="task-list-item-checkbox" disabled> + <a href="/gitlab-org/gitlab-test/-/issues/48" data-original="#48+" data-link="false" data-link-reference="false" data-project="1" data-issue="2" data-reference-format="+" data-reference-type="task" data-container="body" data-placement="top" title="1" class="gfm gfm-issue has-tooltip" data-issue-type="issue">1 (#48)</a> </li> <li data-sourcepos="2:1-2:7" class="task-list-item"> <input type="checkbox" class="task-list-item-checkbox" disabled> 2 diff --git a/spec/frontend/issues/show/utils_spec.js b/spec/frontend/issues/show/utils_spec.js new file mode 100644 index 00000000000..e5f14cfc01a --- /dev/null +++ b/spec/frontend/issues/show/utils_spec.js @@ -0,0 +1,40 @@ +import { convertDescriptionWithNewSort } from '~/issues/show/utils'; + +describe('app/assets/javascripts/issues/show/utils.js', () => { + describe('convertDescriptionWithNewSort', () => { + it('converts markdown description with new list sort order', () => { + const description = `I am text + +- Item 1 +- Item 2 + - Item 3 + - Item 4 +- Item 5`; + + // Drag Item 2 + children to Item 1's position + const html = `<ul data-sourcepos="3:1-8:0"> + <li data-sourcepos="4:1-4:8"> + Item 2 + <ul data-sourcepos="5:1-6:10"> + <li data-sourcepos="5:1-5:10">Item 3</li> + <li data-sourcepos="6:1-6:10">Item 4</li> + </ul> + </li> + <li data-sourcepos="3:1-3:8">Item 1</li> + <li data-sourcepos="7:1-8:0">Item 5</li> + <ul>`; + const list = document.createElement('div'); + list.innerHTML = html; + + const expected = `I am text + +- Item 2 + - Item 3 + - Item 4 +- Item 1 +- Item 5`; + + expect(convertDescriptionWithNewSort(description, list.firstChild)).toBe(expected); + }); + }); +}); |