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 index 0c4bd2a0611..8973d6e451d 100644 --- 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 @@ -31,7 +31,13 @@ jest.mock('@tiptap/react', () => { blur: () => undefined, insertContent: () => undefined, }, - setEditable: () => undefined, + + // Mirrors the real library: an update is emitted unless the caller opts out. + setEditable: (_editable: boolean, emitUpdate = true) => { + if (emitUpdate) { + config?.onUpdate?.({editor: base}); + } + }, getJSON: () => ({type: 'doc', content: [{type: 'paragraph', content: [{type: 'text', text: 'hi'}]}]}), view: {dom: globalThis.document.createElement('div')}, @@ -77,10 +83,13 @@ jest.mock('@tiptap/react', () => { }; }); -jest.mock('./wysiwyg_suggestion_list', () => ({ - __esModule: true, - default: () => null, -})); +jest.mock('./wysiwyg_suggestion_list', () => { + const ReactMock = require('react') as typeof import('react'); + return { + __esModule: true, + default: () => ReactMock.createElement('div', {'data-testid': 'suggestion-list'}), + }; +}); import WysiwygEditor from './wysiwyg_editor'; @@ -479,6 +488,141 @@ describe('WysiwygEditor', () => { expect(ref.current!.hasContentError()).toBe(false); }); + describe('readOnly', () => { + const domAttributes = () => mockCapturedConfig.current?.editorProps?.attributes?.(); + + test('an editable editor is a textbox that is not disabled', () => { + renderWithContext(); + + expect(mockCapturedConfig.current?.editable).toBe(true); + expect(domAttributes()).toMatchObject({role: 'textbox', 'aria-disabled': 'false'}); + }); + + test('disabled is a textbox the user is locked out of', () => { + const {container} = renderWithContext( + , + ); + + expect(mockCapturedConfig.current?.editable).toBe(false); + expect(domAttributes()).toMatchObject({role: 'textbox', 'aria-disabled': 'true', 'data-disabled': 'true'}); + expect(container.querySelector('.WysiwygEditor--disabled')).not.toBeNull(); + }); + + test('readOnly is content: not editable, and not announced as a control', () => { + const {container} = renderWithContext( + , + ); + + expect(mockCapturedConfig.current?.editable).toBe(false); + + const attributes = domAttributes(); + expect(attributes).not.toHaveProperty('role'); + expect(attributes).not.toHaveProperty('aria-disabled'); + expect(attributes).not.toHaveProperty('data-disabled'); + expect(container.querySelector('.WysiwygEditor--disabled')).toBeNull(); + }); + + test('readOnly keeps an id addressable for callers that pass one', () => { + renderWithContext( + , + ); + + expect(domAttributes()).toMatchObject({id: 'page-body', 'data-testid': 'page-body'}); + }); + + test('readOnly wins over disabled, so a caller passing both gets content', () => { + const {container} = renderWithContext( + , + ); + + expect(domAttributes()).not.toHaveProperty('aria-disabled'); + expect(container.querySelector('.WysiwygEditor--disabled')).toBeNull(); + }); + + test('readOnly leaves out the suggestion list, which has nothing to complete', () => { + const {queryByTestId, rerender} = renderWithContext( + , + ); + + expect(queryByTestId('suggestion-list')).toBeNull(); + + rerender(); + expect(queryByTestId('suggestion-list')).not.toBeNull(); + }); + + test('readOnly holds against insertText, which a handle holder can still reach for', () => { + jest.useFakeTimers(); + const onChange = jest.fn(); + const ref = React.createRef>(); + + renderWithContext( + , + ); + + mockChainCalls.current = []; + ref.current!.insertText(':smile:'); + jest.runAllTimers(); + + expect(mockChainCalls.current).toHaveLength(0); + expect(onChange).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + test('switching in and out of readOnly is not an edit', () => { + jest.useFakeTimers(); + const onChange = jest.fn(); + + const {rerender} = renderWithContext( + , + ); + + rerender( + , + ); + rerender( + , + ); + jest.runAllTimers(); + + expect(onChange).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + }); + describe('Enter inside a heading', () => { const headingView = () => ({ state: { 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 4db94bfcf46..5bffec4c95f 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 @@ -110,6 +110,7 @@ type Props = { channelId: string; rootId?: string; disabled?: boolean; + readOnly?: boolean; id?: string; useCtrlSend?: boolean; sendCodeBlockOnCtrlEnter?: boolean; @@ -146,6 +147,7 @@ const WysiwygEditor = forwardRef(({ channelId, rootId, disabled = false, + readOnly = false, id, useCtrlSend = false, sendCodeBlockOnCtrlEnter = false, @@ -165,6 +167,8 @@ const WysiwygEditor = forwardRef(({ const onChangeRef = useLatest(onChange); const onFocusRef = useLatest(onFocus); const onBlurRef = useLatest(onBlur); + const disabledRef = useLatest(disabled); + const readOnlyRef = useLatest(readOnly); const useCtrlSendRef = useLatest(useCtrlSend); const sendCodeBlockOnCtrlEnterRef = useLatest(sendCodeBlockOnCtrlEnter); const placeholderRef = useLatest(placeholderText ?? ''); @@ -270,13 +274,18 @@ const WysiwygEditor = forwardRef(({ // Tiptap emits this from the Editor constructor, which useEditor runs // during render — hence the buffering in captureContentError. onContentError: ({error}) => captureContentError(error), - editable: !disabled, + editable: !disabled && !readOnly, editorProps: { - attributes: { + + // A function, not an object: the editor is built once, so a static map + // would freeze these at their value on mount. + attributes: () => ({ ...(id ? {id, 'data-testid': id} : {}), - role: 'textbox', - ...(disabled ? {'aria-disabled': 'true', 'data-disabled': 'true'} : {'aria-disabled': 'false'}), - }, + ...(readOnlyRef.current ? {} : { + role: 'textbox', + ...(disabledRef.current ? {'aria-disabled': 'true', 'data-disabled': 'true'} : {'aria-disabled': 'false'}), + }), + }), handlePaste: (_view, event) => { if (jsonMode) { return false; @@ -479,7 +488,7 @@ const WysiwygEditor = forwardRef(({ getEditor: () => editorRef.current, insertText: (text: string) => { const ed = editorRef.current; - if (ed && !ed.isDestroyed) { + if (ed && !ed.isDestroyed && !readOnlyRef.current) { const {state} = ed; const {from} = state.selection; const charBefore = from > 0 ? state.doc.textBetween(from - 1, from) : ''; @@ -525,19 +534,21 @@ const WysiwygEditor = forwardRef(({ useEffect(() => { if (editor && !editor.isDestroyed) { - editor.setEditable(!disabled); + editor.setEditable(!disabled && !readOnly, false); } - }, [disabled, editor]); + }, [disabled, readOnly, editor]); return ( -
+
- + {!readOnly && ( + + )}
); }); diff --git a/webapp/platform/shared/src/types/global/editor.ts b/webapp/platform/shared/src/types/global/editor.ts index 34ea111dbef..fae11ef2a97 100644 --- a/webapp/platform/shared/src/types/global/editor.ts +++ b/webapp/platform/shared/src/types/global/editor.ts @@ -29,6 +29,7 @@ export type WysiwygEditorProps = { channelId: string; rootId?: string; disabled?: boolean; + readOnly?: boolean; id?: string; useCtrlSend?: boolean; sendCodeBlockOnCtrlEnter?: boolean;