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:
6644
package-lock.json
generated
6644
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
.cm-editor { height: 100% }
|
||||
.cm-scroller { overflow: auto }
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ exports[`ImageSettingsModal render snapshot 1`] = `
|
||||
}
|
||||
footerAction={null}
|
||||
isOpen={false}
|
||||
size="lg"
|
||||
title="Image Settings"
|
||||
>
|
||||
<ErrorAlert
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { shallow } from 'enzyme';
|
||||
|
||||
import { RawEditor } from '.';
|
||||
|
||||
describe('ImageSettingsModal', () => {
|
||||
describe('RawEditor', () => {
|
||||
const props = {
|
||||
editorRef: {
|
||||
current: {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user