diff --git a/package-lock.json b/package-lock.json
index 208b4088a..731dbc8a9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1277,7 +1277,7 @@
"dev": true
},
"@edx/brand": {
- "version": "npm:@edx/brand-openedx@1.1.0",
+ "version": "npm:@edx/brand@1.1.0",
"resolved": "https://registry.npmjs.org/@edx/brand-openedx/-/brand-openedx-1.1.0.tgz",
"integrity": "sha512-ne2ZKF1r0akkt0rEzCAQAk4cTDTI2GiWCpc+T7ldQpw9X57OnUB16dKsFNe40C9uEjL5h3Ps/ZsFM5dm4cIkEQ==",
"dev": true
diff --git a/src/editors/Editor.jsx b/src/editors/Editor.jsx
index 3529fe7b5..a456eadc0 100644
--- a/src/editors/Editor.jsx
+++ b/src/editors/Editor.jsx
@@ -54,7 +54,7 @@ export const Editor = ({
<>
{(EditorComponent !== undefined)
- ?
+ ?
: }
>
diff --git a/src/editors/__snapshots__/Editor.test.jsx.snap b/src/editors/__snapshots__/Editor.test.jsx.snap
index 2cb55231d..f93a95e94 100644
--- a/src/editors/__snapshots__/Editor.test.jsx.snap
+++ b/src/editors/__snapshots__/Editor.test.jsx.snap
@@ -49,6 +49,11 @@ exports[`Editor snapshots renders "html" editor when ref is ready 1`] = `
}
/>
{
const { isOpen, openModal, closeModal } = modalToggle();
+ // selected image file reference data object.
+ // this field determines the step of the ImageUploadModal
+ const [imageSelection, setImageSelection] = selectedImage(null);
+
return (
@@ -66,6 +77,7 @@ export const TextEditor = ({
blockValue,
openModal,
initializeEditor,
+ setSelection: setImageSelection,
})}
/>
)}
@@ -74,8 +86,13 @@ export const TextEditor = ({
};
TextEditor.defaultProps = {
blockValue: null,
+ editorRef: null,
};
TextEditor.propTypes = {
+ editorRef: PropTypes.oneOfType([
+ PropTypes.func,
+ PropTypes.shape({ current: PropTypes.any }),
+ ]),
setEditorRef: PropTypes.func.isRequired,
// redux
blockValue: PropTypes.shape({
diff --git a/src/editors/containers/TextEditor/TextEditor.test.jsx b/src/editors/containers/TextEditor/TextEditor.test.jsx
index 6f8866815..02484b89b 100644
--- a/src/editors/containers/TextEditor/TextEditor.test.jsx
+++ b/src/editors/containers/TextEditor/TextEditor.test.jsx
@@ -20,17 +20,29 @@ jest.mock('./components/ImageUploadModal', () => 'ImageUploadModal');
jest.mock('./components/SelectImageModal', () => 'SelectImageModal');
jest.mock('./components/ImageSettingsModal', () => 'ImageSettingsModal');
-jest.mock('./hooks', () => ({
- editorConfig: jest.fn(args => ({ editorConfig: args })),
- modalToggle: jest.fn(args => ({ modalToggle: args })),
- nullMethod: jest.fn().mockName('nullMethod'),
-}));
+jest.mock('./hooks', () => {
+ const updateState = jest.fn();
+ return ({
+ editorConfig: jest.fn(args => ({ editorConfig: args })),
+ modalToggle: jest.fn(args => ({ modalToggle: args })),
+ selectedImage: jest.fn(val => ([{ state: val }, jest.fn((newVal) => updateState({ val, newVal })).mockName('setSelection')])),
+ nullMethod: jest.fn().mockName('nullMethod'),
+ });
+});
+
+jest.mock('react', () => {
+ const updateState = jest.fn();
+ return {
+ ...jest.requireActual('react'),
+ updateState,
+ useState: jest.fn(val => ([{ state: val }, jest.fn().mockName('setState')])),
+ };
+});
jest.mock('../../data/redux', () => ({
actions: {
app: {
initializeEditor: jest.fn().mockName('actions.app.initializeEditor'),
- fetchImages: jest.fn().mockName('actions.app.fetchImages'),
},
},
selectors: {
@@ -42,11 +54,17 @@ jest.mock('../../data/redux', () => ({
isFinished: jest.fn((state, params) => ({ isFailed: { state, params } })),
},
},
+ thunkActions: {
+ app: {
+ fetchImages: jest.fn().mockName('actions.app.fetchImages'),
+ },
+ },
}));
describe('TextEditor', () => {
const props = {
setEditorRef: jest.fn().mockName('args.setEditorRef'),
+ editorRef: { current: { value: 'something' } },
// redux
blockValue: { data: 'eDiTablE Text' },
blockFailed: false,
diff --git a/src/editors/containers/TextEditor/__snapshots__/TextEditor.test.jsx.snap b/src/editors/containers/TextEditor/__snapshots__/TextEditor.test.jsx.snap
index 0eb01d0e7..448a0b609 100644
--- a/src/editors/containers/TextEditor/__snapshots__/TextEditor.test.jsx.snap
+++ b/src/editors/containers/TextEditor/__snapshots__/TextEditor.test.jsx.snap
@@ -6,7 +6,20 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
>
@@ -39,7 +53,20 @@ exports[`TextEditor snapshots not yet loaded, Spinner appears 1`] = `
>
diff --git a/src/editors/containers/TextEditor/components/ImageSettingsModal/index.jsx b/src/editors/containers/TextEditor/components/ImageSettingsModal/index.jsx
index 3f88946b0..f838ee3dd 100644
--- a/src/editors/containers/TextEditor/components/ImageSettingsModal/index.jsx
+++ b/src/editors/containers/TextEditor/components/ImageSettingsModal/index.jsx
@@ -28,8 +28,8 @@ export const hooks = {
setWidth,
};
},
- altText: () => {
- const [altText, setAltText] = React.useState('');
+ altText: (savedText) => {
+ const [altText, setAltText] = React.useState(savedText || '');
const [isDecorative, setIsDecorative] = React.useState(false);
return {
value: altText,
@@ -38,10 +38,10 @@ export const hooks = {
setIsDecorative,
};
},
- onImgLoad: (initializeDimensions) => ({ target: img }) => {
+ onImgLoad: (initializeDimensions, selection) => ({ target: img }) => {
initializeDimensions({
- height: img.naturalHeight,
- width: img.naturalWidth,
+ height: selection.height ? selection.height : img.naturalHeight,
+ width: selection.width ? selection.width : img.naturalWidth,
});
},
onInputChange: (handleValue) => (e) => handleValue(e.target.value),
@@ -66,9 +66,9 @@ export const ImageSettingsModal = ({
returnToSelection,
}) => {
const dimensions = module.hooks.dimensions();
- const altText = module.hooks.altText();
- const onImgLoad = module.hooks.onImgLoad(dimensions.initialize);
- const onSaveClick = module.hooks.onSave({
+ const altText = module.hooks.altText(selection.altText);
+ const onImgLoad = module.hooks.onImgLoad(dimensions.initialize, selection);
+ const onSaveClick = () => module.hooks.onSave({
saveToEditor,
dimensions: dimensions.value,
altText: altText.value,
@@ -94,7 +94,6 @@ export const ImageSettingsModal = ({
onLoad={onImgLoad}
src={selection.externalUrl}
/>
-
{ dimensions.value && (
Image Dimensions
@@ -140,6 +139,7 @@ ImageSettingsModal.propTypes = {
selection: PropTypes.shape({
url: PropTypes.string,
externalUrl: PropTypes.string,
+ altText: PropTypes.bool,
}).isRequired,
saveToEditor: PropTypes.func.isRequired,
returnToSelection: PropTypes.func.isRequired,
diff --git a/src/editors/containers/TextEditor/components/ImageUploadModal.jsx b/src/editors/containers/TextEditor/components/ImageUploadModal.jsx
index 0bccf878f..4f9b6cb19 100644
--- a/src/editors/containers/TextEditor/components/ImageUploadModal.jsx
+++ b/src/editors/containers/TextEditor/components/ImageUploadModal.jsx
@@ -1,24 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
-
import ImageSettingsModal from './ImageSettingsModal';
import SelectImageModal from './SelectImageModal';
+import * as module from './ImageUploadModal';
+
+export const hooks = {
+ createSaveCallback: ({
+ close, editorRef, setSelection, selection,
+ }) => (settings) => {
+ editorRef.current.execCommand('mceInsertContent', false, module.hooks.getImgTag({ settings, selection }));
+ setSelection(null);
+ close();
+ },
+ getImgTag: ({ settings, selection }) => `
`,
+};
const ImageUploadModal = ({
// eslint-disable-next-line
editorRef,
isOpen,
close,
+ selection,
+ setSelection,
}) => {
- // selected image file reference data object.
- // existance of this field determines which child modal is displayed
- const [selection, setSelection] = React.useState(null);
- const clearSelection = () => setSelection(null);
- const saveToEditor = (settings) => {
- // eslint-disable-next-line
- console.log({ selection, settings });
- // tell editor ref to insert content at cursor location();
- };
+ const saveToEditor = module.hooks.createSaveCallback({
+ close, editorRef, setSelection, selection,
+ });
+
const closeAndReset = () => {
setSelection(null);
close();
@@ -31,7 +39,7 @@ const ImageUploadModal = ({
close: closeAndReset,
selection,
saveToEditor,
- returnToSelection: clearSelection,
+ returnToSelection: () => setSelection(null),
}}
/>
);
@@ -43,6 +51,12 @@ ImageUploadModal.defaultProps = {
editorRef: null,
};
ImageUploadModal.propTypes = {
+ selection: PropTypes.shape({
+ url: PropTypes.string,
+ externalUrl: PropTypes.string,
+ altText: PropTypes.bool,
+ }).isRequired,
+ setSelection: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
editorRef: PropTypes.oneOfType([
diff --git a/src/editors/containers/TextEditor/components/ImageUploadModal.test.jsx b/src/editors/containers/TextEditor/components/ImageUploadModal.test.jsx
new file mode 100644
index 000000000..aa0699326
--- /dev/null
+++ b/src/editors/containers/TextEditor/components/ImageUploadModal.test.jsx
@@ -0,0 +1,68 @@
+import * as module from './ImageUploadModal';
+
+describe('ImageUploadModal hooks', () => {
+ describe('getImgTag', () => {
+ const mockSelection = { externalUrl: 'sOmEuRl.cOm' };
+ let output;
+ test('It returns a html string which matches an image tag', () => {
+ const mockSettings = {
+ altText: 'aLt tExt',
+ isDecorative: false,
+ dimensions: {
+ width: 2022,
+ height: 1619,
+ },
+
+ };
+ output = module.hooks.getImgTag({ selection: mockSelection, settings: mockSettings });
+ expect(output).toEqual(`
`);
+ });
+ test('If Is decorative is true, alt text is an empty string', () => {
+ const mockSettings = {
+ isDecorative: true,
+ altText: 'aLt tExt',
+ dimensions: {
+ width: 2022,
+ height: 1619,
+ },
+ };
+ output = module.hooks.getImgTag({ selection: mockSelection, settings: mockSettings });
+ expect(output).toEqual(`
`);
+ });
+ });
+
+ describe('createSaveCallback', () => {
+ const close = jest.fn();
+ const execCommandMock = jest.fn();
+ const editorRef = { current: { some: 'dATa', execCommand: execCommandMock } };
+ const setSelection = jest.fn();
+ const selection = jest.fn();
+ const mockSettings = {
+ altText: 'aLt tExt',
+ isDecorative: false,
+ dimensions: {
+ width: 2022,
+ height: 1619,
+ },
+ };
+ let output;
+ beforeEach(() => {
+ output = module.hooks.createSaveCallback({
+ close, editorRef, setSelection, selection,
+ });
+ });
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+ test('It creates a callback, that when called, inserts to the editor, sets the selection to be null, and calls close', () => {
+ jest.spyOn(module.hooks, 'getImgTag').mockImplementationOnce(({ settings }) => ({ selection, settings }));
+ expect(execCommandMock).not.toBeCalled();
+ expect(setSelection).not.toBeCalled();
+ expect(close).not.toBeCalled();
+ output(mockSettings);
+ expect(execCommandMock).toBeCalledWith('mceInsertContent', false, { selection, settings: mockSettings });
+ expect(setSelection).toBeCalledWith(null);
+ expect(close).toBeCalled();
+ });
+ });
+});
diff --git a/src/editors/containers/TextEditor/components/SelectImageModal.jsx b/src/editors/containers/TextEditor/components/SelectImageModal.jsx
index 3d9f4c779..4ff4469e0 100644
--- a/src/editors/containers/TextEditor/components/SelectImageModal.jsx
+++ b/src/editors/containers/TextEditor/components/SelectImageModal.jsx
@@ -3,8 +3,7 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { Button, Image } from '@edx/paragon';
-
-import { actions } from '../../../data/redux';
+import { thunkActions } from '../../../data/redux';
import BaseModal from './BaseModal';
import * as module from './SelectImageModal';
@@ -60,7 +59,7 @@ SelectImageModal.propTypes = {
export const mapStateToProps = () => ({});
export const mapDispatchToProps = {
- fetchImages: actions.app.fetchImages,
+ fetchImages: thunkActions.app.fetchImages,
};
export default connect(mapStateToProps, mapDispatchToProps)(SelectImageModal);
diff --git a/src/editors/containers/TextEditor/hooks.js b/src/editors/containers/TextEditor/hooks.js
index 4e73a4b51..1c6993c53 100644
--- a/src/editors/containers/TextEditor/hooks.js
+++ b/src/editors/containers/TextEditor/hooks.js
@@ -1,47 +1,103 @@
import { useState } from 'react';
import * as module from './hooks';
+import { StrictDict } from '../../utils/index';
-export const addImageUploadButton = (openModal) => (editor) => {
+export const openModalWithSelectedImage = (editor, setImage, openModal) => () => {
+ const imgHTML = editor.selection.getNode();
+ setImage({
+ externalUrl: imgHTML.src,
+ altText: imgHTML.alt,
+ width: imgHTML.width,
+ height: imgHTML.height,
+ });
+ openModal();
+};
+
+export const addImageUploadBehavior = ({ openModal, setImage }) => (editor) => {
editor.ui.registry.addButton('imageuploadbutton', {
icon: 'image',
onAction: openModal,
});
+ editor.ui.registry.addButton('editimagesettings', {
+ icon: 'image',
+ onAction: module.openModalWithSelectedImage(editor, setImage, openModal),
+ });
};
-export const initializeEditorRef = (setRef) => (evt, editor) => {
+export const initializeEditorRef = (setRef, initializeEditor) => (editor) => {
setRef(editor);
+ initializeEditor();
};
// for toast onClose to avoid console warnings
export const nullMethod = () => {};
+export const pluginConfig = {
+ plugins: StrictDict({
+ link: 'link',
+ codesample: 'codesample',
+ emoticons: 'emoticons',
+ table: 'table',
+ charmap: 'charmap',
+ code: 'code',
+ autoresize: 'autoresize',
+ image: 'image',
+ imagetools: 'imagetools',
+ }),
+ menubar: false,
+ toolbar: StrictDict({
+ do: 'undo redo',
+ formatselect: 'formatselect',
+ wieght: 'bold italic backcolor',
+ align: 'alignleft aligncenter alignright alignjustify',
+ indents: 'bullist numlist outdent indent ',
+ imageupload: 'imageuploadbutton',
+ link: 'link',
+ emoticons: 'emoticons',
+ table: 'table',
+ codesample: 'codesample',
+ charmap: 'charmap',
+ removeformat: 'removeformat',
+ hr: 'hr',
+ code: 'code',
+ }),
+ imageToolbar: StrictDict({
+ rotate: 'rotateleft rotateright',
+ flip: 'flipv fliph',
+ editImageSettings: 'editimagesettings',
+ }),
+};
+export const getConfig = (key) => {
+ if (key === 'imageToolbar' || key === 'toolbar') {
+ return Object.values(module.pluginConfig[key]).join(' | ');
+ }
+ return Object.values(module.pluginConfig[key]).join(' ');
+};
+
export const editorConfig = ({
setEditorRef,
blockValue,
openModal,
initializeEditor,
+ setSelection,
}) => ({
- onInit: (evt, editor) => {
- module.initializeEditorRef(setEditorRef)(evt, editor);
- initializeEditor();
- },
+ onInit: (evt, editor) => module.initializeEditorRef(setEditorRef, initializeEditor)(editor),
initialValue: blockValue ? blockValue.data.data : '',
init: {
- setup: module.addImageUploadButton(openModal),
- plugins: 'link codesample emoticons table charmap code autoresize',
+ setup: module.addImageUploadBehavior({ openModal, setImage: setSelection }),
+ plugins: module.getConfig('plugins'),
menubar: false,
- toolbar: 'undo redo | formatselect | '
- + 'bold italic backcolor | alignleft aligncenter '
- + 'alignright alignjustify | bullist numlist outdent indent |'
- + 'imageuploadbutton | link | emoticons | table | codesample | charmap |'
- + 'removeformat | hr |code',
+ toolbar: module.getConfig('toolbar'),
+ imagetools_toolbar: module.getConfig('imageToolbar'),
+ imagetools_cors_hosts: ['courses.edx.org'],
height: '100%',
- content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }',
min_height: 1000,
branding: false,
},
});
+export const selectedImage = (val) => useState(val);
+
export const modalToggle = () => {
const [isOpen, setIsOpen] = useState(false);
return {
diff --git a/src/editors/containers/TextEditor/hooks.test.jsx b/src/editors/containers/TextEditor/hooks.test.jsx
index f1109ddfd..3d2e7ec0d 100644
--- a/src/editors/containers/TextEditor/hooks.test.jsx
+++ b/src/editors/containers/TextEditor/hooks.test.jsx
@@ -12,41 +12,75 @@ jest.mock('react', () => {
describe('TextEditor hooks', () => {
describe('Editor Init hooks', () => {
- describe('addImageUploadButton', () => {
- const mockOpenModal = jest.fn();
- const mockAddbutton = jest.fn(val => ({ onAction: val }));
- const editor = {
- ui: {
- registry: {
- addButton: mockAddbutton,
- },
+ const mockOpenModal = jest.fn();
+ const mockAddbutton = jest.fn(val => ({ onAction: val }));
+ const mockNode = {
+ src: 'sOmEuRl.cOm',
+ alt: 'aLt tExt',
+ width: 2022,
+ height: 1619,
+ };
+ const editor = {
+ ui: {
+ registry: {
+ addButton: mockAddbutton,
},
- };
+ },
+ selection: {
+ getNode: () => mockNode,
+ },
+ };
+ describe('openModalWithSelectedImage', () => {
+ const mockSetImage = jest.fn();
let output;
beforeEach(() => {
- output = module.addImageUploadButton(mockOpenModal);
+ output = module.openModalWithSelectedImage(editor, mockSetImage, mockOpenModal);
+ });
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+ test('image is set to be value stored in editor, modal is opened', () => {
+ output();
+ expect(mockSetImage).toHaveBeenCalledWith({
+ externalUrl: mockNode.src,
+ altText: mockNode.alt,
+ width: mockNode.width,
+ height: mockNode.height,
+ });
+ expect(mockOpenModal).toHaveBeenCalled();
+ });
+ });
+ describe('addImageUploadBehavior', () => {
+ const mockSetImage = jest.fn();
+ let output;
+ const mockHookResult = jest.fn();
+ beforeEach(() => {
+ output = module.addImageUploadBehavior({ openModal: mockOpenModal, setImage: mockSetImage });
+ });
+ afterEach(() => {
+ jest.clearAllMocks();
});
test('It calls addButton in the editor, but openModal is not called', () => {
+ jest.spyOn(module, 'openModalWithSelectedImage').mockImplementationOnce(() => mockHookResult);
output(editor);
- expect(mockAddbutton).toHaveBeenCalledWith('imageuploadbutton', { icon: 'image', onAction: mockOpenModal });
+ expect(mockAddbutton.mock.calls).toEqual([
+ ['imageuploadbutton', { icon: 'image', onAction: mockOpenModal }],
+ ['editimagesettings', { icon: 'image', onAction: mockHookResult }],
+ ]);
expect(mockOpenModal).not.toHaveBeenCalled();
});
});
describe('initializeEditorRef', () => {
const mockSetRef = jest.fn(val => ({ editor: val }));
- const editor = {
- editme: 'MakE sOMe Text',
- };
- const evt = {
- garbage: 'fOr TInYmCE',
- };
+ const mockInitializeEditor = jest.fn();
let output;
beforeEach(() => {
- output = module.initializeEditorRef(mockSetRef);
+ output = module.initializeEditorRef(mockSetRef, mockInitializeEditor);
});
test('It calls setref with editor as params', () => {
- output(evt, editor);
+ output(editor);
expect(mockSetRef).toHaveBeenCalledWith(editor);
+ expect(mockInitializeEditor).toHaveBeenCalled();
});
});
describe('editorConfig', () => {
@@ -83,6 +117,39 @@ describe('TextEditor hooks', () => {
output = module.editorConfig(newprops);
expect(output.initialValue).toBe(htmltext);
});
+ test('It configures plugins and toolbars correctly', () => {
+ output = module.editorConfig(props);
+ Object.values(module.pluginConfig.plugins).forEach(
+ value => expect(output.init.plugins.includes(value)).toBe(true),
+ );
+ Object.values(module.pluginConfig.toolbar).forEach(
+ value => expect(output.init.toolbar.includes(value)).toBe(true),
+ );
+ Object.values(module.pluginConfig.imageToolbar).forEach(
+ value => expect(output.init.imagetools_toolbar.includes(value)).toBe(true),
+ );
+ expect(output.init.menubar).toBe(false);
+ expect(output.init.imagetools_cors_hosts).toMatchObject(['courses.edx.org']);
+ expect(output.init.height).toBe('100%');
+ expect(output.init.min_height).toBe(1000);
+ expect(output.init.branding).toBe(false);
+ });
+ });
+ });
+ describe('selectedImage', () => {
+ const val = { a: 'VaLUe' };
+ const newVal = { some: 'vAlUe' };
+ let output;
+ let setter;
+ beforeEach(() => {
+ [output, setter] = module.selectedImage(val);
+ });
+ test('returns a field which with state input val', () => {
+ expect(output).toMatchObject({ state: val });
+ });
+ test('calling setter with new val sets with respect to new val', () => {
+ setter(newVal);
+ expect(React.updateState).toHaveBeenCalledWith({ val, newVal });
});
});
describe('modalToggle hook', () => {
diff --git a/www/package-lock.json b/www/package-lock.json
index c43199c5a..dd8a4d9f9 100644
--- a/www/package-lock.json
+++ b/www/package-lock.json
@@ -2419,7 +2419,7 @@
"integrity": "sha512-ws57AidsDvREKrZKYffXddNkyaF14iHNHm8VQnZH6t99E8gczjNN0GpvcGny0imC80yQ0tHz1xVUKk/KFQSUyA=="
},
"@edx/brand": {
- "version": "npm:@edx/brand-openedx@1.1.0",
+ "version": "npm:@edx/brand@1.1.0",
"resolved": "https://registry.npmjs.org/@edx/brand-openedx/-/brand-openedx-1.1.0.tgz",
"integrity": "sha512-ne2ZKF1r0akkt0rEzCAQAk4cTDTI2GiWCpc+T7ldQpw9X57OnUB16dKsFNe40C9uEjL5h3Ps/ZsFM5dm4cIkEQ=="
},