Commit c05a065b authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '328652-upgrade-tiptap' into 'master'

Upgrade TipTap to v2

See merge request gitlab-org/gitlab!60006
parents 116f943d d2ddb757
<script> <script>
import { EditorContent, Editor } from 'tiptap'; import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
import { ContentEditor } from '../services/content_editor';
import TopToolbar from './top_toolbar.vue'; import TopToolbar from './top_toolbar.vue';
export default { export default {
components: { components: {
EditorContent, TiptapEditorContent,
TopToolbar, TopToolbar,
}, },
props: { props: {
editor: { contentEditor: {
type: Object, type: ContentEditor,
required: true, required: true,
validator: (editor) => editor instanceof Editor,
}, },
}, },
}; };
</script> </script>
<template> <template>
<div class="md md-area" :class="{ 'is-focused': editor.focused }"> <div class="md md-area" :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }">
<top-toolbar class="gl-mb-4" :editor="editor" /> <top-toolbar class="gl-mb-4" :content-editor="contentEditor" />
<editor-content :editor="editor" /> <tiptap-editor-content :editor="contentEditor.tiptapEditor" />
</div> </div>
</template> </template>
<script> <script>
import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { Editor as TiptapEditor } from '@tiptap/vue-2';
export default { export default {
components: { components: {
...@@ -13,8 +14,8 @@ export default { ...@@ -13,8 +14,8 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
editor: { tiptapEditor: {
type: Object, type: TiptapEditor,
required: true, required: true,
}, },
contentType: { contentType: {
...@@ -25,23 +26,23 @@ export default { ...@@ -25,23 +26,23 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
executeCommand: { editorCommand: {
type: Boolean, type: String,
required: false, required: false,
default: true, default: '',
}, },
}, },
computed: { computed: {
isActive() { isActive() {
return this.editor.isActive[this.contentType]() && this.editor.focused; return this.tiptapEditor.isActive(this.contentType) && this.tiptapEditor.isFocused;
}, },
}, },
methods: { methods: {
execute() { execute() {
const { contentType } = this; const { contentType } = this;
if (this.executeCommand) { if (this.editorCommand) {
this.editor.commands[contentType](); this.tiptapEditor.chain()[this.editorCommand]().focus().run();
} }
this.$emit('click', { contentType }); this.$emit('click', { contentType });
......
<script> <script>
import { ContentEditor } from '../services/content_editor';
import Divider from './divider.vue'; import Divider from './divider.vue';
import ToolbarButton from './toolbar_button.vue'; import ToolbarButton from './toolbar_button.vue';
...@@ -8,8 +9,8 @@ export default { ...@@ -8,8 +9,8 @@ export default {
Divider, Divider,
}, },
props: { props: {
editor: { contentEditor: {
type: Object, type: ContentEditor,
required: true, required: true,
}, },
}, },
...@@ -23,44 +24,50 @@ export default { ...@@ -23,44 +24,50 @@ export default {
data-testid="bold" data-testid="bold"
content-type="bold" content-type="bold"
icon-name="bold" icon-name="bold"
editor-command="toggleBold"
:label="__('Bold text')" :label="__('Bold text')"
:editor="editor" :tiptap-editor="contentEditor.tiptapEditor"
/> />
<toolbar-button <toolbar-button
data-testid="italic" data-testid="italic"
content-type="italic" content-type="italic"
icon-name="italic" icon-name="italic"
editor-command="toggleItalic"
:label="__('Italic text')" :label="__('Italic text')"
:editor="editor" :tiptap-editor="contentEditor.tiptapEditor"
/> />
<toolbar-button <toolbar-button
data-testid="code" data-testid="code"
content-type="code" content-type="code"
icon-name="code" icon-name="code"
editor-command="toggleCode"
:label="__('Code')" :label="__('Code')"
:editor="editor" :tiptap-editor="contentEditor.tiptapEditor"
/> />
<divider /> <divider />
<toolbar-button <toolbar-button
data-testid="blockquote" data-testid="blockquote"
content-type="blockquote" content-type="blockquote"
icon-name="quote" icon-name="quote"
editor-command="toggleBlockquote"
:label="__('Insert a quote')" :label="__('Insert a quote')"
:editor="editor" :tiptap-editor="contentEditor.tiptapEditor"
/> />
<toolbar-button <toolbar-button
data-testid="bullet-list" data-testid="bullet-list"
content-type="bullet_list" content-type="bulletList"
icon-name="list-bulleted" icon-name="list-bulleted"
editor-command="toggleBulletList"
:label="__('Add a bullet list')" :label="__('Add a bullet list')"
:editor="editor" :tiptap-editor="contentEditor.tiptapEditor"
/> />
<toolbar-button <toolbar-button
data-testid="ordered-list" data-testid="ordered-list"
content-type="ordered_list" content-type="orderedList"
icon-name="list-numbered" icon-name="list-numbered"
editor-command="toggleOrderedList"
:label="__('Add a numbered list')" :label="__('Add a numbered list')"
:editor="editor" :tiptap-editor="contentEditor.tiptapEditor"
/> />
</div> </div>
</template> </template>
import { CodeBlockHighlight as BaseCodeBlockHighlight } from 'tiptap-extensions'; import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
export default class GlCodeBlockHighlight extends BaseCodeBlockHighlight { const extractLanguage = (element) => element.firstElementChild?.getAttribute('lang');
get schema() {
const baseSchema = super.schema;
return {
...baseSchema,
attrs: {
params: {
default: null,
},
},
parseDOM: [
{
tag: 'pre',
preserveWhitespace: 'full',
getAttrs: (node) => {
const code = node.querySelector('code');
if (!code) {
return null;
}
export default CodeBlockLowlight.extend({
addAttributes() {
return { return {
...this.parent(),
/* `params` is the name of the attribute that /* `params` is the name of the attribute that
prosemirror-markdown uses to extract the language prosemirror-markdown uses to extract the language
of a codeblock. of a codeblock.
https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/to_markdown.js#L62 https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/to_markdown.js#L62
*/ */
params: code.getAttribute('lang'), params: {
parseHTML: (element) => {
return {
params: extractLanguage(element),
}; };
}, },
}, },
],
}; };
} },
} });
export { default as createEditor } from './services/create_editor'; export * from './services/create_content_editor';
export { default as ContentEditor } from './components/content_editor.vue'; export { default as ContentEditor } from './components/content_editor.vue';
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
constructor({ tiptapEditor, serializer }) {
this._tiptapEditor = tiptapEditor;
this._serializer = serializer;
}
get tiptapEditor() {
return this._tiptapEditor;
}
async setSerializedContent(serializedContent) {
const { _tiptapEditor: editor, _serializer: serializer } = this;
editor.commands.setContent(
await serializer.deserialize({ schema: editor.schema, content: serializedContent }),
);
}
getSerializedContent() {
const { _tiptapEditor: editor, _serializer: serializer } = this;
return serializer.serialize({ schema: editor.schema, content: editor.getJSON() });
}
}
import Blockquote from '@tiptap/extension-blockquote';
import Bold from '@tiptap/extension-bold';
import BulletList from '@tiptap/extension-bullet-list';
import Code from '@tiptap/extension-code';
import Document from '@tiptap/extension-document';
import Dropcursor from '@tiptap/extension-dropcursor';
import Gapcursor from '@tiptap/extension-gapcursor';
import HardBreak from '@tiptap/extension-hard-break';
import Heading from '@tiptap/extension-heading';
import History from '@tiptap/extension-history';
import HorizontalRule from '@tiptap/extension-horizontal-rule';
import Image from '@tiptap/extension-image';
import Italic from '@tiptap/extension-italic';
import Link from '@tiptap/extension-link';
import ListItem from '@tiptap/extension-list-item';
import OrderedList from '@tiptap/extension-ordered-list';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import CodeBlockHighlight from '../extensions/code_block_highlight';
import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
const createTiptapEditor = ({ extensions = [], options } = {}) =>
new Editor({
extensions: [
Dropcursor,
Gapcursor,
History,
Document,
Text,
Paragraph,
Bold,
Italic,
Code,
Link,
Heading,
HardBreak,
Blockquote,
HorizontalRule,
BulletList,
OrderedList,
ListItem,
Image.configure({ inline: true }),
CodeBlockHighlight,
...extensions,
],
editorProps: {
attributes: {
class: 'gl-outline-0!',
},
},
...options,
});
export const createContentEditor = ({ renderMarkdown, extensions = [], tiptapOptions } = {}) => {
if (!isFunction(renderMarkdown)) {
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
}
const tiptapEditor = createTiptapEditor({ extensions, options: tiptapOptions });
const serializer = createMarkdownSerializer({ render: renderMarkdown });
return new ContentEditor({ tiptapEditor, serializer });
};
import { isFunction, isString } from 'lodash';
import { Editor } from 'tiptap';
import {
Bold,
Italic,
Code,
Link,
Image,
Heading,
Blockquote,
HorizontalRule,
BulletList,
OrderedList,
ListItem,
HardBreak,
} from 'tiptap-extensions';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import CodeBlockHighlight from '../extensions/code_block_highlight';
import createMarkdownSerializer from './markdown_serializer';
const createEditor = async ({
content,
renderMarkdown,
serializer: customSerializer,
...options
} = {}) => {
if (!customSerializer && !isFunction(renderMarkdown)) {
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
}
const editor = new Editor({
extensions: [
new Bold(),
new Italic(),
new Code(),
new Link(),
new Image(),
new Heading({ levels: [1, 2, 3, 4, 5, 6] }),
new Blockquote(),
new HorizontalRule(),
new BulletList(),
new ListItem(),
new OrderedList(),
new CodeBlockHighlight(),
new HardBreak(),
],
editorProps: {
attributes: {
class: 'gl-outline-0!',
},
},
...options,
});
const serializer = customSerializer || createMarkdownSerializer({ render: renderMarkdown });
editor.setSerializedContent = async (serializedContent) => {
editor.setContent(
await serializer.deserialize({ schema: editor.schema, content: serializedContent }),
);
};
editor.getSerializedContent = () => {
return serializer.serialize({ schema: editor.schema, content: editor.getJSON() });
};
if (isString(content)) {
await editor.setSerializedContent(content);
}
return editor;
};
export default createEditor;
...@@ -54,14 +54,24 @@ const create = ({ render = () => null }) => { ...@@ -54,14 +54,24 @@ const create = ({ render = () => null }) => {
*/ */
serialize: ({ schema, content }) => { serialize: ({ schema, content }) => {
const document = schema.nodeFromJSON(content); const document = schema.nodeFromJSON(content);
const serializer = new ProseMirrorMarkdownSerializer(defaultMarkdownSerializer.nodes, { const { nodes, marks } = defaultMarkdownSerializer;
...defaultMarkdownSerializer.marks,
bold: { const serializer = new ProseMirrorMarkdownSerializer(
// creates a bold alias for the strong mark converter {
...defaultMarkdownSerializer.marks.strong, ...defaultMarkdownSerializer.nodes,
horizontalRule: nodes.horizontal_rule,
bulletList: nodes.bullet_list,
listItem: nodes.list_item,
orderedList: nodes.ordered_list,
codeBlock: nodes.code_block,
hardBreak: nodes.hard_break,
}, },
{
...defaultMarkdownSerializer.marks,
bold: marks.strong,
italic: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true }, italic: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true },
}); },
);
return serializer.serialize(document, { return serializer.serialize(document, {
tightLists: true, tightLists: true,
......
...@@ -39,7 +39,7 @@ export default { ...@@ -39,7 +39,7 @@ export default {
isContentEditorLoading: true, isContentEditorLoading: true,
useContentEditor: false, useContentEditor: false,
commitMessage: '', commitMessage: '',
editor: null, contentEditor: null,
isDirty: false, isDirty: false,
}; };
}, },
...@@ -102,7 +102,7 @@ export default { ...@@ -102,7 +102,7 @@ export default {
handleFormSubmit() { handleFormSubmit() {
if (this.useContentEditor) { if (this.useContentEditor) {
this.content = this.editor.getSerializedContent(); this.content = this.contentEditor.getSerializedContent();
} }
this.isDirty = false; this.isDirty = false;
...@@ -136,16 +136,18 @@ export default { ...@@ -136,16 +136,18 @@ export default {
this.isContentEditorLoading = true; this.isContentEditorLoading = true;
this.useContentEditor = true; this.useContentEditor = true;
const createEditor = await import( const { createContentEditor } = await import(
/* webpackChunkName: 'content_editor' */ '~/content_editor/services/create_editor' /* webpackChunkName: 'content_editor' */ '~/content_editor/services/create_content_editor'
); );
this.editor = this.contentEditor =
this.editor || this.contentEditor ||
(await createEditor.default({ createContentEditor({
renderMarkdown: (markdown) => this.getContentHTML(markdown), renderMarkdown: (markdown) => this.getContentHTML(markdown),
tiptapOptions: {
onUpdate: () => this.handleContentChange(), onUpdate: () => this.handleContentChange(),
})); },
await this.editor.setSerializedContent(this.content); });
await this.contentEditor.setSerializedContent(this.content);
this.isContentEditorLoading = false; this.isContentEditorLoading = false;
}, },
...@@ -296,7 +298,7 @@ export default { ...@@ -296,7 +298,7 @@ export default {
<div v-if="isContentEditorActive"> <div v-if="isContentEditorActive">
<gl-loading-icon v-if="isContentEditorLoading" class="bordered-box gl-w-full gl-py-6" /> <gl-loading-icon v-if="isContentEditorLoading" class="bordered-box gl-w-full gl-py-6" />
<content-editor v-else :editor="editor" /> <content-editor v-else :content-editor="contentEditor" />
<input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" /> <input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" />
</div> </div>
......
...@@ -307,11 +307,11 @@ module.exports = { ...@@ -307,11 +307,11 @@ module.exports = {
chunks: 'initial', chunks: 'initial',
minChunks: autoEntriesCount * 0.9, minChunks: autoEntriesCount * 0.9,
}), }),
tiptap: { prosemirror: {
priority: 17, priority: 17,
name: 'tiptap', name: 'prosemirror',
chunks: 'all', chunks: 'all',
test: /[\\/]node_modules[\\/](tiptap|prosemirror)-?\w*[\\/]/, test: /[\\/]node_modules[\\/]prosemirror.*?[\\/]/,
minChunks: 2, minChunks: 2,
reuseExistingChunk: true, reuseExistingChunk: true,
}, },
......
...@@ -57,6 +57,28 @@ ...@@ -57,6 +57,28 @@
"@rails/ujs": "^6.0.3-4", "@rails/ujs": "^6.0.3-4",
"@sentry/browser": "^5.22.3", "@sentry/browser": "^5.22.3",
"@sourcegraph/code-host-integration": "0.0.52", "@sourcegraph/code-host-integration": "0.0.52",
"@tiptap/core": "^2.0.0-beta.38",
"@tiptap/extension-blockquote": "^2.0.0-beta.6",
"@tiptap/extension-bold": "^2.0.0-beta.6",
"@tiptap/extension-bullet-list": "^2.0.0-beta.6",
"@tiptap/extension-code": "^2.0.0-beta.6",
"@tiptap/extension-code-block-lowlight": "^2.0.0-beta.9",
"@tiptap/extension-document": "^2.0.0-beta.5",
"@tiptap/extension-dropcursor": "^2.0.0-beta.6",
"@tiptap/extension-gapcursor": "^2.0.0-beta.10",
"@tiptap/extension-hard-break": "^2.0.0-beta.6",
"@tiptap/extension-heading": "^2.0.0-beta.6",
"@tiptap/extension-history": "^2.0.0-beta.5",
"@tiptap/extension-horizontal-rule": "^2.0.0-beta.7",
"@tiptap/extension-image": "^2.0.0-beta.4",
"@tiptap/extension-italic": "^2.0.0-beta.6",
"@tiptap/extension-link": "^2.0.0-beta.6",
"@tiptap/extension-list-item": "^2.0.0-beta.6",
"@tiptap/extension-ordered-list": "^2.0.0-beta.6",
"@tiptap/extension-paragraph": "^2.0.0-beta.7",
"@tiptap/extension-strike": "^2.0.0-beta.7",
"@tiptap/extension-text": "^2.0.0-beta.5",
"@tiptap/vue-2": "^2.0.0-beta.21",
"@toast-ui/editor": "^2.5.2", "@toast-ui/editor": "^2.5.2",
"@toast-ui/vue-editor": "^2.5.2", "@toast-ui/vue-editor": "^2.5.2",
"apollo-cache-inmemory": "^1.6.6", "apollo-cache-inmemory": "^1.6.6",
...@@ -139,7 +161,6 @@ ...@@ -139,7 +161,6 @@
"three-stl-loader": "^1.0.4", "three-stl-loader": "^1.0.4",
"timeago.js": "^4.0.2", "timeago.js": "^4.0.2",
"tiptap": "^1.32.1", "tiptap": "^1.32.1",
"tiptap-commands": "^1.17.1",
"tiptap-extensions": "^1.35.1", "tiptap-extensions": "^1.35.1",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"uuid": "8.1.0", "uuid": "8.1.0",
......
import { mount } from '@vue/test-utils'; import { EditorContent } from '@tiptap/vue-2';
import { EditorContent } from 'tiptap'; import { shallowMount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import ContentEditor from '~/content_editor/components/content_editor.vue'; import ContentEditor from '~/content_editor/components/content_editor.vue';
import TopToolbar from '~/content_editor/components/top_toolbar.vue'; import TopToolbar from '~/content_editor/components/top_toolbar.vue';
import createEditor from '~/content_editor/services/create_editor'; import { createContentEditor } from '~/content_editor/services/create_content_editor';
describe('ContentEditor', () => { describe('ContentEditor', () => {
let wrapper; let wrapper;
let editor; let editor;
const createWrapper = async (_editor) => { const createWrapper = async (contentEditor) => {
wrapper = mount(ContentEditor, { wrapper = shallowMount(ContentEditor, {
propsData: { propsData: {
editor: _editor, contentEditor,
}, },
}); });
}; };
beforeEach(async () => { beforeEach(() => {
editor = await createEditor({ renderMarkdown: () => 'sample text' }); editor = createContentEditor({ renderMarkdown: () => true });
createWrapper(editor);
await waitForPromises();
}); });
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
it('renders editor content component and attaches editor instance', async () => { it('renders editor content component and attaches editor instance', () => {
expect(wrapper.findComponent(EditorContent).props().editor).toBe(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('renders top toolbar component and attaches editor instance', async () => { it.each`
expect(wrapper.findComponent(TopToolbar).props().editor).toBe(editor); 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);
expect(wrapper.classes()).toContain('is-focused');
}); });
}); });
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import { Extension } from '@tiptap/core';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import ToolbarButton from '~/content_editor/components/toolbar_button.vue'; import ToolbarButton from '~/content_editor/components/toolbar_button.vue';
import { createContentEditor } from '~/content_editor/services/create_content_editor';
describe('content_editor/components/toolbar_button', () => { describe('content_editor/components/toolbar_button', () => {
let wrapper; let wrapper;
let editor; let tiptapEditor;
let toggleFooSpy;
const CONTENT_TYPE = 'bold'; const CONTENT_TYPE = 'bold';
const ICON_NAME = 'bold'; const ICON_NAME = 'bold';
const LABEL = 'Bold'; const LABEL = 'Bold';
const buildEditor = () => { const buildEditor = () => {
editor = { toggleFooSpy = jest.fn();
isActive: { tiptapEditor = createContentEditor({
[CONTENT_TYPE]: jest.fn(), extensions: [
}, Extension.create({
commands: { addCommands() {
[CONTENT_TYPE]: jest.fn(), return {
}, toggleFoo: () => toggleFooSpy,
}; };
},
}),
],
renderMarkdown: () => true,
}).tiptapEditor;
jest.spyOn(tiptapEditor, 'isActive');
}; };
const buildWrapper = (propsData = {}) => { const buildWrapper = (propsData = {}) => {
...@@ -26,7 +36,7 @@ describe('content_editor/components/toolbar_button', () => { ...@@ -26,7 +36,7 @@ describe('content_editor/components/toolbar_button', () => {
GlButton, GlButton,
}, },
propsData: { propsData: {
editor, tiptapEditor,
contentType: CONTENT_TYPE, contentType: CONTENT_TYPE,
iconName: ICON_NAME, iconName: ICON_NAME,
label: LABEL, label: LABEL,
...@@ -52,33 +62,34 @@ describe('content_editor/components/toolbar_button', () => { ...@@ -52,33 +62,34 @@ describe('content_editor/components/toolbar_button', () => {
it.each` it.each`
editorState | outcomeDescription | outcome editorState | outcomeDescription | outcome
${{ isActive: true, focused: true }} | ${'button is active'} | ${true} ${{ isActive: true, isFocused: true }} | ${'button is active'} | ${true}
${{ isActive: false, focused: true }} | ${'button is not active'} | ${false} ${{ isActive: false, isFocused: true }} | ${'button is not active'} | ${false}
${{ isActive: true, focused: false }} | ${'button is not active '} | ${false} ${{ isActive: true, isFocused: false }} | ${'button is not active '} | ${false}
`('$outcomeDescription when when editor state is $editorState', ({ editorState, outcome }) => { `('$outcomeDescription when when editor state is $editorState', ({ editorState, outcome }) => {
editor.isActive[CONTENT_TYPE].mockReturnValueOnce(editorState.isActive); tiptapEditor.isActive.mockReturnValueOnce(editorState.isActive);
editor.focused = editorState.focused; tiptapEditor.isFocused = editorState.isFocused;
buildWrapper(); buildWrapper();
expect(findButton().classes().includes('active')).toBe(outcome); expect(findButton().classes().includes('active')).toBe(outcome);
expect(tiptapEditor.isActive).toHaveBeenCalledWith(CONTENT_TYPE);
}); });
describe('when button is clicked', () => { describe('when button is clicked', () => {
it('executes the content type command when executeCommand = true', async () => { it('executes the content type command when executeCommand = true', async () => {
buildWrapper({ executeCommand: true }); buildWrapper({ editorCommand: 'toggleFoo' });
await findButton().trigger('click'); await findButton().trigger('click');
expect(editor.commands[CONTENT_TYPE]).toHaveBeenCalled(); expect(toggleFooSpy).toHaveBeenCalled();
expect(wrapper.emitted().click).toHaveLength(1); expect(wrapper.emitted().click).toHaveLength(1);
}); });
it('does not executes the content type command when executeCommand = false', async () => { it('does not executes the content type command when executeCommand = false', async () => {
buildWrapper({ executeCommand: false }); buildWrapper();
await findButton().trigger('click'); await findButton().trigger('click');
expect(editor.commands[CONTENT_TYPE]).not.toHaveBeenCalled(); expect(toggleFooSpy).not.toHaveBeenCalled();
expect(wrapper.emitted().click).toHaveLength(1); expect(wrapper.emitted().click).toHaveLength(1);
}); });
}); });
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import TopToolbar from '~/content_editor/components/top_toolbar.vue'; import TopToolbar from '~/content_editor/components/top_toolbar.vue';
import { createContentEditor } from '~/content_editor/services/create_content_editor';
describe('content_editor/components/top_toolbar', () => { describe('content_editor/components/top_toolbar', () => {
let wrapper; let wrapper;
let editor; let contentEditor;
const buildEditor = () => { const buildEditor = () => {
editor = {}; contentEditor = createContentEditor({ renderMarkdown: () => true });
}; };
const buildWrapper = () => { const buildWrapper = () => {
wrapper = extendedWrapper( wrapper = extendedWrapper(
shallowMount(TopToolbar, { shallowMount(TopToolbar, {
propsData: { propsData: {
editor, contentEditor,
}, },
}), }),
); );
...@@ -29,18 +30,18 @@ describe('content_editor/components/top_toolbar', () => { ...@@ -29,18 +30,18 @@ describe('content_editor/components/top_toolbar', () => {
}); });
it.each` it.each`
testId | button testId | buttonProps
${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold' }} ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }}
${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic' }} ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }}
${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code' }} ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }}
${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote' }} ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }}
${'bullet-list'} | ${{ contentType: 'bullet_list', iconName: 'list-bulleted', label: 'Add a bullet list' }} ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }}
${'ordered-list'} | ${{ contentType: 'ordered_list', iconName: 'list-numbered', label: 'Add a numbered list' }} ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }}
`('renders $testId button', ({ testId, buttonProps }) => { `('renders $testId button', ({ testId, buttonProps }) => {
buildWrapper(); buildWrapper();
expect(wrapper.findByTestId(testId).props()).toMatchObject({ expect(wrapper.findByTestId(testId).props()).toEqual({
...buttonProps, ...buttonProps,
editor, tiptapEditor: contentEditor.tiptapEditor,
}); });
}); });
}); });
import { createEditor } from '~/content_editor'; import { createContentEditor } from '~/content_editor';
import { loadMarkdownApiExamples, loadMarkdownApiResult } from './markdown_processing_examples'; import { loadMarkdownApiExamples, loadMarkdownApiResult } from './markdown_processing_examples';
describe('markdown processing', () => { describe('markdown processing', () => {
// Ensure we generate same markdown that was provided to Markdown API. // Ensure we generate same markdown that was provided to Markdown API.
it.each(loadMarkdownApiExamples())('correctly handles %s', async (testName, markdown) => { it.each(loadMarkdownApiExamples())('correctly handles %s', async (testName, markdown) => {
const { html } = loadMarkdownApiResult(testName); 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);
}); });
}); });
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants';
import { createContentEditor } from '~/content_editor/services/create_content_editor';
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('throws an error when a renderMarkdown fn is not provided', () => {
expect(() => createContentEditor()).toThrow(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
});
});
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 renderMarkdown = () => true;
const buildMockSerializer = () => ({
serialize: jest.fn(),
deserialize: jest.fn(),
});
it('sets gl-outline-0! class selector to editor attributes', async () => {
const editor = await createEditor({ renderMarkdown });
expect(editor.options.editorProps).toMatchObject({
attributes: {
class: 'gl-outline-0!',
},
});
});
describe('creating an editor', () => {
it('uses markdown serializer when a renderMarkdown function is provided', async () => {
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);
});
});
});
...@@ -337,7 +337,10 @@ describe('WikiForm', () => { ...@@ -337,7 +337,10 @@ describe('WikiForm', () => {
// wait for content editor to load // wait for content editor to load
await waitForPromises(); await waitForPromises();
wrapper.vm.editor.setContent('<p>hello __world__ from content editor</p>', true); wrapper.vm.contentEditor.tiptapEditor.commands.setContent(
'<p>hello __world__ from content editor</p>',
true,
);
await waitForPromises(); await waitForPromises();
...@@ -378,7 +381,10 @@ describe('WikiForm', () => { ...@@ -378,7 +381,10 @@ describe('WikiForm', () => {
// wait for content editor to load // wait for content editor to load
await waitForPromises(); await waitForPromises();
wrapper.vm.editor.setContent('<p>hello __world__ from content editor</p>', true); wrapper.vm.contentEditor.tiptapEditor.commands.setContent(
'<p>hello __world__ from content editor</p>',
true,
);
wrapper.findComponent(GlAlert).findComponent(GlButton).trigger('click'); wrapper.findComponent(GlAlert).findComponent(GlButton).trigger('click');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
......
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment