feat: Image settings feature and design completeness (#30)

* feat: devgallery api mode

* feat: dev gallery app

* chore: link mock block ids to real block type api

* feat: image settings page features

* fix: update tests

* fix: console message cleanup

* fix: test fixes from code walkthrough with ray
This commit is contained in:
Ben Warzeski
2022-03-15 16:32:07 -04:00
committed by GitHub
parent 4ae2d1230b
commit 32e4d5f7a1
20 changed files with 1092 additions and 114 deletions

View File

@@ -1,6 +1,9 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('jest', {
roots: [
'<rootDir>/src',
],
setupFiles: [
'<rootDir>/src/setupTest.js',
],

View File

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

View File

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

View File

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

View File

@@ -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,
}) => (
<Form.Group className="mt-4.5">
<Form.Label as="h4">Accessibility</Form.Label>
<Form.Control
className="mt-4.5"
type="input"
value={value}
disabled={isDecorative}
onChange={hooks.onInputChange(setValue)}
floatingLabel="Alt Text"
/>
<Form.Checkbox
className="mt-4.5 decorative-control-label"
checked={isDecorative}
onChange={hooks.onCheckboxChange(setIsDecorative)}
>
<Form.Label>
This image is decorative (no alt text required).
</Form.Label>
</Form.Checkbox>
</Form.Group>
);
AltTextControls.propTypes = {
isDecorative: PropTypes.bool.isRequired,
value: PropTypes.string.isRequired,
setValue: PropTypes.func.isRequired,
setIsDecorative: PropTypes.func.isRequired,
};
export default AltTextControls;

View File

@@ -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(<AltTextControls {...props} />)).toMatchSnapshot();
});
});
});

View File

@@ -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) && (
<Form.Group>
<Form.Label as="h4">Image Dimensions</Form.Label>
<div className="mt-4.5">
<Form.Control
className="dimension-input"
type="number"
value={value.width}
min={1}
onChange={hooks.onInputChange(setWidth)}
onBlur={updateDimensions}
floatingLabel="Width"
/>
<Form.Control
className="dimension-input"
type="number"
value={value.height}
min={1}
onChange={hooks.onInputChange(setHeight)}
onBlur={updateDimensions}
floatingLabel="Height"
/>
<IconButton
className="d-inline-block"
alt={locked ? 'unlock dimensions' : 'lock dimensions'}
iconAs={Icon}
src={locked ? Locked : Unlocked}
onClick={locked ? unlock : lock}
/>
</div>
</Form.Group>
));
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;

View File

@@ -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(<DimensionControls {...props} />)).toMatchSnapshot();
});
test('null value: empty snapshot', () => {
const el = shallow(<DimensionControls {...props} value={null} />);
expect(el).toMatchSnapshot();
expect(el.isEmptyRender()).toEqual(true);
});
});
});

View File

@@ -0,0 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AltTextControls render snapshot: isDecorative=true 1`] = `
<Form.Group
className="mt-4.5"
>
<Form.Label
as="h4"
>
Accessibility
</Form.Label>
<Form.Control
className="mt-4.5"
disabled={true}
floatingLabel="Alt Text"
onChange={
Object {
"hooks.onInputChange": [MockFunction props.setValue],
}
}
type="input"
value="props.value"
/>
<Form.Checkbox
checked={true}
className="mt-4.5 decorative-control-label"
onChange={
Object {
"hooks.onCheckboxChange": [MockFunction props.setIsDecorative],
}
}
>
<Form.Label>
This image is decorative (no alt text required).
</Form.Label>
</Form.Checkbox>
</Form.Group>
`;

View File

@@ -0,0 +1,50 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DimensionControls render null value: empty snapshot 1`] = `""`;
exports[`DimensionControls render snapshot 1`] = `
<Form.Group>
<Form.Label
as="h4"
>
Image Dimensions
</Form.Label>
<div
className="mt-4.5"
>
<Form.Control
className="dimension-input"
floatingLabel="Width"
min={1}
onBlur={[MockFunction props.updateDimensions]}
onChange={
Object {
"hooks.onInputChange": [MockFunction props.setWidth],
}
}
type="number"
value={20}
/>
<Form.Control
className="dimension-input"
floatingLabel="Height"
min={1}
onBlur={[MockFunction props.updateDimensions]}
onChange={
Object {
"hooks.onInputChange": [MockFunction props.setHeight],
}
}
type="number"
value={40}
/>
<IconButton
alt="unlock dimensions"
className="d-inline-block"
iconAs="Icon"
onClick={[MockFunction props.unlock]}
src={[MockFunction icons.Locked]}
/>
</div>
</Form.Group>
`;

