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.
This commit is contained in:
Pradeep Patro
2025-08-05 15:47:09 +05:30
committed by Muhammad Faraz Maqsood
parent 915bd559e0
commit 2f9566c4f5
22 changed files with 586 additions and 84 deletions

View File

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

View File

@@ -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 (
<div>
<div className="mt-4 text-primary-500">
@@ -20,7 +23,10 @@ const AnswerWidget = ({
<FormattedMessage {...messages.answerWidgetTitle} />
</div>
<div className="small">
{intl.formatMessage(messages.answerHelperText, { helperText: problemStaticData.description })}
<FormattedMessage
{...messages.answerHelperText}
values={{ helperText: localizedProblemStaticData.description }}
/>
</div>
</div>
<AnswersContainer problemType={problemType} />

View File

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

View File

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

View File

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

View File

@@ -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 (
<SettingsOption
title={intl.formatMessage(messages.typeSettingTitle)}
summary={ProblemTypes[problemType].title}
summary={localizedProblemTypes[problemType].title}
>
{problemTypeKeysArray.map((typeKey, i) => (
<TypeRow
@@ -32,7 +34,7 @@ const TypeCard = ({
correctAnswerCount={correctAnswerCount}
key={typeKey}
typeKey={typeKey}
label={ProblemTypes[typeKey].title}
label={localizedProblemTypes[typeKey].title}
selected={typeKey !== problemType}
problemType={problemType}
lastRow={(i + 1) === problemTypeKeysArray.length}

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { ProblemTypeKeys } from '@src/editors/data/constants/problem';
import { render, screen, initializeMocks } from '@src/testUtils';
import TypeCard from './TypeCard';
import { ProblemTypeKeys } from '../../../../../../data/constants/problem';
describe('TypeCard', () => {
const props = {

View File

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

View File

@@ -71,6 +71,7 @@ const SelectTypeWrapper: React.FC<Props> = ({
updateField,
setBlockTitle,
defaultSettings,
formatMessage: intl.formatMessage,
})}
disabled={!selected}
>

View File

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

View File

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

View File

@@ -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<Props> = ({
setSelected,
}) => {
const intl = useIntl();
const handleChange = e => { setSelected(e.target.value); };
return (
<Col xs={12} md={8} className="justify-content-center">
@@ -53,12 +54,12 @@ const AdvanceTypeSelect: React.FC<Props> = ({
value={selected}
className="px-4"
>
{Object.entries(AdvanceProblems).map(([type, data]) => {
if (data.status !== '') {
{Object.entries(AdvanceProblems).map(([type, problemData]) => {
if (problemData.status !== '') {
return (
<ActionRow className="border-primary-100 border-bottom m-0 py-3 w-100" key={type}>
<Form.Radio id={type} value={type}>
{intl.formatMessage(messages.advanceProblemTypeLabel, { problemType: data.title })}
<FormattedMessage {...messages[`problemType.${type}.title`]} />
</Form.Radio>
<ActionRow.Spacer />
<OverlayTrigger
@@ -66,13 +67,13 @@ const AdvanceTypeSelect: React.FC<Props> = ({
overlay={(
<Tooltip id={`tooltip-adv-${type}`}>
<div className="text-left">
{intl.formatMessage(messages.supportStatusTooltipMessage, { supportStatus: data.status.replace(' ', '_') })}
{intl.formatMessage(messages.supportStatusTooltipMessage, { supportStatus: problemData.status.replace(' ', '_') })}
</div>
</Tooltip>
)}
>
<div className="text-gray-500">
{intl.formatMessage(messages.problemSupportStatus, { supportStatus: data.status })}
{intl.formatMessage(messages.problemSupportStatus, { supportStatus: problemData.status })}
</div>
</OverlayTrigger>
</ActionRow>
@@ -81,7 +82,7 @@ const AdvanceTypeSelect: React.FC<Props> = ({
return (
<ActionRow className="border-primary-100 border-bottom m-0 py-3 w-100" key={type}>
<Form.Radio id={type} value={type}>
{intl.formatMessage(messages.advanceProblemTypeLabel, { problemType: data.title })}
<FormattedMessage {...messages[`problemType.${type}.title`]} />
</Form.Radio>
<ActionRow.Spacer />
</ActionRow>

View File

@@ -15,23 +15,25 @@ const Preview = ({
if (problemType === null) {
return null;
}
const data = ProblemTypes[problemType];
const staticData = ProblemTypes[problemType];
return (
<Container style={{ width: '494px', height: '400px' }} className="bg-light-300 rounded p-4">
<div className="small">
{intl.formatMessage(messages.previewTitle, { previewTitle: data.title })}
<FormattedMessage {...messages[`problemType.${problemType}.title`]} /> problem
</div>
<Image
fluid
className="my-3"
src={data.preview}
src={staticData.preview}
alt={intl.formatMessage(messages.previewAltText, { problemType })}
/>
<div className="mb-3">
{intl.formatMessage(messages.previewDescription, { previewDescription: data.previewDescription })}
<FormattedMessage {...messages[`problemType.${problemType}.description`]} />
</div>
<Hyperlink
destination={data.helpLink}
destination={staticData.helpLink}
target="_blank"
>
<FormattedMessage {...messages.learnMoreButtonLabel} />

View File

@@ -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(
<IntlProvider locale="en" messages={messageStrings}>
{component}
</IntlProvider>,
);
};
describe('Preview', () => {
beforeEach(() => {
initializeMocks();
});
it('renders nothing if problemType is null', () => {
const { container } = render(<Preview problemType={null} />);
const reduxProviderDiv = container.querySelector('div[data-testid="redux-provider"]');
expect(reduxProviderDiv?.innerHTML).toBe('');
const { container } = renderWithIntl(<Preview problemType={null} />);
expect(container.firstChild).toBeNull();
});
it('renders preview with correct data for a valid problemType', () => {
render(<Preview problemType="example" />);
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(<Preview problemType="multiplechoiceresponse" />);
// 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(<Preview problemType="example" />);
const link = screen.getByRole('link', { name: 'Learn more in a new tab' });
renderWithIntl(<Preview problemType="multiplechoiceresponse" />);
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(<Preview problemType="example" />);
expect(screen.getByText('Example Title problem')).toBeInTheDocument();
expect(screen.getByText('Example description')).toBeInTheDocument();
it('displays the correct image source and alt text', () => {
renderWithIntl(<Preview problemType="multiplechoiceresponse" />);
const image = screen.getByRole('img');
expect(image).toHaveAttribute('src', 'singleselect.png');
expect(image).toHaveAttribute('alt', 'A preview illustration of a single select problem');
});
});

View File

@@ -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', () => {

View File

@@ -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<Props> = ({
value={key}
{...settings}
>
{ProblemTypes[key].title}
<FormattedMessage {...messages[`problemType.${key}.title`]} />
</SelectableBox>
)
: null

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

@@ -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 <div>{localizedProblemTypes[ProblemTypeKeys.SINGLESELECT].title}</div>;
* };
*/
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: ('<div />'),
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 <div>{localizedAdvanceProblems[AdvanceProblemKeys.BLANK].title}</div>;
* };
*/
export const getAdvanceProblems = (formatMessage) => ({
[AdvanceProblemKeys.BLANK]: {
title: formatMessage(problemMessages.blankProblemTitle),
status: '',
template: '<problem></problem>',
},
[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<string>} 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),
]);