feat: enable markdown to OLX conversion (#2518)

This commit is contained in:
Muhammad Anas
2025-10-22 04:58:20 +05:00
committed by GitHub
parent 191be55b2e
commit 82a3c2815b
15 changed files with 367 additions and 160 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -90,7 +90,10 @@ export const parseState = ({
return {
settings: {
...settings,
...(isMarkdownEditorEnabled && { markdown: contentString }),
// If the save action isnt triggered from the Markdown editor, the Markdown content might be outdated. Since the
// Markdown editor shouldn't be displayed in future in this case, were 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,

View File

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

View File

@@ -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 = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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