feat: improve raw HTML editing Experince. (#101)

* feat: add contents

* feat: add codemirror support to raw HTML editing

* feat: add test coverage

* fix: error

* fix: update codeeditor file path

* fix: update messages
This commit is contained in:
connorhaugh
2022-08-22 15:19:21 -04:00
committed by GitHub
parent 617f316f37
commit 564dcb8ebc
22 changed files with 4206 additions and 3081 deletions

6644
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -56,6 +56,10 @@
"redux-saga": "1.1.3"
},
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"codemirror": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@reduxjs/toolkit": "^1.8.1",
"@tinymce/tinymce-react": "^3.14.0",
"babel-polyfill": "6.26.0",

View File

@@ -33,6 +33,17 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
selection="hooks.selectedImage.selection"
setSelection={[MockFunction hooks.selectedImage.setSelection]}
/>
<SourceCodeModal
close={[MockFunction modal.closeModal]}
editorRef={
Object {
"current": Object {
"value": "something",
},
}
}
isOpen={false}
/>
<Toast
onClose={[MockFunction hooks.nullMethod]}
show={true}
@@ -54,7 +65,8 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
"clearSelection": [MockFunction hooks.selectedImage.clearSelection],
"initializeEditor": [MockFunction args.intializeEditor],
"lmsEndpointUrl": "sOmEvaLue.cOm",
"openModal": [MockFunction modal.openModal],
"openImgModal": [MockFunction modal.openModal],
"openSourceCodeModal": [MockFunction modal.openModal],
"setEditorRef": [MockFunction hooks.prepareEditorRef.setEditorRef],
"setSelection": [MockFunction hooks.selectedImage.setSelection],
"studioEndpointUrl": "sOmEoThERvaLue.cOm",
@@ -98,6 +110,17 @@ exports[`TextEditor snapshots loaded, raw editor 1`] = `
selection="hooks.selectedImage.selection"
setSelection={[MockFunction hooks.selectedImage.setSelection]}
/>
<SourceCodeModal
close={[MockFunction modal.closeModal]}
editorRef={
Object {
"current": Object {
"value": "something",
},
}
}
isOpen={false}
/>
<Toast
onClose={[MockFunction hooks.nullMethod]}
show={false}
@@ -154,6 +177,17 @@ exports[`TextEditor snapshots not yet loaded, Spinner appears 1`] = `
selection="hooks.selectedImage.selection"
setSelection={[MockFunction hooks.selectedImage.setSelection]}
/>
<SourceCodeModal
close={[MockFunction modal.closeModal]}
editorRef={
Object {
"current": Object {
"value": "something",
},
}
}
isOpen={false}
/>
<Toast
onClose={[MockFunction hooks.nullMethod]}
show={false}
@@ -210,6 +244,17 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
selection="hooks.selectedImage.selection"
setSelection={[MockFunction hooks.selectedImage.setSelection]}
/>
<SourceCodeModal
close={[MockFunction modal.closeModal]}
editorRef={
Object {
"current": Object {
"value": "something",
},
}
}
isOpen={false}
/>
<Toast
onClose={[MockFunction hooks.nullMethod]}
show={false}
@@ -231,7 +276,8 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
"clearSelection": [MockFunction hooks.selectedImage.clearSelection],
"initializeEditor": [MockFunction args.intializeEditor],
"lmsEndpointUrl": "sOmEvaLue.cOm",
"openModal": [MockFunction modal.openModal],
"openImgModal": [MockFunction modal.openModal],
"openSourceCodeModal": [MockFunction modal.openModal],
"setEditorRef": [MockFunction hooks.prepareEditorRef.setEditorRef],
"setSelection": [MockFunction hooks.selectedImage.setSelection],
"studioEndpointUrl": "sOmEoThERvaLue.cOm",

View File

