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,
+ },
},
};