diff options
Diffstat (limited to 'spec/frontend/content_editor')
9 files changed, 549 insertions, 72 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 new file mode 100644 index 00000000000..e56c37b0dc9 --- /dev/null +++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap @@ -0,0 +1,36 @@ +// 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 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\\"> + <div role=\\"group\\" class=\\"input-group\\"> + <!----> + <!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"gl-form-input form-control\\"> + <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> + </div> + </form> + </li> + <!----> + <!----> + </div> + <!----> + </div> + </ul> +</div>" +`; diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index e3741032bf4..59c4190ad3a 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -27,7 +27,10 @@ describe('ContentEditor', () => { it('renders editor content component and attaches editor instance', () => { createWrapper(editor); - expect(wrapper.findComponent(EditorContent).props().editor).toBe(editor.tiptapEditor); + const editorContent = wrapper.findComponent(EditorContent); + + expect(editorContent.props().editor).toBe(editor.tiptapEditor); + expect(editorContent.classes()).toContain('md'); }); it('renders top toolbar component and attaches editor instance', () => { @@ -38,8 +41,8 @@ describe('ContentEditor', () => { it.each` isFocused | classes - ${true} | ${['md', 'md-area', 'is-focused']} - ${false} | ${['md', 'md-area']} + ${true} | ${['md-area', 'is-focused']} + ${false} | ${['md-area']} `( 'has $classes class selectors when tiptapEditor.isFocused = $isFocused', ({ isFocused, classes }) => { diff --git a/spec/frontend/content_editor/components/toolbar_link_button_spec.js b/spec/frontend/content_editor/components/toolbar_link_button_spec.js new file mode 100644 index 00000000000..812e769c891 --- /dev/null +++ b/spec/frontend/content_editor/components/toolbar_link_button_spec.js @@ -0,0 +1,151 @@ +import { GlDropdown, GlDropdownDivider, GlFormInputGroup, GlButton } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue'; +import { tiptapExtension as Link } from '~/content_editor/extensions/link'; +import { hasSelection } from '~/content_editor/services/utils'; +import { createTestEditor, mockChainedCommands } from '../test_utils'; + +jest.mock('~/content_editor/services/utils'); + +describe('content_editor/components/toolbar_link_button', () => { + let wrapper; + let editor; + + const buildWrapper = () => { + wrapper = mountExtended(ToolbarLinkButton, { + propsData: { + tiptapEditor: editor, + }, + stubs: { + GlFormInputGroup, + }, + }); + }; + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider); + const findLinkURLInput = () => wrapper.findComponent(GlFormInputGroup).find('input[type="text"]'); + const findApplyLinkButton = () => wrapper.findComponent(GlButton); + const findRemoveLinkButton = () => wrapper.findByText('Remove link'); + + beforeEach(() => { + editor = createTestEditor({ + extensions: [Link], + }); + }); + + afterEach(() => { + editor.destroy(); + wrapper.destroy(); + }); + + it('renders dropdown component', () => { + buildWrapper(); + + expect(findDropdown().html()).toMatchSnapshot(); + }); + + describe('when there is an active link', () => { + beforeEach(() => { + jest.spyOn(editor, 'isActive'); + editor.isActive.mockReturnValueOnce(true); + buildWrapper(); + }); + + it('sets dropdown as active when link extension is active', () => { + expect(findDropdown().props('toggleClass')).toEqual({ active: true }); + }); + + it('displays a remove link dropdown option', () => { + expect(findDropdownDivider().exists()).toBe(true); + expect(wrapper.findByText('Remove link').exists()).toBe(true); + }); + + it('executes removeLink command when the remove link option is clicked', async () => { + const commands = mockChainedCommands(editor, ['focus', 'unsetLink', 'run']); + + await findRemoveLinkButton().trigger('click'); + + expect(commands.unsetLink).toHaveBeenCalled(); + expect(commands.focus).toHaveBeenCalled(); + expect(commands.run).toHaveBeenCalled(); + }); + + it('updates the link with a new link when "Apply" button is clicked', async () => { + const commands = mockChainedCommands(editor, ['focus', 'unsetLink', 'setLink', 'run']); + + await findLinkURLInput().setValue('https://example'); + await findApplyLinkButton().trigger('click'); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.unsetLink).toHaveBeenCalled(); + expect(commands.setLink).toHaveBeenCalledWith({ href: 'https://example' }); + expect(commands.run).toHaveBeenCalled(); + }); + }); + + describe('when there is not an active link', () => { + beforeEach(() => { + jest.spyOn(editor, 'isActive'); + editor.isActive.mockReturnValueOnce(false); + buildWrapper(); + }); + + it('does not set dropdown as active', () => { + expect(findDropdown().props('toggleClass')).toEqual({ active: false }); + }); + + it('does not display a remove link dropdown option', () => { + expect(findDropdownDivider().exists()).toBe(false); + expect(wrapper.findByText('Remove link').exists()).toBe(false); + }); + + it('sets the link to the value in the URL input when "Apply" button is clicked', async () => { + const commands = mockChainedCommands(editor, ['focus', 'unsetLink', 'setLink', 'run']); + + await findLinkURLInput().setValue('https://example'); + await findApplyLinkButton().trigger('click'); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.setLink).toHaveBeenCalledWith({ href: 'https://example' }); + expect(commands.run).toHaveBeenCalled(); + }); + }); + + describe('when the user displays the dropdown', () => { + let commands; + + beforeEach(() => { + commands = mockChainedCommands(editor, ['focus', 'extendMarkRange', 'run']); + }); + + describe('given the user has not selected text', () => { + beforeEach(() => { + hasSelection.mockReturnValueOnce(false); + }); + + it('the editor selection is extended to the current mark extent', () => { + buildWrapper(); + + findDropdown().vm.$emit('show'); + expect(commands.extendMarkRange).toHaveBeenCalledWith(Link.name); + expect(commands.focus).toHaveBeenCalled(); + expect(commands.run).toHaveBeenCalled(); + }); + }); + + describe('given the user has selected text', () => { + beforeEach(() => { + hasSelection.mockReturnValueOnce(true); + }); + + it('the editor does not modify the current selection', () => { + buildWrapper(); + + findDropdown().vm.$emit('show'); + expect(commands.extendMarkRange).not.toHaveBeenCalled(); + expect(commands.focus).not.toHaveBeenCalled(); + expect(commands.run).not.toHaveBeenCalled(); + }); + }); + }); +}); 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 new file mode 100644 index 00000000000..8c54f6bb8bb --- /dev/null +++ b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js @@ -0,0 +1,131 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue'; +import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants'; +import { createTestContentEditorExtension, createTestEditor } from '../test_utils'; + +describe('content_editor/components/toolbar_headings_dropdown', () => { + let wrapper; + let tiptapEditor; + let commandMocks; + + const buildEditor = () => { + const testExtension = createTestContentEditorExtension({ + commands: TEXT_STYLE_DROPDOWN_ITEMS.map((item) => item.editorCommand), + }); + + commandMocks = testExtension.commandMocks; + tiptapEditor = createTestEditor({ + extensions: [testExtension.tiptapExtension], + }); + + jest.spyOn(tiptapEditor, 'isActive'); + }; + + const buildWrapper = (propsData = {}) => { + wrapper = shallowMountExtended(ToolbarTextStyleDropdown, { + stubs: { + GlDropdown, + GlDropdownItem, + }, + propsData: { + tiptapEditor, + ...propsData, + }, + }); + }; + const findDropdown = () => wrapper.findComponent(GlDropdown); + + beforeEach(() => { + buildEditor(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders all text styles as dropdown items', () => { + buildWrapper(); + + TEXT_STYLE_DROPDOWN_ITEMS.forEach((textStyle) => { + expect(wrapper.findByText(textStyle.label).exists()).toBe(true); + }); + }); + + describe('when there is an active item ', () => { + let activeTextStyle; + + beforeEach(() => { + [, activeTextStyle] = TEXT_STYLE_DROPDOWN_ITEMS; + + tiptapEditor.isActive.mockImplementation( + (contentType, params) => + activeTextStyle.contentType === contentType && activeTextStyle.commandParams === params, + ); + + buildWrapper(); + }); + + it('displays the active text style label as the dropdown toggle text ', () => { + expect(findDropdown().props().text).toBe(activeTextStyle.label); + }); + + it('sets dropdown as enabled', () => { + expect(findDropdown().props().disabled).toBe(false); + }); + + it('sets active item as active', () => { + const activeItem = wrapper + .findAllComponents(GlDropdownItem) + .filter((item) => item.text() === activeTextStyle.label) + .at(0); + expect(activeItem.props().isChecked).toBe(true); + }); + }); + + describe('when there isn’t an active item', () => { + beforeEach(() => { + tiptapEditor.isActive.mockReturnValue(false); + buildWrapper(); + }); + + it('sets dropdown as disabled', () => { + expect(findDropdown().props().disabled).toBe(true); + }); + + it('sets dropdown toggle text to Text style', () => { + expect(findDropdown().props().text).toBe('Text style'); + }); + }); + + describe('when a text style is selected', () => { + it('executes the tiptap command related to that text style', () => { + buildWrapper(); + + TEXT_STYLE_DROPDOWN_ITEMS.forEach((textStyle, index) => { + const { editorCommand, commandParams } = textStyle; + + wrapper.findAllComponents(GlDropdownItem).at(index).vm.$emit('click'); + expect(commandMocks[editorCommand]).toHaveBeenCalledWith(commandParams || {}); + }); + }); + + it('emits execute event with contentType and value params that indicates the heading level', () => { + TEXT_STYLE_DROPDOWN_ITEMS.forEach((textStyle, index) => { + buildWrapper(); + const { contentType, commandParams } = textStyle; + + wrapper.findAllComponents(GlDropdownItem).at(index).vm.$emit('click'); + expect(wrapper.emitted('execute')).toEqual([ + [ + { + contentType, + value: commandParams?.level, + }, + ], + ]); + wrapper.destroy(); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js index 8f47be3f489..0a1405a1774 100644 --- a/spec/frontend/content_editor/components/top_toolbar_spec.js +++ b/spec/frontend/content_editor/components/top_toolbar_spec.js @@ -39,32 +39,35 @@ describe('content_editor/components/top_toolbar', () => { }); describe.each` - testId | buttonProps + testId | controlProps ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }} ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }} ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} - `('given a $testId toolbar control', ({ testId, buttonProps }) => { + ${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }} + ${'text-styles'} | ${{}} + ${'link'} | ${{}} + `('given a $testId toolbar control', ({ testId, controlProps }) => { beforeEach(() => { buildWrapper(); }); it('renders the toolbar control with the provided properties', () => { expect(wrapper.findByTestId(testId).props()).toEqual({ - ...buttonProps, + ...controlProps, tiptapEditor: contentEditor.tiptapEditor, }); }); it.each` - control | eventData - ${'bold'} | ${{ contentType: 'bold' }} - ${'blockquote'} | ${{ contentType: 'blockquote', value: 1 }} - `('tracks the execution of toolbar controls', ({ control, eventData }) => { + eventData + ${{ contentType: 'bold' }} + ${{ contentType: 'blockquote', value: 1 }} + `('tracks the execution of toolbar controls', ({ eventData }) => { const { contentType, value } = eventData; - wrapper.findByTestId(control).vm.$emit('execute', eventData); + wrapper.findByTestId(testId).vm.$emit('execute', eventData); expect(trackingSpy).toHaveBeenCalledWith(undefined, TOOLBAR_CONTROL_TRACKING_ACTION, { label: CONTENT_EDITOR_TRACKING_LABEL, diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js new file mode 100644 index 00000000000..cc695ffe241 --- /dev/null +++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js @@ -0,0 +1,37 @@ +import { tiptapExtension as CodeBlockHighlight } from '~/content_editor/extensions/code_block_highlight'; +import { loadMarkdownApiResult } from '../markdown_processing_examples'; +import { createTestEditor } from '../test_utils'; + +describe('content_editor/extensions/code_block_highlight', () => { + let codeBlockHtmlFixture; + let parsedCodeBlockHtmlFixture; + let tiptapEditor; + + const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html'); + const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre'); + + beforeEach(() => { + const { html } = loadMarkdownApiResult('code_block'); + + tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); + codeBlockHtmlFixture = html; + parsedCodeBlockHtmlFixture = parseHTML(codeBlockHtmlFixture); + + tiptapEditor.commands.setContent(codeBlockHtmlFixture); + }); + + it('extracts language and params attributes from Markdown API output', () => { + const language = preElement().getAttribute('lang'); + + expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({ + language, + params: language, + }); + }); + + it('adds code, highlight, and js-syntax-highlight to code block element', () => { + const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + + expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight'); + }); +}); diff --git a/spec/frontend/content_editor/extensions/link_spec.js b/spec/frontend/content_editor/extensions/link_spec.js new file mode 100644 index 00000000000..026b2a06df3 --- /dev/null +++ b/spec/frontend/content_editor/extensions/link_spec.js @@ -0,0 +1,61 @@ +import { + markdownLinkSyntaxInputRuleRegExp, + urlSyntaxRegExp, + extractHrefFromMarkdownLink, +} from '~/content_editor/extensions/link'; + +describe('content_editor/extensions/link', () => { + describe.each` + input | matches + ${'[gitlab](https://gitlab.com)'} | ${true} + ${'[documentation](readme.md)'} | ${true} + ${'[link 123](readme.md)'} | ${true} + ${'[link 123](read me.md)'} | ${true} + ${'text'} | ${false} + ${'documentation](readme.md'} | ${false} + ${'https://www.google.com'} | ${false} + `('markdownLinkSyntaxInputRuleRegExp', ({ input, matches }) => { + it(`${matches ? 'matches' : 'does not match'} ${input}`, () => { + const match = new RegExp(markdownLinkSyntaxInputRuleRegExp).exec(input); + + expect(Boolean(match?.groups.href)).toBe(matches); + }); + }); + + describe.each` + input | matches + ${'http://example.com '} | ${true} + ${'https://example.com '} | ${true} + ${'www.example.com '} | ${true} + ${'example.com/ab.html '} | ${false} + ${'text'} | ${false} + ${' http://example.com '} | ${true} + ${'https://www.google.com '} | ${true} + `('urlSyntaxRegExp', ({ input, matches }) => { + it(`${matches ? 'matches' : 'does not match'} ${input}`, () => { + const match = new RegExp(urlSyntaxRegExp).exec(input); + + expect(Boolean(match?.groups.href)).toBe(matches); + }); + }); + + describe('extractHrefFromMarkdownLink', () => { + const input = '[gitlab](https://gitlab.com)'; + const href = 'https://gitlab.com'; + let match; + let result; + + beforeEach(() => { + match = new RegExp(markdownLinkSyntaxInputRuleRegExp).exec(input); + result = extractHrefFromMarkdownLink(match); + }); + + it('extracts the url from a markdown link captured by markdownLinkSyntaxInputRuleRegExp', () => { + expect(result).toEqual({ href }); + }); + + it('makes sure that url text is the last capture group', () => { + expect(match[match.length - 1]).toEqual('gitlab'); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js index 437714ba938..cf74b5c56c9 100644 --- a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js +++ b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js @@ -5,11 +5,8 @@ import { Heading } from '@tiptap/extension-heading'; import { ListItem } from '@tiptap/extension-list-item'; import { Paragraph } from '@tiptap/extension-paragraph'; import { Text } from '@tiptap/extension-text'; -import { Editor, EditorContent } from '@tiptap/vue-2'; -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import { Editor } from '@tiptap/vue-2'; import { mockTracking } from 'helpers/tracking_helper'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { KEYBOARD_SHORTCUT_TRACKING_ACTION, INPUT_RULE_TRACKING_ACTION, @@ -19,47 +16,33 @@ import trackInputRulesAndShortcuts from '~/content_editor/services/track_input_r import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys'; describe('content_editor/services/track_input_rules_and_shortcuts', () => { - let wrapper; let trackingSpy; let editor; + let trackedExtensions; const HEADING_TEXT = 'Heading text'; - - const buildWrapper = () => { - wrapper = extendedWrapper( - mount(EditorContent, { - propsData: { - editor, - }, - }), - ); - }; + const extensions = [Document, Paragraph, Text, Heading, CodeBlockLowlight, BulletList, ListItem]; beforeEach(() => { trackingSpy = mockTracking(undefined, null, jest.spyOn); }); - afterEach(() => { - wrapper.destroy(); - }); - describe('given the heading extension is instrumented', () => { beforeEach(() => { + trackedExtensions = extensions.map(trackInputRulesAndShortcuts); editor = new Editor({ - extensions: [ - Document, - Paragraph, - Text, - Heading, - CodeBlockLowlight, - BulletList, - ListItem, - ].map(trackInputRulesAndShortcuts), + extensions: extensions.map(trackInputRulesAndShortcuts), }); }); - beforeEach(async () => { - buildWrapper(); - await nextTick(); + it('does not remove existing keyboard shortcuts', () => { + extensions.forEach((extension, index) => { + const originalShortcuts = Object.keys(extension.addKeyboardShortcuts?.() || {}); + const trackedShortcuts = Object.keys( + trackedExtensions[index].addKeyboardShortcuts?.() || {}, + ); + + expect(originalShortcuts).toEqual(trackedShortcuts); + }); }); describe('when creating a heading using an keyboard shortcut', () => { diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index a92ceb6d058..8e73aef678b 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -1,34 +1,106 @@ import { Node } from '@tiptap/core'; +import { Document } from '@tiptap/extension-document'; +import { Paragraph } from '@tiptap/extension-paragraph'; +import { Text } from '@tiptap/extension-text'; +import { Editor } from '@tiptap/vue-2'; -export const createTestContentEditorExtension = () => ({ - tiptapExtension: Node.create({ - name: 'label', - priority: 101, - inline: true, - group: 'inline', - addAttributes() { - return { - labelName: { - default: null, - parseHTML: (element) => { - return { labelName: element.dataset.labelName }; +/** + * Creates an instance of the Tiptap Editor class + * with a minimal configuration for testing purposes. + * + * It only includes the Document, Text, and Paragraph + * extensions. + * + * @param {Array} config.extensions One or more extensions to + * include in the editor + * @returns An instance of a Tiptap’s Editor class + */ +export const createTestEditor = ({ extensions = [] }) => { + return new Editor({ + extensions: [Document, Text, Paragraph, ...extensions], + }); +}; + +export const mockChainedCommands = (editor, commandNames = []) => { + const commandMocks = commandNames.reduce( + (accum, commandName) => ({ + ...accum, + [commandName]: jest.fn(), + }), + {}, + ); + + Object.keys(commandMocks).forEach((commandName) => { + commandMocks[commandName].mockReturnValue(commandMocks); + }); + + jest.spyOn(editor, 'chain').mockImplementation(() => commandMocks); + + return commandMocks; +}; + +/** + * Creates a Content Editor extension for testing + * purposes. + * + * @param {Array} config.commands A list of command names + * to include in the test extension. This utility will create + * Jest mock functions for each command name. + * @returns An object with the following properties: + * + * tiptapExtension A Node tiptap extension + * commandMocks Jest mock functions for each created command + * serializer A markdown serializer for the extension + */ +export const createTestContentEditorExtension = ({ commands = [] } = {}) => { + const commandMocks = commands.reduce( + (accum, commandName) => ({ + ...accum, + [commandName]: jest.fn(), + }), + {}, + ); + + return { + commandMocks, + tiptapExtension: Node.create({ + name: 'label', + priority: 101, + inline: true, + group: 'inline', + addCommands() { + return commands.reduce( + (accum, commandName) => ({ + ...accum, + [commandName]: (...params) => () => commandMocks[commandName](...params), + }), + {}, + ); + }, + addAttributes() { + return { + labelName: { + default: null, + parseHTML: (element) => { + return { labelName: element.dataset.labelName }; + }, }, - }, - }; - }, - parseHTML() { - return [ - { - tag: 'span[data-reference="label"]', - }, - ]; - }, - renderHTML({ HTMLAttributes }) { - return ['span', HTMLAttributes, 0]; + }; + }, + parseHTML() { + return [ + { + tag: 'span[data-reference="label"]', + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + return ['span', HTMLAttributes, 0]; + }, + }), + serializer: (state, node) => { + state.write(`~${node.attrs.labelName}`); + state.closeBlock(node); }, - }), - serializer: (state, node) => { - state.write(`~${node.attrs.labelName}`); - state.closeBlock(node); - }, -}); + }; +}; |