mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-28 18:19:38 +08:00
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:
+149
-5
@@ -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: {
|
||||
|
||||
+26
-15
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user