@@ -16,11 +16,12 @@ export const BaseModal = ({
children,
confirmAction,
footerAction,
size,
}) => (
<ModalDialog
isOpen={isOpen}
onClose={close}
size="lg"
size={size}
variant="default"
hasCloseButton
isFullscreenOnMobile
@@ -49,6 +50,7 @@ export const BaseModal = ({
BaseModal.defaultProps = {
footerAction: null,
size: 'lg',
};
BaseModal.propTypes = {
@@ -58,6 +60,7 @@ BaseModal.propTypes = {
children: PropTypes.node.isRequired,
confirmAction: PropTypes.node.isRequired,
footerAction: PropTypes.node,
size: PropTypes.string,
};
export default BaseModal;

View File

@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CodeEditor Component Snapshots Renders and calls Hooks 1`] = `
<div>
<div
id="editor"
/>
</div>
`;

View File

@@ -0,0 +1,51 @@
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { basicSetup } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { html } from '@codemirror/lang-html';
import * as module from './index';
import './index.scss';
export const hooks = {
createCodeMirrorDomNode: ({ ref, initialText, upstreamRef }) => {
useEffect(() => {
const state = EditorState.create({
doc: initialText,
extensions: [basicSetup, html()],
});
const view = new EditorView({ state, parent: ref.current });
// eslint-disable-next-line no-param-reassign
upstreamRef.current = view;
return () => {
// called on cleanup
view.destroy();
};
}, []);
},
};
export const CodeEditor = ({
innerRef, value,
}) => {
const DOMref = useRef();
module.hooks.createCodeMirrorDomNode({ ref: DOMref, initialText: value, upstreamRef: innerRef });
return (
<div>
<div id="editor" ref={DOMref} />
</div>
);
};
CodeEditor.propTypes = {
innerRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.any }),
]).isRequired,
value: PropTypes.string.isRequired,
};
export default CodeEditor;

View File

@@ -0,0 +1,2 @@
.cm-editor { height: 100% }
.cm-scroller { overflow: auto }

View File

@@ -0,0 +1,83 @@
import React from 'react';
import { shallow } from 'enzyme';
import { EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { html } from '@codemirror/lang-html';
import * as module from './index';
jest.mock('@codemirror/view');
jest.mock('react', () => ({
...jest.requireActual('react'),
useRef: jest.fn(val => ({ current: val })),
useEffect: jest.fn(),
useCallback: (cb, prereqs) => ({ cb, prereqs }),
}));
jest.mock('@codemirror/state', () => ({
...jest.requireActual('@codemirror/state'),
EditorState: {
create: jest.fn(),
},
}));
jest.mock('@codemirror/lang-html', () => ({
html: jest.fn(),
}));
jest.mock('codemirror', () => ({
basicSetup: 'bAsiCSetUp',
}));
describe('CodeEditor', () => {
describe('Hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('createCodeMirrorDomNode', () => {
const props = {
ref: {
current: 'sOmEvAlUe',
},
initialText: 'sOmEhTmL',
upstreamRef: {
current: 'sOmEotHERvAlUe',
},
};
beforeEach(() => {
module.hooks.createCodeMirrorDomNode(props);
});
it('calls useEffect and sets up codemirror objects', () => {
const [cb, prereqs] = React.useEffect.mock.calls[0];
expect(prereqs).toStrictEqual([]);
cb();
expect(EditorState.create).toHaveBeenCalled();
expect(EditorView).toHaveBeenCalled();
expect(html).toHaveBeenCalled();
});
});
});
describe('Component', () => {
describe('Snapshots', () => {
let props;
beforeAll(() => {
props = {
innerRef: {
current: 'sOmEvALUE',
},
value: 'mOcKhTmL',
};
jest.spyOn(module.hooks, 'createCodeMirrorDomNode').mockImplementation(() => ({}));
});
afterAll(() => {
jest.clearAllMocks();
});
test('Renders and calls Hooks ', () => {
// Note: ref won't show up as it is not acutaly a DOM attribute.
expect(shallow(<module.CodeEditor {...props} />)).toMatchSnapshot();
expect(module.hooks.createCodeMirrorDomNode).toHaveBeenCalled();
});
});
});
});

View File

@@ -36,6 +36,7 @@ exports[`ImageSettingsModal render snapshot 1`] = `
}
footerAction={null}
isOpen={false}
size="lg"
title="Image Settings"
>
<ErrorAlert

View File

@@ -1,10 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ImageSettingsModal renders as expected with default behavior 1`] = `
exports[`RawEditor renders as expected with default behavior 1`] = `
<div
className="form-group"
style={
Object {
"height": "600px",
"padding": "10px 30px",
}
}
@@ -14,11 +14,15 @@ exports[`ImageSettingsModal renders as expected with default behavior 1`] = `
>
You are using the raw HTML editor.
</Alert>
<textarea
className="form-control"
rows="12"
>
sOmErAwHtml
</textarea>
<CodeEditor
innerRef={
Object {
"current": Object {
"value": "Ref Value",
},
}
}
value="sOmErAwHtml"
/>
</div>
`;

View File

@@ -2,21 +2,20 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from '@edx/paragon';
import CodeEditor from '../CodeEditor';
export const RawEditor = ({
editorRef,
text,
}) => (
<div className="form-group" style={{ padding: '10px 30px' }}>
<div style={{ padding: '10px 30px', height: '600px' }}>
<Alert variant="danger">
You are using the raw HTML editor.
</Alert>
<textarea
className="form-control"
ref={editorRef}
rows="12"
>
{ text }
</textarea>
<CodeEditor
innerRef={editorRef}
value={text}
/>
</div>
);
RawEditor.defaultProps = {

View File

@@ -3,7 +3,7 @@ import { shallow } from 'enzyme';
import { RawEditor } from '.';
describe('ImageSettingsModal', () => {
describe('RawEditor', () => {
const props = {
editorRef: {
current: {

View File

@@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SourceCodeModal renders as expected with default behavior 1`] = `
<BaseModal
close={[MockFunction]}
confirmAction={
<Button
0="S"
1="o"
2="M"
3="e"
4="v"
5="A"
6="l"
7="u"
8="e"
variant="primary"
>
<FormattedMessage
defaultMessage="Save"
description="Label for Save button for the source code editor"
id="authoring.texteditor.sourcecodemodal.next.label"
/>
</Button>
}
footerAction={null}
isOpen={false}
size="xl"
title="Edit Source Code"
>
<div
style={
Object {
"height": "300px",
"padding": "10px 30px",
}
}
>
<CodeEditor
innerRef="moCKrEf"
value="mOckHtMl"
/>
</div>
</BaseModal>
`;

View File

@@ -0,0 +1,27 @@
import { useRef } from 'react';
import * as module from './hooks';
export const getSaveBtnProps = ({ editorRef, ref, close }) => ({
onClick: () => {
if (editorRef && editorRef.current && ref && ref.current) {
const content = ref.current.state.doc.toString();
editorRef.current.setContent(content);
close();
}
},
});
export const prepareSourceCodeModal = ({ editorRef, close }) => {
const ref = useRef();
const saveBtnProps = module.getSaveBtnProps({ editorRef, ref, close });
if (editorRef && editorRef.current && typeof editorRef.current.getContent === 'function') {
const value = editorRef?.current?.getContent();
return { saveBtnProps, value, ref };
}
return { saveBtnProps, value: null, ref };
};
export default {
prepareSourceCodeModal,
};

View File

@@ -0,0 +1,60 @@
import React from 'react';
import * as module from './hooks';
jest.mock('react', () => ({
...jest.requireActual('react'),
useRef: jest.fn(val => ({ current: val })),
useEffect: jest.fn(),
useCallback: (cb, prereqs) => ({ cb, prereqs }),
}));
describe('SourceCodeModal hooks', () => {
const mockContent = 'sOmEMockHtML';
const mockSetContent = jest.fn();
const mockEditorRef = {
current:
{
setContent: mockSetContent,
getContent: jest.fn(() => mockContent),
},
};
const mockClose = jest.fn();
test('getSaveBtnProps', () => {
const mockRef = {
current: {
state: {
doc: mockContent,
},
},
};
const input = {
ref: mockRef,
editorRef: mockEditorRef,
close: mockClose,
};
const resultProps = module.getSaveBtnProps(input);
resultProps.onClick();
expect(mockSetContent).toHaveBeenCalledWith(mockContent);
expect(mockClose).toHaveBeenCalled();
});
test('prepareSourceCodeModal', () => {
const props = {
close: mockClose,
editorRef: mockEditorRef,
};
const mockRef = { current: 'rEf' };
const spyRef = jest.spyOn(React, 'useRef').mockReturnValueOnce(mockRef);
const mockButton = 'mOcKBuTton';
const spyButtons = jest.spyOn(module, 'getSaveBtnProps').mockImplementation(
() => mockButton,
);
const result = module.prepareSourceCodeModal(props);
expect(spyRef).toHaveBeenCalled();
expect(spyButtons).toHaveBeenCalled();
expect(result).toStrictEqual({ saveBtnProps: mockButton, value: mockEditorRef.current.getContent(), ref: mockRef });
});
});

View File

@@ -0,0 +1,59 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedMessage,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import messages from './messages';
import hooks from './hooks';
import BaseModal from '../BaseModal';
import CodeEditor from '../CodeEditor';
export const SourceCodeModal = ({
isOpen,
close,
editorRef,
// injected
intl,
}) => {
const { saveBtnProps, value, ref } = hooks.prepareSourceCodeModal({ editorRef, close });
return (
<BaseModal
close={close}
size="xl"
confirmAction={(
<Button {...saveBtnProps} variant="primary">
<FormattedMessage {...messages.saveButtonLabel} />
</Button>
)}
isOpen={isOpen}
title={intl.formatMessage(messages.titleLabel)}
>
<div style={{ padding: '10px 30px', height: '300px' }}>
<CodeEditor
innerRef={ref}
value={value}
/>
</div>
</BaseModal>
);
};
SourceCodeModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
editorRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.any }),
]).isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(SourceCodeModal);

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { shallow } from 'enzyme';
import hooks from './hooks';
import { formatMessage } from '../../../../../testUtils';
import { SourceCodeModal } from '.';
jest.mock('./hooks', () => ({
prepareSourceCodeModal: jest.fn(() => {
}),
}));
describe('SourceCodeModal', () => {
const mockClose = jest.fn();
const props = {
isOpen: false,
close: mockClose,
editorRef: {
current: jest.fn(),
},
intl: { formatMessage },
};
test('renders as expected with default behavior', () => {
const mocksaveBtnProps = 'SoMevAlue';
const mockvalue = 'mOckHtMl';
const mockref = 'moCKrEf';
hooks.prepareSourceCodeModal.mockReturnValueOnce({
saveBtnProps: mocksaveBtnProps,
value: mockvalue,
ref: mockref,
});
expect(shallow(<SourceCodeModal {...props} />)).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,13 @@
export const messages = {
saveButtonLabel: {
id: 'authoring.texteditor.sourcecodemodal.next.label',
defaultMessage: 'Save',
description: 'Label for Save button for the source code editor',
},
titleLabel: {
id: 'authoring.texteditor.sourcecodemodal.title.label',
defaultMessage: 'Edit Source Code',
description: 'Title for the source code editor',
},
};
export default messages;

View File

@@ -12,30 +12,33 @@ import * as module from './hooks';
export const { nullMethod, navigateCallback, navigateTo } = appHooks;
export const state = StrictDict({
isModalOpen: (val) => useState(val),
isImageModalOpen: (val) => useState(val),
isSourceCodeModalOpen: (val) => useState(val),
imageSelection: (val) => useState(val),
refReady: (val) => useState(val),
});
export const setupCustomBehavior = ({ openModal, setImage }) => (editor) => {
export const setupCustomBehavior = ({
openImgModal,
openSourceCodeModal, setImage,
}) => (editor) => {
// image upload button
editor.ui.registry.addButton(tinyMCE.buttons.imageUploadButton, {
icon: 'image',
tooltip: 'Add Image',
onAction: openModal,
onAction: openImgModal,
});
// editing an existing image
editor.ui.registry.addButton(tinyMCE.buttons.editImageSettings, {
icon: 'image',
tooltip: 'Edit Image Settings',
onAction: module.openModalWithSelectedImage({ editor, setImage, openModal }),
onAction: module.openModalWithSelectedImage({ editor, setImage, openImgModal }),
});
// overriding the code plugin's icon with 'HTML' text
const openCodeEditor = () => editor.execCommand('mceCodeEditor');
editor.ui.registry.addButton(tinyMCE.buttons.code, {
text: 'HTML',
tooltip: 'Source code',
onAction: openCodeEditor,
onAction: openSourceCodeModal,
});
// add a custom simple inline code block formatter.
const setupCodeFormatting = (api) => {
@@ -64,7 +67,8 @@ export const editorConfig = ({
blockValue,
initializeEditor,
lmsEndpointUrl,
openModal,
openImgModal,
openSourceCodeModal,
setEditorRef,
setSelection,
studioEndpointUrl,
@@ -84,23 +88,36 @@ export const editorConfig = ({
imagetools_cors_hosts: [removeProtocolFromUrl(lmsEndpointUrl), removeProtocolFromUrl(studioEndpointUrl)],
imagetools_toolbar: pluginConfig.imageToolbar,
plugins: pluginConfig.plugins,
setup: module.setupCustomBehavior({ openModal, setImage: setSelection }),
setup: module.setupCustomBehavior({
openImgModal,
openSourceCodeModal,
setImage: setSelection,
}),
toolbar: pluginConfig.toolbar,
valid_children: '+body[style]',
valid_elements: '*[*]',
},
});
export const modalToggle = () => {
const [isOpen, setIsOpen] = module.state.isModalOpen(false);
export const imgModalToggle = () => {
const [isImgOpen, setIsOpen] = module.state.isImageModalOpen(false);
return {
isOpen,
openModal: () => setIsOpen(true),
closeModal: () => setIsOpen(false),
isImgOpen,
openImgModal: () => setIsOpen(true),
closeImgModal: () => setIsOpen(false),
};
};
export const openModalWithSelectedImage = ({ editor, setImage, openModal }) => () => {
export const sourceCodeModalToggle = () => {
const [isSourceCodeOpen, setIsOpen] = module.state.isSourceCodeModalOpen(false);
return {
isSourceCodeOpen,
openSourceCodeModal: () => setIsOpen(true),
closeSourceCodeModal: () => setIsOpen(false),
};
};
export const openModalWithSelectedImage = ({ editor, setImage, openImgModal }) => () => {
const imgHTML = editor.selection.getNode();
setImage({
externalUrl: imgHTML.src,
@@ -108,7 +125,7 @@ export const openModalWithSelectedImage = ({ editor, setImage, openModal }) => (
width: imgHTML.width,
height: imgHTML.height,
});
openModal();
openImgModal();
};
export const prepareEditorRef = () => {
@@ -123,7 +140,7 @@ export const prepareEditorRef = () => {
export const getContent = ({ editorRef, isRaw }) => () => {
if (isRaw && editorRef && editorRef.current) {
return editorRef.current.value;
return editorRef.current.state.doc.toString();
}
return editorRef.current?.getContent();
};

View File

@@ -33,7 +33,8 @@ describe('TextEditor hooks', () => {
jest.clearAllMocks();
});
describe('state hooks', () => {
state.testGetter(state.keys.isModalOpen);
state.testGetter(state.keys.isImageModalOpen);
state.testGetter(state.keys.isSourceCodeModalOpen);
state.testGetter(state.keys.imageSelection);
state.testGetter(state.keys.refReady);
});
@@ -58,30 +59,30 @@ describe('TextEditor hooks', () => {
test('It calls addButton and addToggleButton in the editor, but openModal is not called', () => {
const addButton = jest.fn();
const addToggleButton = jest.fn();
const openModal = jest.fn();
const openImgModal = jest.fn();
const openSourceCodeModal = jest.fn();
const setImage = jest.fn();
const editor = {
ui: { registry: { addButton, addToggleButton } },
};
const mockOpenModalWithImage = args => ({ openModalWithSelectedImage: args });
const expectedSettingsAction = mockOpenModalWithImage({ editor, setImage, openModal });
const openCodeEditor = expect.any(Function);
const expectedSettingsAction = mockOpenModalWithImage({ editor, setImage, openImgModal });
const toggleCodeFormatting = expect.any(Function);
const setupCodeFormatting = expect.any(Function);
jest.spyOn(module, moduleKeys.openModalWithSelectedImage)
.mockImplementationOnce(mockOpenModalWithImage);
output = module.setupCustomBehavior({ openModal, setImage })(editor);
output = module.setupCustomBehavior({ openImgModal, openSourceCodeModal, setImage })(editor);
expect(addButton.mock.calls).toEqual([
[tinyMCE.buttons.imageUploadButton, { icon: 'image', tooltip: 'Add Image', onAction: openModal }],
[tinyMCE.buttons.imageUploadButton, { icon: 'image', tooltip: 'Add Image', onAction: openImgModal }],
[tinyMCE.buttons.editImageSettings, { icon: 'image', tooltip: 'Edit Image Settings', onAction: expectedSettingsAction }],
[tinyMCE.buttons.code, { text: 'HTML', tooltip: 'Source code', onAction: openCodeEditor }],
[tinyMCE.buttons.code, { text: 'HTML', tooltip: 'Source code', onAction: openSourceCodeModal }],
]);
expect(addToggleButton.mock.calls).toEqual([
[tinyMCE.buttons.codeBlock, {
icon: 'sourcecode', tooltip: 'Code Block', onAction: toggleCodeFormatting, onSetup: setupCodeFormatting,
}],
]);
expect(openModal).not.toHaveBeenCalled();
expect(openImgModal).not.toHaveBeenCalled();
});
});
@@ -96,7 +97,8 @@ describe('TextEditor hooks', () => {
const setupCustomBehavior = args => ({ setupCustomBehavior: args });
beforeEach(() => {
props.setEditorRef = jest.fn();
props.openModal = jest.fn();
props.openImgModal = jest.fn();
props.openSourceCodeModal = jest.fn();
props.initializeEditor = jest.fn();
jest.spyOn(module, moduleKeys.setupCustomBehavior)
.mockImplementationOnce(setupCustomBehavior);
@@ -129,25 +131,47 @@ describe('TextEditor hooks', () => {
it('calls setupCustomBehavior on setup', () => {
expect(output.init.setup).toEqual(
setupCustomBehavior({ openModal: props.openModal, setImage: props.setSelection }),
setupCustomBehavior({
openImgModal: props.openImgModal,
openSourceCodeModal: props.openSourceCodeModal,
setImage: props.setSelection,
}),
);
});
});
describe('modalToggle', () => {
const hookKey = state.keys.isModalOpen;
describe('imgModalToggle', () => {
const hookKey = state.keys.isImageModalOpen;
beforeEach(() => {
hook = module.modalToggle();
hook = module.imgModalToggle();
});
test('isOpen: state value', () => {
expect(hook.isOpen).toEqual(state.stateVals[hookKey]);
expect(hook.isImgOpen).toEqual(state.stateVals[hookKey]);
});
test('openModal: calls setter with true', () => {
hook.openModal();
hook.openImgModal();
expect(state.setState[hookKey]).toHaveBeenCalledWith(true);
});
test('closeModal: calls setter with false', () => {
hook.closeModal();
hook.closeImgModal();
expect(state.setState[hookKey]).toHaveBeenCalledWith(false);
});
});
describe('sourceCodeModalToggle', () => {
const hookKey = state.keys.isSourceCodeModalOpen;
beforeEach(() => {
hook = module.sourceCodeModalToggle();
});
test('isOpen: state value', () => {
expect(hook.isSourceCodeOpen).toEqual(state.stateVals[hookKey]);
});
test('openModal: calls setter with true', () => {
hook.openSourceCodeModal();
expect(state.setState[hookKey]).toHaveBeenCalledWith(true);
});
test('closeModal: calls setter with false', () => {
hook.closeSourceCodeModal();
expect(state.setState[hookKey]).toHaveBeenCalledWith(false);
});
});
@@ -155,16 +179,16 @@ describe('TextEditor hooks', () => {
describe('openModalWithSelectedImage', () => {
test('image is set to be value stored in editor, modal is opened', () => {
const setImage = jest.fn();
const openModal = jest.fn();
const openImgModal = jest.fn();
const editor = { selection: { getNode: () => mockNode } };
module.openModalWithSelectedImage({ editor, openModal, setImage })();
module.openModalWithSelectedImage({ editor, openImgModal, setImage })();
expect(setImage).toHaveBeenCalledWith({
externalUrl: mockNode.src,
altText: mockNode.alt,
width: mockNode.width,
height: mockNode.height,
});
expect(openModal).toHaveBeenCalled();
expect(openImgModal).toHaveBeenCalled();
});
});
@@ -198,7 +222,9 @@ describe('TextEditor hooks', () => {
const editorRef = {
current: {
getContent: () => visualContent,
value: rawContent,
state: {
doc: rawContent,
},
},
};
test('returns correct ontent based on isRaw', () => {

View File

@@ -31,6 +31,7 @@ import { RequestKeys } from '../../data/constants/requests';
import EditorContainer from '../EditorContainer';
import ImageUploadModal from './components/ImageUploadModal';
import SourceCodeModal from './components/SourceCodeModal';
import RawEditor from './components/RawEditor';
import * as hooks from './hooks';
import messages from './messages';
@@ -49,7 +50,8 @@ export const TextEditor = ({
intl,
}) => {
const { editorRef, refReady, setEditorRef } = hooks.prepareEditorRef();
const { isOpen, openModal, closeModal } = hooks.modalToggle();
const { isImgOpen, openImgModal, closeImgModal } = hooks.imgModalToggle();
const { isSourceCodeOpen, openSourceCodeModal, closeSourceCodeModal } = hooks.sourceCodeModalToggle();
const imageSelection = hooks.selectedImage(null);
if (!refReady) { return null; }
@@ -68,7 +70,8 @@ export const TextEditor = ({
{...hooks.editorConfig({
setEditorRef,
blockValue,
openModal,
openImgModal,
openSourceCodeModal,
initializeEditor,
lmsEndpointUrl,
studioEndpointUrl,
@@ -86,11 +89,16 @@ export const TextEditor = ({
>
<div className="editor-body h-75 overflow-auto">
<ImageUploadModal
isOpen={isOpen}
close={closeModal}
isOpen={isImgOpen}
close={closeImgModal}
editorRef={editorRef}
{...imageSelection}
/>
<SourceCodeModal
isOpen={isSourceCodeOpen}
close={closeSourceCodeModal}
editorRef={editorRef}
/>
<Toast show={blockFailed} onClose={hooks.nullMethod}>
<FormattedMessage {...messages.couldNotLoadTextContext} />

View File

@@ -4,7 +4,7 @@ import { shallow } from 'enzyme';
import { formatMessage } from '../../../testUtils';
import { actions, selectors } from '../../data/redux';
import { RequestKeys } from '../../data/constants/requests';
import { modalToggle } from './hooks';
import { imgModalToggle, sourceCodeModalToggle } from './hooks';
import { TextEditor, mapStateToProps, mapDispatchToProps } from '.';
// Per https://github.com/tinymce/tinymce-react/issues/91 React unit testing in JSDOM is not supported by tinymce.
@@ -19,15 +19,22 @@ jest.mock('@tinymce/tinymce-react', () => {
});
jest.mock('../EditorContainer', () => 'EditorContainer');
jest.mock('./components/ImageUploadModal', () => 'ImageUploadModal');
jest.mock('./components/SourceCodeModal', () => 'SourceCodeModal');
jest.mock('./hooks', () => ({
editorConfig: jest.fn(args => ({ editorConfig: args })),
getContent: jest.fn(args => ({ getContent: args })),
modalToggle: jest.fn(() => ({
isOpen: true,
openModal: jest.fn().mockName('openModal'),
closeModal: jest.fn().mockName('closeModal'),
imgModalToggle: jest.fn(() => ({
isImgOpen: true,
openImgModal: jest.fn().mockName('openModal'),
closeImgModal: jest.fn().mockName('closeModal'),
})),
sourceCodeModalToggle: jest.fn(() => ({
isSourceCodeOpen: true,
openSourceCodeModal: jest.fn().mockName('openModal'),
closeSourceCodeModal: jest.fn().mockName('closeModal'),
})),
selectedImage: jest.fn(() => ({
selection: 'hooks.selectedImage.selection',
@@ -86,10 +93,15 @@ describe('TextEditor', () => {
intl: { formatMessage },
};
describe('snapshots', () => {
modalToggle.mockReturnValue({
isOpen: false,
openModal: jest.fn().mockName('modal.openModal'),
closeModal: jest.fn().mockName('modal.closeModal'),
imgModalToggle.mockReturnValue({
isImgOpen: false,
openImgModal: jest.fn().mockName('modal.openModal'),
closeImgModal: jest.fn().mockName('modal.closeModal'),
});
sourceCodeModalToggle.mockReturnValue({
isSourceCodeOpen: false,
openSourceCodeModal: jest.fn().mockName('modal.openModal'),
closeSourceCodeModal: jest.fn().mockName('modal.closeModal'),
});
test('renders as expected with default behavior', () => {
expect(shallow(<TextEditor {...props} />)).toMatchSnapshot();