feat: tolerance Setting Widget (#286)
* feat: tolerance Setting Widget * fix: tolerance position and percent summary
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
{ProblemTypeKeys.NUMERIC === problemType
|
||||
&& (
|
||||
<div className="my-3">
|
||||
<ToleranceCard
|
||||
updateSettings={updateSettings}
|
||||
answers={answers}
|
||||
tolerance={settings.tolerance}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="my-3">
|
||||
<ScoringCard scoring={settings.scoring} updateSettings={updateSettings} />
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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 (
|
||||
<SettingsOption
|
||||
title={intl.formatMessage(messages.toleranceSettingTitle)}
|
||||
summary={summary}
|
||||
none={tolerance.type === ToleranceTypes.none.type}
|
||||
>
|
||||
{ canEdit
|
||||
&& (
|
||||
<Alert
|
||||
varaint="info"
|
||||
>
|
||||
<FormattedMessage {...messages.toleranceAnswerRangeWarning} />
|
||||
</Alert>
|
||||
)}
|
||||
<div className="halfSpacedMessage">
|
||||
<span>
|
||||
<FormattedMessage {...messages.toleranceSettingText} />
|
||||
</span>
|
||||
</div>
|
||||
<Form.Group className="pb-0 mb-0">
|
||||
<Form.Control
|
||||
as="select"
|
||||
onChange={handleToleranceTypeChange({ updateSettings, tolerance, answers })}
|
||||
disabled={canEdit}
|
||||
value={tolerance.type}
|
||||
>
|
||||
{Object.keys(ToleranceTypes).map((toleranceType) => (
|
||||
<option
|
||||
key={toleranceType.type}
|
||||
value={toleranceType.type}
|
||||
>
|
||||
{intl.formatMessage(ToleranceTypes[toleranceType].message)}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
{ tolerance?.type !== ToleranceTypes.none.type && !canEdit
|
||||
&& (
|
||||
<Form.Control
|
||||
className="mt-4"
|
||||
type="number"
|
||||
value={tolerance.value}
|
||||
onChange={handleToleranceValueChange({ updateSettings, tolerance, answers })}
|
||||
floatingLabel={intl.formatMessage(messages.toleranceValueInputLabel)}
|
||||
/>
|
||||
)}
|
||||
</Form.Group>
|
||||
|
||||
</SettingsOption>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -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 }) => (
|
||||
<div>{ defaultMessage }</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
jest.mock('../../SettingsOption', () => ({ children, summary }) => (
|
||||
<div className="SettingsOption" data-testid="Settings-Option">{summary}{children}</div>
|
||||
));
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Alert: jest.fn(({ children }) => (
|
||||
<div className="PGN-Alert">{children}</div>)),
|
||||
Form: {
|
||||
Control: jest.fn(({
|
||||
children, onChange, as, value,
|
||||
}) => {
|
||||
if (as === 'select') {
|
||||
return (<select className="PGN-Form-Control" data-testid="select" onChange={onChange}>{children}</select>);
|
||||
}
|
||||
return (<input type="number" data-testid="input" onChange={onChange} value={value} />);
|
||||
}),
|
||||
Group: jest.fn(({ children }) => (<div className="PGN-Form-Group">{children}</div>)),
|
||||
},
|
||||
}));
|
||||
|
||||
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(<ToleranceCard tolerance={mockToleranceNull} {...props} />);
|
||||
const NoneText = screen.getAllByText(ToleranceTypes.none.type);
|
||||
expect(NoneText).toBeDefined();
|
||||
});
|
||||
it('Render Percent Value', () => {
|
||||
render(<ToleranceCard tolerance={mockTolerancePercent} {...props} />);
|
||||
const PercentText = screen.getByText(`± ${mockTolerancePercent.value}%`);
|
||||
expect(PercentText).toBeDefined();
|
||||
});
|
||||
it('Renders Number Value', () => {
|
||||
render(<ToleranceCard tolerance={mockToleranceNumber} {...props} />);
|
||||
const NumberText = screen.getByText(`± ${mockToleranceNumber.value}`);
|
||||
expect(NumberText).toBeDefined();
|
||||
});
|
||||
});
|
||||
describe('Type Select', () => {
|
||||
it('Renders the types for selection', async () => {
|
||||
const { container } = render(<ToleranceCard tolerance={mockToleranceNull} {...props} />);
|
||||
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(<ToleranceCard tolerance={mockToleranceNull} {...props} />);
|
||||
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(<ToleranceCard tolerance={mockToleranceNull} {...props} />);
|
||||
expect(queryByTestId('input')).toBeFalsy();
|
||||
});
|
||||
it('Renders with intial value of tolerance', async () => {
|
||||
const { queryByTestId } = render(<ToleranceCard tolerance={mockToleranceNumber} {...props} />);
|
||||
expect(queryByTestId('input')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('0')).toBeTruthy();
|
||||
});
|
||||
it('Calls change function on change.', () => {
|
||||
const { queryByTestId } = render(<ToleranceCard tolerance={mockToleranceNumber} {...props} />);
|
||||
fireEvent.change(queryByTestId('input'), { target: { value: 52 } });
|
||||
expect(props.updateSettings).toHaveBeenCalledWith({ tolerance: { type: ToleranceTypes.number.type, value: '52' } });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ? '' : '%'}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user