diff --git a/jest.config.js b/jest.config.js index cfe4bb8fc..41fb4a75a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,9 @@ const { createConfig } = require('@edx/frontend-build'); module.exports = createConfig('jest', { + roots: [ + '/src', + ], setupFiles: [ '/src/setupTest.js', ], diff --git a/src/editors/containers/TextEditor/TextEditor.jsx b/src/editors/containers/TextEditor/TextEditor.jsx index 24326b639..6f32cdf5b 100644 --- a/src/editors/containers/TextEditor/TextEditor.jsx +++ b/src/editors/containers/TextEditor/TextEditor.jsx @@ -32,8 +32,8 @@ import { nullMethod, selectedImage, } from './hooks'; -import messages from './messages'; import ImageUploadModal from './components/ImageUploadModal'; +import messages from './messages'; export const TextEditor = ({ setEditorRef, diff --git a/src/editors/containers/TextEditor/TextEditor.test.jsx b/src/editors/containers/TextEditor/TextEditor.test.jsx index 02484b89b..c2e260927 100644 --- a/src/editors/containers/TextEditor/TextEditor.test.jsx +++ b/src/editors/containers/TextEditor/TextEditor.test.jsx @@ -17,8 +17,6 @@ jest.mock('@tinymce/tinymce-react', () => { }; }); jest.mock('./components/ImageUploadModal', () => 'ImageUploadModal'); -jest.mock('./components/SelectImageModal', () => 'SelectImageModal'); -jest.mock('./components/ImageSettingsModal', () => 'ImageSettingsModal'); jest.mock('./hooks', () => { const updateState = jest.fn(); @@ -54,11 +52,6 @@ jest.mock('../../data/redux', () => ({ isFinished: jest.fn((state, params) => ({ isFailed: { state, params } })), }, }, - thunkActions: { - app: { - fetchImages: jest.fn().mockName('actions.app.fetchImages'), - }, - }, })); describe('TextEditor', () => { @@ -66,7 +59,7 @@ describe('TextEditor', () => { setEditorRef: jest.fn().mockName('args.setEditorRef'), editorRef: { current: { value: 'something' } }, // redux - blockValue: { data: 'eDiTablE Text' }, + blockValue: { data: { some: 'eDiTablE Text' } }, blockFailed: false, blockFinished: true, initializeEditor: jest.fn().mockName('args.intializeEditor'), diff --git a/src/editors/containers/TextEditor/__snapshots__/TextEditor.test.jsx.snap b/src/editors/containers/TextEditor/__snapshots__/TextEditor.test.jsx.snap index 448a0b609..46192d13b 100644 --- a/src/editors/containers/TextEditor/__snapshots__/TextEditor.test.jsx.snap +++ b/src/editors/containers/TextEditor/__snapshots__/TextEditor.test.jsx.snap @@ -35,7 +35,9 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = ` editorConfig={ Object { "blockValue": Object { - "data": "eDiTablE Text", + "data": Object { + "some": "eDiTablE Text", + }, }, "initializeEditor": [MockFunction args.intializeEditor], "openModal": [MockFunction modal.openModal], @@ -125,7 +127,9 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = ` editorConfig={ Object { "blockValue": Object { - "data": "eDiTablE Text", + "data": Object { + "some": "eDiTablE Text", + }, }, "initializeEditor": [MockFunction args.intializeEditor], "openModal": [MockFunction modal.openModal], diff --git a/src/editors/containers/TextEditor/components/ImageSettingsModal/AltTextControls.jsx b/src/editors/containers/TextEditor/components/ImageSettingsModal/AltTextControls.jsx new file mode 100644 index 000000000..60f38014e --- /dev/null +++ b/src/editors/containers/TextEditor/components/ImageSettingsModal/AltTextControls.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Form } from '@edx/paragon'; + +import * as hooks from './hooks'; + +/** + * Wrapper for alt-text input and isDecorative checkbox control + * @param {bool} isDecorative - is the image decorative? + * @param {func} setIsDecorative - handle isDecorative change event + * @param {func} setValue - update alt-text value + * @param {string} value - current alt-text value + */ +export const AltTextControls = ({ + isDecorative, + setIsDecorative, + setValue, + value, +}) => ( + + Accessibility + + + + This image is decorative (no alt text required). + + + +); +AltTextControls.propTypes = { + isDecorative: PropTypes.bool.isRequired, + value: PropTypes.string.isRequired, + setValue: PropTypes.func.isRequired, + setIsDecorative: PropTypes.func.isRequired, +}; + +export default AltTextControls; diff --git a/src/editors/containers/TextEditor/components/ImageSettingsModal/AltTextControls.test.jsx b/src/editors/containers/TextEditor/components/ImageSettingsModal/AltTextControls.test.jsx new file mode 100644 index 000000000..a0e3d06df --- /dev/null +++ b/src/editors/containers/TextEditor/components/ImageSettingsModal/AltTextControls.test.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import AltTextControls from './AltTextControls'; + +jest.mock('./hooks', () => ({ + onInputChange: (handler) => ({ 'hooks.onInputChange': handler }), + onCheckboxChange: (handler) => ({ 'hooks.onCheckboxChange': handler }), +})); + +describe('AltTextControls', () => { + const props = { + isDecorative: true, + value: 'props.value', + }; + beforeEach(() => { + props.setValue = jest.fn().mockName('props.setValue'); + props.setIsDecorative = jest.fn().mockName('props.setIsDecorative'); + }); + describe('render', () => { + test('snapshot: isDecorative=true', () => { + expect(shallow()).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/TextEditor/components/ImageSettingsModal/DimensionControls.jsx b/src/editors/containers/TextEditor/components/ImageSettingsModal/DimensionControls.jsx new file mode 100644 index 000000000..ed0b6af87 --- /dev/null +++ b/src/editors/containers/TextEditor/components/ImageSettingsModal/DimensionControls.jsx @@ -0,0 +1,88 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Form, + Icon, + IconButton, +} from '@edx/paragon'; +import { + Locked, + Unlocked, +} from '@edx/paragon/icons'; + +import hooks from './hooks'; + +/** + * Wrapper for image dimension inputs and the lock checkbox. + * @param {obj} locked - locked dimension object + * @param {func} lock - lock dimensions + * @param {func} setHeight - updates dimensions based on new height + * @param {func} setWidth - updates dimensions based on new width + * @param {func} unlock - unlock dimensions + * @param {func} updateDimensions - update dimensions callback + * @param {obj} value - local dimension values { height, width } + */ +export const DimensionControls = ({ + locked, + lock, + setHeight, + setWidth, + unlock, + updateDimensions, + value, +}) => ((value !== null) && ( + + Image Dimensions +
+ + + +
+
+)); +DimensionControls.defaultProps = { + locked: null, + value: { + height: 100, + width: 100, + }, +}; +DimensionControls.propTypes = ({ + value: PropTypes.shape({ + height: PropTypes.number, + width: PropTypes.number, + }), + setHeight: PropTypes.func.isRequired, + setWidth: PropTypes.func.isRequired, + locked: PropTypes.shape({ + width: PropTypes.number, + height: PropTypes.number, + }), + lock: PropTypes.func.isRequired, + unlock: PropTypes.func.isRequired, + updateDimensions: PropTypes.func.isRequired, +}); + +export default DimensionControls; diff --git a/src/editors/containers/TextEditor/components/ImageSettingsModal/DimensionControls.test.jsx b/src/editors/containers/TextEditor/components/ImageSettingsModal/DimensionControls.test.jsx new file mode 100644 index 000000000..8c3a0ed5b --- /dev/null +++ b/src/editors/containers/TextEditor/components/ImageSettingsModal/DimensionControls.test.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import DimensionControls from './DimensionControls'; + +jest.mock('./hooks', () => ({ + onInputChange: (handler) => ({ 'hooks.onInputChange': handler }), +})); + +describe('DimensionControls', () => { + const props = { + locked: { 'props.locked': 'lockedValue' }, + value: { width: 20, height: 40 }, + }; + beforeEach(() => { + props.setWidth = jest.fn().mockName('props.setWidth'); + props.setHeight = jest.fn().mockName('props.setHeight'); + props.lock = jest.fn().mockName('props.lock'); + props.unlock = jest.fn().mockName('props.unlock'); + props.updateDimensions = jest.fn().mockName('props.updateDimensions'); + }); + describe('render', () => { + test('snapshot', () => { + expect(shallow()).toMatchSnapshot(); + }); + test('null value: empty snapshot', () => { + const el = shallow(); + expect(el).toMatchSnapshot(); + expect(el.isEmptyRender()).toEqual(true); + }); + }); +}); diff --git a/src/editors/containers/TextEditor/components/ImageSettingsModal/__snapshots__/AltTextControls.test.jsx.snap b/src/editors/containers/TextEditor/components/ImageSettingsModal/__snapshots__/AltTextControls.test.jsx.snap new file mode 100644 index 000000000..29af0500f --- /dev/null +++ b/src/editors/containers/TextEditor/components/ImageSettingsModal/__snapshots__/AltTextControls.test.jsx.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AltTextControls render snapshot: isDecorative=true 1`] = ` + + + Accessibility + + + + + This image is decorative (no alt text required). + + + +`; diff --git a/src/editors/containers/TextEditor/components/ImageSettingsModal/__snapshots__/DimensionControls.test.jsx.snap b/src/editors/containers/TextEditor/components/ImageSettingsModal/__snapshots__/DimensionControls.test.jsx.snap new file mode 100644 index 000000000..c1ef476fb --- /dev/null +++ b/src/editors/containers/TextEditor/components/ImageSettingsModal/__snapshots__/DimensionControls.test.jsx.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DimensionControls render null value: empty snapshot 1`] = `""`; + +exports[`DimensionControls render snapshot 1`] = ` + + + Image Dimensions + +
+ + + +
+
+`; diff --git a/src/editors/containers/TextEditor/components/ImageSettingsModal/__snapshots__/index.test.jsx.snap b/src/editors/containers/TextEditor/components/ImageSettingsModal/__snapshots__/index.test.jsx.snap new file mode 100644 index 000000000..af2f553f2 --- /dev/null +++ b/src/editors/containers/TextEditor/components/ImageSettingsModal/__snapshots__/index.test.jsx.snap @@ -0,0 +1,108 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ImageSettingsModal render snapshot 1`] = ` + + Save + + } + isOpen={false} + title="Image Settings" +> + +
+
+
+ +
+
+
+ + +
+
+
+`; diff --git a/src/editors/containers/TextEditor/components/ImageSettingsModal/hooks.js b/src/editors/containers/TextEditor/components/ImageSettingsModal/hooks.js new file mode 100644 index 000000000..3bbf66f5a --- /dev/null +++ b/src/editors/containers/TextEditor/components/ImageSettingsModal/hooks.js @@ -0,0 +1,251 @@ +import React from 'react'; + +import { StrictDict } from '../../../../utils'; +import * as module from './hooks'; + +// Simple wrappers for useState to allow easy mocking for tests. +export const state = { + dimensions: (val) => React.useState(val), + locked: (val) => React.useState(val), + local: (val) => React.useState(val), + lockInitialized: (val) => React.useState(val), + altText: (val) => React.useState(val), + isDecorative: (val) => React.useState(val), +}; + +export const dimKeys = StrictDict({ + height: 'height', + width: 'width', +}); + +/** + * findGcd(numerator, denominator) + * Find the greatest common denominator of a ratio or fraction. + * @param {number} numerator - ratio numerator + * @param {number} denominator - ratio denominator + * @return {number} - ratio greatest common denominator + */ +export const findGcd = (a, b) => (b ? findGcd(b, a % b) : a); +const checkEqual = (d1, d2) => (d1.height === d2.height && d1.width === d2.width); + +/** + * getValidDimensions({ dimensions, local, locked }) + * Find valid ending dimensions based on start state, request, and lock state + * @param {obj} dimensions - current stored dimensions + * @param {obj} local - local (active) dimensions in the inputs + * @param {obj} locked - locked dimensions + * @return {obj} - output dimensions after move ({ height, width }) + */ +export const getValidDimensions = ({ + dimensions, + local, + locked, +}) => { + const out = {}; + let iter; + const { minInc } = locked; + const isMin = dimensions.height === minInc.height; + + const keys = (local.height !== dimensions.height) + ? { changed: dimKeys.height, other: dimKeys.width } + : { changed: dimKeys.width, other: dimKeys.height }; + + const direction = local[keys.changed] > dimensions[keys.changed] ? 1 : -1; + + // don't move down if already at minimum size + if (direction < 0 && isMin) { return dimensions; } + // find closest valid iteration of the changed field + iter = Math.max(Math.round(local[keys.changed] / minInc[keys.changed]), 1); + // if closest valid iteration is current iteration, move one iteration in the change direction + if (iter === (dimensions[keys.changed] / minInc[keys.changed])) { iter += direction; } + + out[keys.changed] = iter * minInc[keys.changed]; + out[keys.other] = out[keys.changed] * (locked[keys.other] / locked[keys.changed]); + + return out; +}; + +/** + * newDimensions({ dimensions, local, locked }) + * Returns the local dimensions if unlocked or unchanged, and otherwise returns new valid + * dimensions. + * @param {obj} dimensions - current stored dimensions + * @param {obj} local - local (active) dimensions in the inputs + * @param {obj} locked - locked dimensions + * @return {obj} - output dimensions after attempted move ({ height, width }) + */ +export const newDimensions = ({ dimensions, local, locked }) => ( + (!locked || checkEqual(local, dimensions)) + ? local + : module.getValidDimensions({ dimensions, local, locked }) +); + +/** + * lockDimensions({ dimensions, lockInitialized, setLocked }) + * Lock dimensions if lock initialized. Store minimum valid increment on lock so + * that we don't have re-compute. + * @param {obj} dimensions - current stored dimensions + * @param {bool} lockInitialized - has the lock state initialized? + * @param {func} setLocked - set lock state + */ +export const lockDimensions = ({ dimensions, lockInitialized, setLocked }) => { + if (!lockInitialized) { return; } + + // find minimum viable increment + let gcd = findGcd(dimensions.width, dimensions.height); + if ([dimensions.width, dimensions.height].some(v => !Number.isInteger(v / gcd))) { + gcd = 1; + } + const minInc = { width: dimensions.width / gcd, height: dimensions.height / gcd, gcd }; + setLocked({ ...dimensions, minInc }); +}; + +/** + * dimensionLockHooks({ dimensions }) + * Returns a set of hooks pertaining to the dimension locks. + * Locks the dimensions initially, on lock initialization. + * @param {obj} dimensions - current stored dimensions + * @return {obj} - dimension lock hooks + * {func} initializeLock - enable the lock mechanism + * {obj} locked - current locked state + * {func} lock - lock the current dimensions + * {func} unlock - unlock the dimensions + */ +export const dimensionLockHooks = ({ dimensions }) => { + const [locked, setLocked] = module.state.locked(null); + const [lockInitialized, setLockInitialized] = module.state.lockInitialized(null); + const lock = () => module.lockDimensions({ lockInitialized, dimensions, setLocked }); + + React.useEffect(lock, [lockInitialized]); + + return { + initializeLock: () => setLockInitialized(true), + locked, + lock, + unlock: () => setLocked(null), + }; +}; + +/** + * dimensionHooks() + * Returns an object of dimension-focused react hooks. + * @return {obj} - dimension hooks + * {func} onImgLoad - initializes image dimension fields + * @param {object} selection - selected image object with possible override dimensions. + * @return {callback} - image load event callback that loads dimensions. + * {object} locked - current locked state + * {func} lock - lock current dimensions + * {func} unlock - unlock dimensions + * {object} value - current dimension values + * {func} setHeight - set height + * @param {string} - new height string + * {func} setWidth - set width + * @param {string} - new width string + * {func} updateDimensions - set dimensions based on state + */ +export const dimensionHooks = () => { + const [dimensions, setDimensions] = module.state.dimensions(null); + const [local, setLocal] = module.state.local(null); + const setAll = (value) => { + setDimensions(value); + setLocal(value); + }; + const { + initializeLock, + lock, + locked, + unlock, + } = module.dimensionLockHooks({ dimensions }); + return { + onImgLoad: (selection) => ({ target: img }) => { + setAll({ + height: selection.height || img.naturalHeight, + width: selection.width || img.naturalWidth, + }); + initializeLock(); + }, + locked, + lock, + unlock, + value: local, + setHeight: (height) => setLocal({ ...local, height: parseInt(height, 10) }), + setWidth: (width) => setLocal({ ...local, width: parseInt(width, 10) }), + updateDimensions: () => setAll(module.newDimensions({ dimensions, local, locked })), + }; +}; + +/** + * altTextHooks(savedText) + * Returns a set of react hooks focused around alt text + * @return {obj} - alt text hooks + * {string} value - alt text value + * {func} setValue - set alt test value + * @param {string} - new alt text + * {bool} isDecorative - is the image decorative? + * {func} setIsDecorative - set isDecorative field + * @param {bool} isDecorative + */ +export const altTextHooks = (savedText) => { + const [value, setValue] = module.state.altText(savedText || ''); + const [isDecorative, setIsDecorative] = module.state.isDecorative(false); + return { + value, + setValue, + isDecorative, + setIsDecorative, + }; +}; + +/** + * onInputChange(handleValue) + * Simple event handler forwarding the event target value to a given callback + * @param {func} handleValue - event value handler + * @return {func} - evt callback that will call handleValue with the event target value. + */ +export const onInputChange = (handleValue) => (e) => handleValue(e.target.value); + +/** + * onCheckboxChange(handleValue) + * Simple event handler forwarding the event target checked prop to a given callback + * @param {func} handleValue - event value handler + * @return {func} - evt callback that will call handleValue with the event target checked prop. + */ +export const onCheckboxChange = (handleValue) => (e) => handleValue(e.target.checked); + +/** + * onSave({ altText, dimensions, isDecorative, saveToEditor }) + * Handle saving the image context to the text editor + * @param {string} altText - image alt text + * @param {object} dimension - image dimensions ({ width, height }) + * @param {bool} isDecorative - is the image decorative? + * @param {func} saveToEditor - save method for submitting image settings. + */ +export const onSaveClick = ({ + altText, + dimensions, + isDecorative, + saveToEditor, +}) => () => saveToEditor({ + altText, + dimensions, + isDecorative, +}); + +/** + * isSaveDisabled(altText) + * Returns true the save button should be disabled (altText is missing and not decorative) + * @param {object} altText - altText hook object + * {bool} isDecorative - is the image decorative? + * {string} value - alt text value + * @return {bool} - should the save button be disabled? + */ +export const isSaveDisabled = (altText) => !altText.isDecorative && (altText.value === ''); + +export default { + altText: altTextHooks, + dimensions: dimensionHooks, + isSaveDisabled, + onCheckboxChange, + onInputChange, + onSaveClick, +}; diff --git a/src/editors/containers/TextEditor/components/ImageSettingsModal/hooks.test.js b/src/editors/containers/TextEditor/components/ImageSettingsModal/hooks.test.js new file mode 100644 index 000000000..9bb687348 --- /dev/null +++ b/src/editors/containers/TextEditor/components/ImageSettingsModal/hooks.test.js @@ -0,0 +1,320 @@ +import { StrictDict } from '../../../../utils'; +import * as hooks from './hooks'; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useEffect: jest.fn(), +})); + +const simpleDims = { width: 3, height: 4 }; +const reducedDims = { width: 7, height: 13 }; +const gcd = 7; +const multiDims = { + width: reducedDims.width * gcd, + height: reducedDims.height * gcd, +}; + +const stateKeys = StrictDict(Object.keys(hooks.state).reduce( + (obj, key) => ({ ...obj, [key]: key }), + {}, +)); + +const hookKeys = StrictDict(Object.keys(hooks).reduce( + (obj, key) => ({ ...obj, [key]: key }), + {}, +)); + +let oldState; +const setState = {}; +const mockState = () => { + oldState = hooks.state; + const keys = Object.keys(stateKeys); + keys.forEach(key => { + setState[key] = jest.fn(); + hooks.state[key] = jest.fn(val => [val, setState[key]]); + }); +}; +const restoreState = () => { + hooks.state = { ...oldState }; +}; + +const mockStateVal = (key, val) => ( + hooks.state[key].mockReturnValueOnce([val, setState[key]]) +); + +let hook; + +describe('ImageSettingsModal hooks', () => { + describe('dimensions-related hooks', () => { + describe('getValidDimensions', () => { + describe('decreasing change when at minimum valid increment', () => { + it('returns current dimensions', () => { + const dimensions = { ...reducedDims }; + const locked = { minInc: { ...dimensions, gcd } }; + let local = { ...dimensions, width: dimensions.width - 1 }; + expect( + hooks.getValidDimensions({ dimensions, local, locked }), + ).toEqual(dimensions); + local = { ...dimensions, height: dimensions.height - 1 }; + expect( + hooks.getValidDimensions({ dimensions, local, locked }), + ).toEqual(dimensions); + }); + }); + describe('valid change', () => { + it( + 'returns the nearest valid pair of dimensions in the change direciton', + () => { + const w = 7; + const h = 13; + const values = [ + // bumps up if direction is up but nearest is current + [[w + 1, h], [w * 2, h * 2]], + [[w + 1, h], [w * 2, h * 2]], + // bumps up if just below next + [[w, 2 * h - 1], [w * 2, h * 2]], + [[w, 2 * h - 1], [w * 2, h * 2]], + // rounds down to next if that is closest + [[w, 2 * h + 1], [w * 2, h * 2]], + [[w, 2 * h + 1], [w * 2, h * 2]], + // ensure is not locked to second iteration, by getting close to 3rd + [[w, 3 * h - 1], [w * 3, h * 3]], + [[w, 3 * h - 1], [w * 3, h * 3]], + ]; + values.forEach(([local, expected]) => { + const dimensions = { width: w, height: h }; + expect(hooks.getValidDimensions({ + dimensions, + local: { width: local[0], height: local[1] }, + locked: { ...dimensions, minInc: { ...dimensions, gcd: 1 } }, + })).toEqual({ width: expected[0], height: expected[1] }); + }); + }, + ); + }); + }); + describe('newDimensions', () => { + it('returns the local values if not locked, or if local is equal to dimensions', () => { + expect(hooks.newDimensions({ + dimensions: { ...simpleDims }, + local: { ...simpleDims }, + locked: { ...simpleDims }, + })).toEqual({ ...simpleDims }); + expect(hooks.newDimensions({ + dimensions: { ...simpleDims }, + local: { ...reducedDims }, + locked: null, + })).toEqual({ ...reducedDims }); + }); + it('returns getValidDimensions if locked and local has changed', () => { + const getValidDimensions = (args) => ({ getValidDimensions: args }); + jest.spyOn(hooks, hookKeys.getValidDimensions).mockImplementationOnce(getValidDimensions); + const args = { + dimensions: { ...simpleDims }, + local: { ...multiDims }, + locked: { ...reducedDims }, + }; + expect(hooks.newDimensions(args)).toEqual(getValidDimensions(args)); + }); + }); + describe('lockDimensions', () => { + it('does not call setLocked if lockInitialized is false', () => { + setState.locked = jest.fn(); + hooks.lockDimensions({ + dimensions: simpleDims, + setLocked: setState.locked, + lockInitialized: false, + }); + expect(setState.locked).not.toHaveBeenCalled(); + }); + it( + 'calls setLocked with the given dimensions and minInc, including gcd', + () => { + setState.locked = jest.fn(); + hooks.lockDimensions({ + dimensions: simpleDims, + setLocked: setState.locked, + lockInitialized: true, + }); + expect(setState.locked).toHaveBeenCalledWith({ + ...simpleDims, + minInc: { gcd: 1, ...simpleDims }, + }); + setState.locked.mockClear(); + + hooks.lockDimensions({ + dimensions: multiDims, + setLocked: setState.locked, + lockInitialized: true, + }); + expect(hooks.findGcd(multiDims.width, multiDims.height)).toEqual(7); + expect(setState.locked).toHaveBeenCalledWith({ + ...multiDims, + minInc: { gcd, ...reducedDims }, + }); + }, + ); + }); + describe('dimensionLockHooks', () => { + beforeEach(() => { + mockState(); + hook = hooks.dimensionLockHooks({ dimensions: simpleDims }); + }); + afterEach(() => { + restoreState(); + }); + + test('locked is initially null', () => { + expect(hook.locked).toEqual(null); + }); + test('initializeLock calls setLockInitialized with true', () => { + hook.initializeLock(); + expect(setState.lockInitialized).toHaveBeenCalledWith(true); + }); + test('lock calls lockDimensions with lockInitialized, dimensions, and setLocked', () => { + mockStateVal(stateKeys.lockInitialized, true, setState.lockInitialized); + hook = hooks.dimensionLockHooks({ dimensions: simpleDims }); + const lockDimensionsSpy = jest.spyOn(hooks, hookKeys.lockDimensions); + hook.lock(); + expect(lockDimensionsSpy).toHaveBeenCalledWith({ + dimensions: simpleDims, + setLocked: setState.locked, + lockInitialized: true, + }); + }); + test('unlock sets locked to null', () => { + hook = hooks.dimensionLockHooks({ dimensions: simpleDims }); + hook.unlock(); + expect(setState.locked).toHaveBeenCalledWith(null); + }); + }); + describe('dimensionHooks', () => { + let lockHooks; + beforeEach(() => { + mockState(); + lockHooks = { + initializeLock: jest.fn(), + lock: jest.fn(), + unlock: jest.fn(), + locked: { ...reducedDims }, + }; + jest.spyOn(hooks, hookKeys.dimensionLockHooks).mockReturnValueOnce(lockHooks); + hook = hooks.dimensionHooks(); + }); + afterEach(() => { + restoreState(); + }); + it('initializes dimension lock hooks with incoming dimension value', () => { + mockStateVal(stateKeys.dimensions, reducedDims, setState.local); + hook = hooks.dimensionHooks(); + expect(hooks.dimensionLockHooks).toHaveBeenCalledWith({ dimensions: reducedDims }); + }); + test('value is tied to local state', () => { + mockStateVal(stateKeys.local, simpleDims, setState.local); + hook = hooks.dimensionHooks(); + expect(hook.value).toEqual(simpleDims); + }); + describe('onImgLoad', () => { + const img = { naturalHeight: 200, naturalWidth: 345 }; + const evt = { target: img }; + it('calls initializeDimensions with selection dimensions if passed', () => { + hook.onImgLoad(simpleDims)(evt); + expect(setState.dimensions).toHaveBeenCalledWith(simpleDims); + expect(setState.local).toHaveBeenCalledWith(simpleDims); + }); + it('calls initializeDimensions with target image dimensions if no selection', () => { + hook.onImgLoad({})(evt); + const expected = { width: img.naturalWidth, height: img.naturalHeight }; + expect(setState.dimensions).toHaveBeenCalledWith(expected); + expect(setState.local).toHaveBeenCalledWith(expected); + }); + it('calls initializeLock', () => { + const initializeDimensions = jest.fn(); + hook.onImgLoad(initializeDimensions, simpleDims)(evt); + expect(lockHooks.initializeLock).toHaveBeenCalled(); + }); + }); + describe('setHeight', () => { + it('sets local height to int value of argument', () => { + mockStateVal(stateKeys.local, simpleDims, setState.local); + hooks.dimensionHooks().setHeight('23.4'); + expect(setState.local).toHaveBeenCalledWith({ ...simpleDims, height: 23 }); + }); + }); + describe('setWidth', () => { + it('sets local width to int value of argument', () => { + mockStateVal(stateKeys.local, simpleDims, setState.local); + hooks.dimensionHooks().setWidth('34.5'); + expect(setState.local).toHaveBeenCalledWith({ ...simpleDims, width: 34 }); + }); + }); + describe('updateDimensions', () => { + it('sets local and stored dimensions to newDimensions output', () => { + const newDimensions = (args) => ({ newDimensions: args }); + mockStateVal(stateKeys.dimensions, simpleDims, setState.dimensions); + mockStateVal(stateKeys.locked, reducedDims, setState.locked); + mockStateVal(stateKeys.local, multiDims, setState.local); + jest.spyOn(hooks, hookKeys.newDimensions).mockImplementationOnce(newDimensions); + hook = hooks.dimensionHooks(); + hook.updateDimensions(); + const expected = newDimensions({ + dimensions: simpleDims, + locked: reducedDims, + local: multiDims, + }); + expect(setState.local).toHaveBeenCalledWith(expected); + expect(setState.dimensions).toHaveBeenCalledWith(expected); + }); + }); + }); + }); + describe('altTextHooks', () => { + it('returns value and isDecorative, along with associated setters', () => { + mockState(); + const value = 'myVAL'; + const isDecorative = 'IS WE Decorating?'; + mockStateVal(stateKeys.altText, value, setState.altText); + mockStateVal(stateKeys.isDecorative, isDecorative, setState.isDecorative); + hook = hooks.altTextHooks(); + expect(hook.value).toEqual(value); + expect(hook.setValue).toEqual(setState.altText); + expect(hook.isDecorative).toEqual(isDecorative); + expect(hook.setIsDecorative).toEqual(setState.isDecorative); + }); + }); + describe('onInputChange', () => { + it('calls handleValue with event value prop', () => { + const value = 'TEST value'; + const onChange = jest.fn(); + hooks.onInputChange(onChange)({ target: { value } }); + expect(onChange).toHaveBeenCalledWith(value); + }); + }); + describe('onCheckboxChange', () => { + it('calls handleValue with event checked prop', () => { + const checked = 'TEST value'; + const onChange = jest.fn(); + hooks.onCheckboxChange(onChange)({ target: { checked } }); + expect(onChange).toHaveBeenCalledWith(checked); + }); + }); + describe('onSaveClick', () => { + it('calls saveToEditor with dimensions, altText, and isDecorative', () => { + const dimensions = simpleDims; + const altText = 'What is this?'; + const isDecorative = 'probably'; + const saveToEditor = jest.fn(); + hooks.onSaveClick({ + altText, + dimensions, + isDecorative, + saveToEditor, + })(); + expect(saveToEditor).toHaveBeenCalledWith({ + altText, + dimensions, + isDecorative, + }); + }); + }); +}); diff --git a/src/editors/containers/TextEditor/components/ImageSettingsModal/index.jsx b/src/editors/containers/TextEditor/components/ImageSettingsModal/index.jsx index f838ee3dd..77258972d 100644 --- a/src/editors/containers/TextEditor/components/ImageSettingsModal/index.jsx +++ b/src/editors/containers/TextEditor/components/ImageSettingsModal/index.jsx @@ -1,63 +1,25 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { - Button, - Form, - Image, -} from '@edx/paragon'; +import { Button, Image } from '@edx/paragon'; +import { ArrowBackIos } from '@edx/paragon/icons'; import BaseModal from '../BaseModal'; -import * as module from '.'; -export const hooks = { - dimensions: () => { - const [baseDimensions, setBaseDimensions] = React.useState(null); - const [dimensions, setDimensions] = React.useState(null); - const initialize = ({ height, width }) => { - setBaseDimensions({ height, width }); - setDimensions({ height, width }); - }; - const reset = () => setDimensions(baseDimensions); - const setWidth = (width) => setDimensions({ ...dimensions, width }); - const setHeight = (height) => setDimensions({ ...dimensions, height }); - return { - value: dimensions, - initialize, - reset, - setHeight, - setWidth, - }; - }, - altText: (savedText) => { - const [altText, setAltText] = React.useState(savedText || ''); - const [isDecorative, setIsDecorative] = React.useState(false); - return { - value: altText, - set: setAltText, - isDecorative, - setIsDecorative, - }; - }, - onImgLoad: (initializeDimensions, selection) => ({ target: img }) => { - initializeDimensions({ - height: selection.height ? selection.height : img.naturalHeight, - width: selection.width ? selection.width : img.naturalWidth, - }); - }, - onInputChange: (handleValue) => (e) => handleValue(e.target.value), - onCheckboxChange: (handleValue) => (e) => handleValue(e.target.checked), - onSave: ({ - saveToEditor, - dimensions, - altText, - isDecorative, - }) => saveToEditor({ - dimensions, - altText, - isDecorative, - }), -}; +import AltTextControls from './AltTextControls'; +import DimensionControls from './DimensionControls'; +import hooks from './hooks'; +import './index.scss'; +/** + * Modal display wrapping the dimension and alt-text controls for image tags + * inserted into the TextEditor TinyMCE context. + * Provides a thumbnail and populates dimension and alt-text controls. + * @param {bool} isOpen - is the modal open? + * @param {func} close - close the modal + * @param {obj} selection - current image selection object + * @param {func} saveToEditor - save the current settings to the editor + * @param {func} returnToSelection - return to image selection + */ export const ImageSettingsModal = ({ isOpen, close, @@ -65,10 +27,9 @@ export const ImageSettingsModal = ({ saveToEditor, returnToSelection, }) => { - const dimensions = module.hooks.dimensions(); - const altText = module.hooks.altText(selection.altText); - const onImgLoad = module.hooks.onImgLoad(dimensions.initialize, selection); - const onSaveClick = () => module.hooks.onSave({ + const dimensions = hooks.dimensions(); + const altText = hooks.altText(); + const onSaveClick = hooks.onSaveClick({ saveToEditor, dimensions: dimensions.value, altText: altText.value, @@ -80,55 +41,38 @@ export const ImageSettingsModal = ({ close={close} isOpen={isOpen} confirmAction={( - )} > -
- - { dimensions.value && ( - - Image Dimensions - +
+ - - - )} - - Accessibility - - - This image is decorative (no alt text required). - - +
+
+
+ + +
+ ); }; diff --git a/src/editors/containers/TextEditor/components/ImageSettingsModal/index.scss b/src/editors/containers/TextEditor/components/ImageSettingsModal/index.scss new file mode 100644 index 000000000..c62b94e3c --- /dev/null +++ b/src/editors/containers/TextEditor/components/ImageSettingsModal/index.scss @@ -0,0 +1,31 @@ +.img-settings-form-container { + height: 300px; + + .img-settings-thumbnail-container { + width: 282px; + .img-settings-thumbnail { + margin-left: 32px; + max-height: 250px; + max-width: 250px; + } + } + + hr { + width: 1px; + } + .img-settings-form-controls { + width: 375px; + margin: 0 24px; + .dimension-input { + width: 145px; + margin-right: 15px; + display: inline-block; + } + .img-settings-control-label { + font-size: 1rem; + } + .decorative-control-label label { + font-size: .75rem; + } + } +} diff --git a/src/editors/containers/TextEditor/components/ImageSettingsModal/index.test.jsx b/src/editors/containers/TextEditor/components/ImageSettingsModal/index.test.jsx new file mode 100644 index 000000000..2cc69a8ae --- /dev/null +++ b/src/editors/containers/TextEditor/components/ImageSettingsModal/index.test.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import ImageSettingsModal from '.'; + +jest.mock('./AltTextControls', () => 'AltTextControls'); +jest.mock('./DimensionControls', () => 'DimensionControls'); + +jest.mock('./hooks', () => ({ + dimensions: () => ({ + value: { width: 12, height: 13 }, + onImgLoad: jest.fn( + (selection) => ({ 'hooks.dimensions.onImgLoad.callback': { selection } }), + ).mockName('hooks.dimensions.onImgLoad'), + }), + altText: () => ({ + value: 'alternative Taxes', + isDecorative: false, + }), + onSaveClick: (args) => ({ 'hooks.onSaveClick': args }), + isSaveDisabled: (args) => ({ 'hooks.isSaveDisabled': args }), +})); + +describe('ImageSettingsModal', () => { + const props = { + isOpen: false, + selection: { selected: 'image data' }, + }; + beforeEach(() => { + props.close = jest.fn().mockName('props.close'); + props.saveToEditor = jest.fn().mockName('props.saveToEditor'); + props.returnToSelection = jest.fn().mockName('props.returnToSelector'); + }); + describe('render', () => { + test('snapshot', () => { + expect(shallow()).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/TextEditor/components/ImageUploadModal.jsx b/src/editors/containers/TextEditor/components/ImageUploadModal.jsx index 4f9b6cb19..9f14f30c1 100644 --- a/src/editors/containers/TextEditor/components/ImageUploadModal.jsx +++ b/src/editors/containers/TextEditor/components/ImageUploadModal.jsx @@ -26,7 +26,6 @@ const ImageUploadModal = ({ const saveToEditor = module.hooks.createSaveCallback({ close, editorRef, setSelection, selection, }); - const closeAndReset = () => { setSelection(null); close(); @@ -49,13 +48,14 @@ const ImageUploadModal = ({ ImageUploadModal.defaultProps = { editorRef: null, + selection: 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, diff --git a/src/editors/containers/TextEditor/components/SelectImageModal.jsx b/src/editors/containers/TextEditor/components/SelectImageModal.jsx index 4ff4469e0..2dc540c4d 100644 --- a/src/editors/containers/TextEditor/components/SelectImageModal.jsx +++ b/src/editors/containers/TextEditor/components/SelectImageModal.jsx @@ -3,6 +3,7 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { Button, Image } from '@edx/paragon'; + import { thunkActions } from '../../../data/redux'; import BaseModal from './BaseModal'; import * as module from './SelectImageModal'; diff --git a/src/editors/data/services/cms/mockApi.js b/src/editors/data/services/cms/mockApi.js index 6b7fae947..676c6bfee 100644 --- a/src/editors/data/services/cms/mockApi.js +++ b/src/editors/data/services/cms/mockApi.js @@ -11,7 +11,9 @@ export const fetchBlockById = ({ blockId, studioEndpointUrl }) => mockPromise({ }, }); -export const fetchByUnitId = () => mockPromise({ +// TODO: update to return block data appropriate per block ID, which will equal block type +// eslint-disable-next-line +export const fetchByUnitId = ({ blockId, studioEndpointUrl }) => mockPromise({ data: { ancestors: [{ id: 'unitUrl' }] }, }); diff --git a/src/setupTest.js b/src/setupTest.js index d0e8508ec..05f683954 100644 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -64,12 +64,14 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon Button: 'Button', Icon: 'Icon', IconButton: 'IconButton', + Image: 'Image', ModalDialog: { Footer: 'ModalDialog.Footer', Header: 'ModalDialog.Header', Title: 'ModalDialog.Title', }, Form: { + Checkbox: 'Form.Checkbox', Control: { Feedback: 'Form.Control.Feedback', }, @@ -83,4 +85,6 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon jest.mock('@edx/paragon/icons', () => ({ Close: jest.fn().mockName('icons.Close'), Edit: jest.fn().mockName('icons.Edit'), + Locked: jest.fn().mockName('icons.Locked'), + Unlocked: jest.fn().mockName('icons.Unlocked'), }));