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