diff options
Diffstat (limited to 'spec/frontend/content_editor')
15 files changed, 445 insertions, 98 deletions
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 2ddcd8f024e..12484cb13c6 100644 --- a/spec/frontend/content_editor/components/content_editor_alert_spec.js +++ b/spec/frontend/content_editor/components/content_editor_alert_spec.js @@ -3,20 +3,25 @@ import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue'; import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; -import { createTestEditor, emitEditorEvent } from '../test_utils'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import { ALERT_EVENT } from '~/content_editor/constants'; +import { createTestEditor } from '../test_utils'; describe('content_editor/components/content_editor_alert', () => { let wrapper; let tiptapEditor; + let eventHub; const findErrorAlert = () => wrapper.findComponent(GlAlert); const createWrapper = async () => { tiptapEditor = createTestEditor(); + eventHub = eventHubFactory(); wrapper = shallowMountExtended(ContentEditorAlert, { provide: { tiptapEditor, + eventHub, }, stubs: { EditorStateObserver, @@ -37,7 +42,9 @@ describe('content_editor/components/content_editor_alert', () => { async ({ message, variant }) => { createWrapper(); - await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message, variant } }); + eventHub.$emit(ALERT_EVENT, { message, variant }); + + await nextTick(); expect(findErrorAlert().text()).toBe(message); expect(findErrorAlert().attributes().variant).toBe(variant); @@ -48,11 +55,9 @@ describe('content_editor/components/content_editor_alert', () => { const message = 'error message'; createWrapper(); - - await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message } }); - + eventHub.$emit(ALERT_EVENT, { message }); + await nextTick(); findErrorAlert().vm.$emit('dismiss'); - await nextTick(); 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 9a772c41e52..73fcfeab8bc 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -1,6 +1,4 @@ -import { GlLoadingIcon } from '@gitlab/ui'; import { EditorContent } 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'; @@ -8,11 +6,7 @@ import ContentEditorProvider from '~/content_editor/components/content_editor_pr import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; import FormattingBubbleMenu from '~/content_editor/components/formatting_bubble_menu.vue'; import TopToolbar from '~/content_editor/components/top_toolbar.vue'; -import { - LOADING_CONTENT_EVENT, - LOADING_SUCCESS_EVENT, - LOADING_ERROR_EVENT, -} from '~/content_editor/constants'; +import LoadingIndicator from '~/content_editor/components/loading_indicator.vue'; import { emitEditorEvent } from '../test_utils'; jest.mock('~/emoji'); @@ -25,9 +19,6 @@ describe('ContentEditor', () => { const findEditorElement = () => wrapper.findByTestId('content-editor'); const findEditorContent = () => wrapper.findComponent(EditorContent); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findBubbleMenu = () => wrapper.findComponent(FormattingBubbleMenu); - const createWrapper = (propsData = {}) => { renderMarkdown = jest.fn(); @@ -117,69 +108,15 @@ describe('ContentEditor', () => { expect(wrapper.findComponent(ContentEditorAlert).exists()).toBe(true); }); - describe('when loading content', () => { - beforeEach(async () => { - createWrapper(); - - contentEditor.emit(LOADING_CONTENT_EVENT); - - await nextTick(); - }); - - it('displays loading indicator', () => { - expect(findLoadingIcon().exists()).toBe(true); - }); - - it('hides EditorContent component', () => { - expect(findEditorContent().exists()).toBe(false); - }); - - it('hides formatting bubble menu', () => { - expect(findBubbleMenu().exists()).toBe(false); - }); - }); - - describe('when loading content succeeds', () => { - beforeEach(async () => { - createWrapper(); - - contentEditor.emit(LOADING_CONTENT_EVENT); - await nextTick(); - contentEditor.emit(LOADING_SUCCESS_EVENT); - await nextTick(); - }); - - it('hides loading indicator', () => { - expect(findLoadingIcon().exists()).toBe(false); - }); + it('renders loading indicator component', () => { + createWrapper(); - it('displays EditorContent component', () => { - expect(findEditorContent().exists()).toBe(true); - }); + expect(wrapper.findComponent(LoadingIndicator).exists()).toBe(true); }); - describe('when loading content fails', () => { - const error = 'error'; - - beforeEach(async () => { - createWrapper(); - - contentEditor.emit(LOADING_CONTENT_EVENT); - await nextTick(); - contentEditor.emit(LOADING_ERROR_EVENT, error); - await nextTick(); - }); - - it('hides loading indicator', () => { - expect(findLoadingIcon().exists()).toBe(false); - }); - - it('displays EditorContent component', () => { - expect(findEditorContent().exists()).toBe(true); - }); + it('renders formatting bubble menu', () => { + createWrapper(); - it('displays formatting bubble menu', () => { - expect(findBubbleMenu().exists()).toBe(true); - }); + expect(wrapper.findComponent(FormattingBubbleMenu).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 5e4bb348e1f..51a594a606b 100644 --- a/spec/frontend/content_editor/components/editor_state_observer_spec.js +++ b/spec/frontend/content_editor/components/editor_state_observer_spec.js @@ -3,6 +3,13 @@ import { each } from 'lodash'; 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 { createTestEditor } from '../test_utils'; describe('content_editor/components/editor_state_observer', () => { @@ -11,19 +18,29 @@ describe('content_editor/components/editor_state_observer', () => { let onDocUpdateListener; let onSelectionUpdateListener; let onTransactionListener; + let onLoadingContentListener; + let onLoadingSuccessListener; + let onLoadingErrorListener; + let onAlertListener; + let eventHub; const buildEditor = () => { tiptapEditor = createTestEditor(); + eventHub = eventHubFactory(); jest.spyOn(tiptapEditor, 'on'); }; const buildWrapper = () => { wrapper = shallowMount(EditorStateObserver, { - provide: { tiptapEditor }, + provide: { tiptapEditor, eventHub }, listeners: { docUpdate: onDocUpdateListener, selectionUpdate: onSelectionUpdateListener, transaction: onTransactionListener, + [ALERT_EVENT]: onAlertListener, + [LOADING_CONTENT_EVENT]: onLoadingContentListener, + [LOADING_SUCCESS_EVENT]: onLoadingSuccessListener, + [LOADING_ERROR_EVENT]: onLoadingErrorListener, }, }); }; @@ -32,8 +49,11 @@ describe('content_editor/components/editor_state_observer', () => { onDocUpdateListener = jest.fn(); onSelectionUpdateListener = jest.fn(); onTransactionListener = jest.fn(); + onAlertListener = jest.fn(); + onLoadingSuccessListener = jest.fn(); + onLoadingContentListener = jest.fn(); + onLoadingErrorListener = jest.fn(); buildEditor(); - buildWrapper(); }); afterEach(() => { @@ -44,6 +64,8 @@ describe('content_editor/components/editor_state_observer', () => { it('emits update, selectionUpdate, and transaction events', () => { const content = '<p>My paragraph</p>'; + buildWrapper(); + tiptapEditor.commands.insertContent(content); expect(onDocUpdateListener).toHaveBeenCalledWith( @@ -58,10 +80,27 @@ 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} + `('listens to $event event in the eventBus object', ({ event, listener }) => { + const args = {}; + + buildWrapper(); + + eventHub.$emit(event, args); + expect(listener()).toHaveBeenCalledWith(args); + }); + describe('when component is destroyed', () => { it('removes onTiptapDocUpdate and onTiptapSelectionUpdate hooks', () => { jest.spyOn(tiptapEditor, 'off'); + buildWrapper(); + wrapper.destroy(); each(tiptapToComponentMap, (_, tiptapEvent) => { @@ -71,5 +110,25 @@ 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'); + + buildWrapper(); + + wrapper.destroy(); + + expect(eventHub.$off).toHaveBeenCalledWith( + event, + eventHub.$on.mock.calls.find(([eventName]) => eventName === event)[1], + ); + }); }); }); diff --git a/spec/frontend/content_editor/components/loading_indicator_spec.js b/spec/frontend/content_editor/components/loading_indicator_spec.js new file mode 100644 index 00000000000..e4fb09b70a4 --- /dev/null +++ b/spec/frontend/content_editor/components/loading_indicator_spec.js @@ -0,0 +1,71 @@ +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 = () => { + wrapper = shallowMountExtended(LoadingIndicator); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when loading content', () => { + beforeEach(async () => { + 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_button_spec.js b/spec/frontend/content_editor/components/toolbar_button_spec.js index 60263c46bdd..ce50482302d 100644 --- a/spec/frontend/content_editor/components/toolbar_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_button_spec.js @@ -2,6 +2,7 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; import ToolbarButton from '~/content_editor/components/toolbar_button.vue'; +import eventHubFactory from '~/helpers/event_hub_factory'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; describe('content_editor/components/toolbar_button', () => { @@ -25,6 +26,7 @@ describe('content_editor/components/toolbar_button', () => { }, provide: { tiptapEditor, + eventHub: eventHubFactory(), }, propsData: { contentType: CONTENT_TYPE, 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 0cf488260bd..fc26a9da471 100644 --- a/spec/frontend/content_editor/components/toolbar_link_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_link_button_spec.js @@ -1,6 +1,7 @@ import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue'; +import eventHubFactory from '~/helpers/event_hub_factory'; import Link from '~/content_editor/extensions/link'; import { hasSelection } from '~/content_editor/services/utils'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; @@ -15,6 +16,7 @@ describe('content_editor/components/toolbar_link_button', () => { wrapper = mountExtended(ToolbarLinkButton, { provide: { tiptapEditor: editor, + eventHub: eventHubFactory(), }, }); }; 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 65c1c8c8310..608be1bd693 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 @@ -4,6 +4,7 @@ import EditorStateObserver from '~/content_editor/components/editor_state_observ import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue'; import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants'; import Heading from '~/content_editor/extensions/heading'; +import eventHubFactory from '~/helpers/event_hub_factory'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; describe('content_editor/components/toolbar_text_style_dropdown', () => { @@ -27,6 +28,7 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => { }, provide: { tiptapEditor, + eventHub: eventHubFactory(), }, propsData: { ...propsData, diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js index d2d2cd98a78..ec67545cf17 100644 --- a/spec/frontend/content_editor/extensions/attachment_spec.js +++ b/spec/frontend/content_editor/extensions/attachment_spec.js @@ -4,7 +4,9 @@ import Attachment from '~/content_editor/extensions/attachment'; import Image from '~/content_editor/extensions/image'; import Link from '~/content_editor/extensions/link'; import Loading from '~/content_editor/extensions/loading'; +import { VARIANT_DANGER } from '~/flash'; import httpStatus from '~/lib/utils/http_status'; +import eventHubFactory from '~/helpers/event_hub_factory'; import { createTestEditor, createDocBuilder } from '../test_utils'; const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="auto"> @@ -25,6 +27,7 @@ describe('content_editor/extensions/attachment', () => { let link; let renderMarkdown; let mock; + let eventHub; const uploadsPath = '/uploads/'; const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' }); @@ -50,9 +53,15 @@ describe('content_editor/extensions/attachment', () => { beforeEach(() => { renderMarkdown = jest.fn(); + eventHub = eventHubFactory(); tiptapEditor = createTestEditor({ - extensions: [Loading, Link, Image, Attachment.configure({ renderMarkdown, uploadsPath })], + extensions: [ + Loading, + Link, + Image, + Attachment.configure({ renderMarkdown, uploadsPath, eventHub }), + ], }); ({ @@ -160,7 +169,8 @@ describe('content_editor/extensions/attachment', () => { it('emits an alert event that includes an error message', (done) => { tiptapEditor.commands.uploadAttachment({ file: imageFile }); - tiptapEditor.on('alert', ({ message }) => { + eventHub.$on('alert', ({ message, variant }) => { + expect(variant).toBe(VARIANT_DANGER); expect(message).toBe('An error occurred while uploading the image. Please try again.'); done(); }); @@ -236,7 +246,8 @@ describe('content_editor/extensions/attachment', () => { it('emits an alert event that includes an error message', (done) => { tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); - tiptapEditor.on('alert', ({ message }) => { + eventHub.$on('alert', ({ message, variant }) => { + expect(variant).toBe(VARIANT_DANGER); expect(message).toBe('An error occurred while uploading the file. Please try again.'); done(); }); diff --git a/spec/frontend/content_editor/extensions/paste_markdown_spec.js b/spec/frontend/content_editor/extensions/paste_markdown_spec.js new file mode 100644 index 00000000000..8f734c7dabc --- /dev/null +++ b/spec/frontend/content_editor/extensions/paste_markdown_spec.js @@ -0,0 +1,127 @@ +import PasteMarkdown from '~/content_editor/extensions/paste_markdown'; +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 waitForPromises from 'helpers/wait_for_promises'; +import { createTestEditor, createDocBuilder, waitUntilNextDocTransaction } from '../test_utils'; + +describe('content_editor/extensions/paste_markdown', () => { + let tiptapEditor; + let doc; + let p; + let bold; + let renderMarkdown; + let eventHub; + const defaultData = { 'text/plain': '**bold text**' }; + + beforeEach(() => { + renderMarkdown = jest.fn(); + eventHub = eventHubFactory(); + + jest.spyOn(eventHub, '$emit'); + + tiptapEditor = createTestEditor({ + extensions: [PasteMarkdown.configure({ renderMarkdown, eventHub }), Bold], + }); + + ({ + builders: { doc, p, bold }, + } = createDocBuilder({ + tiptapEditor, + names: { + Bold: { markType: Bold.name }, + }, + })); + }); + + const buildClipboardEvent = ({ data = {}, types = ['text/plain'] } = {}) => { + return Object.assign(new Event('paste'), { + clipboardData: { types, getData: jest.fn((type) => data[type] || defaultData[type]) }, + }); + }; + + const triggerPasteEventHandler = (event) => { + let handled = false; + + tiptapEditor.view.someProp('handlePaste', (eventHandler) => { + handled = eventHandler(tiptapEditor.view, event); + }); + + return handled; + }; + + const triggerPasteEventHandlerAndWaitForTransaction = (event) => { + return waitUntilNextDocTransaction({ + tiptapEditor, + action: () => { + tiptapEditor.view.someProp('handlePaste', (eventHandler) => { + return eventHandler(tiptapEditor.view, event); + }); + }, + }); + }; + + it.each` + types | data | handled | desc + ${['text/plain']} | ${{}} | ${true} | ${'handles plain text'} + ${['text/plain', 'text/html']} | ${{}} | ${false} | ${'doesn’t handle html format'} + ${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "markdown" }' }} | ${true} | ${'handles vscode markdown'} + ${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "ruby" }' }} | ${false} | ${'doesn’t vscode code snippet'} + `('$desc', ({ types, handled, data }) => { + expect(triggerPasteEventHandler(buildClipboardEvent({ types, data }))).toBe(handled); + }); + + describe('when pasting raw markdown source', () => { + describe('when rendering markdown succeeds', () => { + beforeEach(() => { + renderMarkdown.mockResolvedValueOnce('<strong>bold text</strong>'); + }); + + it('transforms pasted text into a prosemirror node', async () => { + const expectedDoc = doc(p(bold('bold text'))); + + await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent()); + + 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', () => { + beforeEach(() => { + renderMarkdown.mockRejectedValueOnce(); + }); + + it(`triggers ${LOADING_ERROR_EVENT} event`, async () => { + triggerPasteEventHandler(buildClipboardEvent()); + + await waitForPromises(); + + expect(eventHub.$emit).toHaveBeenCalledWith(LOADING_ERROR_EVENT); + }); + + it(`triggers ${ALERT_EVENT} event`, async () => { + triggerPasteEventHandler(buildClipboardEvent()); + + await waitForPromises(); + + expect(eventHub.$emit).toHaveBeenCalledWith(ALERT_EVENT, { + message: expect.any(String), + variant: VARIANT_DANGER, + }); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/markdown_processing_spec_helper.js b/spec/frontend/content_editor/markdown_processing_spec_helper.js index bb7ec0030a2..41442dd8388 100644 --- a/spec/frontend/content_editor/markdown_processing_spec_helper.js +++ b/spec/frontend/content_editor/markdown_processing_spec_helper.js @@ -55,7 +55,7 @@ const testSerializesHtmlToMarkdownForElement = async ({ markdown, html }) => { // Assert that the markdown we ended up with after sending it through all the ContentEditor // plumbing matches the original markdown from the YAML. - expect(serializedContent).toBe(markdown); + expect(serializedContent.trim()).toBe(markdown.trim()); }; // describeMarkdownProcesssing diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js index e48687f1548..3bc72b13302 100644 --- a/spec/frontend/content_editor/services/content_editor_spec.js +++ b/spec/frontend/content_editor/services/content_editor_spec.js @@ -4,19 +4,31 @@ import { LOADING_ERROR_EVENT, } from '~/content_editor/constants'; import { ContentEditor } from '~/content_editor/services/content_editor'; - -import { createTestEditor } from '../test_utils'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import { createTestEditor, createDocBuilder } from '../test_utils'; describe('content_editor/services/content_editor', () => { let contentEditor; let serializer; + let deserializer; + let eventHub; + let doc; + let p; beforeEach(() => { const tiptapEditor = createTestEditor(); jest.spyOn(tiptapEditor, 'destroy'); + ({ + builders: { doc, p }, + } = createDocBuilder({ + tiptapEditor, + })); + serializer = { deserialize: jest.fn() }; - contentEditor = new ContentEditor({ tiptapEditor, serializer }); + deserializer = { deserialize: jest.fn() }; + eventHub = eventHubFactory(); + contentEditor = new ContentEditor({ tiptapEditor, serializer, deserializer, eventHub }); }); describe('.dispose', () => { @@ -30,33 +42,42 @@ describe('content_editor/services/content_editor', () => { }); describe('when setSerializedContent succeeds', () => { + let document; + beforeEach(() => { - serializer.deserialize.mockResolvedValueOnce(''); + document = doc(p('document')); + deserializer.deserialize.mockResolvedValueOnce({ document }); }); - it('emits loadingContent and loadingSuccess event', () => { + it('emits loadingContent and loadingSuccess event in the eventHub', () => { let loadingContentEmitted = false; - contentEditor.on(LOADING_CONTENT_EVENT, () => { + eventHub.$on(LOADING_CONTENT_EVENT, () => { loadingContentEmitted = true; }); - contentEditor.on(LOADING_SUCCESS_EVENT, () => { + eventHub.$on(LOADING_SUCCESS_EVENT, () => { expect(loadingContentEmitted).toBe(true); }); contentEditor.setSerializedContent('**bold text**'); }); + + it('sets the deserialized document in the tiptap editor object', async () => { + await contentEditor.setSerializedContent('**bold text**'); + + expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON()); + }); }); describe('when setSerializedContent fails', () => { const error = 'error'; beforeEach(() => { - serializer.deserialize.mockRejectedValueOnce(error); + deserializer.deserialize.mockRejectedValueOnce(error); }); it('emits loadingError event', async () => { - contentEditor.on(LOADING_ERROR_EVENT, (e) => { + eventHub.$on(LOADING_ERROR_EVENT, (e) => { expect(e).toBe('error'); }); diff --git a/spec/frontend/content_editor/services/markdown_deserializer_spec.js b/spec/frontend/content_editor/services/markdown_deserializer_spec.js new file mode 100644 index 00000000000..bea43a0effc --- /dev/null +++ b/spec/frontend/content_editor/services/markdown_deserializer_spec.js @@ -0,0 +1,62 @@ +import createMarkdownDeserializer from '~/content_editor/services/markdown_deserializer'; +import Bold from '~/content_editor/extensions/bold'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/services/markdown_deserializer', () => { + let renderMarkdown; + let doc; + let p; + let bold; + let tiptapEditor; + + beforeEach(() => { + tiptapEditor = createTestEditor({ + extensions: [Bold], + }); + + ({ + builders: { doc, p, bold }, + } = createDocBuilder({ + tiptapEditor, + names: { + bold: { markType: Bold.name }, + }, + })); + renderMarkdown = jest.fn(); + }); + + describe('when deserializing', () => { + let result; + const text = 'Bold text'; + + beforeEach(async () => { + const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); + + renderMarkdown.mockResolvedValueOnce(`<p><strong>${text}</strong></p>`); + + result = await deserializer.deserialize({ + content: 'content', + schema: tiptapEditor.schema, + }); + }); + it('transforms HTML returned by render function to a ProseMirror document', async () => { + const expectedDoc = doc(p(bold(text))); + + expect(result.document.toJSON()).toEqual(expectedDoc.toJSON()); + }); + + it('returns parsed HTML as a DOM object', () => { + expect(result.dom.innerHTML).toEqual(`<p><strong>${text}</strong></p><!--content-->`); + }); + }); + + describe('when the render function returns an empty value', () => { + it('returns an empty object', async () => { + const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); + + renderMarkdown.mockResolvedValueOnce(null); + + expect(await deserializer.deserialize({ content: 'content' })).toEqual({}); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 01d4c994e88..2b76dc6c984 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -597,6 +597,7 @@ this is not really json but just trying out whether this case works or not paragraph('A giant ', italic('owl-like'), ' creature.'), ), ), + heading('this is a heading'), ), ).toBe( ` @@ -612,6 +613,8 @@ A giant _owl-like_ creature. </dd> </dl> + +# this is a heading `.trim(), ); }); @@ -623,6 +626,7 @@ A giant _owl-like_ creature. detailsContent(paragraph('this is the summary')), detailsContent(paragraph('this content will be hidden')), ), + heading('this is a heading'), ), ).toBe( ` @@ -630,6 +634,8 @@ A giant _owl-like_ creature. <summary>this is the summary</summary> this content will be hidden </details> + +# this is a heading `.trim(), ); }); @@ -648,7 +654,7 @@ this content will be hidden detailsContent(paragraph('this content will be ', italic('hidden'))), ), details(detailsContent(paragraph('summary 2')), detailsContent(paragraph('content 2'))), - ), + ).trim(), ).toBe( ` <details> @@ -669,6 +675,7 @@ console.log(c); this content will be _hidden_ </details> + <details> <summary>summary 2</summary> content 2 @@ -694,7 +701,7 @@ content 2 ), ), ), - ), + ).trim(), ).toBe( ` <details> @@ -709,7 +716,9 @@ content 2 _inception_ </details> + </details> + </details> `.trim(), ); diff --git a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js index 6f908f468f6..abd9588daff 100644 --- a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js +++ b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js @@ -2,8 +2,8 @@ import { Extension } from '@tiptap/core'; import BulletList from '~/content_editor/extensions/bullet_list'; import ListItem from '~/content_editor/extensions/list_item'; import Paragraph from '~/content_editor/extensions/paragraph'; -import markdownSerializer from '~/content_editor/services/markdown_serializer'; -import { getMarkdownSource } from '~/content_editor/services/markdown_sourcemap'; +import markdownDeserializer from '~/content_editor/services/markdown_deserializer'; +import { getMarkdownSource, getFullSource } from '~/content_editor/services/markdown_sourcemap'; import { createTestEditor, createDocBuilder } from '../test_utils'; const BULLET_LIST_MARKDOWN = `+ list item 1 @@ -52,10 +52,29 @@ const { }); describe('content_editor/services/markdown_sourcemap', () => { + describe('getFullSource', () => { + it.each` + lastChild | expected + ${null} | ${[]} + ${{ nodeName: 'paragraph' }} | ${[]} + ${{ nodeName: '#comment', textContent: null }} | ${[]} + ${{ nodeName: '#comment', textContent: '+ list item 1\n+ list item 2' }} | ${['+ list item 1', '+ list item 2']} + `('with lastChild=$lastChild, returns $expected', ({ lastChild, expected }) => { + const element = { + ownerDocument: { + body: { + lastChild, + }, + }, + }; + + expect(getFullSource(element)).toEqual(expected); + }); + }); + it('gets markdown source for a rendered HTML element', async () => { - const deserialized = await markdownSerializer({ + const { document } = await markdownDeserializer({ render: () => BULLET_LIST_HTML, - serializerConfig: {}, }).deserialize({ schema: tiptapEditor.schema, content: BULLET_LIST_MARKDOWN, @@ -76,6 +95,6 @@ describe('content_editor/services/markdown_sourcemap', () => { ), ); - expect(deserialized).toEqual(expected.toJSON()); + expect(document.toJSON()).toEqual(expected.toJSON()); }); }); diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index 84eaa3c5f44..dde9d738235 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -142,3 +142,23 @@ export const triggerMarkInputRule = ({ tiptapEditor, inputRuleText }) => { f(view, selection.from, inputRuleText.length + 1, inputRuleText), ); }; + +/** + * Executes an action that triggers a transaction in the + * tiptap Editor. Returns a promise that resolves + * after the transaction completes + * @param {*} params.tiptapEditor Tiptap editor + * @param {*} params.action A function that triggers a transaction in the tiptap Editor + * @returns A promise that resolves when the transaction completes + */ +export const waitUntilNextDocTransaction = ({ tiptapEditor, action }) => { + return new Promise((resolve) => { + const handleTransaction = () => { + tiptapEditor.off('update', handleTransaction); + resolve(); + }; + + tiptapEditor.on('update', handleTransaction); + action(); + }); +}; |