diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchEditorCard.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchEditorCard.jsx
index df3a9bcac3..744b24aa67 100644
--- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchEditorCard.jsx
+++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchEditorCard.jsx
@@ -1,6 +1,6 @@
import React from 'react';
import { useDispatch } from 'react-redux';
-import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Card } from '@openedx/paragon';
import PropTypes from 'prop-types';
import { thunkActions } from '@src/editors/data/redux';
@@ -15,6 +15,7 @@ const SwitchEditorCard = ({
editorType,
problemType,
}) => {
+ const intl = useIntl();
const [isConfirmOpen, setConfirmOpen] = React.useState(false);
const dispatch = useDispatch();
const { editorRef } = useProblemEditorContext();
@@ -27,7 +28,7 @@ const SwitchEditorCard = ({
close={() => {
setConfirmOpen(false);
}}
- title={}
+ title={intl.formatMessage(messages[`ConfirmSwitchMessageTitle-${editorType}`])}
confirmAction={
-
- );
-};
-DimensionControls.defaultProps = {
- value: {
- height: '100',
- width: '100',
- },
-};
-DimensionControls.propTypes = {
- value: PropTypes.shape({
- height: PropTypes.string,
- width: PropTypes.string,
- }),
- setHeight: PropTypes.func.isRequired,
- setWidth: PropTypes.func.isRequired,
- isLocked: PropTypes.bool.isRequired,
- lock: PropTypes.func.isRequired,
- unlock: PropTypes.func.isRequired,
- updateDimensions: PropTypes.func.isRequired,
-};
-
-export default DimensionControls;
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/DimensionControls.test.tsx b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/DimensionControls.test.tsx
deleted file mode 100644
index ec726ae9fe..0000000000
--- a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/DimensionControls.test.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-import React, { useEffect } from 'react';
-import {
- fireEvent,
- render,
- screen,
- waitFor,
- initializeMocks,
-} from '@src/testUtils';
-import DimensionControls from './DimensionControls';
-import * as hooks from './hooks';
-
-const WrappedDimensionControls = () => {
- const dimensions = hooks.dimensionHooks('altText');
-
- useEffect(() => {
- dimensions.onImgLoad({})({ target: { naturalWidth: 1517, naturalHeight: 803 } });
- }, []);
-
- return ;
-};
-
-const UnlockedDimensionControls = () => {
- const dimensions = hooks.dimensionHooks('altText');
-
- useEffect(() => {
- dimensions.onImgLoad({})({ target: { naturalWidth: 1517, naturalHeight: 803 } });
- dimensions.unlock();
- }, []);
-
- return ;
-};
-
-describe('DimensionControls', () => {
- describe('render', () => {
- const props = {
- lockAspectRatio: { width: 4, height: 5 },
- locked: { 'props.locked': 'lockedValue' },
- isLocked: true,
- value: { width: '20', height: '40' },
- setWidth: jest.fn(),
- setHeight: jest.fn(),
- lock: jest.fn(),
- unlock: jest.fn(),
- updateDimensions: jest.fn(),
- };
- beforeEach(() => {
- jest.spyOn(hooks, 'onInputChange').mockImplementation((handler) => ({ 'hooks.onInputChange': handler }));
- initializeMocks();
- });
- afterEach(() => {
- jest.spyOn(hooks, 'onInputChange').mockRestore();
- });
- test('renders component', () => {
- render();
- expect(screen.getByText('Image Dimensions')).toBeInTheDocument();
- });
- test('renders nothing with null value', () => {
- const reduxProviderWrapper = '';
- const { container } = render();
- expect(screen.queryByText('Image Dimensions')).not.toBeInTheDocument();
- expect(container.innerHTML).toBe(reduxProviderWrapper);
- expect(container.firstChild?.textContent).toBe('');
- });
-
- test('renders locked and unlocked icon button according to isLocked prop', () => {
- const { rerender } = render();
- expect(screen.getByRole('button', { name: 'lock dimensions' })).toBeInTheDocument();
- rerender();
- expect(screen.getByRole('button', { name: 'unlock dimensions' })).toBeInTheDocument();
- });
- });
-
- describe('component tests for dimensions', () => {
- beforeEach(() => {
- initializeMocks();
- });
-
- it('renders with initial dimensions', () => {
- const { container } = render();
- const widthInput = container.querySelector('input.form-control');
- expect(widthInput).not.toBeNull();
- expect((widthInput as HTMLInputElement).value).toBe('1517');
- });
-
- it('resizes dimensions proportionally', async () => {
- const { container } = render();
- const widthInput = container.querySelector('input.form-control') as HTMLInputElement;
- expect((widthInput as HTMLInputElement).value).toBe('1517');
- fireEvent.change(widthInput, { target: { value: 758 } });
- await waitFor(() => {
- expect((container.querySelectorAll('input.form-control')[0] as HTMLInputElement).value).toBe('758');
- });
- fireEvent.blur(widthInput);
- await waitFor(() => {
- expect((container.querySelectorAll('input.form-control')[0] as HTMLInputElement).value).toBe('758');
- expect((container.querySelectorAll('input.form-control')[1] as HTMLInputElement).value).toBe('401');
- });
- });
-
- it('resizes only changed dimension when unlocked', async () => {
- const { container } = render();
- const widthInput = container.querySelector('input.form-control') as HTMLInputElement;
- expect(widthInput.value).toBe('1517');
- fireEvent.change(widthInput, { target: { value: 758 } });
- await waitFor(() => {
- expect((container.querySelectorAll('input.form-control')[0] as HTMLInputElement).value).toBe('758');
- });
- fireEvent.blur(widthInput);
- await waitFor(() => {
- expect((container.querySelectorAll('input.form-control')[0] as HTMLInputElement).value).toBe('758');
- expect((container.querySelectorAll('input.form-control')[1] as HTMLInputElement).value).toBe('803');
- });
- });
- });
-});
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/DimensionControls.tsx b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/DimensionControls.tsx
new file mode 100644
index 0000000000..d1b3e8862a
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/DimensionControls.tsx
@@ -0,0 +1,97 @@
+import { useFormikContext } from 'formik';
+import React from 'react';
+import {
+ Form,
+ Icon,
+ IconButton,
+} from '@openedx/paragon';
+import {
+ Locked,
+ Unlocked,
+} from '@openedx/paragon/icons';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ ImageConfig,
+ OrigImageDimensions,
+} from './types';
+
+import messages from './messages';
+
+/**
+ * Wrapper for image dimension inputs and the lock checkbox.
+ * @param {ImageDimensions} originalDimensions - original dimensions of the image
+ */
+const DimensionControls = ({ originalDimensions }: { originalDimensions: OrigImageDimensions; }) => {
+ const { width: originalWidth, height: originalHeight } = originalDimensions;
+ const intl = useIntl();
+ const formik = useFormikContext();
+ if (!(originalWidth && originalHeight)) {
+ return null;
+ }
+ const handleUpdateDimensions = async ({ target }) => {
+ const { name } = target;
+ let { value } = target;
+ // If dimensions are locked just set the value and return.
+ await formik.setFieldValue(name, value);
+ if (!formik.values.isLocked) {
+ return;
+ }
+ // For percentages, both values need to be ratio is locked
+ if (value.trim().endsWith('%')) {
+ await formik.setFieldValue('width', value);
+ await formik.setFieldValue('height', value);
+ return;
+ }
+ if (!value) {
+ return;
+ }
+ // For numerical values we calculate the other dimension based on the original ratio.
+ value = parseInt(value, 10);
+ if (name === 'height') {
+ await formik.setFieldValue('width', Math.round((value / originalHeight) * originalWidth));
+ } else if (name === 'width') {
+ await formik.setFieldValue('height', Math.round((value / originalWidth) * originalHeight));
+ }
+ };
+ return (
+ <>
+
+ {intl.formatMessage(messages.imageDimensionsLabel)}
+
+
+
+
+
+
+
+
+ formik.setFieldValue('isLocked', !formik.values.isLocked)}
+ />
+
+ >
+ );
+};
+
+export default DimensionControls;
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/hooks.js b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/hooks.js
deleted file mode 100644
index 5d9e26657e..0000000000
--- a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/hooks.js
+++ /dev/null
@@ -1,347 +0,0 @@
-import React from 'react';
-
-import { StrictDict } from '../../../utils';
-// This 'module' self-import hack enables mocking during tests.
-// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
-// should be re-thought and cleaned up to avoid this pattern.
-// eslint-disable-next-line import/no-self-import
-import * as module from './hooks';
-
-// Simple wrappers for useState to allow easy mocking for tests.
-export const state = {
- // eslint-disable-next-line react-hooks/rules-of-hooks
- altText: (val) => React.useState(val),
- // eslint-disable-next-line react-hooks/rules-of-hooks
- dimensions: (val) => React.useState(val),
- // eslint-disable-next-line react-hooks/rules-of-hooks
- showAltTextDismissibleError: (val) => React.useState(val),
- // eslint-disable-next-line react-hooks/rules-of-hooks
- showAltTextSubmissionError: (val) => React.useState(val),
- // eslint-disable-next-line react-hooks/rules-of-hooks
- isDecorative: (val) => React.useState(val),
- // eslint-disable-next-line react-hooks/rules-of-hooks
- isLocked: (val) => React.useState(val),
- // eslint-disable-next-line react-hooks/rules-of-hooks
- local: (val) => React.useState(val),
- // eslint-disable-next-line react-hooks/rules-of-hooks
- lockAspectRatio: (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, which may be 1.
- * @param {number} numerator - ratio numerator
- * @param {number} denominator - ratio denominator
- * @return {number} - ratio greatest common denominator
- */
-export const findGcd = (a, b) => {
- const gcd = b ? findGcd(b, a % b) : a;
-
- if (gcd === 1 || [a, b].some(v => !Number.isInteger(v / gcd))) {
- return 1;
- }
-
- return gcd;
-};
-
-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,
- isLocked,
- lockAspectRatio,
-}) => {
- // if lock is not active, just return new dimensions.
- // If lock is active, but dimensions have not changed, also just return new dimensions.
- if (!isLocked || checkEqual(local, dimensions)) {
- return local;
- }
-
- const out = {};
-
- // changed key is value of local height if that has changed, otherwise width.
- const keys = (local.height !== dimensions.height)
- ? { changed: dimKeys.height, other: dimKeys.width }
- : { changed: dimKeys.width, other: dimKeys.height };
-
- out[keys.changed] = local[keys.changed];
- out[keys.other] = Math.round((local[keys.changed] * lockAspectRatio[keys.other]) / lockAspectRatio[keys.changed]);
-
- return out;
-};
-
-/**
- * reduceDimensions(width, height)
- * reduces both values by dividing by their greates common denominator (which can simply be 1).
- * @return {Array} [width, height]
- */
-export const reduceDimensions = (width, height) => {
- const gcd = module.findGcd(width, height);
-
- return [width / gcd, height / gcd];
-};
-
-/**
- * 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
- * {bool} isLocked - are dimensions locked?
- * {obj} lockAspectRatio - image dimensions ({ height, width })
- * {func} lock - lock the dimensions
- * {func} unlock - unlock the dimensions
- */
-export const dimensionLockHooks = () => {
- const [lockAspectRatio, setLockAspectRatio] = module.state.lockAspectRatio(null);
- const [isLocked, setIsLocked] = module.state.isLocked(true);
-
- const initializeLock = ({ width, height }) => {
- // width and height are treated as a fraction and reduced.
- const [w, h] = reduceDimensions(width, height);
-
- setLockAspectRatio({ width: w, height: h });
- };
-
- return {
- initializeLock,
- isLocked,
- lock: () => setIsLocked(true),
- lockAspectRatio,
- unlock: () => setIsLocked(false),
- };
-};
-
-/**
- * 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
- * {obj} errorProps - props for user feedback error
- * {bool} isError - true if dimensions are blank
- * {func} setError - sets isError to true
- * {func} dismissError - sets isError to false
- * {bool} isHeightValid - true if height field is ready to save
- * {func} setHeightValid - sets isHeightValid to true
- * {func} setHeightNotValid - sets isHeightValid to false
- * {bool} isWidthValid - true if width field is ready to save
- * {func} setWidthValid - sets isWidthValid to true
- * {func} setWidthNotValid - sets isWidthValid to false
- */
-export const dimensionHooks = (altTextHook) => {
- const [dimensions, setDimensions] = module.state.dimensions(null);
- const [local, setLocal] = module.state.local(null);
-
- const setAll = ({ height, width, altText }) => {
- if (altText === '' || altText) {
- if (altText === '') {
- altTextHook.setIsDecorative(true);
- }
- altTextHook.setValue(altText);
- }
- setDimensions({ height, width });
- setLocal({ height, width });
- };
-
- const setHeight = (height) => {
- if (height.match(/[0-9]+[%]{1}/)) {
- const heightPercent = height.match(/[0-9]+[%]{1}/)[0];
- setLocal({ ...local, height: heightPercent });
- } else if (height.match(/[0-9]/)) {
- setLocal({ ...local, height: parseInt(height, 10) });
- }
- };
-
- const setWidth = (width) => {
- if (width.match(/[0-9]+[%]{1}/)) {
- const widthPercent = width.match(/[0-9]+[%]{1}/)[0];
- setLocal({ ...local, width: widthPercent });
- } else if (width.match(/[0-9]/)) {
- setLocal({ ...local, width: parseInt(width, 10) });
- }
- };
-
- const {
- initializeLock,
- isLocked,
- lock,
- lockAspectRatio,
- unlock,
- } = module.dimensionLockHooks({ dimensions });
-
- return {
- onImgLoad: (selection) => ({ target: img }) => {
- const imageDims = { height: img.naturalHeight, width: img.naturalWidth };
- setAll(selection.height ? selection : imageDims);
- initializeLock(selection.height ? selection : imageDims);
- },
- isLocked,
- lock,
- unlock,
- value: local,
- setHeight,
- setWidth,
- updateDimensions: () =>
- setAll(module.getValidDimensions({
- dimensions,
- local,
- isLocked,
- lockAspectRatio,
- })),
- };
-};
-
-/**
- * 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
- * {obj} error - error at top of page
- * {bool} show - is error being displayed?
- * {func} set - set show to true
- * {func} dismiss - set show to false
- * {obj} validation - local alt text error
- * {bool} show - is validation error being displayed?
- * {func} set - set validation to true
- * {func} dismiss - set validation to false
- */
-export const altTextHooks = (savedText) => {
- const [value, setValue] = module.state.altText(savedText || '');
- const [isDecorative, setIsDecorative] = module.state.isDecorative(false);
- const [showAltTextDismissibleError, setShowAltTextDismissibleError] = module.state.showAltTextDismissibleError(false);
- const [showAltTextSubmissionError, setShowAltTextSubmissionError] = module.state.showAltTextSubmissionError(false);
-
- const validateAltText = (newVal, newDecorative) => {
- if (showAltTextSubmissionError) {
- if (newVal || newDecorative) {
- setShowAltTextSubmissionError(false);
- }
- }
- };
-
- return {
- value,
- setValue: (val) => {
- setValue(val);
- validateAltText(val, null);
- },
- isDecorative,
- setIsDecorative: (decorative) => {
- setIsDecorative(decorative);
- validateAltText(null, decorative);
- },
- error: {
- show: showAltTextDismissibleError,
- set: () => setShowAltTextDismissibleError(true),
- dismiss: () => setShowAltTextDismissibleError(false),
- },
- validation: {
- show: showAltTextSubmissionError,
- set: () => setShowAltTextSubmissionError(true),
- dismiss: () => setShowAltTextSubmissionError(false),
- },
- };
-};
-
-/**
- * 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);
-
-/**
- * checkFormValidation({ altText, isDecorative, onAltTextFail })
- * Handle saving the image context to the text editor
- * @param {string} altText - image alt text
- * @param {bool} isDecorative - is the image decorative?
- * @param {func} onAltTextFail - called if alt text validation fails
- */
-export const checkFormValidation = ({
- altText,
- isDecorative,
- onAltTextFail,
-}) => {
- if (!isDecorative && altText === '') {
- onAltTextFail();
- return false;
- }
- return true;
-};
-
-/**
- * onSave({ altText, dimensions, isDecorative, saveToEditor })
- * Handle saving the image context to the text editor
- * @param {string} altText - image alt text
- * @param {object} dimensions - 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,
-}) =>
-() => {
- if (
- module.checkFormValidation({
- altText: altText.value,
- isDecorative,
- onAltTextFail: () => {
- altText.error.set();
- altText.validation.set();
- },
- })
- ) {
- altText.error.dismiss();
- altText.validation.dismiss();
- // Replaces double quotes with " to prevent the alt text from being truncated
- // or breaking the image tag structure.
- const altTextValue = altText.value.replace(/"/g, '"');
- saveToEditor({
- altText: altTextValue,
- dimensions,
- isDecorative,
- });
- }
-};
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/hooks.test.js b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/hooks.test.js
deleted file mode 100644
index 300ceec33d..0000000000
--- a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/hooks.test.js
+++ /dev/null
@@ -1,414 +0,0 @@
-import React from 'react';
-import { StrictDict } from '../../../utils';
-import { MockUseState } from '../../../testUtils';
-import * as hooks from './hooks';
-
-jest.mock('react', () => ({
- ...jest.requireActual('react'),
- useEffect: jest.fn(),
- useState: (val) => ({ useState: val }),
-}));
-
-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 state = new MockUseState(hooks);
-
-const hookKeys = StrictDict(
- Object.keys(hooks).reduce(
- (obj, key) => ({ ...obj, [key]: key }),
- {},
- ),
-);
-
-let hook;
-
-const testVal = 'MY test VALUE';
-
-describe('state values', () => {
- const testStateMethod = (key) => {
- // eslint-disable-next-line react-hooks/rules-of-hooks
- expect(hooks.state[key](testVal)).toEqual(React.useState(testVal));
- };
- test('provides altText state value', () => testStateMethod(state.keys.altText));
- test('provides dimensions state value', () => testStateMethod(state.keys.dimensions));
- test('provides showAltTextDismissibleError state value', () =>
- testStateMethod(state.keys.showAltTextDismissibleError));
- test('provides showAltTextSubmissionError state value', () => testStateMethod(state.keys.showAltTextSubmissionError));
- test('provides isDecorative state value', () => testStateMethod(state.keys.isDecorative));
- test('provides isLocked state value', () => testStateMethod(state.keys.isLocked));
- test('provides local state value', () => testStateMethod(state.keys.local));
- test('provides lockAspectRatio state value', () => testStateMethod(state.keys.lockAspectRatio));
-});
-
-describe('ImageSettingsModal hooks', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
- describe('dimensions-related hooks', () => {
- describe('getValidDimensions', () => {
- it('returns local dimensions if not locked', () => {
- expect(hooks.getValidDimensions({
- dimensions: simpleDims,
- local: reducedDims,
- isLocked: false,
- lockAspectRatio: simpleDims,
- })).toEqual(reducedDims);
- });
- it('returns local dimensions if the same as stored', () => {
- expect(hooks.getValidDimensions({
- dimensions: simpleDims,
- local: simpleDims,
- isLocked: true,
- lockAspectRatio: reducedDims,
- })).toEqual(simpleDims);
- });
- describe('valid change when aspect ratio is locked', () => {
- describe(
- 'keeps changed dimension and keeps the other dimension proportional but rounded',
- () => {
- const [w, h] = [7, 13];
-
- const testDimensions = (newDimensions, expected) => {
- const dimensions = { width: w, height: h };
- expect(hooks.getValidDimensions({
- dimensions,
- local: { width: newDimensions[0], height: newDimensions[1] },
- lockAspectRatio: { ...dimensions },
- isLocked: true,
- })).toEqual({ width: expected[0], height: expected[1] });
- };
-
- it('if width is increased, increases and rounds height to stay proportional', () => {
- testDimensions([8, h], [8, 15]);
- });
- it('if height is increased, increases and rounds width to stay proportional', () => {
- testDimensions([w, 25], [13, 25]);
- });
- it('if width is decreased, decreases and rounds height to stay proportional', () => {
- testDimensions([6, h], [6, 11]);
- });
- it('if height is decreased, decreases and rounds width to stay proportional', () => {
- testDimensions([7, 10], [5, 10]);
- });
- },
- );
- });
- it('calculates new dimensions proportionally and correctly when lock is active', () => {
- expect(hooks.getValidDimensions({
- dimensions: { width: 1517, height: 803 },
- local: { width: 758, height: 803 },
- isLocked: true,
- lockAspectRatio: { width: 1517, height: 803 },
- })).toEqual({ width: 758, height: 401 });
- });
- });
- describe('dimensionLockHooks', () => {
- beforeEach(() => {
- state.mock();
- hook = hooks.dimensionLockHooks({ dimensions: simpleDims });
- });
- afterEach(() => {
- state.restore();
- });
- test('lockAspectRatio defaults to null', () => {
- expect(hook.lockAspectRatio).toEqual(null);
- });
- test('isLocked defaults to true', () => {
- expect(hook.isLocked).toEqual(true);
- });
- describe('initializeLock', () => {
- it('calls setLockAspectRatio with the passed dimensions divided by their gcd', () => {
- hook.initializeLock(multiDims);
- expect(state.setState.lockAspectRatio).toHaveBeenCalledWith(reducedDims);
- });
- it('returns the values themselves if they have no gcd', () => {
- jest.spyOn(hooks, hookKeys.findGcd).mockReturnValueOnce(1);
- hook.initializeLock(simpleDims);
- expect(state.setState.lockAspectRatio).toHaveBeenCalledWith(simpleDims);
- });
- });
- test('lock sets isLocked to true', () => {
- hook = hooks.dimensionLockHooks({ dimensions: simpleDims });
- hook.lock();
- expect(state.setState.isLocked).toHaveBeenCalledWith(true);
- });
- test('unlock sets locked to null', () => {
- hook = hooks.dimensionLockHooks({ dimensions: simpleDims });
- hook.unlock();
- expect(state.setState.isLocked).toHaveBeenCalledWith(false);
- });
- });
- describe('dimensionHooks', () => {
- let lockHooks;
- beforeEach(() => {
- state.mock();
- lockHooks = {
- initializeLock: jest.fn(),
- lock: jest.fn(),
- unlock: jest.fn(),
- locked: { ...reducedDims },
- };
- jest.spyOn(hooks, hookKeys.dimensionLockHooks).mockReturnValueOnce(lockHooks);
- hook = hooks.dimensionHooks();
- });
- afterEach(() => {
- state.restore();
- });
- it('initializes dimension lock hooks with incoming dimension value', () => {
- state.mockVal(state.keys.dimensions, reducedDims);
- hook = hooks.dimensionHooks();
- expect(hooks.dimensionLockHooks).toHaveBeenCalledWith({ dimensions: reducedDims });
- });
- test('value is tied to local state', () => {
- state.mockVal(state.keys.local, simpleDims);
- 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(state.setState.dimensions).toHaveBeenCalledWith(simpleDims);
- expect(state.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(state.setState.dimensions).toHaveBeenCalledWith(expected);
- expect(state.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', () => {
- state.mockVal(state.keys.local, simpleDims);
- hooks.dimensionHooks().setHeight('23.4');
- expect(state.setState.local).toHaveBeenCalledWith({ ...simpleDims, height: 23 });
- });
- });
- describe('setWidth', () => {
- it('sets local width to int value of argument', () => {
- state.mockVal(state.keys.local, simpleDims);
- hooks.dimensionHooks().setWidth('34.5');
- expect(state.setState.local).toHaveBeenCalledWith({ ...simpleDims, width: 34 });
- });
- });
- describe('updateDimensions', () => {
- it('sets local and stored dimensions to newDimensions output', () => {
- // store values we care about under height or width, and add junk data to be stripped out.
- const testDims = (args) => ({ ...simpleDims, height: args });
- const getValidDimensions = (args) => ({ ...testDims(args), junk: 'data' });
- state.mockVal(state.keys.isLocked, true);
- state.mockVal(state.keys.dimensions, simpleDims);
- state.mockVal(state.keys.lockAspectRatio, reducedDims);
- state.mockVal(state.keys.local, multiDims);
- jest.spyOn(hooks, hookKeys.getValidDimensions).mockImplementationOnce(getValidDimensions);
- hook = hooks.dimensionHooks();
- hook.updateDimensions();
- const expected = testDims({
- dimensions: simpleDims,
- lockAspectRatio: reducedDims,
- local: multiDims,
- isLocked: true,
- });
- expect(state.setState.local).toHaveBeenCalledWith(expected);
- expect(state.setState.dimensions).toHaveBeenCalledWith(expected);
- });
- });
- });
- });
- describe('altTextHooks', () => {
- const value = 'myVAL';
- const isDecorative = true;
- const showAltTextDismissibleError = true;
- const showAltTextSubmissionError = true;
- beforeEach(() => {
- state.mock();
- hook = hooks.altTextHooks();
- });
- afterEach(() => {
- state.restore();
- });
- it('returns value and isDecorative', () => {
- state.mockVal(state.keys.altText, value);
- state.mockVal(state.keys.isDecorative, isDecorative);
- hook = hooks.altTextHooks();
- expect(hook.value).toEqual(value);
- expect(hook.isDecorative).toEqual(isDecorative);
- });
- test('setValue sets value', () => {
- state.mockVal(state.keys.altText, value);
- hook = hooks.altTextHooks();
- hook.setValue(value);
- expect(state.setState.altText).toHaveBeenCalledWith(value);
- });
- test('setIsDecorative sets isDecorative', () => {
- state.mockVal(state.keys.altText, value);
- hook = hooks.altTextHooks();
- hook.setIsDecorative(value);
- expect(state.setState.isDecorative).toHaveBeenCalledWith(value);
- });
- describe('error', () => {
- test('show is initialized to false and returns properly', () => {
- expect(hook.error.show).toEqual(false);
- state.mockVal(state.keys.showAltTextDismissibleError, showAltTextDismissibleError);
- hook = hooks.altTextHooks();
- expect(hook.error.show).toEqual(showAltTextDismissibleError);
- });
- test('set sets showAltTextDismissibleError to true', () => {
- hook.error.set();
- expect(state.setState.showAltTextDismissibleError).toHaveBeenCalledWith(true);
- });
- test('dismiss sets showAltTextDismissibleError to false', () => {
- hook.error.dismiss();
- expect(state.setState.showAltTextDismissibleError).toHaveBeenCalledWith(false);
- });
- });
- describe('validation', () => {
- test('show is initialized to false and returns properly', () => {
- expect(hook.validation.show).toEqual(false);
- state.mockVal(state.keys.showAltTextSubmissionError, showAltTextSubmissionError);
- hook = hooks.altTextHooks();
- expect(hook.validation.show).toEqual(showAltTextSubmissionError);
- });
- test('set sets showAltTextSubmissionError to true', () => {
- hook.validation.set();
- expect(state.setState.showAltTextSubmissionError).toHaveBeenCalledWith(true);
- });
- test('dismiss sets showAltTextSubmissionError to false', () => {
- hook.validation.dismiss();
- expect(state.setState.showAltTextSubmissionError).toHaveBeenCalledWith(false);
- });
- });
- });
- 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('checkFormValidation', () => {
- const props = {
- onAltTextFail: jest.fn().mockName('onAltTextFail'),
- };
- beforeEach(() => {
- props.altText = '';
- props.isDecorative = false;
- });
- it('calls onAltTextFail when isDecorative is false and altText is an empty string', () => {
- hooks.checkFormValidation({ ...props });
- expect(props.onAltTextFail).toHaveBeenCalled();
- });
- it('returns false when isDeocrative is false and altText is an empty string', () => {
- expect(hooks.checkFormValidation({ ...props })).toEqual(false);
- });
- it('returns true when isDecorative is true', () => {
- props.isDecorative = true;
- expect(hooks.checkFormValidation({ ...props })).toEqual(true);
- });
- });
- describe('onSaveClick', () => {
- const props = {
- altText: {
- error: {
- show: true,
- set: jest.fn(),
- dismiss: jest.fn(),
- },
- validation: {
- show: true,
- set: jest.fn(),
- dismiss: jest.fn(),
- },
- },
- dimensions: simpleDims,
- saveToEditor: jest.fn().mockName('saveToEditor'),
- };
- beforeEach(() => {
- props.altText.value = 'What is this?';
- props.isDecorative = false;
- });
- it('calls checkFormValidation', () => {
- jest.spyOn(hooks, hookKeys.checkFormValidation);
- hooks.onSaveClick({ ...props })();
- expect(hooks.checkFormValidation).toHaveBeenCalled();
- });
- it('calls saveToEditor with dimensions, altText and isDecorative when checkFormValidation is true', () => {
- jest.spyOn(hooks, hookKeys.checkFormValidation).mockReturnValueOnce(true);
- hooks.onSaveClick({ ...props })();
- expect(props.saveToEditor).toHaveBeenCalledWith({
- altText: props.altText.value,
- dimensions: props.dimensions,
- isDecorative: props.isDecorative,
- });
- });
- it('replaces double quotes with " before saving to editor', () => {
- props.altText.value = 'The "Submit For Grading" button';
- jest.spyOn(hooks, hookKeys.checkFormValidation).mockReturnValueOnce(true);
-
- hooks.onSaveClick({ ...props })();
-
- expect(props.saveToEditor).toHaveBeenCalledWith({
- altText: 'The "Submit For Grading" button',
- dimensions: props.dimensions,
- isDecorative: props.isDecorative,
- });
- });
- it('does not modify altText if there are no double quotes', () => {
- props.altText.value = 'The Submit For Grading button';
- jest.spyOn(hooks, hookKeys.checkFormValidation).mockReturnValueOnce(true);
-
- hooks.onSaveClick({ ...props })();
-
- expect(props.saveToEditor).toHaveBeenCalledWith({
- altText: 'The Submit For Grading button',
- dimensions: props.dimensions,
- isDecorative: props.isDecorative,
- });
- });
- it('calls dismissError and sets showAltTextSubmissionError to false when checkFormValidation is true', () => {
- jest.spyOn(hooks, hookKeys.checkFormValidation).mockReturnValueOnce(true);
- hooks.onSaveClick({ ...props })();
- expect(props.altText.error.dismiss).toHaveBeenCalled();
- expect(props.altText.validation.dismiss).toHaveBeenCalled();
- });
- it('does not call saveEditor when checkFormValidation is false', () => {
- jest.spyOn(hooks, hookKeys.checkFormValidation).mockReturnValueOnce(false);
- hooks.onSaveClick({ ...props })();
- expect(props.saveToEditor).not.toHaveBeenCalled();
- });
- });
- describe('findGcd', () => {
- it('should return correct gcd', () => {
- expect(hooks.findGcd(9, 12)).toBe(3);
- expect(hooks.findGcd(3, 4)).toBe(1);
- });
- });
- describe('reduceDimensions', () => {
- it('should return correct gcd', () => {
- expect(hooks.reduceDimensions(9, 12)).toEqual([3, 4]);
- expect(hooks.reduceDimensions(7, 8)).toEqual([7, 8]);
- });
- });
-});
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.jsx b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.jsx
deleted file mode 100644
index 4d41f03fbd..0000000000
--- a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.jsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { Button, Image } from '@openedx/paragon';
-import { ArrowBackIos } from '@openedx/paragon/icons';
-import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
-
-import './index.scss';
-import * as hooks from './hooks';
-import messages from './messages';
-import BaseModal from '../../BaseModal';
-import AltTextControls from './AltTextControls';
-import DimensionControls from './DimensionControls';
-import ErrorAlert from '../../ErrorAlerts/ErrorAlert';
-
-/**
- * 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
- */
-const ImageSettingsModal = ({
- close,
- isOpen,
- returnToSelection,
- saveToEditor,
- selection,
-}) => {
- const intl = useIntl();
- const altText = hooks.altTextHooks(selection.altText);
- const dimensions = hooks.dimensionHooks(altText);
- const onSaveClick = hooks.onSaveClick({
- altText,
- dimensions: dimensions.value,
- isDecorative: altText.isDecorative,
- saveToEditor,
- });
- return (
-
-
-
- }
- isOpen={isOpen}
- title={intl.formatMessage(messages.titleLabel)}
- >
-
-
-
-
-
-
-
- );
-};
-
-ImageSettingsModal.propTypes = {
- close: PropTypes.func.isRequired,
- isOpen: PropTypes.bool.isRequired,
- returnToSelection: PropTypes.func.isRequired,
- saveToEditor: PropTypes.func.isRequired,
- selection: PropTypes.shape({
- altText: PropTypes.string,
- externalUrl: PropTypes.string,
- url: PropTypes.string,
- }).isRequired,
-};
-export default ImageSettingsModal;
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.test.tsx b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.test.tsx
index a55a365b3c..235946d1b4 100644
--- a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.test.tsx
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.test.tsx
@@ -1,27 +1,9 @@
+import { initializeMocks, render, screen } from '@src/testUtils';
+import { fireEvent } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
import React from 'react';
-import { render, screen, initializeMocks } from '@src/testUtils';
import ImageSettingsModal from '.';
-
-jest.mock('./AltTextControls', () => 'AltTextControls');
-jest.mock('./DimensionControls', () => 'DimensionControls');
-
-jest.mock('./hooks', () => ({
- altTextHooks: () => ({
- error: {
- show: true,
- dismiss: jest.fn(),
- },
- isDecorative: false,
- value: 'alternative Taxes',
- }),
- dimensionHooks: () => ({
- onImgLoad: jest.fn(
- (selection) => ({ 'hooks.dimensions.onImgLoad.callback': { selection } }),
- ).mockName('hooks.dimensions.onImgLoad'),
- value: { width: 12, height: 13 },
- }),
- onSaveClick: (args) => ({ 'hooks.onSaveClick': args }),
-}));
+import messages from './messages';
describe('ImageSettingsModal', () => {
const props = {
@@ -30,16 +12,132 @@ describe('ImageSettingsModal', () => {
altText: 'AlTTExt',
externalUrl: 'ExtERNALurL',
url: 'UrL',
+ classList: [],
},
close: jest.fn().mockName('props.close'),
returnToSelection: jest.fn().mockName('props.returnToSelector'),
saveToEditor: jest.fn().mockName('props.saveToEditor'),
};
+ const user = userEvent.setup();
beforeEach(() => {
initializeMocks();
});
- test('renders component', () => {
- render();
+ test('Test null altText', () => {
+ render();
expect(screen.getByText('Image Settings')).toBeInTheDocument();
});
+ test('Test clicking replace image', async () => {
+ render();
+ await user.click(screen.getByRole('button', { name: /replace image/i }));
+ expect(props.returnToSelection).toHaveBeenCalled();
+ });
+ test('Test clicking cancel', async () => {
+ render();
+ await user.click(screen.getByRole('button', { name: /cancel/i }));
+ expect(props.close).toHaveBeenCalled();
+ });
+ describe('Alt Text Editing', () => {
+ test('Empty Alt Text raises error if image is nto decorative', async () => {
+ render();
+ await user.clear(screen.getByRole('textbox', { name: /alt text/i }));
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ expect(screen.getByText(messages.altTextLocalFeedback.defaultMessage)).toBeInTheDocument();
+ expect(screen.getByText(messages.altTextError.defaultMessage)).toBeInTheDocument();
+ expect(props.saveToEditor).not.toHaveBeenCalled();
+ });
+ test('Error can be dismissed', async () => {
+ render();
+ await user.clear(screen.getByRole('textbox', { name: /alt text/i }));
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ expect(screen.getByText(messages.altTextError.defaultMessage)).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: 'Dismiss' }));
+ expect(screen.queryByText(messages.altTextError.defaultMessage)).not.toBeInTheDocument();
+ });
+ test('Empty Alt Text doesn\'t raise error if image is decorative', async () => {
+ render();
+ await user.clear(screen.getByRole('textbox', { name: /alt text/i }));
+ await user.click(screen.getByRole('checkbox', { name: /decorative/i }));
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ expect(screen.queryByText(messages.altTextLocalFeedback.defaultMessage)).not.toBeInTheDocument();
+ expect(screen.queryByText(messages.altTextError.defaultMessage)).not.toBeInTheDocument();
+ expect(props.saveToEditor).toHaveBeenCalled();
+ });
+ test('If alt text is entered it does not raise any error', async () => {
+ render();
+ await user.type(screen.getByRole('textbox', { name: /alt text/i }), 'some text');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ expect(screen.queryByText(messages.altTextLocalFeedback.defaultMessage)).not.toBeInTheDocument();
+ expect(screen.queryByText(messages.altTextError.defaultMessage)).not.toBeInTheDocument();
+ expect(props.saveToEditor).toHaveBeenCalled();
+ });
+ });
+ describe('Image Dimensions Editing', () => {
+ function mockImageLoad(naturalWidth: number, naturalHeight: number) {
+ const img = screen.getByRole('img');
+ Object.defineProperty(img, 'naturalWidth', { value: naturalWidth });
+ Object.defineProperty(img, 'naturalHeight', { value: naturalHeight });
+ fireEvent.load(img);
+ }
+
+ test('Image dimensions are editable and saved correctly', async () => {
+ render();
+ mockImageLoad(1920, 1080);
+ await user.type(await screen.findByRole('textbox', { name: /width/i }), '1280');
+ await user.type(screen.getByRole('textbox', { name: /height/i }), '720');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ expect(screen.queryByText(messages.dimensionLocalFeedback.defaultMessage)).not.toBeInTheDocument();
+ expect(props.saveToEditor).toHaveBeenCalled();
+ });
+
+ test('Image dimensions are editable and saved correctly with percentages', async () => {
+ render();
+ mockImageLoad(1920, 1080);
+ await user.type(await screen.findByRole('textbox', { name: /width/i }), '75%');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ expect(screen.queryByText(messages.dimensionLocalFeedback.defaultMessage)).not.toBeInTheDocument();
+ expect(props.saveToEditor).toHaveBeenCalled();
+ });
+
+ describe('Dimension Locking', () => {
+ test('When dimensions are locked it maintains the original ratio', async () => {
+ render();
+ mockImageLoad(1920, 1080);
+ const widthInput = await screen.findByRole('textbox', { name: /width/i });
+ const heightInput = await screen.findByRole('textbox', { name: /height/i });
+ await user.clear(widthInput);
+ await user.type(widthInput, '1280');
+ expect(heightInput).toHaveValue('720');
+ await user.clear(heightInput);
+ await user.type(heightInput, '900');
+ expect(widthInput).toHaveValue('1600');
+ });
+
+ test('When dimensions are locked it maintains the original ratio with percentages', async () => {
+ render();
+ mockImageLoad(1920, 1080);
+ const widthInput = await screen.findByRole('textbox', { name: /width/i });
+ const heightInput = await screen.findByRole('textbox', { name: /height/i });
+ await user.clear(widthInput);
+ await user.type(widthInput, '75%');
+ expect(heightInput).toHaveValue('75%');
+ await user.clear(heightInput);
+ await user.type(heightInput, '90%');
+ expect(widthInput).toHaveValue('90%');
+ });
+
+ test('When dimensions are not locked it allows any ratio', async () => {
+ render();
+ mockImageLoad(1920, 1080);
+ await user.click(await screen.findByRole('button', { name: /unlock dimensions/i }));
+ const widthInput = await screen.findByRole('textbox', { name: /width/i });
+ const heightInput = await screen.findByRole('textbox', { name: /height/i });
+ await user.clear(widthInput);
+ await user.type(widthInput, '1280');
+ expect(heightInput).toHaveValue('1080');
+ await user.clear(heightInput);
+ await user.type(heightInput, '900');
+ expect(widthInput).toHaveValue('1280');
+ });
+ });
+ });
});
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.tsx b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.tsx
new file mode 100644
index 0000000000..35abc716c1
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.tsx
@@ -0,0 +1,224 @@
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import './index.scss';
+
+import { Button, Image } from '@openedx/paragon';
+import { ArrowBackIos } from '@openedx/paragon/icons';
+import { ImageAdditionalSettingsSlot } from '@src/plugin-slots/ImageAdditionalSettingsSlot';
+import ImageSettingsModalSlot from '@src/plugin-slots/ImageSettingsModalSlot';
+import { Formik, FormikBag, useFormikContext } from 'formik';
+import React from 'react';
+import * as Yup from 'yup';
+import BaseModal from '../../BaseModal';
+import ErrorAlert from '../../ErrorAlerts/ErrorAlert';
+import AltTextControls from './AltTextControls';
+import DimensionControls from './DimensionControls';
+import messages from './messages';
+import type {
+ HTMLImageAttrs,
+ ImageConfig,
+ OrigImageDimensions,
+} from './types';
+
+interface ImageSettingsModalCommonProps {
+ close: () => void;
+ isOpen: boolean;
+ returnToSelection: () => void;
+ selection: {
+ altText: string | null;
+ externalUrl: string;
+ url: string;
+ classList: string[];
+ };
+}
+
+interface ImageSettingsModalInternalProps extends ImageSettingsModalCommonProps {
+ imgDimensions: OrigImageDimensions;
+ setImgDimensions: (dimensions: OrigImageDimensions) => void;
+}
+
+interface ImageSettingsModalProps extends ImageSettingsModalCommonProps {
+ saveToEditor: (config: HTMLImageAttrs) => void;
+}
+
+export interface ImageSettingsModalFormProps
+ extends ImageSettingsModalCommonProps, ImageSettingsModalInternalProps
+{
+ initialValues: T;
+ validationSchema: Yup.ObjectSchema>;
+ handleSubmit: (values: T, actions: any) => void;
+ processValues?: (values: T) => void;
+}
+
+const ImageSettingsModalBase = ({
+ isOpen,
+ close,
+ returnToSelection,
+ selection,
+ imgDimensions,
+ setImgDimensions,
+}: ImageSettingsModalInternalProps) => {
+ const intl = useIntl();
+ const formik = useFormikContext();
+
+ const setOriginalDimensions = React.useCallback(async (event: React.ChangeEvent) => {
+ const img = event.currentTarget as HTMLImageElement;
+ setImgDimensions({ width: img.naturalWidth, height: img.naturalHeight });
+ await formik.setFieldValue('width', img.naturalWidth, false);
+ await formik.setFieldValue('height', img.naturalHeight, false);
+ }, [formik]);
+
+ const handleDismissError = React.useCallback(() => formik.setFieldError('altText', ''), [formik]);
+
+ return (
+
+ {intl.formatMessage(messages.saveButtonLabel)}
+
+ }
+ isOpen={isOpen}
+ title={intl.formatMessage(messages.titleLabel)}
+ >
+
+ {intl.formatMessage(messages.altTextError)}
+
+
+
+
+
+ );
+};
+
+export const ImageSettingsModalForm = ({
+ isOpen,
+ close,
+ returnToSelection,
+ selection,
+ imgDimensions,
+ setImgDimensions,
+ initialValues,
+ validationSchema,
+ handleSubmit,
+ processValues,
+}: ImageSettingsModalFormProps) => (
+ {
+ if (processValues) { processValues(values); }
+ handleSubmit(values, formikHelpers);
+ },
+ [handleSubmit, processValues],
+ )}
+ >
+
+
+);
+
+/**
+ * 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 {Object} props
+ * @param {bool} props.isOpen - is the modal open?
+ * @param {func} props.close - close the modal
+ * @param {Object} props.selection - current image selection object
+ * @param {func} props.saveToEditor - save the current settings to the editor
+ * @param {func} props.returnToSelection - return to image selection
+ */
+const ImageSettingsModal = ({
+ close,
+ isOpen,
+ returnToSelection,
+ saveToEditor,
+ selection,
+}: ImageSettingsModalProps) => {
+ const intl = useIntl();
+ const [imgDimensions, setImgDimensions] = React.useState({ width: 0, height: 0 });
+ const initialValues: ImageConfig = {
+ isLocked: true,
+ width: imgDimensions.width,
+ height: imgDimensions.height,
+ isDecorative: !selection.altText,
+ altText: selection.altText || '',
+ classList: selection.classList,
+ };
+ const validationSchema = Yup.object().shape({
+ isLocked: Yup.boolean(),
+ isDecorative: Yup.boolean(),
+ width: Yup.string().trim().matches(/^[0-9]+%?$/).required(),
+ height: Yup.string().trim().matches(/^[0-9]+%?$/).required(),
+ classList: Yup.array().of(Yup.string()).default([]),
+ altText: Yup.string().when('isDecorative', {
+ is: true,
+ otherwise: (schema) => schema.required(intl.formatMessage(messages.altTextLocalFeedback)),
+ }),
+ });
+
+ const handleSubmit = React.useCallback((value: ImageConfig, actions: FormikBag) => {
+ const { width, height, isDecorative, isLocked, ...imgAttrs } = value;
+ saveToEditor({
+ dimensions: {
+ width,
+ height,
+ },
+ ...imgAttrs,
+ });
+ actions.setSubmitting(false);
+ }, [saveToEditor]);
+
+ return (
+
+ );
+};
+
+export default ImageSettingsModal;
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/types.ts b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/types.ts
new file mode 100644
index 0000000000..d7d60337f6
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/types.ts
@@ -0,0 +1,25 @@
+export interface ImageDimensions {
+ width: string | number;
+ height: string | number;
+}
+
+export interface OrigImageDimensions {
+ width: number;
+ height: number;
+}
+
+export interface AltText {
+ altText: string;
+ isDecorative: boolean;
+}
+
+export interface ImageConfig extends ImageDimensions, AltText {
+ isLocked: boolean;
+ classList?: string[];
+}
+
+export interface HTMLImageAttrs {
+ dimensions: ImageDimensions;
+ altText: string;
+ classList?: string[];
+}
diff --git a/src/editors/sharedComponents/ImageUploadModal/index.jsx b/src/editors/sharedComponents/ImageUploadModal/index.jsx
index 3756eeb0bf..32262e5bac 100644
--- a/src/editors/sharedComponents/ImageUploadModal/index.jsx
+++ b/src/editors/sharedComponents/ImageUploadModal/index.jsx
@@ -31,11 +31,14 @@ export const imgProps = ({
const index = url.indexOf('static/');
url = url.substring(index);
}
+ const { dimensions, altText, isDecorative, classList, ...imgAttrs } = settings;
return {
src: url,
- alt: settings.isDecorative ? '' : settings.altText,
- width: settings.dimensions.width,
- height: settings.dimensions.height,
+ alt: isDecorative ? '' : altText,
+ width: dimensions.width,
+ height: dimensions.height,
+ class: (classList ?? []).join(' '),
+ ...imgAttrs,
};
};
@@ -75,7 +78,6 @@ export const updateImagesRef = ({
height,
width,
});
-
// eslint-disable-next-line no-param-reassign
images.current = imageAlreadyExists ? mappedImages : [...images.current, newImage];
};
diff --git a/src/editors/sharedComponents/ImageUploadModal/index.test.tsx b/src/editors/sharedComponents/ImageUploadModal/index.test.tsx
index f16b1972f4..cfd532fc13 100644
--- a/src/editors/sharedComponents/ImageUploadModal/index.test.tsx
+++ b/src/editors/sharedComponents/ImageUploadModal/index.test.tsx
@@ -22,6 +22,7 @@ const settings = {
width: 2022,
height: 1619,
},
+ classList: [],
};
const mockImage = {
@@ -56,26 +57,29 @@ describe('ImageUploadModal', () => {
alt: settings.altText,
width: settings.dimensions.width,
height: settings.dimensions.height,
+ class: ''
};
- const testImgTag = (args) => {
+ const testImgTag = ({expected, settings}) => {
const output = hooks.imgTag({
- settings: args.settings,
+ settings,
selection,
lmsEndpointUrl: 'sOmE',
editorType: 'tinyMCE',
isLibrary: true,
});
- expect(output).toEqual(`
`);
+ expect(output).toEqual(`
`);
};
- test('It returns a html string which matches an image tag', () => {
+ test.each([
+ [ settings, expected ],
+ [ {...settings, isDecorative: true}, {...expected, alt: ''} ],
+ [ {...settings, classList: []}, {...expected} ],
+ [ {...settings, classList: null}, {...expected} ],
+ [ {...settings, classList: undefined}, {...expected} ],
+ [ {...settings, classList: ['class1', 'class2']}, {...expected, class: 'class1 class2'} ],
+ [ {...settings, title: "some title"}, {...expected, title: 'some title'} ],
+ ])('It returns a html string which matches an image tag', (settings, expected) => {
testImgTag({ settings, expected });
});
- test('If isDecorative is true, alt text is an empty string', () => {
- testImgTag({
- settings: { ...settings, isDecorative: true },
- expected: { ...expected, alt: '' },
- });
- });
});
describe('createSaveCallback', () => {
const updateImageDimensionsSpy = jest.spyOn(tinyMceHooks, 'updateImageDimensions');
diff --git a/src/editors/sharedComponents/SelectionModal/SearchSort.jsx b/src/editors/sharedComponents/SelectionModal/SearchSort.jsx
index 273d552019..bb8446050b 100644
--- a/src/editors/sharedComponents/SelectionModal/SearchSort.jsx
+++ b/src/editors/sharedComponents/SelectionModal/SearchSort.jsx
@@ -18,13 +18,14 @@ import {
import messages from './messages';
import './index.scss';
-import { sortKeys, sortMessages } from '../../containers/VideoGallery/utils';
const SearchSort = ({
searchString,
onSearchChange,
clearSearchString,
sortBy,
+ sortKeys,
+ sortMessages,
onSortClick,
filterBy,
onFilterClick,
@@ -135,6 +136,8 @@ SearchSort.propTypes = {
onFilterClick: PropTypes.func,
filterKeys: PropTypes.shape({}),
filterMessages: PropTypes.shape({}),
+ sortKeys: PropTypes.shape({}).isRequired,
+ sortMessages: PropTypes.shape({}).isRequired,
showSwitch: PropTypes.bool,
switchMessage: PropTypes.shape({}).isRequired,
onSwitchClick: PropTypes.func,
diff --git a/src/editors/sharedComponents/TinyMceWidget/hooks.test.js b/src/editors/sharedComponents/TinyMceWidget/hooks.test.js
index e45b13ddc8..9a99d05d02 100644
--- a/src/editors/sharedComponents/TinyMceWidget/hooks.test.js
+++ b/src/editors/sharedComponents/TinyMceWidget/hooks.test.js
@@ -456,6 +456,7 @@ describe('TinyMceEditor hooks', () => {
altText: mockNode.alt,
width: mockImage.width,
height: mockImage.height,
+ classList: [],
});
});
@@ -484,6 +485,7 @@ describe('TinyMceEditor hooks', () => {
altText: mockNode.alt,
width: null,
height: null,
+ classList: [],
});
});
});
diff --git a/src/editors/sharedComponents/TinyMceWidget/hooks.ts b/src/editors/sharedComponents/TinyMceWidget/hooks.ts
index 4816286d1f..17f0583413 100644
--- a/src/editors/sharedComponents/TinyMceWidget/hooks.ts
+++ b/src/editors/sharedComponents/TinyMceWidget/hooks.ts
@@ -290,6 +290,7 @@ export const openModalWithSelectedImage = ({
altText: tinyMceHTML.alt,
width,
height,
+ classList: Array.from(tinyMceHTML.classList ?? []),
});
openImgModal();
diff --git a/src/plugin-slots/ImageAdditionalSettingsSlot/README.md b/src/plugin-slots/ImageAdditionalSettingsSlot/README.md
new file mode 100644
index 0000000000..7f048f6720
--- /dev/null
+++ b/src/plugin-slots/ImageAdditionalSettingsSlot/README.md
@@ -0,0 +1,67 @@
+# Image Additional Settings Slot
+
+### Slot ID: `org.openedx.frontend.authoring.image_additional_settings.v1`
+
+## Description
+
+This slot is used to add additional settings to the image settings modal.
+
+## Example
+
+The following `env.config.jsx` will add a checkbox that will apply or remove the 'showcase' class to the selected image.
+
+
+
+```jsx
+import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
+import { Form } from '@openedx/paragon';
+import { useFormikContext } from 'formik';
+import React from 'react';
+
+const ShowcaseClassEditor = () => {
+ const formik = useFormikContext();
+ const { classList } = formik.values;
+
+ const checked = classList.includes('showcase');
+
+ const handleChange = async (event) => {
+ // Toggle the 'showcase' class based on the checkbox state
+ const filteredList = classList.filter(c => c !== 'showcase');
+ const newList = event.target.checked
+ ? [...filteredList, 'showcase']
+ : filteredList;
+ await formik.setFieldValue('classList', newList);
+ };
+
+ return (
+
+
+
+ Showcase Image
+
+
+ );
+};
+
+const config = {
+ pluginSlots: {
+ 'org.openedx.frontend.authoring.image_additional_settings.v1': {
+ plugins: [
+ {
+ op: PLUGIN_OPERATIONS.Insert,
+ widget: {
+ id: 'showcase_class_editor',
+ type: 'DIRECT_PLUGIN',
+ RenderWidget: ShowcaseClassEditor,
+ },
+ },
+ ],
+ },
+ },
+};
+
+export default config;
+```
diff --git a/src/plugin-slots/ImageAdditionalSettingsSlot/images/image-additional-settings-slot-example.png b/src/plugin-slots/ImageAdditionalSettingsSlot/images/image-additional-settings-slot-example.png
new file mode 100644
index 0000000000..d84079ea95
Binary files /dev/null and b/src/plugin-slots/ImageAdditionalSettingsSlot/images/image-additional-settings-slot-example.png differ
diff --git a/src/plugin-slots/ImageAdditionalSettingsSlot/index.tsx b/src/plugin-slots/ImageAdditionalSettingsSlot/index.tsx
new file mode 100644
index 0000000000..1ac7d0c9d4
--- /dev/null
+++ b/src/plugin-slots/ImageAdditionalSettingsSlot/index.tsx
@@ -0,0 +1,5 @@
+import { PluginSlot } from '@openedx/frontend-plugin-framework/dist';
+
+export const ImageAdditionalSettingsSlot = () => (
+
+);
diff --git a/src/plugin-slots/ImageSettingsModalSlot/README.md b/src/plugin-slots/ImageSettingsModalSlot/README.md
new file mode 100644
index 0000000000..c884554950
--- /dev/null
+++ b/src/plugin-slots/ImageSettingsModalSlot/README.md
@@ -0,0 +1,47 @@
+# Image Settings Modal Slot
+
+### Slot ID: `org.openedx.frontend.authoring.image_settings_modal.v1`
+
+### Plugin Props:
+
+- `initialValues` - Object. The initial values for the Formik form.
+- `validationSchema` - Object. The Yup validation schema for the form.
+- `processValues` - Function. A function to process form values before submission.
+
+## Description
+
+This slot wraps the image settings modal. It can be used to modify the form's initial values, validation schema, or to wrap the entire modal component. You can also use this slot to add validation and initial values to the form. The `processValues` function is called to clean up values being submitted.
+
+Combined with `ImageAdditionalSettingsSlot`, this slot allows extending the image editing capabilities of the image settings modal.
+
+Since this slot wraps a modal that uses a React portal, you can't use it to wrap the modal in another div etc.
+
+## Example
+
+The following `env.config.jsx` will modify the image settings modal to log the values being submitted.
+
+```jsx
+import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
+
+const config = {
+ pluginSlots: {
+ 'org.openedx.frontend.authoring.image_settings_modal.v1': {
+ keepDefault: true,
+ plugins: [
+ {
+ op: PLUGIN_OPERATIONS.Modify,
+ widgetId: 'default_contents',
+ fn: (widget) => {
+ widget.content.processValues = (values) => {
+ console.log('Processing values:', values);
+ };
+ return widget;
+ },
+ },
+ ],
+ },
+ },
+};
+
+export default config;
+```
diff --git a/src/plugin-slots/ImageSettingsModalSlot/index.tsx b/src/plugin-slots/ImageSettingsModalSlot/index.tsx
new file mode 100644
index 0000000000..edf96f38ab
--- /dev/null
+++ b/src/plugin-slots/ImageSettingsModalSlot/index.tsx
@@ -0,0 +1,47 @@
+import { PluginSlot } from '@openedx/frontend-plugin-framework';
+import {
+ ImageSettingsModalForm,
+ type ImageSettingsModalFormProps,
+} from '@src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal';
+
+import React from 'react';
+
+const ImageSettingsModalSlot = ({
+ isOpen,
+ close,
+ returnToSelection,
+ selection,
+ imgDimensions,
+ setImgDimensions,
+ initialValues,
+ validationSchema,
+ handleSubmit,
+ processValues,
+}: ImageSettingsModalFormProps) => (
+
+
+
+);
+
+export default ImageSettingsModalSlot;