diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-07-20 15:26:25 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-07-20 15:26:25 +0300 |
commit | a09983ae35713f5a2bbb100981116d31ce99826e (patch) | |
tree | 2ee2af7bd104d57086db360a7e6d8c9d5d43667a /spec/frontend/vue_shared | |
parent | 18c5ab32b738c0b6ecb4d0df3994000482f34bd8 (diff) |
Add latest changes from gitlab-org/gitlab@13-2-stable-ee
Diffstat (limited to 'spec/frontend/vue_shared')
43 files changed, 1668 insertions, 376 deletions
diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap index df4b30f1cb8..19671d425a9 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap @@ -18,15 +18,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <gl-emoji - data-fallback-src="/assets/emoji/thumbsup-59ec2457ab33e8897261d01a495f6cf5c668d0004807dc541c3b1be5294b1e61.png" data-name="thumbsup" - data-unicode-version="6.0" - title="thumbs up sign" - > - - 👍 - - </gl-emoji> + /> </span> @@ -51,15 +44,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <gl-emoji - data-fallback-src="/assets/emoji/thumbsdown-5954334e2dae5357312b3d629f10a496c728029e02216f8c8b887f9b51561c61.png" data-name="thumbsdown" - data-unicode-version="6.0" - title="thumbs down sign" - > - - 👎 - - </gl-emoji> + /> </span> @@ -84,15 +70,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <gl-emoji - data-fallback-src="/assets/emoji/smile-14905c372d5bf7719bd727c9efae31a03291acec79801652a23710c6848c5d14.png" data-name="smile" - data-unicode-version="6.0" - title="smiling face with open mouth and smiling eyes" - > - - 😄 - - </gl-emoji> + /> </span> @@ -117,15 +96,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <gl-emoji - data-fallback-src="/assets/emoji/ok_hand-d63002dce3cc3655b67b8765b7c28d370edba0e3758b2329b60e0e61c4d8e78d.png" data-name="ok_hand" - data-unicode-version="6.0" - title="ok hand sign" - > - - 👌 - - </gl-emoji> + /> </span> @@ -150,15 +122,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <gl-emoji - data-fallback-src="/assets/emoji/cactus-2c5c4c35f26c7046fdc002b337e0d939729b33a26980e675950f9934c91e40fd.png" data-name="cactus" - data-unicode-version="6.0" - title="cactus" - > - - 🌵 - - </gl-emoji> + /> </span> @@ -183,15 +148,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <gl-emoji - data-fallback-src="/assets/emoji/a-bddbb39e8a1d35d42b7c08e7d47f63988cb4d8614b79f74e70b9c67c221896cc.png" data-name="a" - data-unicode-version="6.0" - title="negative squared latin capital letter a" - > - - 🅰 - - </gl-emoji> + /> </span> @@ -216,15 +174,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <gl-emoji - data-fallback-src="/assets/emoji/b-722f9db9442e7c0fc0d0ac0f5291fbf47c6a0ac4d8abd42e97957da705fb82bf.png" data-name="b" - data-unicode-version="6.0" - title="negative squared latin capital letter b" - > - - 🅱 - - </gl-emoji> + /> </span> diff --git a/spec/frontend/vue_shared/components/file_icon_spec.js b/spec/frontend/vue_shared/components/file_icon_spec.js index 5a385eee60c..adf0da21f9f 100644 --- a/spec/frontend/vue_shared/components/file_icon_spec.js +++ b/spec/frontend/vue_shared/components/file_icon_spec.js @@ -1,12 +1,14 @@ import { shallowMount } from '@vue/test-utils'; import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import FileIcon from '~/vue_shared/components/file_icon.vue'; +import { FILE_SYMLINK_MODE } from '~/vue_shared/constants'; describe('File Icon component', () => { let wrapper; - const findIcon = () => wrapper.find('svg'); + const findSvgIcon = () => wrapper.find('svg'); + const findGlIcon = () => wrapper.find(GlIcon); const getIconName = () => - findIcon() + findSvgIcon() .find('use') .element.getAttribute('xlink:href') .replace(`${gon.sprite_file_icons}#`, ''); @@ -27,7 +29,7 @@ describe('File Icon component', () => { }); expect(wrapper.element.tagName).toEqual('SPAN'); - expect(findIcon().exists()).toBeDefined(); + expect(findSvgIcon().exists()).toBeDefined(); }); it.each` @@ -46,8 +48,8 @@ describe('File Icon component', () => { folder: true, }); - expect(findIcon().exists()).toBe(false); - expect(wrapper.find(GlIcon).classes()).toContain('folder-icon'); + expect(findSvgIcon().exists()).toBe(false); + expect(findGlIcon().classes()).toContain('folder-icon'); }); it('should render a loading icon', () => { @@ -66,8 +68,19 @@ describe('File Icon component', () => { cssClasses: 'extraclasses', size, }); + const classes = findSvgIcon().classes(); - expect(findIcon().classes()).toContain(`s${size}`); - expect(findIcon().classes()).toContain('extraclasses'); + expect(classes).toContain(`s${size}`); + expect(classes).toContain('extraclasses'); + }); + + it('should render a symlink icon', () => { + createComponent({ + fileName: 'anything', + fileMode: FILE_SYMLINK_MODE, + }); + + expect(findSvgIcon().exists()).toBe(false); + expect(findGlIcon().attributes('name')).toBe('symlink'); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index eded5b87abc..05508d14209 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -13,7 +13,7 @@ import { SortDirection } from '~/vue_shared/components/filtered_search_bar/const import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; -import { mockAvailableTokens, mockSortOptions } from './mock_data'; +import { mockAvailableTokens, mockSortOptions, mockHistoryItems } from './mock_data'; const createComponent = ({ namespace = 'gitlab-org/gitlab-test', @@ -53,11 +53,17 @@ describe('FilteredSearchBarRoot', () => { describe('computed', () => { describe('tokenSymbols', () => { - it('returns array of map containing type and symbols from `tokens` prop', () => { + it('returns a map containing type and symbols from `tokens` prop', () => { expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@' }); }); }); + describe('tokenTitles', () => { + it('returns a map containing type and title from `tokens` prop', () => { + expect(wrapper.vm.tokenTitles).toEqual({ author_username: 'Author' }); + }); + }); + describe('sortDirectionIcon', () => { it('returns string "sort-lowest" when `selectedSortDirection` is "ascending"', () => { wrapper.setData({ @@ -133,14 +139,6 @@ describe('FilteredSearchBarRoot', () => { }); }); - describe('getRecentSearches', () => { - it('returns array of strings representing recent searches', () => { - wrapper.vm.recentSearchesStore.setRecentSearches(['foo']); - - expect(wrapper.vm.getRecentSearches()).toEqual(['foo']); - }); - }); - describe('handleSortOptionClick', () => { it('emits component event `onSort` with selected sort by value', () => { wrapper.vm.handleSortOptionClick(mockSortOptions[1]); @@ -172,6 +170,27 @@ describe('FilteredSearchBarRoot', () => { }); }); + describe('handleHistoryItemSelected', () => { + it('emits `onFilter` event with provided filters param', () => { + wrapper.vm.handleHistoryItemSelected(mockHistoryItems[0]); + + expect(wrapper.emitted('onFilter')[0]).toEqual([mockHistoryItems[0]]); + }); + }); + + describe('handleClearHistory', () => { + it('clears search history from recent searches store', () => { + jest.spyOn(wrapper.vm.recentSearchesStore, 'setRecentSearches').mockReturnValue([]); + jest.spyOn(wrapper.vm.recentSearchesService, 'save'); + + wrapper.vm.handleClearHistory(); + + expect(wrapper.vm.recentSearchesStore.setRecentSearches).toHaveBeenCalledWith([]); + expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([]); + expect(wrapper.vm.recentSearches).toEqual([]); + }); + }); + describe('handleFilterSubmit', () => { const mockFilters = [ { @@ -186,14 +205,11 @@ describe('FilteredSearchBarRoot', () => { it('calls `recentSearchesStore.addRecentSearch` with serialized value of provided `filters` param', () => { jest.spyOn(wrapper.vm.recentSearchesStore, 'addRecentSearch'); - // jest.spyOn(wrapper.vm.recentSearchesService, 'save'); wrapper.vm.handleFilterSubmit(mockFilters); return wrapper.vm.recentSearchesPromise.then(() => { - expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith( - 'author_username:=@root foo', - ); + expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith(mockFilters); }); }); @@ -203,9 +219,17 @@ describe('FilteredSearchBarRoot', () => { wrapper.vm.handleFilterSubmit(mockFilters); return wrapper.vm.recentSearchesPromise.then(() => { - expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([ - 'author_username:=@root foo', - ]); + expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([mockFilters]); + }); + }); + + it('sets `recentSearches` data prop with array of searches', () => { + jest.spyOn(wrapper.vm.recentSearchesService, 'save'); + + wrapper.vm.handleFilterSubmit(mockFilters); + + return wrapper.vm.recentSearchesPromise.then(() => { + expect(wrapper.vm.recentSearches).toEqual([mockFilters]); }); }); @@ -222,6 +246,7 @@ describe('FilteredSearchBarRoot', () => { wrapper.setData({ selectedSortOption: mockSortOptions[0], selectedSortDirection: SortDirection.descending, + recentSearches: mockHistoryItems, }); return wrapper.vm.$nextTick(); @@ -232,6 +257,7 @@ describe('FilteredSearchBarRoot', () => { expect(glFilteredSearchEl.props('placeholder')).toBe('Filter requirements'); expect(glFilteredSearchEl.props('availableTokens')).toEqual(mockAvailableTokens); + expect(glFilteredSearchEl.props('historyItems')).toEqual(mockHistoryItems); }); it('renders sort dropdown component', () => { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index edc0f119262..7e28c4e11e1 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -44,6 +44,29 @@ export const mockAuthorToken = { export const mockAvailableTokens = [mockAuthorToken]; +export const mockHistoryItems = [ + [ + { + type: 'author_username', + value: { + data: 'toby', + operator: '=', + }, + }, + 'duo', + ], + [ + { + type: 'author_username', + value: { + data: 'root', + operator: '=', + }, + }, + 'si', + ], +]; + export const mockSortOptions = [ { id: 1, diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js index 3650ef79136..45294096eda 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -11,11 +11,12 @@ import { mockAuthorToken, mockAuthors } from '../mock_data'; jest.mock('~/flash'); -const createComponent = ({ config = mockAuthorToken, value = { data: '' } } = {}) => +const createComponent = ({ config = mockAuthorToken, value = { data: '' }, active = false } = {}) => mount(AuthorToken, { propsData: { config, value, + active, }, provide: { portalName: 'fake target', @@ -51,29 +52,23 @@ describe('AuthorToken', () => { describe('computed', () => { describe('currentValue', () => { it('returns lowercase string for `value.data`', () => { - wrapper.setProps({ - value: { data: 'FOO' }, - }); + wrapper = createComponent({ value: { data: 'FOO' } }); - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.currentValue).toBe('foo'); - }); + expect(wrapper.vm.currentValue).toBe('foo'); }); }); describe('activeAuthor', () => { - it('returns object for currently present `value.data`', () => { + it('returns object for currently present `value.data`', async () => { + wrapper = createComponent({ value: { data: mockAuthors[0].username } }); + wrapper.setData({ authors: mockAuthors, }); - wrapper.setProps({ - value: { data: mockAuthors[0].username }, - }); + await wrapper.vm.$nextTick(); - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]); - }); + expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]); }); }); }); diff --git a/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js b/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js index 8437e68d73c..93f4db5df18 100644 --- a/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js +++ b/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js @@ -38,6 +38,9 @@ describe('GlModalVuex', () => { localVue, store, propsData, + stubs: { + GlModal, + }, }); }; @@ -148,4 +151,29 @@ describe('GlModalVuex', () => { .then(done) .catch(done.fail); }); + + it.each(['ok', 'cancel'])( + 'passes an "%s" handler to the "modal-footer" slot scope', + handlerName => { + state.isVisible = true; + + const modalFooterSlotContent = jest.fn(); + + factory({ + scopedSlots: { + 'modal-footer': modalFooterSlotContent, + }, + }); + + const handler = modalFooterSlotContent.mock.calls[0][0][handlerName]; + + expect(wrapper.emitted(handlerName)).toBeFalsy(); + expect(actions.hide).not.toHaveBeenCalled(); + + handler(); + + expect(actions.hide).toHaveBeenCalledTimes(1); + expect(wrapper.emitted(handlerName)).toBeTruthy(); + }, + ); }); diff --git a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js index ca75c55df26..548d4476c0f 100644 --- a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; -import { mockAssigneesList } from '../../../../javascripts/boards/mock_data'; +import { mockAssigneesList } from 'jest/boards/mock_data'; const TEST_CSS_CLASSES = 'test-classes'; const TEST_MAX_VISIBLE = 4; diff --git a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js index 90c3fe54901..69d8c1a5918 100644 --- a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js @@ -4,7 +4,7 @@ import { shallowMount } from '@vue/test-utils'; import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; import Icon from '~/vue_shared/components/icon.vue'; -import { mockMilestone } from '../../../../javascripts/boards/mock_data'; +import { mockMilestone } from 'jest/boards/mock_data'; const createComponent = (milestone = mockMilestone) => { const Component = Vue.extend(IssueMilestone); diff --git a/spec/frontend/vue_shared/components/issue/issue_warning_spec.js b/spec/frontend/vue_shared/components/issue/issue_warning_spec.js deleted file mode 100644 index 891c70bcb5c..00000000000 --- a/spec/frontend/vue_shared/components/issue/issue_warning_spec.js +++ /dev/null @@ -1,105 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import IssueWarning from '~/vue_shared/components/issue/issue_warning.vue'; -import Icon from '~/vue_shared/components/icon.vue'; - -describe('Issue Warning Component', () => { - let wrapper; - - const findIcon = () => wrapper.find(Icon); - const findLockedBlock = () => wrapper.find({ ref: 'locked' }); - const findConfidentialBlock = () => wrapper.find({ ref: 'confidential' }); - const findLockedAndConfidentialBlock = () => wrapper.find({ ref: 'lockedAndConfidential' }); - - const createComponent = props => { - wrapper = shallowMount(IssueWarning, { - propsData: { - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('when issue is locked but not confidential', () => { - beforeEach(() => { - createComponent({ - isLocked: true, - lockedIssueDocsPath: 'locked-path', - isConfidential: false, - }); - }); - - it('renders information about locked issue', () => { - expect(findLockedBlock().exists()).toBe(true); - expect(findLockedBlock().element).toMatchSnapshot(); - }); - - it('renders warning icon', () => { - expect(findIcon().exists()).toBe(true); - }); - - it('does not render information about locked and confidential issue', () => { - expect(findLockedAndConfidentialBlock().exists()).toBe(false); - }); - - it('does not render information about confidential issue', () => { - expect(findConfidentialBlock().exists()).toBe(false); - }); - }); - - describe('when issue is confidential but not locked', () => { - beforeEach(() => { - createComponent({ - isLocked: false, - isConfidential: true, - confidentialIssueDocsPath: 'confidential-path', - }); - }); - - it('renders information about confidential issue', () => { - expect(findConfidentialBlock().exists()).toBe(true); - expect(findConfidentialBlock().element).toMatchSnapshot(); - }); - - it('renders warning icon', () => { - expect(wrapper.find(Icon).exists()).toBe(true); - }); - - it('does not render information about locked issue', () => { - expect(findLockedBlock().exists()).toBe(false); - }); - - it('does not render information about locked and confidential issue', () => { - expect(findLockedAndConfidentialBlock().exists()).toBe(false); - }); - }); - - describe('when issue is locked and confidential', () => { - beforeEach(() => { - createComponent({ - isLocked: true, - isConfidential: true, - }); - }); - - it('renders information about locked and confidential issue', () => { - expect(findLockedAndConfidentialBlock().exists()).toBe(true); - expect(findLockedAndConfidentialBlock().element).toMatchSnapshot(); - }); - - it('does not render warning icon', () => { - expect(wrapper.find(Icon).exists()).toBe(false); - }); - - it('does not render information about locked issue', () => { - expect(findLockedBlock().exists()).toBe(false); - }); - - it('does not render information about confidential issue', () => { - expect(findConfidentialBlock().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js index 9be0a67e4fa..fe9a5156539 100644 --- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js +++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js @@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'; import { formatDate } from '~/lib/utils/datetime_utility'; import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data'; +import { TEST_HOST } from 'jest/helpers/test_constants'; describe('RelatedIssuableItem', () => { let wrapper; @@ -19,7 +20,7 @@ describe('RelatedIssuableItem', () => { idKey: 1, displayReference: 'gitlab-org/gitlab-test#1', pathIdSeparator: '#', - path: `${gl.TEST_HOST}/path`, + path: `${TEST_HOST}/path`, title: 'title', confidential: true, dueDate: '1990-12-31', diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_mock_data.js b/spec/frontend/vue_shared/components/issue/related_issuable_mock_data.js index 5f69d761fdf..17813f2833d 100644 --- a/spec/frontend/vue_shared/components/issue/related_issuable_mock_data.js +++ b/spec/frontend/vue_shared/components/issue/related_issuable_mock_data.js @@ -1,3 +1,5 @@ +import { TEST_HOST } from 'jest/helpers/test_constants'; + export const defaultProps = { endpoint: '/foo/bar/issues/1/related_issues', currentNamespacePath: 'foo', @@ -83,8 +85,8 @@ export const defaultAssignees = [ name: 'Administrator', username: 'root', state: 'active', - avatar_url: `${gl.TEST_HOST}`, - web_url: `${gl.TEST_HOST}/root`, + avatar_url: `${TEST_HOST}`, + web_url: `${TEST_HOST}/root`, status_tooltip_html: null, path: '/root', }, @@ -93,8 +95,8 @@ export const defaultAssignees = [ name: 'Brooks Beatty', username: 'brynn_champlin', state: 'active', - avatar_url: `${gl.TEST_HOST}`, - web_url: `${gl.TEST_HOST}/brynn_champlin`, + avatar_url: `${TEST_HOST}`, + web_url: `${TEST_HOST}/brynn_champlin`, status_tooltip_html: null, path: '/brynn_champlin', }, @@ -103,8 +105,8 @@ export const defaultAssignees = [ name: 'Bryce Turcotte', username: 'melynda', state: 'active', - avatar_url: `${gl.TEST_HOST}`, - web_url: `${gl.TEST_HOST}/melynda`, + avatar_url: `${TEST_HOST}`, + web_url: `${TEST_HOST}/melynda`, status_tooltip_html: null, path: '/melynda', }, @@ -113,8 +115,8 @@ export const defaultAssignees = [ name: 'Conchita Eichmann', username: 'juliana_gulgowski', state: 'active', - avatar_url: `${gl.TEST_HOST}`, - web_url: `${gl.TEST_HOST}/juliana_gulgowski`, + avatar_url: `${TEST_HOST}`, + web_url: `${TEST_HOST}/juliana_gulgowski`, status_tooltip_html: null, path: '/juliana_gulgowski', }, diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js index 9a5b95b555f..c6e147899e4 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js @@ -69,11 +69,6 @@ describe('Suggestion Diff component', () => { expect(addToBatchBtn.html().includes('Add suggestion to batch')).toBe(true); }); - it('renders correct tooltip message for apply button', () => { - createComponent(); - expect(wrapper.vm.tooltipMessage).toBe('This also resolves the discussion'); - }); - describe('when apply suggestion is clicked', () => { beforeEach(() => { createComponent(); @@ -227,17 +222,23 @@ describe('Suggestion Diff component', () => { createComponent({ canApply: false }); }); - it('disables apply suggestion and add to batch buttons', () => { + it('disables apply suggestion and hides add to batch button', () => { expect(findApplyButton().exists()).toBe(true); - expect(findAddToBatchButton().exists()).toBe(true); + expect(findAddToBatchButton().exists()).toBe(false); expect(findApplyButton().attributes('disabled')).toBe('true'); - expect(findAddToBatchButton().attributes('disabled')).toBe('true'); + }); + }); + + describe('tooltip message for apply button', () => { + it('renders correct tooltip message when button is applicable', () => { + createComponent(); + expect(wrapper.vm.tooltipMessage).toBe('This also resolves this thread'); }); - it('renders correct tooltip message for apply button', () => { - expect(wrapper.vm.tooltipMessage).toBe( - "Can't apply as this line has changed or the suggestion already matches its content.", - ); + it('renders the inapplicable reason in the tooltip when button is not applicable', () => { + const inapplicableReason = 'lorem'; + createComponent({ canApply: false, inapplicableReason }); + expect(wrapper.vm.tooltipMessage).toBe(inapplicableReason); }); }); }); diff --git a/spec/frontend/vue_shared/components/issue/__snapshots__/issue_warning_spec.js.snap b/spec/frontend/vue_shared/components/notes/__snapshots__/noteable_warning_spec.js.snap index 49b18d3e106..573bc9abe4d 100644 --- a/spec/frontend/vue_shared/components/issue/__snapshots__/issue_warning_spec.js.snap +++ b/spec/frontend/vue_shared/components/notes/__snapshots__/noteable_warning_spec.js.snap @@ -1,6 +1,21 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Issue Warning Component when issue is confidential but not locked renders information about confidential issue 1`] = ` +exports[`Issue Warning Component when issue is locked but not confidential renders information about locked issue 1`] = ` +<span> + + This issue is locked. + Only project members can comment. + + <gl-link-stub + href="locked-path" + target="_blank" + > + Learn more + </gl-link-stub> +</span> +`; + +exports[`Issue Warning Component when noteable is confidential but not locked renders information about confidential issue 1`] = ` <span> This is a confidential issue. @@ -10,14 +25,12 @@ exports[`Issue Warning Component when issue is confidential but not locked rende href="confidential-path" target="_blank" > - - Learn more - + Learn more </gl-link-stub> </span> `; -exports[`Issue Warning Component when issue is locked and confidential renders information about locked and confidential issue 1`] = ` +exports[`Issue Warning Component when noteable is locked and confidential renders information about locked and confidential noteable 1`] = ` <span> <span> This issue is @@ -43,20 +56,3 @@ exports[`Issue Warning Component when issue is locked and confidential renders i </span> `; - -exports[`Issue Warning Component when issue is locked but not confidential renders information about locked issue 1`] = ` -<span> - - This issue is locked. - Only project members can comment. - - <gl-link-stub - href="locked-path" - target="_blank" - > - - Learn more - - </gl-link-stub> -</span> -`; diff --git a/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js b/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js new file mode 100644 index 00000000000..ae8c9a0928e --- /dev/null +++ b/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js @@ -0,0 +1,196 @@ +import { shallowMount } from '@vue/test-utils'; +import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue'; +import Icon from '~/vue_shared/components/icon.vue'; + +describe('Issue Warning Component', () => { + let wrapper; + + const findIcon = (w = wrapper) => w.find(Icon); + const findLockedBlock = (w = wrapper) => w.find({ ref: 'locked' }); + const findConfidentialBlock = (w = wrapper) => w.find({ ref: 'confidential' }); + const findLockedAndConfidentialBlock = (w = wrapper) => w.find({ ref: 'lockedAndConfidential' }); + + const createComponent = props => + shallowMount(NoteableWarning, { + propsData: { + ...props, + }, + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('when issue is locked but not confidential', () => { + beforeEach(() => { + wrapper = createComponent({ + isLocked: true, + lockedNoteableDocsPath: 'locked-path', + isConfidential: false, + }); + }); + + it('renders information about locked issue', () => { + expect(findLockedBlock().exists()).toBe(true); + expect(findLockedBlock().element).toMatchSnapshot(); + }); + + it('renders warning icon', () => { + expect(findIcon().exists()).toBe(true); + }); + + it('does not render information about locked and confidential issue', () => { + expect(findLockedAndConfidentialBlock().exists()).toBe(false); + }); + + it('does not render information about confidential issue', () => { + expect(findConfidentialBlock().exists()).toBe(false); + }); + }); + + describe('when noteable is confidential but not locked', () => { + beforeEach(() => { + wrapper = createComponent({ + isLocked: false, + isConfidential: true, + confidentialNoteableDocsPath: 'confidential-path', + }); + }); + + it('renders information about confidential issue', async () => { + expect(findConfidentialBlock().exists()).toBe(true); + expect(findConfidentialBlock().element).toMatchSnapshot(); + + await wrapper.vm.$nextTick(); + expect(findConfidentialBlock(wrapper).text()).toContain('This is a confidential issue.'); + }); + + it('renders warning icon', () => { + expect(wrapper.find(Icon).exists()).toBe(true); + }); + + it('does not render information about locked noteable', () => { + expect(findLockedBlock().exists()).toBe(false); + }); + + it('does not render information about locked and confidential noteable', () => { + expect(findLockedAndConfidentialBlock().exists()).toBe(false); + }); + }); + + describe('when noteable is locked and confidential', () => { + beforeEach(() => { + wrapper = createComponent({ + isLocked: true, + isConfidential: true, + }); + }); + + it('renders information about locked and confidential noteable', () => { + expect(findLockedAndConfidentialBlock().exists()).toBe(true); + expect(findLockedAndConfidentialBlock().element).toMatchSnapshot(); + }); + + it('does not render warning icon', () => { + expect(wrapper.find(Icon).exists()).toBe(false); + }); + + it('does not render information about locked noteable', () => { + expect(findLockedBlock().exists()).toBe(false); + }); + + it('does not render information about confidential noteable', () => { + expect(findConfidentialBlock().exists()).toBe(false); + }); + }); + + describe('when noteableType prop is defined', () => { + let wrapperLocked; + let wrapperConfidential; + let wrapperLockedAndConfidential; + + beforeEach(() => { + wrapperLocked = createComponent({ + isLocked: true, + isConfidential: false, + }); + wrapperConfidential = createComponent({ + isLocked: false, + isConfidential: true, + }); + wrapperLockedAndConfidential = createComponent({ + isLocked: true, + isConfidential: true, + }); + }); + + afterEach(() => { + wrapperLocked.destroy(); + wrapperConfidential.destroy(); + wrapperLockedAndConfidential.destroy(); + }); + + it('renders confidential & locked messages with noteable "issue"', () => { + expect(findLockedBlock(wrapperLocked).text()).toContain('This issue is locked.'); + expect(findConfidentialBlock(wrapperConfidential).text()).toContain( + 'This is a confidential issue.', + ); + expect(findLockedAndConfidentialBlock(wrapperLockedAndConfidential).text()).toContain( + 'This issue is confidential and locked.', + ); + }); + + it('renders confidential & locked messages with noteable "epic"', async () => { + wrapperLocked.setProps({ + noteableType: 'Epic', + }); + wrapperConfidential.setProps({ + noteableType: 'Epic', + }); + wrapperLockedAndConfidential.setProps({ + noteableType: 'Epic', + }); + + await wrapperLocked.vm.$nextTick(); + expect(findLockedBlock(wrapperLocked).text()).toContain('This epic is locked.'); + + await wrapperConfidential.vm.$nextTick(); + expect(findConfidentialBlock(wrapperConfidential).text()).toContain( + 'This is a confidential epic.', + ); + + await wrapperLockedAndConfidential.vm.$nextTick(); + expect(findLockedAndConfidentialBlock(wrapperLockedAndConfidential).text()).toContain( + 'This epic is confidential and locked.', + ); + }); + + it('renders confidential & locked messages with noteable "merge request"', async () => { + wrapperLocked.setProps({ + noteableType: 'MergeRequest', + }); + wrapperConfidential.setProps({ + noteableType: 'MergeRequest', + }); + wrapperLockedAndConfidential.setProps({ + noteableType: 'MergeRequest', + }); + + await wrapperLocked.vm.$nextTick(); + expect(findLockedBlock(wrapperLocked).text()).toContain('This merge request is locked.'); + + await wrapperConfidential.vm.$nextTick(); + expect(findConfidentialBlock(wrapperConfidential).text()).toContain( + 'This is a confidential merge request.', + ); + + await wrapperLockedAndConfidential.vm.$nextTick(); + expect(findLockedAndConfidentialBlock(wrapperLockedAndConfidential).text()).toContain( + 'This merge request is confidential and locked.', + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js index eb1d9e93634..385134c4a3f 100644 --- a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js @@ -74,6 +74,16 @@ describe('ProjectListItem component', () => { expect(renderedNamespace).toBe('a / ... / e /'); }); + it(`renders a simple namespace name of a GraphQL project`, () => { + options.propsData.project.name_with_namespace = undefined; + options.propsData.project.nameWithNamespace = 'test'; + + wrapper = shallowMount(Component, options); + const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text()); + + expect(renderedNamespace).toBe('test /'); + }); + it(`renders the project name`, () => { options.propsData.project.name = 'my-test-project'; diff --git a/spec/frontend/vue_shared/components/remove_member_modal_spec.js b/spec/frontend/vue_shared/components/remove_member_modal_spec.js new file mode 100644 index 00000000000..2d380b25a0a --- /dev/null +++ b/spec/frontend/vue_shared/components/remove_member_modal_spec.js @@ -0,0 +1,65 @@ +import { GlFormCheckbox, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; + +describe('RemoveMemberModal', () => { + const memberPath = '/gitlab-org/gitlab-test/-/project_members/90'; + let wrapper; + + const findForm = () => wrapper.find({ ref: 'form' }); + const findGlModal = () => wrapper.find(GlModal); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe.each` + state | isAccessRequest | actionText | checkboxTestDescription | checkboxExpected | message + ${'removing a member'} | ${'false'} | ${'Remove member'} | ${'shows a checkbox to allow removal from related issues and MRs'} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} + ${'denying an access request'} | ${'true'} | ${'Deny access request'} | ${'does not show a checkbox'} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} + `( + 'when $state', + ({ actionText, isAccessRequest, message, checkboxTestDescription, checkboxExpected }) => { + beforeEach(() => { + wrapper = shallowMount(RemoveMemberModal, { + data() { + return { + modalData: { + isAccessRequest, + message, + memberPath, + }, + }; + }, + }); + }); + + it(`has the title ${actionText}`, () => { + expect(findGlModal().attributes('title')).toBe(actionText); + }); + + it('contains a form action', () => { + expect(findForm().attributes('action')).toBe(memberPath); + }); + + it('displays a message to the user', () => { + expect(wrapper.find('[data-testid=modal-message]').text()).toBe(message); + }); + + it(`${checkboxTestDescription}`, () => { + expect(wrapper.contains(GlFormCheckbox)).toBe(checkboxExpected); + }); + + it('submits the form when the modal is submitted', () => { + const spy = jest.spyOn(findForm().element, 'submit'); + + findGlModal().vm.$emit('primary'); + + expect(spy).toHaveBeenCalled(); + + spy.mockRestore(); + }); + }, + ); +}); diff --git a/spec/frontend/vue_shared/components/__snapshots__/resizable_chart_container_spec.js.snap b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/resizable_chart_container_spec.js.snap index add0c36a120..add0c36a120 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/resizable_chart_container_spec.js.snap +++ b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/resizable_chart_container_spec.js.snap diff --git a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap new file mode 100644 index 00000000000..103b53cb280 --- /dev/null +++ b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap @@ -0,0 +1,324 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Resizable Skeleton Loader default setup renders the bars, labels, and grid with correct position, size, and rx percentages 1`] = ` +<gl-skeleton-loader-stub + baseurl="" + height="130" + preserveaspectratio="xMidYMid meet" + width="400" +> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="30%" + /> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="60%" + /> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="90%" + /> + + <rect + data-testid="skeleton-chart-bar" + height="5%" + rx="0.4%" + width="6%" + x="5.875%" + y="85%" + /> + <rect + data-testid="skeleton-chart-bar" + height="7%" + rx="0.4%" + width="6%" + x="17.625%" + y="83%" + /> + <rect + data-testid="skeleton-chart-bar" + height="9%" + rx="0.4%" + width="6%" + x="29.375%" + y="81%" + /> + <rect + data-testid="skeleton-chart-bar" + height="14%" + rx="0.4%" + width="6%" + x="41.125%" + y="76%" + /> + <rect + data-testid="skeleton-chart-bar" + height="21%" + rx="0.4%" + width="6%" + x="52.875%" + y="69%" + /> + <rect + data-testid="skeleton-chart-bar" + height="35%" + rx="0.4%" + width="6%" + x="64.625%" + y="55%" + /> + <rect + data-testid="skeleton-chart-bar" + height="50%" + rx="0.4%" + width="6%" + x="76.375%" + y="40%" + /> + <rect + data-testid="skeleton-chart-bar" + height="80%" + rx="0.4%" + width="6%" + x="88.125%" + y="10%" + /> + + <rect + data-testid="skeleton-chart-label" + height="5%" + rx="0.4%" + width="4%" + x="6.875%" + y="95%" + /> + <rect + data-testid="skeleton-chart-label" + height="5%" + rx="0.4%" + width="4%" + x="18.625%" + y="95%" + /> + <rect + data-testid="skeleton-chart-label" + height="5%" + rx="0.4%" + width="4%" + x="30.375%" + y="95%" + /> + <rect + data-testid="skeleton-chart-label" + height="5%" + rx="0.4%" + width="4%" + x="42.125%" + y="95%" + /> + <rect + data-testid="skeleton-chart-label" + height="5%" + rx="0.4%" + width="4%" + x="53.875%" + y="95%" + /> + <rect + data-testid="skeleton-chart-label" + height="5%" + rx="0.4%" + width="4%" + x="65.625%" + y="95%" + /> + <rect + data-testid="skeleton-chart-label" + height="5%" + rx="0.4%" + width="4%" + x="77.375%" + y="95%" + /> + <rect + data-testid="skeleton-chart-label" + height="5%" + rx="0.4%" + width="4%" + x="89.125%" + y="95%" + /> +</gl-skeleton-loader-stub> +`; + +exports[`Resizable Skeleton Loader with custom settings renders the correct position, and size percentages for bars and labels with different settings 1`] = ` +<gl-skeleton-loader-stub + baseurl="" + height="130" + preserveaspectratio="xMidYMid meet" + uniquekey="" + width="400" +> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="30%" + /> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="60%" + /> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="90%" + /> + + <rect + data-testid="skeleton-chart-bar" + height="5%" + rx="0.6%" + width="3%" + x="6.0625%" + y="85%" + /> + <rect + data-testid="skeleton-chart-bar" + height="7%" + rx="0.6%" + width="3%" + x="18.1875%" + y="83%" + /> + <rect + data-testid="skeleton-chart-bar" + height="9%" + rx="0.6%" + width="3%" + x="30.3125%" + y="81%" + /> + <rect + data-testid="skeleton-chart-bar" + height="14%" + rx="0.6%" + width="3%" + x="42.4375%" + y="76%" + /> + <rect + data-testid="skeleton-chart-bar" + height="21%" + rx="0.6%" + width="3%" + x="54.5625%" + y="69%" + /> + <rect + data-testid="skeleton-chart-bar" + height="35%" + rx="0.6%" + width="3%" + x="66.6875%" + y="55%" + /> + <rect + data-testid="skeleton-chart-bar" + height="50%" + rx="0.6%" + width="3%" + x="78.8125%" + y="40%" + /> + <rect + data-testid="skeleton-chart-bar" + height="80%" + rx="0.6%" + width="3%" + x="90.9375%" + y="10%" + /> + + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="4.0625%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="16.1875%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="28.3125%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="40.4375%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="52.5625%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="64.6875%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="76.8125%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="88.9375%" + y="98%" + /> +</gl-skeleton-loader-stub> +`; diff --git a/spec/frontend/vue_shared/components/resizable_chart_container_spec.js b/spec/frontend/vue_shared/components/resizable_chart/resizable_chart_container_spec.js index 3a5514ef318..3a5514ef318 100644 --- a/spec/frontend/vue_shared/components/resizable_chart_container_spec.js +++ b/spec/frontend/vue_shared/components/resizable_chart/resizable_chart_container_spec.js diff --git a/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js b/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js new file mode 100644 index 00000000000..7facd02e596 --- /dev/null +++ b/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js @@ -0,0 +1,55 @@ +import { shallowMount } from '@vue/test-utils'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; + +describe('Resizable Skeleton Loader', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(ChartSkeletonLoader, { + propsData, + }); + }; + + const verifyElementsPresence = () => { + const gridItems = wrapper.findAll('[data-testid="skeleton-chart-grid"]').wrappers; + const barItems = wrapper.findAll('[data-testid="skeleton-chart-bar"]').wrappers; + const labelItems = wrapper.findAll('[data-testid="skeleton-chart-label"]').wrappers; + expect(gridItems.length).toBe(3); + expect(barItems.length).toBe(8); + expect(labelItems.length).toBe(8); + }; + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + }); + + describe('default setup', () => { + beforeEach(() => { + createComponent({ uniqueKey: null }); + }); + + it('renders the bars, labels, and grid with correct position, size, and rx percentages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders the correct number of grid items, bars, and labels', () => { + verifyElementsPresence(); + }); + }); + + describe('with custom settings', () => { + beforeEach(() => { + createComponent({ uniqueKey: '', rx: 0.6, barWidth: 3, labelWidth: 7, labelHeight: 2 }); + }); + + it('renders the correct position, and size percentages for bars and labels with different settings', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders the correct number of grid items, bars, and labels', () => { + verifyElementsPresence(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js index faa32131fab..78f27c9948b 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js @@ -2,18 +2,35 @@ import { generateToolbarItem, addCustomEventListener, removeCustomEventListener, + registerHTMLToMarkdownRenderer, addImage, getMarkdown, -} from '~/vue_shared/components/rich_content_editor/editor_service'; +} from '~/vue_shared/components/rich_content_editor/services/editor_service'; +import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'; + +jest.mock('~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'); describe('Editor Service', () => { - const mockInstance = { - eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() }, - editor: { exec: jest.fn() }, - invoke: jest.fn(), - }; - const event = 'someCustomEvent'; - const handler = jest.fn(); + let mockInstance; + let event; + let handler; + + beforeEach(() => { + mockInstance = { + eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() }, + editor: { exec: jest.fn() }, + invoke: jest.fn(), + toMarkOptions: { + renderer: { + constructor: { + factory: jest.fn(), + }, + }, + }, + }; + event = 'someCustomEvent'; + handler = jest.fn(); + }); describe('generateToolbarItem', () => { const config = { @@ -74,4 +91,33 @@ describe('Editor Service', () => { expect(mockInstance.invoke).toHaveBeenCalledWith('getMarkdown'); }); }); + + describe('registerHTMLToMarkdownRenderer', () => { + let baseRenderer; + const htmlToMarkdownRenderer = {}; + const extendedRenderer = {}; + + beforeEach(() => { + baseRenderer = mockInstance.toMarkOptions.renderer; + buildHTMLToMarkdownRenderer.mockReturnValueOnce(htmlToMarkdownRenderer); + baseRenderer.constructor.factory.mockReturnValueOnce(extendedRenderer); + + registerHTMLToMarkdownRenderer(mockInstance); + }); + + it('builds a new instance of the HTML to Markdown renderer', () => { + expect(buildHTMLToMarkdownRenderer).toHaveBeenCalledWith(baseRenderer); + }); + + it('extends base renderer with the HTML to Markdown renderer', () => { + expect(baseRenderer.constructor.factory).toHaveBeenCalledWith( + baseRenderer, + htmlToMarkdownRenderer, + ); + }); + + it('replaces the default renderer with extended renderer', () => { + expect(mockInstance.toMarkOptions.renderer).toBe(extendedRenderer); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js new file mode 100644 index 00000000000..0c2ac53aa52 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js @@ -0,0 +1,76 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal, GlTabs } from '@gitlab/ui'; +import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue'; +import UploadImageTab from '~/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue'; +import { IMAGE_TABS } from '~/vue_shared/components/rich_content_editor/constants'; + +describe('Add Image Modal', () => { + let wrapper; + const propsData = { imageRoot: 'path/to/root/' }; + + const findModal = () => wrapper.find(GlModal); + const findTabs = () => wrapper.find(GlTabs); + const findUploadImageTab = () => wrapper.find(UploadImageTab); + const findUrlInput = () => wrapper.find({ ref: 'urlInput' }); + const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' }); + + beforeEach(() => { + wrapper = shallowMount(AddImageModal, { + provide: { glFeatures: { sseImageUploads: true } }, + propsData, + }); + }); + + describe('when content is loaded', () => { + it('renders a modal component', () => { + expect(findModal().exists()).toBe(true); + }); + + it('renders a Tabs component', () => { + expect(findTabs().exists()).toBe(true); + }); + + it('renders an upload image tab', () => { + expect(findUploadImageTab().exists()).toBe(true); + }); + + it('renders an input to add an image URL', () => { + expect(findUrlInput().exists()).toBe(true); + }); + + it('renders an input to add an image description', () => { + expect(findDescriptionInput().exists()).toBe(true); + }); + }); + + describe('add image', () => { + describe('Upload', () => { + it('validates the file', () => { + const preventDefault = jest.fn(); + const description = 'some description'; + const file = { name: 'some_file.png' }; + + wrapper.vm.$refs.uploadImageTab = { validateFile: jest.fn() }; + wrapper.setData({ file, description, tabIndex: IMAGE_TABS.UPLOAD_TAB }); + + findModal().vm.$emit('ok', { preventDefault }); + + expect(wrapper.vm.$refs.uploadImageTab.validateFile).toHaveBeenCalled(); + }); + }); + + describe('URL', () => { + it('emits an addImage event when a valid URL is specified', () => { + const preventDefault = jest.fn(); + const mockImage = { imageUrl: '/some/valid/url.png', description: 'some description' }; + wrapper.setData({ ...mockImage, tabIndex: IMAGE_TABS.URL_TAB }); + + findModal().vm.$emit('ok', { preventDefault }); + expect(preventDefault).not.toHaveBeenCalled(); + expect(wrapper.emitted('addImage')).toEqual([ + [{ imageUrl: mockImage.imageUrl, altText: mockImage.description }], + ]); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab_spec.js new file mode 100644 index 00000000000..ded490b2568 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab_spec.js @@ -0,0 +1,41 @@ +import { shallowMount } from '@vue/test-utils'; +import UploadImageTab from '~/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue'; + +describe('Upload Image Tab', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallowMount(UploadImageTab); + }); + + afterEach(() => wrapper.destroy()); + + const triggerInputEvent = size => { + const file = { size, name: 'file-name.png' }; + const mockEvent = new Event('input'); + + Object.defineProperty(mockEvent, 'target', { value: { files: [file] } }); + + wrapper.find({ ref: 'fileInput' }).element.dispatchEvent(mockEvent); + + return file; + }; + + describe('onInput', () => { + it.each` + size | fileError + ${2000000000} | ${'Maximum file size is 2MB. Please select a smaller file.'} + ${200} | ${null} + `('validates the file correctly', ({ size, fileError }) => { + triggerInputEvent(size); + + expect(wrapper.vm.fileError).toBe(fileError); + }); + }); + + it('emits input event when file is valid', () => { + const file = triggerInputEvent(200); + + expect(wrapper.emitted('input')).toEqual([[file]]); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image_modal_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image_modal_spec.js deleted file mode 100644 index 4889bc8538d..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image_modal_spec.js +++ /dev/null @@ -1,41 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlModal } from '@gitlab/ui'; -import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image_modal.vue'; - -describe('Add Image Modal', () => { - let wrapper; - - const findModal = () => wrapper.find(GlModal); - const findUrlInput = () => wrapper.find({ ref: 'urlInput' }); - const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' }); - - beforeEach(() => { - wrapper = shallowMount(AddImageModal); - }); - - describe('when content is loaded', () => { - it('renders a modal component', () => { - expect(findModal().exists()).toBe(true); - }); - - it('renders an input to add an image URL', () => { - expect(findUrlInput().exists()).toBe(true); - }); - - it('renders an input to add an image description', () => { - expect(findDescriptionInput().exists()).toBe(true); - }); - }); - - describe('add image', () => { - it('emits an addImage event when a valid URL is specified', () => { - const preventDefault = jest.fn(); - const mockImage = { imageUrl: '/some/valid/url.png', altText: 'some description' }; - wrapper.setData({ ...mockImage }); - - findModal().vm.$emit('ok', { preventDefault }); - expect(preventDefault).not.toHaveBeenCalled(); - expect(wrapper.emitted('addImage')).toEqual([[mockImage]]); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js index 0db10389df4..b6ff6aa767c 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; -import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image_modal.vue'; +import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue'; import { EDITOR_OPTIONS, EDITOR_TYPES, @@ -13,25 +13,28 @@ import { addCustomEventListener, removeCustomEventListener, addImage, -} from '~/vue_shared/components/rich_content_editor/editor_service'; + registerHTMLToMarkdownRenderer, +} from '~/vue_shared/components/rich_content_editor/services/editor_service'; -jest.mock('~/vue_shared/components/rich_content_editor/editor_service', () => ({ - ...jest.requireActual('~/vue_shared/components/rich_content_editor/editor_service'), +jest.mock('~/vue_shared/components/rich_content_editor/services/editor_service', () => ({ + ...jest.requireActual('~/vue_shared/components/rich_content_editor/services/editor_service'), addCustomEventListener: jest.fn(), removeCustomEventListener: jest.fn(), addImage: jest.fn(), + registerHTMLToMarkdownRenderer: jest.fn(), })); describe('Rich Content Editor', () => { let wrapper; - const value = '## Some Markdown'; + const content = '## Some Markdown'; + const imageRoot = 'path/to/root/'; const findEditor = () => wrapper.find({ ref: 'editor' }); const findAddImageModal = () => wrapper.find(AddImageModal); beforeEach(() => { wrapper = shallowMount(RichContentEditor, { - propsData: { value }, + propsData: { content, imageRoot }, }); }); @@ -41,7 +44,7 @@ describe('Rich Content Editor', () => { }); it('renders the correct content', () => { - expect(findEditor().props().initialValue).toBe(value); + expect(findEditor().props().initialValue).toBe(content); }); it('provides the correct editor options', () => { @@ -73,17 +76,37 @@ describe('Rich Content Editor', () => { }); }); + describe('when content is reset', () => { + it('should reset the content via setMarkdown', () => { + const newContent = 'Just the body content excluding the front matter for example'; + const mockInstance = { invoke: jest.fn() }; + wrapper.vm.$refs.editor = mockInstance; + + wrapper.vm.resetInitialValue(newContent); + + expect(mockInstance.invoke).toHaveBeenCalledWith('setMarkdown', newContent); + }); + }); + describe('when editor is loaded', () => { - it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { - const mockEditorApi = { eventManager: { addEventType: jest.fn(), listen: jest.fn() } }; + let mockEditorApi; + + beforeEach(() => { + mockEditorApi = { eventManager: { addEventType: jest.fn(), listen: jest.fn() } }; findEditor().vm.$emit('load', mockEditorApi); + }); + it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { expect(addCustomEventListener).toHaveBeenCalledWith( mockEditorApi, CUSTOM_EVENTS.openAddImageModal, wrapper.vm.onOpenAddImageModal, ); }); + + it('registers HTML to markdown renderer', () => { + expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(mockEditorApi); + }); }); describe('when editor is destroyed', () => { @@ -107,7 +130,7 @@ describe('Rich Content Editor', () => { }); it('calls the onAddImage method when the addImage event is emitted', () => { - const mockImage = { imageUrl: 'some/url.png', description: 'some description' }; + const mockImage = { imageUrl: 'some/url.png', altText: 'some description' }; const mockInstance = { exec: jest.fn() }; wrapper.vm.$refs.editor = mockInstance; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js new file mode 100644 index 00000000000..cafe53e6bb2 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js @@ -0,0 +1,29 @@ +import buildCustomHTMLRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer'; + +describe('Build Custom Renderer Service', () => { + describe('buildCustomHTMLRenderer', () => { + it('should return an object with the default renderer functions when lacking arguments', () => { + expect(buildCustomHTMLRenderer()).toEqual( + expect.objectContaining({ + list: expect.any(Function), + text: expect.any(Function), + }), + ); + }); + + it('should return an object with both custom and default renderer functions when passed customRenderers', () => { + const mockHtmlCustomRenderer = jest.fn(); + const customRenderers = { + html: [mockHtmlCustomRenderer], + }; + + expect(buildCustomHTMLRenderer(customRenderers)).toEqual( + expect.objectContaining({ + html: expect.any(Function), + list: expect.any(Function), + text: expect.any(Function), + }), + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js new file mode 100644 index 00000000000..0e8610a22f5 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js @@ -0,0 +1,50 @@ +import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'; + +describe('HTMLToMarkdownRenderer', () => { + let baseRenderer; + let htmlToMarkdownRenderer; + const NODE = { nodeValue: 'mock_node' }; + + beforeEach(() => { + baseRenderer = { + trim: jest.fn(input => `trimmed ${input}`), + getSpaceCollapsedText: jest.fn(input => `space collapsed ${input}`), + getSpaceControlled: jest.fn(input => `space controlled ${input}`), + convert: jest.fn(), + }; + }); + + describe('TEXT_NODE visitor', () => { + it('composes getSpaceControlled, getSpaceCollapsedText, and trim services', () => { + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + + expect(htmlToMarkdownRenderer.TEXT_NODE(NODE)).toBe( + `space controlled trimmed space collapsed ${NODE.nodeValue}`, + ); + }); + }); + + describe('LI OL, LI UL visitor', () => { + const oneLevelNestedList = '\n * List item 1\n * List item 2'; + const twoLevelNestedList = '\n * List item 1\n * List item 2'; + const spaceInContentList = '\n * List item 1\n * List item 2'; + + it.each` + list | indentSpaces | result + ${oneLevelNestedList} | ${2} | ${'\n * List item 1\n * List item 2'} + ${oneLevelNestedList} | ${3} | ${'\n * List item 1\n * List item 2'} + ${oneLevelNestedList} | ${6} | ${'\n * List item 1\n * List item 2'} + ${twoLevelNestedList} | ${4} | ${'\n * List item 1\n * List item 2'} + ${spaceInContentList} | ${1} | ${'\n * List item 1\n * List item 2'} + `('changes the list indentation to $indentSpaces spaces', ({ list, indentSpaces, result }) => { + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { + subListIndentSpaces: indentSpaces, + }); + + baseRenderer.convert.mockReturnValueOnce(list); + + expect(htmlToMarkdownRenderer['LI OL, LI UL'](NODE, list)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, list); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js new file mode 100644 index 00000000000..18dff0a39bb --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js @@ -0,0 +1,88 @@ +import { + buildTextToken, + buildUneditableOpenTokens, + buildUneditableCloseToken, + buildUneditableCloseTokens, + buildUneditableTokens, + buildUneditableInlineTokens, + buildUneditableHtmlAsTextTokens, +} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { + originInlineToken, + originToken, + uneditableOpenTokens, + uneditableCloseToken, + uneditableCloseTokens, + uneditableBlockTokens, + uneditableInlineTokens, + uneditableTokens, +} from './mock_data'; + +describe('Build Uneditable Token renderer helper', () => { + describe('buildTextToken', () => { + it('returns an object literal representing a text token', () => { + const text = originToken.content; + expect(buildTextToken(text)).toStrictEqual(originToken); + }); + }); + + describe('buildUneditableOpenTokens', () => { + it('returns a 2-item array of tokens with the originToken appended to an open token', () => { + const result = buildUneditableOpenTokens(originToken); + + expect(result).toHaveLength(2); + expect(result).toStrictEqual(uneditableOpenTokens); + }); + }); + + describe('buildUneditableCloseToken', () => { + it('returns an object literal representing the uneditable close token', () => { + expect(buildUneditableCloseToken()).toStrictEqual(uneditableCloseToken); + }); + }); + + describe('buildUneditableCloseTokens', () => { + it('returns a 2-item array of tokens with the originToken prepended to a close token', () => { + const result = buildUneditableCloseTokens(originToken); + + expect(result).toHaveLength(2); + expect(result).toStrictEqual(uneditableCloseTokens); + }); + }); + + describe('buildUneditableTokens', () => { + it('returns a 3-item array of tokens with the originToken wrapped in the middle of block tokens', () => { + const result = buildUneditableTokens(originToken); + + expect(result).toHaveLength(3); + expect(result).toStrictEqual(uneditableTokens); + }); + }); + + describe('buildUneditableInlineTokens', () => { + it('returns a 3-item array of tokens with the originInlineToken wrapped in the middle of inline tokens', () => { + const result = buildUneditableInlineTokens(originInlineToken); + + expect(result).toHaveLength(3); + expect(result).toStrictEqual(uneditableInlineTokens); + }); + }); + + describe('buildUneditableHtmlAsTextTokens', () => { + it('returns a 3-item array of tokens with the htmlBlockNode wrapped as a text token in the middle of block tokens', () => { + const htmlBlockNode = { + type: 'htmlBlock', + literal: '<div data-tomark-pass ><h1>Some header</h1><p>Some paragraph</p></div>', + }; + const result = buildUneditableHtmlAsTextTokens(htmlBlockNode); + const { type, content } = result[1]; + + expect(type).toBe('text'); + expect(content).not.toMatch(/ data-tomark-pass /); + + expect(result).toHaveLength(3); + expect(result).toStrictEqual(uneditableBlockTokens); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js new file mode 100644 index 00000000000..660c21281fd --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js @@ -0,0 +1,58 @@ +// Node spec helpers + +export const buildMockTextNode = literal => { + return { + firstChild: null, + literal, + type: 'text', + }; +}; + +export const normalTextNode = buildMockTextNode('This is just normal text.'); + +// Token spec helpers + +const buildMockUneditableOpenToken = type => { + return { + type: 'openTag', + tagName: type, + attributes: { contenteditable: false }, + classNames: [ + 'gl-px-4 gl-py-2 gl-my-5 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed', + ], + }; +}; + +const buildMockUneditableCloseToken = type => { + return { type: 'closeTag', tagName: type }; +}; + +export const originToken = { + type: 'text', + tagName: null, + content: '{:.no_toc .hidden-md .hidden-lg}', +}; +export const uneditableCloseToken = buildMockUneditableCloseToken('div'); +export const uneditableOpenTokens = [buildMockUneditableOpenToken('div'), originToken]; +export const uneditableCloseTokens = [originToken, uneditableCloseToken]; +export const uneditableTokens = [...uneditableOpenTokens, uneditableCloseToken]; + +export const originInlineToken = { + type: 'text', + content: '<i>Inline</i> content', +}; +export const uneditableInlineTokens = [ + buildMockUneditableOpenToken('a'), + originInlineToken, + buildMockUneditableCloseToken('a'), +]; + +export const uneditableBlockTokens = [ + buildMockUneditableOpenToken('div'), + { + type: 'text', + tagName: null, + content: '<div><h1>Some header</h1><p>Some paragraph</p></div>', + }, + buildMockUneditableCloseToken('div'), +]; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js new file mode 100644 index 00000000000..b723ee8c8a0 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js @@ -0,0 +1,30 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text'; +import { buildUneditableTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { buildMockTextNode, normalTextNode } from './mock_data'; + +const embeddedRubyTextNode = buildMockTextNode('<%= partial("some/path") %>'); + +describe('Render Embedded Ruby Text renderer', () => { + describe('canRender', () => { + it('should return true when the argument `literal` has embedded ruby syntax', () => { + expect(renderer.canRender(embeddedRubyTextNode)).toBe(true); + }); + + it('should return false when the argument `literal` lacks embedded ruby syntax', () => { + expect(renderer.canRender(normalTextNode)).toBe(false); + }); + }); + + describe('render', () => { + const origin = jest.fn(); + + it('should return uneditable tokens', () => { + const context = { origin }; + + expect(renderer.render(embeddedRubyTextNode, context)).toStrictEqual( + buildUneditableTokens(origin()), + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js new file mode 100644 index 00000000000..d6bb01259bb --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js @@ -0,0 +1,33 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline'; +import { buildUneditableInlineTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { normalTextNode } from './mock_data'; + +const fontAwesomeInlineHtmlNode = { + firstChild: null, + literal: '<i class="far fa-paper-plane" id="biz-tech-icons">', + type: 'html', +}; + +describe('Render Font Awesome Inline HTML renderer', () => { + describe('canRender', () => { + it('should return true when the argument `literal` has font awesome inline html syntax', () => { + expect(renderer.canRender(fontAwesomeInlineHtmlNode)).toBe(true); + }); + + it('should return false when the argument `literal` lacks font awesome inline html syntax', () => { + expect(renderer.canRender(normalTextNode)).toBe(false); + }); + }); + + describe('render', () => { + it('should return uneditable inline tokens', () => { + const token = { type: 'text', tagName: null, content: fontAwesomeInlineHtmlNode.literal }; + const context = { origin: () => token }; + + expect(renderer.render(fontAwesomeInlineHtmlNode, context)).toStrictEqual( + buildUneditableInlineTokens(token), + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js new file mode 100644 index 00000000000..a6c712eeb31 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js @@ -0,0 +1,38 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_html_block'; +import { buildUneditableHtmlAsTextTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { normalTextNode } from './mock_data'; + +const htmlBlockNode = { + firstChild: null, + literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>', + type: 'htmlBlock', +}; + +describe('Render HTML renderer', () => { + describe('canRender', () => { + it('should return true when the argument is an html block', () => { + expect(renderer.canRender(htmlBlockNode)).toBe(true); + }); + + it('should return false when the argument is not an html block', () => { + expect(renderer.canRender(normalTextNode)).toBe(false); + }); + }); + + describe('render', () => { + const htmlBlockNodeToMark = { + firstChild: null, + literal: '<div data-to-mark ></div>', + type: 'htmlBlock', + }; + + it.each` + node + ${htmlBlockNode} + ${htmlBlockNodeToMark} + `('should return uneditable tokens wrapping the $node as a token', ({ node }) => { + expect(renderer.render(node)).toStrictEqual(buildUneditableHtmlAsTextTokens(node)); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js new file mode 100644 index 00000000000..2897929f1bf --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js @@ -0,0 +1,55 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text'; +import { buildUneditableInlineTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { buildMockTextNode, normalTextNode } from './mock_data'; + +const mockTextStart = 'Majority example '; +const mockTextMiddle = '[environment terraform plans][terraform]'; +const mockTextEnd = '.'; +const identifierInstanceStartTextNode = buildMockTextNode(mockTextStart); +const identifierInstanceEndTextNode = buildMockTextNode(mockTextEnd); + +describe('Render Identifier Instance Text renderer', () => { + describe('canRender', () => { + it.each` + node | target + ${normalTextNode} | ${false} + ${identifierInstanceStartTextNode} | ${false} + ${identifierInstanceEndTextNode} | ${false} + ${buildMockTextNode(mockTextMiddle)} | ${true} + ${buildMockTextNode('Minority example [environment terraform plans][]')} | ${true} + ${buildMockTextNode('Minority example [environment terraform plans]')} | ${true} + `( + 'should return $target when the $node validates against identifier instance syntax', + ({ node, target }) => { + expect(renderer.canRender(node)).toBe(target); + }, + ); + }); + + describe('render', () => { + it.each` + start | middle | end + ${mockTextStart} | ${mockTextMiddle} | ${mockTextEnd} + ${mockTextStart} | ${'[environment terraform plans][]'} | ${mockTextEnd} + ${mockTextStart} | ${'[environment terraform plans]'} | ${mockTextEnd} + `( + 'should return inline editable, uneditable, and editable tokens in sequence', + ({ start, middle, end }) => { + const buildMockTextToken = content => ({ type: 'text', tagName: null, content }); + + const startToken = buildMockTextToken(start); + const middleToken = buildMockTextToken(middle); + const endToken = buildMockTextToken(end); + + const content = `${start}${middle}${end}`; + const contentToken = buildMockTextToken(content); + const contentNode = buildMockTextNode(content); + const context = { origin: jest.fn().mockReturnValueOnce(contentToken) }; + expect(renderer.render(contentNode, context)).toStrictEqual( + [startToken, buildUneditableInlineTokens(middleToken), endToken].flat(), + ); + }, + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js new file mode 100644 index 00000000000..320589e4de3 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js @@ -0,0 +1,65 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph'; +import { + buildUneditableOpenTokens, + buildUneditableCloseToken, +} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { buildMockTextNode } from './mock_data'; + +const buildMockParagraphNode = literal => { + return { + firstChild: buildMockTextNode(literal), + type: 'paragraph', + }; +}; + +const normalParagraphNode = buildMockParagraphNode( + 'This is just normal paragraph. It has multiple sentences.', +); +const identifierParagraphNode = buildMockParagraphNode( + `[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example2.com`, +); + +describe('Render Identifier Paragraph renderer', () => { + describe('canRender', () => { + it.each` + node | paragraph | target + ${identifierParagraphNode} | ${'[Some text]: https://link.com'} | ${true} + ${normalParagraphNode} | ${'Normal non-identifier text. Another sentence.'} | ${false} + `( + 'should return $target when the $node matches $paragraph syntax', + ({ node, paragraph, target }) => { + const context = { + entering: true, + getChildrenText: jest.fn().mockReturnValueOnce(paragraph), + }; + + expect(renderer.canRender(node, context)).toBe(target); + }, + ); + }); + + describe('render', () => { + let origin; + + beforeEach(() => { + origin = jest.fn(); + }); + + it('should return uneditable open tokens when entering', () => { + const context = { entering: true, origin }; + + expect(renderer.render(identifierParagraphNode, context)).toStrictEqual( + buildUneditableOpenTokens(origin()), + ); + }); + + it('should return an uneditable close tokens when exiting', () => { + const context = { entering: false, origin }; + + expect(renderer.render(identifierParagraphNode, context)).toStrictEqual( + buildUneditableCloseToken(origin()), + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js new file mode 100644 index 00000000000..e60bf1c8c92 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js @@ -0,0 +1,55 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list'; +import { + buildUneditableOpenTokens, + buildUneditableCloseToken, +} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { buildMockTextNode } from './mock_data'; + +const buildMockListNode = literal => { + return { + firstChild: { + firstChild: { + firstChild: buildMockTextNode(literal), + type: 'paragraph', + }, + type: 'item', + }, + type: 'list', + }; +}; + +const normalListNode = buildMockListNode('Just another bullet point'); +const kramdownListNode = buildMockListNode('TOC'); + +describe('Render Kramdown List renderer', () => { + describe('canRender', () => { + it('should return true when the argument is a special kramdown TOC ordered/unordered list', () => { + expect(renderer.canRender(kramdownListNode)).toBe(true); + }); + + it('should return false when the argument is a normal ordered/unordered list', () => { + expect(renderer.canRender(normalListNode)).toBe(false); + }); + }); + + describe('render', () => { + const origin = jest.fn(); + + it('should return uneditable open tokens when entering', () => { + const context = { entering: true, origin }; + + expect(renderer.render(kramdownListNode, context)).toStrictEqual( + buildUneditableOpenTokens(origin()), + ); + }); + + it('should return an uneditable close tokens when exiting', () => { + const context = { entering: false, origin }; + + expect(renderer.render(kramdownListNode, context)).toStrictEqual( + buildUneditableCloseToken(origin()), + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js new file mode 100644 index 00000000000..97ff9794e69 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js @@ -0,0 +1,30 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text'; +import { buildUneditableTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { buildMockTextNode, normalTextNode } from './mock_data'; + +const kramdownTextNode = buildMockTextNode('{:toc}'); + +describe('Render Kramdown Text renderer', () => { + describe('canRender', () => { + it('should return true when the argument `literal` has kramdown syntax', () => { + expect(renderer.canRender(kramdownTextNode)).toBe(true); + }); + + it('should return false when the argument `literal` lacks kramdown syntax', () => { + expect(renderer.canRender(normalTextNode)).toBe(false); + }); + }); + + describe('render', () => { + const origin = jest.fn(); + + it('should return uneditable tokens', () => { + const context = { origin }; + + expect(renderer.render(kramdownTextNode, context)).toStrictEqual( + buildUneditableTokens(origin()), + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js index d02d924bd2b..79851e5db05 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js @@ -82,10 +82,9 @@ describe('DropdownButtonComponent', () => { }); it('renders dropdown button icon', () => { - const dropdownIconEl = vm.$el.querySelector('i.fa'); + const dropdownIconEl = vm.$el.querySelector('.dropdown-menu-toggle .gl-icon'); expect(dropdownIconEl).not.toBeNull(); - expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js index 035af946d75..510e537b1cd 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js @@ -29,13 +29,11 @@ describe('DropdownSearchInputComponent', () => { }); it('renders search icon element', () => { - expect(vm.$el.querySelector('.fa-search.dropdown-input-search')).not.toBeNull(); + expect(vm.$el.querySelector('.dropdown-input-search')).not.toBeNull(); }); it('renders clear search icon element', () => { - expect( - vm.$el.querySelector('.fa-times.dropdown-input-clear.js-dropdown-input-clear'), - ).not.toBeNull(); + expect(vm.$el.querySelector('.dropdown-input-clear.js-dropdown-input-clear')).not.toBeNull(); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js index 214eb239432..68c9d26bb1a 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js @@ -1,18 +1,19 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlButton } from '@gitlab/ui'; import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue'; import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; import { mockConfig } from './mock_data'; +let store; const localVue = createLocalVue(); localVue.use(Vuex); const createComponent = (initialState = mockConfig) => { - const store = new Vuex.Store(labelSelectModule()); + store = new Vuex.Store(labelSelectModule()); store.dispatch('setInitialState', initialState); @@ -33,26 +34,32 @@ describe('DropdownButton', () => { wrapper.destroy(); }); + const findDropdownButton = () => wrapper.find(GlButton); + const findDropdownText = () => wrapper.find('.dropdown-toggle-text'); + const findDropdownIcon = () => wrapper.find(GlIcon); + describe('methods', () => { describe('handleButtonClick', () => { - it('calls action `toggleDropdownContents` and stops event propagation when `state.variant` is "standalone"', () => { - const event = { - stopPropagation: jest.fn(), - }; - wrapper = createComponent({ - ...mockConfig, - variant: 'standalone', - }); - - jest.spyOn(wrapper.vm, 'toggleDropdownContents'); - - wrapper.vm.handleButtonClick(event); - - expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled(); - expect(event.stopPropagation).toHaveBeenCalled(); - - wrapper.destroy(); - }); + it.each` + variant + ${'standalone'} + ${'embedded'} + `( + 'toggles dropdown content and stops event propagation when `state.variant` is "$variant"', + ({ variant }) => { + const event = { stopPropagation: jest.fn() }; + + wrapper = createComponent({ + ...mockConfig, + variant, + }); + + findDropdownButton().vm.$emit('click', event); + + expect(store.state.showDropdownContents).toBe(true); + expect(event.stopPropagation).toHaveBeenCalled(); + }, + ); }); }); @@ -61,15 +68,24 @@ describe('DropdownButton', () => { expect(wrapper.is('gl-button-stub')).toBe(true); }); - it('renders button text element', () => { - const dropdownTextEl = wrapper.find('.dropdown-toggle-text'); + it('renders default button text element', () => { + const dropdownTextEl = findDropdownText(); expect(dropdownTextEl.exists()).toBe(true); expect(dropdownTextEl.text()).toBe('Label'); }); + it('renders provided button text element', () => { + store.state.dropdownButtonText = 'Custom label'; + const dropdownTextEl = findDropdownText(); + + return wrapper.vm.$nextTick().then(() => { + expect(dropdownTextEl.text()).toBe('Custom label'); + }); + }); + it('renders chevron icon element', () => { - const iconEl = wrapper.find(GlIcon); + const iconEl = findDropdownIcon(); expect(iconEl.exists()).toBe(true); expect(iconEl.props('name')).toBe('chevron-down'); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index 1504e1521d3..9b01e0b9637 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -44,6 +44,7 @@ const createComponent = (initialState = mockConfig) => { describe('DropdownContentsLabelsView', () => { let wrapper; let wrapperStandalone; + let wrapperEmbedded; beforeEach(() => { wrapper = createComponent(); @@ -51,11 +52,16 @@ describe('DropdownContentsLabelsView', () => { ...mockConfig, variant: 'standalone', }); + wrapperEmbedded = createComponent({ + ...mockConfig, + variant: 'embedded', + }); }); afterEach(() => { wrapper.destroy(); wrapperStandalone.destroy(); + wrapperEmbedded.destroy(); }); describe('computed', () => { @@ -211,6 +217,10 @@ describe('DropdownContentsLabelsView', () => { expect(wrapperStandalone.find('.dropdown-title').exists()).toBe(false); }); + it('renders dropdown title element when `state.variant` is "embedded"', () => { + expect(wrapperEmbedded.find('.dropdown-title').exists()).toBe(true); + }); + it('renders dropdown close button element', () => { const closeButtonEl = wrapper.find('.dropdown-title').find(GlButton); @@ -291,5 +301,9 @@ describe('DropdownContentsLabelsView', () => { it('does not render footer list items when `state.variant` is "standalone"', () => { expect(wrapperStandalone.find('.dropdown-footer').exists()).toBe(false); }); + + it('renders footer list items when `state.variant` is "embedded"', () => { + expect(wrapperEmbedded.find('.dropdown-footer').exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js index ee4e9090e5d..6e97b046be2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js @@ -89,18 +89,23 @@ describe('LabelsSelectRoot', () => { expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative'); }); - it('renders component root element with CSS class `is-standalone` when `state.variant` is "standalone"', () => { - const wrapperStandalone = createComponent({ - ...mockConfig, - variant: 'standalone', - }); - - return wrapperStandalone.vm.$nextTick(() => { - expect(wrapperStandalone.classes()).toContain('is-standalone'); - - wrapperStandalone.destroy(); - }); - }); + it.each` + variant | cssClass + ${'standalone'} | ${'is-standalone'} + ${'embedded'} | ${'is-embedded'} + `( + 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"', + ({ variant, cssClass }) => { + wrapper = createComponent({ + ...mockConfig, + variant, + }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.classes()).toContain(cssClass); + }); + }, + ); it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', () => { expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js index b866117efcf..52116f757c5 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js @@ -2,13 +2,20 @@ import * as getters from '~/vue_shared/components/sidebar/labels_select_vue/stor describe('LabelsSelect Getters', () => { describe('dropdownButtonText', () => { - it('returns string "Label" when state.labels has no selected labels', () => { - const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - - expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe( - 'Label', - ); - }); + it.each` + labelType | dropdownButtonText | expected + ${'default'} | ${''} | ${'Label'} + ${'custom'} | ${'Custom label'} | ${'Custom label'} + `( + 'returns $labelType text when state.labels has no selected labels', + ({ dropdownButtonText, expected }) => { + const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + const selectedLabels = []; + const state = { labels, selectedLabels, dropdownButtonText }; + + expect(getters.dropdownButtonText(state, {})).toBe(expected); + }, + ); it('returns label title when state.labels has only 1 label', () => { const labels = [{ id: 1, title: 'Foobar', set: true }]; diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index 2c7fce714f0..a4ff6ac0c16 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -4,7 +4,6 @@ import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue'; import Icon from '~/vue_shared/components/icon.vue'; const DEFAULT_PROPS = { - loaded: true, user: { username: 'root', name: 'Administrator', @@ -12,6 +11,7 @@ const DEFAULT_PROPS = { bio: null, workInformation: null, status: null, + loaded: true, }, }; @@ -46,28 +46,21 @@ describe('User Popover Component', () => { }); }; - describe('Empty', () => { - beforeEach(() => { - createWrapper( - {}, - { - propsData: { - target: findTarget(), - user: { - name: null, - username: null, - location: null, - bio: null, - workInformation: null, - status: null, - }, - }, + describe('when user is loading', () => { + it('displays skeleton loaders', () => { + createWrapper({ + user: { + name: null, + username: null, + location: null, + bio: null, + workInformation: null, + status: null, + loaded: false, }, - ); - }); + }); - it('should return skeleton loaders', () => { - expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true); + expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(4); }); }); @@ -90,9 +83,10 @@ describe('User Popover Component', () => { describe('job data', () => { const findWorkInformation = () => wrapper.find({ ref: 'workInformation' }); const findBio = () => wrapper.find({ ref: 'bio' }); + const bio = 'My super interesting bio'; it('should show only bio if work information is not available', () => { - const user = { ...DEFAULT_PROPS.user, bio: 'My super interesting bio' }; + const user = { ...DEFAULT_PROPS.user, bio, bioHtml: bio }; createWrapper({ user }); @@ -114,7 +108,8 @@ describe('User Popover Component', () => { it('should display bio and work information in separate lines', () => { const user = { ...DEFAULT_PROPS.user, - bio: 'My super interesting bio', + bio, + bioHtml: bio, workInformation: 'Frontend Engineer at GitLab', }; @@ -127,12 +122,13 @@ describe('User Popover Component', () => { it('should not encode special characters in bio', () => { const user = { ...DEFAULT_PROPS.user, - bio: 'I like <html> & CSS', + bio: 'I like CSS', + bioHtml: 'I like <b>CSS</b>', }; createWrapper({ user }); - expect(findBio().text()).toBe('I like <html> & CSS'); + expect(findBio().html()).toContain('I like <b>CSS</b>'); }); it('shows icon for bio', () => { |