feat: tolerance Setting Widget (#286)

* feat: tolerance Setting Widget

* fix: tolerance position and percent summary
This commit is contained in:
connorhaugh
2023-03-22 14:31:02 -04:00
committed by GitHub
parent 16003a7f4a
commit df5af3efd9
8 changed files with 335 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ? '' : '%'}`,
},
};
}

View File

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