|<\/label>/gm);
+ let updatedContent;
+ parsedLabels.forEach((label, i) => {
+ let updatedLabel = label;
+ if (!label.startsWith('<') && !label.endsWith('>')) {
+ let previousLabel = parsedLabels[i - 1];
+ let nextLabel = parsedLabels[i + 1];
+ if (!previousLabel.endsWith('')) {
+ previousLabel = `${previousLabel}
`;
+ updatedContent = content.replace(parsedLabels[i - 1], previousLabel);
+ content = updatedContent;
+ }
+ if (previousLabel.endsWith('
') && !label.startWith('')) {
+ updatedLabel = `
${label}`;
+ updatedContent = content.replace(label, updatedLabel);
+ content = updatedContent;
+ }
+ if (!nextLabel.startsWith('
')) {
+ nextLabel = `${nextLabel}`;
+ updatedContent = content.replace(parsedLabels[i + 1], nextLabel);
+ content = updatedContent;
+ }
+ }
+ });
+ updateQuestion(content);
+};
+
+export const setupCustomBehavior = ({ updateQuestion }) => (editor) => {
+ // add a custom simple inline label formatter.
+ const toggleLabelFormatting = () => {
+ editor.execCommand('mceToggleFormat', false, 'label');
+ };
+ editor.ui.registry.addIcon('textToSpeech',
+ ' ');
+ editor.ui.registry.addButton('customLabelButton', {
+ icon: 'textToSpeech',
+ text: 'Label',
+ tooltip: 'Apply a "Question" label to specific text, recognized by screen readers. Recommended to improve accessibility.',
+ onAction: toggleLabelFormatting,
+ });
+ editor.on('blur', () => {
+ module.parseContentForLabels({
+ editor,
+ updateQuestion,
+ });
+ });
+};
+
export const problemEditorConfig = ({
setEditorRef,
- editorRef,
question,
updateQuestion,
}) => ({
@@ -27,10 +77,10 @@ export const problemEditorConfig = ({
branding: false,
min_height: 150,
placeholder: 'Enter your question',
- },
- onFocusOut: () => {
- const content = editorRef.current.getContent();
- updateQuestion(content);
+ formats: { label: { inline: 'label' } },
+ setup: module.setupCustomBehavior({ updateQuestion }),
+ toolbar: `${pluginConfig().toolbar} | customLabelButton`,
+ plugins: 'autoresize',
},
});
diff --git a/src/editors/containers/ProblemEditor/hooks.test.js b/src/editors/containers/ProblemEditor/hooks.test.js
new file mode 100644
index 000000000..9bac2b788
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/hooks.test.js
@@ -0,0 +1,135 @@
+import { useEffect } from 'react';
+import { MockUseState } from '../../../testUtils';
+
+// import tinyMCE from '../../data/constants/tinyMCE';
+import { keyStore } from '../../utils';
+import pluginConfig from '../TextEditor/pluginConfig';
+import * as module from './hooks';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ createRef: jest.fn(val => ({ ref: val })),
+ useRef: jest.fn(val => ({ current: val })),
+ useEffect: jest.fn(),
+ useCallback: (cb, prereqs) => ({ cb, prereqs }),
+}));
+
+const state = new MockUseState(module);
+const moduleKeys = keyStore(module);
+
+let hook;
+let output;
+
+describe('Problem editor hooks', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ describe('state hooks', () => {
+ state.testGetter(state.keys.refReady);
+ });
+
+ describe('non-state hooks', () => {
+ beforeEach(() => { state.mock(); });
+ afterEach(() => { state.restore(); });
+
+ describe('setupCustomBehavior', () => {
+ test('It calls addButton and addToggleButton in the editor, but openModal is not called', () => {
+ const addButton = jest.fn();
+ const addIcon = jest.fn();
+ const updateQuestion = jest.fn();
+ const editor = {
+ ui: { registry: { addButton, addIcon } },
+ on: jest.fn(),
+ };
+ const toggleLabelFormatting = expect.any(Function);
+ output = module.setupCustomBehavior({ updateQuestion })(editor);
+ expect(addIcon.mock.calls).toEqual([['textToSpeech',
+ ' ',
+ ]]);
+ expect(addButton.mock.calls).toEqual([
+ ['customLabelButton', {
+ icon: 'textToSpeech',
+ text: 'Label',
+ tooltip: 'Apply a "Question" label to specific text, recognized by screen readers. Recommended to improve accessibility.',
+ onAction: toggleLabelFormatting,
+ }],
+ ]);
+ });
+ });
+
+ describe('parseContentForLabels', () => {
+ test('it calls getContent and setContent', () => {
+ const editor = { getContent: jest.fn(() => '
Some question label
some content around a label followed by more text
') };
+ const updateQuestion = jest.fn();
+ const content = 'Some question label
some content
around a label
followed by more text
';
+ module.parseContentForLabels({ editor, updateQuestion });
+ expect(editor.getContent).toHaveBeenCalled();
+ expect(updateQuestion).toHaveBeenCalledWith(content);
+ });
+ });
+
+ describe('problemEditorConfig', () => {
+ const props = {
+ question: '',
+ };
+ const evt = 'fakeEvent';
+ const editor = 'myEditor';
+ const setupCustomBehavior = args => ({ setupCustomBehavior: args });
+ beforeEach(() => {
+ props.setEditorRef = jest.fn();
+ props.updateQuestion = jest.fn();
+ jest.spyOn(module, moduleKeys.setupCustomBehavior)
+ .mockImplementationOnce(setupCustomBehavior);
+ output = module.problemEditorConfig(props);
+ });
+ test('It creates an onInit which calls setEditorRef', () => {
+ output.onInit(evt, editor);
+ expect(props.setEditorRef).toHaveBeenCalledWith(editor);
+ });
+ test('It sets the blockvalue to be empty string by default', () => {
+ expect(output.initialValue).toBe('');
+ });
+ test('It sets the blockvalue to be the blockvalue if nonempty', () => {
+ const questionText = 'SomE hTML content';
+ output = module.problemEditorConfig({ ...props, question: questionText });
+ expect(output.initialValue).toBe(questionText);
+ });
+ test('It configures plugins and toolbars correctly', () => {
+ expect(output.init.plugins).toEqual('autoresize');
+ expect(output.init.toolbar).toEqual(`${pluginConfig().toolbar} | customLabelButton`);
+ });
+
+ it('calls setupCustomBehavior on setup', () => {
+ expect(output.init.setup).toEqual(
+ setupCustomBehavior({
+ updateQuestion: props.updateQuestion,
+ }),
+ );
+ });
+ });
+
+ describe('prepareEditorRef', () => {
+ beforeEach(() => {
+ hook = module.prepareEditorRef();
+ });
+ const key = state.keys.refReady;
+ test('sets refReady to false by default, ref is null', () => {
+ expect(state.stateVals[key]).toEqual(false);
+ expect(hook.editorRef.current).toBe(null);
+ });
+ test('when useEffect triggers, refReady is set to true', () => {
+ expect(state.setState[key]).not.toHaveBeenCalled();
+ const [cb, prereqs] = useEffect.mock.calls[0];
+ expect(prereqs).toStrictEqual([state.setState[key]]);
+ cb();
+ expect(state.setState[key]).toHaveBeenCalledWith(true);
+ });
+ test('calling setEditorRef sets the ref value', () => {
+ const fakeEditor = { editor: 'faKe Editor' };
+ expect(hook.editorRef.current).not.toBe(fakeEditor);
+ hook.setEditorRef.cb(fakeEditor);
+ expect(hook.editorRef.current).toBe(fakeEditor);
+ });
+ });
+ });
+});
diff --git a/src/editors/containers/ProblemEditor/index.test.jsx b/src/editors/containers/ProblemEditor/index.test.jsx
new file mode 100644
index 000000000..b1da0373f
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/index.test.jsx
@@ -0,0 +1,95 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { thunkActions, selectors } from '../../data/redux';
+import { RequestKeys } from '../../data/constants/requests';
+import { ProblemEditor, mapStateToProps, mapDispatchToProps } from '.';
+
+jest.mock('./components/EditProblemView', () => 'EditProblemView');
+jest.mock('./components/SelectTypeModal', () => 'SelectTypeModal');
+
+jest.mock('react', () => {
+ const updateState = jest.fn();
+ return {
+ ...jest.requireActual('react'),
+ updateState,
+ useState: jest.fn(val => ([{ state: val }, jest.fn().mockName('setState')])),
+ };
+});
+
+jest.mock('../../data/redux', () => ({
+ thunkActions: {
+ problem: {
+ initializeProblemEditor: jest.fn().mockName('thunkActions.problem.initializeProblem'),
+ },
+ },
+ selectors: {
+ app: {
+ blockValue: jest.fn(state => ({ blockValue: state })),
+ },
+ problem: {
+ problemType: jest.fn(state => ({ problemType: state })),
+ },
+ requests: {
+ isFinished: jest.fn((state, params) => ({ isFailed: { state, params } })),
+ },
+ },
+}));
+
+describe('ProblemEditor', () => {
+ const props = {
+ onClose: jest.fn().mockName('props.onClose'),
+ // redux
+ problemType: null,
+ blockValue: { data: { data: 'eDiTablE Text' } },
+ blockFinished: false,
+ studioViewFinished: false,
+ initializeProblemEditor: jest.fn().mockName('args.intializeProblemEditor'),
+ };
+ describe('snapshots', () => {
+ test('renders as expected with default behavior', () => {
+ expect(shallow( )).toMatchSnapshot();
+ });
+ test('block not yet loaded, Spinner appears', () => {
+ expect(shallow( )).toMatchSnapshot();
+ });
+ test('studio view not yet loaded, Spinner appears', () => {
+ expect(shallow( )).toMatchSnapshot();
+ });
+ test('renders SelectTypeModal', () => {
+ expect(shallow( )).toMatchSnapshot();
+ });
+ test('renders EditProblemView', () => {
+ expect(shallow( )).toMatchSnapshot();
+ });
+ });
+
+ describe('mapStateToProps', () => {
+ const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
+ test('blockValue from app.blockValue', () => {
+ expect(
+ mapStateToProps(testState).blockValue,
+ ).toEqual(selectors.app.blockValue(testState));
+ });
+ test('problemType from problem.problemType', () => {
+ expect(
+ mapStateToProps(testState).problemType,
+ ).toEqual(selectors.problem.problemType(testState));
+ });
+ test('blockFinished from requests.isFinished', () => {
+ expect(
+ mapStateToProps(testState).blockFinished,
+ ).toEqual(selectors.requests.isFinished(testState, { requestKey: RequestKeys.fetchBlock }));
+ });
+ test('studioViewFinished from requests.isFinished', () => {
+ expect(
+ mapStateToProps(testState).studioViewFinished,
+ ).toEqual(selectors.requests.isFinished(testState, { requestKey: RequestKeys.fetchStudioView }));
+ });
+ });
+ describe('mapDispatchToProps', () => {
+ test('initializeProblemEditor from thunkActions.problem.initializeProblem', () => {
+ expect(mapDispatchToProps.initializeProblemEditor).toEqual(thunkActions.problem.initializeProblem);
+ });
+ });
+});
diff --git a/www/package-lock.json b/www/package-lock.json
index a251014fc..82ef681b0 100644
--- a/www/package-lock.json
+++ b/www/package-lock.json
@@ -14,7 +14,7 @@
"@edx/frontend-build": "^11.0.0",
"@edx/frontend-lib-content-components": "file:..",
"@edx/frontend-platform": "2.5.1",
- "@edx/paragon": "^20.27.0",
+ "@edx/paragon": "^20.28.0",
"core-js": "^3.21.1",
"dotenv": "^16.0.0",
"prop-types": "^15.5.10",
@@ -57,9 +57,9 @@
"devDependencies": {
"@edx/frontend-build": "^11.0.2",
"@edx/frontend-platform": "2.4.0",
- "@edx/paragon": "^20.27.0",
+ "@edx/paragon": "^20.28.0",
"@testing-library/dom": "^8.13.0",
- "@testing-library/react": "12.1.1",
+ "@testing-library/react": "^12.1.1",
"@testing-library/user-event": "^13.5.0",
"codecov": "3.8.3",
"enzyme": "3.11.0",
@@ -79,7 +79,7 @@
},
"peerDependencies": {
"@edx/frontend-platform": ">1.15.0",
- "@edx/paragon": "^20.21.0",
+ "@edx/paragon": "^20.27.0",
"prop-types": "^15.5.10",
"react": "^16.14.0",
"react-dom": "^16.14.0"
@@ -1988,9 +1988,9 @@
}
},
"node_modules/@edx/paragon": {
- "version": "20.27.0",
- "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.27.0.tgz",
- "integrity": "sha512-jy62ZEBdAVlsP6tAm1/YDyMtc9fiD47H00whoW+y2Z+lLZqPsv6D5boIPQIcdBeg0W4f2gCU4TEy2+b2q8mYGA==",
+ "version": "20.28.0",
+ "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.28.0.tgz",
+ "integrity": "sha512-ydM3DK2aqdYRXyNEC0+P9tFpckH9kx/b/HtgSGrNMreRGAJ90QNkI/zAdlwj8U9mmoaE9jfIpjsO4g84qnrk1A==",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/react-fontawesome": "^0.1.18",
@@ -22133,10 +22133,10 @@
"@codemirror/view": "^6.0.0",
"@edx/frontend-build": "^11.0.2",
"@edx/frontend-platform": "2.4.0",
- "@edx/paragon": "^20.27.0",
+ "@edx/paragon": "^20.28.0",
"@reduxjs/toolkit": "^1.8.1",
"@testing-library/dom": "^8.13.0",
- "@testing-library/react": "12.1.1",
+ "@testing-library/react": "^12.1.1",
"@testing-library/user-event": "^13.5.0",
"@tinymce/tinymce-react": "^3.14.0",
"babel-polyfill": "6.26.0",
@@ -22231,9 +22231,9 @@
}
},
"@edx/paragon": {
- "version": "20.27.0",
- "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.27.0.tgz",
- "integrity": "sha512-jy62ZEBdAVlsP6tAm1/YDyMtc9fiD47H00whoW+y2Z+lLZqPsv6D5boIPQIcdBeg0W4f2gCU4TEy2+b2q8mYGA==",
+ "version": "20.28.0",
+ "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.28.0.tgz",
+ "integrity": "sha512-ydM3DK2aqdYRXyNEC0+P9tFpckH9kx/b/HtgSGrNMreRGAJ90QNkI/zAdlwj8U9mmoaE9jfIpjsO4g84qnrk1A==",
"requires": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/react-fontawesome": "^0.1.18",
diff --git a/www/package.json b/www/package.json
index fc163e96c..4d2bd1002 100644
--- a/www/package.json
+++ b/www/package.json
@@ -15,7 +15,7 @@
"@edx/frontend-build": "^11.0.0",
"@edx/frontend-lib-content-components": "file:..",
"@edx/frontend-platform": "2.5.1",
- "@edx/paragon": "^20.27.0",
+ "@edx/paragon": "^20.28.0",
"core-js": "^3.21.1",
"dotenv": "^16.0.0",
"prop-types": "^15.5.10",