diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx index 46c88184c..6deba0cbd 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx @@ -8,7 +8,7 @@ import { Form, } from '@edx/paragon'; import { FeedbackOutline, DeleteOutline } from '@edx/paragon/icons'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import messages from './messages'; import { selectors } from '../../../../../data/redux'; import { answerOptionProps } from '../../../../../data/services/cms/types'; @@ -38,6 +38,53 @@ export const AnswerOption = ({ const setSelectedFeedback = hooks.setSelectedFeedback({ answer, hasSingleAnswer, dispatch }); const setUnselectedFeedback = hooks.setUnselectedFeedback({ answer, hasSingleAnswer, dispatch }); const { isFeedbackVisible, toggleFeedback } = hooks.useFeedback(answer); + + const getInputArea = () => { + if ([ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT].includes(problemType)) { + return ( + + ); + } + if (problemType !== ProblemTypeKeys.NUMERIC || !answer.isAnswerRange) { + return ( + + ); + } + // Return Answer Range View + return ( +
+ +
+
+ +
+ +
+ + ); + }; + return (
- {[ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT].includes(problemType) ? ( - - ) : ( - - )} + {getInputArea()} { selectedFeedback: 'selected feedback', unselectedFeedback: 'unselected feedback', }; + const answerRange = { + id: 'A', + title: 'Answer 1', + correct: true, + selectedFeedback: 'selected feedback', + unselectedFeedback: 'unselected feedback', + isAnswerRange: true, + }; const props = { hasSingleAnswer: false, @@ -45,7 +53,11 @@ describe('AnswerOption', () => { test('snapshot: renders correct option with numeric input problem', () => { expect(shallow()).toMatchSnapshot(); }); + test('snapshot: renders correct option with numeric input problem and answer range', () => { + expect(shallow()).toMatchSnapshot(); + }); }); + describe('mapStateToProps', () => { const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; test('problemType from problem.problemType', () => { diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.jsx index 65feb3979..77f28f91b 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.jsx @@ -3,18 +3,22 @@ import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Dropdown, Icon } from '@edx/paragon'; +import { Add } from '@edx/paragon/icons'; import messages from './messages'; import { useAnswerContainer, isSingleAnswerProblem } from './hooks'; import { actions, selectors } from '../../../../../data/redux'; import { answerOptionProps } from '../../../../../data/services/cms/types'; import AnswerOption from './AnswerOption'; import Button from '../../../../../sharedComponents/Button'; +import { ProblemTypeKeys } from '../../../../../data/constants/problem'; export const AnswersContainer = ({ problemType, // Redux answers, addAnswer, + addAnswerRange, updateField, }) => { const hasSingleAnswer = isSingleAnswerProblem(problemType); @@ -30,12 +34,45 @@ export const AnswersContainer = ({ answer={answer} /> ))} - + + {problemType !== ProblemTypeKeys.NUMERIC ? ( + + + + ) : ( + + + + + + + + + + 1 || (answers.length === 1 && answers[0].isAnswerRange) ? 'disabled' : ''}`} + > + + + + + )}
); }; @@ -44,6 +81,7 @@ AnswersContainer.propTypes = { problemType: PropTypes.string.isRequired, answers: PropTypes.arrayOf(answerOptionProps).isRequired, addAnswer: PropTypes.func.isRequired, + addAnswerRange: PropTypes.func.isRequired, updateField: PropTypes.func.isRequired, }; @@ -53,6 +91,7 @@ export const mapStateToProps = (state) => ({ export const mapDispatchToProps = { addAnswer: actions.problem.addAnswer, + addAnswerRange: actions.problem.addAnswerRange, updateField: actions.problem.updateField, }; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.test.jsx index 91974e787..451e9cc3e 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.test.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.test.jsx @@ -8,6 +8,7 @@ import { actions, selectors } from '../../../../../data/redux'; import * as module from './AnswersContainer'; import { AnswersContainer as AnswersContainerWithoutHOC } from './AnswersContainer'; +import { ProblemTypeKeys } from '../../../../../data/constants/problem'; jest.mock('@edx/frontend-platform/i18n', () => ({ FormattedMessage: ({ defaultMessage }) => (

{defaultMessage}

), @@ -54,6 +55,77 @@ describe('AnswersContainer', () => { )).toMatchSnapshot(); }); }); + test('snapshot: numeric problems: answer range/answer select button: empty', () => { + act(() => { + const emptyAnswerProps = { + problemType: ProblemTypeKeys.NUMERIC, + answers: [], + updateField: jest.fn(), + addAnswer: jest.fn(), + addAnswerRange: jest.fn(), + }; + expect(shallow( + , + )).toMatchSnapshot(); + }); + }); + test('snapshot: numeric problems: answer range/answer select button: Range disables the additon of more adds', () => { + act(() => { + const answerRangeProps = { + problemType: ProblemTypeKeys.NUMERIC, + answers: [{ + id: 'A', + title: 'Answer 1', + correct: true, + selectedFeedback: 'selected feedback', + unselectedFeedback: 'unselected feedback', + isAnswerRange: true, + }], + updateField: jest.fn(), + addAnswer: jest.fn(), + addAnswerRange: jest.fn(), + }; + expect(shallow( + , + )).toMatchSnapshot(); + }); + }); + test('snapshot: numeric problems: answer range/answer select button: multiple answers disables range.', () => { + act(() => { + const answersProps = { + problemType: ProblemTypeKeys.NUMERIC, + answers: [{ + id: 'A', + title: 'Answer 1', + correct: true, + selectedFeedback: 'selected feedback', + unselectedFeedback: 'unselected feedback', + isAnswerRange: false, + }, + { + id: 'B', + title: 'Answer 1', + correct: true, + selectedFeedback: 'selected feedback', + unselectedFeedback: 'unselected feedback', + isAnswerRange: false, + }, + ], + updateField: jest.fn(), + addAnswer: jest.fn(), + addAnswerRange: jest.fn(), + }; + expect(shallow( + , + )).toMatchSnapshot(); + }); + }); test('useAnswerContainer', async () => { let container = null; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/__snapshots__/AnswerOption.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/__snapshots__/AnswerOption.test.jsx.snap index c5425570f..a6d3b4d56 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/__snapshots__/AnswerOption.test.jsx.snap +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/__snapshots__/AnswerOption.test.jsx.snap @@ -151,6 +151,100 @@ exports[`AnswerOption render snapshot: renders correct option with numeric input `; +exports[`AnswerOption render snapshot: renders correct option with numeric input problem and answer range 1`] = ` + +
+ +
+
+
+ +
+
+ +
+
+ + + +
+
+ + + + +
+
+`; + exports[`AnswerOption render snapshot: renders correct option with selected unselected feedback 1`] = ` + + + + + + + + + + + + + + + + +`; + +exports[`AnswersContainer render snapshot: numeric problems: answer range/answer select button: empty 1`] = ` +
+ + + + + + + + + + + + + + +
+`; + +exports[`AnswersContainer render snapshot: numeric problems: answer range/answer select button: multiple answers disables range. 1`] = ` +
+ + + + + + + + + + + + + + + + +
+`; + exports[`AnswersContainer render snapshot: renders correct default 1`] = `
-
+
diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js index d047f9552..bd2358fc7 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js @@ -56,5 +56,22 @@ const messages = defineMessages({ defaultMessage: 'is not selected', description: 'Bold & underlined text for feedback if option is not selected', }, + + addAnswerRangeButtonText: { + id: 'authoring.answerwidget.answer.addAnswerRangeButton', + defaultMessage: 'Add answer range', + description: 'Button text to add a range of answers', + }, + answerRangeTextboxPlaceholder: { + id: 'authoring.answerwidget.answer.answerRangeTextboxPlaceholder', + defaultMessage: 'Enter an answer range', + description: 'Text to prompt the user to add an answer range to the textbox.', + }, + answerRangeHelperText: { + id: 'authoring.answerwidget.answer.answerRangeHelperText', + defaultMessage: 'Enter min and max values separated by a comma. Use a bracket to include the number next to it in the range, or a parenthesis to exclude the number. For example, to identify the correct answers as 5, 6, or 7, but not 8, specify [5,8).', + description: 'Helper text describing usage of answer ranges', + }, }); + export default messages; 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 index bda7efd3d..06929a34a 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/index.jsx @@ -7,13 +7,10 @@ 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 isAnswerRangeSet = ({ answers }) => !!answers[0].isAnswerRange; export const handleToleranceTypeChange = ({ updateSettings, tolerance, answers }) => (event) => { - if (!isAnswerRangeSet(answers)) { + if (!isAnswerRangeSet({ answers })) { let value; if (event.target.value === ToleranceTypes.none.type) { value = null; @@ -26,7 +23,7 @@ export const handleToleranceTypeChange = ({ updateSettings, tolerance, answers } }; export const handleToleranceValueChange = ({ updateSettings, tolerance, answers }) => (event) => { - if (!isAnswerRangeSet(answers)) { + if (!isAnswerRangeSet({ answers })) { const newTolerance = { value: event.target.value, type: tolerance.type }; updateSettings({ tolerance: newTolerance }); } @@ -52,7 +49,7 @@ export const ToleranceCard = ({ // inject intl, }) => { - const canEdit = isAnswerRangeSet({ answers }); + const isAnswerRange = isAnswerRangeSet({ answers }); let summary = getSummary({ tolerance, intl }); useEffect(() => { summary = getSummary({ tolerance, intl }); }, [tolerance]); return ( @@ -61,7 +58,7 @@ export const ToleranceCard = ({ summary={summary} none={tolerance.type === ToleranceTypes.none.type} > - { canEdit + { isAnswerRange && ( {Object.keys(ToleranceTypes).map((toleranceType) => ( @@ -90,7 +87,7 @@ export const ToleranceCard = ({ ))} - { tolerance?.type !== ToleranceTypes.none.type && !canEdit + { tolerance?.type !== ToleranceTypes.none.type && !isAnswerRange && ( ({
{children}
)), Form: { Control: jest.fn(({ - children, onChange, as, value, + children, onChange, as, value, disabled, }) => { if (as === 'select') { - return (); + return (); } return (); }), @@ -49,7 +50,15 @@ describe('ToleranceCard', () => { }; const props = { - answers: [], // TODO: for TNL 10258 + answers: [{ + id: 'A', + correct: true, + selectedFeedback: '', + title: 'An Answer', + isAnswerRange: false, + unselectedFeedback: '', + }, + ], updateSettings: jest.fn(), intl: { formatMessage, @@ -74,6 +83,32 @@ describe('ToleranceCard', () => { const NumberText = screen.getByText(`± ${mockToleranceNumber.value}`); expect(NumberText).toBeDefined(); }); + + it('If there is an answer range, show message and disable dropdown.', () => { + const rangeprops = { + answers: [{ + id: 'A', + correct: true, + selectedFeedback: '', + title: 'An Answer', + isAnswerRange: true, + unselectedFeedback: '', + }, + ], + updateSettings: jest.fn(), + intl: { + formatMessage, + }, + }; + + render(); + const NumberText = screen.getByText(messages.toleranceAnswerRangeWarning.defaultMessage); + expect(NumberText).toBeDefined(); + expect(screen.getByTestId('select').getAttributeNames().includes('disabled')).toBeTruthy(); + }); }); describe('Type Select', () => { it('Renders the types for selection', async () => { diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/__snapshots__/index.test.jsx.snap index c22fd2c4c..e4f96de36 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/__snapshots__/index.test.jsx.snap @@ -40,7 +40,7 @@ exports[`EditorProblemView component renders simple view 1`] = ` className="editProblemView d-flex flex-row flex-nowrap justify-content-end" > diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx index 8258a84e4..1c9bdb49e 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx @@ -40,7 +40,7 @@ export const EditProblemView = ({ ) : ( - + diff --git a/src/editors/containers/ProblemEditor/data/OLXParser.js b/src/editors/containers/ProblemEditor/data/OLXParser.js index 39b43ef71..ca4965bd4 100644 --- a/src/editors/containers/ProblemEditor/data/OLXParser.js +++ b/src/editors/containers/ProblemEditor/data/OLXParser.js @@ -263,7 +263,6 @@ export class OLXParser { let answerFeedback = ''; const answers = []; let responseParam = {}; - // TODO: UI needs to be added to support adding tolerence in numeric response. const feedback = this.getFeedback(numericalresponse); if (_.has(numericalresponse, 'responseparam')) { const type = _.get(numericalresponse, 'responseparam.@_type'); @@ -272,11 +271,13 @@ export class OLXParser { [type]: defaultValue, }; } + const isAnswerRange = /[([]\d*,\d*[)\]]/gm.test(numericalresponse['@_answer']); answers.push({ id: indexToLetterMap[answers.length], title: numericalresponse['@_answer'], correct: true, selectedFeedback: feedback, + isAnswerRange, ...responseParam, }); @@ -299,6 +300,7 @@ export class OLXParser { title: additionalAnswer['@_answer'], correct: true, selectedFeedback: answerFeedback, + isAnswerRange: false, }); } return { answers }; diff --git a/src/editors/containers/ProblemEditor/data/mockData/olxTestData.js b/src/editors/containers/ProblemEditor/data/mockData/olxTestData.js index 89bde5b7f..c2e534dee 100644 --- a/src/editors/containers/ProblemEditor/data/mockData/olxTestData.js +++ b/src/editors/containers/ProblemEditor/data/mockData/olxTestData.js @@ -351,12 +351,14 @@ export const numericInputWithFeedbackAndHintsOLX = { title: '100', correct: true, selectedFeedback: '

You can specify optional feedback like this, which appears after this answer is submitted.

', + isAnswerRange: false, tolerance: '5', }, { id: 'B', title: '200', correct: true, + isAnswerRange: false, selectedFeedback: '

You can specify optional feedback like this, which appears after this answer is submitted.

', }, ], diff --git a/src/editors/data/redux/problem/reducers.js b/src/editors/data/redux/problem/reducers.js index 38aafa91e..5642f81e2 100644 --- a/src/editors/data/redux/problem/reducers.js +++ b/src/editors/data/redux/problem/reducers.js @@ -85,8 +85,24 @@ const problem = createSlice({ deleteAnswer: (state, { payload }) => { const { id, correct } = payload; if (state.answers.length <= 1) { + if (state.answers.length > 0 && state.answers[0].isAnswerRange) { + return { + ...state, + correctAnswerCount: 1, + answers: [{ + id: 'A', + title: '', + selectedFeedback: '', + unselectedFeedback: '', + correct: state.problemType === ProblemTypeKeys.NUMERIC, + isAnswerRange: false, + }, + ], + }; + } return state; } + let { correctAnswerCount } = state; if (correct) { correctAnswerCount -= 1; @@ -115,6 +131,7 @@ const problem = createSlice({ selectedFeedback: '', unselectedFeedback: '', correct: state.problemType === ProblemTypeKeys.NUMERIC, + isAnswerRange: false, }; let { correctAnswerCount } = state; if (state.problemType === ProblemTypeKeys.NUMERIC) { @@ -131,6 +148,24 @@ const problem = createSlice({ answers, }; }, + addAnswerRange: (state) => { + // As you may only have one answer range at a time, overwrite the answer object. + const newOption = { + id: 'A', + title: '', + selectedFeedback: '', + unselectedFeedback: '', + correct: state.problemType === ProblemTypeKeys.NUMERIC, + isAnswerRange: true, + }; + const correctAnswerCount = 1; + return { + ...state, + correctAnswerCount, + answers: [newOption], + }; + }, + updateSettings: (state, { payload }) => ({ ...state, settings: { diff --git a/src/editors/data/redux/problem/reducers.test.js b/src/editors/data/redux/problem/reducers.test.js index 2b3b003b7..4466eb852 100644 --- a/src/editors/data/redux/problem/reducers.test.js +++ b/src/editors/data/redux/problem/reducers.test.js @@ -56,6 +56,7 @@ describe('problem reducer', () => { correct: false, selectedFeedback: '', title: '', + isAnswerRange: false, unselectedFeedback: '', }; expect(reducer(testingState, actions.addAnswer(answer))).toEqual({ @@ -91,6 +92,7 @@ describe('problem reducer', () => { correct: false, selectedFeedback: '', title: '', + isAnswerRange: false, unselectedFeedback: '', }; it('sets answers', () => { @@ -116,6 +118,26 @@ describe('problem reducer', () => { }); }); }); + + describe('addAnswerRange', () => { + const answerRange = { + id: 'A', + correct: true, + selectedFeedback: '', + title: '', + isAnswerRange: true, + unselectedFeedback: '', + }; + it('sets answerRange', () => { + expect(reducer({ ...testingState, problemType: ProblemTypeKeys.NUMERIC }, actions.addAnswerRange())).toEqual({ + ...testingState, + correctAnswerCount: 1, + problemType: ProblemTypeKeys.NUMERIC, + answers: [answerRange], + }); + }); + }); + describe('updateAnswer', () => { it('sets answers, as well as setting the correctAnswerCount ', () => { const answer = { id: 'A', correct: true }; @@ -162,6 +184,39 @@ describe('problem reducer', () => { }], }); }); + it('if you delete an answer range, it will be replaced with a blank answer', () => { + const answer = { + id: 'A', + correct: true, + selectedFeedback: '', + title: '', + isAnswerRange: false, + unselectedFeedback: '', + }; + const answerRange = { + id: 'A', + correct: false, + selectedFeedback: '', + title: '', + isAnswerRange: true, + unselectedFeedback: '', + }; + + expect(reducer( + { + ...testingState, + problemType: ProblemTypeKeys.NUMERIC, + correctAnswerCount: 1, + answers: [{ ...answerRange }], + }, + actions.deleteAnswer(answer), + )).toEqual({ + ...testingState, + problemType: ProblemTypeKeys.NUMERIC, + correctAnswerCount: 1, + answers: [{ ...answer }], + }); + }); }); }); });