diff options
Diffstat (limited to 'spec/frontend/content_editor')
14 files changed, 1516 insertions, 63 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 3c88c05a4b4..8f5516545eb 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 @@ -11,7 +11,16 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen <ul role=\\"menu\\" tabindex=\\"-1\\" class=\\"dropdown-menu\\"> <div class=\\"gl-new-dropdown-inner\\"> <!----> + <div class=\\"gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5\\"> + <div class=\\"gl-display-flex\\"> + <!----> + </div> + <div class=\\"gl-display-flex\\"> + <!----> + </div> + </div> <div class=\\"gl-new-dropdown-contents\\"> + <!----> <li role=\\"presentation\\" class=\\"gl-px-3!\\"> <form tabindex=\\"-1\\" class=\\"b-dropdown-form gl-p-0\\"> <div placeholder=\\"Link URL\\"> diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index d516baf6f0f..3d1ef03083d 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -6,6 +6,7 @@ import ContentEditor from '~/content_editor/components/content_editor.vue'; import ContentEditorError from '~/content_editor/components/content_editor_error.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/formatting_bubble_menu.vue'; import TopToolbar from '~/content_editor/components/top_toolbar.vue'; import { LOADING_CONTENT_EVENT, @@ -25,6 +26,7 @@ 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(); @@ -131,6 +133,10 @@ describe('ContentEditor', () => { it('hides EditorContent component', () => { expect(findEditorContent().exists()).toBe(false); }); + + it('hides formatting bubble menu', () => { + expect(findBubbleMenu().exists()).toBe(false); + }); }); describe('when loading content succeeds', () => { @@ -171,5 +177,9 @@ describe('ContentEditor', () => { it('displays EditorContent component', () => { expect(findEditorContent().exists()).toBe(true); }); + + it('displays formatting bubble menu', () => { + expect(findBubbleMenu().exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js new file mode 100644 index 00000000000..e48f59f6d9c --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js @@ -0,0 +1,193 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { NodeViewWrapper } from '@tiptap/vue-2'; +import { selectedRect as getSelectedRect } from 'prosemirror-tables'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue'; +import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../../test_utils'; + +jest.mock('prosemirror-tables'); + +describe('content/components/wrappers/table_cell_base', () => { + let wrapper; + let editor; + let getPos; + + const createWrapper = async (propsData = { cellType: 'td' }) => { + wrapper = shallowMountExtended(TableCellBaseWrapper, { + propsData: { + editor, + getPos, + ...propsData, + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItemWithLabel = (name) => + wrapper + .findAllComponents(GlDropdownItem) + .filter((dropdownItem) => dropdownItem.text().includes(name)) + .at(0); + const findDropdownItemWithLabelExists = (name) => + wrapper + .findAllComponents(GlDropdownItem) + .filter((dropdownItem) => dropdownItem.text().includes(name)).length > 0; + const setCurrentPositionInCell = () => { + const { $cursor } = editor.state.selection; + + getPos.mockReturnValue($cursor.pos - $cursor.parentOffset - 1); + }; + const mockDropdownHide = () => { + /* + * TODO: Replace this method with using the scoped hide function + * provided by BootstrapVue https://bootstrap-vue.org/docs/components/dropdown. + * GitLab UI is not exposing it in the default scope + */ + findDropdown().vm.hide = jest.fn(); + }; + + beforeEach(() => { + getPos = jest.fn(); + editor = createTestEditor({}); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a td node-view-wrapper with relative position', () => { + createWrapper(); + expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('gl-relative'); + expect(wrapper.findComponent(NodeViewWrapper).props().as).toBe('td'); + }); + + it('displays dropdown when selection cursor is on the cell', async () => { + setCurrentPositionInCell(); + createWrapper(); + + await nextTick(); + + expect(findDropdown().props()).toMatchObject({ + category: 'tertiary', + icon: 'chevron-down', + size: 'small', + split: false, + }); + expect(findDropdown().attributes()).toMatchObject({ + boundary: 'viewport', + 'no-caret': '', + }); + }); + + it('does not display dropdown when selection cursor is not on the cell', async () => { + createWrapper(); + + await nextTick(); + + expect(findDropdown().exists()).toBe(false); + }); + + describe('when dropdown is visible', () => { + beforeEach(async () => { + setCurrentPositionInCell(); + getSelectedRect.mockReturnValue({ + map: { + height: 1, + width: 1, + }, + }); + + createWrapper(); + await nextTick(); + + mockDropdownHide(); + }); + + it.each` + dropdownItemLabel | commandName + ${'Insert column before'} | ${'addColumnBefore'} + ${'Insert column after'} | ${'addColumnAfter'} + ${'Insert row before'} | ${'addRowBefore'} + ${'Insert row after'} | ${'addRowAfter'} + ${'Delete table'} | ${'deleteTable'} + `( + 'executes $commandName when $dropdownItemLabel button is clicked', + ({ commandName, dropdownItemLabel }) => { + const mocks = mockChainedCommands(editor, [commandName, 'run']); + + findDropdownItemWithLabel(dropdownItemLabel).vm.$emit('click'); + + expect(mocks[commandName]).toHaveBeenCalled(); + }, + ); + + it('does not allow deleting rows and columns', async () => { + expect(findDropdownItemWithLabelExists('Delete row')).toBe(false); + expect(findDropdownItemWithLabelExists('Delete column')).toBe(false); + }); + + it('allows deleting rows when there are more than 2 rows in the table', async () => { + const mocks = mockChainedCommands(editor, ['deleteRow', 'run']); + + getSelectedRect.mockReturnValue({ + map: { + height: 3, + }, + }); + + emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' }); + + await nextTick(); + + findDropdownItemWithLabel('Delete row').vm.$emit('click'); + + expect(mocks.deleteRow).toHaveBeenCalled(); + }); + + it('allows deleting columns when there are more than 1 column in the table', async () => { + const mocks = mockChainedCommands(editor, ['deleteColumn', 'run']); + + getSelectedRect.mockReturnValue({ + map: { + width: 2, + }, + }); + + emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' }); + + await nextTick(); + + findDropdownItemWithLabel('Delete column').vm.$emit('click'); + + expect(mocks.deleteColumn).toHaveBeenCalled(); + }); + + describe('when current row is the table’s header', () => { + beforeEach(async () => { + // Remove 2 rows condition + getSelectedRect.mockReturnValue({ + map: { + height: 3, + }, + }); + + createWrapper({ cellType: 'th' }); + + await nextTick(); + }); + + it('does not allow adding a row before the header', async () => { + expect(findDropdownItemWithLabelExists('Insert row before')).toBe(false); + }); + + it('does not allow removing the header row', async () => { + createWrapper({ cellType: 'th' }); + + await nextTick(); + + expect(findDropdownItemWithLabelExists('Delete row')).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js new file mode 100644 index 00000000000..5d26c44ba03 --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js @@ -0,0 +1,37 @@ +import { shallowMount } from '@vue/test-utils'; +import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue'; +import TableCellBodyWrapper from '~/content_editor/components/wrappers/table_cell_body.vue'; +import { createTestEditor } from '../../test_utils'; + +describe('content/components/wrappers/table_cell_body', () => { + let wrapper; + let editor; + let getPos; + + const createWrapper = async () => { + wrapper = shallowMount(TableCellBodyWrapper, { + propsData: { + editor, + getPos, + }, + }); + }; + + beforeEach(() => { + getPos = jest.fn(); + editor = createTestEditor({}); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a TableCellBase component', () => { + createWrapper(); + expect(wrapper.findComponent(TableCellBaseWrapper).props()).toEqual({ + editor, + getPos, + cellType: 'td', + }); + }); +}); diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js new file mode 100644 index 00000000000..e561191418d --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js @@ -0,0 +1,37 @@ +import { shallowMount } from '@vue/test-utils'; +import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue'; +import TableCellHeaderWrapper from '~/content_editor/components/wrappers/table_cell_header.vue'; +import { createTestEditor } from '../../test_utils'; + +describe('content/components/wrappers/table_cell_header', () => { + let wrapper; + let editor; + let getPos; + + const createWrapper = async () => { + wrapper = shallowMount(TableCellHeaderWrapper, { + propsData: { + editor, + getPos, + }, + }); + }; + + beforeEach(() => { + getPos = jest.fn(); + editor = createTestEditor({}); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a TableCellBase component', () => { + createWrapper(); + expect(wrapper.findComponent(TableCellBaseWrapper).props()).toEqual({ + editor, + getPos, + cellType: 'th', + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js index 1334b1ddaad..d4f05a25bd6 100644 --- a/spec/frontend/content_editor/extensions/attachment_spec.js +++ b/spec/frontend/content_editor/extensions/attachment_spec.js @@ -1,18 +1,23 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { once } from 'lodash'; -import waitForPromises from 'helpers/wait_for_promises'; 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 httpStatus from '~/lib/utils/http_status'; -import { loadMarkdownApiResult } from '../markdown_processing_examples'; import { createTestEditor, createDocBuilder } from '../test_utils'; +const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="auto"> + <a class="no-attachment-icon" href="/group1/project1/-/wikis/test-file.png" target="_blank" rel="noopener noreferrer" data-canonical-src="test-file.png"> + <img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png"> + </a> +</p>`; +const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto"> + <a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a> +</p>`; + describe('content_editor/extensions/attachment', () => { let tiptapEditor; - let eq; let doc; let p; let image; @@ -25,6 +30,24 @@ describe('content_editor/extensions/attachment', () => { const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' }); const attachmentFile = new File(['foo'], 'test-file.zip', { type: 'application/zip' }); + const expectDocumentAfterTransaction = ({ number, expectedDoc, action }) => { + return new Promise((resolve) => { + let counter = 1; + const handleTransaction = () => { + if (counter === number) { + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); + tiptapEditor.off('update', handleTransaction); + resolve(); + } + + counter += 1; + }; + + tiptapEditor.on('update', handleTransaction); + action(); + }); + }; + beforeEach(() => { renderMarkdown = jest.fn(); @@ -34,7 +57,6 @@ describe('content_editor/extensions/attachment', () => { ({ builders: { doc, p, image, loading, link }, - eq, } = createDocBuilder({ tiptapEditor, names: { @@ -76,9 +98,7 @@ describe('content_editor/extensions/attachment', () => { const base64EncodedFile = 'data:image/png;base64,Zm9v'; beforeEach(() => { - renderMarkdown.mockResolvedValue( - loadMarkdownApiResult('project_wiki_attachment_image').body, - ); + renderMarkdown.mockResolvedValue(PROJECT_WIKI_ATTACHMENT_IMAGE_HTML); }); describe('when uploading succeeds', () => { @@ -92,18 +112,14 @@ describe('content_editor/extensions/attachment', () => { mock.onPost().reply(httpStatus.OK, successResponse); }); - it('inserts an image with src set to the encoded image file and uploading true', (done) => { + it('inserts an image with src set to the encoded image file and uploading true', async () => { const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile }))); - tiptapEditor.on( - 'update', - once(() => { - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); - done(); - }), - ); - - tiptapEditor.commands.uploadAttachment({ file: imageFile }); + await expectDocumentAfterTransaction({ + number: 1, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + }); }); it('updates the inserted image with canonicalSrc when upload is successful', async () => { @@ -118,11 +134,11 @@ describe('content_editor/extensions/attachment', () => { ), ); - tiptapEditor.commands.uploadAttachment({ file: imageFile }); - - await waitForPromises(); - - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + await expectDocumentAfterTransaction({ + number: 2, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + }); }); }); @@ -131,14 +147,14 @@ describe('content_editor/extensions/attachment', () => { mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR); }); - it('resets the doc to orginal state', async () => { + it('resets the doc to original state', async () => { const expectedDoc = doc(p('')); - tiptapEditor.commands.uploadAttachment({ file: imageFile }); - - await waitForPromises(); - - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + await expectDocumentAfterTransaction({ + number: 2, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + }); }); it('emits an error event that includes an error message', (done) => { @@ -153,7 +169,7 @@ describe('content_editor/extensions/attachment', () => { }); describe('when the file has a zip (or any other attachment) mime type', () => { - const markdownApiResult = loadMarkdownApiResult('project_wiki_attachment_link').body; + const markdownApiResult = PROJECT_WIKI_ATTACHMENT_LINK_HTML; beforeEach(() => { renderMarkdown.mockResolvedValue(markdownApiResult); @@ -170,18 +186,14 @@ describe('content_editor/extensions/attachment', () => { mock.onPost().reply(httpStatus.OK, successResponse); }); - it('inserts a loading mark', (done) => { + it('inserts a loading mark', async () => { const expectedDoc = doc(p(loading({ label: 'test-file' }))); - tiptapEditor.on( - 'update', - once(() => { - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); - done(); - }), - ); - - tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); + await expectDocumentAfterTransaction({ + number: 1, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: attachmentFile }), + }); }); it('updates the loading mark with a link with canonicalSrc and href attrs', async () => { @@ -198,11 +210,11 @@ describe('content_editor/extensions/attachment', () => { ), ); - tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); - - await waitForPromises(); - - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + await expectDocumentAfterTransaction({ + number: 2, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: attachmentFile }), + }); }); }); @@ -214,11 +226,11 @@ describe('content_editor/extensions/attachment', () => { it('resets the doc to orginal state', async () => { const expectedDoc = doc(p('')); - tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); - - await waitForPromises(); - - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + await expectDocumentAfterTransaction({ + number: 2, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: attachmentFile }), + }); }); it('emits an error event that includes an error message', (done) => { diff --git a/spec/frontend/content_editor/extensions/blockquote_spec.js b/spec/frontend/content_editor/extensions/blockquote_spec.js new file mode 100644 index 00000000000..c5b5044352d --- /dev/null +++ b/spec/frontend/content_editor/extensions/blockquote_spec.js @@ -0,0 +1,19 @@ +import { multilineInputRegex } from '~/content_editor/extensions/blockquote'; + +describe('content_editor/extensions/blockquote', () => { + describe.each` + input | matches + ${'>>> '} | ${true} + ${' >>> '} | ${true} + ${'\t>>> '} | ${true} + ${'>> '} | ${false} + ${'>>>x '} | ${false} + ${'> '} | ${false} + `('multilineInputRegex', ({ input, matches }) => { + it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => { + const match = new RegExp(multilineInputRegex).test(input); + + expect(match).toBe(matches); + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js index 188e6580dc6..6a0a0c76825 100644 --- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js +++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js @@ -1,9 +1,15 @@ import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; -import { loadMarkdownApiResult } from '../markdown_processing_examples'; import { createTestEditor } from '../test_utils'; +const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"> + <code> + <span id="LC1" class="line" lang="javascript"> + <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">hello world</span><span class="dl">'</span><span class="p">)</span> + </span> + </code> +</pre>`; + describe('content_editor/extensions/code_block_highlight', () => { - let codeBlockHtmlFixture; let parsedCodeBlockHtmlFixture; let tiptapEditor; @@ -11,13 +17,10 @@ describe('content_editor/extensions/code_block_highlight', () => { const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre'); beforeEach(() => { - const { html } = loadMarkdownApiResult('code_block'); - tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); - codeBlockHtmlFixture = html; - parsedCodeBlockHtmlFixture = parseHTML(codeBlockHtmlFixture); + parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML); - tiptapEditor.commands.setContent(codeBlockHtmlFixture); + tiptapEditor.commands.setContent(CODE_BLOCK_HTML); }); it('extracts language and params attributes from Markdown API output', () => { diff --git a/spec/frontend/content_editor/markdown_processing_examples.js b/spec/frontend/content_editor/markdown_processing_examples.js index 12eed00f3c6..b3aabfeb145 100644 --- a/spec/frontend/content_editor/markdown_processing_examples.js +++ b/spec/frontend/content_editor/markdown_processing_examples.js @@ -6,7 +6,8 @@ import { getJSONFixture } from 'helpers/fixtures'; export const loadMarkdownApiResult = (testName) => { const fixturePathPrefix = `api/markdown/${testName}.json`; - return getJSONFixture(fixturePathPrefix); + const fixture = getJSONFixture(fixturePathPrefix); + return fixture.body || fixture.html; }; export const loadMarkdownApiExamples = () => { @@ -16,3 +17,9 @@ export const loadMarkdownApiExamples = () => { return apiMarkdownExampleObjects.map(({ name, context, markdown }) => [name, context, markdown]); }; + +export const loadMarkdownApiExample = (testName) => { + return loadMarkdownApiExamples().find(([name, context]) => { + return (context ? `${context}_${name}` : name) === testName; + })[2]; +}; diff --git a/spec/frontend/content_editor/markdown_processing_spec.js b/spec/frontend/content_editor/markdown_processing_spec.js index da3f6e64db8..71565768558 100644 --- a/spec/frontend/content_editor/markdown_processing_spec.js +++ b/spec/frontend/content_editor/markdown_processing_spec.js @@ -9,8 +9,9 @@ describe('markdown processing', () => { 'correctly handles %s (context: %s)', async (name, context, markdown) => { const testName = context ? `${context}_${name}` : name; - const { html, body } = loadMarkdownApiResult(testName); - const contentEditor = createContentEditor({ renderMarkdown: () => html || body }); + const contentEditor = createContentEditor({ + renderMarkdown: () => loadMarkdownApiResult(testName), + }); await contentEditor.setSerializedContent(markdown); expect(contentEditor.getSerializedContent()).toBe(markdown); diff --git a/spec/frontend/content_editor/services/mark_utils_spec.js b/spec/frontend/content_editor/services/mark_utils_spec.js new file mode 100644 index 00000000000..bbfb8f26f99 --- /dev/null +++ b/spec/frontend/content_editor/services/mark_utils_spec.js @@ -0,0 +1,38 @@ +import { + markInputRegex, + extractMarkAttributesFromMatch, +} from '~/content_editor/services/mark_utils'; + +describe('content_editor/services/mark_utils', () => { + describe.each` + tag | input | matches + ${'tag'} | ${'<tag>hello</tag>'} | ${true} + ${'tag'} | ${'<tag title="tooltip">hello</tag>'} | ${true} + ${'kbd'} | ${'Hold <kbd>Ctrl</kbd>'} | ${true} + ${'time'} | ${'Lets meet at <time title="today" datetime="20:00">20:00</time>'} | ${true} + ${'tag'} | ${'<tag width=30 height=30>attrs not quoted</tag>'} | ${false} + ${'tag'} | ${"<tag title='abc'>single quote attrs not supported</tag>"} | ${false} + ${'tag'} | ${'<tag title>attr has no value</tag>'} | ${false} + ${'tag'} | ${'<tag>tag opened but not closed'} | ${false} + ${'tag'} | ${'</tag>tag closed before opened<tag>'} | ${false} + `('inputRegex("$tag")', ({ tag, input, matches }) => { + it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => { + const match = markInputRegex(tag).test(input); + + expect(match).toBe(matches); + }); + }); + + describe.each` + tag | input | attrs + ${'kbd'} | ${'Hold <kbd>Ctrl</kbd>'} | ${{}} + ${'tag'} | ${'<tag title="tooltip">hello</tag>'} | ${{ title: 'tooltip' }} + ${'time'} | ${'Lets meet at <time title="today" datetime="20:00">20:00</time>'} | ${{ title: 'today', datetime: '20:00' }} + ${'abbr'} | ${'Sure, you can try it out but <abbr title="Your mileage may vary">YMMV</abbr>'} | ${{ title: 'Your mileage may vary' }} + `('extractAttributesFromMatch(inputRegex("$tag").exec(\'$input\'))', ({ tag, input, attrs }) => { + it(`returns: "${JSON.stringify(attrs)}"`, () => { + const matches = markInputRegex(tag).exec(input); + expect(extractMarkAttributesFromMatch(matches)).toEqual(attrs); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js new file mode 100644 index 00000000000..6f2c908c289 --- /dev/null +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -0,0 +1,1008 @@ +import Blockquote from '~/content_editor/extensions/blockquote'; +import Bold from '~/content_editor/extensions/bold'; +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 DescriptionItem from '~/content_editor/extensions/description_item'; +import DescriptionList from '~/content_editor/extensions/description_list'; +import Division from '~/content_editor/extensions/division'; +import Emoji from '~/content_editor/extensions/emoji'; +import Figure from '~/content_editor/extensions/figure'; +import FigureCaption from '~/content_editor/extensions/figure_caption'; +import HardBreak from '~/content_editor/extensions/hard_break'; +import Heading from '~/content_editor/extensions/heading'; +import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; +import Image from '~/content_editor/extensions/image'; +import InlineDiff from '~/content_editor/extensions/inline_diff'; +import Italic from '~/content_editor/extensions/italic'; +import Link from '~/content_editor/extensions/link'; +import ListItem from '~/content_editor/extensions/list_item'; +import OrderedList from '~/content_editor/extensions/ordered_list'; +import Paragraph from '~/content_editor/extensions/paragraph'; +import Strike from '~/content_editor/extensions/strike'; +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 TaskItem from '~/content_editor/extensions/task_item'; +import TaskList from '~/content_editor/extensions/task_list'; +import Text from '~/content_editor/extensions/text'; +import markdownSerializer from '~/content_editor/services/markdown_serializer'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +jest.mock('~/emoji'); + +jest.mock('~/content_editor/services/feature_flags', () => ({ + isBlockTablesFeatureEnabled: jest.fn().mockReturnValue(true), +})); + +const tiptapEditor = createTestEditor({ + extensions: [ + Blockquote, + Bold, + BulletList, + Code, + CodeBlockHighlight, + DescriptionItem, + DescriptionList, + Division, + Emoji, + Figure, + FigureCaption, + HardBreak, + Heading, + HorizontalRule, + Image, + InlineDiff, + Italic, + Link, + ListItem, + OrderedList, + Paragraph, + Strike, + Table, + TableCell, + TableHeader, + TableRow, + TaskItem, + TaskList, + Text, + ], +}); + +const { + builders: { + doc, + blockquote, + bold, + bulletList, + code, + codeBlock, + division, + descriptionItem, + descriptionList, + emoji, + figure, + figureCaption, + heading, + hardBreak, + horizontalRule, + image, + inlineDiff, + italic, + link, + listItem, + orderedList, + paragraph, + strike, + table, + tableCell, + tableHeader, + tableRow, + taskItem, + taskList, + }, +} = createDocBuilder({ + tiptapEditor, + names: { + blockquote: { nodeType: Blockquote.name }, + bold: { markType: Bold.name }, + bulletList: { nodeType: BulletList.name }, + code: { markType: Code.name }, + codeBlock: { nodeType: CodeBlockHighlight.name }, + division: { nodeType: Division.name }, + descriptionItem: { nodeType: DescriptionItem.name }, + descriptionList: { nodeType: DescriptionList.name }, + emoji: { markType: Emoji.name }, + figure: { nodeType: Figure.name }, + figureCaption: { nodeType: FigureCaption.name }, + hardBreak: { nodeType: HardBreak.name }, + heading: { nodeType: Heading.name }, + horizontalRule: { nodeType: HorizontalRule.name }, + image: { nodeType: Image.name }, + inlineDiff: { markType: InlineDiff.name }, + italic: { nodeType: Italic.name }, + link: { markType: Link.name }, + listItem: { nodeType: ListItem.name }, + orderedList: { nodeType: OrderedList.name }, + paragraph: { nodeType: Paragraph.name }, + strike: { markType: Strike.name }, + table: { nodeType: Table.name }, + tableCell: { nodeType: TableCell.name }, + tableHeader: { nodeType: TableHeader.name }, + tableRow: { nodeType: TableRow.name }, + taskItem: { nodeType: TaskItem.name }, + taskList: { nodeType: TaskList.name }, + }, +}); + +const serialize = (...content) => + markdownSerializer({}).serialize({ + schema: tiptapEditor.schema, + content: doc(...content).toJSON(), + }); + +describe('markdownSerializer', () => { + it('correctly serializes bold', () => { + expect(serialize(paragraph(bold('bold')))).toBe('**bold**'); + }); + + it('correctly serializes italics', () => { + expect(serialize(paragraph(italic('italics')))).toBe('_italics_'); + }); + + it('correctly serializes inline diff', () => { + expect( + serialize( + paragraph( + inlineDiff({ type: 'addition' }, '+30 lines'), + inlineDiff({ type: 'deletion' }, '-10 lines'), + ), + ), + ).toBe('{++30 lines+}{--10 lines-}'); + }); + + it('correctly serializes a line break', () => { + expect(serialize(paragraph('hello', hardBreak(), 'world'))).toBe('hello\\\nworld'); + }); + + it('correctly serializes a link', () => { + expect(serialize(paragraph(link({ href: 'https://example.com' }, 'example url')))).toBe( + '[example url](https://example.com)', + ); + }); + + it('correctly serializes a plain URL link', () => { + expect(serialize(paragraph(link({ href: 'https://example.com' }, 'https://example.com')))).toBe( + '<https://example.com>', + ); + }); + + it('correctly serializes a link with a title', () => { + expect( + serialize( + paragraph(link({ href: 'https://example.com', title: 'click this link' }, 'example url')), + ), + ).toBe('[example url](https://example.com "click this link")'); + }); + + it('correctly serializes a plain URL link with a title', () => { + expect( + serialize( + paragraph( + link({ href: 'https://example.com', title: 'link title' }, 'https://example.com'), + ), + ), + ).toBe('[https://example.com](https://example.com "link title")'); + }); + + it('correctly serializes a link with a canonicalSrc', () => { + expect( + serialize( + paragraph( + link( + { + href: '/uploads/abcde/file.zip', + canonicalSrc: 'file.zip', + title: 'click here to download', + }, + 'download file', + ), + ), + ), + ).toBe('[download file](file.zip "click here to download")'); + }); + + it('correctly serializes strikethrough', () => { + expect(serialize(paragraph(strike('deleted content')))).toBe('~~deleted content~~'); + }); + + it('correctly serializes blockquotes with hard breaks', () => { + expect(serialize(blockquote('some text', hardBreak(), hardBreak(), 'new line'))).toBe( + ` +> some text\\ +> \\ +> new line + `.trim(), + ); + }); + + it('correctly serializes blockquote with multiple block nodes', () => { + expect(serialize(blockquote(paragraph('some paragraph'), codeBlock('var x = 10;')))).toBe( + ` +> some paragraph +> +> \`\`\` +> var x = 10; +> \`\`\` + `.trim(), + ); + }); + + it('correctly serializes a multiline blockquote', () => { + expect( + serialize( + blockquote( + { multiline: true }, + paragraph('some paragraph with ', bold('bold')), + codeBlock('var y = 10;'), + ), + ), + ).toBe( + ` +>>> +some paragraph with **bold** + +\`\`\` +var y = 10; +\`\`\` + +>>> + `.trim(), + ); + }); + + it('correctly serializes a code block with language', () => { + expect( + serialize( + codeBlock( + { language: 'json' }, + 'this is not really json but just trying out whether this case works or not', + ), + ), + ).toBe( + ` +\`\`\`json +this is not really json but just trying out whether this case works or not +\`\`\` + `.trim(), + ); + }); + + it('correctly serializes emoji', () => { + expect(serialize(paragraph(emoji({ name: 'dog' })))).toBe(':dog:'); + }); + + it('correctly serializes headings', () => { + expect( + serialize( + heading({ level: 1 }, 'Heading 1'), + heading({ level: 2 }, 'Heading 2'), + heading({ level: 3 }, 'Heading 3'), + heading({ level: 4 }, 'Heading 4'), + heading({ level: 5 }, 'Heading 5'), + heading({ level: 6 }, 'Heading 6'), + ), + ).toBe( + ` +# Heading 1 + +## Heading 2 + +### Heading 3 + +#### Heading 4 + +##### Heading 5 + +###### Heading 6 + `.trim(), + ); + }); + + it('correctly serializes horizontal rule', () => { + expect(serialize(horizontalRule(), horizontalRule(), horizontalRule())).toBe( + ` +--- + +--- + +--- + `.trim(), + ); + }); + + it('correctly serializes an image', () => { + expect(serialize(paragraph(image({ src: 'img.jpg', alt: 'foo bar' })))).toBe( + '![foo bar](img.jpg)', + ); + }); + + it('correctly serializes an image with a title', () => { + expect(serialize(paragraph(image({ src: 'img.jpg', title: 'baz', alt: 'foo bar' })))).toBe( + '![foo bar](img.jpg "baz")', + ); + }); + + it('correctly serializes an image with a canonicalSrc', () => { + expect( + serialize( + paragraph( + image({ + src: '/uploads/abcde/file.png', + alt: 'this is an image', + canonicalSrc: 'file.png', + title: 'foo bar baz', + }), + ), + ), + ).toBe('![this is an image](file.png "foo bar baz")'); + }); + + it('correctly serializes bullet list', () => { + expect( + serialize( + bulletList( + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem(paragraph('list item 3')), + ), + ), + ).toBe( + ` +* list item 1 +* list item 2 +* list item 3 + `.trim(), + ); + }); + + it('correctly serializes bullet list with different bullet styles', () => { + expect( + serialize( + bulletList( + { bullet: '+' }, + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem( + paragraph('list item 3'), + bulletList( + { bullet: '-' }, + listItem(paragraph('sub-list item 1')), + listItem(paragraph('sub-list item 2')), + ), + ), + ), + ), + ).toBe( + ` ++ list item 1 ++ list item 2 ++ list item 3 + - sub-list item 1 + - sub-list item 2 + `.trim(), + ); + }); + + it('correctly serializes a numeric list', () => { + expect( + serialize( + orderedList( + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem(paragraph('list item 3')), + ), + ), + ).toBe( + ` +1. list item 1 +2. list item 2 +3. list item 3 + `.trim(), + ); + }); + + it('correctly serializes a numeric list with parens', () => { + expect( + serialize( + orderedList( + { parens: true }, + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem(paragraph('list item 3')), + ), + ), + ).toBe( + ` +1) list item 1 +2) list item 2 +3) list item 3 + `.trim(), + ); + }); + + it('correctly serializes a numeric list with a different start order', () => { + expect( + serialize( + orderedList( + { start: 17 }, + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem(paragraph('list item 3')), + ), + ), + ).toBe( + ` +17. list item 1 +18. list item 2 +19. list item 3 + `.trim(), + ); + }); + + it('correctly serializes a numeric list with an invalid start order', () => { + expect( + serialize( + orderedList( + { start: NaN }, + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem(paragraph('list item 3')), + ), + ), + ).toBe( + ` +1. list item 1 +2. list item 2 +3. list item 3 + `.trim(), + ); + }); + + it('correctly serializes a bullet list inside an ordered list', () => { + expect( + serialize( + orderedList( + { start: 17 }, + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem( + paragraph('list item 3'), + bulletList( + listItem(paragraph('sub-list item 1')), + listItem(paragraph('sub-list item 2')), + ), + ), + ), + ), + ).toBe( + // notice that 4 space indent works fine in this case, + // when it usually wouldn't + ` +17. list item 1 +18. list item 2 +19. list item 3 + * sub-list item 1 + * sub-list item 2 + `.trim(), + ); + }); + + it('correctly serializes a task list', () => { + expect( + serialize( + taskList( + taskItem({ checked: true }, paragraph('list item 1')), + taskItem(paragraph('list item 2')), + taskItem( + paragraph('list item 3'), + taskList( + taskItem({ checked: true }, paragraph('sub-list item 1')), + taskItem(paragraph('sub-list item 2')), + ), + ), + ), + ), + ).toBe( + ` +* [x] list item 1 +* [ ] list item 2 +* [ ] list item 3 + * [x] sub-list item 1 + * [ ] sub-list item 2 + `.trim(), + ); + }); + + it('correctly serializes a numeric task list + with start order', () => { + expect( + serialize( + taskList( + { numeric: true }, + taskItem({ checked: true }, paragraph('list item 1')), + taskItem(paragraph('list item 2')), + taskItem( + paragraph('list item 3'), + taskList( + { numeric: true, start: 1351, parens: true }, + taskItem({ checked: true }, paragraph('sub-list item 1')), + taskItem(paragraph('sub-list item 2')), + ), + ), + ), + ), + ).toBe( + ` +1. [x] list item 1 +2. [ ] list item 2 +3. [ ] list item 3 + 1351) [x] sub-list item 1 + 1352) [ ] sub-list item 2 + `.trim(), + ); + }); + + it('correctly renders a description list', () => { + expect( + serialize( + descriptionList( + descriptionItem(paragraph('Beast of Bodmin')), + descriptionItem({ isTerm: false }, paragraph('A large feline inhabiting Bodmin Moor.')), + + descriptionItem(paragraph('Morgawr')), + descriptionItem({ isTerm: false }, paragraph('A sea serpent.')), + + descriptionItem(paragraph('Owlman')), + descriptionItem( + { isTerm: false }, + paragraph('A giant ', italic('owl-like'), ' creature.'), + ), + ), + ), + ).toBe( + ` +<dl> +<dt>Beast of Bodmin</dt> +<dd>A large feline inhabiting Bodmin Moor.</dd> +<dt>Morgawr</dt> +<dd>A sea serpent.</dd> +<dt>Owlman</dt> +<dd> + +A giant _owl-like_ creature. + +</dd> +</dl> + `.trim(), + ); + }); + + it('correctly renders div', () => { + expect( + serialize( + division(paragraph('just a paragraph in a div')), + division(paragraph('just some ', bold('styled'), ' ', italic('content'), ' in a div')), + ), + ).toBe( + '<div>just a paragraph in a div</div>\n<div>\n\njust some **styled** _content_ in a div\n\n</div>', + ); + }); + + it('correctly renders figure', () => { + expect( + serialize( + figure( + paragraph(image({ src: 'elephant.jpg', alt: 'An elephant at sunset' })), + figureCaption('An elephant at sunset'), + ), + ), + ).toBe( + ` +<figure> + +![An elephant at sunset](elephant.jpg) + +<figcaption>An elephant at sunset</figcaption> +</figure> + `.trim(), + ); + }); + + it('correctly renders figure with styled caption', () => { + expect( + serialize( + figure( + paragraph(image({ src: 'elephant.jpg', alt: 'An elephant at sunset' })), + figureCaption(italic('An elephant at sunset')), + ), + ), + ).toBe( + ` +<figure> + +![An elephant at sunset](elephant.jpg) + +<figcaption> + +_An elephant at sunset_ + +</figcaption> +</figure> + `.trim(), + ); + }); + + it('correctly serializes a table with inline content', () => { + expect( + serialize( + table( + // each table cell must contain at least one paragraph + tableRow( + tableHeader(paragraph('header')), + tableHeader(paragraph('header')), + tableHeader(paragraph('header')), + ), + tableRow( + tableCell(paragraph('cell')), + tableCell(paragraph('cell')), + tableCell(paragraph('cell')), + ), + tableRow( + tableCell(paragraph('cell')), + tableCell(paragraph('cell')), + tableCell(paragraph('cell')), + ), + ), + ).trim(), + ).toBe( + ` +| header | header | header | +|--------|--------|--------| +| cell | cell | cell | +| cell | cell | cell | + `.trim(), + ); + }); + + it('correctly serializes a table with line breaks', () => { + expect( + serialize( + table( + tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))), + tableRow( + tableCell(paragraph('cell with', hardBreak(), 'line', hardBreak(), 'breaks')), + tableCell(paragraph('cell')), + ), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + ), + ).trim(), + ).toBe( + ` +| header | header | +|--------|--------| +| cell with<br>line<br>breaks | cell | +| cell | cell | + `.trim(), + ); + }); + + it('correctly serializes two consecutive tables', () => { + expect( + serialize( + table( + tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + ), + table( + tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + ), + ).trim(), + ).toBe( + ` +| header | header | +|--------|--------| +| cell | cell | +| cell | cell | + +| header | header | +|--------|--------| +| cell | cell | +| cell | cell | + `.trim(), + ); + }); + + it('correctly serializes a table with block content', () => { + expect( + serialize( + table( + tableRow( + tableHeader(paragraph('examples of')), + tableHeader(paragraph('block content')), + tableHeader(paragraph('in tables')), + tableHeader(paragraph('in content editor')), + ), + tableRow( + tableCell(heading({ level: 1 }, 'heading 1')), + tableCell(heading({ level: 2 }, 'heading 2')), + tableCell(paragraph(bold('just bold'))), + tableCell(paragraph(bold('bold'), ' ', italic('italic'), ' ', code('code'))), + ), + tableRow( + tableCell( + paragraph('all marks in three paragraphs:'), + paragraph('the ', bold('quick'), ' ', italic('brown'), ' ', code('fox')), + paragraph( + link({ href: '/home' }, 'jumps'), + ' over the ', + strike('lazy'), + ' ', + emoji({ name: 'dog' }), + ), + ), + tableCell( + paragraph(image({ src: 'img.jpg', alt: 'some image' }), hardBreak(), 'image content'), + ), + tableCell( + blockquote('some text', hardBreak(), hardBreak(), 'in a multiline blockquote'), + ), + tableCell( + codeBlock( + { language: 'javascript' }, + 'var a = 2;\nvar b = 3;\nvar c = a + d;\n\nconsole.log(c);', + ), + ), + ), + tableRow( + tableCell(bulletList(listItem('item 1'), listItem('item 2'), listItem('item 2'))), + tableCell(orderedList(listItem('item 1'), listItem('item 2'), listItem('item 2'))), + tableCell( + paragraph('paragraphs separated by'), + horizontalRule(), + paragraph('a horizontal rule'), + ), + tableCell( + table( + tableRow(tableHeader(paragraph('table')), tableHeader(paragraph('inside'))), + tableRow(tableCell(paragraph('another')), tableCell(paragraph('table'))), + ), + ), + ), + ), + ).trim(), + ).toBe( + ` +<table> +<tr> +<th>examples of</th> +<th>block content</th> +<th>in tables</th> +<th>in content editor</th> +</tr> +<tr> +<td> + +# heading 1 +</td> +<td> + +## heading 2 +</td> +<td> + +**just bold** +</td> +<td> + +**bold** _italic_ \`code\` +</td> +</tr> +<tr> +<td> + +all marks in three paragraphs: + +the **quick** _brown_ \`fox\` + +[jumps](/home) over the ~~lazy~~ :dog: +</td> +<td> + +![some image](img.jpg)<br>image content +</td> +<td> + +> some text\\ +> \\ +> in a multiline blockquote +</td> +<td> + +\`\`\`javascript +var a = 2; +var b = 3; +var c = a + d; + +console.log(c); +\`\`\` +</td> +</tr> +<tr> +<td> + +* item 1 +* item 2 +* item 2 +</td> +<td> + +1. item 1 +2. item 2 +3. item 2 +</td> +<td> + +paragraphs separated by + +--- + +a horizontal rule +</td> +<td> + +| table | inside | +|-------|--------| +| another | table | + +</td> +</tr> +</table> + `.trim(), + ); + }); + + it('correctly renders content after a markdown table', () => { + expect( + serialize( + table(tableRow(tableHeader(paragraph('header'))), tableRow(tableCell(paragraph('cell')))), + heading({ level: 1 }, 'this is a heading'), + ).trim(), + ).toBe( + ` +| header | +|--------| +| cell | + +# this is a heading + `.trim(), + ); + }); + + it('correctly renders content after an html table', () => { + expect( + serialize( + table( + tableRow(tableHeader(paragraph('header'))), + tableRow(tableCell(blockquote('hi'), paragraph('there'))), + ), + heading({ level: 1 }, 'this is a heading'), + ).trim(), + ).toBe( + ` +<table> +<tr> +<th>header</th> +</tr> +<tr> +<td> + +> hi + +there +</td> +</tr> +</table> + +# this is a heading + `.trim(), + ); + }); + + it('correctly serializes tables with misplaced header cells', () => { + expect( + serialize( + table( + tableRow(tableHeader(paragraph('cell')), tableCell(paragraph('cell'))), + tableRow(tableCell(paragraph('cell')), tableHeader(paragraph('cell'))), + ), + ).trim(), + ).toBe( + ` +<table> +<tr> +<th>cell</th> +<td>cell</td> +</tr> +<tr> +<td>cell</td> +<th>cell</th> +</tr> +</table> + `.trim(), + ); + }); + + it('correctly serializes table without any headers', () => { + expect( + serialize( + table( + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + ), + ).trim(), + ).toBe( + ` +<table> +<tr> +<td>cell</td> +<td>cell</td> +</tr> +<tr> +<td>cell</td> +<td>cell</td> +</tr> +</table> + `.trim(), + ); + }); + + it('correctly serializes table with rowspan and colspan', () => { + expect( + serialize( + table( + tableRow( + tableHeader(paragraph('header')), + tableHeader(paragraph('header')), + tableHeader(paragraph('header')), + ), + tableRow( + tableCell({ colspan: 2 }, paragraph('cell with rowspan: 2')), + tableCell({ rowspan: 2 }, paragraph('cell')), + ), + tableRow(tableCell({ colspan: 2 }, paragraph('cell with rowspan: 2'))), + ), + ).trim(), + ).toBe( + ` +<table> +<tr> +<th>header</th> +<th>header</th> +<th>header</th> +</tr> +<tr> +<td colspan="2">cell with rowspan: 2</td> +<td rowspan="2">cell</td> +</tr> +<tr> +<td colspan="2">cell with rowspan: 2</td> +</tr> +</table> + `.trim(), + ); + }); +}); diff --git a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js new file mode 100644 index 00000000000..6f908f468f6 --- /dev/null +++ b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js @@ -0,0 +1,81 @@ +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 { createTestEditor, createDocBuilder } from '../test_utils'; + +const BULLET_LIST_MARKDOWN = `+ list item 1 ++ list item 2 + - embedded list item 3`; +const BULLET_LIST_HTML = `<ul data-sourcepos="1:1-3:24" dir="auto"> + <li data-sourcepos="1:1-1:13">list item 1</li> + <li data-sourcepos="2:1-3:24">list item 2 + <ul data-sourcepos="3:3-3:24"> + <li data-sourcepos="3:3-3:24">embedded list item 3</li> + </ul> + </li> +</ul>`; + +const SourcemapExtension = Extension.create({ + // lets add `source` attribute to every element using `getMarkdownSource` + addGlobalAttributes() { + return [ + { + types: [Paragraph.name, BulletList.name, ListItem.name], + attributes: { + source: { + parseHTML: (element) => { + const source = getMarkdownSource(element); + return source; + }, + }, + }, + }, + ]; + }, +}); + +const tiptapEditor = createTestEditor({ + extensions: [BulletList, ListItem, SourcemapExtension], +}); + +const { + builders: { doc, bulletList, listItem, paragraph }, +} = createDocBuilder({ + tiptapEditor, + names: { + bulletList: { nodeType: BulletList.name }, + listItem: { nodeType: ListItem.name }, + }, +}); + +describe('content_editor/services/markdown_sourcemap', () => { + it('gets markdown source for a rendered HTML element', async () => { + const deserialized = await markdownSerializer({ + render: () => BULLET_LIST_HTML, + serializerConfig: {}, + }).deserialize({ + schema: tiptapEditor.schema, + content: BULLET_LIST_MARKDOWN, + }); + + const expected = doc( + bulletList( + { bullet: '+', source: '+ list item 1\n+ list item 2' }, + listItem({ source: '+ list item 1' }, paragraph('list item 1')), + listItem( + { source: '+ list item 2' }, + paragraph('list item 2'), + bulletList( + { bullet: '-', source: '- embedded list item 3' }, + listItem({ source: '- embedded list item 3' }, paragraph('embedded list item 3')), + ), + ), + ), + ); + + expect(deserialized).toEqual(expected.toJSON()); + }); +}); diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index b5a2abc2389..cf5aa3f2938 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -98,9 +98,7 @@ export const createTestContentEditorExtension = ({ commands = [] } = {}) => { return { labelName: { default: null, - parseHTML: (element) => { - return { labelName: element.dataset.labelName }; - }, + parseHTML: (element) => element.dataset.labelName, }, }; }, |