feat: insert images into text editor html (#29)

This work adds the functionality that when an image is selected ineither the select image step of the image upload modal, or by using the toolbar inside tinymce, the requisite image is loaded into the settings page, and on the click of the save button, it is inserted into tinymce html.
This commit is contained in:
connorhaugh
2022-03-15 10:53:56 -04:00
committed by GitHub
parent 5258e93972
commit 4ae2d1230b
13 changed files with 350 additions and 65 deletions

2
package-lock.json generated
View File

@@ -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

View File

@@ -54,7 +54,7 @@ export const Editor = ({
<>
<EditorHeader editorRef={editorRef} />
{(EditorComponent !== undefined)
? <EditorComponent {...{ setEditorRef }} />
? <EditorComponent {...{ setEditorRef, editorRef }} />
: <FormattedMessage {...messages.couldNotFindEditor} />}
<EditorFooter editorRef={editorRef} />
</>

View File

@@ -49,6 +49,11 @@ exports[`Editor snapshots renders "html" editor when ref is ready 1`] = `
}
/>
<TextEditor
editorRef={
Object {
"current": "ref",
}
}
setEditorRef={[MockFunction setEditorRef]}
/>
<EditorFooter

View File

@@ -15,6 +15,8 @@ import 'tinymce/plugins/emoticons/js/emojis';
import 'tinymce/plugins/charmap';
import 'tinymce/plugins/code';
import 'tinymce/plugins/autoresize';
import 'tinymce/plugins/image';
import 'tinymce/plugins/imagetools';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import {
@@ -28,12 +30,14 @@ import {
editorConfig,
modalToggle,
nullMethod,
selectedImage,
} from './hooks';
import messages from './messages';
import ImageUploadModal from './components/ImageUploadModal';
export const TextEditor = ({
setEditorRef,
editorRef,
// redux
blockValue,
blockFailed,
@@ -42,11 +46,18 @@ export const TextEditor = ({
}) => {
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 (
<div className="editor-body h-75">
<ImageUploadModal
isOpen={isOpen}
close={closeModal}
editorRef={editorRef}
selection={imageSelection}
setSelection={setImageSelection}
/>
<Toast show={blockFailed} onClose={nullMethod}>
@@ -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({

View File

@@ -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,

View File

@@ -6,7 +6,20 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
>
<ImageUploadModal
close={[MockFunction modal.closeModal]}
editorRef={
Object {
"current": Object {
"value": "something",
},
}
}
isOpen={false}
selection={
Object {
"state": null,
}
}
setSelection={[MockFunction setSelection]}
/>
<Toast
onClose={[MockFunction nullMethod]}
@@ -27,6 +40,7 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
"initializeEditor": [MockFunction args.intializeEditor],
"openModal": [MockFunction modal.openModal],
"setEditorRef": [MockFunction args.setEditorRef],
"setSelection": [MockFunction setSelection],
}
}
/>
@@ -39,7 +53,20 @@ exports[`TextEditor snapshots not yet loaded, Spinner appears 1`] = `
>
<ImageUploadModal
close={[MockFunction modal.closeModal]}
editorRef={
Object {
"current": Object {
"value": "something",
},
}
}
isOpen={false}
selection={
Object {
"state": null,
}
}
setSelection={[MockFunction setSelection]}
/>
<Toast
onClose={[MockFunction nullMethod]}
@@ -69,7 +96,20 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
>
<ImageUploadModal
close={[MockFunction modal.closeModal]}
editorRef={
Object {
"current": Object {
"value": "something",
},
}
}
isOpen={false}
selection={
Object {
"state": null,
}
}
setSelection={[MockFunction setSelection]}
/>
<Toast
onClose={[MockFunction nullMethod]}
@@ -90,6 +130,7 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
"initializeEditor": [MockFunction args.intializeEditor],
"openModal": [MockFunction modal.openModal],
"setEditorRef": [MockFunction args.setEditorRef],
"setSelection": [MockFunction setSelection],
}
}
/>

View File

@@ -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 && (
<Form.Group>
<Form.Label>Image Dimensions</Form.Label>
@@ -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,

View File

@@ -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 }) => `<img src="${selection.externalUrl}" alt="${settings.isDecorative ? '' : settings.altText}" width="${settings.dimensions.width}" height="${settings.dimensions.height}">`,
};
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([

View File

@@ -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(`<img src="${mockSelection.externalUrl}" alt="${mockSettings.altText}" width="${mockSettings.dimensions.width}" height="${mockSettings.dimensions.height}">`);
});
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(`<img src="${mockSelection.externalUrl}" alt="" width="${mockSettings.dimensions.width}" height="${mockSettings.dimensions.height}">`);
});
});
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();
});
});
});

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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', () => {

2
www/package-lock.json generated
View File

@@ -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=="
},