diff options
Diffstat (limited to 'spec/frontend/content_editor')
21 files changed, 689 insertions, 273 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); }); }); diff --git a/spec/frontend/content_editor/extensions/paste_markdown_spec.js b/spec/frontend/content_editor/extensions/paste_markdown_spec.js index 53efda6aee2..30e798e8817 100644 --- a/spec/frontend/content_editor/extensions/paste_markdown_spec.js +++ b/spec/frontend/content_editor/extensions/paste_markdown_spec.js @@ -5,12 +5,7 @@ import Frontmatter from '~/content_editor/extensions/frontmatter'; import Bold from '~/content_editor/extensions/bold'; import { VARIANT_DANGER } from '~/flash'; import eventHubFactory from '~/helpers/event_hub_factory'; -import { - ALERT_EVENT, - LOADING_CONTENT_EVENT, - LOADING_SUCCESS_EVENT, - LOADING_ERROR_EVENT, -} from '~/content_editor/constants'; +import { ALERT_EVENT } from '~/content_editor/constants'; import waitForPromises from 'helpers/wait_for_promises'; import { createTestEditor, createDocBuilder, waitUntilNextDocTransaction } from '../test_utils'; @@ -115,13 +110,6 @@ describe('content_editor/extensions/paste_markdown', () => { expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); - - it(`triggers ${LOADING_SUCCESS_EVENT}`, async () => { - await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent()); - - expect(eventHub.$emit).toHaveBeenCalledWith(LOADING_CONTENT_EVENT); - expect(eventHub.$emit).toHaveBeenCalledWith(LOADING_SUCCESS_EVENT); - }); }); describe('when rendering markdown fails', () => { @@ -129,13 +117,6 @@ describe('content_editor/extensions/paste_markdown', () => { renderMarkdown.mockRejectedValueOnce(); }); - it(`triggers ${LOADING_ERROR_EVENT} event`, async () => { - await triggerPasteEventHandler(buildClipboardEvent()); - await waitForPromises(); - - expect(eventHub.$emit).toHaveBeenCalledWith(LOADING_ERROR_EVENT); - }); - it(`triggers ${ALERT_EVENT} event`, async () => { await triggerPasteEventHandler(buildClipboardEvent()); await waitForPromises(); diff --git a/spec/frontend/content_editor/remark_markdown_processing_spec.js b/spec/frontend/content_editor/remark_markdown_processing_spec.js index 7ae0a7c13c1..bc43af9bd8b 100644 --- a/spec/frontend/content_editor/remark_markdown_processing_spec.js +++ b/spec/frontend/content_editor/remark_markdown_processing_spec.js @@ -1,8 +1,10 @@ +import Audio from '~/content_editor/extensions/audio'; import Bold from '~/content_editor/extensions/bold'; import Blockquote from '~/content_editor/extensions/blockquote'; import BulletList from '~/content_editor/extensions/bullet_list'; import Code from '~/content_editor/extensions/code'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import Diagram from '~/content_editor/extensions/diagram'; import FootnoteDefinition from '~/content_editor/extensions/footnote_definition'; import FootnoteReference from '~/content_editor/extensions/footnote_reference'; import Frontmatter from '~/content_editor/extensions/frontmatter'; @@ -21,22 +23,27 @@ import Sourcemap from '~/content_editor/extensions/sourcemap'; import Strike from '~/content_editor/extensions/strike'; import Table from '~/content_editor/extensions/table'; import TableHeader from '~/content_editor/extensions/table_header'; +import TableOfContents from '~/content_editor/extensions/table_of_contents'; import TableRow from '~/content_editor/extensions/table_row'; import TableCell from '~/content_editor/extensions/table_cell'; import TaskList from '~/content_editor/extensions/task_list'; import TaskItem from '~/content_editor/extensions/task_item'; +import Video from '~/content_editor/extensions/video'; import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; import markdownSerializer from '~/content_editor/services/markdown_serializer'; +import { SAFE_VIDEO_EXT, SAFE_AUDIO_EXT, DIAGRAM_LANGUAGES } from '~/content_editor/constants'; import { createTestEditor, createDocBuilder } from './test_utils'; const tiptapEditor = createTestEditor({ extensions: [ + Audio, Blockquote, Bold, BulletList, Code, CodeBlockHighlight, + Diagram, FootnoteDefinition, FootnoteReference, Frontmatter, @@ -55,8 +62,10 @@ const tiptapEditor = createTestEditor({ TableRow, TableHeader, TableCell, + TableOfContents, TaskList, TaskItem, + Video, ...HTMLNodes, ], }); @@ -65,12 +74,14 @@ const { builders: { doc, paragraph, + audio, bold, blockquote, bulletList, code, codeBlock, div, + diagram, footnoteDefinition, footnoteReference, frontmatter, @@ -89,17 +100,21 @@ const { tableRow, tableHeader, tableCell, + tableOfContents, taskItem, taskList, + video, }, } = createDocBuilder({ tiptapEditor, names: { + audio: { nodeType: Audio.name }, blockquote: { nodeType: Blockquote.name }, bold: { markType: Bold.name }, bulletList: { nodeType: BulletList.name }, code: { markType: Code.name }, codeBlock: { nodeType: CodeBlockHighlight.name }, + diagram: { nodeType: Diagram.name }, footnoteDefinition: { nodeType: FootnoteDefinition.name }, footnoteReference: { nodeType: FootnoteReference.name }, frontmatter: { nodeType: Frontmatter.name }, @@ -118,8 +133,10 @@ const { tableCell: { nodeType: TableCell.name }, tableHeader: { nodeType: TableHeader.name }, tableRow: { nodeType: TableRow.name }, + tableOfContents: { nodeType: TableOfContents.name }, taskItem: { nodeType: TaskItem.name }, taskList: { nodeType: TaskList.name }, + video: { nodeType: Video.name }, ...HTMLNodes.reduce( (builders, htmlNode) => ({ ...builders, @@ -1233,6 +1250,62 @@ title: 'layout' ), ), }, + ...SAFE_AUDIO_EXT.map((extension) => { + const src = `http://test.host/video.${extension}`; + const markdown = `![audio](${src})`; + + return { + markdown, + expectedDoc: doc( + paragraph( + source(markdown), + audio({ + ...source(markdown), + canonicalSrc: src, + src, + alt: 'audio', + }), + ), + ), + }; + }), + ...SAFE_VIDEO_EXT.map((extension) => { + const src = `http://test.host/video.${extension}`; + const markdown = `![video](${src})`; + + return { + markdown, + expectedDoc: doc( + paragraph( + source(markdown), + video({ + ...source(markdown), + canonicalSrc: src, + src, + alt: 'video', + }), + ), + ), + }; + }), + ...DIAGRAM_LANGUAGES.map((language) => { + const markdown = `\`\`\`${language} +content +\`\`\``; + + return { + markdown, + expectedDoc: doc(diagram({ ...source(markdown), language }, 'content')), + }; + }), + { + markdown: '[[_TOC_]]', + expectedDoc: doc(tableOfContents(source('[[_TOC_]]'))), + }, + { + markdown: '[TOC]', + expectedDoc: doc(tableOfContents(source('[TOC]'))), + }, ]; const runOnly = examples.find((example) => example.only === true); diff --git a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js index 4a57c7b1942..bd48b7fdd23 100644 --- a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js +++ b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js @@ -1,6 +1,7 @@ import { DOMSerializer } from 'prosemirror-model'; // TODO: DRY up duplication with spec/frontend/content_editor/services/markdown_serializer_spec.js // See https://gitlab.com/groups/gitlab-org/-/epics/7719#plan +import Audio from '~/content_editor/extensions/audio'; import Blockquote from '~/content_editor/extensions/blockquote'; import Bold from '~/content_editor/extensions/bold'; import BulletList from '~/content_editor/extensions/bullet_list'; @@ -33,13 +34,16 @@ import Table from '~/content_editor/extensions/table'; import TableCell from '~/content_editor/extensions/table_cell'; import TableHeader from '~/content_editor/extensions/table_header'; import TableRow from '~/content_editor/extensions/table_row'; +import TableOfContents from '~/content_editor/extensions/table_of_contents'; import TaskItem from '~/content_editor/extensions/task_item'; import TaskList from '~/content_editor/extensions/task_list'; +import Video from '~/content_editor/extensions/video'; import createMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; import { createTestEditor } from 'jest/content_editor/test_utils'; const tiptapEditor = createTestEditor({ extensions: [ + Audio, Blockquote, Bold, BulletList, @@ -72,8 +76,10 @@ const tiptapEditor = createTestEditor({ TableCell, TableHeader, TableRow, + TableOfContents, TaskItem, TaskList, + Video, ], }); diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js index a3553e612ca..6175cbdd3d4 100644 --- a/spec/frontend/content_editor/services/content_editor_spec.js +++ b/spec/frontend/content_editor/services/content_editor_spec.js @@ -1,8 +1,3 @@ -import { - LOADING_CONTENT_EVENT, - LOADING_SUCCESS_EVENT, - LOADING_ERROR_EVENT, -} from '~/content_editor/constants'; import { ContentEditor } from '~/content_editor/services/content_editor'; import eventHubFactory from '~/helpers/event_hub_factory'; import { createTestEditor, createDocBuilder } from '../test_utils'; @@ -14,6 +9,7 @@ describe('content_editor/services/content_editor', () => { let eventHub; let doc; let p; + const testMarkdown = '**bold text**'; beforeEach(() => { const tiptapEditor = createTestEditor(); @@ -36,6 +32,9 @@ describe('content_editor/services/content_editor', () => { }); }); + const testDoc = () => doc(p('document')); + const testEmptyDoc = () => doc(); + describe('.dispose', () => { it('destroys the tiptapEditor', () => { expect(contentEditor.tiptapEditor.destroy).not.toHaveBeenCalled(); @@ -46,51 +45,77 @@ describe('content_editor/services/content_editor', () => { }); }); - describe('when setSerializedContent succeeds', () => { - let document; - const languages = ['javascript']; - const testMarkdown = '**bold text**'; + describe('empty', () => { + it('returns true when tiptapEditor is empty', async () => { + deserializer.deserialize.mockResolvedValueOnce({ document: testEmptyDoc() }); + + await contentEditor.setSerializedContent(testMarkdown); - beforeEach(() => { - document = doc(p('document')); - deserializer.deserialize.mockResolvedValueOnce({ document, languages }); + expect(contentEditor.empty).toBe(true); }); - it('emits loadingContent and loadingSuccess event in the eventHub', () => { - let loadingContentEmitted = false; + it('returns false when tiptapEditor is not empty', async () => { + deserializer.deserialize.mockResolvedValueOnce({ document: testDoc() }); - eventHub.$on(LOADING_CONTENT_EVENT, () => { - loadingContentEmitted = true; - }); - eventHub.$on(LOADING_SUCCESS_EVENT, () => { - expect(loadingContentEmitted).toBe(true); - }); + await contentEditor.setSerializedContent(testMarkdown); - contentEditor.setSerializedContent(testMarkdown); + expect(contentEditor.empty).toBe(false); }); + }); - it('sets the deserialized document in the tiptap editor object', async () => { - await contentEditor.setSerializedContent(testMarkdown); + describe('editable', () => { + it('returns true when tiptapEditor is editable', async () => { + contentEditor.setEditable(true); - expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON()); + expect(contentEditor.editable).toBe(true); + }); + + it('returns false when tiptapEditor is readonly', async () => { + contentEditor.setEditable(false); + + expect(contentEditor.editable).toBe(false); }); }); - describe('when setSerializedContent fails', () => { - const error = 'error'; + describe('changed', () => { + it('returns true when the initial document changes', async () => { + deserializer.deserialize.mockResolvedValueOnce({ document: testDoc() }); + + await contentEditor.setSerializedContent(testMarkdown); + + contentEditor.tiptapEditor.commands.insertContent(' new content'); + + expect(contentEditor.changed).toBe(true); + }); + + it('returns false when the initial document hasn’t changed', async () => { + deserializer.deserialize.mockResolvedValueOnce({ document: testDoc() }); + + await contentEditor.setSerializedContent(testMarkdown); + + expect(contentEditor.changed).toBe(false); + }); + + it('returns false when an initial document is not set and the document is empty', () => { + expect(contentEditor.changed).toBe(false); + }); - beforeEach(() => { - deserializer.deserialize.mockRejectedValueOnce(error); + it('returns true when an initial document is not set and the document is not empty', () => { + contentEditor.tiptapEditor.commands.insertContent('new content'); + + expect(contentEditor.changed).toBe(true); }); + }); + + describe('when setSerializedContent succeeds', () => { + it('sets the deserialized document in the tiptap editor object', async () => { + const document = testDoc(); + + deserializer.deserialize.mockResolvedValueOnce({ document }); - it('emits loadingError event', async () => { - eventHub.$on(LOADING_ERROR_EVENT, (e) => { - expect(e).toBe('error'); - }); + await contentEditor.setSerializedContent(testMarkdown); - await expect(() => contentEditor.setSerializedContent('**bold text**')).rejects.toEqual( - error, - ); + expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON()); }); }); }); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 0e5281be9bf..56394c85e8b 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -1,3 +1,4 @@ +import Audio from '~/content_editor/extensions/audio'; import Blockquote from '~/content_editor/extensions/blockquote'; import Bold from '~/content_editor/extensions/bold'; import BulletList from '~/content_editor/extensions/bullet_list'; @@ -33,6 +34,7 @@ import TableHeader from '~/content_editor/extensions/table_header'; import TableRow from '~/content_editor/extensions/table_row'; import TaskItem from '~/content_editor/extensions/task_item'; import TaskList from '~/content_editor/extensions/task_list'; +import Video from '~/content_editor/extensions/video'; import markdownSerializer from '~/content_editor/services/markdown_serializer'; import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; import { createTestEditor, createDocBuilder } from '../test_utils'; @@ -41,6 +43,7 @@ jest.mock('~/emoji'); const tiptapEditor = createTestEditor({ extensions: [ + Audio, Blockquote, Bold, BulletList, @@ -73,6 +76,7 @@ const tiptapEditor = createTestEditor({ TableRow, TaskItem, TaskList, + Video, ...HTMLMarks, ...HTMLNodes, ], @@ -80,6 +84,7 @@ const tiptapEditor = createTestEditor({ const { builders: { + audio, doc, blockquote, bold, @@ -114,6 +119,7 @@ const { tableRow, taskItem, taskList, + video, }, } = createDocBuilder({ tiptapEditor, @@ -1230,6 +1236,21 @@ paragraph ); }); + it('serializes audio and video elements', () => { + expect( + serialize( + paragraph( + audio({ alt: 'audio', canonicalSrc: 'audio.mp3' }), + ' and ', + video({ alt: 'video', canonicalSrc: 'video.mov' }), + ), + ), + ).toBe( + ` +![audio](audio.mp3) and ![video](video.mov)`.trimLeft(), + ); + }); + const defaultEditAction = (initialContent) => { tiptapEditor.chain().setContent(initialContent.toJSON()).insertContent(' modified').run(); }; |