From 82a3c2815bc776a3aa23dd82bbd8848441e12123 Mon Sep 17 00:00:00 2001 From: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com> Date: Wed, 22 Oct 2025 04:58:20 +0500 Subject: [PATCH] feat: enable markdown to OLX conversion (#2518) --- .../EditProblemView/ProblemEditorContext.tsx | 31 ++++ .../EditProblemView/SettingsWidget/index.jsx | 3 +- .../SettingsWidget/index.test.tsx | 71 ++++++-- .../SettingsWidget/messages.js | 6 +- .../settingsComponents/SwitchEditorCard.jsx | 15 +- .../SwitchEditorCard.test.tsx | 32 ++-- .../components/EditProblemView/hooks.js | 5 +- .../components/EditProblemView/hooks.test.js | 8 + .../components/EditProblemView/index.jsx | 153 +++++++++--------- .../ProblemEditor/data/OLXParser.test.js | 10 -- src/editors/data/constants/problem.ts | 2 +- .../data/redux/thunkActions/problem.test.ts | 91 ++++++++--- .../data/redux/thunkActions/problem.ts | 35 ++-- .../sharedComponents/CodeEditor/index.jsx | 17 +- .../CodeEditor/index.test.tsx | 48 +++++- 15 files changed, 367 insertions(+), 160 deletions(-) create mode 100644 src/editors/containers/ProblemEditor/components/EditProblemView/ProblemEditorContext.tsx diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/ProblemEditorContext.tsx b/src/editors/containers/ProblemEditor/components/EditProblemView/ProblemEditorContext.tsx new file mode 100644 index 000000000..d05ad805e --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/ProblemEditorContext.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +type ProblemEditorRef = React.MutableRefObject | React.RefObject | null; + +export interface ProblemEditorContextValue { + editorRef: ProblemEditorRef; +} + +export type ProblemEditorContextInit = { + editorRef?: ProblemEditorRef; +}; + +const context = React.createContext(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 '); + } + return ctx; +} + +export const ProblemEditorContextProvider: React.FC<{ children: React.ReactNode; } & ProblemEditorContextInit> = ({ + children, + editorRef = null, +}) => { + const ctx: ProblemEditorContextValue = React.useMemo(() => ({ editorRef }), [editorRef]); + + return {children}; +}; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.jsx index 1ebabe093..4dddd02d4 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.jsx @@ -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 = ({
- { showMarkdownEditorButton + { (showMarkdownEditorButton && !isMarkdownEditorEnabled) // Only show button if not already in markdown editor && (
diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.test.tsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.test.tsx index 89ded5dd7..5a9e96341 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.test.tsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.test.tsx @@ -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( + + + , + options, + ); + beforeEach(() => { initializeMocks(); }); @@ -56,7 +68,7 @@ describe('SettingsWidget', () => { describe('behavior', () => { it('calls showAdvancedSettingsCards when initialized', () => { jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsBaseProps); - editorRender(); + 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(); + 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(); + 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( - , - ); + 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: '', + 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: '', + 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(); + 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(); + 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(); + const { container } = renderSettingsWidget({ ...libraryProps, problemType: ProblemTypeKeys.ADVANCED }); expect(container.querySelector('randomization')).toBeInTheDocument(); }); }); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/messages.js index 78d8cd565..e91503253 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/messages.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/messages.js @@ -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', diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchEditorCard.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchEditorCard.jsx index 1205b8395..b047b1506 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchEditorCard.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchEditorCard.jsx @@ -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 ( @@ -33,7 +30,7 @@ const SwitchEditorCard = ({ - - - )} + + getContent({ + problemState, + openSaveWarningModal, + isAdvancedProblemType, + isMarkdownEditorEnabled, + editorRef, + lmsEndpointUrl, + })} + isDirty={checkIfDirty} + returnFunction={returnFunction} > - {isAdvancedProblemType ? ( - - ) : ( - <> -
- -
-
- -
- + + + + )} - + > + {isAdvancedProblemType ? ( + + ) : ( + <> +
+ +
+
+ +
+ + )} + -
- {isAdvancedProblemType || isMarkdownEditorEnabled ? ( - - - - ) : ( - - - - +
+ {isAdvancedProblemType || isMarkdownEditorEnabled ? ( + + + + ) : ( + + + + + + )} + + + - )} - - - - -
- +
+
+
); }; EditProblemView.defaultProps = { diff --git a/src/editors/containers/ProblemEditor/data/OLXParser.test.js b/src/editors/containers/ProblemEditor/data/OLXParser.test.js index e71892a9c..fd9fad8e2 100644 --- a/src/editors/containers/ProblemEditor/data/OLXParser.test.js +++ b/src/editors/containers/ProblemEditor/data/OLXParser.test.js @@ -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 { diff --git a/src/editors/data/constants/problem.ts b/src/editors/data/constants/problem.ts index 804dba3b5..8470b43d6 100644 --- a/src/editors/data/constants/problem.ts +++ b/src/editors/data/constants/problem.ts @@ -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', diff --git a/src/editors/data/redux/thunkActions/problem.test.ts b/src/editors/data/redux/thunkActions/problem.test.ts index 3c7edbe52..c4172f391 100644 --- a/src/editors/data/redux/thunkActions/problem.test.ts +++ b/src/editors/data/redux/thunkActions/problem.test.ts @@ -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: '\n

MockMarkdownContent

\n
', + 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); }); }); diff --git a/src/editors/data/redux/thunkActions/problem.ts b/src/editors/data/redux/thunkActions/problem.ts index 74876fdb1..24068fc90 100644 --- a/src/editors/data/redux/thunkActions/problem.ts +++ b/src/editors/data/redux/thunkActions/problem.ts @@ -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); } diff --git a/src/editors/sharedComponents/CodeEditor/index.jsx b/src/editors/sharedComponents/CodeEditor/index.jsx index 7d86ae058..6e18862cf 100644 --- a/src/editors/sharedComponents/CodeEditor/index.jsx +++ b/src/editors/sharedComponents/CodeEditor/index.jsx @@ -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 (
diff --git a/src/editors/sharedComponents/CodeEditor/index.test.tsx b/src/editors/sharedComponents/CodeEditor/index.test.tsx index c41096608..ebd667f26 100644 --- a/src/editors/sharedComponents/CodeEditor/index.test.tsx +++ b/src/editors/sharedComponents/CodeEditor/index.test.tsx @@ -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(); + // Initial render: value matches doc, no dispatch + expect(mockDispatch).not.toHaveBeenCalled(); + + // Rerender with new value triggers effect + rerender(); + 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(); + // Give a tick to ensure no extra dispatch happens + await waitFor(() => expect(mockDispatch).not.toHaveBeenCalled()); }); }); });