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:
@@ -1,6 +1,9 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
roots: [
|
||||
'<rootDir>/src',
|
||||
],
|
||||
setupFiles: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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' }] },
|
||||
});
|
||||
|
||||
|
||||
@@ -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'),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user