MM-70366: Add a readOnly mode to WysiwygEditor (#38141)

* MM-70366: Add a readOnly mode to WysiwygEditor

* PR feedback

* lint and coderabbiut

---------

Co-authored-by: Nevyana Angelova <nevyangelova@Nevy-Macbook-16-2025.local>
This commit is contained in:
nang2049
2026-08-28 13:13:07 +07:00
committed by GitHub
parent 441e45a914
commit ab32bb0bc6
3 changed files with 176 additions and 20 deletions
@@ -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(<WysiwygEditor {...baseProps}/>);
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(
<WysiwygEditor
{...baseProps}
disabled={true}
/>,
);
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(
<WysiwygEditor
{...baseProps}
readOnly={true}
/>,
);
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(
<WysiwygEditor
{...baseProps}
readOnly={true}
id='page-body'
/>,
);
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(
<WysiwygEditor
{...baseProps}
disabled={true}
readOnly={true}
/>,
);
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(
<WysiwygEditor
{...baseProps}
readOnly={true}
/>,
);
expect(queryByTestId('suggestion-list')).toBeNull();
rerender(<WysiwygEditor {...baseProps}/>);
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<React.ComponentRef<typeof WysiwygEditor>>();
renderWithContext(
<WysiwygEditor
{...baseProps}
ref={ref}
onChange={onChange}
readOnly={true}
/>,
);
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(
<WysiwygEditor
{...baseProps}
onChange={onChange}
/>,
);
rerender(
<WysiwygEditor
{...baseProps}
onChange={onChange}
readOnly={true}
/>,
);
rerender(
<WysiwygEditor
{...baseProps}
onChange={onChange}
/>,
);
jest.runAllTimers();
expect(onChange).not.toHaveBeenCalled();
jest.useRealTimers();
});
});
describe('Enter inside a heading', () => {
const headingView = () => ({
state: {
@@ -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<WysiwygEditorHandle, Props>(({
channelId,
rootId,
disabled = false,
readOnly = false,
id,
useCtrlSend = false,
sendCodeBlockOnCtrlEnter = false,
@@ -165,6 +167,8 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, Props>(({
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<WysiwygEditorHandle, Props>(({
// 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<WysiwygEditorHandle, Props>(({
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<WysiwygEditorHandle, Props>(({
useEffect(() => {
if (editor && !editor.isDestroyed) {
editor.setEditable(!disabled);
editor.setEditable(!disabled && !readOnly, false);
}
}, [disabled, editor]);
}, [disabled, readOnly, editor]);
return (
<div className={`WysiwygEditor${disabled ? ' WysiwygEditor--disabled' : ''}`}>
<div className={`WysiwygEditor${disabled && !readOnly ? ' WysiwygEditor--disabled' : ''}`}>
<EditorContent editor={editor}/>
<WysiwygSuggestionList
editor={editor}
channelId={channelId}
rootId={rootId}
onSubmit={handleSuggestionSubmit}
/>
{!readOnly && (
<WysiwygSuggestionList
editor={editor}
channelId={channelId}
rootId={rootId}
onSubmit={handleSuggestionSubmit}
/>
)}
</div>
);
});
@@ -29,6 +29,7 @@ export type WysiwygEditorProps = {
channelId: string;
rootId?: string;
disabled?: boolean;
readOnly?: boolean;
id?: string;
useCtrlSend?: boolean;
sendCodeBlockOnCtrlEnter?: boolean;