diff options
Diffstat (limited to 'spec/frontend/content_editor')
10 files changed, 461 insertions, 52 deletions
diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap new file mode 100644 index 00000000000..35c02911e27 --- /dev/null +++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`content_editor/components/toolbar_button displays tertiary, small button with a provided label and icon 1`] = ` +"<b-button-stub size=\\"sm\\" variant=\\"default\\" type=\\"button\\" tag=\\"button\\" aria-label=\\"Bold\\" title=\\"Bold\\" class=\\"gl-mx-2 gl-button btn-default-tertiary btn-icon\\"> + <!----> + <gl-icon-stub name=\\"bold\\" size=\\"16\\" class=\\"gl-button-icon\\"></gl-icon-stub> + <!----> +</b-button-stub>" +`; diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index f055a49135b..e3741032bf4 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -1,26 +1,59 @@ +import { EditorContent } from '@tiptap/vue-2'; import { shallowMount } from '@vue/test-utils'; -import { EditorContent } from 'tiptap'; import ContentEditor from '~/content_editor/components/content_editor.vue'; -import createEditor from '~/content_editor/services/create_editor'; - -jest.mock('~/content_editor/services/create_editor'); +import TopToolbar from '~/content_editor/components/top_toolbar.vue'; +import { createContentEditor } from '~/content_editor/services/create_content_editor'; describe('ContentEditor', () => { let wrapper; + let editor; - const buildWrapper = () => { - wrapper = shallowMount(ContentEditor); + const createWrapper = async (contentEditor) => { + wrapper = shallowMount(ContentEditor, { + propsData: { + contentEditor, + }, + }); }; + beforeEach(() => { + editor = createContentEditor({ renderMarkdown: () => true }); + }); + afterEach(() => { wrapper.destroy(); }); it('renders editor content component and attaches editor instance', () => { - const editor = {}; + createWrapper(editor); + + expect(wrapper.findComponent(EditorContent).props().editor).toBe(editor.tiptapEditor); + }); + + it('renders top toolbar component and attaches editor instance', () => { + createWrapper(editor); + + expect(wrapper.findComponent(TopToolbar).props().contentEditor).toBe(editor); + }); + + it.each` + isFocused | classes + ${true} | ${['md', 'md-area', 'is-focused']} + ${false} | ${['md', 'md-area']} + `( + 'has $classes class selectors when tiptapEditor.isFocused = $isFocused', + ({ isFocused, classes }) => { + editor.tiptapEditor.isFocused = isFocused; + createWrapper(editor); + + expect(wrapper.classes()).toStrictEqual(classes); + }, + ); + + it('adds isFocused class when tiptapEditor is focused', () => { + editor.tiptapEditor.isFocused = true; + createWrapper(editor); - createEditor.mockReturnValueOnce(editor); - buildWrapper(); - expect(wrapper.findComponent(EditorContent).props().editor).toBe(editor); + expect(wrapper.classes()).toContain('is-focused'); }); }); diff --git a/spec/frontend/content_editor/components/toolbar_button_spec.js b/spec/frontend/content_editor/components/toolbar_button_spec.js new file mode 100644 index 00000000000..a49efa34017 --- /dev/null +++ b/spec/frontend/content_editor/components/toolbar_button_spec.js @@ -0,0 +1,98 @@ +import { GlButton } from '@gitlab/ui'; +import { Extension } from '@tiptap/core'; +import { shallowMount } from '@vue/test-utils'; +import ToolbarButton from '~/content_editor/components/toolbar_button.vue'; +import { createContentEditor } from '~/content_editor/services/create_content_editor'; + +describe('content_editor/components/toolbar_button', () => { + let wrapper; + let tiptapEditor; + let toggleFooSpy; + const CONTENT_TYPE = 'bold'; + const ICON_NAME = 'bold'; + const LABEL = 'Bold'; + + const buildEditor = () => { + toggleFooSpy = jest.fn(); + tiptapEditor = createContentEditor({ + extensions: [ + { + tiptapExtension: Extension.create({ + addCommands() { + return { + toggleFoo: () => toggleFooSpy, + }; + }, + }), + }, + ], + renderMarkdown: () => true, + }).tiptapEditor; + + jest.spyOn(tiptapEditor, 'isActive'); + }; + + const buildWrapper = (propsData = {}) => { + wrapper = shallowMount(ToolbarButton, { + stubs: { + GlButton, + }, + propsData: { + tiptapEditor, + contentType: CONTENT_TYPE, + iconName: ICON_NAME, + label: LABEL, + ...propsData, + }, + }); + }; + const findButton = () => wrapper.findComponent(GlButton); + + beforeEach(() => { + buildEditor(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays tertiary, small button with a provided label and icon', () => { + buildWrapper(); + + expect(findButton().html()).toMatchSnapshot(); + }); + + it.each` + editorState | outcomeDescription | outcome + ${{ isActive: true, isFocused: true }} | ${'button is active'} | ${true} + ${{ isActive: false, isFocused: true }} | ${'button is not active'} | ${false} + ${{ isActive: true, isFocused: false }} | ${'button is not active '} | ${false} + `('$outcomeDescription when when editor state is $editorState', ({ editorState, outcome }) => { + tiptapEditor.isActive.mockReturnValueOnce(editorState.isActive); + tiptapEditor.isFocused = editorState.isFocused; + buildWrapper(); + + expect(findButton().classes().includes('active')).toBe(outcome); + expect(tiptapEditor.isActive).toHaveBeenCalledWith(CONTENT_TYPE); + }); + + describe('when button is clicked', () => { + it('executes the content type command when executeCommand = true', async () => { + buildWrapper({ editorCommand: 'toggleFoo' }); + + await findButton().trigger('click'); + + expect(toggleFooSpy).toHaveBeenCalled(); + expect(wrapper.emitted().execute).toHaveLength(1); + }); + + it('does not executes the content type command when executeCommand = false', async () => { + buildWrapper(); + + await findButton().trigger('click'); + + expect(toggleFooSpy).not.toHaveBeenCalled(); + expect(wrapper.emitted().execute).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js new file mode 100644 index 00000000000..8f47be3f489 --- /dev/null +++ b/spec/frontend/content_editor/components/top_toolbar_spec.js @@ -0,0 +1,76 @@ +import { shallowMount } from '@vue/test-utils'; +import { mockTracking } from 'helpers/tracking_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import TopToolbar from '~/content_editor/components/top_toolbar.vue'; +import { + TOOLBAR_CONTROL_TRACKING_ACTION, + CONTENT_EDITOR_TRACKING_LABEL, +} from '~/content_editor/constants'; +import { createContentEditor } from '~/content_editor/services/create_content_editor'; + +describe('content_editor/components/top_toolbar', () => { + let wrapper; + let contentEditor; + let trackingSpy; + const buildEditor = () => { + contentEditor = createContentEditor({ renderMarkdown: () => true }); + }; + + const buildWrapper = () => { + wrapper = extendedWrapper( + shallowMount(TopToolbar, { + propsData: { + contentEditor, + }, + }), + ); + }; + + beforeEach(() => { + trackingSpy = mockTracking(undefined, null, jest.spyOn); + }); + + beforeEach(() => { + buildEditor(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + testId | buttonProps + ${'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 }) => { + beforeEach(() => { + buildWrapper(); + }); + + it('renders the toolbar control with the provided properties', () => { + expect(wrapper.findByTestId(testId).props()).toEqual({ + ...buttonProps, + tiptapEditor: contentEditor.tiptapEditor, + }); + }); + + it.each` + control | eventData + ${'bold'} | ${{ contentType: 'bold' }} + ${'blockquote'} | ${{ contentType: 'blockquote', value: 1 }} + `('tracks the execution of toolbar controls', ({ control, eventData }) => { + const { contentType, value } = eventData; + wrapper.findByTestId(control).vm.$emit('execute', eventData); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, TOOLBAR_CONTROL_TRACKING_ACTION, { + label: CONTENT_EDITOR_TRACKING_LABEL, + property: contentType, + value, + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/markdown_processing_spec.js b/spec/frontend/content_editor/markdown_processing_spec.js index e435af30e9f..cb34476d680 100644 --- a/spec/frontend/content_editor/markdown_processing_spec.js +++ b/spec/frontend/content_editor/markdown_processing_spec.js @@ -1,12 +1,13 @@ -import { createEditor } from '~/content_editor'; +import { createContentEditor } from '~/content_editor'; import { loadMarkdownApiExamples, loadMarkdownApiResult } from './markdown_processing_examples'; describe('markdown processing', () => { // Ensure we generate same markdown that was provided to Markdown API. it.each(loadMarkdownApiExamples())('correctly handles %s', async (testName, markdown) => { const { html } = loadMarkdownApiResult(testName); - const editor = await createEditor({ content: markdown, renderMarkdown: () => html }); + const contentEditor = createContentEditor({ renderMarkdown: () => html }); + await contentEditor.setSerializedContent(markdown); - expect(editor.getSerializedContent()).toBe(markdown); + expect(contentEditor.getSerializedContent()).toBe(markdown); }); }); diff --git a/spec/frontend/content_editor/services/build_serializer_config_spec.js b/spec/frontend/content_editor/services/build_serializer_config_spec.js new file mode 100644 index 00000000000..532e0493830 --- /dev/null +++ b/spec/frontend/content_editor/services/build_serializer_config_spec.js @@ -0,0 +1,38 @@ +import * as Blockquote from '~/content_editor/extensions/blockquote'; +import * as Bold from '~/content_editor/extensions/bold'; +import * as Dropcursor from '~/content_editor/extensions/dropcursor'; +import * as Paragraph from '~/content_editor/extensions/paragraph'; + +import buildSerializerConfig from '~/content_editor/services/build_serializer_config'; + +describe('content_editor/services/build_serializer_config', () => { + describe('given one or more content editor extensions', () => { + it('creates a serializer config that collects all extension serializers by type', () => { + const extensions = [Bold, Blockquote, Paragraph]; + const serializerConfig = buildSerializerConfig(extensions); + + extensions.forEach(({ tiptapExtension, serializer }) => { + const { name, type } = tiptapExtension; + expect(serializerConfig[`${type}s`][name]).toBe(serializer); + }); + }); + }); + + describe('given an extension without serializer', () => { + it('does not include the extension in the serializer config', () => { + const serializerConfig = buildSerializerConfig([Dropcursor]); + + expect(serializerConfig.marks[Dropcursor.tiptapExtension.name]).toBe(undefined); + expect(serializerConfig.nodes[Dropcursor.tiptapExtension.name]).toBe(undefined); + }); + }); + + describe('given no extensions', () => { + it('creates an empty serializer config', () => { + expect(buildSerializerConfig()).toStrictEqual({ + marks: {}, + nodes: {}, + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/create_content_editor_spec.js b/spec/frontend/content_editor/services/create_content_editor_spec.js new file mode 100644 index 00000000000..59b2fab6d54 --- /dev/null +++ b/spec/frontend/content_editor/services/create_content_editor_spec.js @@ -0,0 +1,51 @@ +import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants'; +import { createContentEditor } from '~/content_editor/services/create_content_editor'; +import { createTestContentEditorExtension } from '../test_utils'; + +describe('content_editor/services/create_editor', () => { + let renderMarkdown; + let editor; + + beforeEach(() => { + renderMarkdown = jest.fn(); + editor = createContentEditor({ renderMarkdown }); + }); + + it('sets gl-outline-0! class selector to the tiptapEditor instance', () => { + expect(editor.tiptapEditor.options.editorProps).toMatchObject({ + attributes: { + class: 'gl-outline-0!', + }, + }); + }); + + it('provides the renderMarkdown function to the markdown serializer', async () => { + const serializedContent = '**bold text**'; + + renderMarkdown.mockReturnValueOnce('<p><b>bold text</b></p>'); + + await editor.setSerializedContent(serializedContent); + + expect(renderMarkdown).toHaveBeenCalledWith(serializedContent); + }); + + it('allows providing external content editor extensions', async () => { + const labelReference = 'this is a ~group::editor'; + + renderMarkdown.mockReturnValueOnce( + '<p>this is a <span data-reference="label" data-label-name="group::editor">group::editor</span></p>', + ); + editor = createContentEditor({ + renderMarkdown, + extensions: [createTestContentEditorExtension()], + }); + + await editor.setSerializedContent(labelReference); + + expect(editor.getSerializedContent()).toBe(labelReference); + }); + + it('throws an error when a renderMarkdown fn is not provided', () => { + expect(() => createContentEditor()).toThrow(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); + }); +}); diff --git a/spec/frontend/content_editor/services/create_editor_spec.js b/spec/frontend/content_editor/services/create_editor_spec.js deleted file mode 100644 index 4cf63e608eb..00000000000 --- a/spec/frontend/content_editor/services/create_editor_spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants'; -import createEditor from '~/content_editor/services/create_editor'; -import createMarkdownSerializer from '~/content_editor/services/markdown_serializer'; - -jest.mock('~/content_editor/services/markdown_serializer'); - -describe('content_editor/services/create_editor', () => { - const buildMockSerializer = () => ({ - serialize: jest.fn(), - deserialize: jest.fn(), - }); - - describe('creating an editor', () => { - it('uses markdown serializer when a renderMarkdown function is provided', async () => { - const renderMarkdown = () => true; - const mockSerializer = buildMockSerializer(); - createMarkdownSerializer.mockReturnValueOnce(mockSerializer); - - await createEditor({ renderMarkdown }); - - expect(createMarkdownSerializer).toHaveBeenCalledWith({ render: renderMarkdown }); - }); - - it('uses custom serializer when it is provided', async () => { - const mockSerializer = buildMockSerializer(); - const serializedContent = '**bold**'; - - mockSerializer.serialize.mockReturnValueOnce(serializedContent); - - const editor = await createEditor({ serializer: mockSerializer }); - - expect(editor.getSerializedContent()).toBe(serializedContent); - }); - - it('throws an error when neither a serializer or renderMarkdown fn are provided', async () => { - await expect(createEditor()).rejects.toThrow(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); - }); - }); -}); 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 new file mode 100644 index 00000000000..437714ba938 --- /dev/null +++ b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js @@ -0,0 +1,108 @@ +import { BulletList } from '@tiptap/extension-bullet-list'; +import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; +import { Document } from '@tiptap/extension-document'; +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 { mockTracking } from 'helpers/tracking_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { + KEYBOARD_SHORTCUT_TRACKING_ACTION, + INPUT_RULE_TRACKING_ACTION, + CONTENT_EDITOR_TRACKING_LABEL, +} from '~/content_editor/constants'; +import trackInputRulesAndShortcuts from '~/content_editor/services/track_input_rules_and_shortcuts'; +import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys'; + +describe('content_editor/services/track_input_rules_and_shortcuts', () => { + let wrapper; + let trackingSpy; + let editor; + const HEADING_TEXT = 'Heading text'; + + const buildWrapper = () => { + wrapper = extendedWrapper( + mount(EditorContent, { + propsData: { + editor, + }, + }), + ); + }; + + beforeEach(() => { + trackingSpy = mockTracking(undefined, null, jest.spyOn); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('given the heading extension is instrumented', () => { + beforeEach(() => { + editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + Heading, + CodeBlockLowlight, + BulletList, + ListItem, + ].map(trackInputRulesAndShortcuts), + }); + }); + + beforeEach(async () => { + buildWrapper(); + await nextTick(); + }); + + describe('when creating a heading using an keyboard shortcut', () => { + it('sends a tracking event indicating that a heading was created using an input rule', async () => { + const shortcuts = Heading.config.addKeyboardShortcuts.call(Heading); + const [firstShortcut] = Object.keys(shortcuts); + const nodeName = Heading.name; + + editor.chain().keyboardShortcut(firstShortcut).insertContent(HEADING_TEXT).run(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, KEYBOARD_SHORTCUT_TRACKING_ACTION, { + label: CONTENT_EDITOR_TRACKING_LABEL, + property: `${nodeName}.${firstShortcut}`, + }); + }); + }); + + it.each` + extension | shortcut + ${ListItem.name} | ${ENTER_KEY} + ${CodeBlockLowlight.name} | ${BACKSPACE_KEY} + `('does not track $shortcut shortcut for $extension extension', ({ shortcut }) => { + editor.chain().keyboardShortcut(shortcut).run(); + + expect(trackingSpy).not.toHaveBeenCalled(); + }); + + describe('when creating a heading using an input rule', () => { + it('sends a tracking event indicating that a heading was created using an input rule', async () => { + const nodeName = Heading.name; + const { view } = editor; + const { selection } = view.state; + + // Triggers the event handler that input rules listen to + view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, '## ')); + + editor.chain().insertContent(HEADING_TEXT).run(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, INPUT_RULE_TRACKING_ACTION, { + label: CONTENT_EDITOR_TRACKING_LABEL, + property: `${nodeName}`, + }); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js new file mode 100644 index 00000000000..a92ceb6d058 --- /dev/null +++ b/spec/frontend/content_editor/test_utils.js @@ -0,0 +1,34 @@ +import { Node } from '@tiptap/core'; + +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 }; + }, + }, + }; + }, + parseHTML() { + return [ + { + tag: 'span[data-reference="label"]', + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + return ['span', HTMLAttributes, 0]; + }, + }), + serializer: (state, node) => { + state.write(`~${node.attrs.labelName}`); + state.closeBlock(node); + }, +}); |