feat: added markdown editor for editing problems in markdown format (#1805)

This commit is contained in:
Muhammad Anas
2025-04-24 01:24:27 +05:00
committed by GitHub
parent 74d7d66c59
commit 380f3be164
54 changed files with 1415 additions and 243 deletions

26
package-lock.json generated
View File

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

View File

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

View File

@@ -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<Props> = ({
learningContextId,
blockType,
blockId,
isMarkdownEditorEnabledForCourse,
lmsEndpointUrl,
studioEndpointUrl,
onClose = null,
@@ -34,6 +36,7 @@ const Editor: React.FC<Props> = ({
data: {
blockId,
blockType,
isMarkdownEditorEnabledForCourse,
learningContextId,
lmsEndpointUrl,
studioEndpointUrl,

View File

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

View File

@@ -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<Props> = ({
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<Props> = ({
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}

View File

@@ -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<Props> = ({
courseId,
blockType,
blockId = null,
isMarkdownEditorEnabledForCourse = false,
lmsEndpointUrl = null,
studioEndpointUrl = null,
onClose = null,
@@ -45,6 +47,7 @@ const EditorPage: React.FC<Props> = ({
learningContextId: courseId,
blockType,
blockId,
isMarkdownEditorEnabledForCourse,
lmsEndpointUrl,
studioEndpointUrl,
returnFunction,

View File

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

View File

@@ -52,7 +52,8 @@ exports[`SettingsWidget isLibrary snapshot: renders Settings widget for Advanced
<div
className="my-3"
>
<SwitchToAdvancedEditorCard
<SwitchEditorCard
editorType="advanced"
problemType="stringresponse"
/>
</div>
@@ -113,7 +114,8 @@ exports[`SettingsWidget isLibrary snapshot: renders Settings widget page 1`] = `
<div
className="my-3"
>
<SwitchToAdvancedEditorCard
<SwitchEditorCard
editorType="advanced"
problemType="stringresponse"
/>
</div>
@@ -174,7 +176,8 @@ exports[`SettingsWidget isLibrary snapshot: renders Settings widget page advance
<div
className="my-3"
>
<SwitchToAdvancedEditorCard
<SwitchEditorCard
editorType="advanced"
problemType="stringresponse"
/>
</div>
@@ -261,7 +264,8 @@ exports[`SettingsWidget snapshot snapshot: renders Settings widget for Advanced
<div
className="my-3"
>
<SwitchToAdvancedEditorCard
<SwitchEditorCard
editorType="advanced"
problemType="stringresponse"
/>
</div>
@@ -348,7 +352,8 @@ exports[`SettingsWidget snapshot snapshot: renders Settings widget page 1`] = `
<div
className="my-3"
>
<SwitchToAdvancedEditorCard
<SwitchEditorCard
editorType="advanced"
problemType="stringresponse"
/>
</div>
@@ -435,7 +440,8 @@ exports[`SettingsWidget snapshot snapshot: renders Settings widget page advanced
<div
className="my-3"
>
<SwitchToAdvancedEditorCard
<SwitchEditorCard
editorType="advanced"
problemType="stringresponse"
/>
</div>

View File

@@ -322,11 +322,11 @@ export const typeRowHooks = ({
};
};
export const confirmSwitchToAdvancedEditor = ({
switchToAdvancedEditor,
export const handleConfirmEditorSwitch = ({
switchEditor,
setConfirmOpen,
}) => {
switchToAdvancedEditor();
switchEditor();
setConfirmOpen(false);
window.scrollTo({
top: 0,

View File

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

View File

@@ -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 = ({
</div>
)}
<div className="my-3">
<SwitchToAdvancedEditorCard problemType={problemType} />
<SwitchEditorCard problemType={problemType} editorType="advanced" />
</div>
{ showMarkdownEditorButton
&& (
<div className="my-3">
<SwitchEditorCard problemType={problemType} editorType="markdown" />
</div>
)}
</Collapsible.Body>
</Collapsible.Advanced>
</div>
@@ -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 = {

View File

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

View File

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

View File

@@ -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 (
<Card className="border border-light-700 shadow-none">
<BaseModal
isOpen={isConfirmOpen}
close={() => { setConfirmOpen(false); }}
title={<FormattedMessage {...messages[`ConfirmSwitchMessageTitle-${editorType}`]} />}
confirmAction={(
<Button
/* istanbul ignore next */
onClick={() => handleConfirmEditorSwitch({ switchEditor: () => switchEditor(editorType), setConfirmOpen })}
variant="primary"
>
<FormattedMessage {...messages[`ConfirmSwitchButtonLabel-${editorType}`]} />
</Button>
)}
size="md"
>
<FormattedMessage {...messages[`ConfirmSwitchMessage-${editorType}`]} />
</BaseModal>
<Button
className="my-3 ml-2 py-0"
variant="link"
size="sm"
onClick={() => { setConfirmOpen(true); }}
>
<FormattedMessage {...messages[`SwitchButtonLabel-${editorType}`]} />
</Button>
</Card>
);
};
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);

View File

@@ -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(<SwitchEditorCard switchEditor={mockSwitchEditor} problemType="stringresponse" />).snapshot,
).toMatchSnapshot();
});
test('snapshot: SwitchEditorCard returns null for advanced problems', () => {
expect(
shallow(<SwitchEditorCard switchEditor={mockSwitchEditor} problemType="advanced" />).snapshot,
).toMatchSnapshot();
});
test('snapshot: SwitchEditorCard returns null when editor is Markdown', () => {
expect(
shallow(<SwitchEditorCard switchEditor={mockSwitchEditor} problemType="stringresponse" editorType="markdown" isMarkdownEditorEnabled />).snapshot,
).toMatchSnapshot();
});
describe('mapDispatchToProps', () => {
test('updateField from actions.problem.updateField', () => {
expect(mapDispatchToProps.switchEditor).toEqual(thunkActions.problem.switchEditor);
});
});
});

View File

@@ -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 (
<Card className="border border-light-700 shadow-none">
<BaseModal
isOpen={isConfirmOpen}
close={() => { setConfirmOpen(false); }}
title={(<FormattedMessage {...messages.ConfirmSwitchMessageTitle} />)}
confirmAction={(
<Button
onClick={() => confirmSwitchToAdvancedEditor({ switchToAdvancedEditor, setConfirmOpen })}
variant="primary"
>
<FormattedMessage {...messages.ConfirmSwitchButtonLabel} />
</Button>
)}
size="md"
>
<FormattedMessage {...messages.ConfirmSwitchMessage} />
</BaseModal>
<Button
className="my-3 ml-2 py-0"
variant="link"
size="sm"
onClick={() => { setConfirmOpen(true); }}
>
<FormattedMessage {...messages.SwitchButtonLabel} />
</Button>
</Card>
);
};
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));

View File

@@ -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(<SwitchToAdvancedEditorCard switchToAdvancedEditor={mockSwitchToAdvancedEditor} problemType="stringresponse" />).snapshot,
).toMatchSnapshot();
});
test('snapshot: SwitchToAdvancedEditorCard returns null', () => {
expect(
shallow(<SwitchToAdvancedEditorCard switchToAdvancedEditor={mockSwitchToAdvancedEditor} problemType="advanced" />).snapshot,
).toMatchSnapshot();
});
describe('mapDispatchToProps', () => {
test('updateField from actions.problem.updateField', () => {
expect(mapDispatchToProps.switchToAdvancedEditor).toEqual(thunkActions.problem.switchToAdvancedEditor);
});
});
});

View File

@@ -0,0 +1,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SwitchEditorCard snapshot snapshot: SwitchEditorCard 1`] = `
<Card
className="border border-light-700 shadow-none"
>
<BaseModal
bodyStyle={null}
close={[Function]}
confirmAction={
<Button
className={null}
onClick={[Function]}
text={null}
variant="primary"
>
<FormattedMessage />
</Button>
}
footerAction={null}
headerComponent={null}
hideCancelButton={false}
isFullscreenScroll={true}
isOpen={false}
size="md"
title={<FormattedMessage />}
>
<FormattedMessage />
</BaseModal>
<Button
className="my-3 ml-2 py-0"
onClick={[Function]}
size="sm"
text={null}
variant="link"
>
<FormattedMessage />
</Button>
</Card>
`;
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`;

View File

@@ -1,60 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SwitchToAdvancedEditorCard snapshot snapshot: SwitchToAdvancedEditorCard 1`] = `
<Card
className="border border-light-700 shadow-none"
>
<BaseModal
bodyStyle={null}
close={[Function]}
confirmAction={
<Button
className={null}
onClick={[Function]}
text={null}
variant="primary"
>
<FormattedMessage
defaultMessage="Switch to advanced editor"
description="message to confirm that a user wants to use the advanced editor"
id="authoring.problemeditor.settings.switchtoadvancededitor.ConfirmSwitchButtonLabel"
/>
</Button>
}
footerAction={null}
headerComponent={null}
hideCancelButton={false}
isFullscreenScroll={true}
isOpen={false}
size="md"
title={
<FormattedMessage
defaultMessage="Convert to OLX?"
description="message to confirm that a user wants to use the advanced editor"
id="authoring.problemeditor.settings.switchtoadvancededitor.ConfirmSwitchMessageTitle"
/>
}
>
<FormattedMessage
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"
id="authoring.problemeditor.settings.switchtoadvancededitor.ConfirmSwitchMessage"
/>
</BaseModal>
<Button
className="my-3 ml-2 py-0"
onClick={[Function]}
size="sm"
text={null}
variant="link"
>
<FormattedMessage
defaultMessage="Switch to advanced editor"
description="button to switch to the advanced mode of the editor."
id="authoring.problemeditor.settings.switchtoadvancededitor.label"
/>
</Button>
</Card>
`;
exports[`SwitchToAdvancedEditorCard snapshot snapshot: SwitchToAdvancedEditorCard returns null 1`] = `null`;

View File

@@ -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`] = `
<EditorContainer
getContent={[Function]}
isDirty={[Function]}
returnFunction={null}
>
<AlertModal
footerNode={
<ActionRow>
<Button
onClick={[Function]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Label for cancel button in the save warning modal"
id="authoring.problemEditor.editProblemView.saveWarningModal.cancelButton.label"
/>
</Button>
<Button
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Ok"
description="Label for save button in the save warning modal"
id="authoring.problemEditor.editProblemView.saveWarningModal.saveButton.label"
/>
</Button>
</ActionRow>
}
isOpen={false}
onClose={[Function]}
title="No answer specified"
>
<Fragment>
<div>
<FormattedMessage
defaultMessage="Are you sure you want to exit the editor?"
description="Question in body of save warning modal"
id="authoring.problemEditor.editProblemView.saveWarningModal.body.question"
/>
</div>
<div>
<FormattedMessage
defaultMessage="No correct answer has been specified."
description="Explanation in body of no answer modal"
id="authoring.problemEditor.editProblemView.saveWarningModal.noAnswer.body.explanation"
/>
</div>
</Fragment>
</AlertModal>
<div
className="editProblemView d-flex flex-row flex-nowrap justify-content-end"
>
<Container
className="advancedEditorTopMargin p-0"
fluid={true}
>
<RawEditor
content="# Markdown content"
editorRef={
{
"current": null,
}
}
lang="markdown"
/>
</Container>
<span
className="editProblemView-settingsColumn"
>
<injectIntl(ShimmedIntlComponent)
problemType="multiplechoiceresponse"
/>
</span>
</div>
</EditorContainer>
`;
exports[`EditorProblemView component renders raw editor for advanced problem type 1`] = `
<EditorContainer
getContent={[Function]}
isDirty={[Function]}
@@ -50,7 +129,7 @@ exports[`EditorProblemView component renders raw editor 1`] = `
fluid={true}
>
<RawEditor
content={null}
content="<problem>...</problem>"
editorRef={
{
"current": null,

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import 'tinymce';
import { StrictDict } from '../../../../utils';
import { StrictDict, convertMarkdownToXml } from '../../../../utils';
import ReactStateSettingsParser from '../../data/ReactStateSettingsParser';
import ReactStateOLXParser from '../../data/ReactStateOLXParser';
import { setAssetToStaticUrl } from '../../../../sharedComponents/TinyMceWidget/hooks';
@@ -68,17 +68,32 @@ export const fetchEditorContent = ({ format }) => {
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,
})();

View File

@@ -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 = '<problem>Raw Markdown</problem>';
const mockRawOLX = '<problem>rawOLX</problem>';
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();

View File

@@ -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 = ({
)}
</AlertModal>
<div className="editProblemView d-flex flex-row flex-nowrap justify-content-end">
{isAdvancedProblemType ? (
{isAdvancedProblemType || isMarkdownEditorEnabled ? (
<Container fluid className="advancedEditorTopMargin p-0">
<RawEditor editorRef={editorRef} lang="xml" content={problemState.rawOLX} />
<RawEditor editorRef={editorRef} lang={isMarkdownEditorEnabled ? 'markdown' : 'xml'} content={isMarkdownEditorEnabled ? problemState.rawMarkdown : problemState.rawOLX} />
</Container>
) : (
<span className="flex-grow-1 mb-5">
@@ -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),
});

View File

@@ -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(<EditProblemView
problemType={ProblemTypeKeys.ADVANCED}
problemState={{}}
isMarkdownEditorEnabled={false}
problemState={{ rawOLX: '<problem>...</problem>' }}
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(<EditProblemView
problemType={ProblemTypeKeys.SINGLESELECT}
isMarkdownEditorEnabled
problemState={{ rawMarkdown: '# Markdown content' }}
assets={{}}
intl={{ formatMessage }}
/>);
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
});
});

View File

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

View File

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

View File

@@ -1,9 +0,0 @@
/* eslint-disable */
const textInput =`<problem>
<stringresponse type="ci">
<additional_answer />
<textline size="20"/>
</stringresponse>
</problem>`
export default textInput;

View File

@@ -1,5 +1,5 @@
/* eslint-disable */
const dropdown = `<problem>
const olx = `<problem>
<optionresponse>
<optioninput>
<option correct="True"></option>
@@ -9,4 +9,10 @@ const dropdown = `<problem>
</optionresponse>
</problem>`
export default dropdown;
const markdown = `[[
an incorrect answer
(the correct answer)
an incorrect answer
]]`
export default { olx, markdown };

View File

@@ -1,5 +1,5 @@
/* eslint-disable */
const multiSelect= `<problem>
const olx= `<problem>
<choiceresponse>
<checkboxgroup>
<choice correct="true"></choice>
@@ -9,4 +9,10 @@
</choiceresponse>
</problem>`
export default multiSelect;
const markdown = `[x] a correct answer
[ ] an incorrect answer
[ ] an incorrect answer
[x] a correct answer
`
export default { olx, markdown };

View File

@@ -1,9 +1,11 @@
/* eslint-disable */
const numeric = `<problem>
const olx = `<problem>
<numericalresponse>
<responseparam type="tolerance" default="5"/>
<formulaequationinput/>
</numericalresponse>
</problem>`
export default numeric;
const markdown = `= 100 +-5`
export default { olx, markdown };

View File

@@ -1,5 +1,5 @@
/* eslint-disable */
const singleSelect = `<problem>
const olx = `<problem>
<multiplechoiceresponse>
<choicegroup>
<choice correct="true"></choice>
@@ -9,4 +9,9 @@ const singleSelect = `<problem>
</multiplechoiceresponse>
</problem>`
export default singleSelect;
const markdown = `( ) an incorrect answer
(x) the correct answer
( ) an incorrect answer
`
export default { olx, markdown };

View File

@@ -0,0 +1,13 @@
/* eslint-disable */
const olx =`<problem>
<stringresponse type="ci">
<additional_answer />
<textline size="20"/>
</stringresponse>
</problem>`
const markdown = `= the correct answer
or= optional acceptable variant of the correct answer
`
export default { olx, markdown };

View File

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

View File

@@ -21,6 +21,7 @@ describe('app reducer', () => {
blockId: 'anID',
learningContextId: 'OTHERid',
blockType: 'someTYPE',
isMarkdownEditorEnabledForCourse: true,
};
expect(reducer(
testingState,

View File

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

View File

@@ -48,6 +48,7 @@ describe('app selectors unit tests', () => {
simpleKeys.images,
simpleKeys.videos,
simpleKeys.showRawEditor,
simpleKeys.isMarkdownEditorEnabledForCourse,
].map(testSimpleSelector);
});
});

View File

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

View File

@@ -107,6 +107,7 @@ export interface EditorState {
videos: Record<string, any>;
courseDetails: Record<string, any>;
showRawEditor: boolean;
isMarkdownEditorEnabledForCourse: boolean;
},
requests: Record<keyof typeof RequestKeys, {
status: keyof typeof RequestStates;
@@ -154,7 +155,9 @@ export interface EditorState {
/** Has the user made changes to this problem since opening the editor? */
isDirty: boolean;
rawOLX: string;
rawMarkdown: string;
problemType: null | ProblemType | AdvancedProblemType;
isMarkdownEditorEnabled: boolean;
question: string;
answers: any[];
correctAnswerCount: number;

View File

@@ -10,6 +10,8 @@ const nextAlphaId = (lastId: string) => String.fromCharCode(lastId.charCodeAt(0)
const initialState: EditorState['problem'] = {
rawOLX: '',
rawMarkdown: '',
isMarkdownEditorEnabled: false,
problemType: null,
question: '',
answers: [],

View File

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

View File

@@ -7,6 +7,8 @@ const mkSimpleSelector = <T>(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),

View File

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

View File

@@ -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 (['<problem></problem>', '<problem/>'].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,
};

View File

@@ -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) => { ... })
*/

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ const CodeEditor = ({
return (
<div>
<div id="CodeMirror" ref={DOMref} />
{showBtnEscapeHTML && (
{showBtnEscapeHTML && lang !== 'markdown' && (
<Button
variant="tertiary"
aria-label={intl.formatMessage(messages.escapeHTMLButtonLabel)}

View File

@@ -36,6 +36,10 @@ jest.mock('@codemirror/lang-xml', () => ({
xml: jest.fn(),
}));
jest.mock('@codemirror/lang-markdown', () => ({
markdown: jest.fn(),
}));
jest.mock('codemirror', () => ({
basicSetup: 'bAsiCSetUp',
}));

View File

@@ -18,10 +18,9 @@ const RawEditor = ({
}) => {
const value = getValue(content) || '';
const staticUpdate = setAssetToStaticUrl({ editorValue: value });
return (
<div>
{lang === 'xml' ? null : (
{['xml', 'markdown'].includes(lang) ? null : (
<Alert variant="danger">
You are using the raw {lang} editor.
</Alert>

View File

@@ -0,0 +1,493 @@
export const convertMarkdownToXml = (markdown) => {
const demandHintTags = [];
// Comprehensive XML conversion function
const toXml = (partialMarkdown) => {
let xml = partialMarkdown;
let i; let makeParagraph; let demandhints;
const responseTypes = [
'optionresponse', 'multiplechoiceresponse', 'stringresponse', 'numericalresponse', 'choiceresponse',
];
// fix DOS \r\n line endings to look like \n
xml = xml.replace(/\r\n/g, '\n');
// replace headers
xml = xml.replace(/(^.*?$)(?=\n==+$)/gm, '<h3 class="hd hd-2 problem-header">$1</h3>');
xml = xml.replace(/\n^==+$/gm, '');
// extract question and description(optional)
// >>question||description<< converts to
// <label>question</label> <description>description</description>
xml = xml.replace(/>>([^]+?)<</gm, (match, questionText) => {
const result = questionText.split('||');
const label = `<label>${ result[0] }</label>\n`;
// don't add empty <description> tag
if (result.length === 1 || !result[1]) {
return label;
}
return `${label }<description>${ result[1] }</description>\n`;
});
// Pull out demand hints, || a hint ||
demandhints = '';
xml = xml.replace(/(^\s*\|\|.*?\|\|\s*$\n?)+/gm, (match) => { // $\n
let inner;
const options = match.split('\n');
for (i = 0; i < options.length; i += 1) {
inner = /\s*\|\|(.*?)\|\|/.exec(options[i]);
if (inner) {
demandhints += ` <hint>${ inner[1].trim() }</hint>\n`;
}
}
return '';
});
// replace \n+whitespace within extended hint {{ .. }}, by a space, so the whole
// hint sits on one line.
// This is the one instance of {{ ... }} matching that permits \n
xml = xml.replace(/{{(.|\n)*?}}/gm, (match) => match.replace(/\r?\n( |\t)*/g, ' '));
// Function used in many places to extract {{ label:: a hint }}.
// Returns a little hash with various parts of the hint:
// hint: the hint or empty, nothint: the rest
// labelassign: javascript assignment of label attribute, or empty
const extractHint = (inputText, detectParens) => {
let text = inputText;
const curly = /\s*{{(.*?)}}/.exec(text);
let hint = '';
let label = '';
let parens = false;
let labelassign = '';
let labelmatch;
if (curly) {
text = text.replace(curly[0], '');
hint = curly[1].trim();
labelmatch = /^(.*?)::/.exec(hint);
if (labelmatch) {
hint = hint.replace(labelmatch[0], '').trim();
label = labelmatch[1].trim();
labelassign = ` label="${ label }"`;
}
}
if (detectParens) {
if (text.length >= 2 && text[0] === '(' && text[text.length - 1] === ')') {
text = text.substring(1, text.length - 1);
parens = true;
}
}
return {
nothint: text,
hint,
label,
parens,
labelassign,
};
};
xml = xml.replace(/\[\[((.|\n)+?)\]\]/g, (match, group1) => {
let textHint; let options; let optiontag; let correct;
let optionlines; let line; let correctstr; let hintstr; let label;
// decide if this is old style or new style
if (match.indexOf('\n') === -1) { // OLD style, [[ .... ]] on one line
options = group1.split(/,\s*/g);
optiontag = ' <optioninput options="(';
for (i = 0; i < options.length; i += 1) {
optiontag += `'${ options[i].replace(/(?:^|,)\s*\((.*?)\)\s*(?:$|,)/g, '$1') }'${
i < options.length - 1 ? ',' : ''}`;
}
optiontag += ')" correct="';
correct = /(?:^|,)\s*\((.*?)\)\s*(?:$|,)/g.exec(group1);
if (correct) {
optiontag += correct[1];
}
optiontag += '">';
return `\n<optionresponse>\n${ optiontag }</optioninput>\n</optionresponse>\n\n`;
}
// new style [[ many-lines ]]
const lines = group1.split('\n');
optionlines = '';
for (i = 0; i < lines.length; i++) {
line = lines[i].trim();
if (line.length > 0) {
textHint = extractHint(line, true);
correctstr = ` correct="${ textHint.parens ? 'True' : 'False' }"`;
hintstr = '';
if (textHint.hint) {
label = textHint.label;
if (label) {
label = ` label="${ label }"`;
}
hintstr = ` <optionhint${ label }>${ textHint.hint }</optionhint>`;
}
optionlines += ` <option${ correctstr }>${ textHint.nothint }${hintstr }</option>\n`;
}
}
return `\n<optionresponse>\n <optioninput>\n${ optionlines } </optioninput>\n</optionresponse>\n\n`;
});
xml = xml.replace(/(^\s*\(.{0,3}\).*?$\n*)+/gm, (match) => {
let choices = '';
let shuffle = false;
const options = match.split('\n');
let correct;
let fixed; let hint; let
result;
for (i = 0; i < options.length; i++) {
options[i] = options[i].trim(); // trim off leading/trailing whitespace
if (options[i].length > 0) {
let [, value] = options[i].split(/^\s*\(.{0,3}\)\s*/);
const [,inparens] = /^\s*\((.{0,3})\)\s*/.exec(options[i]);
correct = /x/i.test(inparens);
fixed = '';
if (/@/.test(inparens)) {
fixed = ' fixed="true"';
}
if (/!/.test(inparens)) {
shuffle = true;
}
hint = extractHint(value);
if (hint.hint) {
value = hint.nothint;
value = `${value } <choicehint${ hint.labelassign }>${ hint.hint }</choicehint>`;
}
choices += ` <choice correct="${ correct }"${ fixed }>${ value }</choice>\n`;
}
}
result = '<multiplechoiceresponse>\n';
if (shuffle) {
result += ' <choicegroup type="MultipleChoice" shuffle="true">\n';
} else {
result += ' <choicegroup type="MultipleChoice">\n';
}
result += choices;
result += ' </choicegroup>\n';
result += '</multiplechoiceresponse>\n\n';
return result;
});
// group check answers
// [.] with {{...}} lines mixed in
xml = xml.replace(/(^\s*((\[.?\])|({{.*?}})).*?$\n*)+/gm, (match) => {
let groupString = '<choiceresponse>\n';
const options = match.split('\n');
let correct; let abhint; let endHints;
let hint; let inner; let select; let
hints;
groupString += ' <checkboxgroup>\n';
endHints = ''; // save these up to emit at the end
for (i = 0; i < options.length; i += 1) {
if (options[i].trim().length > 0) {
// detect the {{ ((A*B)) ...}} case first
// emits: <compoundhint value="A*B">AB hint</compoundhint>
abhint = /^\s*{{\s*\(\((.*?)\)\)(.*?)}}/.exec(options[i]);
if (abhint) {
// lone case of hint text processing outside of extractHint, since syntax here is unique
let [, , hintbody] = abhint;
hintbody = hintbody.replace('&lf;', '\n').trim();
endHints += ` <compoundhint value="${ abhint[1].trim() }">${ hintbody }</compoundhint>\n`;
// eslint-disable-next-line no-continue
continue;
}
let [, value] = options[i].split(/^\s*\[.?\]\s*/);
correct = /^\s*\[x\]/i.test(options[i]);
hints = '';
// {{ selected: Youre right that apple is a fruit. },
// {unselected: Remember that apple is also a fruit.}}
hint = extractHint(value);
if (hint.hint) {
inner = `{${ hint.hint }}`; // parsing is easier if we put outer { } back
// include \n since we are downstream of extractHint()
select = /{\s*(s|selected):((.|\n)*?)}/i.exec(inner);
// checkbox choicehints get their own line, since there can be two of them
// <choicehint selected="true">Youre right that apple is a fruit.</choicehint>
if (select) {
hints += `\n <choicehint selected="true">${ select[2].trim() }</choicehint>`;
}
select = /{\s*(u|unselected):((.|\n)*?)}/i.exec(inner);
if (select) {
hints += `\n <choicehint selected="false">${ select[2].trim() }</choicehint>`;
}
// Blank out the original text only if the specific "selected" syntax is found
// That way, if the user types it wrong, at least they can see it's not processed.
if (hints) {
value = hint.nothint;
}
}
groupString += ` <choice correct="${ correct }">${ value }${hints }</choice>\n`;
}
}
groupString += endHints;
groupString += ' </checkboxgroup>\n';
groupString += '</choiceresponse>\n\n';
return groupString;
});
// replace string and numerical, numericalresponse, stringresponse
// A fine example of the function-composition programming style.
xml = xml.replace(/(^s?=\s*(.*?$)(\n*(or|not)=\s*(.*?$))*)+/gm, (match, p) => {
// Line split here, trim off leading xxx= in each function
const answersList = p.split('\n');
const isRangeToleranceCase = (answer) => {
const rangeStart = ['[', '('];
const rangeEnd = [']', ')'];
return rangeStart.includes(answer[0]) && rangeEnd.includes(answer[answer.length - 1]);
};
const checkIsNumeric = (stringValue) => {
// remove OLX feedback
const cleanedValue = stringValue.includes('{{') && stringValue.includes('}}')
? stringValue.replace(/{{[\s\S]*?}}/g, '').trim()
: stringValue;
// allow for "e" in scientific notation, but exclude other letters
if (cleanedValue.match(/[a-df-z]/i)) {
return false;
}
return !Number.isNaN(parseFloat(cleanedValue));
};
const getAnswerData = (answerValue) => {
const answerData = {};
const answerParams = /(.*?)\+-\s*(.*?$)/.exec(answerValue);
if (answerParams) {
const [, rawAnswer, defaultValue] = answerParams;
answerData.answer = rawAnswer.replace(/\s+/g, ''); // inputs like 5*2 +- 10
answerData.default = defaultValue;
} else {
answerData.answer = answerValue.replace(/\s+/g, ''); // inputs like 5*2
}
return answerData;
};
const processNumericalResponse = (answerValues) => {
let firstAnswer; let answerData; let numericalResponseString; let additionalAnswerString;
let hintLine; let additionalTextHint; let additionalHintLine; let orMatch; let
hasTolerance;
// First string case is s?= [e.g. = 100]
firstAnswer = answerValues[0].replace(/^=\s*/, '');
// If answer is not numerical
if (!checkIsNumeric(firstAnswer) && !isRangeToleranceCase(firstAnswer)) {
return false;
}
const textHint = extractHint(firstAnswer);
hintLine = '';
if (textHint.hint) {
firstAnswer = textHint.nothint;
hintLine = ` <correcthint${ textHint.labelassign }>${ textHint.hint }</correcthint>\n`;
}
// Range case
if (isRangeToleranceCase(firstAnswer)) {
// [5, 7) or (5, 7), or (1.2345 * (2+3), 7*4 ] - range tolerance case
// = (5*2)*3 should not be used as range tolerance
numericalResponseString = `<numericalresponse answer="${ firstAnswer }">\n`;
} else {
answerData = getAnswerData(firstAnswer);
numericalResponseString = `<numericalresponse answer="${ answerData.answer }">\n`;
if (answerData.default) {
numericalResponseString += ` <responseparam type="tolerance" default="${ answerData.default }" />\n`;
}
}
// Additional answer case or= [e.g. or= 10]
// Since answerValues[0] is firstAnswer, so we will not include this in additional answers.
additionalAnswerString = '';
for (i = 1; i < answerValues.length; i++) {
additionalHintLine = '';
additionalTextHint = extractHint(answerValues[i]);
orMatch = /^or=\s*(.*)/.exec(additionalTextHint.nothint);
if (orMatch) {
hasTolerance = /(.*?)\+-\s*(.*?$)/.exec(orMatch[1]);
// Do not add additional_answer if additional answer is not numerical (eg. or= ABC)
// or contains range tolerance case (eg. or= (5,7)
// or has tolerance (eg. or= 10 +- 0.02)
if (Number.isNaN(Number(orMatch[1]))
|| isRangeToleranceCase(orMatch[1])
|| hasTolerance) {
// eslint-disable-next-line no-continue
continue;
}
if (additionalTextHint.hint) {
additionalHintLine = `<correcthint${ additionalTextHint.labelassign }>${ additionalTextHint.hint }</correcthint>`;
}
additionalAnswerString += ` <additional_answer answer="${ orMatch[1] }">`;
additionalAnswerString += additionalHintLine;
additionalAnswerString += '</additional_answer>\n';
}
}
// Add additional answers string to numerical problem string.
if (additionalAnswerString) {
numericalResponseString += additionalAnswerString;
}
numericalResponseString += ' <formulaequationinput />\n';
numericalResponseString += hintLine;
numericalResponseString += '</numericalresponse>\n\n';
return numericalResponseString;
};
const processStringResponse = (values) => {
let firstAnswer; let textHint; let typ; let string; let orMatch; let
notMatch;
// First string case is s?=
firstAnswer = values.shift();
firstAnswer = firstAnswer.replace(/^s?=\s*/, '');
textHint = extractHint(firstAnswer);
firstAnswer = textHint.nothint;
typ = ' type="ci"';
if (firstAnswer[0] === '|') { // this is regexp case
typ = ' type="ci regexp"';
firstAnswer = firstAnswer.slice(1).trim();
}
string = `<stringresponse answer="${ firstAnswer }"${ typ } >\n`;
if (textHint.hint) {
string += ` <correcthint${ textHint.labelassign }>${
textHint.hint }</correcthint>\n`;
}
// Subsequent cases are not= or or=
for (i = 0; i < values.length; i += 1) {
textHint = extractHint(values[i]);
notMatch = /^not=\s*(.*)/.exec(textHint.nothint);
if (notMatch) {
string += ` <stringequalhint answer="${ notMatch[1] }"${ textHint.labelassign }>${ textHint.hint }</stringequalhint>\n`;
// eslint-disable-next-line no-continue
continue;
}
orMatch = /^or=\s*(.*)/.exec(textHint.nothint);
if (orMatch) {
// additional_answer with answer= attribute
string += ` <additional_answer answer="${ orMatch[1] }">`;
if (textHint.hint) {
string += `<correcthint${ textHint.labelassign }>${ textHint.hint }</correcthint>`;
}
string += '</additional_answer>\n';
}
}
string += ' <textline size="20"/>\n</stringresponse>\n\n';
return string;
};
return processNumericalResponse(answersList) || processStringResponse(answersList);
});
// replace explanations
xml = xml.replace(/\[explanation\]\n?([^\]]*)\[\/?explanation\]/gmi, (match, p1) => `<solution>\n<div class="detailed-solution">\nExplanation\n\n${ p1 }\n</div>\n</solution>`);
// replace code blocks
xml = xml.replace(/\[code\]\n?([^\]]*)\[\/?code\]/gmi, (match, p1) => `<pre><code>${ p1 }</code></pre>`);
// split scripts and preformatted sections, and wrap paragraphs
const splits = xml.split(/(<\/?(?:script|pre|label|description).*?>)/g);
// Wrap a string by <p> tag when line is not already wrapped by another tag
// true when line is not already wrapped by another tag false otherwise
makeParagraph = true;
for (i = 0; i < splits.length; i += 1) {
if (/<(script|pre|label|description)/.test(splits[i])) {
makeParagraph = false;
}
if (makeParagraph) {
splits[i] = splits[i].replace(/(^(?!\s*<|$).*$)/gm, '<p>$1</p>');
}
if (/<\/(script|pre|label|description)/.test(splits[i])) {
makeParagraph = true;
}
}
xml = splits.join('');
// rid white space
xml = xml.replace(/\n\n\n/g, '\n');
// if we've come across demand hints, wrap in <demandhint> at the end
if (demandhints) {
demandHintTags.push(demandhints);
}
// make selector to search responsetypes in xml
const responseTypesSelector = responseTypes.join(', ');
// make temporary xml
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(`<prob>${ xml }</prob>`, 'application/xml');
let responseType = xmlDoc.querySelectorAll(responseTypesSelector);
// convert if there is only one responsetype
if (responseType.length === 1) {
[responseType] = responseType;
const inputtype = responseType.firstElementChild;
// used to decide whether an element should be placed before or after an inputtype
let beforeInputtype = true;
Array.from(xmlDoc.querySelector('prob').children).forEach(child => {
// we don't want to add the responsetype again into new xml
if (responseType.nodeName === child.nodeName) {
beforeInputtype = false;
return;
}
if (beforeInputtype) {
responseType.insertBefore(child, inputtype);
} else {
responseType.appendChild(child);
}
});
const serializer = new XMLSerializer();
xml = serializer.serializeToString(responseType);
// remove xmlns attribute added by the serializer
xml = xml.replace(/\sxmlns=['"].*?['"]/gi, '');
// XMLSerializer messes the indentation of XML so add newline
// at the end of each ending tag to make the xml looks better
xml = xml.replace(/(<\/.*?>)(<.*?>)/gi, '$1\n$2');
}
// remove class attribute added on <p> tag for question title
xml = xml.replace(/\sclass='qtitle'/gi, '');
return xml;
};
// Process markdown into XML
const responseTypesMarkdown = markdown.split(/\n\s*---\s*\n/g);
const responseTypesXML = responseTypesMarkdown
.filter(responseTypeMarkdown => responseTypeMarkdown.trim().length > 0)
.map(toXml);
// Construct final XML
let finalDemandHints = '';
if (demandHintTags.length) {
finalDemandHints = `\n<demandhint>\n${demandHintTags.join('')}</demandhint>`;
}
const finalXml = `<problem>\n${responseTypesXML.join('\n\n')}${finalDemandHints}\n</problem>`;
return finalXml;
};

View File

@@ -0,0 +1,311 @@
import { convertMarkdownToXml } from './convertMarkdownToXML';
describe('convertMarkdownToXml', () => {
// Helper function to normalize whitespace for easier comparison
const normalizeWhitespace = (str) => str.replace(/\s+/g, ' ').trim();
// Test basic functionality
describe('basic conversion', () => {
it('should wrap content in problem tags', () => {
const result = convertMarkdownToXml('Simple text');
expect(result).toContain('<problem>');
expect(result).toContain('</problem>');
});
it('should handle empty input', () => {
const result = convertMarkdownToXml('');
expect(result).toBe('<problem>\n\n</problem>');
});
});
// Test headers
describe('headers', () => {
it('should convert markdown headers to HTML headers', () => {
const markdown = 'Header Text\n===========';
const expected = '<h3 class="hd hd-2 problem-header">Header Text</h3>';
const result = convertMarkdownToXml(markdown);
expect(result).toContain(expected);
});
});
// Test labels and descriptions
describe('labels and descriptions', () => {
it('should convert markdown labels', () => {
const markdown = '>>Question Label<<';
const expected = '<label>Question Label</label>';
const result = convertMarkdownToXml(markdown);
expect(result).toContain(expected);
});
it('should convert markdown labels with descriptions', () => {
const markdown = '>>Question Label||Description text<<';
const expected = '<label>Question Label</label>\n<description>Description text</description>';
const result = convertMarkdownToXml(markdown);
expect(result).toContain(expected);
});
});
// Test demand hints
describe('demand hints', () => {
it('should extract demand hints', () => {
const markdown = '|| This is a hint ||';
const expected = '<demandhint>\n <hint>This is a hint</hint>\n</demandhint>';
const result = convertMarkdownToXml(markdown);
expect(result).toContain(expected);
});
it('should handle multiple demand hints', () => {
const markdown = '|| First hint ||\n|| Second hint ||';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<hint>First hint</hint>');
expect(result).toContain('<hint>Second hint</hint>');
});
});
// Test option responses
describe('option responses', () => {
it('should convert single-line option response', () => {
const markdown = '[[Apple, Banana, (Orange)]]';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<optionresponse>');
expect(result).toContain('<optioninput options="(\'Apple\',\'Banana\',\'Orange\')" correct="Orange"/>');
expect(result).toContain('</optionresponse>');
});
it('should convert multi-line option response', () => {
const markdown = '[[Apple\nBanana\n(Orange)]]';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<optionresponse>');
expect(result).toContain('<option correct="False">Apple</option>');
expect(result).toContain('<option correct="False">Banana</option>');
expect(result).toContain('<option correct="True">Orange</option>');
});
it('should handle option hints', () => {
const markdown = '[[Apple {{label::This is a hint}}\nBanana\n(Orange)]]';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<optionhint label="label">This is a hint</optionhint>');
});
});
// Test multiple choice responses
describe('multiple choice responses', () => {
it('should convert multiple choice questions', () => {
const markdown = '(x) Correct Answer\n() Wrong Answer';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<multiplechoiceresponse>');
expect(result).toContain('<choice correct="true">Correct Answer</choice>');
expect(result).toContain('<choice correct="false">Wrong Answer</choice>');
});
it('should handle shuffle flag in multiple choice', () => {
const markdown = '(x!) Correct Answer\n() Wrong Answer';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('shuffle="true"');
});
it('should handle fixed flag in multiple choice', () => {
const markdown = '(x@) Correct Answer\n() Wrong Answer';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('fixed="true"');
});
it('should handle choice hints', () => {
const markdown = '(x) Correct Answer {{label::Hint text}}\n() Wrong Answer';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<choicehint label="label">Hint text</choicehint>');
});
});
// Test checkbox groups
describe('checkbox groups', () => {
it('should convert checkbox questions', () => {
const markdown = '[x] Correct Option\n[] Wrong Option';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<choiceresponse>');
expect(result).toContain('<checkboxgroup>');
expect(result).toContain('<choice correct="true">Correct Option</choice>');
expect(result).toContain('<choice correct="false">Wrong Option</choice>');
});
it('should handle choice hints in checkboxes', () => {
let markdown = '[x] Option {{selected: Good choice}}';
let result = convertMarkdownToXml(markdown);
expect(result).toContain('<choicehint selected="true">Good choice</choicehint>');
markdown = '[x] Option {{unselected: Bad choice}}';
result = convertMarkdownToXml(markdown);
expect(result).toContain('<choicehint selected="false">Bad choice</choicehint>');
});
it('should handle compound hints', () => {
const markdown = '{{ ((A*B)) This is a compound hint }}';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<compoundhint value="A*B">This is a compound hint</compoundhint>');
});
});
// Test numerical responses
describe('numerical responses', () => {
it('should convert basic numerical response', () => {
const markdown = '= 100';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<numericalresponse answer="100">');
expect(result).toContain('<formulaequationinput/>');
});
it('should handle tolerance in numerical response', () => {
const markdown = '= 100 +- 5';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<numericalresponse answer="100">');
expect(result).toContain('<responseparam type="tolerance" default="5"/>');
});
it('should handle range tolerance', () => {
const markdown = '= [90, 110]';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<numericalresponse answer="[90, 110]">');
});
it('should handle additional answers', () => {
const markdown = '= 100\nor= 200 {{This is an additional answer}}';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<additional_answer answer="200">');
expect(result).toContain('<correcthint>This is an additional answer</correcthint>');
});
it('should handle correct hints', () => {
const markdown = '= 100 {{Great job!}}';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<correcthint>Great job!</correcthint>');
});
});
// Test string responses
describe('string responses', () => {
it('should convert basic string response', () => {
const markdown = 's= answer {{Hint}}';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<stringresponse answer="answer" type="ci">');
expect(result).toContain('<textline size="20"/>');
expect(result).toContain('<correcthint>Hint</correcthint>');
});
it('should handle regexp in string response', () => {
const markdown = 's= |answer.*';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<stringresponse answer="answer.*" type="ci regexp">');
});
it('should handle additional answers', () => {
const markdown = 's= answer1\nor= answer2 {{This is an additional answer}}';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<additional_answer answer="answer2">');
expect(result).toContain('<correcthint>This is an additional answer</correcthint>');
});
it('should handle string equal hints', () => {
const markdown = 's= correct\nnot= wrong {{Try again}}';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<stringequalhint answer="wrong">Try again</stringequalhint>');
});
});
// Test explanations and code blocks
describe('explanations and code blocks', () => {
it('should convert explanations', () => {
const markdown = '[explanation]\nThis is an explanation\n[/explanation]';
const expected = '<solution>\n<div class="detailed-solution">\n<p>Explanation</p>\n\n<p>This is an explanation</p>\n</div>\n</solution>';
const result = convertMarkdownToXml(markdown);
expect(normalizeWhitespace(result)).toContain(normalizeWhitespace(expected));
});
it('should convert code blocks', () => {
const markdown = '[code]\nfunction test() {\n return true;\n}\n[/code]';
const expected = '<pre><code>function test() {\n return true;\n}\n</code></pre>';
const result = convertMarkdownToXml(markdown);
expect(result).toContain(expected);
});
});
// Test paragraph wrapping
describe('paragraph wrapping', () => {
it('should wrap text in paragraphs', () => {
const markdown = 'This is a paragraph.';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<p>This is a paragraph.</p>');
});
it('should not wrap text in paragraphs inside pre tags', () => {
const markdown = '[code]\nThis should not be wrapped\n[/code]';
const result = convertMarkdownToXml(markdown);
expect(result).not.toContain('<p>This should not be wrapped</p>');
});
});
// Integration tests for complex cases
describe('complex integration tests', () => {
it('should handle a complete problem with multiple components', () => {
const markdown = `Problem Title
==============
>>What is the capital of France?||Choose the correct answer<<
(x) Paris {{Correct! Paris is indeed the capital of France.}}
() London {{No, London is the capital of the United Kingdom.}}
() Berlin {{No, Berlin is the capital of Germany.}}
() Madrid {{No, Madrid is the capital of Spain.}}
[explanation]
Paris is the capital and most populous city of France.
[/explanation]
|| Need a hint? Think about the Eiffel Tower! ||`;
const result = convertMarkdownToXml(markdown);
// Check for main structure elements
expect(result).toContain('<h3 class="hd hd-2 problem-header">Problem Title</h3>');
expect(result).toContain('<label>What is the capital of France?</label>');
expect(result).toContain('<description>Choose the correct answer</description>');
expect(result).toContain('<multiplechoiceresponse>');
expect(result).toContain('<choicegroup type="MultipleChoice">');
expect(result).toContain('<choice correct="true">Paris <choicehint>Correct! Paris is indeed the capital of France.</choicehint>\n</choice>');
expect(result).toContain('<solution>');
expect(result).toContain('<demandhint>');
expect(result).toContain('<hint>Need a hint? Think about the Eiffel Tower!</hint>');
});
it('should handle multiple response types separated by ---', () => {
const markdown = `Question 1
==========
= 42
---
Question 2
==========
s= hello`;
const result = convertMarkdownToXml(markdown);
// Check that both questions are included
expect(result).toContain('<numericalresponse answer="42">');
expect(result).toContain('<stringresponse answer="hello"');
});
});
// Edge cases and error handling
describe('edge cases and error handling', () => {
it('should handle Windows-style line endings', () => {
const markdown = 'Question\r\n===========\r\n= 42';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<h3 class="hd hd-2 problem-header">Question</h3>');
expect(result).toContain('<numericalresponse answer="42">');
});
it('should handle multiple empty demand hints gracefully', () => {
const markdown = '|| ||\n|| ||';
const result = convertMarkdownToXml(markdown);
expect(result).toContain('<hint></hint>');
});
});
});

View File

@@ -4,4 +4,5 @@ export { default as camelizeKeys } from './camelizeKeys';
export { default as removeItemOnce } from './removeOnce';
export { default as formatDuration } from './formatDuration';
export { default as snakeCaseKeys } from './snakeCaseKeys';
export { convertMarkdownToXml } from './convertMarkdownToXML';
export * from './formatLibraryImgRequest';