From 2f9566c4f54fd64713ee381cec454deef1eaf8e1 Mon Sep 17 00:00:00 2001 From: Pradeep Patro Date: Tue, 5 Aug 2025 15:47:09 +0530 Subject: [PATCH] refactor: Problem type handling to support localization - Updated hooks and components to utilize localized problem titles and descriptions. - Introduced `getProblemTypes` and `getAdvanceProblems` functions for internationalization support. - Enhanced tests to verify localized titles and descriptions for problem types. - Added new messages for various problem types and their descriptions. - Refactored `TypeCard`, `TypeRow`, and `SelectTypeModal` components to use localized strings. - Improved test coverage for problem type selection and rendering. --- .../AnswerWidget/AnswersContainer.test.tsx | 4 +- .../EditProblemView/AnswerWidget/index.jsx | 12 +- .../EditProblemView/SettingsWidget/hooks.js | 27 +++- .../SettingsWidget/hooks.test.js | 29 +++- .../SettingsWidget/index.test.tsx | 3 +- .../settingsComponents/TypeCard.jsx | 8 +- .../settingsComponents/TypeCard.test.tsx | 3 +- .../settingsComponents/TypeRow.jsx | 4 + .../SelectTypeWrapper/index.tsx | 1 + .../SelectTypeWrapper/messages.ts | 128 +++++++++++++++ .../content/AdvanceTypeSelect.test.tsx | 3 +- .../content/AdvanceTypeSelect.tsx | 15 +- .../SelectTypeModal/content/Preview.jsx | 12 +- .../SelectTypeModal/content/Preview.test.tsx | 89 +++++++---- .../content/ProblemTypeSelect.test.tsx | 3 +- .../content/ProblemTypeSelect.tsx | 9 +- .../SelectTypeModal/content/messages.ts | 104 ++++++++++++- .../components/SelectTypeModal/hooks.js | 19 ++- .../components/SelectTypeModal/hooks.test.js | 24 ++- .../components/SelectTypeModal/index.tsx | 13 +- src/editors/data/constants/problem.test.ts | 13 ++ src/editors/data/constants/problem.ts | 147 ++++++++++++++++++ 22 files changed, 586 insertions(+), 84 deletions(-) create mode 100644 src/editors/data/constants/problem.test.ts diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.test.tsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.test.tsx index e3844afba..5d03e976e 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.test.tsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.test.tsx @@ -1,10 +1,10 @@ import React from 'react'; + +import { ProblemTypeKeys } from '@src/editors/data/constants/problem'; import { render, screen, fireEvent, initializeMocks, } from '../../../../../../testUtils'; import AnswersContainer from './AnswersContainer'; -import { ProblemTypeKeys } from '../../../../../data/constants/problem'; -// Import actions after mocking to access mocked functions import { actions } from '../../../../../data/redux'; const { useAnswerContainer } = require('./hooks'); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.jsx index 9094cb52d..9f3070449 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.jsx @@ -2,8 +2,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { getProblemTypes } from '@src/editors/data/constants/problem'; import messages from './messages'; -import { ProblemTypes } from '../../../../../data/constants/problem'; import AnswersContainer from './AnswersContainer'; // This widget should be connected, grab all answers from store, update them as needed. @@ -12,7 +12,10 @@ const AnswerWidget = ({ problemType, }) => { const intl = useIntl(); - const problemStaticData = ProblemTypes[problemType]; + + const localizedProblemTypes = getProblemTypes(intl.formatMessage); + const localizedProblemStaticData = localizedProblemTypes[problemType]; + return (
@@ -20,7 +23,10 @@ const AnswerWidget = ({
- {intl.formatMessage(messages.answerHelperText, { helperText: problemStaticData.description })} +
diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js index 81db5e4b4..eb0ed887b 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js @@ -3,18 +3,19 @@ import { useState, useEffect } from 'react'; import { includes, isEmpty, isFinite, isNaN, isNil, } from 'lodash'; +import { + ProblemTypeKeys, + ProblemTypes, + RichTextProblems, + ShowAnswerTypesKeys, + getProblemTypes, +} from '@src/editors/data/constants/problem'; // This 'module' self-import hack enables mocking during tests. // See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested // should be re-thought and cleaned up to avoid this pattern. // eslint-disable-next-line import/no-self-import import * as module from './hooks'; import messages from './messages'; -import { - ProblemTypeKeys, - ProblemTypes, - RichTextProblems, - ShowAnswerTypesKeys, -} from '../../../../../data/constants/problem'; import { fetchEditorContent } from '../hooks'; export const state = { @@ -232,6 +233,7 @@ export const typeRowHooks = ({ typeKey, updateField, updateAnswer, + formatMessage, }) => { const clearPreviouslySelectedAnswers = () => { let currentAnswerTitles; @@ -312,8 +314,17 @@ export const typeRowHooks = ({ updateAnswersToCorrect(); } - if (blockTitle === ProblemTypes[problemType].title) { - setBlockTitle(ProblemTypes[typeKey].title); + // Check if blockTitle matches either the localized or non-localized problem type title + const localizedProblemTypes = formatMessage ? getProblemTypes(formatMessage) : null; + const currentTitle = localizedProblemTypes + ? localizedProblemTypes[problemType].title + : ProblemTypes[problemType].title; + + if (blockTitle === currentTitle) { + const newTitle = localizedProblemTypes + ? localizedProblemTypes[typeKey].title + : ProblemTypes[typeKey].title; + setBlockTitle(newTitle); } updateField({ problemType: typeKey }); }; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.test.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.test.js index 37e876a28..079506716 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.test.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.test.js @@ -1,9 +1,10 @@ import { useEffect } from 'react'; +import * as problemConstants from '@src/editors/data/constants/problem'; +import { ProblemTypeKeys, ProblemTypes } from '@src/editors/data/constants/problem'; import { MockUseState } from '../../../../../testUtils'; import messages from './messages'; import { keyStore } from '../../../../../utils'; import * as hooks from './hooks'; -import { ProblemTypeKeys, ProblemTypes } from '../../../../../data/constants/problem'; import * as editHooks from '../hooks'; jest.mock('react', () => { @@ -381,6 +382,32 @@ describe('Problem settings hooks', () => { expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(3, { ...typeRowProps.answers[2], title: 'testC' }); expect(typeRowProps.updateField).toHaveBeenCalledWith({ problemType: ProblemTypeKeys.TEXTINPUT }); }); + + test('test typeRowHooks sets localized block title when formatMessage is provided', () => { + const mockSetBlockTitle = jest.fn(); + const mockUpdateField = jest.fn(); + const mockUpdateAnswer = jest.fn(); + const mockFormatMessage = (msg) => `localized-${msg.id || msg.defaultMessage || msg}`; + const props = { + answers: [], + blockTitle: 'localized-problem.multiplechoiceresponse.title', // Simulate a localized title + correctAnswerCount: 1, + problemType: ProblemTypeKeys.SINGLESELECT, + setBlockTitle: mockSetBlockTitle, + typeKey: ProblemTypeKeys.MULTISELECT, + updateField: mockUpdateField, + updateAnswer: mockUpdateAnswer, + formatMessage: mockFormatMessage, + }; + jest.spyOn(problemConstants, 'getProblemTypes').mockImplementation((fmt) => ({ + [ProblemTypeKeys.SINGLESELECT]: { title: fmt({ id: 'problem.multiplechoiceresponse.title' }) }, + [ProblemTypeKeys.MULTISELECT]: { title: fmt({ id: 'problem.choiceresponse.title' }) }, + })); + const hook = hooks.typeRowHooks(props); + hook.onClick(); + expect(mockSetBlockTitle).toHaveBeenCalledWith('localized-problem.choiceresponse.title'); + expect(mockUpdateField).toHaveBeenCalledWith({ problemType: ProblemTypeKeys.MULTISELECT }); + }); }); test('test handleConfirmEditorSwitch hook', () => { const switchEditor = jest.fn(); 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 f74b710a3..33f728d2e 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.test.tsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.test.tsx @@ -1,10 +1,11 @@ import React from 'react'; + +import { ProblemTypeKeys } from '@src/editors/data/constants/problem'; import { render, screen, initializeMocks, } from '@src/testUtils'; import * as hooks from './hooks'; import { SettingsWidgetInternal as SettingsWidget } from '.'; -import { ProblemTypeKeys } from '../../../../../data/constants/problem'; jest.mock('./settingsComponents/GeneralFeedback', () => 'GeneralFeedback'); jest.mock('./settingsComponents/GroupFeedback', () => 'GroupFeedback'); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeCard.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeCard.jsx index 2d51ae00c..4b3238d0e 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeCard.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeCard.jsx @@ -1,8 +1,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; + +import { ProblemTypeKeys, getProblemTypes } from '@src/editors/data/constants/problem'; import SettingsOption from '../SettingsOption'; -import { ProblemTypeKeys, ProblemTypes } from '../../../../../../data/constants/problem'; import messages from '../messages'; import TypeRow from './TypeRow'; @@ -16,6 +17,7 @@ const TypeCard = ({ updateAnswer, }) => { const intl = useIntl(); + const localizedProblemTypes = getProblemTypes(intl.formatMessage); const problemTypeKeysArray = Object.values(ProblemTypeKeys).filter(key => key !== ProblemTypeKeys.ADVANCED); if (problemType === ProblemTypeKeys.ADVANCED) { return null; } @@ -23,7 +25,7 @@ const TypeCard = ({ return ( {problemTypeKeysArray.map((typeKey, i) => ( { const props = { diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeRow.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeRow.jsx index ec8bc78ec..f3b29f6af 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeRow.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeRow.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { Icon } from '@openedx/paragon'; import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Check } from '@openedx/paragon/icons'; import { typeRowHooks } from '../hooks'; @@ -19,6 +20,8 @@ const TypeRow = ({ updateField, updateAnswer, }) => { + const intl = useIntl(); + const { onClick } = typeRowHooks({ answers, blockTitle, @@ -28,6 +31,7 @@ const TypeRow = ({ typeKey, updateField, updateAnswer, + formatMessage: intl.formatMessage, }); return ( diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.tsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.tsx index 030366546..b707b8276 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.tsx +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.tsx @@ -71,6 +71,7 @@ const SelectTypeWrapper: React.FC = ({ updateField, setBlockTitle, defaultSettings, + formatMessage: intl.formatMessage, })} disabled={!selected} > diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/messages.ts b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/messages.ts index 120968e30..ba83fa78f 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/messages.ts +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/messages.ts @@ -27,6 +27,134 @@ const messages = defineMessages({ defaultMessage: 'Select', description: 'Screen reader label for select button.', }, + + // Problem Type Titles + singleSelectTitle: { + id: 'authoring.problemeditor.problemtype.singleselect.title', + defaultMessage: 'Single select', + description: 'Title for single select problem type', + }, + multiSelectTitle: { + id: 'authoring.problemeditor.problemtype.multiselect.title', + defaultMessage: 'Multi-select', + description: 'Title for multi-select problem type', + }, + dropdownTitle: { + id: 'authoring.problemeditor.problemtype.dropdown.title', + defaultMessage: 'Dropdown', + description: 'Title for dropdown problem type', + }, + numericalInputTitle: { + id: 'authoring.problemeditor.problemtype.numeric.title', + defaultMessage: 'Numerical input', + description: 'Title for numerical input problem type', + }, + textInputTitle: { + id: 'authoring.problemeditor.problemtype.textinput.title', + defaultMessage: 'Text input', + description: 'Title for text input problem type', + }, + advancedProblemTitle: { + id: 'authoring.problemeditor.problemtype.advanced.title', + defaultMessage: 'Advanced Problem', + description: 'Title for advanced problem type', + }, + + // Advanced Problem Type Titles + blankProblemTitle: { + id: 'authoring.problemeditor.advancedproblemtype.blank.title', + defaultMessage: 'Blank problem', + description: 'Title for blank advanced problem type', + }, + circuitSchematicTitle: { + id: 'authoring.problemeditor.advancedproblemtype.circuitschematic.title', + defaultMessage: 'Circuit schematic builder', + description: 'Title for circuit schematic builder advanced problem type', + }, + customJavaScriptTitle: { + id: 'authoring.problemeditor.advancedproblemtype.jsinput.title', + defaultMessage: 'Custom JavaScript display and grading', + description: 'Title for custom JavaScript display and grading advanced problem type', + }, + customPythonTitle: { + id: 'authoring.problemeditor.advancedproblemtype.customgrader.title', + defaultMessage: 'Custom Python-evaluated input', + description: 'Title for custom Python-evaluated input advanced problem type', + }, + imageMappedTitle: { + id: 'authoring.problemeditor.advancedproblemtype.image.title', + defaultMessage: 'Image mapped input', + description: 'Title for image mapped input advanced problem type', + }, + mathExpressionTitle: { + id: 'authoring.problemeditor.advancedproblemtype.formula.title', + defaultMessage: 'Math expression input', + description: 'Title for math expression input advanced problem type', + }, + problemWithHintTitle: { + id: 'authoring.problemeditor.advancedproblemtype.problemwithhint.title', + defaultMessage: 'Problem with adaptive hint', + description: 'Title for problem with adaptive hint advanced problem type', + }, + + // Problem Type Descriptions + singleSelectDescription: { + id: 'authoring.problemeditor.problemtype.singleselect.description', + defaultMessage: 'Learners must select the correct answer from a list of possible options.', + description: 'Preview description for single select problem type', + }, + multiSelectDescription: { + id: 'authoring.problemeditor.problemtype.multiselect.description', + defaultMessage: 'Learners must select all correct answers from a list of possible options.', + description: 'Preview description for multi-select problem type', + }, + dropdownDescription: { + id: 'authoring.problemeditor.problemtype.dropdown.description', + defaultMessage: 'Learners must select the correct answer from a list of possible options', + description: 'Preview description for dropdown problem type', + }, + numericalInputDescription: { + id: 'authoring.problemeditor.problemtype.numeric.description', + defaultMessage: 'Specify one or more correct numeric answers, submitted in a response field.', + description: 'Preview description for numerical input problem type', + }, + textInputDescription: { + id: 'authoring.problemeditor.problemtype.textinput.description', + defaultMessage: 'Specify one or more correct text answers, including numbers and special characters, submitted in a response field.', + description: 'Preview description for text input problem type', + }, + advancedProblemDescription: { + id: 'authoring.problemeditor.problemtype.advanced.description', + defaultMessage: 'An Advanced Problem Type', + description: 'Description for advanced problem type', + }, + + // Problem Type Instructions + singleSelectInstruction: { + id: 'authoring.problemeditor.problemtype.singleselect.instruction', + defaultMessage: 'Enter your single select answers below and select which choices are correct. Learners must choose one correct answer.', + description: 'Instruction for single select problem type', + }, + multiSelectInstruction: { + id: 'authoring.problemeditor.problemtype.multiselect.instruction', + defaultMessage: 'Enter your multi select answers below and select which choices are correct. Learners must choose all correct answers.', + description: 'Instruction for multi-select problem type', + }, + dropdownInstruction: { + id: 'authoring.problemeditor.problemtype.dropdown.instruction', + defaultMessage: 'Enter your dropdown answers below and select which choice is correct. Learners must select one correct answer.', + description: 'Instruction for dropdown problem type', + }, + numericalInputInstruction: { + id: 'authoring.problemeditor.problemtype.numeric.instruction', + defaultMessage: 'Enter correct numerical input answers below. Learners must enter one correct answer.', + description: 'Instruction for numerical input problem type', + }, + textInputInstruction: { + id: 'authoring.problemeditor.problemtype.textinput.instruction', + defaultMessage: 'Enter your text input answers below and select which choices are correct. Learners must enter one correct answer.', + description: 'Instruction for text input problem type', + }, }); export default messages; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/AdvanceTypeSelect.test.tsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/AdvanceTypeSelect.test.tsx index c6d5cb868..436968e3f 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/AdvanceTypeSelect.test.tsx +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/AdvanceTypeSelect.test.tsx @@ -1,9 +1,10 @@ import React from 'react'; + +import { ProblemTypeKeys, AdvanceProblems } from '@src/editors/data/constants/problem'; import { render, screen, fireEvent, initializeMocks, } from '../../../../../../testUtils'; import AdvanceTypeSelect from './AdvanceTypeSelect'; -import { ProblemTypeKeys, AdvanceProblems } from '../../../../../data/constants/problem'; describe('AdvanceTypeSelect', () => { const setSelected = jest.fn(); diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/AdvanceTypeSelect.tsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/AdvanceTypeSelect.tsx index 5a53fd258..524bbd698 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/AdvanceTypeSelect.tsx +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/AdvanceTypeSelect.tsx @@ -17,7 +17,7 @@ import { AdvanceProblems, ProblemType, ProblemTypeKeys, -} from '../../../../../data/constants/problem'; +} from '@src/editors/data/constants/problem'; import messages from './messages'; interface Props { @@ -30,6 +30,7 @@ const AdvanceTypeSelect: React.FC = ({ setSelected, }) => { const intl = useIntl(); + const handleChange = e => { setSelected(e.target.value); }; return ( @@ -53,12 +54,12 @@ const AdvanceTypeSelect: React.FC = ({ value={selected} className="px-4" > - {Object.entries(AdvanceProblems).map(([type, data]) => { - if (data.status !== '') { + {Object.entries(AdvanceProblems).map(([type, problemData]) => { + if (problemData.status !== '') { return ( - {intl.formatMessage(messages.advanceProblemTypeLabel, { problemType: data.title })} + = ({ overlay={(
- {intl.formatMessage(messages.supportStatusTooltipMessage, { supportStatus: data.status.replace(' ', '_') })} + {intl.formatMessage(messages.supportStatusTooltipMessage, { supportStatus: problemData.status.replace(' ', '_') })}
)} >
- {intl.formatMessage(messages.problemSupportStatus, { supportStatus: data.status })} + {intl.formatMessage(messages.problemSupportStatus, { supportStatus: problemData.status })}
@@ -81,7 +82,7 @@ const AdvanceTypeSelect: React.FC = ({ return ( - {intl.formatMessage(messages.advanceProblemTypeLabel, { problemType: data.title })} + diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/Preview.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/Preview.jsx index 71698b941..8db20435c 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/Preview.jsx +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/Preview.jsx @@ -15,23 +15,25 @@ const Preview = ({ if (problemType === null) { return null; } - const data = ProblemTypes[problemType]; + + const staticData = ProblemTypes[problemType]; + return (
- {intl.formatMessage(messages.previewTitle, { previewTitle: data.title })} + problem
{intl.formatMessage(messages.previewAltText,
- {intl.formatMessage(messages.previewDescription, { previewDescription: data.previewDescription })} +
diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/Preview.test.tsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/Preview.test.tsx index acb338435..4a4af6c33 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/Preview.test.tsx +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/Preview.test.tsx @@ -1,48 +1,77 @@ import React from 'react'; -import { render, screen, initializeMocks } from '../../../../../../testUtils'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { render, screen } from '@testing-library/react'; import Preview from './Preview'; +import messages from './messages'; -// Mock ProblemTypes and messages -jest.mock('../../../../../data/constants/problem', () => ({ - ProblemTypes: { - example: { - title: 'Example Title', - preview: 'example.png', - previewDescription: 'Example description', - helpLink: 'https://help.example.com', +// Mock ProblemTypes to provide test data +jest.mock('@src/editors/data/constants/problem', () => { + const actualModule = jest.requireActual('@src/editors/data/constants/problem'); + + return { + ...actualModule, + ProblemTypes: { + multiplechoiceresponse: { + title: 'Single select', + preview: 'singleselect.png', + previewDescription: 'Learners must select the correct answer from a list of possible options.', + helpLink: 'https://help.example.com/singleselect', + }, }, - }, -})); + }; +}); + +// Helper to render component with proper IntlProvider +const renderWithIntl = (component: React.ReactElement) => { + // Convert message objects to message strings for IntlProvider + const messageStrings = Object.fromEntries( + Object.entries(messages).map(([key, value]) => [key, (value as any).defaultMessage]), + ); + + return render( + + {component} + , + ); +}; describe('Preview', () => { - beforeEach(() => { - initializeMocks(); - }); - it('renders nothing if problemType is null', () => { - const { container } = render(); - const reduxProviderDiv = container.querySelector('div[data-testid="redux-provider"]'); - expect(reduxProviderDiv?.innerHTML).toBe(''); + const { container } = renderWithIntl(); + expect(container.firstChild).toBeNull(); }); it('renders preview with correct data for a valid problemType', () => { - render(); - expect(screen.getByText('Example Title problem')).toBeInTheDocument(); - expect(screen.getByText('Example description')).toBeInTheDocument(); - expect(screen.getByRole('img')).toHaveAttribute('src', 'example.png'); - expect(screen.getByRole('img')).toHaveAttribute('alt', 'A preview illustration of a null problem'); - expect(screen.getByRole('link', { name: 'Learn more in a new tab' })).toHaveAttribute('href', 'https://help.example.com'); + renderWithIntl(); + + // Check that the title is rendered correctly + expect(screen.getByText('Single select problem')).toBeInTheDocument(); + + // Check that the description is rendered correctly + expect(screen.getByText('Learners must select the correct answer from a list of possible options.')).toBeInTheDocument(); + + // Check that the image has correct src attribute + const image = screen.getByRole('img'); + expect(image).toHaveAttribute('src', 'singleselect.png'); + + // Check that the learn more link is rendered correctly + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', 'https://help.example.com/singleselect'); + expect(link).toHaveAttribute('target', '_blank'); + expect(screen.getByText('Learn more')).toBeInTheDocument(); }); it('renders the help link with target="_blank"', () => { - render(); - const link = screen.getByRole('link', { name: 'Learn more in a new tab' }); + renderWithIntl(); + const link = screen.getByRole('link'); expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('href', 'https://help.example.com/singleselect'); }); - it('renders the correct title and description', () => { - render(); - expect(screen.getByText('Example Title problem')).toBeInTheDocument(); - expect(screen.getByText('Example description')).toBeInTheDocument(); + it('displays the correct image source and alt text', () => { + renderWithIntl(); + const image = screen.getByRole('img'); + expect(image).toHaveAttribute('src', 'singleselect.png'); + expect(image).toHaveAttribute('alt', 'A preview illustration of a single select problem'); }); }); diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.test.tsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.test.tsx index 742c80baf..76a94bbe0 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.test.tsx +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.test.tsx @@ -1,8 +1,9 @@ import React from 'react'; + +import { ProblemTypeKeys } from '@src/editors/data/constants/problem'; import { render, screen, fireEvent, initializeMocks, } from '../../../../../../testUtils'; -import { ProblemTypeKeys } from '../../../../../data/constants/problem'; import ProblemTypeSelect from './ProblemTypeSelect'; describe('ProblemTypeSelect', () => { diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.tsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.tsx index a87f32904..d3c323ee6 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.tsx +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.tsx @@ -2,15 +2,14 @@ import React from 'react'; import { Button, Container } from '@openedx/paragon'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -// SelectableBox in paragon has a bug where you can't change selection. So we override it -import SelectableBox from '../../../../../sharedComponents/SelectableBox'; import { - ProblemTypes, ProblemTypeKeys, AdvanceProblemKeys, AdvancedProblemType, ProblemType, -} from '../../../../../data/constants/problem'; +} from '@src/editors/data/constants/problem'; +// SelectableBox in paragon has a bug where you can't change selection. So we override it +import SelectableBox from '../../../../../sharedComponents/SelectableBox'; import messages from './messages'; interface Props { @@ -45,7 +44,7 @@ const ProblemTypeSelect: React.FC = ({ value={key} {...settings} > - {ProblemTypes[key].title} + ) : null diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/messages.ts b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/messages.ts index 66873921d..7c8ade410 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/messages.ts +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/messages.ts @@ -17,10 +17,104 @@ const messages = defineMessages({ defaultMessage: 'Go back', description: 'Return to the previous menu that shows basic problem types', }, - advanceProblemTypeLabel: { - id: 'authoring.problemEditor.advanceProblem.problemType.label', - defaultMessage: '{problemType}', - description: 'Label for advance problem type radio select', + + // Direct problem type message pattern - replacing redundant advanceProblemTypeLabel + 'problemType.blankadvanced.title': { + id: 'authoring.problemeditor.advancedproblemtype.blank.title', + defaultMessage: 'Blank problem', + description: 'Title for blank advanced problem type', + }, + 'problemType.circuitschematic.title': { + id: 'authoring.problemeditor.advancedproblemtype.circuitschematic.title', + defaultMessage: 'Circuit schematic builder', + description: 'Title for circuit schematic builder advanced problem type', + }, + 'problemType.jsinputresponse.title': { + id: 'authoring.problemeditor.advancedproblemtype.jsinput.title', + defaultMessage: 'Custom JavaScript display and grading', + description: 'Title for custom JavaScript display and grading advanced problem type', + }, + 'problemType.customgrader.title': { + id: 'authoring.problemeditor.advancedproblemtype.customgrader.title', + defaultMessage: 'Custom Python-evaluated input', + description: 'Title for custom Python-evaluated input advanced problem type', + }, + 'problemType.imageresponse.title': { + id: 'authoring.problemeditor.advancedproblemtype.image.title', + defaultMessage: 'Image mapped input', + description: 'Title for image mapped input advanced problem type', + }, + 'problemType.formularesponse.title': { + id: 'authoring.problemeditor.advancedproblemtype.formula.title', + defaultMessage: 'Math expression input', + description: 'Title for math expression input advanced problem type', + }, + 'problemType.problemwithhint.title': { + id: 'authoring.problemeditor.advancedproblemtype.problemwithhint.title', + defaultMessage: 'Problem with adaptive hint', + description: 'Title for problem with adaptive hint advanced problem type', + }, + + // Basic Problem Type Messages by Key + 'problemType.multiplechoiceresponse.title': { + id: 'authoring.problemeditor.problemtype.singleselect.title', + defaultMessage: 'Single select', + description: 'Title for single select problem type', + }, + 'problemType.multiplechoiceresponse.description': { + id: 'authoring.problemeditor.problemtype.singleselect.description', + defaultMessage: 'Learners must select the correct answer from a list of possible options.', + description: 'Preview description for single select problem type', + }, + 'problemType.choiceresponse.title': { + id: 'authoring.problemeditor.problemtype.multiselect.title', + defaultMessage: 'Multi-select', + description: 'Title for multi-select problem type', + }, + 'problemType.choiceresponse.description': { + id: 'authoring.problemeditor.problemtype.multiselect.description', + defaultMessage: 'Learners must select all correct answers from a list of possible options.', + description: 'Preview description for multi-select problem type', + }, + 'problemType.optionresponse.title': { + id: 'authoring.problemeditor.problemtype.dropdown.title', + defaultMessage: 'Dropdown', + description: 'Title for dropdown problem type', + }, + 'problemType.optionresponse.description': { + id: 'authoring.problemeditor.problemtype.dropdown.description', + defaultMessage: 'Learners must select the correct answer from a list of possible options', + description: 'Preview description for dropdown problem type', + }, + 'problemType.numericalresponse.title': { + id: 'authoring.problemeditor.problemtype.numeric.title', + defaultMessage: 'Numerical input', + description: 'Title for numerical input problem type', + }, + 'problemType.numericalresponse.description': { + id: 'authoring.problemeditor.problemtype.numeric.description', + defaultMessage: 'Specify one or more correct numeric answers, submitted in a response field.', + description: 'Preview description for numerical input problem type', + }, + 'problemType.stringresponse.title': { + id: 'authoring.problemeditor.problemtype.textinput.title', + defaultMessage: 'Text input', + description: 'Title for text input problem type', + }, + 'problemType.stringresponse.description': { + id: 'authoring.problemeditor.problemtype.textinput.description', + defaultMessage: 'Specify one or more correct text answers, including numbers and special characters, submitted in a response field.', + description: 'Preview description for text input problem type', + }, + 'problemType.advanced.title': { + id: 'authoring.problemeditor.problemtype.advanced.title', + defaultMessage: 'Advanced Problem', + description: 'Title for advanced problem type', + }, + 'problemType.advanced.description': { + id: 'authoring.problemeditor.problemtype.advanced.description', + defaultMessage: 'An Advanced Problem Type', + description: 'Description for advanced problem type', }, problemSupportStatus: { id: 'authoring.problemEditor.advanceProblem.supportStatus', @@ -41,7 +135,7 @@ const messages = defineMessages({ in the future. They are not recommened for use in courses due to non-compliance with one or more of the base requirements, such as testing, accessibility, internationalization, and documentation.} - other { } + other { } }`, description: 'Message for support status tooltip', }, diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.js b/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.js index 10aa2edc5..b239baa68 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.js +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.js @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { - AdvanceProblemKeys, AdvanceProblems, ProblemTypeKeys, ProblemTypes, -} from '../../../../data/constants/problem'; + AdvanceProblemKeys, AdvanceProblems, ProblemTypeKeys, ProblemTypes, getProblemTypes, getAdvanceProblems, +} from '@src/editors/data/constants/problem'; import { snakeCaseKeys } from '../../../../utils'; import { getDataFromOlx } from '../../../../data/redux/thunkActions/problem'; @@ -10,10 +10,16 @@ export const onSelect = ({ updateField, setBlockTitle, defaultSettings, + formatMessage, }) => () => { if (Object.values(AdvanceProblemKeys).includes(selected)) { updateField({ problemType: ProblemTypeKeys.ADVANCED, rawOLX: AdvanceProblems[selected].template }); - setBlockTitle(AdvanceProblems[selected].title); + if (formatMessage) { + const localizedAdvanceProblems = getAdvanceProblems(formatMessage); + setBlockTitle(localizedAdvanceProblems[selected].title); + } else { + setBlockTitle(AdvanceProblems[selected].title); + } } else { const newOLX = ProblemTypes[selected].template; const newMarkdown = ProblemTypes[selected].markdownTemplate; @@ -28,7 +34,12 @@ export const onSelect = ({ defaultSettings: snakeCaseKeys(defaultSettings), }); updateField({ ...newState, rawMarkdown: newMarkdown }); - setBlockTitle(ProblemTypes[selected].title); + if (formatMessage) { + const localizedProblemTypes = getProblemTypes(formatMessage); + setBlockTitle(localizedProblemTypes[selected].title); + } else { + setBlockTitle(ProblemTypes[selected].title); + } } }; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.test.js b/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.test.js index 4e246f049..72a963e77 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.test.js +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.test.js @@ -1,7 +1,9 @@ /* eslint-disable prefer-destructuring */ import React from 'react'; + +import * as problemConstants from '@src/editors/data/constants/problem'; +import { AdvanceProblems, ProblemTypeKeys, ProblemTypes } from '@src/editors/data/constants/problem'; import * as hooks from './hooks'; -import { AdvanceProblems, ProblemTypeKeys, ProblemTypes } from '../../../../data/constants/problem'; import { getDataFromOlx } from '../../../../data/redux/thunkActions/problem'; jest.mock('react', () => ({ @@ -65,6 +67,26 @@ describe('SelectTypeModal hooks', () => { }); expect(mocksetBlockTitle).toHaveBeenCalledWith(ProblemTypes[mockSelected].title); }); + test('onSelect sets localized advanced problem title when formatMessage is provided', () => { + const mockSetBlockTitle2 = jest.fn(); + const mockUpdateField2 = jest.fn(); + const mockFormatMessage2 = (msg) => `localized-${msg.id || msg.defaultMessage || msg}`; + const mockSelected2 = 'circuitschematic'; + jest.spyOn(problemConstants, 'getAdvanceProblems').mockImplementation((fmt) => ({ + circuitschematic: { title: fmt({ id: 'problem.circuitschematic.title' }) }, + })); + hooks.onSelect({ + selected: mockSelected2, + updateField: mockUpdateField2, + setBlockTitle: mockSetBlockTitle2, + formatMessage: mockFormatMessage2, + })(); + expect(mockSetBlockTitle2).toHaveBeenCalledWith('localized-problem.circuitschematic.title'); + expect(mockUpdateField2).toHaveBeenCalledWith({ + problemType: ProblemTypeKeys.ADVANCED, + rawOLX: AdvanceProblems[mockSelected2].template, + }); + }); }); describe('useArrowNav', () => { diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.tsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.tsx index fbb17fad4..1881035f9 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.tsx +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.tsx @@ -1,17 +1,18 @@ import React from 'react'; import { Row, Stack } from '@openedx/paragon'; -import ProblemTypeSelect from './content/ProblemTypeSelect'; -import Preview from './content/Preview'; -import AdvanceTypeSelect from './content/AdvanceTypeSelect'; -import SelectTypeWrapper from './SelectTypeWrapper'; -import * as hooks from './hooks'; + import { AdvancedProblemType, isAdvancedProblemType, ProblemType, ProblemTypeKeys, -} from '../../../../data/constants/problem'; +} from '@src/editors/data/constants/problem'; +import ProblemTypeSelect from './content/ProblemTypeSelect'; +import Preview from './content/Preview'; +import AdvanceTypeSelect from './content/AdvanceTypeSelect'; +import SelectTypeWrapper from './SelectTypeWrapper'; +import * as hooks from './hooks'; interface Props { onClose: (() => void) | null; diff --git a/src/editors/data/constants/problem.test.ts b/src/editors/data/constants/problem.test.ts new file mode 100644 index 000000000..e11f855e6 --- /dev/null +++ b/src/editors/data/constants/problem.test.ts @@ -0,0 +1,13 @@ +import { getProblemTitles } from '@src/editors/data/constants/problem'; + +describe('getProblemTitles', () => { + it('returns a set of localized problem titles', () => { + const formatMessage = (msg) => msg.id || msg.defaultMessage || String(msg); + const titles = getProblemTitles(formatMessage); + + expect(titles.size).toBeGreaterThan(0); + for (const title of titles) { + expect(typeof title).toBe('string'); + } + }); +}); diff --git a/src/editors/data/constants/problem.ts b/src/editors/data/constants/problem.ts index 1fd3bd908..804dba3b5 100644 --- a/src/editors/data/constants/problem.ts +++ b/src/editors/data/constants/problem.ts @@ -6,6 +6,7 @@ import numericalInput from '../images/numericalInput.png'; import textInput from '../images/textInput.png'; import advancedOlxTemplates from './advancedOlxTemplates'; import basicProblemTemplates from './basicProblemTemplates'; +import problemMessages from '../../containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/messages'; export const ProblemTypeKeys = StrictDict({ SINGLESELECT: 'multiplechoiceresponse', @@ -17,6 +18,87 @@ export const ProblemTypeKeys = StrictDict({ } as const); export type ProblemType = typeof ProblemTypeKeys[keyof typeof ProblemTypeKeys]; +/** + * Get problem types with internationalized strings. + * @param {Function} formatMessage - The intl.formatMessage function + * @returns {Object} ProblemTypes object with localized strings + * + * Usage in React components: + * + * import { useIntl } from '@edx/frontend-platform/i18n'; + * import { getProblemTypes } from '../path/to/problem'; + * + * const MyComponent = () => { + * const intl = useIntl(); + * const localizedProblemTypes = getProblemTypes(intl.formatMessage); + * + * return
{localizedProblemTypes[ProblemTypeKeys.SINGLESELECT].title}
; + * }; + */ +export const getProblemTypes = (formatMessage) => ({ + [ProblemTypeKeys.SINGLESELECT]: { + title: formatMessage(problemMessages.singleSelectTitle), + preview: singleSelect, + previewDescription: formatMessage(problemMessages.singleSelectDescription), + description: formatMessage(problemMessages.singleSelectInstruction), + helpLink: 'https://docs.openedx.org/en/latest/educators/concepts/exercise_tools/about_multi_select.html', + prev: ProblemTypeKeys.TEXTINPUT, + next: ProblemTypeKeys.MULTISELECT, + template: basicProblemTemplates.singleSelect.olx, + markdownTemplate: basicProblemTemplates.singleSelect.markdown, + }, + [ProblemTypeKeys.MULTISELECT]: { + title: formatMessage(problemMessages.multiSelectTitle), + preview: multiSelect, + previewDescription: formatMessage(problemMessages.multiSelectDescription), + description: formatMessage(problemMessages.multiSelectInstruction), + helpLink: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_multi_select.html', + next: ProblemTypeKeys.DROPDOWN, + prev: ProblemTypeKeys.SINGLESELECT, + template: basicProblemTemplates.multiSelect.olx, + markdownTemplate: basicProblemTemplates.multiSelect.markdown, + }, + [ProblemTypeKeys.DROPDOWN]: { + title: formatMessage(problemMessages.dropdownTitle), + preview: dropdown, + previewDescription: formatMessage(problemMessages.dropdownDescription), + description: formatMessage(problemMessages.dropdownInstruction), + helpLink: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_dropdown.html', + next: ProblemTypeKeys.NUMERIC, + prev: ProblemTypeKeys.MULTISELECT, + template: basicProblemTemplates.dropdown.olx, + markdownTemplate: basicProblemTemplates.dropdown.markdown, + }, + [ProblemTypeKeys.NUMERIC]: { + title: formatMessage(problemMessages.numericalInputTitle), + preview: numericalInput, + previewDescription: formatMessage(problemMessages.numericalInputDescription), + description: formatMessage(problemMessages.numericalInputInstruction), + helpLink: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/manage_numerical_input_problem.html', + next: ProblemTypeKeys.TEXTINPUT, + prev: ProblemTypeKeys.DROPDOWN, + template: basicProblemTemplates.numeric.olx, + markdownTemplate: basicProblemTemplates.numeric.markdown, + }, + [ProblemTypeKeys.TEXTINPUT]: { + title: formatMessage(problemMessages.textInputTitle), + preview: textInput, + previewDescription: formatMessage(problemMessages.textInputDescription), + description: formatMessage(problemMessages.textInputInstruction), + helpLink: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_text_input.html', + prev: ProblemTypeKeys.NUMERIC, + next: ProblemTypeKeys.SINGLESELECT, + template: basicProblemTemplates.textInput.olx, + markdownTemplate: basicProblemTemplates.textInput.markdown, + }, + [ProblemTypeKeys.ADVANCED]: { + title: formatMessage(problemMessages.advancedProblemTitle), + preview: ('
'), + description: formatMessage(problemMessages.advancedProblemDescription), + helpLink: 'something.com', + }, +}); + export const ProblemTypes = StrictDict({ [ProblemTypeKeys.SINGLESELECT]: { title: 'Single select', @@ -96,6 +178,61 @@ export function isAdvancedProblemType(pt: ProblemType | AdvancedProblemType): pt return Object.values(AdvanceProblemKeys).includes(pt as any); } +/** + * Get advanced problem types with internationalized strings. + * @param {Function} formatMessage - The intl.formatMessage function + * @returns {Object} AdvanceProblems object with localized strings + * + * Usage in React components: + * + * import { useIntl } from '@edx/frontend-platform/i18n'; + * import { getAdvanceProblems } from '../path/to/problem'; + * + * const MyComponent = () => { + * const intl = useIntl(); + * const localizedAdvanceProblems = getAdvanceProblems(intl.formatMessage); + * + * return
{localizedAdvanceProblems[AdvanceProblemKeys.BLANK].title}
; + * }; + */ +export const getAdvanceProblems = (formatMessage) => ({ + [AdvanceProblemKeys.BLANK]: { + title: formatMessage(problemMessages.blankProblemTitle), + status: '', + template: '', + }, + [AdvanceProblemKeys.CIRCUITSCHEMATIC]: { + title: formatMessage(problemMessages.circuitSchematicTitle), + status: 'Not supported', + template: advancedOlxTemplates.circuitSchematic, + }, + [AdvanceProblemKeys.JSINPUT]: { + title: formatMessage(problemMessages.customJavaScriptTitle), + status: '', + template: advancedOlxTemplates.jsInputResponse, + }, + [AdvanceProblemKeys.CUSTOMGRADER]: { + title: formatMessage(problemMessages.customPythonTitle), + status: 'Provisional', + template: advancedOlxTemplates.customGrader, + }, + [AdvanceProblemKeys.IMAGE]: { + title: formatMessage(problemMessages.imageMappedTitle), + status: 'Not supported', + template: advancedOlxTemplates.imageResponse, + }, + [AdvanceProblemKeys.FORMULA]: { + title: formatMessage(problemMessages.mathExpressionTitle), + status: '', + template: advancedOlxTemplates.formulaResponse, + }, + [AdvanceProblemKeys.PROBLEMWITHHINT]: { + title: formatMessage(problemMessages.problemWithHintTitle), + status: 'Not supported', + template: advancedOlxTemplates.problemWithHint, + }, +}); + export const AdvanceProblems = StrictDict({ [AdvanceProblemKeys.BLANK]: { title: 'Blank problem', @@ -248,3 +385,13 @@ export const ignoredOlxAttributes = [ // Useful for the block creation workflow. export const problemTitles = new Set([...Object.values(ProblemTypes).map((problem) => problem.title), ...Object.values(AdvanceProblems).map((problem) => problem.title)]); + +/** + * Get problem titles with internationalization support + * @param {Function} formatMessage - The intl.formatMessage function + * @returns {Set} Set of localized problem titles + */ +export const getProblemTitles = (formatMessage) => new Set([ + ...Object.values(getProblemTypes(formatMessage)).map((problem) => problem.title), + ...Object.values(getAdvanceProblems(formatMessage)).map((problem) => problem.title), +]);