From 380f3be1641641a24ff2c75bf3fc27f7df85f506 Mon Sep 17 00:00:00 2001 From: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com> Date: Thu, 24 Apr 2025 01:24:27 +0500 Subject: [PATCH] feat: added markdown editor for editing problems in markdown format (#1805) --- package-lock.json | 26 + package.json | 1 + src/editors/Editor.tsx | 3 + src/editors/EditorContainer.test.jsx | 7 + src/editors/EditorContainer.tsx | 5 + src/editors/EditorPage.tsx | 3 + .../EditorContainer.test.jsx.snap | 1 + .../__snapshots__/index.test.jsx.snap | 18 +- .../EditProblemView/SettingsWidget/hooks.js | 6 +- .../SettingsWidget/hooks.test.js | 10 +- .../EditProblemView/SettingsWidget/index.jsx | 15 +- .../SettingsWidget/index.test.jsx | 2 +- .../SettingsWidget/messages.js | 38 +- .../settingsComponents/SwitchEditorCard.jsx | 70 +++ .../SwitchEditorCard.test.jsx | 30 ++ .../SwitchToAdvancedEditorCard.jsx | 63 --- .../SwitchToAdvancedEditorCard.test.jsx | 25 - .../SwitchEditorCard.test.jsx.snap | 44 ++ .../SwitchToAdvancedEditorCard.test.jsx.snap | 60 --- .../__snapshots__/index.test.jsx.snap | 83 ++- .../components/EditProblemView/hooks.js | 43 +- .../components/EditProblemView/hooks.test.js | 19 + .../components/EditProblemView/index.jsx | 10 +- .../components/EditProblemView/index.test.jsx | 34 +- .../components/SelectTypeModal/hooks.js | 3 +- .../components/SelectTypeModal/hooks.test.js | 10 +- .../constants/basicOlxTemplates/textInput.js | 9 - .../dropdown.js | 10 +- .../index.js | 0 .../multiSelect.js | 10 +- .../numeric.js | 6 +- .../singleSelect.js | 9 +- .../basicProblemTemplates/textInput.js | 13 + src/editors/data/constants/problem.ts | 19 +- src/editors/data/redux/app/reducer.test.js | 1 + src/editors/data/redux/app/reducer.ts | 2 + src/editors/data/redux/app/selectors.test.ts | 1 + src/editors/data/redux/app/selectors.ts | 1 + src/editors/data/redux/index.ts | 3 + src/editors/data/redux/problem/reducers.ts | 2 + .../data/redux/problem/selectors.test.ts | 1 + src/editors/data/redux/problem/selectors.ts | 2 + .../data/redux/thunkActions/problem.test.ts | 66 ++- .../data/redux/thunkActions/problem.ts | 45 +- .../data/redux/thunkActions/requests.js | 2 +- src/editors/hooks.test.jsx | 2 + src/editors/hooks.ts | 2 +- .../sharedComponents/CodeEditor/hooks.js | 9 +- .../sharedComponents/CodeEditor/index.jsx | 2 +- .../CodeEditor/index.test.jsx | 4 + .../sharedComponents/RawEditor/index.jsx | 3 +- src/editors/utils/convertMarkdownToXML.js | 493 ++++++++++++++++++ .../utils/convertMarkdownToXML.test.ts | 311 +++++++++++ src/editors/utils/index.ts | 1 + 54 files changed, 1415 insertions(+), 243 deletions(-) create mode 100644 src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchEditorCard.jsx create mode 100644 src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchEditorCard.test.jsx delete mode 100644 src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchToAdvancedEditorCard.jsx delete mode 100644 src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchToAdvancedEditorCard.test.jsx create mode 100644 src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/SwitchEditorCard.test.jsx.snap delete mode 100644 src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/SwitchToAdvancedEditorCard.test.jsx.snap delete mode 100644 src/editors/data/constants/basicOlxTemplates/textInput.js rename src/editors/data/constants/{basicOlxTemplates => basicProblemTemplates}/dropdown.js (61%) rename src/editors/data/constants/{basicOlxTemplates => basicProblemTemplates}/index.js (100%) rename src/editors/data/constants/{basicOlxTemplates => basicProblemTemplates}/multiSelect.js (58%) rename src/editors/data/constants/{basicOlxTemplates => basicProblemTemplates}/numeric.js (64%) rename src/editors/data/constants/{basicOlxTemplates => basicProblemTemplates}/singleSelect.js (62%) create mode 100644 src/editors/data/constants/basicProblemTemplates/textInput.js create mode 100644 src/editors/utils/convertMarkdownToXML.js create mode 100644 src/editors/utils/convertMarkdownToXML.test.ts diff --git a/package-lock.json b/package-lock.json index ae27f0c1f..95be91b52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "AGPL-3.0", "dependencies": { "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-markdown": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lint": "^6.2.1", "@codemirror/state": "^6.0.0", @@ -2033,6 +2034,21 @@ "@lezer/javascript": "^1.0.0" } }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.2.tgz", + "integrity": "sha512-c/5MYinGbFxYl4itE9q/rgN/sMTjOr8XL5OWnC+EaRMLfCbVUmmubTJfdgpfcSS2SCaT7b+Q+xi3l6CgoE+BsA==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, "node_modules/@codemirror/lang-xml": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", @@ -3870,6 +3886,16 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@lezer/markdown": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.4.2.tgz", + "integrity": "sha512-iYewCigG/517D0xJPQd7RGaCjZAFwROiH8T9h7OTtz0bRVtkxzFhGBFJ9JGKgBBs4uuo1cvxzyQ5iKhDLMcLUQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0" + } + }, "node_modules/@lezer/xml": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", diff --git a/package.json b/package.json index a25790ea7..19334d346 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lang-markdown": "^6.0.0", "@codemirror/lint": "^6.2.1", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", diff --git a/src/editors/Editor.tsx b/src/editors/Editor.tsx index 69a898eaf..404fe6c01 100644 --- a/src/editors/Editor.tsx +++ b/src/editors/Editor.tsx @@ -13,6 +13,7 @@ import AdvancedEditor from './AdvancedEditor'; export interface Props extends EditorComponent { blockType: string; blockId: string | null; + isMarkdownEditorEnabledForCourse: boolean; learningContextId: string | null; lmsEndpointUrl: string | null; studioEndpointUrl: string | null; @@ -23,6 +24,7 @@ const Editor: React.FC = ({ learningContextId, blockType, blockId, + isMarkdownEditorEnabledForCourse, lmsEndpointUrl, studioEndpointUrl, onClose = null, @@ -34,6 +36,7 @@ const Editor: React.FC = ({ data: { blockId, blockType, + isMarkdownEditorEnabledForCourse, learningContextId, lmsEndpointUrl, studioEndpointUrl, diff --git a/src/editors/EditorContainer.test.jsx b/src/editors/EditorContainer.test.jsx index 436f81c3c..b9076dbf5 100644 --- a/src/editors/EditorContainer.test.jsx +++ b/src/editors/EditorContainer.test.jsx @@ -24,6 +24,13 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ }), })); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: () => ({ + useReactMarkdownEditor: true, // or false depending on the test + }), +})); + const props = { learningContextId: 'cOuRsEId' }; describe('Editor Container', () => { diff --git a/src/editors/EditorContainer.tsx b/src/editors/EditorContainer.tsx index c3962cf3b..6b808aba1 100644 --- a/src/editors/EditorContainer.tsx +++ b/src/editors/EditorContainer.tsx @@ -5,11 +5,13 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, Hyperlink } from '@openedx/paragon'; import { Warning as WarningIcon } from '@openedx/paragon/icons'; +import { useSelector } from 'react-redux'; import EditorPage from './EditorPage'; import AlertMessage from '../generic/alert-message'; import messages from './messages'; import { getLibraryId } from '../generic/key-utils'; import { createCorrectInternalRoute } from '../utils'; +import { getWaffleFlags } from '../data/selectors'; interface Props { /** Course ID or Library ID */ @@ -37,6 +39,8 @@ const EditorContainer: React.FC = ({ const location = useLocation(); const [searchParams] = useSearchParams(); const upstreamLibRef = searchParams.get('upstreamLibRef'); + const waffleFlags = useSelector(getWaffleFlags); + const isMarkdownEditorEnabledForCourse = waffleFlags?.useReactMarkdownEditor; if (blockType === undefined || blockId === undefined) { // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. @@ -76,6 +80,7 @@ const EditorContainer: React.FC = ({ courseId={learningContextId} blockType={blockType} blockId={blockId} + isMarkdownEditorEnabledForCourse={isMarkdownEditorEnabledForCourse} studioEndpointUrl={getConfig().STUDIO_BASE_URL} lmsEndpointUrl={getConfig().LMS_BASE_URL} onClose={onClose ? () => onClose(location.state?.from) : null} diff --git a/src/editors/EditorPage.tsx b/src/editors/EditorPage.tsx index a0dd36595..bb78903f4 100644 --- a/src/editors/EditorPage.tsx +++ b/src/editors/EditorPage.tsx @@ -11,6 +11,7 @@ interface Props extends EditorComponent { blockId?: string; blockType: string; courseId: string; + isMarkdownEditorEnabledForCourse?: boolean; lmsEndpointUrl?: string; studioEndpointUrl?: string; fullScreen?: boolean; @@ -25,6 +26,7 @@ const EditorPage: React.FC = ({ courseId, blockType, blockId = null, + isMarkdownEditorEnabledForCourse = false, lmsEndpointUrl = null, studioEndpointUrl = null, onClose = null, @@ -45,6 +47,7 @@ const EditorPage: React.FC = ({ learningContextId: courseId, blockType, blockId, + isMarkdownEditorEnabledForCourse, lmsEndpointUrl, studioEndpointUrl, returnFunction, diff --git a/src/editors/__snapshots__/EditorContainer.test.jsx.snap b/src/editors/__snapshots__/EditorContainer.test.jsx.snap index d73484841..71043325c 100644 --- a/src/editors/__snapshots__/EditorContainer.test.jsx.snap +++ b/src/editors/__snapshots__/EditorContainer.test.jsx.snap @@ -60,6 +60,7 @@ exports[`Editor Container snapshots rendering correctly with expected Input 1`] blockId="company-id1" blockType="html" courseId="cOuRsEId" + isMarkdownEditorEnabledForCourse={true} lmsEndpointUrl="http://localhost:18000" onClose={null} returnFunction={null} diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/__snapshots__/index.test.jsx.snap index 8ae716816..7fcd593f0 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/__snapshots__/index.test.jsx.snap @@ -52,7 +52,8 @@ exports[`SettingsWidget isLibrary snapshot: renders Settings widget for Advanced
-
@@ -113,7 +114,8 @@ exports[`SettingsWidget isLibrary snapshot: renders Settings widget page 1`] = `
-
@@ -174,7 +176,8 @@ exports[`SettingsWidget isLibrary snapshot: renders Settings widget page advance
-
@@ -261,7 +264,8 @@ exports[`SettingsWidget snapshot snapshot: renders Settings widget for Advanced
-
@@ -348,7 +352,8 @@ exports[`SettingsWidget snapshot snapshot: renders Settings widget page 1`] = `
-
@@ -435,7 +440,8 @@ exports[`SettingsWidget snapshot snapshot: renders Settings widget page advanced
-
diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js index dffc721af..81db5e4b4 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js @@ -322,11 +322,11 @@ export const typeRowHooks = ({ }; }; -export const confirmSwitchToAdvancedEditor = ({ - switchToAdvancedEditor, +export const handleConfirmEditorSwitch = ({ + switchEditor, setConfirmOpen, }) => { - switchToAdvancedEditor(); + switchEditor(); setConfirmOpen(false); window.scrollTo({ top: 0, diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.test.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.test.js index 2576025a9..37e876a28 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.test.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.test.js @@ -382,15 +382,15 @@ describe('Problem settings hooks', () => { expect(typeRowProps.updateField).toHaveBeenCalledWith({ problemType: ProblemTypeKeys.TEXTINPUT }); }); }); - test('test confirmSwitchToAdvancedEditor hook', () => { - const switchToAdvancedEditor = jest.fn(); + test('test handleConfirmEditorSwitch hook', () => { + const switchEditor = jest.fn(); const setConfirmOpen = jest.fn(); window.scrollTo = jest.fn(); - hooks.confirmSwitchToAdvancedEditor({ - switchToAdvancedEditor, + hooks.handleConfirmEditorSwitch({ + switchEditor, setConfirmOpen, }); - expect(switchToAdvancedEditor).toHaveBeenCalled(); + expect(switchEditor).toHaveBeenCalled(); expect(setConfirmOpen).toHaveBeenCalledWith(false); expect(window.scrollTo).toHaveBeenCalled(); }); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.jsx index 6519efe88..763cc5151 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.jsx @@ -14,7 +14,7 @@ import TimerCard from './settingsComponents/TimerCard'; import TypeCard from './settingsComponents/TypeCard'; import ToleranceCard from './settingsComponents/Tolerance'; import GroupFeedbackCard from './settingsComponents/GroupFeedback/index'; -import SwitchToAdvancedEditorCard from './settingsComponents/SwitchToAdvancedEditorCard'; +import SwitchEditorCard from './settingsComponents/SwitchEditorCard'; import messages from './messages'; import { showAdvancedSettingsCards } from './hooks'; @@ -39,9 +39,9 @@ const SettingsWidget = ({ images, isLibrary, learningContextId, + showMarkdownEditorButton, }) => { const { isAdvancedCardsVisible, showAdvancedCards } = showAdvancedSettingsCards(); - const feedbackCard = () => { if ([ProblemTypeKeys.MULTISELECT].includes(problemType)) { return ( @@ -153,8 +153,14 @@ const SettingsWidget = ({ )}
- +
+ { showMarkdownEditorButton + && ( +
+ +
+ )} @@ -196,6 +202,7 @@ SettingsWidget.propTypes = { isLibrary: PropTypes.bool.isRequired, // eslint-disable-next-line settings: PropTypes.any.isRequired, + showMarkdownEditorButton: PropTypes.bool.isRequired, }; const mapStateToProps = (state) => ({ @@ -208,6 +215,8 @@ const mapStateToProps = (state) => ({ images: selectors.app.images(state), isLibrary: selectors.app.isLibrary(state), learningContextId: selectors.app.learningContextId(state), + showMarkdownEditorButton: selectors.app.isMarkdownEditorEnabledForCourse(state) + && selectors.problem.rawMarkdown(state), }); export const mapDispatchToProps = { diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.test.jsx index db7834492..955bc8241 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.test.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.test.jsx @@ -17,7 +17,7 @@ jest.mock('./settingsComponents/HintsCard', () => 'HintsCard'); jest.mock('./settingsComponents/ResetCard', () => 'ResetCard'); jest.mock('./settingsComponents/ScoringCard', () => 'ScoringCard'); jest.mock('./settingsComponents/ShowAnswerCard', () => 'ShowAnswerCard'); -jest.mock('./settingsComponents/SwitchToAdvancedEditorCard', () => 'SwitchToAdvancedEditorCard'); +jest.mock('./settingsComponents/SwitchEditorCard', () => 'SwitchEditorCard'); jest.mock('./settingsComponents/TimerCard', () => 'TimerCard'); jest.mock('./settingsComponents/TypeCard', () => 'TypeCard'); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/messages.js index 26c5414a1..78d8cd565 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/messages.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/messages.js @@ -157,26 +157,46 @@ const messages = defineMessages({ defaultMessage: 'Type', description: 'Type settings card title', }, - SwitchButtonLabel: { - id: 'authoring.problemeditor.settings.switchtoadvancededitor.label', + 'SwitchButtonLabel-advanced': { + id: 'authoring.problemeditor.settings.switchtoeditor.label.advanced', defaultMessage: 'Switch to advanced editor', - description: 'button to switch to the advanced mode of the editor.', + description: 'button to switch to the advanced mode of the editor', }, - ConfirmSwitchMessage: { - id: 'authoring.problemeditor.settings.switchtoadvancededitor.ConfirmSwitchMessage', + 'SwitchButtonLabel-markdown': { + id: 'authoring.problemeditor.settings.switchtoeditor.label.markdown', + defaultMessage: 'Switch to markdown editor', + description: 'button to switch to the markdown editor', + }, + 'ConfirmSwitchMessage-advanced': { + id: 'authoring.problemeditor.settings.switchtoeditor.ConfirmSwitchMessage.advanced', defaultMessage: 'If you use the advanced editor, this problem will be converted to OLX and you will not be able to return to the simple editor.', description: 'message to confirm that a user wants to use the advanced editor', }, - ConfirmSwitchMessageTitle: { - id: 'authoring.problemeditor.settings.switchtoadvancededitor.ConfirmSwitchMessageTitle', + 'ConfirmSwitchMessage-markdown': { + id: 'authoring.problemeditor.settings.switchtoeditor.ConfirmSwitchMessage.markdown', + defaultMessage: 'If you use the markdown editor, this problem will be converted to markdown and you will not be able to return to the simple editor.', + description: 'message to confirm that a user wants to use the advanced editor', + }, + 'ConfirmSwitchMessageTitle-advanced': { + id: 'authoring.problemeditor.settings.switchtoeditor.ConfirmSwitchMessageTitle.advanced', defaultMessage: 'Convert to OLX?', description: 'message to confirm that a user wants to use the advanced editor', }, - ConfirmSwitchButtonLabel: { - id: 'authoring.problemeditor.settings.switchtoadvancededitor.ConfirmSwitchButtonLabel', + 'ConfirmSwitchMessageTitle-markdown': { + id: 'authoring.problemeditor.settings.switchtoeditor.ConfirmSwitchMessageTitle.markdown', + defaultMessage: 'Convert to Markdown?', + description: 'message to confirm that a user wants to use the markdown editor', + }, + 'ConfirmSwitchButtonLabel-advanced': { + id: 'authoring.problemeditor.settings.switchtoeditor.ConfirmSwitchButtonLabel.advanced', defaultMessage: 'Switch to advanced editor', description: 'message to confirm that a user wants to use the advanced editor', }, + 'ConfirmSwitchButtonLabel-markdown': { + id: 'authoring.problemeditor.settings.switchtoeditor.ConfirmSwitchButtonLabel.markdown', + defaultMessage: 'Switch to markdown editor', + description: 'message to confirm that a user wants to use the markdown editor', + }, explanationInputLabel: { id: 'authoring.problemeditor.settings.showAnswer.explanation.inputLabel', defaultMessage: 'Explanation', diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchEditorCard.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchEditorCard.jsx new file mode 100644 index 000000000..9326b5e5d --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchEditorCard.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Card } from '@openedx/paragon'; +import PropTypes from 'prop-types'; +import messages from '../messages'; +import { selectors, thunkActions } from '../../../../../../data/redux'; +import BaseModal from '../../../../../../sharedComponents/BaseModal'; +import Button from '../../../../../../sharedComponents/Button'; +import { handleConfirmEditorSwitch } from '../hooks'; +import { ProblemTypeKeys } from '../../../../../../data/constants/problem'; + +const SwitchEditorCard = ({ + editorType, + problemType, + switchEditor, + isMarkdownEditorEnabled, +}) => { + const [isConfirmOpen, setConfirmOpen] = React.useState(false); + + if (isMarkdownEditorEnabled || problemType === ProblemTypeKeys.ADVANCED) { return null; } + + return ( + + { setConfirmOpen(false); }} + title={} + confirmAction={( + + )} + size="md" + > + + + + + ); +}; + +SwitchEditorCard.propTypes = { + switchEditor: PropTypes.func.isRequired, + isMarkdownEditorEnabled: PropTypes.bool.isRequired, + problemType: PropTypes.string.isRequired, + editorType: PropTypes.string.isRequired, +}; + +export const mapStateToProps = (state) => ({ + isMarkdownEditorEnabled: selectors.problem.isMarkdownEditorEnabled(state) + && selectors.app.isMarkdownEditorEnabledForCourse(state), +}); +export const mapDispatchToProps = { + switchEditor: thunkActions.problem.switchEditor, +}; + +export const SwitchEditorCardInternal = SwitchEditorCard; // For testing only +export default connect(mapStateToProps, mapDispatchToProps)(SwitchEditorCard); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchEditorCard.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchEditorCard.test.jsx new file mode 100644 index 000000000..6ee6fbc4d --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchEditorCard.test.jsx @@ -0,0 +1,30 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { SwitchEditorCardInternal as SwitchEditorCard, mapDispatchToProps } from './SwitchEditorCard'; +import { thunkActions } from '../../../../../../data/redux'; + +describe('SwitchEditorCard snapshot', () => { + const mockSwitchEditor = jest.fn().mockName('switchEditor'); + test('snapshot: SwitchEditorCard', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + test('snapshot: SwitchEditorCard returns null for advanced problems', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + test('snapshot: SwitchEditorCard returns null when editor is Markdown', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + + describe('mapDispatchToProps', () => { + test('updateField from actions.problem.updateField', () => { + expect(mapDispatchToProps.switchEditor).toEqual(thunkActions.problem.switchEditor); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchToAdvancedEditorCard.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchToAdvancedEditorCard.jsx deleted file mode 100644 index 90454579e..000000000 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchToAdvancedEditorCard.jsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Card } from '@openedx/paragon'; -import PropTypes from 'prop-types'; -import messages from '../messages'; -import { thunkActions } from '../../../../../../data/redux'; -import BaseModal from '../../../../../../sharedComponents/BaseModal'; -import Button from '../../../../../../sharedComponents/Button'; -import { confirmSwitchToAdvancedEditor } from '../hooks'; -import { ProblemTypeKeys } from '../../../../../../data/constants/problem'; - -const SwitchToAdvancedEditorCard = ({ - problemType, - switchToAdvancedEditor, -}) => { - const [isConfirmOpen, setConfirmOpen] = React.useState(false); - - if (problemType === ProblemTypeKeys.ADVANCED) { return null; } - - return ( - - { setConfirmOpen(false); }} - title={()} - confirmAction={( - - )} - size="md" - > - - - - - ); -}; - -SwitchToAdvancedEditorCard.propTypes = { - switchToAdvancedEditor: PropTypes.func.isRequired, - problemType: PropTypes.string.isRequired, -}; - -export const mapStateToProps = () => ({ -}); -export const mapDispatchToProps = { - switchToAdvancedEditor: thunkActions.problem.switchToAdvancedEditor, -}; - -export const SwitchToAdvancedEditorCardInternal = SwitchToAdvancedEditorCard; // For testing only -export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(SwitchToAdvancedEditorCard)); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchToAdvancedEditorCard.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchToAdvancedEditorCard.test.jsx deleted file mode 100644 index b9d59f27f..000000000 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchToAdvancedEditorCard.test.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import 'CourseAuthoring/editors/setupEditorTest'; -import React from 'react'; -import { shallow } from '@edx/react-unit-test-utils'; -import { SwitchToAdvancedEditorCardInternal as SwitchToAdvancedEditorCard, mapDispatchToProps } from './SwitchToAdvancedEditorCard'; -import { thunkActions } from '../../../../../../data/redux'; - -describe('SwitchToAdvancedEditorCard snapshot', () => { - const mockSwitchToAdvancedEditor = jest.fn().mockName('switchToAdvancedEditor'); - test('snapshot: SwitchToAdvancedEditorCard', () => { - expect( - shallow().snapshot, - ).toMatchSnapshot(); - }); - test('snapshot: SwitchToAdvancedEditorCard returns null', () => { - expect( - shallow().snapshot, - ).toMatchSnapshot(); - }); - - describe('mapDispatchToProps', () => { - test('updateField from actions.problem.updateField', () => { - expect(mapDispatchToProps.switchToAdvancedEditor).toEqual(thunkActions.problem.switchToAdvancedEditor); - }); - }); -}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/SwitchEditorCard.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/SwitchEditorCard.test.jsx.snap new file mode 100644 index 000000000..ecb8f3d1e --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/SwitchEditorCard.test.jsx.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SwitchEditorCard snapshot snapshot: SwitchEditorCard 1`] = ` + + + + + } + footerAction={null} + headerComponent={null} + hideCancelButton={false} + isFullscreenScroll={true} + isOpen={false} + size="md" + title={} + > + + + + +`; + +exports[`SwitchEditorCard snapshot snapshot: SwitchEditorCard returns null for advanced problems 1`] = `null`; + +exports[`SwitchEditorCard snapshot snapshot: SwitchEditorCard returns null when editor is Markdown 1`] = `null`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/SwitchToAdvancedEditorCard.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/SwitchToAdvancedEditorCard.test.jsx.snap deleted file mode 100644 index ee45799a9..000000000 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/SwitchToAdvancedEditorCard.test.jsx.snap +++ /dev/null @@ -1,60 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SwitchToAdvancedEditorCard snapshot snapshot: SwitchToAdvancedEditorCard 1`] = ` - - - - - } - footerAction={null} - headerComponent={null} - hideCancelButton={false} - isFullscreenScroll={true} - isOpen={false} - size="md" - title={ - - } - > - - - - -`; - -exports[`SwitchToAdvancedEditorCard snapshot snapshot: SwitchToAdvancedEditorCard returns null 1`] = `null`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/__snapshots__/index.test.jsx.snap index 987a73112..f3577747a 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/__snapshots__/index.test.jsx.snap @@ -1,6 +1,85 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`EditorProblemView component renders raw editor 1`] = ` +exports[`EditorProblemView component renders markdown editor when isMarkdownEditorEnabled is true 1`] = ` + + + + + + } + isOpen={false} + onClose={[Function]} + title="No answer specified" + > + +
+ +
+
+ +
+
+
+
+ + + + + + +
+
+`; + +exports[`EditorProblemView component renders raw editor for advanced problem type 1`] = ` { export const parseState = ({ problem, isAdvanced, + isMarkdownEditorEnabled, ref, lmsEndpointUrl, }) => () => { - const rawOLX = ref?.current?.state.doc.toString(); - const editorObject = fetchEditorContent({ format: '' }); - const reactOLXParser = new ReactStateOLXParser({ problem, editorObject }); + // Constructs the save payload by parsing the current state of the problem editor. + // If the Markdown editor is enabled, the editor content is converted to OLX using convertMarkdownToXml. + // For advanced problems, raw editor content is used as OLX; for visual ones, it's built via ReactStateOLXParser. + // Settings are then parsed from the OLX and returned alongside the OLX content, + // including markdown incase of markdown editor. + const contentString = ref?.current?.state.doc.toString(); + const rawOLX = isMarkdownEditorEnabled ? convertMarkdownToXml(contentString) : contentString; + let reactBuiltOlx; + if (!isMarkdownEditorEnabled) { + const editorObject = fetchEditorContent({ format: '' }); + const reactOLXParser = new ReactStateOLXParser({ problem, editorObject }); + reactBuiltOlx = setAssetToStaticUrl({ editorValue: reactOLXParser.buildOLX(), lmsEndpointUrl }); + } const reactSettingsParser = new ReactStateSettingsParser({ problem, rawOLX }); - const reactBuiltOlx = setAssetToStaticUrl({ editorValue: reactOLXParser.buildOLX(), lmsEndpointUrl }); + const settings = isAdvanced ? reactSettingsParser.parseRawOlxSettings() : reactSettingsParser.getSettings(); return { - settings: isAdvanced ? reactSettingsParser.parseRawOlxSettings() : reactSettingsParser.getSettings(), - olx: isAdvanced ? rawOLX : reactBuiltOlx, + settings: { + ...settings, + ...(isMarkdownEditorEnabled && { markdown: contentString }), + markdown_edited: isMarkdownEditorEnabled, + }, + olx: isAdvanced || isMarkdownEditorEnabled ? rawOLX : reactBuiltOlx, }; }; @@ -130,8 +145,11 @@ export const checkForNoAnswers = ({ openSaveWarningModal, problem }) => { return false; }; -export const checkForSettingDiscrepancy = ({ problem, ref, openSaveWarningModal }) => { - const rawOLX = ref?.current?.state.doc.toString(); +export const checkForSettingDiscrepancy = ({ + problem, ref, openSaveWarningModal, isMarkdownEditorEnabled, +}) => { + const contentString = ref?.current?.state.doc.toString(); + const rawOLX = isMarkdownEditorEnabled ? convertMarkdownToXml(contentString) : contentString; const reactSettingsParser = new ReactStateSettingsParser({ problem, rawOLX }); const problemSettings = reactSettingsParser.getSettings(); const rawOlxSettings = reactSettingsParser.parseRawOlxSettings(); @@ -154,23 +172,26 @@ export const getContent = ({ problemState, openSaveWarningModal, isAdvancedProblemType, + isMarkdownEditorEnabled, editorRef, lmsEndpointUrl, }) => { const problem = problemState; - const hasNoAnswers = isAdvancedProblemType ? false : checkForNoAnswers({ + const hasNoAnswers = isAdvancedProblemType || isMarkdownEditorEnabled ? false : checkForNoAnswers({ problem, openSaveWarningModal, }); - const hasMismatchedSettings = isAdvancedProblemType ? checkForSettingDiscrepancy({ + const hasMismatchedSettings = isAdvancedProblemType || isMarkdownEditorEnabled ? checkForSettingDiscrepancy({ ref: editorRef, problem, openSaveWarningModal, + isMarkdownEditorEnabled, }) : false; if (!hasNoAnswers && !hasMismatchedSettings) { const data = parseState({ isAdvanced: isAdvancedProblemType, ref: editorRef, + isMarkdownEditorEnabled, problem, lmsEndpointUrl, })(); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.test.js b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.test.js index e45206e1c..2184782e3 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.test.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.test.js @@ -2,6 +2,8 @@ import { ProblemTypeKeys, ShowAnswerTypesKeys } from '../../../../data/constants import * as hooks from './hooks'; import { MockUseState } from '../../../../testUtils'; +const mockRawMarkdown = 'Raw Markdown'; +const mockMarkdownToXML = 'Raw Markdown'; const mockRawOLX = 'rawOLX'; const mockBuiltOLX = 'builtOLX'; const mockGetSettings = { @@ -49,6 +51,7 @@ const problemState = { const toStringMock = () => mockRawOLX; const refMock = { current: { state: { doc: { toString: toStringMock } } } }; +const markdownRefMock = { current: { state: { doc: { toString: () => mockRawMarkdown } } } }; jest.mock('../../data/ReactStateOLXParser', () => ( jest.fn().mockImplementation(() => ({ @@ -56,6 +59,11 @@ jest.mock('../../data/ReactStateOLXParser', () => ( })) )); +jest.mock('../../../../utils', () => ({ + ...jest.requireActual('../../../../utils'), + convertMarkdownToXml: jest.fn().mockImplementation(() => mockMarkdownToXML), +})); + const hookState = new MockUseState(hooks); describe('saveWarningModalToggle', () => { @@ -158,6 +166,17 @@ describe('EditProblemView hooks parseState', () => { })(); expect(res.olx).toBe(mockRawOLX); }); + it('markdown problem', () => { + const res = hooks.parseState({ + problem: problemState, + isAdvanced: false, + isMarkdownEditorEnabled: true, + ref: markdownRefMock, + assets: {}, + })(); + expect(res.settings.markdown).toBe(mockRawMarkdown); + expect(res.olx).toBe(mockMarkdownToXML); + }); }); describe('checkNoAnswers', () => { const openSaveWarningModal = jest.fn(); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx index fbeb086b2..c584aa7b9 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx @@ -30,6 +30,7 @@ const EditProblemView = ({ returnFunction, // redux problemType, + isMarkdownEditorEnabled, problemState, lmsEndpointUrl, returnUrl, @@ -57,6 +58,7 @@ const EditProblemView = ({ problemState, openSaveWarningModal, isAdvancedProblemType, + isMarkdownEditorEnabled, editorRef, lmsEndpointUrl, })} @@ -79,6 +81,7 @@ const EditProblemView = ({ content: parseState({ problem: problemState, isAdvanced: isAdvancedProblemType, + isMarkdown: isMarkdownEditorEnabled, ref: editorRef, lmsEndpointUrl, })(), @@ -107,9 +110,9 @@ const EditProblemView = ({ )}
- {isAdvancedProblemType ? ( + {isAdvancedProblemType || isMarkdownEditorEnabled ? ( - + ) : ( @@ -141,6 +144,7 @@ EditProblemView.propTypes = { lmsEndpointUrl: PropTypes.string, returnUrl: PropTypes.string.isRequired, isDirty: PropTypes.bool, + isMarkdownEditorEnabled: PropTypes.bool, // injected intl: intlShape.isRequired, }; @@ -150,6 +154,8 @@ export const mapStateToProps = (state) => ({ lmsEndpointUrl: selectors.app.lmsEndpointUrl(state), returnUrl: selectors.app.returnUrl(state), problemType: selectors.problem.problemType(state), + isMarkdownEditorEnabled: selectors.problem.isMarkdownEditorEnabled(state) + && selectors.app.isMarkdownEditorEnabledForCourse(state), problemState: selectors.problem.completeState(state), isDirty: selectors.problem.isDirty(state), }); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/index.test.jsx index a55d025ba..ca9174c8c 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/index.test.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/index.test.jsx @@ -22,15 +22,41 @@ describe('EditorProblemView component', () => { expect(wrapper.instance.findByType(RawEditor).length).toBe(0); }); - test('renders raw editor', () => { + test('renders raw editor for advanced problem type', () => { const wrapper = shallow(...' }} assets={{}} intl={{ formatMessage }} />); + expect(wrapper.snapshot).toMatchSnapshot(); - expect(wrapper.instance.findByType(AnswerWidget).length).toBe(0); - expect(wrapper.instance.findByType(RawEditor).length).toBe(1); + + const rawEditor = wrapper.instance.findByType(RawEditor); + expect(rawEditor.length).toBe(1); + expect(rawEditor[0].props.lang).toBe('xml'); + + const answerWidget = wrapper.instance.findByType(AnswerWidget); + expect(answerWidget.length).toBe(0); // since advanced problem type skips AnswerWidget + }); + + test('renders markdown editor when isMarkdownEditorEnabled is true', () => { + const wrapper = shallow(); + + expect(wrapper.snapshot).toMatchSnapshot(); + + const rawEditor = wrapper.instance.findByType(RawEditor); + expect(rawEditor.length).toBe(1); + expect(rawEditor[0].props.lang).toBe('markdown'); + + const answerWidget = wrapper.instance.findByType(AnswerWidget); + expect(answerWidget.length).toBe(0); // since markdown view skips AnswerWidget }); }); diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.js b/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.js index cc4cc538f..10aa2edc5 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.js +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.js @@ -16,6 +16,7 @@ export const onSelect = ({ setBlockTitle(AdvanceProblems[selected].title); } else { const newOLX = ProblemTypes[selected].template; + const newMarkdown = ProblemTypes[selected].markdownTemplate; const newState = getDataFromOlx({ rawOLX: newOLX, rawSettings: { @@ -26,7 +27,7 @@ export const onSelect = ({ }, defaultSettings: snakeCaseKeys(defaultSettings), }); - updateField(newState); + updateField({ ...newState, rawMarkdown: newMarkdown }); setBlockTitle(ProblemTypes[selected].title); } }; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.test.js b/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.test.js index 1de51371c..4e246f049 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.test.js +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.test.js @@ -40,14 +40,14 @@ describe('SelectTypeModal hooks', () => { }); expect(mocksetBlockTitle).toHaveBeenCalledWith(AdvanceProblems[mockAdvancedSelected].title); }); - test('updateField is called with selected on visual propblems', () => { + test('updateField is called with selected on visual problems', () => { hooks.onSelect({ selected: mockSelected, updateField: mockUpdateField, setBlockTitle: mocksetBlockTitle, defaultSettings: mockDefaultSettings, })(); - // const testOlXParser = new OLXParser(ProblemTypes[mockSelected].template); + const testState = getDataFromOlx({ rawOLX: ProblemTypes[mockSelected].template, rawSettings: { @@ -58,7 +58,11 @@ describe('SelectTypeModal hooks', () => { }, defaultSettings: mockDefaultSettings, }); - expect(mockUpdateField).toHaveBeenCalledWith(testState); + + expect(mockUpdateField).toHaveBeenCalledWith({ + ...testState, + rawMarkdown: ProblemTypes[mockSelected].markdownTemplate, + }); expect(mocksetBlockTitle).toHaveBeenCalledWith(ProblemTypes[mockSelected].title); }); }); diff --git a/src/editors/data/constants/basicOlxTemplates/textInput.js b/src/editors/data/constants/basicOlxTemplates/textInput.js deleted file mode 100644 index a1ece7a16..000000000 --- a/src/editors/data/constants/basicOlxTemplates/textInput.js +++ /dev/null @@ -1,9 +0,0 @@ -/* eslint-disable */ -const textInput =` - - - - -` - -export default textInput; \ No newline at end of file diff --git a/src/editors/data/constants/basicOlxTemplates/dropdown.js b/src/editors/data/constants/basicProblemTemplates/dropdown.js similarity index 61% rename from src/editors/data/constants/basicOlxTemplates/dropdown.js rename to src/editors/data/constants/basicProblemTemplates/dropdown.js index e66e0d1f0..ec51411e7 100644 --- a/src/editors/data/constants/basicOlxTemplates/dropdown.js +++ b/src/editors/data/constants/basicProblemTemplates/dropdown.js @@ -1,5 +1,5 @@ /* eslint-disable */ -const dropdown = ` +const olx = ` @@ -9,4 +9,10 @@ const dropdown = ` ` -export default dropdown; +const markdown = `[[ +an incorrect answer +(the correct answer) +an incorrect answer +]]` + +export default { olx, markdown }; diff --git a/src/editors/data/constants/basicOlxTemplates/index.js b/src/editors/data/constants/basicProblemTemplates/index.js similarity index 100% rename from src/editors/data/constants/basicOlxTemplates/index.js rename to src/editors/data/constants/basicProblemTemplates/index.js diff --git a/src/editors/data/constants/basicOlxTemplates/multiSelect.js b/src/editors/data/constants/basicProblemTemplates/multiSelect.js similarity index 58% rename from src/editors/data/constants/basicOlxTemplates/multiSelect.js rename to src/editors/data/constants/basicProblemTemplates/multiSelect.js index a743ae684..7ed6963bb 100644 --- a/src/editors/data/constants/basicOlxTemplates/multiSelect.js +++ b/src/editors/data/constants/basicProblemTemplates/multiSelect.js @@ -1,5 +1,5 @@ /* eslint-disable */ - const multiSelect= ` + const olx= ` @@ -9,4 +9,10 @@ ` -export default multiSelect; \ No newline at end of file +const markdown = `[x] a correct answer +[ ] an incorrect answer +[ ] an incorrect answer +[x] a correct answer +` + +export default { olx, markdown }; diff --git a/src/editors/data/constants/basicOlxTemplates/numeric.js b/src/editors/data/constants/basicProblemTemplates/numeric.js similarity index 64% rename from src/editors/data/constants/basicOlxTemplates/numeric.js rename to src/editors/data/constants/basicProblemTemplates/numeric.js index 5b7a915c9..d39c9a6e5 100644 --- a/src/editors/data/constants/basicOlxTemplates/numeric.js +++ b/src/editors/data/constants/basicProblemTemplates/numeric.js @@ -1,9 +1,11 @@ /* eslint-disable */ -const numeric = ` +const olx = ` ` -export default numeric; \ No newline at end of file +const markdown = `= 100 +-5` + +export default { olx, markdown }; diff --git a/src/editors/data/constants/basicOlxTemplates/singleSelect.js b/src/editors/data/constants/basicProblemTemplates/singleSelect.js similarity index 62% rename from src/editors/data/constants/basicOlxTemplates/singleSelect.js rename to src/editors/data/constants/basicProblemTemplates/singleSelect.js index ea0e37036..ef4e2f262 100644 --- a/src/editors/data/constants/basicOlxTemplates/singleSelect.js +++ b/src/editors/data/constants/basicProblemTemplates/singleSelect.js @@ -1,5 +1,5 @@ /* eslint-disable */ -const singleSelect = ` +const olx = ` @@ -9,4 +9,9 @@ const singleSelect = ` ` -export default singleSelect; \ No newline at end of file +const markdown = `( ) an incorrect answer +(x) the correct answer +( ) an incorrect answer +` + +export default { olx, markdown }; \ No newline at end of file diff --git a/src/editors/data/constants/basicProblemTemplates/textInput.js b/src/editors/data/constants/basicProblemTemplates/textInput.js new file mode 100644 index 000000000..7240bfb18 --- /dev/null +++ b/src/editors/data/constants/basicProblemTemplates/textInput.js @@ -0,0 +1,13 @@ +/* eslint-disable */ +const olx =` + + + + +` + +const markdown = `= the correct answer +or= optional acceptable variant of the correct answer +` + +export default { olx, markdown }; diff --git a/src/editors/data/constants/problem.ts b/src/editors/data/constants/problem.ts index dd5b92cee..1fd3bd908 100644 --- a/src/editors/data/constants/problem.ts +++ b/src/editors/data/constants/problem.ts @@ -5,7 +5,7 @@ import dropdown from '../images/dropdown.png'; import numericalInput from '../images/numericalInput.png'; import textInput from '../images/textInput.png'; import advancedOlxTemplates from './advancedOlxTemplates'; -import basicOlxTemplates from './basicOlxTemplates'; +import basicProblemTemplates from './basicProblemTemplates'; export const ProblemTypeKeys = StrictDict({ SINGLESELECT: 'multiplechoiceresponse', @@ -26,8 +26,8 @@ export const ProblemTypes = StrictDict({ helpLink: 'https://docs.openedx.org/en/latest/educators/concepts/exercise_tools/about_multi_select.html', prev: ProblemTypeKeys.TEXTINPUT, next: ProblemTypeKeys.MULTISELECT, - template: basicOlxTemplates.singleSelect, - + template: basicProblemTemplates.singleSelect.olx, + markdownTemplate: basicProblemTemplates.singleSelect.markdown, }, [ProblemTypeKeys.MULTISELECT]: { title: 'Multi-select', @@ -37,7 +37,8 @@ export const ProblemTypes = StrictDict({ helpLink: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_multi_select.html', next: ProblemTypeKeys.DROPDOWN, prev: ProblemTypeKeys.SINGLESELECT, - template: basicOlxTemplates.multiSelect, + template: basicProblemTemplates.multiSelect.olx, + markdownTemplate: basicProblemTemplates.multiSelect.markdown, }, [ProblemTypeKeys.DROPDOWN]: { title: 'Dropdown', @@ -47,7 +48,8 @@ export const ProblemTypes = StrictDict({ helpLink: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_dropdown.html', next: ProblemTypeKeys.NUMERIC, prev: ProblemTypeKeys.MULTISELECT, - template: basicOlxTemplates.dropdown, + template: basicProblemTemplates.dropdown.olx, + markdownTemplate: basicProblemTemplates.dropdown.markdown, }, [ProblemTypeKeys.NUMERIC]: { title: 'Numerical input', @@ -57,7 +59,8 @@ export const ProblemTypes = StrictDict({ helpLink: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/manage_numerical_input_problem.html', next: ProblemTypeKeys.TEXTINPUT, prev: ProblemTypeKeys.DROPDOWN, - template: basicOlxTemplates.numeric, + template: basicProblemTemplates.numeric.olx, + markdownTemplate: basicProblemTemplates.numeric.markdown, }, [ProblemTypeKeys.TEXTINPUT]: { title: 'Text input', @@ -67,7 +70,8 @@ export const ProblemTypes = StrictDict({ helpLink: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_text_input.html', prev: ProblemTypeKeys.NUMERIC, next: ProblemTypeKeys.SINGLESELECT, - template: basicOlxTemplates.textInput, + template: basicProblemTemplates.textInput.olx, + markdownTemplate: basicProblemTemplates.textInput.markdown, }, [ProblemTypeKeys.ADVANCED]: { title: 'Advanced Problem', @@ -238,6 +242,7 @@ export const ignoredOlxAttributes = [ // '@_markdown', // Not sure if this is safe to ignore; some tests seem to indicate it's not. '@_url_name', '@_x-is-pointer-node', + '@_markdown_edited', ] as const; // Useful for the block creation workflow. diff --git a/src/editors/data/redux/app/reducer.test.js b/src/editors/data/redux/app/reducer.test.js index 67f613ab7..d3710a5f0 100644 --- a/src/editors/data/redux/app/reducer.test.js +++ b/src/editors/data/redux/app/reducer.test.js @@ -21,6 +21,7 @@ describe('app reducer', () => { blockId: 'anID', learningContextId: 'OTHERid', blockType: 'someTYPE', + isMarkdownEditorEnabledForCourse: true, }; expect(reducer( testingState, diff --git a/src/editors/data/redux/app/reducer.ts b/src/editors/data/redux/app/reducer.ts index 91ce05256..9b3476c60 100644 --- a/src/editors/data/redux/app/reducer.ts +++ b/src/editors/data/redux/app/reducer.ts @@ -21,6 +21,7 @@ const initialState: EditorState['app'] = { videos: {}, courseDetails: {}, showRawEditor: false, + isMarkdownEditorEnabledForCourse: false, }; // eslint-disable-next-line no-unused-vars @@ -35,6 +36,7 @@ const app = createSlice({ blockId: payload.blockId, learningContextId: payload.learningContextId, blockType: payload.blockType, + isMarkdownEditorEnabledForCourse: payload.isMarkdownEditorEnabledForCourse, blockValue: null, }), setUnitUrl: (state, { payload }) => ({ ...state, unitUrl: payload }), diff --git a/src/editors/data/redux/app/selectors.test.ts b/src/editors/data/redux/app/selectors.test.ts index aab64b080..dc30e2c28 100644 --- a/src/editors/data/redux/app/selectors.test.ts +++ b/src/editors/data/redux/app/selectors.test.ts @@ -48,6 +48,7 @@ describe('app selectors unit tests', () => { simpleKeys.images, simpleKeys.videos, simpleKeys.showRawEditor, + simpleKeys.isMarkdownEditorEnabledForCourse, ].map(testSimpleSelector); }); }); diff --git a/src/editors/data/redux/app/selectors.ts b/src/editors/data/redux/app/selectors.ts index b2fd6d6ae..0d8d5cb68 100644 --- a/src/editors/data/redux/app/selectors.ts +++ b/src/editors/data/redux/app/selectors.ts @@ -26,6 +26,7 @@ export const simpleSelectors = { images: mkSimpleSelector(app => app.images), videos: mkSimpleSelector(app => app.videos), showRawEditor: mkSimpleSelector(app => app.showRawEditor), + isMarkdownEditorEnabledForCourse: mkSimpleSelector(app => app.isMarkdownEditorEnabledForCourse), }; export const returnUrl = createSelector( diff --git a/src/editors/data/redux/index.ts b/src/editors/data/redux/index.ts index 3606f99aa..5f7fb0b0d 100644 --- a/src/editors/data/redux/index.ts +++ b/src/editors/data/redux/index.ts @@ -107,6 +107,7 @@ export interface EditorState { videos: Record; courseDetails: Record; showRawEditor: boolean; + isMarkdownEditorEnabledForCourse: boolean; }, requests: Record String.fromCharCode(lastId.charCodeAt(0) const initialState: EditorState['problem'] = { rawOLX: '', + rawMarkdown: '', + isMarkdownEditorEnabled: false, problemType: null, question: '', answers: [], diff --git a/src/editors/data/redux/problem/selectors.test.ts b/src/editors/data/redux/problem/selectors.test.ts index 974bd67bc..afc9efa9c 100644 --- a/src/editors/data/redux/problem/selectors.test.ts +++ b/src/editors/data/redux/problem/selectors.test.ts @@ -32,6 +32,7 @@ describe('problem selectors unit tests', () => { simpleKeys.settings, simpleKeys.question, simpleKeys.defaultSettings, + simpleKeys.isMarkdownEditorEnabled, ].map(testSimpleSelector); }); test('simple selector completeState equals the entire state', () => { diff --git a/src/editors/data/redux/problem/selectors.ts b/src/editors/data/redux/problem/selectors.ts index b6cb5ef60..70d417b5e 100644 --- a/src/editors/data/redux/problem/selectors.ts +++ b/src/editors/data/redux/problem/selectors.ts @@ -7,6 +7,8 @@ const mkSimpleSelector = (cb: (problemState: EditorState['problem']) => T) => export const simpleSelectors = { problemType: mkSimpleSelector(problemData => problemData.problemType), + isMarkdownEditorEnabled: mkSimpleSelector(problemData => problemData.isMarkdownEditorEnabled), + rawMarkdown: mkSimpleSelector(problemData => problemData.rawMarkdown), generalFeedback: mkSimpleSelector(problemData => problemData.generalFeedback), groupFeedbackList: mkSimpleSelector(problemData => problemData.groupFeedbackList), answers: mkSimpleSelector(problemData => problemData.answers), diff --git a/src/editors/data/redux/thunkActions/problem.test.ts b/src/editors/data/redux/thunkActions/problem.test.ts index 6ccc95b29..17f7f85b0 100644 --- a/src/editors/data/redux/thunkActions/problem.test.ts +++ b/src/editors/data/redux/thunkActions/problem.test.ts @@ -3,11 +3,14 @@ import { actions } from '..'; import { initializeProblem, switchToAdvancedEditor, + switchToMarkdownEditor, + switchEditor, fetchAdvancedSettings, loadProblem, } from './problem'; import { checkboxesOLXWithFeedbackAndHintsOLX, advancedProblemOlX, blankProblemOLX } from '../../../containers/ProblemEditor/data/mockData/olxTestData'; import { ProblemTypeKeys } from '../../constants/problem'; +import * as requests from './requests'; const mockOlx = 'SOmEVALue'; const mockBuildOlx = jest.fn(() => mockOlx); @@ -23,12 +26,15 @@ jest.mock('../problem', () => ({ jest.mock('./requests', () => ({ fetchAdvancedSettings: (args) => ({ fetchAdvanceSettings: args }), + saveBlock: (args) => ({ saveBlock: args }), })); const blockValue = { data: { data: checkboxesOLXWithFeedbackAndHintsOLX.rawOLX, - metadata: {}, + metadata: { + markdown: 'SomeMarkdown', + }, }, }; @@ -47,6 +53,7 @@ describe('problem thunkActions', () => { }, app: { learningContextId: 'course-v1:org+course+run', + blockValue, }, })); }); @@ -64,22 +71,65 @@ describe('problem thunkActions', () => { actions.problem.updateField({ problemType: ProblemTypeKeys.ADVANCED, rawOLX: mockOlx }), ); }); + test('switchToMarkdownEditor dispatches correct actions', () => { + switchToMarkdownEditor()(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith( + actions.problem.updateField({ + isMarkdownEditorEnabled: true, + }), + ); + + expect(dispatch).toHaveBeenCalledWith( + requests.saveBlock({ + content: { + settings: { markdown_edited: true }, + olx: blockValue.data.data, + }, + }), + ); + }); + + describe('switchEditor', () => { + let switchToAdvancedEditorMock; + let switchToMarkdownEditorMock; + + beforeEach(() => { + switchToAdvancedEditorMock = jest.fn(); + switchToMarkdownEditorMock = jest.fn(); + // eslint-disable-next-line global-require + jest.spyOn(require('./problem'), 'switchToAdvancedEditor').mockImplementation(() => switchToAdvancedEditorMock); + // eslint-disable-next-line global-require + jest.spyOn(require('./problem'), 'switchToMarkdownEditor').mockImplementation(() => switchToMarkdownEditorMock); + }); + + test('dispatches switchToAdvancedEditor when editorType is advanced', () => { + switchEditor('advanced')(dispatch, getState); + expect(switchToAdvancedEditorMock).toHaveBeenCalledWith(dispatch, getState); + }); + + test('dispatches switchToMarkdownEditor when editorType is markdown', () => { + switchEditor('markdown')(dispatch, getState); + expect(switchToMarkdownEditorMock).toHaveBeenCalledWith(dispatch, getState); + }); + }); + describe('fetchAdvanceSettings', () => { it('dispatches fetchAdvanceSettings action', () => { - fetchAdvancedSettings({ rawOLX, rawSettings })(dispatch); + fetchAdvancedSettings({ rawOLX, rawSettings, isMarkdownEditorEnabled: true })(dispatch); [[dispatchedAction]] = dispatch.mock.calls; expect(dispatchedAction.fetchAdvanceSettings).not.toEqual(undefined); }); it('dispatches actions.problem.updateField and loadProblem on success', () => { dispatch.mockClear(); - fetchAdvancedSettings({ rawOLX, rawSettings })(dispatch); + fetchAdvancedSettings({ rawOLX, rawSettings, isMarkdownEditorEnabled: true })(dispatch); [[dispatchedAction]] = dispatch.mock.calls; dispatchedAction.fetchAdvanceSettings.onSuccess({ data: { key: 'test', max_attempts: 1 } }); expect(dispatch).toHaveBeenCalledWith(actions.problem.load(undefined)); }); it('calls loadProblem on failure', () => { dispatch.mockClear(); - fetchAdvancedSettings({ rawOLX, rawSettings })(dispatch); + fetchAdvancedSettings({ rawOLX, rawSettings, isMarkdownEditorEnabled: true })(dispatch); [[dispatchedAction]] = dispatch.mock.calls; dispatchedAction.fetchAdvanceSettings.onFailure(); expect(dispatch).toHaveBeenCalledWith(actions.problem.load(undefined)); @@ -88,12 +138,16 @@ describe('problem thunkActions', () => { describe('loadProblem', () => { test('initializeProblem advanced Problem', () => { rawOLX = advancedProblemOlX.rawOLX; - loadProblem({ rawOLX, rawSettings, defaultSettings })(dispatch); + loadProblem({ + rawOLX, rawSettings, defaultSettings, isMarkdownEditorEnabled: true, + })(dispatch); expect(dispatch).toHaveBeenCalledWith(actions.problem.load(undefined)); }); test('initializeProblem blank Problem', () => { rawOLX = blankProblemOLX.rawOLX; - loadProblem({ rawOLX, rawSettings, defaultSettings })(dispatch); + loadProblem({ + rawOLX, rawSettings, defaultSettings, isMarkdownEditorEnabled: true, + })(dispatch); expect(dispatch).toHaveBeenCalledWith(actions.problem.setEnableTypeSelection(undefined)); }); }); diff --git a/src/editors/data/redux/thunkActions/problem.ts b/src/editors/data/redux/thunkActions/problem.ts index c10360e8c..28ba7b34f 100644 --- a/src/editors/data/redux/thunkActions/problem.ts +++ b/src/editors/data/redux/thunkActions/problem.ts @@ -24,6 +24,18 @@ export const switchToAdvancedEditor = () => (dispatch, getState) => { dispatch(actions.problem.updateField({ problemType: ProblemTypeKeys.ADVANCED, rawOLX })); }; +export const switchToMarkdownEditor = () => (dispatch, getState) => { + const state = getState(); + dispatch(actions.problem.updateField({ isMarkdownEditorEnabled: true })); + const { blockValue } = state.app; + const olx = get(blockValue, 'data.data', ''); + const content = { settings: { markdown_edited: true }, olx }; + // Sending a request to save the problem block with the updated markdown_edited value + dispatch(requests.saveBlock({ content })); +}; + +export const switchEditor = (editorType) => (dispatch, getState) => (editorType === 'advanced' ? switchToAdvancedEditor : switchToMarkdownEditor)()(dispatch, getState); + export const isBlankProblem = ({ rawOLX }) => { if (['', ''].includes(rawOLX.replace(/\s/g, ''))) { return true; @@ -53,15 +65,21 @@ export const getDataFromOlx = ({ rawOLX, rawSettings, defaultSettings }) => { return { settings: parsedSettings }; }; -export const loadProblem = ({ rawOLX, rawSettings, defaultSettings }) => (dispatch) => { +export const loadProblem = ({ + rawOLX, rawSettings, defaultSettings, isMarkdownEditorEnabled, +}) => (dispatch) => { if (isBlankProblem({ rawOLX })) { dispatch(actions.problem.setEnableTypeSelection(camelizeKeys(defaultSettings))); } else { - dispatch(actions.problem.load(getDataFromOlx({ rawOLX, rawSettings, defaultSettings }))); + dispatch(actions.problem.load({ + ...getDataFromOlx({ rawOLX, rawSettings, defaultSettings }), + rawMarkdown: rawSettings.markdown, + isMarkdownEditorEnabled, + })); } }; -export const fetchAdvancedSettings = ({ rawOLX, rawSettings }) => (dispatch) => { +export const fetchAdvancedSettings = ({ rawOLX, rawSettings, isMarkdownEditorEnabled }) => (dispatch) => { const advancedProblemSettingKeys = ['max_attempts', 'showanswer', 'show_reset_button', 'rerandomize']; dispatch(requests.fetchAdvancedSettings({ onSuccess: (response) => { @@ -72,26 +90,37 @@ export const fetchAdvancedSettings = ({ rawOLX, rawSettings }) => (dispatch) => } }); dispatch(actions.problem.updateField({ defaultSettings: camelizeKeys(defaultSettings) })); - loadProblem({ rawOLX, rawSettings, defaultSettings })(dispatch); + loadProblem({ + rawOLX, rawSettings, defaultSettings, isMarkdownEditorEnabled, + })(dispatch); + }, + onFailure: () => { + loadProblem({ + rawOLX, rawSettings, defaultSettings: {}, isMarkdownEditorEnabled, + })(dispatch); }, - onFailure: () => { loadProblem({ rawOLX, rawSettings, defaultSettings: {} })(dispatch); }, })); }; export const initializeProblem = (blockValue) => (dispatch, getState) => { const rawOLX = get(blockValue, 'data.data', ''); const rawSettings = get(blockValue, 'data.metadata', {}); + const isMarkdownEditorEnabled = get(blockValue, 'data.metadata.markdown_edited', false); const learningContextId = selectors.app.learningContextId(getState()); if (isLibraryKey(learningContextId)) { // Content libraries don't yet support defaults for fields like max_attempts, showanswer, etc. // So proceed with loading the problem. // Though first we need to fake the request or else the problem type selection UI won't display: dispatch(actions.requests.completeRequest({ requestKey: RequestKeys.fetchAdvancedSettings, response: {} })); - dispatch(loadProblem({ rawOLX, rawSettings, defaultSettings: {} })); + dispatch(loadProblem({ + rawOLX, rawSettings, defaultSettings: {}, isMarkdownEditorEnabled, + })); } else { // Load the defaults (for max_attempts, etc.) from the course's advanced settings, then proceed: - dispatch(fetchAdvancedSettings({ rawOLX, rawSettings })); + dispatch(fetchAdvancedSettings({ rawOLX, rawSettings, isMarkdownEditorEnabled })); } }; -export default { initializeProblem, switchToAdvancedEditor, fetchAdvancedSettings }; +export default { + initializeProblem, switchEditor, switchToAdvancedEditor, fetchAdvancedSettings, +}; diff --git a/src/editors/data/redux/thunkActions/requests.js b/src/editors/data/redux/thunkActions/requests.js index 335e98c60..b4e49bf0f 100644 --- a/src/editors/data/redux/thunkActions/requests.js +++ b/src/editors/data/redux/thunkActions/requests.js @@ -110,7 +110,7 @@ export const fetchUnit = ({ ...rest }) => (dispatch, getState) => { /** * Tracked saveBlock api method. Tracked to the `saveBlock` request key. - * @param {string} content + * @param {Object} content * @param {[func]} onSuccess - onSuccess method ((response) => { ... }) * @param {[func]} onFailure - onFailure method ((error) => { ... }) */ diff --git a/src/editors/hooks.test.jsx b/src/editors/hooks.test.jsx index ad6dc8d31..a0c3d6b2a 100644 --- a/src/editors/hooks.test.jsx +++ b/src/editors/hooks.test.jsx @@ -56,6 +56,7 @@ describe('hooks', () => { blockId: 'blockId', studioEndpointUrl: 'studioEndpointUrl', learningContextId: 'learningContextId', + isMarkdownEditorEnabledForCourse: true, }; hooks.useInitializeApp({ dispatch, data: fakeData }); expect(dispatch).not.toHaveBeenCalledWith(fakeData); @@ -64,6 +65,7 @@ describe('hooks', () => { fakeData.blockId, fakeData.studioEndpointUrl, fakeData.learningContextId, + fakeData.isMarkdownEditorEnabledForCourse, ]); cb(); expect(dispatch).toHaveBeenCalledWith(thunkActions.app.initialize(fakeData)); diff --git a/src/editors/hooks.ts b/src/editors/hooks.ts index 5669dd652..57962fe01 100644 --- a/src/editors/hooks.ts +++ b/src/editors/hooks.ts @@ -12,7 +12,7 @@ export const useInitializeApp = ({ dispatch, data }) => { setLoading(true); dispatch(thunkActions.app.initialize(data)); setLoading(false); - }, [data?.blockId, data?.studioEndpointUrl, data?.learningContextId]); + }, [data?.blockId, data?.studioEndpointUrl, data?.learningContextId, data?.isMarkdownEditorEnabledForCourse]); return loading; }; diff --git a/src/editors/sharedComponents/CodeEditor/hooks.js b/src/editors/sharedComponents/CodeEditor/hooks.js index 75e85f23e..0eb25a7cc 100644 --- a/src/editors/sharedComponents/CodeEditor/hooks.js +++ b/src/editors/sharedComponents/CodeEditor/hooks.js @@ -6,11 +6,16 @@ import { EditorState } from '@codemirror/state'; import { EditorView } from '@codemirror/view'; import { html } from '@codemirror/lang-html'; import { xml } from '@codemirror/lang-xml'; +import { markdown } from '@codemirror/lang-markdown'; import { linter } from '@codemirror/lint'; import alphanumericMap from './constants'; import './index.scss'; -const CODEMIRROR_LANGUAGES = { HTML: 'html', XML: 'xml' }; +const CODEMIRROR_LANGUAGES = { + html, + markdown, + xml, +}; export const state = { // eslint-disable-next-line react-hooks/rules-of-hooks @@ -95,7 +100,7 @@ export const createCodeMirrorDomNode = ({ }) => { // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { - const languageExtension = lang === CODEMIRROR_LANGUAGES.HTML ? html() : xml(); + const languageExtension = CODEMIRROR_LANGUAGES[lang](); const cleanText = cleanHTML({ initialText }); const newState = EditorState.create({ doc: cleanText, diff --git a/src/editors/sharedComponents/CodeEditor/index.jsx b/src/editors/sharedComponents/CodeEditor/index.jsx index 29a4ed2da..8877fe4ba 100644 --- a/src/editors/sharedComponents/CodeEditor/index.jsx +++ b/src/editors/sharedComponents/CodeEditor/index.jsx @@ -28,7 +28,7 @@ const CodeEditor = ({ return (
- {showBtnEscapeHTML && ( + {showBtnEscapeHTML && lang !== 'markdown' && (