From 4ae2d1230b830f8ee2f6fbca4a605dec720104ff Mon Sep 17 00:00:00 2001 From: connorhaugh <49422820+connorhaugh@users.noreply.github.com> Date: Tue, 15 Mar 2022 10:53:56 -0400 Subject: [PATCH] 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. --- package-lock.json | 2 +- src/editors/Editor.jsx | 2 +- .../__snapshots__/Editor.test.jsx.snap | 5 + .../containers/TextEditor/TextEditor.jsx | 17 +++ .../containers/TextEditor/TextEditor.test.jsx | 30 ++++- .../__snapshots__/TextEditor.test.jsx.snap | 41 +++++++ .../components/ImageSettingsModal/index.jsx | 18 +-- .../components/ImageUploadModal.jsx | 36 ++++-- .../components/ImageUploadModal.test.jsx | 68 ++++++++++++ .../components/SelectImageModal.jsx | 5 +- src/editors/containers/TextEditor/hooks.js | 84 +++++++++++--- .../containers/TextEditor/hooks.test.jsx | 105 ++++++++++++++---- www/package-lock.json | 2 +- 13 files changed, 350 insertions(+), 65 deletions(-) create mode 100644 src/editors/containers/TextEditor/components/ImageUploadModal.test.jsx 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 }) => `${settings.isDecorative ? '' : settings.altText}`, +}; 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(`${mockSettings.altText}`); + }); + 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==" },