diff --git a/webapp/channels/src/components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor.test.tsx b/webapp/channels/src/components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor.test.tsx new file mode 100644 index 00000000000..55af63b4ad7 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor.test.tsx @@ -0,0 +1,454 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Node} from '@tiptap/core'; +import React from 'react'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +const mockCapturedConfig: {current: any} = {current: null}; + +// Set to make the useEditor mock emit a contentError during construction, +// matching Tiptap's render-phase emit. +const mockConstructorError: {current: Error | null} = {current: null}; + +jest.mock('@tiptap/react', () => { + const ReactMock = require('react') as typeof import('react'); + return { + __esModule: true, + useEditor: (config: any) => { + mockCapturedConfig.current = config; + const base: any = { + isDestroyed: false, + isEmpty: true, + commands: { + clearContent: () => undefined, + focus: () => undefined, + blur: () => undefined, + insertContent: () => undefined, + }, + setEditable: () => undefined, + getJSON: () => ({type: 'doc', content: [{type: 'paragraph', content: [{type: 'text', text: 'hi'}]}]}), + view: {dom: globalThis.document.createElement('div')}, + }; + + // Mirrors the real library: getMarkdown is attached by the Markdown + // extension's onBeforeCreate, not by the contentType option. + const hasMarkdownExt = (config?.extensions ?? []).some((e: any) => (e.name || e.config?.name) === 'markdown'); + if (hasMarkdownExt) { + base.getMarkdown = () => 'hi'; + } + + // Tiptap emits contentError synchronously inside the Editor + // constructor, i.e. during render, and constructs only once per + // mount. Consume the error so re-renders don't re-emit. + if (mockConstructorError.current) { + const error = mockConstructorError.current; + mockConstructorError.current = null; + config?.onContentError?.({error, editor: base, disableCollaboration: () => undefined}); + } + return base; + }, + EditorContent: () => ReactMock.createElement('div', {'data-testid': 'editor-content'}), + }; +}); + +jest.mock('./wysiwyg_suggestion_list', () => ({ + __esModule: true, + default: () => null, +})); + +import WysiwygEditor from './wysiwyg_editor'; + +const baseProps = { + value: '', + onChange: jest.fn(), + onSubmit: jest.fn(), + channelId: 'c1', +}; + +const extensionNames = (): string[] => (mockCapturedConfig.current?.extensions ?? []).map((e: any) => e.name || e.config?.name); + +describe('WysiwygEditor', () => { + beforeEach(() => { + mockCapturedConfig.current = null; + mockConstructorError.current = null; + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('markdown mode (default) registers the Markdown extension', () => { + renderWithContext(); + + expect(extensionNames()).toContain('markdown'); + expect(mockCapturedConfig.current?.contentType).toBe('markdown'); + }); + + test('json mode omits the Markdown extension and drops the markdown contentType', () => { + renderWithContext( + , + ); + + expect(extensionNames()).not.toContain('markdown'); + expect(mockCapturedConfig.current?.contentType).toBeUndefined(); + }); + + test('extensions prop is appended to the built-in set at mount', () => { + const CustomNode = Node.create({name: 'customNode', group: 'block'}); + + renderWithContext( + , + ); + + const names = extensionNames(); + expect(names).toContain('table'); // built-ins still present + + // Position matters: consumer extensions must come last so they can + // override built-in nodes of the same name. + expect(names.indexOf('customNode')).toBe(names.length - 1); + expect(names.indexOf('customNode')).toBeGreaterThan(names.indexOf('markdown')); + }); + + test('extensions prop is appended in json mode too, where Markdown is absent', () => { + const CustomNode = Node.create({name: 'customNode', group: 'block'}); + + renderWithContext( + , + ); + + const names = extensionNames(); + expect(names).not.toContain('markdown'); + expect(names.indexOf('customNode')).toBe(names.length - 1); + }); + + test('onChange emits JSON in json mode', () => { + jest.useFakeTimers(); + const onChange = jest.fn(); + + renderWithContext( + , + ); + + mockCapturedConfig.current?.onUpdate?.({editor: {getJSON: () => ({type: 'doc', content: []}), getMarkdown: () => ''} as any}); + jest.runAllTimers(); + + expect(onChange).toHaveBeenCalledWith(JSON.stringify({type: 'doc', content: []})); + + jest.useRealTimers(); + }); + + test('onChange emits markdown in markdown mode', () => { + jest.useFakeTimers(); + const onChange = jest.fn(); + + renderWithContext( + , + ); + + mockCapturedConfig.current?.onUpdate?.({editor: {getJSON: () => ({}), getMarkdown: () => 'hello'} as any}); + jest.runAllTimers(); + + expect(onChange).toHaveBeenCalledWith('hello'); + + jest.useRealTimers(); + }); + + test('json mode parses a JSON string value into an object for initial content', () => { + const doc = {type: 'doc', content: [{type: 'paragraph'}]}; + + renderWithContext( + , + ); + + expect(mockCapturedConfig.current?.content).toEqual(doc); + }); + + test('json mode falls back to an empty doc when value is not valid JSON', () => { + renderWithContext( + , + ); + + expect(mockCapturedConfig.current?.content).toEqual({type: 'doc', content: [{type: 'paragraph'}]}); + }); + + test('json mode falls back to an empty doc when value parses to a non-object', () => { + renderWithContext( + , + ); + + expect(mockCapturedConfig.current?.content).toEqual({type: 'doc', content: [{type: 'paragraph'}]}); + }); + + test('markdown mode leaves enableContentCheck off', () => { + renderWithContext(); + + expect(mockCapturedConfig.current?.enableContentCheck).toBe(false); + }); + + test('json mode enables enableContentCheck and forwards the callback', () => { + const onContentError = jest.fn(); + + renderWithContext( + , + ); + + expect(mockCapturedConfig.current?.enableContentCheck).toBe(true); + + const err = new Error('bad node'); + mockCapturedConfig.current?.onContentError?.({error: err}); + expect(onContentError).toHaveBeenCalledWith(err); + }); + + test('a post-mount content error forwards but does not latch hasContentError', () => { + const onContentError = jest.fn(); + const ref = React.createRef>(); + + renderWithContext( + , + ); + + expect(ref.current!.hasContentError()).toBe(false); + + const err = new Error('bad insert'); + mockCapturedConfig.current?.onContentError?.({error: err}); + + expect(onContentError).toHaveBeenCalledWith(err); + + // Latching here would permanently stall a consumer's autosave loop that + // started from a clean load. + expect(ref.current!.hasContentError()).toBe(false); + }); + + test('a content error emitted during construction is deferred so a consumer can setState', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + const seen: Array = []; + const constructorError = new Error('schema mismatch'); + + mockConstructorError.current = constructorError; + + // Models the real consumer: a parent that records the error in state. + // If the editor emits during its own render, this setState happens while + // rendering a different component and React logs an error. + const Parent = () => { + const [err, setErr] = React.useState(null); + seen.push(err); + return ( + + ); + }; + + renderWithContext(); + + expect(seen[seen.length - 1]).toBe(constructorError); + expect(consoleError).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + test('hasContentError latches when the initial load fails during construction', () => { + const ref = React.createRef>(); + + mockConstructorError.current = new Error('schema mismatch'); + + renderWithContext( + , + ); + + expect(ref.current!.hasContentError()).toBe(true); + }); + + test('contentType is frozen at mount and ignores a later prop change', () => { + const onChange = jest.fn(); + const {rerender} = renderWithContext( + , + ); + + rerender( + , + ); + + // Still json mode: paste stays short-circuited and updates stay JSON. + expect(mockCapturedConfig.current?.editorProps?.handlePaste?.({}, {})).toBe(false); + + jest.useFakeTimers(); + mockCapturedConfig.current?.onUpdate?.({editor: {getJSON: () => ({type: 'doc'})}}); + jest.runAllTimers(); + + expect(onChange).toHaveBeenCalledWith(JSON.stringify({type: 'doc'})); + }); + + test('json mode reports a parse error via onContentError when value is unparseable', async () => { + const onContentError = jest.fn(); + + renderWithContext( + , + ); + + expect(onContentError).toHaveBeenCalledTimes(1); + expect(onContentError.mock.calls[0][0]).toBeInstanceOf(Error); + }); + + test('handlePaste short-circuits in json mode; markdown mode still handles pastes', () => { + const mkEvent = () => ({ + preventDefault: jest.fn(), + clipboardData: { + getData: (type: string) => (type === 'text/plain' ? '# heading' : ''), + }, + }) as any; + + renderWithContext( + , + ); + expect(mockCapturedConfig.current?.editorProps?.handlePaste?.({} as any, mkEvent())).toBe(false); + + mockCapturedConfig.current = null; + renderWithContext(); + + const result = mockCapturedConfig.current?.editorProps?.handlePaste?.({} as any, mkEvent()); + expect(result).toBe(true); + }); + + test('getEditor() on the handle returns the underlying Tiptap Editor instance', () => { + const ref = React.createRef>(); + + renderWithContext( + , + ); + + const editor = ref.current!.getEditor(); + expect(editor).not.toBeNull(); + expect(typeof (editor as any).getJSON).toBe('function'); + }); + + test.each([ + ['null', 'null'], + ['array', '[1,2,3]'], + ['number', '42'], + ])('json mode falls back to empty doc when value parses to %s', (_label, raw) => { + const onContentError = jest.fn(); + + renderWithContext( + , + ); + + expect(mockCapturedConfig.current?.content).toEqual({type: 'doc', content: [{type: 'paragraph'}]}); + expect(onContentError).toHaveBeenCalledTimes(1); + }); + + test('json mode does not throw when consumer omits onContentError for a bad value', () => { + expect(() => { + renderWithContext( + , + ); + }).not.toThrow(); + }); + + test('handle.hasContentError() reflects load failure', async () => { + const ref = React.createRef>(); + + renderWithContext( + , + ); + + expect(ref.current!.hasContentError()).toBe(true); + }); + + test('handle.hasContentError() is false after a clean json load', () => { + const ref = React.createRef>(); + + renderWithContext( + , + ); + + expect(ref.current!.hasContentError()).toBe(false); + }); +}); diff --git a/webapp/channels/src/components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor.tsx b/webapp/channels/src/components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor.tsx index 1cd6947382a..def0a1c883d 100644 --- a/webapp/channels/src/components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor.tsx +++ b/webapp/channels/src/components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {Extensions} from '@tiptap/core'; import {Extension} from '@tiptap/core'; import {CodeBlockLowlight} from '@tiptap/extension-code-block-lowlight'; import Link from '@tiptap/extension-link'; @@ -18,8 +19,9 @@ import {EditorContent, useEditor} from '@tiptap/react'; import type {Editor} from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import emojiRegex from 'emoji-regex'; +import isPlainObject from 'lodash/isPlainObject'; import {common, createLowlight} from 'lowlight'; -import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef} from 'react'; +import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import {useDispatch} from 'react-redux'; import {editLatestPost} from 'actions/views/create_comment'; @@ -91,11 +93,15 @@ export type WysiwygEditorHandle = { focus: () => void; blur: () => void; getInputBox: () => HTMLElement | null; + + // True when the initial `value` failed to load in json mode. See + // PublishedWysiwygEditorHandle for the autosave-gating contract. + hasContentError: () => boolean; }; type Props = { value: string; - onChange: (markdown: string) => void; + onChange: (content: string) => void; onSubmit: () => void; onFocus?: () => void; onBlur?: () => void; @@ -107,6 +113,26 @@ type Props = { useCtrlSend?: boolean; sendCodeBlockOnCtrlEnter?: boolean; onKeyDown?: (e: React.KeyboardEvent) => void; + contentType?: 'markdown' | 'json'; + extensions?: Extensions; + onContentError?: (error: Error) => void; +}; + +const EMPTY_JSON_DOC = {type: 'doc', content: [{type: 'paragraph'}]} as const; + +const parseJsonModeContent = (value: string): {content: string | Record; error: Error | null} => { + if (!value) { + return {content: EMPTY_JSON_DOC, error: null}; + } + try { + const parsed = JSON.parse(value); + if (isPlainObject(parsed)) { + return {content: parsed as Record, error: null}; + } + return {content: EMPTY_JSON_DOC, error: new Error('Invalid JSON content: expected an object doc')}; + } catch (err) { + return {content: EMPTY_JSON_DOC, error: err instanceof Error ? err : new Error('Invalid JSON content')}; + } }; const WysiwygEditor = forwardRef(({ @@ -123,7 +149,13 @@ const WysiwygEditor = forwardRef(({ useCtrlSend = false, sendCodeBlockOnCtrlEnter = false, onKeyDown, + contentType = 'markdown', + extensions: extraExtensions, + onContentError, }, ref) => { + // Frozen: the Tiptap schema is fixed at construction, so a mid-flight prop + // swap would desync the paste and update handlers from the actual editor. + const jsonMode = useRef(contentType === 'json').current; const dispatch = useDispatch(); const channelIdRef = useLatest(channelId); const rootIdRef = useLatest(rootId); @@ -144,6 +176,11 @@ const WysiwygEditor = forwardRef(({ }, SERIALIZE_DEBOUNCE_MS); const handleUpdate = useCallback(({editor}: {editor: Editor}) => { + if (jsonMode) { + debouncedOnChange(JSON.stringify(editor.getJSON())); + return; + } + // Strip   artifacts the @tiptap/markdown serializer leaves around // empty paragraphs at doc start/end. const md = editor.getMarkdown().trimEnd(). @@ -151,38 +188,89 @@ const WysiwygEditor = forwardRef(({ replace(/\n\n $/g, ''). replace(/^ $/, ''); debouncedOnChange(md); - }, [debouncedOnChange]); + }, [debouncedOnChange, jsonMode]); + + const baseExtensions: Extensions = [ + StarterKit.configure({ + heading: {levels: [1, 2, 3, 4, 5, 6]}, + codeBlock: false, + link: false, + }), + CodeBlockLowlight.configure({ + lowlight, + }), + Link.configure({ + openOnClick: false, + autolink: true, + linkOnPaste: true, + }), + Placeholder.configure({ + placeholder: () => placeholderRef.current, + showOnlyCurrent: true, + }), + Table.configure({resizable: false, cellMinWidth: 80}), + TableRow, + TableCell, + TableHeader, + EmojiDecorations, + ]; + if (!jsonMode) { + baseExtensions.push(Markdown.configure({markedOptions: {gfm: true}})); + } + if (extraExtensions?.length) { + baseExtensions.push(...extraExtensions); + } + + const onContentErrorRef = useLatest(onContentError); + const mountedRef = useRef(false); + const pendingErrorRef = useRef(null); + + // A ref, not state: the handle must report this synchronously, and a + // consumer reading it from its own onContentError handler runs before any + // re-render would land. + const hasContentErrorRef = useRef(false); + + const [initialContent] = useState>(() => { + if (!jsonMode) { + return value; + } + const {content, error} = parseJsonModeContent(value); + if (error) { + hasContentErrorRef.current = true; + pendingErrorRef.current = error; + } + return content; + }); + + const captureContentError = (error: Error) => { + // Post-mount errors come from consumer-driven commands, not the initial + // load, so they forward without latching hasContentError. + if (mountedRef.current) { + onContentErrorRef.current?.(error); + return; + } + + hasContentErrorRef.current = true; + pendingErrorRef.current = error; + }; + + useEffect(() => { + mountedRef.current = true; + if (pendingErrorRef.current) { + onContentErrorRef.current?.(pendingErrorRef.current); + pendingErrorRef.current = null; + } + }, []); const editor = useEditor({ - extensions: [ - StarterKit.configure({ - heading: {levels: [1, 2, 3, 4, 5, 6]}, - codeBlock: false, - link: false, - }), - CodeBlockLowlight.configure({ - lowlight, - }), - Link.configure({ - openOnClick: false, - autolink: true, - linkOnPaste: true, - }), - Placeholder.configure({ - placeholder: () => placeholderRef.current, - showOnlyCurrent: true, - }), - Table.configure({resizable: false, cellMinWidth: 80}), - TableRow, - TableCell, - TableHeader, - Markdown.configure({ - markedOptions: {gfm: true}, - }), - EmojiDecorations, - ], - content: value, - contentType: 'markdown', + extensions: baseExtensions, + content: initialContent, + contentType: jsonMode ? undefined : 'markdown', + enableContentCheck: jsonMode, + + // Tiptap emits this from the Editor constructor, which useEditor runs + // during render — hence the buffering in captureContentError. + onContentError: ({error}) => captureContentError(error), editable: !disabled, editorProps: { attributes: { @@ -191,6 +279,10 @@ const WysiwygEditor = forwardRef(({ ...(disabled ? {'aria-disabled': 'true', 'data-disabled': 'true'} : {'aria-disabled': 'false'}), }, handlePaste: (_view, event) => { + if (jsonMode) { + return false; + } + const text = event.clipboardData?.getData('text/plain'); if (!text) { return false; @@ -411,6 +503,7 @@ const WysiwygEditor = forwardRef(({ } return null; }, + hasContentError: () => hasContentErrorRef.current, }), []); const lastValueRef = useRef(value); diff --git a/webapp/channels/src/plugins/published_editor.test.tsx b/webapp/channels/src/plugins/published_editor.test.tsx index d22d99ad4d0..f809e9608c3 100644 --- a/webapp/channels/src/plugins/published_editor.test.tsx +++ b/webapp/channels/src/plugins/published_editor.test.tsx @@ -22,6 +22,7 @@ jest.mock('components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor', () => blur: () => {}, getInputBox: () => null, getEditor: () => null, + hasContentError: () => false, })); return null; }), @@ -89,6 +90,8 @@ describe('WysiwygEditor handle forwarding', () => { expect(typeof handle!.focus).toBe('function'); expect(typeof handle!.blur).toBe('function'); expect(typeof handle!.getInputBox).toBe('function'); + expect(typeof handle!.getEditor).toBe('function'); + expect(typeof handle!.hasContentError).toBe('function'); }); }); diff --git a/webapp/platform/shared/src/types/global/editor.ts b/webapp/platform/shared/src/types/global/editor.ts index 64e78d37a63..34ea111dbef 100644 --- a/webapp/platform/shared/src/types/global/editor.ts +++ b/webapp/platform/shared/src/types/global/editor.ts @@ -21,7 +21,7 @@ export type ActionResult = { export type WysiwygEditorProps = { value: string; - onChange: (markdown: string) => void; + onChange: (content: string) => void; onSubmit: () => void; onFocus?: () => void; onBlur?: () => void; @@ -33,6 +33,16 @@ export type WysiwygEditorProps = { useCtrlSend?: boolean; sendCodeBlockOnCtrlEnter?: boolean; onKeyDown?: (e: KeyboardEvent) => void; + + // 'json' reads and emits stringified ProseMirror JSON. Mount-only. + contentType?: 'markdown' | 'json'; + + // Mount-only. `any[]` so consumers don't need `@tiptap/core` transitively. + extensions?: any[]; + + // Any content error in 'json' mode, for the editor's lifetime. See + // hasContentError() for the autosave-gating contract. + onContentError?: (error: Error) => void; }; export type SuggestionListProps = { @@ -121,6 +131,15 @@ export type PublishedWysiwygEditorHandle = { focus: () => void; blur: () => void; getInputBox: () => HTMLElement | null; + + // Null until the mount effect runs, so a useLayoutEffect can still miss it. + // In 'json' mode use getJSON(); getMarkdown() isn't attached. + getEditor: () => any; + + // True when the initial `value` failed to load in 'json' mode. Autosaving + // consumers must gate the first onChange on this, or the empty fallback + // overwrites the source. Load-only, so it can't stall a healthy session. + hasContentError: () => boolean; }; export type PublishedFormattingBarHandle = {