+
You are using the raw HTML editor.
-
+
);
RawEditor.defaultProps = {
diff --git a/src/editors/containers/TextEditor/components/RawEditor/index.test.jsx b/src/editors/containers/TextEditor/components/RawEditor/index.test.jsx
index cf703a75a..96387b0ba 100644
--- a/src/editors/containers/TextEditor/components/RawEditor/index.test.jsx
+++ b/src/editors/containers/TextEditor/components/RawEditor/index.test.jsx
@@ -3,7 +3,7 @@ import { shallow } from 'enzyme';
import { RawEditor } from '.';
-describe('ImageSettingsModal', () => {
+describe('RawEditor', () => {
const props = {
editorRef: {
current: {
diff --git a/src/editors/containers/TextEditor/components/SourceCodeModal/__snapshots__/index.test.jsx.snap b/src/editors/containers/TextEditor/components/SourceCodeModal/__snapshots__/index.test.jsx.snap
new file mode 100644
index 000000000..d90258b84
--- /dev/null
+++ b/src/editors/containers/TextEditor/components/SourceCodeModal/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,45 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SourceCodeModal renders as expected with default behavior 1`] = `
+
+
+
+ }
+ footerAction={null}
+ isOpen={false}
+ size="xl"
+ title="Edit Source Code"
+>
+
+
+
+
+`;
diff --git a/src/editors/containers/TextEditor/components/SourceCodeModal/hooks.js b/src/editors/containers/TextEditor/components/SourceCodeModal/hooks.js
new file mode 100644
index 000000000..c82e704c4
--- /dev/null
+++ b/src/editors/containers/TextEditor/components/SourceCodeModal/hooks.js
@@ -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,
+};
diff --git a/src/editors/containers/TextEditor/components/SourceCodeModal/hooks.test.js b/src/editors/containers/TextEditor/components/SourceCodeModal/hooks.test.js
new file mode 100644
index 000000000..08df5a2f7
--- /dev/null
+++ b/src/editors/containers/TextEditor/components/SourceCodeModal/hooks.test.js
@@ -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 });
+ });
+});
diff --git a/src/editors/containers/TextEditor/components/SourceCodeModal/index.jsx b/src/editors/containers/TextEditor/components/SourceCodeModal/index.jsx
new file mode 100644
index 000000000..590e10c1e
--- /dev/null
+++ b/src/editors/containers/TextEditor/components/SourceCodeModal/index.jsx
@@ -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 (
+
+
+
+ )}
+ isOpen={isOpen}
+ title={intl.formatMessage(messages.titleLabel)}
+ >
+
+
+
+
+
+ );
+};
+
+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);
diff --git a/src/editors/containers/TextEditor/components/SourceCodeModal/index.test.jsx b/src/editors/containers/TextEditor/components/SourceCodeModal/index.test.jsx
new file mode 100644
index 000000000..b01045346
--- /dev/null
+++ b/src/editors/containers/TextEditor/components/SourceCodeModal/index.test.jsx
@@ -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(
)).toMatchSnapshot();
+ });
+});
diff --git a/src/editors/containers/TextEditor/components/SourceCodeModal/messages.js b/src/editors/containers/TextEditor/components/SourceCodeModal/messages.js
new file mode 100644
index 000000000..b0e46bf63
--- /dev/null
+++ b/src/editors/containers/TextEditor/components/SourceCodeModal/messages.js
@@ -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;
diff --git a/src/editors/containers/TextEditor/hooks.js b/src/editors/containers/TextEditor/hooks.js
index 480baf4ac..eaceea2ef 100644
--- a/src/editors/containers/TextEditor/hooks.js
+++ b/src/editors/containers/TextEditor/hooks.js
@@ -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();
};
diff --git a/src/editors/containers/TextEditor/hooks.test.jsx b/src/editors/containers/TextEditor/hooks.test.jsx
index d4d714f7f..760619c8d 100644
--- a/src/editors/containers/TextEditor/hooks.test.jsx
+++ b/src/editors/containers/TextEditor/hooks.test.jsx
@@ -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', () => {
diff --git a/src/editors/containers/TextEditor/index.jsx b/src/editors/containers/TextEditor/index.jsx
index 0de15d843..7f73dc1e3 100644
--- a/src/editors/containers/TextEditor/index.jsx
+++ b/src/editors/containers/TextEditor/index.jsx
@@ -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 = ({
>
+
diff --git a/src/editors/containers/TextEditor/index.test.jsx b/src/editors/containers/TextEditor/index.test.jsx
index 04db506cf..256703927 100644
--- a/src/editors/containers/TextEditor/index.test.jsx
+++ b/src/editors/containers/TextEditor/index.test.jsx
@@ -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()).toMatchSnapshot();