View File

@@ -0,0 +1,108 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ImageSettingsModal render snapshot 1`] = `
<BaseModal
close={[MockFunction props.close]}
confirmAction={
<Button
disabled={
Object {
"hooks.isSaveDisabled": Object {
"isDecorative": false,
"value": "alternative Taxes",
},
}
}
onClick={
Object {
"hooks.onSaveClick": Object {
"altText": "alternative Taxes",
"dimensions": Object {
"height": 13,
"width": 12,
},
"isDecorative": false,
"saveToEditor": [MockFunction props.saveToEditor],
},
}
}
variant="primary"
>
Save
</Button>
}
isOpen={false}
title="Image Settings"
>
<Button
onClick={[MockFunction props.returnToSelector]}
size="inline"
variant="link"
>
Replace image
</Button>
<br />
<div
className="d-flex flex-row m-2 img-settings-form-container"
>
<div
className="img-settings-thumbnail-container"
>
<Image
className="img-settings-thumbnail"
onLoad={
Object {
"hooks.dimensions.onImgLoad.callback": Object {
"selection": Object {
"selected": "image data",
},
},
}
}
/>
</div>
<hr
className="h-100 bg-primary-200 m-0"
/>
<div
className="img-settings-form-controls"
>
<DimensionControls
onImgLoad={
[MockFunction hooks.dimensions.onImgLoad] {
"calls": Array [
Array [
Object {
"selected": "image data",
},
],
],
"results": Array [
Object {
"type": "return",
"value": Object {
"hooks.dimensions.onImgLoad.callback": Object {
"selection": Object {
"selected": "image data",
},
},
},
},
],
}
}
value={
Object {
"height": 13,
"width": 12,
}
}
/>
<AltTextControls
isDecorative={false}
value="alternative Taxes"
/>
</div>
</div>
</BaseModal>
`;

View File

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

View File

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

View File

@@ -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={(
<Button variant="primary" onClick={onSaveClick}>
<Button
variant="primary"
onClick={onSaveClick}
disabled={hooks.isSaveDisabled(altText)}
>
Save
</Button>
)}
>
<Button onClick={returnToSelection} variant="link" size="inline">
Select another image
<Button
onClick={returnToSelection}
variant="link"
size="inline"
iconBefore={ArrowBackIos}
>
Replace image
</Button>
<br />
<Image
style={{ maxWidth: '200px', maxHeight: '200px' }}
onLoad={onImgLoad}
src={selection.externalUrl}
/>
{ dimensions.value && (
<Form.Group>
<Form.Label>Image Dimensions</Form.Label>
<Form.Control
type="number"
value={dimensions.value.width}
min={0}
onChange={module.hooks.onInputChange(dimensions.setWidth)}
floatingLabel="Width"
<div className="d-flex flex-row m-2 img-settings-form-container">
<div className="img-settings-thumbnail-container">
<Image
className="img-settings-thumbnail"
onLoad={dimensions.onImgLoad(selection)}
src={selection.externalUrl}
/>
<Form.Control
type="number"
value={dimensions.value.height}
min={0}
onChange={module.hooks.onInputChange(dimensions.setHeight)}
floatingLabel="Height"
/>
</Form.Group>
)}
<Form.Group>
<Form.Label>Accessibility</Form.Label>
<Form.Control
type="input"
value={altText.value}
disabled={altText.isDecorative}
onChange={module.hooks.onInputChange(altText.set)}
floatingLabel="Alt Text"
/>
<Form.Checkbox
checked={altText.isDecorative}
onChange={module.hooks.onCheckboxChange(altText.setIsDecorative)}
>
This image is decorative (no alt text required).
</Form.Checkbox>
</Form.Group>
</div>
<hr className="h-100 bg-primary-200 m-0" />
<div className="img-settings-form-controls">
<DimensionControls {...dimensions} />
<AltTextControls {...altText} />
</div>
</div>
</BaseModal>
);
};

View File

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

View File

@@ -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(<ImageSettingsModal {...props} />)).toMatchSnapshot();
});
});
});

View File

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

View File

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

View File

@@ -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' }] },
});

View File

@@ -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'),
}));