diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-09-20 02:18:09 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-09-20 02:18:09 +0300 |
commit | 6ed4ec3e0b1340f96b7c043ef51d1b33bbe85fde (patch) | |
tree | dc4d20fe6064752c0bd323187252c77e0a89144b /spec/frontend/content_editor/components | |
parent | 9868dae7fc0655bd7ce4a6887d4e6d487690eeed (diff) |
Add latest changes from gitlab-org/gitlab@15-4-stable-eev15.4.0-rc42
Diffstat (limited to 'spec/frontend/content_editor/components')
16 files changed, 528 insertions, 218 deletions
diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap index b54f7cf17c8..6ad8a9de8d3 100644 --- a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap +++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap @@ -1,49 +1,33 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`content_editor/components/toolbar_link_button renders dropdown component 1`] = ` -"<div class=\\"dropdown b-dropdown gl-new-dropdown btn-group\\" aria-label=\\"Insert link\\" title=\\"Insert link\\"> - <!----><button aria-haspopup=\\"true\\" aria-expanded=\\"false\\" type=\\"button\\" class=\\"btn dropdown-toggle btn-default btn-sm gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only\\"> - <!----> <svg data-testid=\\"link-icon\\" role=\\"img\\" aria-hidden=\\"true\\" class=\\"dropdown-icon gl-icon s16\\"> - <use href=\\"#link\\"></use> - </svg> <span class=\\"gl-new-dropdown-button-text\\"></span> <svg data-testid=\\"chevron-down-icon\\" role=\\"img\\" aria-hidden=\\"true\\" class=\\"gl-button-icon dropdown-chevron gl-icon s16\\"> - <use href=\\"#chevron-down\\"></use> - </svg></button> - <ul role=\\"menu\\" tabindex=\\"-1\\" class=\\"dropdown-menu\\"> - <div class=\\"gl-new-dropdown-inner\\"> +"<div title=\\"Insert link\\" lazy=\\"\\"> + <li role=\\"presentation\\" class=\\"gl-px-3!\\"> + <form tabindex=\\"-1\\" class=\\"b-dropdown-form gl-p-0\\"> + <div role=\\"group\\" class=\\"input-group\\" placeholder=\\"Link URL\\"> + <!----> + <!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"form-control gl-form-input\\"> + <div class=\\"input-group-append\\"><button type=\\"button\\" class=\\"btn btn-confirm btn-md gl-button\\"> + <!----> + <!----> <span class=\\"gl-button-text\\">Apply</span></button></div> + <!----> + </div> + </form> + </li> + <li role=\\"presentation\\" class=\\"gl-new-dropdown-divider\\"> + <hr role=\\"separator\\" aria-orientation=\\"horizontal\\" class=\\"dropdown-divider\\"> + </li> + <li role=\\"presentation\\" class=\\"gl-new-dropdown-item\\"><button role=\\"menuitem\\" type=\\"button\\" class=\\"dropdown-item\\"> + <!----> <!----> <!----> - <div class=\\"gl-new-dropdown-contents\\"> + <div class=\\"gl-new-dropdown-item-text-wrapper\\"> + <p class=\\"gl-new-dropdown-item-text-primary\\"> + Upload file + </p> <!----> - <li role=\\"presentation\\" class=\\"gl-px-3!\\"> - <form tabindex=\\"-1\\" class=\\"b-dropdown-form gl-p-0\\"> - <div role=\\"group\\" class=\\"input-group\\" placeholder=\\"Link URL\\"> - <!----> - <!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"form-control gl-form-input\\"> - <div class=\\"input-group-append\\"><button type=\\"button\\" class=\\"btn btn-confirm btn-md gl-button\\"> - <!----> - <!----> <span class=\\"gl-button-text\\">Apply</span></button></div> - <!----> - </div> - </form> - </li> - <li role=\\"presentation\\" class=\\"gl-new-dropdown-divider\\"> - <hr role=\\"separator\\" aria-orientation=\\"horizontal\\" class=\\"dropdown-divider\\"> - </li> - <li role=\\"presentation\\" class=\\"gl-new-dropdown-item\\"><button role=\\"menuitem\\" type=\\"button\\" class=\\"dropdown-item\\"> - <!----> - <!----> - <!----> - <div class=\\"gl-new-dropdown-item-text-wrapper\\"> - <p class=\\"gl-new-dropdown-item-text-primary\\"> - Upload file - </p> - <!----> - </div> - <!----> - </button></li> <input type=\\"file\\" name=\\"content_editor_attachment\\" class=\\"gl-display-none\\"> </div> <!----> - </div> - </ul> + </button></li> </div>" `; diff --git a/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js new file mode 100644 index 00000000000..0700cf5d529 --- /dev/null +++ b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js @@ -0,0 +1,126 @@ +import { BubbleMenuPlugin } from '@tiptap/extension-bubble-menu'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue'; +import { createTestEditor } from '../../test_utils'; + +jest.mock('@tiptap/extension-bubble-menu'); + +describe('content_editor/components/bubble_menus/bubble_menu', () => { + let wrapper; + let tiptapEditor; + const pluginKey = 'key'; + const shouldShow = jest.fn(); + const tippyOptions = { placement: 'bottom' }; + const pluginInitializationResult = {}; + + const buildEditor = () => { + tiptapEditor = createTestEditor(); + }; + + const createWrapper = (propsData = {}) => { + wrapper = shallowMountExtended(BubbleMenu, { + provide: { + tiptapEditor, + }, + propsData: { + pluginKey, + shouldShow, + tippyOptions, + ...propsData, + }, + slots: { + default: '<div>menu content</div>', + }, + }); + }; + + const setupMocks = () => { + BubbleMenuPlugin.mockReturnValueOnce(pluginInitializationResult); + jest.spyOn(tiptapEditor, 'registerPlugin').mockImplementationOnce(() => true); + }; + + const invokeTippyEvent = (eventName, eventArgs) => { + const pluginConfig = BubbleMenuPlugin.mock.calls[0][0]; + + pluginConfig.tippyOptions[eventName](eventArgs); + }; + + beforeEach(() => { + buildEditor(); + setupMocks(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('initializes BubbleMenuPlugin', async () => { + createWrapper({}); + + await nextTick(); + + expect(BubbleMenuPlugin).toHaveBeenCalledWith({ + pluginKey, + editor: tiptapEditor, + shouldShow, + element: wrapper.vm.$el, + tippyOptions: expect.objectContaining({ + onHidden: expect.any(Function), + onShow: expect.any(Function), + ...tippyOptions, + }), + }); + + expect(tiptapEditor.registerPlugin).toHaveBeenCalledWith(pluginInitializationResult); + }); + + it('does not render default slot by default', async () => { + createWrapper({}); + + await nextTick(); + + expect(wrapper.text()).not.toContain('menu content'); + }); + + describe('when onShow event handler is invoked', () => { + const onShowArgs = {}; + + beforeEach(async () => { + createWrapper({}); + + await nextTick(); + + invokeTippyEvent('onShow', onShowArgs); + }); + + it('displays the menu content', () => { + expect(wrapper.text()).toContain('menu content'); + }); + + it('emits show event', () => { + expect(wrapper.emitted('show')).toEqual([[onShowArgs]]); + }); + }); + + describe('when onHidden event handler is invoked', () => { + const onHiddenArgs = {}; + + beforeEach(async () => { + createWrapper({}); + + await nextTick(); + + invokeTippyEvent('onShow', onHiddenArgs); + invokeTippyEvent('onHidden', onHiddenArgs); + }); + + it('displays the menu content', () => { + expect(wrapper.text()).not.toContain('menu content'); + }); + + it('emits show event', () => { + expect(wrapper.emitted('hidden')).toEqual([[onHiddenArgs]]); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js b/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js index 154035a46ed..378b11f4ae9 100644 --- a/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js @@ -1,4 +1,3 @@ -import { BubbleMenu } from '@tiptap/vue-2'; import { GlDropdown, GlDropdownForm, @@ -9,8 +8,9 @@ import { import { nextTick } from 'vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { stubComponent } from 'helpers/stub_component'; -import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block.vue'; +import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block_bubble_menu.vue'; import eventHubFactory from '~/helpers/event_hub_factory'; +import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; import Diagram from '~/content_editor/extensions/diagram'; import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader'; @@ -18,7 +18,7 @@ import { createTestEditor, emitEditorEvent } from '../../test_utils'; const createFakeEvent = () => ({ preventDefault: jest.fn(), stopPropagation: jest.fn() }); -describe('content_editor/components/bubble_menus/code_block', () => { +describe('content_editor/components/bubble_menus/code_block_bubble_menu', () => { let wrapper; let tiptapEditor; let contentEditor; @@ -40,6 +40,7 @@ describe('content_editor/components/bubble_menus/code_block', () => { }, stubs: { GlDropdownItem: stubComponent(GlDropdownItem), + BubbleMenu: stubComponent(BubbleMenu), }, }); }; @@ -73,7 +74,6 @@ describe('content_editor/components/bubble_menus/code_block', () => { await emitEditorEvent({ event: 'transaction', tiptapEditor }); - expect(bubbleMenu.props('editor')).toBe(tiptapEditor); expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']); }); diff --git a/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js b/spec/frontend/content_editor/components/bubble_menus/formatting_bubble_menu_spec.js index 1e2f58d9e40..cce17176129 100644 --- a/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/formatting_bubble_menu_spec.js @@ -1,7 +1,8 @@ -import { BubbleMenu } from '@tiptap/vue-2'; import { mockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import FormattingBubbleMenu from '~/content_editor/components/bubble_menus/formatting.vue'; +import FormattingBubbleMenu from '~/content_editor/components/bubble_menus/formatting_bubble_menu.vue'; +import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue'; +import { stubComponent } from 'helpers/stub_component'; import { BUBBLE_MENU_TRACKING_ACTION, @@ -9,7 +10,7 @@ import { } from '~/content_editor/constants'; import { createTestEditor } from '../../test_utils'; -describe('content_editor/components/bubble_menus/formatting', () => { +describe('content_editor/components/bubble_menus/formatting_bubble_menu', () => { let wrapper; let trackingSpy; let tiptapEditor; @@ -25,6 +26,9 @@ describe('content_editor/components/bubble_menus/formatting', () => { provide: { tiptapEditor, }, + stubs: { + BubbleMenu: stubComponent(BubbleMenu), + }, }); }; @@ -41,7 +45,6 @@ describe('content_editor/components/bubble_menus/formatting', () => { buildWrapper(); const bubbleMenu = wrapper.findComponent(BubbleMenu); - expect(bubbleMenu.props().editor).toBe(tiptapEditor); expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']); }); diff --git a/spec/frontend/content_editor/components/bubble_menus/link_spec.js b/spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js index 93204deb68c..9aa9c6483f4 100644 --- a/spec/frontend/content_editor/components/bubble_menus/link_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js @@ -1,18 +1,20 @@ import { GlLink, GlForm } from '@gitlab/ui'; -import { BubbleMenu } from '@tiptap/vue-2'; +import { nextTick } from 'vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link.vue'; +import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link_bubble_menu.vue'; +import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; import eventHubFactory from '~/helpers/event_hub_factory'; +import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue'; +import { stubComponent } from 'helpers/stub_component'; import Link from '~/content_editor/extensions/link'; -import { createTestEditor, emitEditorEvent } from '../../test_utils'; +import { createTestEditor } from '../../test_utils'; const createFakeEvent = () => ({ preventDefault: jest.fn(), stopPropagation: jest.fn() }); -describe('content_editor/components/bubble_menus/link', () => { +describe('content_editor/components/bubble_menus/link_bubble_menu', () => { let wrapper; let tiptapEditor; let contentEditor; - let bubbleMenu; let eventHub; const buildEditor = () => { @@ -28,9 +30,28 @@ describe('content_editor/components/bubble_menus/link', () => { contentEditor, eventHub, }, + stubs: { + BubbleMenu: stubComponent(BubbleMenu), + }, }); }; + const showMenu = () => { + wrapper.findComponent(BubbleMenu).vm.$emit('show'); + return nextTick(); + }; + + const buildWrapperAndDisplayMenu = () => { + buildWrapper(); + + return showMenu(); + }; + + const findBubbleMenu = () => wrapper.findComponent(BubbleMenu); + const findLink = () => wrapper.findComponent(GlLink); + const findEditorStateObserver = () => wrapper.findComponent(EditorStateObserver); + const findEditLinkButton = () => wrapper.findByTestId('edit-link'); + const expectLinkButtonsToExist = (exist = true) => { expect(wrapper.findComponent(GlLink).exists()).toBe(exist); expect(wrapper.findByTestId('copy-link-url').exists()).toBe(exist); @@ -40,7 +61,6 @@ describe('content_editor/components/bubble_menus/link', () => { beforeEach(async () => { buildEditor(); - buildWrapper(); tiptapEditor .chain() @@ -49,10 +69,6 @@ describe('content_editor/components/bubble_menus/link', () => { ) .setTextSelection(14) // put cursor in the middle of the link .run(); - - await emitEditorEvent({ event: 'transaction', tiptapEditor }); - - bubbleMenu = wrapper.findComponent(BubbleMenu); }); afterEach(() => { @@ -60,13 +76,15 @@ describe('content_editor/components/bubble_menus/link', () => { }); it('renders bubble menu component', async () => { - expect(bubbleMenu.props('editor')).toBe(tiptapEditor); - expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']); + await buildWrapperAndDisplayMenu(); + + expect(findBubbleMenu().classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']); }); it('shows a clickable link to the URL in the link node', async () => { - const link = wrapper.findComponent(GlLink); - expect(link.attributes()).toEqual( + await buildWrapperAndDisplayMenu(); + + expect(findLink().attributes()).toEqual( expect.objectContaining({ href: '/path/to/project/-/wikis/uploads/my_file.pdf', 'aria-label': 'uploads/my_file.pdf', @@ -74,11 +92,82 @@ describe('content_editor/components/bubble_menus/link', () => { target: '_blank', }), ); - expect(link.text()).toBe('uploads/my_file.pdf'); + expect(findLink().text()).toBe('uploads/my_file.pdf'); + }); + + it('updates the bubble menu state when @selectionUpdate event is triggered', async () => { + const linkUrl = 'https://gitlab.com'; + + await buildWrapperAndDisplayMenu(); + + expect(findLink().attributes()).toEqual( + expect.objectContaining({ + href: '/path/to/project/-/wikis/uploads/my_file.pdf', + }), + ); + + tiptapEditor + .chain() + .setContent( + `Link to <a href="${linkUrl}" data-canonical-src="${linkUrl}" title="Click here to download">GitLab</a>`, + ) + .setTextSelection(11) + .run(); + + findEditorStateObserver().vm.$emit('selectionUpdate'); + + await nextTick(); + + expect(findLink().attributes()).toEqual( + expect.objectContaining({ + href: linkUrl, + }), + ); + }); + + describe('when the selection changes within the same link', () => { + it('does not update the bubble menu state', async () => { + await buildWrapperAndDisplayMenu(); + + await findEditLinkButton().trigger('click'); + + expect(wrapper.findComponent(GlForm).exists()).toBe(true); + + tiptapEditor.commands.setTextSelection(13); + + findEditorStateObserver().vm.$emit('selectionUpdate'); + + await nextTick(); + + expect(wrapper.findComponent(GlForm).exists()).toBe(true); + }); + }); + + it('cleans bubble menu state when hidden event is triggered', async () => { + await buildWrapperAndDisplayMenu(); + + expect(findLink().attributes()).toEqual( + expect.objectContaining({ + href: '/path/to/project/-/wikis/uploads/my_file.pdf', + }), + ); + + findBubbleMenu().vm.$emit('hidden'); + + await nextTick(); + + expect(findLink().attributes()).toEqual( + expect.objectContaining({ + href: '#', + }), + ); + expect(findLink().text()).toEqual(''); }); describe('copy button', () => { it('copies the canonical link to clipboard', async () => { + await buildWrapperAndDisplayMenu(); + jest.spyOn(navigator.clipboard, 'writeText'); await wrapper.findByTestId('copy-link-url').vm.$emit('click'); @@ -89,6 +178,7 @@ describe('content_editor/components/bubble_menus/link', () => { describe('remove link button', () => { it('removes the link', async () => { + await buildWrapperAndDisplayMenu(); await wrapper.findByTestId('remove-link').vm.$emit('click'); expect(tiptapEditor.getHTML()).toBe('<p>Download PDF File</p>'); @@ -106,7 +196,7 @@ describe('content_editor/components/bubble_menus/link', () => { .setTextSelection(4) .run(); - await emitEditorEvent({ event: 'transaction', tiptapEditor }); + await buildWrapperAndDisplayMenu(); }); it('directly opens the edit form for a placeholder link', async () => { @@ -133,6 +223,7 @@ describe('content_editor/components/bubble_menus/link', () => { let linkTitleInput; beforeEach(async () => { + await buildWrapperAndDisplayMenu(); await wrapper.findByTestId('edit-link').vm.$emit('click'); linkHrefInput = wrapper.findByTestId('link-href'); @@ -157,19 +248,6 @@ describe('content_editor/components/bubble_menus/link', () => { expect(to).toBe(18); }); - it('shows the copy/edit/remove link buttons again if selection changes to another non-link and then back again to a link', async () => { - expectLinkButtonsToExist(false); - - tiptapEditor.commands.setTextSelection(3); - await emitEditorEvent({ event: 'transaction', tiptapEditor }); - - tiptapEditor.commands.setTextSelection(14); - await emitEditorEvent({ event: 'transaction', tiptapEditor }); - - expectLinkButtonsToExist(true); - expect(wrapper.findComponent(GlForm).exists()).toBe(false); - }); - describe('after making changes in the form and clicking apply', () => { beforeEach(async () => { linkHrefInput.setValue('https://google.com'); diff --git a/spec/frontend/content_editor/components/bubble_menus/media_spec.js b/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js index fada4f06743..13c6495ac41 100644 --- a/spec/frontend/content_editor/components/bubble_menus/media_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js @@ -1,7 +1,8 @@ import { GlLink, GlForm } from '@gitlab/ui'; -import { BubbleMenu } from '@tiptap/vue-2'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media.vue'; +import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue'; +import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media_bubble_menu.vue'; +import { stubComponent } from 'helpers/stub_component'; import eventHubFactory from '~/helpers/event_hub_factory'; import Image from '~/content_editor/extensions/image'; import Audio from '~/content_editor/extensions/audio'; @@ -33,7 +34,7 @@ describe.each` ${'audio'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${'test-file.mp3'} | ${TIPTAP_AUDIO_HTML} ${'video'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${'test-file.mp4'} | ${TIPTAP_VIDEO_HTML} `( - 'content_editor/components/bubble_menus/media ($mediaType)', + 'content_editor/components/bubble_menus/media_bubble_menu ($mediaType)', ({ mediaType, mediaHTML, filePath, mediaOutputHTML }) => { let wrapper; let tiptapEditor; @@ -54,11 +55,14 @@ describe.each` contentEditor, eventHub, }, + stubs: { + BubbleMenu: stubComponent(BubbleMenu), + }, }); }; const selectFile = async (file) => { - const input = wrapper.find({ ref: 'fileSelector' }); + const input = wrapper.findComponent({ ref: 'fileSelector' }); // override the property definition because `input.files` isn't directly modifyable Object.defineProperty(input.element, 'files', { value: [file], writable: true }); @@ -94,7 +98,6 @@ describe.each` }); it('renders bubble menu component', async () => { - expect(bubbleMenu.props('editor')).toBe(tiptapEditor); expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']); }); diff --git a/spec/frontend/content_editor/components/content_editor_alert_spec.js b/spec/frontend/content_editor/components/content_editor_alert_spec.js index 12484cb13c6..ee9ead8f8a7 100644 --- a/spec/frontend/content_editor/components/content_editor_alert_spec.js +++ b/spec/frontend/content_editor/components/content_editor_alert_spec.js @@ -51,6 +51,16 @@ describe('content_editor/components/content_editor_alert', () => { }, ); + it('does not show primary action by default', async () => { + const message = 'error message'; + + createWrapper(); + eventHub.$emit(ALERT_EVENT, { message }); + await nextTick(); + + expect(findErrorAlert().attributes().primaryButtonText).toBeUndefined(); + }); + it('allows dismissing the error', async () => { const message = 'error message'; @@ -62,4 +72,19 @@ describe('content_editor/components/content_editor_alert', () => { expect(findErrorAlert().exists()).toBe(false); }); + + it('allows dismissing the error with a primary action button', async () => { + const message = 'error message'; + const actionLabel = 'Retry'; + const action = jest.fn(); + + createWrapper(); + eventHub.$emit(ALERT_EVENT, { message, action, actionLabel }); + await nextTick(); + findErrorAlert().vm.$emit('primaryAction'); + await nextTick(); + + expect(action).toHaveBeenCalled(); + expect(findErrorAlert().exists()).toBe(false); + }); }); diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 0ba2672100b..ae52cb05eaf 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -1,136 +1,227 @@ -import { EditorContent } from '@tiptap/vue-2'; +import { GlAlert } from '@gitlab/ui'; +import { EditorContent, Editor } from '@tiptap/vue-2'; +import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ContentEditor from '~/content_editor/components/content_editor.vue'; import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue'; import ContentEditorProvider from '~/content_editor/components/content_editor_provider.vue'; import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; -import FormattingBubbleMenu from '~/content_editor/components/bubble_menus/formatting.vue'; +import FormattingBubbleMenu from '~/content_editor/components/bubble_menus/formatting_bubble_menu.vue'; +import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block_bubble_menu.vue'; +import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link_bubble_menu.vue'; +import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media_bubble_menu.vue'; import TopToolbar from '~/content_editor/components/top_toolbar.vue'; import LoadingIndicator from '~/content_editor/components/loading_indicator.vue'; -import { emitEditorEvent } from '../test_utils'; +import waitForPromises from 'helpers/wait_for_promises'; jest.mock('~/emoji'); describe('ContentEditor', () => { let wrapper; - let contentEditor; let renderMarkdown; const uploadsPath = '/uploads'; const findEditorElement = () => wrapper.findByTestId('content-editor'); const findEditorContent = () => wrapper.findComponent(EditorContent); const findEditorStateObserver = () => wrapper.findComponent(EditorStateObserver); - const createWrapper = (propsData = {}) => { - renderMarkdown = jest.fn(); - + const findLoadingIndicator = () => wrapper.findComponent(LoadingIndicator); + const findContentEditorAlert = () => wrapper.findComponent(ContentEditorAlert); + const createWrapper = ({ markdown } = {}) => { wrapper = shallowMountExtended(ContentEditor, { propsData: { renderMarkdown, uploadsPath, - ...propsData, + markdown, }, stubs: { EditorStateObserver, ContentEditorProvider, - }, - listeners: { - initialized(editor) { - contentEditor = editor; - }, + ContentEditorAlert, }, }); }; + beforeEach(() => { + renderMarkdown = jest.fn(); + }); + afterEach(() => { wrapper.destroy(); }); - it('triggers initialized event and provides contentEditor instance as event data', () => { + it('triggers initialized event', () => { createWrapper(); - expect(contentEditor).not.toBeFalsy(); + expect(wrapper.emitted('initialized')).toHaveLength(1); }); - it('renders EditorContent component and provides tiptapEditor instance', () => { - createWrapper(); + it('renders EditorContent component and provides tiptapEditor instance', async () => { + const markdown = 'hello world'; + + createWrapper({ markdown }); + + renderMarkdown.mockResolvedValueOnce(markdown); + + await nextTick(); const editorContent = findEditorContent(); - expect(editorContent.props().editor).toBe(contentEditor.tiptapEditor); + expect(editorContent.props().editor).toBeInstanceOf(Editor); expect(editorContent.classes()).toContain('md'); }); - it('renders ContentEditorProvider component', () => { - createWrapper(); + it('renders ContentEditorProvider component', async () => { + await createWrapper(); expect(wrapper.findComponent(ContentEditorProvider).exists()).toBe(true); }); - it('renders top toolbar component', () => { - createWrapper(); + it('renders top toolbar component', async () => { + await createWrapper(); expect(wrapper.findComponent(TopToolbar).exists()).toBe(true); }); - it('adds is-focused class when focus event is emitted', async () => { - createWrapper(); + describe('when setting initial content', () => { + it('displays loading indicator', async () => { + createWrapper(); - await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' }); + await nextTick(); - expect(findEditorElement().classes()).toContain('is-focused'); - }); + expect(findLoadingIndicator().exists()).toBe(true); + }); - it('removes is-focused class when blur event is emitted', async () => { - createWrapper(); + it('emits loading event', async () => { + createWrapper(); - await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' }); - await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'blur' }); + await nextTick(); - expect(findEditorElement().classes()).not.toContain('is-focused'); - }); + expect(wrapper.emitted('loading')).toHaveLength(1); + }); - it('emits change event when document is updated', async () => { - createWrapper(); + describe('succeeds', () => { + beforeEach(async () => { + renderMarkdown.mockResolvedValueOnce('hello world'); - await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'update' }); + createWrapper({ markddown: 'hello world' }); + await nextTick(); + }); - expect(wrapper.emitted('change')).toEqual([ - [ - { - empty: contentEditor.empty, - }, - ], - ]); - }); + it('hides loading indicator', async () => { + await nextTick(); + expect(findLoadingIndicator().exists()).toBe(false); + }); - it('renders content_editor_alert component', () => { - createWrapper(); + it('emits loadingSuccess event', () => { + expect(wrapper.emitted('loadingSuccess')).toHaveLength(1); + }); + }); + + describe('fails', () => { + beforeEach(async () => { + renderMarkdown.mockRejectedValueOnce(new Error()); + + createWrapper({ markddown: 'hello world' }); + await nextTick(); + }); + + it('sets the content editor as read only when loading content fails', async () => { + await nextTick(); - expect(wrapper.findComponent(ContentEditorAlert).exists()).toBe(true); + expect(findEditorContent().props().editor.isEditable).toBe(false); + }); + + it('hides loading indicator', async () => { + await nextTick(); + + expect(findLoadingIndicator().exists()).toBe(false); + }); + + it('emits loadingError event', () => { + expect(wrapper.emitted('loadingError')).toHaveLength(1); + }); + + it('displays error alert indicating that the content editor failed to load', () => { + expect(findContentEditorAlert().text()).toContain( + 'An error occurred while trying to render the content editor. Please try again.', + ); + }); + + describe('when clicking the retry button in the loading error alert and loading succeeds', () => { + beforeEach(async () => { + renderMarkdown.mockResolvedValueOnce('hello markdown'); + await wrapper.findComponent(GlAlert).vm.$emit('primaryAction'); + }); + + it('hides the loading error alert', () => { + expect(findContentEditorAlert().text()).toBe(''); + }); + + it('sets the content editor as writable', async () => { + await nextTick(); + + expect(findEditorContent().props().editor.isEditable).toBe(true); + }); + }); + }); }); - it('renders loading indicator component', () => { - createWrapper(); + describe('when focused event is emitted', () => { + beforeEach(async () => { + createWrapper(); + + findEditorStateObserver().vm.$emit('focus'); + + await nextTick(); + }); - expect(wrapper.findComponent(LoadingIndicator).exists()).toBe(true); + it('adds is-focused class when focus event is emitted', () => { + expect(findEditorElement().classes()).toContain('is-focused'); + }); + + it('removes is-focused class when blur event is emitted', async () => { + findEditorStateObserver().vm.$emit('blur'); + + await nextTick(); + + expect(findEditorElement().classes()).not.toContain('is-focused'); + }); }); - it('renders formatting bubble menu', () => { - createWrapper(); + describe('when editorStateObserver emits docUpdate event', () => { + it('emits change event with the latest markdown', async () => { + const markdown = 'Loaded content'; - expect(wrapper.findComponent(FormattingBubbleMenu).exists()).toBe(true); + renderMarkdown.mockResolvedValueOnce(markdown); + + createWrapper({ markdown: 'initial content' }); + + await nextTick(); + await waitForPromises(); + + findEditorStateObserver().vm.$emit('docUpdate'); + + expect(wrapper.emitted('change')).toEqual([ + [ + { + markdown, + changed: false, + empty: false, + }, + ], + ]); + }); }); it.each` - event - ${'loading'} - ${'loadingSuccess'} - ${'loadingError'} - `('broadcasts $event event triggered by editor-state-observer component', ({ event }) => { + name | component + ${'formatting'} | ${FormattingBubbleMenu} + ${'link'} | ${LinkBubbleMenu} + ${'media'} | ${MediaBubbleMenu} + ${'codeBlock'} | ${CodeBlockBubbleMenu} + `('renders formatting bubble menu', ({ component }) => { createWrapper(); - findEditorStateObserver().vm.$emit(event); - - expect(wrapper.emitted(event)).toHaveLength(1); + expect(wrapper.findComponent(component).exists()).toBe(true); }); }); diff --git a/spec/frontend/content_editor/components/editor_state_observer_spec.js b/spec/frontend/content_editor/components/editor_state_observer_spec.js index 51a594a606b..e8c2d8c8793 100644 --- a/spec/frontend/content_editor/components/editor_state_observer_spec.js +++ b/spec/frontend/content_editor/components/editor_state_observer_spec.js @@ -4,12 +4,7 @@ import EditorStateObserver, { tiptapToComponentMap, } from '~/content_editor/components/editor_state_observer.vue'; import eventHubFactory from '~/helpers/event_hub_factory'; -import { - LOADING_CONTENT_EVENT, - LOADING_SUCCESS_EVENT, - LOADING_ERROR_EVENT, - ALERT_EVENT, -} from '~/content_editor/constants'; +import { ALERT_EVENT } from '~/content_editor/constants'; import { createTestEditor } from '../test_utils'; describe('content_editor/components/editor_state_observer', () => { @@ -18,9 +13,6 @@ describe('content_editor/components/editor_state_observer', () => { let onDocUpdateListener; let onSelectionUpdateListener; let onTransactionListener; - let onLoadingContentListener; - let onLoadingSuccessListener; - let onLoadingErrorListener; let onAlertListener; let eventHub; @@ -38,9 +30,6 @@ describe('content_editor/components/editor_state_observer', () => { selectionUpdate: onSelectionUpdateListener, transaction: onTransactionListener, [ALERT_EVENT]: onAlertListener, - [LOADING_CONTENT_EVENT]: onLoadingContentListener, - [LOADING_SUCCESS_EVENT]: onLoadingSuccessListener, - [LOADING_ERROR_EVENT]: onLoadingErrorListener, }, }); }; @@ -50,9 +39,6 @@ describe('content_editor/components/editor_state_observer', () => { onSelectionUpdateListener = jest.fn(); onTransactionListener = jest.fn(); onAlertListener = jest.fn(); - onLoadingSuccessListener = jest.fn(); - onLoadingContentListener = jest.fn(); - onLoadingErrorListener = jest.fn(); buildEditor(); }); @@ -81,11 +67,8 @@ describe('content_editor/components/editor_state_observer', () => { }); it.each` - event | listener - ${ALERT_EVENT} | ${() => onAlertListener} - ${LOADING_CONTENT_EVENT} | ${() => onLoadingContentListener} - ${LOADING_SUCCESS_EVENT} | ${() => onLoadingSuccessListener} - ${LOADING_ERROR_EVENT} | ${() => onLoadingErrorListener} + event | listener + ${ALERT_EVENT} | ${() => onAlertListener} `('listens to $event event in the eventBus object', ({ event, listener }) => { const args = {}; @@ -114,9 +97,6 @@ describe('content_editor/components/editor_state_observer', () => { it.each` event ${ALERT_EVENT} - ${LOADING_CONTENT_EVENT} - ${LOADING_SUCCESS_EVENT} - ${LOADING_ERROR_EVENT} `('removes $event event hook from eventHub', ({ event }) => { jest.spyOn(eventHub, '$off'); jest.spyOn(eventHub, '$on'); diff --git a/spec/frontend/content_editor/components/loading_indicator_spec.js b/spec/frontend/content_editor/components/loading_indicator_spec.js index e4fb09b70a4..0065103d01b 100644 --- a/spec/frontend/content_editor/components/loading_indicator_spec.js +++ b/spec/frontend/content_editor/components/loading_indicator_spec.js @@ -1,18 +1,10 @@ import { GlLoadingIcon } from '@gitlab/ui'; -import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import LoadingIndicator from '~/content_editor/components/loading_indicator.vue'; -import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; -import { - LOADING_CONTENT_EVENT, - LOADING_SUCCESS_EVENT, - LOADING_ERROR_EVENT, -} from '~/content_editor/constants'; describe('content_editor/components/loading_indicator', () => { let wrapper; - const findEditorStateObserver = () => wrapper.findComponent(EditorStateObserver); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const createWrapper = () => { @@ -24,48 +16,12 @@ describe('content_editor/components/loading_indicator', () => { }); describe('when loading content', () => { - beforeEach(async () => { + beforeEach(() => { createWrapper(); - - findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT); - - await nextTick(); }); it('displays loading indicator', () => { expect(findLoadingIcon().exists()).toBe(true); }); }); - - describe('when loading content succeeds', () => { - beforeEach(async () => { - createWrapper(); - - findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT); - await nextTick(); - findEditorStateObserver().vm.$emit(LOADING_SUCCESS_EVENT); - await nextTick(); - }); - - it('hides loading indicator', () => { - expect(findLoadingIcon().exists()).toBe(false); - }); - }); - - describe('when loading content fails', () => { - const error = 'error'; - - beforeEach(async () => { - createWrapper(); - - findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT); - await nextTick(); - findEditorStateObserver().vm.$emit(LOADING_ERROR_EVENT, error); - await nextTick(); - }); - - it('hides loading indicator', () => { - expect(findLoadingIcon().exists()).toBe(false); - }); - }); }); diff --git a/spec/frontend/content_editor/components/toolbar_image_button_spec.js b/spec/frontend/content_editor/components/toolbar_image_button_spec.js index dab7e67d7c5..5473d43f5a1 100644 --- a/spec/frontend/content_editor/components/toolbar_image_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_image_button_spec.js @@ -1,8 +1,9 @@ -import { GlButton, GlFormInputGroup } from '@gitlab/ui'; +import { GlButton, GlFormInputGroup, GlDropdown } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import ToolbarImageButton from '~/content_editor/components/toolbar_image_button.vue'; import Attachment from '~/content_editor/extensions/attachment'; import Image from '~/content_editor/extensions/image'; +import { stubComponent } from 'helpers/stub_component'; import { createTestEditor, mockChainedCommands } from '../test_utils'; describe('content_editor/components/toolbar_image_button', () => { @@ -14,15 +15,19 @@ describe('content_editor/components/toolbar_image_button', () => { provide: { tiptapEditor: editor, }, + stubs: { + GlDropdown: stubComponent(GlDropdown), + }, }); }; const findImageURLInput = () => wrapper.findComponent(GlFormInputGroup).find('input[type="text"]'); const findApplyImageButton = () => wrapper.findComponent(GlButton); + const findDropdown = () => wrapper.findComponent(GlDropdown); const selectFile = async (file) => { - const input = wrapper.find({ ref: 'fileSelector' }); + const input = wrapper.findComponent({ ref: 'fileSelector' }); // override the property definition because `input.files` isn't directly modifyable Object.defineProperty(input.element, 'files', { value: [file], writable: true }); @@ -77,4 +82,16 @@ describe('content_editor/components/toolbar_image_button', () => { expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'image', value: 'upload' }]); }); + + describe('a11y tests', () => { + it('sets text, title, and text-sr-only properties to the table button dropdown', () => { + buildWrapper(); + + expect(findDropdown().props()).toMatchObject({ + text: 'Insert image', + textSrOnly: true, + }); + expect(findDropdown().attributes('title')).toBe('Insert image'); + }); + }); }); diff --git a/spec/frontend/content_editor/components/toolbar_link_button_spec.js b/spec/frontend/content_editor/components/toolbar_link_button_spec.js index fc26a9da471..40e859e96af 100644 --- a/spec/frontend/content_editor/components/toolbar_link_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_link_button_spec.js @@ -4,6 +4,7 @@ import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.v import eventHubFactory from '~/helpers/event_hub_factory'; import Link from '~/content_editor/extensions/link'; import { hasSelection } from '~/content_editor/services/utils'; +import { stubComponent } from 'helpers/stub_component'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; jest.mock('~/content_editor/services/utils'); @@ -18,6 +19,9 @@ describe('content_editor/components/toolbar_link_button', () => { tiptapEditor: editor, eventHub: eventHubFactory(), }, + stubs: { + GlDropdown: stubComponent(GlDropdown), + }, }); }; const findDropdown = () => wrapper.findComponent(GlDropdown); @@ -26,7 +30,7 @@ describe('content_editor/components/toolbar_link_button', () => { const findRemoveLinkButton = () => wrapper.findByText('Remove link'); const selectFile = async (file) => { - const input = wrapper.find({ ref: 'fileSelector' }); + const input = wrapper.findComponent({ ref: 'fileSelector' }); // override the property definition because `input.files` isn't directly modifyable Object.defineProperty(input.element, 'files', { value: [file], writable: true }); @@ -205,4 +209,16 @@ describe('content_editor/components/toolbar_link_button', () => { }); }); }); + + describe('a11y tests', () => { + it('sets text, title, and text-sr-only properties to the table button dropdown', () => { + buildWrapper(); + + expect(findDropdown().props()).toMatchObject({ + text: 'Insert link', + textSrOnly: true, + }); + expect(findDropdown().attributes('title')).toBe('Insert link'); + }); + }); }); diff --git a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js index 62fec8d4e72..a23f8370adf 100644 --- a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js +++ b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js @@ -1,8 +1,10 @@ +import { GlDropdown } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import ToolbarMoreDropdown from '~/content_editor/components/toolbar_more_dropdown.vue'; import Diagram from '~/content_editor/extensions/diagram'; import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; import eventHubFactory from '~/helpers/event_hub_factory'; +import { stubComponent } from 'helpers/stub_component'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; describe('content_editor/components/toolbar_more_dropdown', () => { @@ -23,10 +25,15 @@ describe('content_editor/components/toolbar_more_dropdown', () => { tiptapEditor, eventHub, }, + stubs: { + GlDropdown: stubComponent(GlDropdown), + }, propsData, }); }; + const findDropdown = () => wrapper.findComponent(GlDropdown); + beforeEach(() => { buildEditor(); buildWrapper(); @@ -67,4 +74,14 @@ describe('content_editor/components/toolbar_more_dropdown', () => { expect(wrapper.emitted('execute')).toEqual([[{ contentType }]]); }); }); + + describe('a11y tests', () => { + it('sets text, title, and text-sr-only properties to the table button dropdown', () => { + expect(findDropdown().props()).toMatchObject({ + text: 'More', + textSrOnly: true, + }); + expect(findDropdown().attributes('title')).toBe('More'); + }); + }); }); diff --git a/spec/frontend/content_editor/components/toolbar_table_button_spec.js b/spec/frontend/content_editor/components/toolbar_table_button_spec.js index 056e5e04e1f..aa4604661e5 100644 --- a/spec/frontend/content_editor/components/toolbar_table_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_table_button_spec.js @@ -1,6 +1,7 @@ import { GlDropdown, GlButton } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import ToolbarTableButton from '~/content_editor/components/toolbar_table_button.vue'; +import { stubComponent } from 'helpers/stub_component'; import { createTestEditor, mockChainedCommands } from '../test_utils'; describe('content_editor/components/toolbar_table_button', () => { @@ -12,6 +13,9 @@ describe('content_editor/components/toolbar_table_button', () => { provide: { tiptapEditor: editor, }, + stubs: { + GlDropdown: stubComponent(GlDropdown), + }, }); }; @@ -98,4 +102,14 @@ describe('content_editor/components/toolbar_table_button', () => { expect(getNumButtons()).toBe(100); // 10x10 (and not 11x11) }); + + describe('a11y tests', () => { + it('sets text, title, and text-sr-only properties to the table button dropdown', () => { + expect(findDropdown().props()).toMatchObject({ + text: 'Insert table', + textSrOnly: true, + }); + expect(findDropdown().attributes('title')).toBe('Insert table'); + }); + }); }); diff --git a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js index 608be1bd693..3ebb305afbf 100644 --- a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js +++ b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js @@ -53,7 +53,7 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => { }); }); - describe('when there is an active item ', () => { + describe('when there is an active item', () => { let activeTextStyle; beforeEach(async () => { @@ -68,7 +68,7 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => { await emitEditorEvent({ event: 'transaction', tiptapEditor }); }); - it('displays the active text style label as the dropdown toggle text ', () => { + it('displays the active text style label as the dropdown toggle text', () => { expect(findDropdown().props().text).toBe(activeTextStyle.label); }); diff --git a/spec/frontend/content_editor/components/wrappers/code_block_spec.js b/spec/frontend/content_editor/components/wrappers/code_block_spec.js index 17a365e12bb..a5ef19fb8e8 100644 --- a/spec/frontend/content_editor/components/wrappers/code_block_spec.js +++ b/spec/frontend/content_editor/components/wrappers/code_block_spec.js @@ -104,7 +104,7 @@ describe('content/components/wrappers/code_block', () => { it('does not render a preview if showPreview: false', async () => { createWrapper({ language: 'plantuml', isDiagram: true, showPreview: false }); - expect(wrapper.find({ ref: 'diagramContainer' }).exists()).toBe(false); + expect(wrapper.findComponent({ ref: 'diagramContainer' }).exists()).toBe(false); }); it('does not update preview when diagram is not active', async () => { @@ -134,7 +134,7 @@ describe('content/components/wrappers/code_block', () => { await nextTick(); expect(wrapper.find('img').attributes('src')).toBe('url/to/some/diagram'); - expect(wrapper.find(SandboxedMermaid).exists()).toBe(false); + expect(wrapper.findComponent(SandboxedMermaid).exists()).toBe(false); }); it('renders an iframe with preview for a mermaid diagram', async () => { @@ -143,7 +143,7 @@ describe('content/components/wrappers/code_block', () => { await emitEditorEvent({ event: 'transaction', tiptapEditor }); await nextTick(); - expect(wrapper.find(SandboxedMermaid).props('source')).toBe(''); + expect(wrapper.findComponent(SandboxedMermaid).props('source')).toBe(''); expect(wrapper.find('img').exists()).toBe(false); }); }); |