Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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')},

Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ type Props = {
channelId: string;
rootId?: string;
disabled?: boolean;
readOnly?: boolean;
id?: string;
useCtrlSend?: boolean;
sendCodeBlockOnCtrlEnter?: boolean;
Expand Down Expand Up @@ -146,6 +147,7 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, Props>(({
channelId,
rootId,
disabled = false,
readOnly = false,
id,
useCtrlSend = false,
sendCodeBlockOnCtrlEnter = false,
Expand All @@ -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 ?? '');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) : '';
Expand Down Expand Up @@ -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>
);
});
Expand Down
1 change: 1 addition & 0 deletions webapp/platform/shared/src/types/global/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type WysiwygEditorProps = {
channelId: string;
rootId?: string;
disabled?: boolean;
readOnly?: boolean;
id?: string;
useCtrlSend?: boolean;
sendCodeBlockOnCtrlEnter?: boolean;
Expand Down
Loading