feat: enable markdown to OLX conversion (#2518)
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
type ProblemEditorRef = React.MutableRefObject<unknown> | React.RefObject<unknown> | null;
|
||||
|
||||
export interface ProblemEditorContextValue {
|
||||
editorRef: ProblemEditorRef;
|
||||
}
|
||||
|
||||
export type ProblemEditorContextInit = {
|
||||
editorRef?: ProblemEditorRef;
|
||||
};
|
||||
|
||||
const context = React.createContext<ProblemEditorContextValue | undefined>(undefined);
|
||||
|
||||
export function useProblemEditorContext() {
|
||||
const ctx = React.useContext(context);
|
||||
if (ctx === undefined) {
|
||||
/* istanbul ignore next */
|
||||
throw new Error('This component needs to be wrapped in <ProblemEditorContextProvider>');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export const ProblemEditorContextProvider: React.FC<{ children: React.ReactNode; } & ProblemEditorContextInit> = ({
|
||||
children,
|
||||
editorRef = null,
|
||||
}) => {
|
||||
const ctx: ProblemEditorContextValue = React.useMemo(() => ({ editorRef }), [editorRef]);
|
||||
|
||||
return <context.Provider value={ctx}>{children}</context.Provider>;
|
||||
};
|
||||
@@ -45,6 +45,7 @@ const SettingsWidget = ({
|
||||
isMarkdownEditorEnabledForContext,
|
||||
} = useEditorContext();
|
||||
const rawMarkdown = useSelector(selectors.problem.rawMarkdown);
|
||||
const isMarkdownEditorEnabled = useSelector(selectors.problem.isMarkdownEditorEnabled);
|
||||
const showMarkdownEditorButton = isMarkdownEditorEnabledForContext && rawMarkdown;
|
||||
const { isAdvancedCardsVisible, showAdvancedCards } = showAdvancedSettingsCards();
|
||||
const feedbackCard = () => {
|
||||
@@ -161,7 +162,7 @@ const SettingsWidget = ({
|
||||
<div className="my-3">
|
||||
<SwitchEditorCard problemType={problemType} editorType="advanced" />
|
||||
</div>
|
||||
{ showMarkdownEditorButton
|
||||
{ (showMarkdownEditorButton && !isMarkdownEditorEnabled) // Only show button if not already in markdown editor
|
||||
&& (
|
||||
<div className="my-3">
|
||||
<SwitchEditorCard problemType={problemType} editorType="markdown" />
|
||||
|
||||
@@ -2,10 +2,11 @@ import React from 'react';
|
||||
|
||||
import { ProblemTypeKeys } from '@src/editors/data/constants/problem';
|
||||
import { screen, initializeMocks } from '@src/testUtils';
|
||||
import { editorRender } from '@src/editors/editorTestRender';
|
||||
import { editorRender, type PartialEditorState } from '@src/editors/editorTestRender';
|
||||
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
|
||||
import * as hooks from './hooks';
|
||||
import { SettingsWidgetInternal as SettingsWidget } from '.';
|
||||
import { ProblemEditorContextProvider } from '../ProblemEditorContext';
|
||||
|
||||
jest.mock('./settingsComponents/GeneralFeedback', () => 'GeneralFeedback');
|
||||
jest.mock('./settingsComponents/GroupFeedback', () => 'GroupFeedback');
|
||||
@@ -23,7 +24,6 @@ describe('SettingsWidget', () => {
|
||||
const showAdvancedSettingsCardsBaseProps = {
|
||||
isAdvancedCardsVisible: false,
|
||||
showAdvancedCards: jest.fn().mockName('showAdvancedSettingsCards.showAdvancedCards'),
|
||||
setResetTrue: jest.fn().mockName('showAdvancedSettingsCards.setResetTrue'),
|
||||
};
|
||||
|
||||
const props = {
|
||||
@@ -49,6 +49,18 @@ describe('SettingsWidget', () => {
|
||||
|
||||
};
|
||||
|
||||
const editorRef = { current: null };
|
||||
|
||||
const renderSettingsWidget = (
|
||||
overrideProps = {},
|
||||
options = {},
|
||||
) => editorRender(
|
||||
<ProblemEditorContextProvider editorRef={editorRef}>
|
||||
<SettingsWidget {...props} {...overrideProps} />
|
||||
</ProblemEditorContextProvider>,
|
||||
options,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
@@ -56,7 +68,7 @@ describe('SettingsWidget', () => {
|
||||
describe('behavior', () => {
|
||||
it('calls showAdvancedSettingsCards when initialized', () => {
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsBaseProps);
|
||||
editorRender(<SettingsWidget {...props} />);
|
||||
renderSettingsWidget();
|
||||
expect(hooks.showAdvancedSettingsCards).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -64,7 +76,7 @@ describe('SettingsWidget', () => {
|
||||
describe('renders', () => {
|
||||
test('renders Settings widget page', () => {
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsBaseProps);
|
||||
editorRender(<SettingsWidget {...props} />);
|
||||
renderSettingsWidget();
|
||||
expect(screen.getByText('Show advanced settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -74,7 +86,7 @@ describe('SettingsWidget', () => {
|
||||
isAdvancedCardsVisible: true,
|
||||
};
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
|
||||
const { container } = editorRender(<SettingsWidget {...props} />);
|
||||
const { container } = renderSettingsWidget();
|
||||
expect(screen.queryByText('Show advanced settings')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('showanswercard')).toBeInTheDocument();
|
||||
expect(container.querySelector('resetcard')).toBeInTheDocument();
|
||||
@@ -86,12 +98,49 @@ describe('SettingsWidget', () => {
|
||||
isAdvancedCardsVisible: true,
|
||||
};
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
|
||||
const { container } = editorRender(
|
||||
<SettingsWidget {...props} problemType={ProblemTypeKeys.ADVANCED} />,
|
||||
);
|
||||
const { container } = renderSettingsWidget({ problemType: ProblemTypeKeys.ADVANCED });
|
||||
expect(container.querySelector('randomization')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe('SwitchEditorCard rendering (markdown vs advanced)', () => {
|
||||
test('shows two SwitchEditorCard components when markdown is available and not currently enabled', () => {
|
||||
const showAdvancedSettingsCardsProps = {
|
||||
...showAdvancedSettingsCardsBaseProps,
|
||||
isAdvancedCardsVisible: true,
|
||||
};
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
|
||||
const modifiedInitialState: PartialEditorState = {
|
||||
problem: {
|
||||
problemType: null, // non-advanced problem
|
||||
isMarkdownEditorEnabled: false, // currently in advanced/raw (or standard) editor
|
||||
rawOLX: '<problem></problem>',
|
||||
rawMarkdown: '## Problem', // markdown content exists so button should appear
|
||||
isDirty: false,
|
||||
},
|
||||
};
|
||||
const { container } = renderSettingsWidget({}, { initialState: modifiedInitialState });
|
||||
expect(container.querySelectorAll('switcheditorcard')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('shows only the advanced SwitchEditorCard when already in markdown mode', () => {
|
||||
const showAdvancedSettingsCardsProps = {
|
||||
...showAdvancedSettingsCardsBaseProps,
|
||||
isAdvancedCardsVisible: true,
|
||||
};
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
|
||||
const modifiedInitialState: PartialEditorState = {
|
||||
problem: {
|
||||
problemType: null,
|
||||
isMarkdownEditorEnabled: true, // already in markdown editor, so markdown button hidden
|
||||
rawOLX: '<problem></problem>',
|
||||
rawMarkdown: '## Problem',
|
||||
isDirty: false,
|
||||
},
|
||||
};
|
||||
const { container } = renderSettingsWidget({}, { initialState: modifiedInitialState });
|
||||
expect(container.querySelectorAll('switcheditorcard')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLibrary', () => {
|
||||
const libraryProps = {
|
||||
@@ -100,7 +149,7 @@ describe('SettingsWidget', () => {
|
||||
};
|
||||
test('renders Settings widget page', () => {
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsBaseProps);
|
||||
const { container } = editorRender(<SettingsWidget {...libraryProps} />);
|
||||
const { container } = renderSettingsWidget(libraryProps);
|
||||
expect(container.querySelector('timercard')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('resetcard')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('typecard')).toBeInTheDocument();
|
||||
@@ -114,7 +163,7 @@ describe('SettingsWidget', () => {
|
||||
isAdvancedCardsVisible: true,
|
||||
};
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
|
||||
const { container } = editorRender(<SettingsWidget {...libraryProps} />);
|
||||
const { container } = renderSettingsWidget(libraryProps);
|
||||
expect(screen.queryByText('Show advanced settings')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('showanswearscard')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('resetcard')).not.toBeInTheDocument();
|
||||
@@ -128,7 +177,7 @@ describe('SettingsWidget', () => {
|
||||
isAdvancedCardsVisible: true,
|
||||
};
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
|
||||
const { container } = editorRender(<SettingsWidget {...libraryProps} problemType={ProblemTypeKeys.ADVANCED} />);
|
||||
const { container } = renderSettingsWidget({ ...libraryProps, problemType: ProblemTypeKeys.ADVANCED });
|
||||
expect(container.querySelector('randomization')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,13 +169,13 @@ const messages = defineMessages({
|
||||
},
|
||||
'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.',
|
||||
defaultMessage: 'If you use the advanced editor, this problem will be converted to OLX. Depending on what edits you make to the OLX, you may not be able to return to the simple editor.',
|
||||
description: 'message to confirm that a user wants to use the advanced editor',
|
||||
},
|
||||
'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',
|
||||
defaultMessage: 'Some edits that are possible with the markdown editor are not supported by the simple editor, so you may not be able to change back to the simple editor.',
|
||||
description: 'message to confirm that a user wants to use the markdown editor',
|
||||
},
|
||||
'ConfirmSwitchMessageTitle-advanced': {
|
||||
id: 'authoring.problemeditor.settings.switchtoeditor.ConfirmSwitchMessageTitle.advanced',
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Card } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useEditorContext } from '@src/editors/EditorContext';
|
||||
import { selectors, thunkActions } from '@src/editors/data/redux';
|
||||
import { thunkActions } from '@src/editors/data/redux';
|
||||
import BaseModal from '@src/editors/sharedComponents/BaseModal';
|
||||
import Button from '@src/editors/sharedComponents/Button';
|
||||
import { ProblemTypeKeys } from '@src/editors/data/constants/problem';
|
||||
import messages from '../messages';
|
||||
import { handleConfirmEditorSwitch } from '../hooks';
|
||||
import { useProblemEditorContext } from '../../ProblemEditorContext';
|
||||
|
||||
const SwitchEditorCard = ({
|
||||
editorType,
|
||||
problemType,
|
||||
}) => {
|
||||
const [isConfirmOpen, setConfirmOpen] = React.useState(false);
|
||||
const { isMarkdownEditorEnabledForContext } = useEditorContext();
|
||||
const isMarkdownEditorEnabled = useSelector(selectors.problem.isMarkdownEditorEnabled);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const isMarkdownEditorActive = isMarkdownEditorEnabled && isMarkdownEditorEnabledForContext;
|
||||
if (isMarkdownEditorActive || problemType === ProblemTypeKeys.ADVANCED) { return null; }
|
||||
const { editorRef } = useProblemEditorContext();
|
||||
if (problemType === ProblemTypeKeys.ADVANCED) { return null; }
|
||||
|
||||
return (
|
||||
<Card className="border border-light-700 shadow-none">
|
||||
@@ -33,7 +30,7 @@ const SwitchEditorCard = ({
|
||||
<Button
|
||||
/* istanbul ignore next */
|
||||
onClick={() => handleConfirmEditorSwitch({
|
||||
switchEditor: () => dispatch(thunkActions.problem.switchEditor(editorType)),
|
||||
switchEditor: () => dispatch(thunkActions.problem.switchEditor(editorType, editorRef)),
|
||||
setConfirmOpen,
|
||||
})}
|
||||
variant="primary"
|
||||
|
||||
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { editorRender } from '@src/editors/editorTestRender';
|
||||
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
|
||||
import { thunkActions } from '@src/editors/data/redux';
|
||||
import { ProblemEditorContextProvider } from '../../ProblemEditorContext';
|
||||
import SwitchEditorCard from './SwitchEditorCard';
|
||||
|
||||
const switchEditorSpy = jest.spyOn(thunkActions.problem, 'switchEditor');
|
||||
@@ -13,6 +14,13 @@ describe('SwitchEditorCard - markdown', () => {
|
||||
problemType: 'stringresponse',
|
||||
editorType: 'markdown',
|
||||
};
|
||||
const editorRef = { current: null };
|
||||
|
||||
const renderSwitchEditorCard = (overrideProps = {}) => editorRender(
|
||||
<ProblemEditorContextProvider editorRef={editorRef}>
|
||||
<SwitchEditorCard {...baseProps} {...overrideProps} />
|
||||
</ProblemEditorContextProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
@@ -23,7 +31,7 @@ describe('SwitchEditorCard - markdown', () => {
|
||||
mockWaffleFlags({ useReactMarkdownEditor: true });
|
||||
// The markdown editor is not currently active (default)
|
||||
|
||||
editorRender(<SwitchEditorCard {...baseProps} />);
|
||||
renderSwitchEditorCard();
|
||||
const user = userEvent.setup();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
const switchButton = screen.getByRole('button', { name: 'Switch to markdown editor' });
|
||||
@@ -38,7 +46,7 @@ describe('SwitchEditorCard - markdown', () => {
|
||||
mockWaffleFlags({ useReactMarkdownEditor: true });
|
||||
// The markdown editor is not currently active (default)
|
||||
|
||||
editorRender(<SwitchEditorCard {...baseProps} />);
|
||||
renderSwitchEditorCard();
|
||||
const user = userEvent.setup();
|
||||
const switchButton = screen.getByRole('button', { name: 'Switch to markdown editor' });
|
||||
expect(switchButton).toBeInTheDocument();
|
||||
@@ -49,28 +57,12 @@ describe('SwitchEditorCard - markdown', () => {
|
||||
expect(confirmButton).toBeInTheDocument();
|
||||
expect(switchEditorSpy).not.toHaveBeenCalled();
|
||||
await user.click(confirmButton);
|
||||
expect(switchEditorSpy).toHaveBeenCalledWith('markdown');
|
||||
expect(switchEditorSpy).toHaveBeenCalledWith('markdown', editorRef);
|
||||
// Markdown editor would now be active.
|
||||
});
|
||||
|
||||
test('renders nothing for advanced problemType', () => {
|
||||
const { container } = editorRender(<SwitchEditorCard {...baseProps} problemType="advanced" />);
|
||||
const reduxWrapper = (container.firstChild as HTMLElement | null);
|
||||
expect(reduxWrapper?.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
test('returns null when editor is already Markdown', () => {
|
||||
// Markdown Editor support is on for this course:
|
||||
mockWaffleFlags({ useReactMarkdownEditor: true });
|
||||
// The markdown editor *IS* currently active (default)
|
||||
|
||||
const { container } = editorRender(<SwitchEditorCard {...baseProps} />, {
|
||||
initialState: {
|
||||
problem: {
|
||||
isMarkdownEditorEnabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const { container } = renderSwitchEditorCard({ problemType: 'advanced' });
|
||||
const reduxWrapper = (container.firstChild as HTMLElement | null);
|
||||
expect(reduxWrapper?.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
@@ -90,7 +90,10 @@ export const parseState = ({
|
||||
return {
|
||||
settings: {
|
||||
...settings,
|
||||
...(isMarkdownEditorEnabled && { markdown: contentString }),
|
||||
// If the save action isn’t triggered from the Markdown editor, the Markdown content might be outdated. Since the
|
||||
// Markdown editor shouldn't be displayed in future in this case, we’re sending `null` instead.
|
||||
// TODO: Implement OLX-to-Markdown conversion to properly handle this scenario.
|
||||
markdown: isMarkdownEditorEnabled ? contentString : null,
|
||||
markdown_edited: isMarkdownEditorEnabled,
|
||||
},
|
||||
olx: isAdvanced || isMarkdownEditorEnabled ? rawOLX : reactBuiltOlx,
|
||||
|
||||
@@ -165,6 +165,7 @@ describe('EditProblemView hooks parseState', () => {
|
||||
assets: {},
|
||||
})();
|
||||
expect(res.olx).toBe(mockRawOLX);
|
||||
expect(res.settings.markdown).toBe(null);
|
||||
});
|
||||
it('markdown problem', () => {
|
||||
const res = hooks.parseState({
|
||||
@@ -306,6 +307,8 @@ describe('EditProblemView hooks parseState', () => {
|
||||
show_reset_button: false,
|
||||
submission_wait_seconds: 0,
|
||||
attempts_before_showanswer_button: 0,
|
||||
markdown: null,
|
||||
markdown_edited: false,
|
||||
};
|
||||
const openSaveWarningModal = jest.fn();
|
||||
|
||||
@@ -313,6 +316,7 @@ describe('EditProblemView hooks parseState', () => {
|
||||
const problem = { ...problemState, problemType: ProblemTypeKeys.NUMERIC, answers: [{ id: 'A', title: 'problem', correct: true }] };
|
||||
const content = hooks.getContent({
|
||||
isAdvancedProblemType: false,
|
||||
isMarkdownEditorEnabled: false,
|
||||
problemState: problem,
|
||||
editorRef,
|
||||
assets,
|
||||
@@ -339,6 +343,7 @@ describe('EditProblemView hooks parseState', () => {
|
||||
};
|
||||
const { settings } = hooks.getContent({
|
||||
isAdvancedProblemType: false,
|
||||
isMarkdownEditorEnabled: false,
|
||||
problemState: problem,
|
||||
editorRef,
|
||||
assets,
|
||||
@@ -353,12 +358,15 @@ describe('EditProblemView hooks parseState', () => {
|
||||
attempts_before_showanswer_button: 0,
|
||||
submission_wait_seconds: 0,
|
||||
weight: 1,
|
||||
markdown: null,
|
||||
markdown_edited: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('default advanced save and returns parseState data', () => {
|
||||
const content = hooks.getContent({
|
||||
isAdvancedProblemType: true,
|
||||
isMarkdownEditorEnabled: false,
|
||||
problemState,
|
||||
editorRef,
|
||||
assets,
|
||||
|
||||
@@ -28,6 +28,7 @@ import ExplanationWidget from './ExplanationWidget';
|
||||
import { saveBlock } from '../../../../hooks';
|
||||
|
||||
import { selectors } from '../../../../data/redux';
|
||||
import { ProblemEditorContextProvider } from './ProblemEditorContext';
|
||||
|
||||
const EditProblemView = ({ returnFunction }) => {
|
||||
const intl = useIntl();
|
||||
@@ -58,85 +59,87 @@ const EditProblemView = ({ returnFunction }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<EditorContainer
|
||||
getContent={() => getContent({
|
||||
problemState,
|
||||
openSaveWarningModal,
|
||||
isAdvancedProblemType,
|
||||
isMarkdownEditorEnabled,
|
||||
editorRef,
|
||||
lmsEndpointUrl,
|
||||
})}
|
||||
isDirty={checkIfDirty}
|
||||
returnFunction={returnFunction}
|
||||
>
|
||||
<AlertModal
|
||||
title={isAdvancedProblemType
|
||||
? intl.formatMessage(messages.olxSettingDiscrepancyTitle)
|
||||
: intl.formatMessage(messages.noAnswerTitle)}
|
||||
isOpen={isSaveWarningModalOpen}
|
||||
onClose={closeSaveWarningModal}
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button variant="tertiary" onClick={closeSaveWarningModal}>
|
||||
<FormattedMessage {...messages.saveWarningModalCancelButtonLabel} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveBlock({
|
||||
content: parseState({
|
||||
problem: problemState,
|
||||
isAdvanced: isAdvancedProblemType,
|
||||
isMarkdown: isMarkdownEditorEnabled,
|
||||
ref: editorRef,
|
||||
lmsEndpointUrl,
|
||||
})(),
|
||||
returnFunction,
|
||||
destination: returnUrl,
|
||||
dispatch,
|
||||
analytics,
|
||||
})}
|
||||
>
|
||||
<FormattedMessage {...messages.saveWarningModalSaveButtonLabel} />
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
<ProblemEditorContextProvider editorRef={editorRef}>
|
||||
<EditorContainer
|
||||
getContent={() => getContent({
|
||||
problemState,
|
||||
openSaveWarningModal,
|
||||
isAdvancedProblemType,
|
||||
isMarkdownEditorEnabled,
|
||||
editorRef,
|
||||
lmsEndpointUrl,
|
||||
})}
|
||||
isDirty={checkIfDirty}
|
||||
returnFunction={returnFunction}
|
||||
>
|
||||
{isAdvancedProblemType ? (
|
||||
<FormattedMessage {...messages.olxSettingDiscrepancyBodyExplanation} />
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<FormattedMessage {...messages.saveWarningModalBodyQuestion} />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage {...messages.noAnswerBodyExplanation} />
|
||||
</div>
|
||||
</>
|
||||
<AlertModal
|
||||
title={isAdvancedProblemType
|
||||
? intl.formatMessage(messages.olxSettingDiscrepancyTitle)
|
||||
: intl.formatMessage(messages.noAnswerTitle)}
|
||||
isOpen={isSaveWarningModalOpen}
|
||||
onClose={closeSaveWarningModal}
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button variant="tertiary" onClick={closeSaveWarningModal}>
|
||||
<FormattedMessage {...messages.saveWarningModalCancelButtonLabel} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveBlock({
|
||||
content: parseState({
|
||||
problem: problemState,
|
||||
isAdvanced: isAdvancedProblemType,
|
||||
isMarkdown: isMarkdownEditorEnabled,
|
||||
ref: editorRef,
|
||||
lmsEndpointUrl,
|
||||
})(),
|
||||
returnFunction,
|
||||
destination: returnUrl,
|
||||
dispatch,
|
||||
analytics,
|
||||
})}
|
||||
>
|
||||
<FormattedMessage {...messages.saveWarningModalSaveButtonLabel} />
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
</AlertModal>
|
||||
>
|
||||
{isAdvancedProblemType ? (
|
||||
<FormattedMessage {...messages.olxSettingDiscrepancyBodyExplanation} />
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<FormattedMessage {...messages.saveWarningModalBodyQuestion} />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage {...messages.noAnswerBodyExplanation} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AlertModal>
|
||||
|
||||
<div className="editProblemView d-flex flex-row flex-nowrap justify-content-end">
|
||||
{isAdvancedProblemType || isMarkdownEditorEnabled ? (
|
||||
<Container fluid className="advancedEditorTopMargin p-0">
|
||||
<RawEditor
|
||||
editorRef={editorRef}
|
||||
lang={isMarkdownEditorEnabled ? 'markdown' : 'xml'}
|
||||
content={isMarkdownEditorEnabled ? problemState.rawMarkdown : problemState.rawOLX}
|
||||
/>
|
||||
</Container>
|
||||
) : (
|
||||
<span className="flex-grow-1 mb-5">
|
||||
<QuestionWidget />
|
||||
<ExplanationWidget />
|
||||
<AnswerWidget problemType={problemType} />
|
||||
<div className="editProblemView d-flex flex-row flex-nowrap justify-content-end">
|
||||
{isAdvancedProblemType || isMarkdownEditorEnabled ? (
|
||||
<Container fluid className="advancedEditorTopMargin p-0">
|
||||
<RawEditor
|
||||
editorRef={editorRef}
|
||||
lang={isMarkdownEditorEnabled ? 'markdown' : 'xml'}
|
||||
content={isMarkdownEditorEnabled ? problemState.rawMarkdown : problemState.rawOLX}
|
||||
/>
|
||||
</Container>
|
||||
) : (
|
||||
<span className="flex-grow-1 mb-5">
|
||||
<QuestionWidget />
|
||||
<ExplanationWidget />
|
||||
<AnswerWidget problemType={problemType} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="editProblemView-settingsColumn">
|
||||
<SettingsWidget problemType={problemType} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="editProblemView-settingsColumn">
|
||||
<SettingsWidget problemType={problemType} />
|
||||
</span>
|
||||
</div>
|
||||
</EditorContainer>
|
||||
</div>
|
||||
</EditorContainer>
|
||||
</ProblemEditorContextProvider>
|
||||
);
|
||||
};
|
||||
EditProblemView.defaultProps = {
|
||||
|
||||
@@ -54,16 +54,6 @@ const numericalProblemPartialCreditParser = new OLXParser(numericalProblemPartia
|
||||
|
||||
describe('OLXParser', () => {
|
||||
describe('throws error and redirects to advanced editor', () => {
|
||||
describe('when settings attributes are on problem tags', () => {
|
||||
it('should throw error and contain message regarding opening advanced editor', () => {
|
||||
try {
|
||||
labelDescriptionQuestionOlxParser.getParsedOLXData();
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
expect(e.message).toBe('Unrecognized attribute "markdown" associated with problem, opening in advanced editor');
|
||||
}
|
||||
});
|
||||
});
|
||||
describe('when settings attributes are on problem tags', () => {
|
||||
it('should throw error and contain message regarding opening advanced editor', () => {
|
||||
try {
|
||||
|
||||
@@ -376,7 +376,7 @@ export const settingsOlxAttributes = [
|
||||
] as const;
|
||||
|
||||
export const ignoredOlxAttributes = [
|
||||
// '@_markdown', // Not sure if this is safe to ignore; some tests seem to indicate it's not.
|
||||
'@_markdown',
|
||||
'@_url_name',
|
||||
'@_x-is-pointer-node',
|
||||
'@_markdown_edited',
|
||||
|
||||
@@ -45,31 +45,86 @@ describe('problem thunkActions', () => {
|
||||
let dispatch;
|
||||
let getState;
|
||||
let dispatchedAction;
|
||||
let mockEditorRef;
|
||||
|
||||
const mockProblemState = (isMarkdownEditorEnabled) => ({
|
||||
problem: {
|
||||
isMarkdownEditorEnabled,
|
||||
rawOLX: 'PREVIOUS_OLX',
|
||||
},
|
||||
app: {
|
||||
learningContextId: 'course-v1:org+course+run',
|
||||
blockValue,
|
||||
},
|
||||
});
|
||||
|
||||
const createMockEditorRef = (content = 'MockMarkdownContent') => ({
|
||||
current: {
|
||||
state: {
|
||||
doc: { toString: jest.fn(() => content) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch = jest.fn((action) => ({ dispatch: action }));
|
||||
getState = jest.fn(() => ({
|
||||
problem: {
|
||||
},
|
||||
app: {
|
||||
learningContextId: 'course-v1:org+course+run',
|
||||
blockValue,
|
||||
},
|
||||
}));
|
||||
mockEditorRef = createMockEditorRef();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
test('initializeProblem visual Problem :', () => {
|
||||
initializeProblem(blockValue)(dispatch, getState);
|
||||
expect(dispatch).toHaveBeenCalled();
|
||||
|
||||
describe('when markdown editor is enabled', () => {
|
||||
beforeEach(() => {
|
||||
getState = jest.fn(() => mockProblemState(true));
|
||||
});
|
||||
|
||||
test('initializeProblem triggers dispatch', () => {
|
||||
initializeProblem(blockValue)(dispatch, getState);
|
||||
expect(dispatch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('switchToAdvancedEditor converts markdown to OLX', () => {
|
||||
switchToAdvancedEditor(mockEditorRef)(dispatch, getState);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
actions.problem.updateField({
|
||||
problemType: ProblemTypeKeys.ADVANCED,
|
||||
rawOLX: '<problem>\n<p>MockMarkdownContent</p>\n</problem>',
|
||||
isMarkdownEditorEnabled: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('switchToAdvancedEditor falls back to previous OLX if editorRef missing', () => {
|
||||
switchToAdvancedEditor(null)(dispatch, getState);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
actions.problem.updateField({
|
||||
problemType: ProblemTypeKeys.ADVANCED,
|
||||
rawOLX: 'PREVIOUS_OLX',
|
||||
isMarkdownEditorEnabled: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
test('switchToAdvancedEditor visual Problem', () => {
|
||||
switchToAdvancedEditor()(dispatch, getState);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
actions.problem.updateField({ problemType: ProblemTypeKeys.ADVANCED, rawOLX: mockOlx }),
|
||||
);
|
||||
|
||||
describe('when markdown editor is disabled', () => {
|
||||
beforeEach(() => {
|
||||
getState = jest.fn(() => mockProblemState(false));
|
||||
});
|
||||
|
||||
test('switchToAdvancedEditor uses ReactStateOLXParser', () => {
|
||||
switchToAdvancedEditor(mockEditorRef)(dispatch, getState);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
actions.problem.updateField({
|
||||
problemType: ProblemTypeKeys.ADVANCED,
|
||||
rawOLX: mockOlx,
|
||||
isMarkdownEditorEnabled: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('switchToMarkdownEditor dispatches correct actions', () => {
|
||||
switchToMarkdownEditor()(dispatch);
|
||||
|
||||
@@ -94,12 +149,12 @@ describe('problem thunkActions', () => {
|
||||
});
|
||||
|
||||
test('dispatches switchToAdvancedEditor when editorType is advanced', () => {
|
||||
switchEditor('advanced')(dispatch, getState);
|
||||
switchEditor('advanced', mockEditorRef)(dispatch, getState);
|
||||
expect(switchToAdvancedEditorMock).toHaveBeenCalledWith(dispatch, getState);
|
||||
});
|
||||
|
||||
test('dispatches switchToMarkdownEditor when editorType is markdown', () => {
|
||||
switchEditor('markdown')(dispatch, getState);
|
||||
switchEditor('markdown', mockEditorRef)(dispatch, getState);
|
||||
expect(switchToMarkdownEditorMock).toHaveBeenCalledWith(dispatch);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { get, isEmpty } from 'lodash';
|
||||
import { camelizeKeys, convertMarkdownToXml } from '@src/editors/utils';
|
||||
import { actions as problemActions } from '../problem';
|
||||
import { actions as requestActions } from '../requests';
|
||||
import { selectors as appSelectors } from '../app';
|
||||
@@ -8,7 +9,6 @@ import { OLXParser } from '../../../containers/ProblemEditor/data/OLXParser';
|
||||
import { parseSettings } from '../../../containers/ProblemEditor/data/SettingsParser';
|
||||
import { ProblemTypeKeys } from '../../constants/problem';
|
||||
import ReactStateOLXParser from '../../../containers/ProblemEditor/data/ReactStateOLXParser';
|
||||
import { camelizeKeys } from '../../../utils';
|
||||
import { fetchEditorContent } from '../../../containers/ProblemEditor/components/EditProblemView/hooks';
|
||||
import { RequestKeys } from '../../constants/requests';
|
||||
|
||||
@@ -16,21 +16,38 @@ import { RequestKeys } from '../../constants/requests';
|
||||
const actions = { problem: problemActions, requests: requestActions };
|
||||
const selectors = { app: appSelectors };
|
||||
|
||||
export const switchToAdvancedEditor = () => (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const editorObject = fetchEditorContent({ format: '' });
|
||||
const reactOLXParser = new ReactStateOLXParser({ problem: state.problem, editorObject });
|
||||
const rawOLX = reactOLXParser.buildOLX();
|
||||
dispatch(actions.problem.updateField({ problemType: ProblemTypeKeys.ADVANCED, rawOLX }));
|
||||
export const switchToAdvancedEditor = (editorRef) => (dispatch, getState) => {
|
||||
const { problem } = getState();
|
||||
let rawOLX;
|
||||
|
||||
if (problem.isMarkdownEditorEnabled) {
|
||||
// Convert current markdown from CodeMirror editor
|
||||
if (editorRef?.current?.state?.doc) {
|
||||
const markdownContent = editorRef.current.state.doc.toString();
|
||||
rawOLX = convertMarkdownToXml(markdownContent);
|
||||
} else {
|
||||
// Fallback to previously saved olx
|
||||
rawOLX = problem.rawOLX;
|
||||
}
|
||||
} else {
|
||||
const editorObject = fetchEditorContent({ format: '' });
|
||||
rawOLX = new ReactStateOLXParser({ problem, editorObject }).buildOLX();
|
||||
}
|
||||
|
||||
dispatch(actions.problem.updateField({
|
||||
problemType: ProblemTypeKeys.ADVANCED,
|
||||
rawOLX,
|
||||
isMarkdownEditorEnabled: false,
|
||||
}));
|
||||
};
|
||||
|
||||
export const switchToMarkdownEditor = () => (dispatch) => {
|
||||
dispatch(actions.problem.updateField({ isMarkdownEditorEnabled: true }));
|
||||
};
|
||||
|
||||
export const switchEditor = (editorType) => (dispatch, getState) => {
|
||||
export const switchEditor = (editorType, editorRef) => (dispatch, getState) => {
|
||||
if (editorType === 'advanced') {
|
||||
switchToAdvancedEditor()(dispatch, getState);
|
||||
switchToAdvancedEditor(editorRef)(dispatch, getState);
|
||||
} else {
|
||||
switchToMarkdownEditor()(dispatch);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
@@ -24,6 +24,21 @@ const CodeEditor = ({
|
||||
});
|
||||
const { showBtnEscapeHTML, hideBtn } = hooks.prepareShowBtnEscapeHTML();
|
||||
|
||||
// Ensure Editor updates when value prop changes. Triggered when switching editors (markdown->advanced).
|
||||
useEffect(() => {
|
||||
if (innerRef && innerRef.current) {
|
||||
const view = innerRef.current;
|
||||
if (view.state && view.state.doc) {
|
||||
const currentValue = view.state.doc.toString();
|
||||
if (currentValue !== value) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: currentValue.length, insert: value },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [value, innerRef]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div id="CodeMirror" ref={DOMref} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import {
|
||||
render, screen, initializeMocks, fireEvent,
|
||||
} from '@src/testUtils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { formatMessage, MockUseState } from '../../testUtils';
|
||||
import alphanumericMap from './constants';
|
||||
import { CodeEditorInternal as CodeEditor } from './index';
|
||||
@@ -107,6 +108,7 @@ describe('CodeEditor', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
let useRefSpy;
|
||||
test('Renders and calls Hooks ', () => {
|
||||
const props = {
|
||||
intl: { formatMessage },
|
||||
@@ -122,7 +124,7 @@ describe('CodeEditor', () => {
|
||||
.mockImplementationOnce(() => mockDOMRef) // for DOMref
|
||||
.mockImplementationOnce(() => mockBtnRef); // for btnRef
|
||||
|
||||
jest.spyOn(React, 'useRef').mockImplementation(mockUseRef);
|
||||
useRefSpy = jest.spyOn(React, 'useRef').mockImplementation(mockUseRef);
|
||||
|
||||
const mockHideBtn = jest.fn();
|
||||
jest.spyOn(hooks, 'prepareShowBtnEscapeHTML').mockImplementation(() => ({
|
||||
@@ -139,6 +141,50 @@ describe('CodeEditor', () => {
|
||||
expect(hooks.createCodeMirrorDomNode).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Unescape HTML Literals' }));
|
||||
expect(mockEscapeHTMLSpecialChars).toHaveBeenCalled();
|
||||
// Prevent React.useRef mock leakage into subsequent tests
|
||||
useRefSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('value change effect', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
test('dispatches changes when value prop updates', async () => {
|
||||
const mockDispatch = jest.fn();
|
||||
const oldContent = 'old content';
|
||||
const newContent = 'new content';
|
||||
const mockView = {
|
||||
state: { doc: { toString: () => oldContent } },
|
||||
dispatch: mockDispatch,
|
||||
};
|
||||
const innerRef = { current: mockView };
|
||||
|
||||
jest.spyOn(hooks, 'createCodeMirrorDomNode').mockImplementation(() => ({}));
|
||||
jest.spyOn(hooks, 'prepareShowBtnEscapeHTML').mockReturnValue({ showBtnEscapeHTML: false, hideBtn: jest.fn() });
|
||||
|
||||
const { rerender } = render(<CodeEditor innerRef={innerRef} value={oldContent} lang="xml" />);
|
||||
// Initial render: value matches doc, no dispatch
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
|
||||
// Rerender with new value triggers effect
|
||||
rerender(<CodeEditor innerRef={innerRef} value={newContent} lang="xml" />);
|
||||
await waitFor(() => expect(mockDispatch).toHaveBeenCalledTimes(1));
|
||||
const callArg = mockDispatch.mock.calls[0][0];
|
||||
expect(callArg.changes.insert).toBe(newContent);
|
||||
expect(callArg.changes.from).toBe(0);
|
||||
expect(callArg.changes.to).toBe(oldContent.length);
|
||||
|
||||
// Simulate that the editor document now reflects the new content so a rerender
|
||||
// with the same value does not trigger another dispatch.
|
||||
mockView.state.doc.toString = () => newContent;
|
||||
|
||||
// Rerender again with same value should not trigger additional dispatch
|
||||
mockDispatch.mockClear();
|
||||
rerender(<CodeEditor innerRef={innerRef} value={newContent} lang="xml" />);
|
||||
// Give a tick to ensure no extra dispatch happens
|
||||
await waitFor(() => expect(mockDispatch).not.toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user