feat: added markdown editor for editing problems in markdown format (#1805)
This commit is contained in:
26
package-lock.json
generated
26
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -322,11 +322,11 @@ export const typeRowHooks = ({
|
||||
};
|
||||
};
|
||||
|
||||
export const confirmSwitchToAdvancedEditor = ({
|
||||
switchToAdvancedEditor,
|
||||
export const handleConfirmEditorSwitch = ({
|
||||
switchEditor,
|
||||
setConfirmOpen,
|
||||
}) => {
|
||||
switchToAdvancedEditor();
|
||||
switchEditor();
|
||||
setConfirmOpen(false);
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
@@ -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`;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
/* eslint-disable */
|
||||
const textInput =`<problem>
|
||||
<stringresponse type="ci">
|
||||
<additional_answer />
|
||||
<textline size="20"/>
|
||||
</stringresponse>
|
||||
</problem>`
|
||||
|
||||
export default textInput;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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.
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('app reducer', () => {
|
||||
blockId: 'anID',
|
||||
learningContextId: 'OTHERid',
|
||||
blockType: 'someTYPE',
|
||||
isMarkdownEditorEnabledForCourse: true,
|
||||
};
|
||||
expect(reducer(
|
||||
testingState,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('app selectors unit tests', () => {
|
||||
simpleKeys.images,
|
||||
simpleKeys.videos,
|
||||
simpleKeys.showRawEditor,
|
||||
simpleKeys.isMarkdownEditorEnabledForCourse,
|
||||
].map(testSimpleSelector);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) => { ... })
|
||||
*/
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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',
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
|
||||
493
src/editors/utils/convertMarkdownToXML.js
Normal file
493
src/editors/utils/convertMarkdownToXML.js
Normal 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: You’re 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">You’re 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;
|
||||
};
|
||||
311
src/editors/utils/convertMarkdownToXML.test.ts
Normal file
311
src/editors/utils/convertMarkdownToXML.test.ts
Normal 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>');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user