diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.jsx index e63433a79..2137c090b 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.jsx @@ -13,6 +13,7 @@ import ResetCard from './settingsComponents/ResetCard'; import MatlabCard from './settingsComponents/MatlabCard'; import TimerCard from './settingsComponents/TimerCard'; import TypeCard from './settingsComponents/TypeCard'; +import ToleranceCard from './settingsComponents/Tolerance'; import GroupFeedbackCard from './settingsComponents/GroupFeedback/index'; import SwitchToAdvancedEditorCard from './settingsComponents/SwitchToAdvancedEditorCard'; import messages from './messages'; @@ -65,6 +66,16 @@ export const SettingsWidget = ({ updateAnswer={updateAnswer} /> + {ProblemTypeKeys.NUMERIC === problemType + && ( +
+ +
+ )}
diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants.js new file mode 100644 index 000000000..16d408045 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants.js @@ -0,0 +1,18 @@ +/* eslint-disable import/prefer-default-export */ +import messages from './messages'; + +export const ToleranceTypes = { + percent: { + type: 'Percent', + message: messages.typesPercentage, + }, + number: { + type: 'Number', + message: messages.typesNumber, + + }, + none: { + type: 'None', + message: messages.typesNone, + }, +}; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/index.jsx new file mode 100644 index 000000000..bda7efd3d --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/index.jsx @@ -0,0 +1,125 @@ +import React, { useEffect } from 'react'; +import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import { Alert, Form } from '@edx/paragon'; +import PropTypes from 'prop-types'; +import SettingsOption from '../../SettingsOption'; +import messages from './messages'; +import { ToleranceTypes } from './constants'; + +// eslint-disable-next-line no-unused-vars +export const isAnswerRangeSet = ({ answers }) => + // TODO: for TNL 10258 + // eslint-disable-next-line implicit-arrow-linebreak + false; + +export const handleToleranceTypeChange = ({ updateSettings, tolerance, answers }) => (event) => { + if (!isAnswerRangeSet(answers)) { + let value; + if (event.target.value === ToleranceTypes.none.type) { + value = null; + } else { + value = tolerance.value || 0; + } + const newTolerance = { type: ToleranceTypes[Object.keys(ToleranceTypes)[event.target.selectedIndex]].type, value }; + updateSettings({ tolerance: newTolerance }); + } +}; + +export const handleToleranceValueChange = ({ updateSettings, tolerance, answers }) => (event) => { + if (!isAnswerRangeSet(answers)) { + const newTolerance = { value: event.target.value, type: tolerance.type }; + updateSettings({ tolerance: newTolerance }); + } +}; + +export const getSummary = ({ tolerance, intl }) => { + switch (tolerance?.type) { + case ToleranceTypes.percent.type: + return `± ${tolerance.value}%`; + case ToleranceTypes.number.type: + return `± ${tolerance.value}`; + case ToleranceTypes.none.type: + return intl.formatMessage(messages.noneToleranceSummary); + default: + return intl.formatMessage(messages.noneToleranceSummary); + } +}; + +export const ToleranceCard = ({ + tolerance, + answers, + updateSettings, + // inject + intl, +}) => { + const canEdit = isAnswerRangeSet({ answers }); + let summary = getSummary({ tolerance, intl }); + useEffect(() => { summary = getSummary({ tolerance, intl }); }, [tolerance]); + return ( + + { canEdit + && ( + + + + )} +
+ + + +
+ + + {Object.keys(ToleranceTypes).map((toleranceType) => ( + + ))} + + { tolerance?.type !== ToleranceTypes.none.type && !canEdit + && ( + + )} + + +
+ ); +}; + +ToleranceCard.propTypes = { + tolerance: PropTypes.shape({ + type: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.any]), + }).isRequired, + answers: PropTypes.arrayOf(PropTypes.shape({ + correct: PropTypes.bool, + id: PropTypes.string, + selectedFeedback: PropTypes.string, + title: PropTypes.string, + unselectedFeedback: PropTypes.string, + })).isRequired, + updateSettings: PropTypes.func.isRequired, + intl: intlShape.isRequired, +}; + +export default injectIntl(ToleranceCard); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/index.test.jsx new file mode 100644 index 000000000..e5d47f4af --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/index.test.jsx @@ -0,0 +1,116 @@ +import { + render, screen, fireEvent, +} from '@testing-library/react'; +import React from 'react'; +import { ToleranceTypes } from './constants'; +import { ToleranceCard } from './index'; +import { formatMessage } from '../../../../../../../../testUtils'; + +jest.mock('@edx/frontend-platform/i18n', () => ({ + __esmodule: true, + ...jest.requireActual('@edx/frontend-platform/i18n'), + FormattedMessage: jest.fn(({ defaultMessage }) => ( +
{ defaultMessage }
+ )), +})); + +jest.mock('../../SettingsOption', () => ({ children, summary }) => ( +
{summary}{children}
+)); + +jest.mock('@edx/paragon', () => ({ + Alert: jest.fn(({ children }) => ( +
{children}
)), + Form: { + Control: jest.fn(({ + children, onChange, as, value, + }) => { + if (as === 'select') { + return (); + } + return (); + }), + Group: jest.fn(({ children }) => (
{children}
)), + }, +})); + +describe('ToleranceCard', () => { + const mockToleranceNull = { + type: ToleranceTypes.none.type, + value: null, + }; + const mockTolerancePercent = { + type: ToleranceTypes.percent.type, + value: 0, + }; + const mockToleranceNumber = { + type: ToleranceTypes.number.type, + value: 0, + }; + + const props = { + answers: [], // TODO: for TNL 10258 + updateSettings: jest.fn(), + intl: { + formatMessage, + }, + }; + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('summary', () => { + it('Renders None', async () => { + render(); + const NoneText = screen.getAllByText(ToleranceTypes.none.type); + expect(NoneText).toBeDefined(); + }); + it('Render Percent Value', () => { + render(); + const PercentText = screen.getByText(`± ${mockTolerancePercent.value}%`); + expect(PercentText).toBeDefined(); + }); + it('Renders Number Value', () => { + render(); + const NumberText = screen.getByText(`± ${mockToleranceNumber.value}`); + expect(NumberText).toBeDefined(); + }); + }); + describe('Type Select', () => { + it('Renders the types for selection', async () => { + const { container } = render(); + const options = container.querySelectorAll('option'); + expect(options.length).toBe(3); + Object.keys(ToleranceTypes).forEach(type => { + expect(screen.getAllByText(ToleranceTypes[type].message.defaultMessage)).toBeDefined(); + }); + }); + it('Calls updateSettings on selection of an option', async () => { + const { container, getByTestId } = render(); + const select = getByTestId('select'); + fireEvent.change(select, { target: { value: ToleranceTypes.number.type } }); + const options = container.querySelectorAll('option'); + expect(options[0].selected).toBeFalsy(); + expect(options[1].selected).toBeTruthy(); + expect(options[2].selected).toBeFalsy(); + expect(props.updateSettings).toHaveBeenCalledWith({ tolerance: { type: ToleranceTypes.number.type, value: 0 } }); + fireEvent.change(select, { target: { value: ToleranceTypes.none.type } }); + expect(props.updateSettings).toHaveBeenCalledWith({ tolerance: { type: ToleranceTypes.none.type, value: null } }); + }); + }); + describe('Value Select', () => { + it('Doesnt render if type is null', async () => { + const { queryByTestId } = render(); + expect(queryByTestId('input')).toBeFalsy(); + }); + it('Renders with intial value of tolerance', async () => { + const { queryByTestId } = render(); + expect(queryByTestId('input')).toBeTruthy(); + expect(screen.getByDisplayValue('0')).toBeTruthy(); + }); + it('Calls change function on change.', () => { + const { queryByTestId } = render(); + fireEvent.change(queryByTestId('input'), { target: { value: 52 } }); + expect(props.updateSettings).toHaveBeenCalledWith({ tolerance: { type: ToleranceTypes.number.type, value: '52' } }); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/messages.js new file mode 100644 index 000000000..b31c0c6d7 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/messages.js @@ -0,0 +1,46 @@ +const messages = { + toleranceSettingTitle: { + id: 'problemEditor.settings.tolerance.title', + defaultMessage: 'Tolerance', + description: 'Title for tolerance setting menu', + }, + noneToleranceSummary: { + id: 'problemEditor.settings.tolerance.summary.none', + defaultMessage: 'None', + description: 'message provided when no tolerance is set for a problem', + }, + toleranceSettingText: { + id: 'problemEditor.settings.tolerance.description.text', + defaultMessage: 'The margin of error on either side of an answer.', + description: 'Description of the features of setting a tolerance for a problem', + }, + toleranceValueInputLabel: { + id: 'problemEditor.settings.tolerance.valueinput', + defaultMessage: 'Tolerance', + description: 'floating label for input to set the value of the tolerance', + }, + toleranceAnswerRangeWarning: { + id: 'problemEditor.settings.tolerance.answerrangewarning', + defaultMessage: 'Tolerance cannot be applied to an answer range', + description: 'a warning to users that tolerance cannot be aplied to an answer range.', + }, + typesPercentage: { + id: 'problemEditor.settings.tolerance.type.percent', + defaultMessage: 'Percentage', + description: 'A possible value type for a tolerance', + + }, + typesNumber: { + id: 'problemEditor.settings.tolerance.type.number', + defaultMessage: 'Number', + description: 'A possible value type for a tolerance', + + }, + typesNone: { + id: 'problemEditor.settings.tolerance.type.none', + defaultMessage: 'None', + description: 'A possible value type for a tolerance', + }, + +}; +export default messages; diff --git a/src/editors/containers/ProblemEditor/data/OLXParser.js b/src/editors/containers/ProblemEditor/data/OLXParser.js index 94756a475..b5467b838 100644 --- a/src/editors/containers/ProblemEditor/data/OLXParser.js +++ b/src/editors/containers/ProblemEditor/data/OLXParser.js @@ -462,6 +462,16 @@ export class OLXParser { } const { answers } = answersObject; const settings = { hints }; + if (ProblemTypeKeys.NUMERIC === problemType && _.has(answers[0], 'tolerance')) { + const toleranceValue = answers[0].tolerance; + if (!toleranceValue || toleranceValue.length === 0) { + settings.tolerance = { value: null, type: 'None' }; + } else if (toleranceValue.includes('%')) { + settings.tolerance = { value: parseInt(toleranceValue.slice(0, -1)), type: 'Percent' }; + } else { + settings.tolerance = { value: parseInt(toleranceValue), type: 'Number' }; + } + } if (solutionExplanation) { settings.solutionExplanation = solutionExplanation; } return { diff --git a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js index ea63ed5c7..795f23199 100644 --- a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js +++ b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js @@ -1,6 +1,7 @@ import _ from 'lodash-es'; import { XMLParser, XMLBuilder } from 'fast-xml-parser'; import { ProblemTypeKeys } from '../../../data/constants/problem'; +import { ToleranceTypes } from '../components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants'; class ReactStateOLXParser { constructor(problemState) { @@ -306,6 +307,7 @@ class ReactStateOLXParser { buildNumericalResponse() { const { answers } = this.problemState; + const { tolerance } = this.problemState.settings; const { selectedFeedback } = this.editorObject; let answerObject = {}; const additionalAnswers = []; @@ -316,11 +318,11 @@ class ReactStateOLXParser { if (answer.correct && !firstCorrectAnswerParsed) { firstCorrectAnswerParsed = true; let responseParam = {}; - if (_.has(answer, 'tolerance')) { + if (tolerance?.value) { responseParam = { responseparam: { '@_type': 'tolerance', - '@_default': _.get(answer, 'tolerance', 0), + '@_default': `${tolerance.value}${tolerance.type === ToleranceTypes.number.type ? '' : '%'}`, }, }; } diff --git a/src/editors/data/redux/problem/reducers.js b/src/editors/data/redux/problem/reducers.js index 6e7628ea5..6d5f36704 100644 --- a/src/editors/data/redux/problem/reducers.js +++ b/src/editors/data/redux/problem/reducers.js @@ -3,6 +3,7 @@ import { createSlice } from '@reduxjs/toolkit'; import { indexToLetterMap } from '../../../containers/ProblemEditor/data/OLXParser'; import { StrictDict } from '../../../utils'; import { ProblemTypeKeys, ShowAnswerTypesKeys } from '../../constants/problem'; +import { ToleranceTypes } from '../../../containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants'; const nextAlphaId = (lastId) => String.fromCharCode(lastId.charCodeAt(0) + 1); const initialState = { @@ -32,6 +33,10 @@ const initialState = { }, showResetButton: false, solutionExplanation: '', + tolerance: { + value: null, + type: ToleranceTypes.none.type, + }, }, };