feat: unselect multiple choices type multi->single (#181)

This commit is contained in:
Kristin Aoki
2023-01-10 13:25:19 -05:00
committed by GitHub
parent 880d205cbb
commit af4cd55390
16 changed files with 429 additions and 13 deletions

View File

@@ -16,9 +16,9 @@ export const AnswersContainer = ({
// Redux
answers,
addAnswer,
updateField,
}) => {
const { hasSingleAnswer } = initializeAnswerContainer(problemType);
const { hasSingleAnswer } = initializeAnswerContainer({ answers, problemType, updateField });
return (
<div>
{answers.map((answer) => (
@@ -44,6 +44,7 @@ AnswersContainer.propTypes = {
problemType: PropTypes.string.isRequired,
answers: PropTypes.arrayOf(answerOptionProps).isRequired,
addAnswer: PropTypes.func.isRequired,
updateField: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
@@ -52,6 +53,7 @@ export const mapStateToProps = (state) => ({
export const mapDispatchToProps = {
addAnswer: actions.problem.addAnswer,
updateField: actions.problem.updateField,
};
export default connect(mapStateToProps, mapDispatchToProps)(AnswersContainer);

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { shallow } from 'enzyme';
import { actions, selectors } from '../../../../../data/redux';
import * as module from './AnswersContainer';
jest.mock('../../../../../data/redux', () => ({
actions: {
problem: {
updateField: jest.fn().mockName('actions.problem.updateField'),
addAnswer: jest.fn().mockName('actions.problem.addAnswer'),
},
},
selectors: {
problem: {
answers: jest.fn(state => ({ answers: state })),
},
},
}));
describe('AnswersContainer', () => {
const props = {
answers: [],
updateField: jest.fn(),
addAnswer: jest.fn(),
};
describe('render', () => {
test('snapshot: renders correct default', () => {
expect(shallow(<module.AnswersContainer {...props} />)).toMatchSnapshot();
});
test('snapshot: renders correctly with answers', () => {
expect(shallow(<module.AnswersContainer answers={[{ id: 'a', title: 'sOMetITlE', correct: true }, { id: 'b', title: 'sOMetITlE', correct: true }]} {...props} />)).toMatchSnapshot();
});
});
describe('mapStateToProps', () => {
const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
test('answers from problem.answers', () => {
expect(
module.mapStateToProps(testState).answers,
).toEqual(selectors.problem.answers(testState));
});
});
describe('mapDispatchToProps', () => {
test('updateField from actions.problem.updateField', () => {
expect(module.mapDispatchToProps.updateField).toEqual(actions.problem.updateField);
});
test('updateField from actions.problem.addAnswer', () => {
expect(module.mapDispatchToProps.addAnswer).toEqual(actions.problem.addAnswer);
});
});
});

View File

@@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AnswersContainer render snapshot: renders correct default 1`] = `
<div>
<Button
className="my-3 ml-2"
onClick={[MockFunction]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="Add answer"
description="Button text to add answer"
id="authoring.answerwidget.answer.addAnswerButton"
/>
</Button>
</div>
`;
exports[`AnswersContainer render snapshot: renders correctly with answers 1`] = `
<div>
<Button
className="my-3 ml-2"
onClick={[MockFunction]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="Add answer"
description="Button text to add answer"
id="authoring.answerwidget.answer.addAnswerButton"
/>
</Button>
</div>
`;

View File

@@ -3,7 +3,7 @@ import { useState, useEffect } from 'react';
import _ from 'lodash-es';
import * as module from './hooks';
import messages from './messages';
import { ShowAnswerTypesKeys } from '../../../../../data/constants/problem';
import { ProblemTypeKeys, ShowAnswerTypesKeys } from '../../../../../data/constants/problem';
export const state = {
showAdvanced: (val) => useState(val),
@@ -182,11 +182,23 @@ export const timerCardHooks = (updateSettings) => ({
},
});
export const typeRowHooks = (typeKey, updateField) => {
export const typeRowHooks = ({
answers,
correctAnswerCount,
typeKey,
updateField,
updateAnswer,
}) => {
const onClick = () => {
if (typeKey === ProblemTypeKeys.SINGLESELECT || typeKey === ProblemTypeKeys.DROPDOWN) {
if (correctAnswerCount > 1) {
answers.forEach(answer => {
updateAnswer({ ...answer, correct: false });
});
}
}
updateField({ problemType: typeKey });
};
return {
onClick,
};

View File

@@ -18,6 +18,7 @@ jest.mock('../../../../../data/redux', () => ({
problem: {
updateSettings: (args) => ({ updateSettings: args }),
updateField: (args) => ({ updateField: args }),
updateAnswer: (args) => ({ updateAnswer: args }),
},
},
}));
@@ -217,10 +218,19 @@ describe('Problem settings hooks', () => {
describe('Type row hooks', () => {
test('test onClick', () => {
const typekey = 'TEXTINPUT';
const typekey = 'multiplechoiceresponse';
const updateField = jest.fn();
output = hooks.typeRowHooks(typekey, updateField);
const updateAnswer = jest.fn();
const answers = [{ correct: true, id: 'a' }, { correct: true, id: 'b' }];
output = hooks.typeRowHooks({
answers,
correctAnswerCount: 2,
typeKey: typekey,
updateField,
updateAnswer,
});
output.onClick();
expect(updateAnswer).toHaveBeenCalledWith({ ...answers[1], correct: false });
expect(updateField).toHaveBeenCalledWith({ problemType: typekey });
});
});

View File

@@ -23,9 +23,12 @@ import './index.scss';
export const SettingsWidget = ({
problemType,
// redux
answers,
correctAnswerCount,
settings,
updateSettings,
updateField,
updateAnswer,
}) => {
const { isAdvancedCardsVisible, showAdvancedCards } = showAdvancedSettingsCards();
return (
@@ -38,7 +41,13 @@ export const SettingsWidget = ({
<Row>
<Col>
<Row className="my-2">
<TypeCard problemType={problemType} updateField={updateField} />
<TypeCard
answers={answers}
correctAnswerCount={correctAnswerCount}
problemType={problemType}
updateField={updateField}
updateAnswer={updateAnswer}
/>
</Row>
<Row className="my-2">
<ScoringCard scoring={settings.scoring} updateSettings={updateSettings} />
@@ -91,7 +100,16 @@ export const SettingsWidget = ({
};
SettingsWidget.propTypes = {
answers: PropTypes.arrayOf(PropTypes.shape({
correct: PropTypes.bool,
id: PropTypes.string,
selectedFeedback: PropTypes.string,
title: PropTypes.string,
unselectedFeedback: PropTypes.string,
})).isRequired,
correctAnswerCount: PropTypes.number.isRequired,
problemType: PropTypes.string.isRequired,
updateAnswer: PropTypes.func.isRequired,
updateField: PropTypes.func.isRequired,
updateSettings: PropTypes.func.isRequired,
// eslint-disable-next-line
@@ -100,11 +118,14 @@ SettingsWidget.propTypes = {
const mapStateToProps = (state) => ({
settings: selectors.problem.settings(state),
answers: selectors.problem.answers(state),
correctAnswerCount: selectors.problem.correctAnswerCount(state),
});
export const mapDispatchToProps = {
updateSettings: actions.problem.updateSettings,
updateField: actions.problem.updateField,
updateAnswer: actions.problem.updateAnswer,
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(SettingsWidget));

View File

@@ -7,8 +7,11 @@ import messages from '../messages';
import TypeRow from './TypeRow';
export const TypeCard = ({
answers,
correctAnswerCount,
problemType,
updateField,
updateAnswer,
// inject
intl,
}) => {
@@ -21,12 +24,15 @@ export const TypeCard = ({
>
{problemTypeKeysArray.map((typeKey, i) => (
<TypeRow
answers={answers}
correctAnswerCount={correctAnswerCount}
key={typeKey}
typeKey={typeKey}
label={ProblemTypes[typeKey].title}
selected={typeKey !== problemType}
lastRow={(i + 1) === problemTypeKeysArray.length}
updateField={updateField}
updateAnswer={updateAnswer}
/>
))}
</SettingsOption>
@@ -34,9 +40,19 @@ export const TypeCard = ({
};
TypeCard.propTypes = {
intl: intlShape.isRequired,
answers: PropTypes.arrayOf(PropTypes.shape({
correct: PropTypes.bool,
id: PropTypes.string,
selectedFeedback: PropTypes.string,
title: PropTypes.string,
unselectedFeedback: PropTypes.string,
})).isRequired,
correctAnswerCount: PropTypes.number.isRequired,
problemType: PropTypes.string.isRequired,
updateField: PropTypes.func.isRequired,
updateAnswer: PropTypes.func.isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(TypeCard);

View File

@@ -6,8 +6,12 @@ import { ProblemTypeKeys } from '../../../../../../data/constants/problem';
describe('TypeCard', () => {
const props = {
answers: [],
correctAnswerCount: 0,
problemType: ProblemTypeKeys.TEXTINPUT,
updateField: jest.fn().mockName('args.updateField'),
updateAnswer: jest.fn().mockName('args.updateAnswer'),
// injected
intl: { formatMessage },
};

View File

@@ -5,13 +5,22 @@ import { Check } from '@edx/paragon/icons';
import { typeRowHooks } from '../hooks';
export const TypeRow = ({
answers,
correctAnswerCount,
typeKey,
label,
selected,
lastRow,
updateField,
updateAnswer,
}) => {
const { onClick } = typeRowHooks(typeKey, updateField);
const { onClick } = typeRowHooks({
answers,
correctAnswerCount,
typeKey,
updateField,
updateAnswer,
});
return (
<>
@@ -25,10 +34,19 @@ export const TypeRow = ({
};
TypeRow.propTypes = {
answers: PropTypes.arrayOf(PropTypes.shape({
correct: PropTypes.bool,
id: PropTypes.string,
selectedFeedback: PropTypes.string,
title: PropTypes.string,
unselectedFeedback: PropTypes.string,
})).isRequired,
correctAnswerCount: PropTypes.number.isRequired,
typeKey: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
selected: PropTypes.bool.isRequired,
lastRow: PropTypes.bool.isRequired,
updateAnswer: PropTypes.func.isRequired,
updateField: PropTypes.func.isRequired,
};

View File

@@ -10,11 +10,14 @@ jest.mock('../hooks', () => ({
describe('TypeRow', () => {
const typeKey = 'TEXTINPUT';
const props = {
answers: [],
correctAnswerCount: 0,
typeKey,
label: 'Text Input Problem',
selected: true,
lastRow: false,
updateField: jest.fn().mockName('args.updateField'),
updateAnswer: jest.fn().mockName('args.updateAnswer'),
};
const typeRowHooksProps = {
@@ -26,7 +29,13 @@ describe('TypeRow', () => {
describe('behavior', () => {
it(' calls typeRowHooks when initialized', () => {
shallow(<TypeRow {...props} />);
expect(typeRowHooks).toHaveBeenCalledWith(typeKey, props.updateField);
expect(typeRowHooks).toHaveBeenCalledWith({
answers: props.answers,
correctAnswerCount: props.correctAnswerCount,
typeKey,
updateField: props.updateField,
updateAnswer: props.updateAnswer,
});
});
});

View File

@@ -6,51 +6,69 @@ exports[`TypeCard snapshot snapshot: renders type setting card 1`] = `
title="Type"
>
<TypeRow
answers={Array []}
correctAnswerCount={0}
key="multiplechoiceresponse"
label="Single Select Problem"
lastRow={false}
selected={true}
typeKey="multiplechoiceresponse"
updateAnswer={[MockFunction args.updateAnswer]}
updateField={[MockFunction args.updateField]}
/>
<TypeRow
answers={Array []}
correctAnswerCount={0}
key="choiceresponse"
label="Multi Select Problem"
lastRow={false}
selected={true}
typeKey="choiceresponse"
updateAnswer={[MockFunction args.updateAnswer]}
updateField={[MockFunction args.updateField]}
/>
<TypeRow
answers={Array []}
correctAnswerCount={0}
key="optionresponse"
label="Dropdown Problem"
lastRow={false}
selected={true}
typeKey="optionresponse"
updateAnswer={[MockFunction args.updateAnswer]}
updateField={[MockFunction args.updateField]}
/>
<TypeRow
answers={Array []}
correctAnswerCount={0}
key="numericalresponse"
label="Numeric Response Problem"
lastRow={false}
selected={true}
typeKey="numericalresponse"
updateAnswer={[MockFunction args.updateAnswer]}
updateField={[MockFunction args.updateField]}
/>
<TypeRow
answers={Array []}
correctAnswerCount={0}
key="stringresponse"
label="Text Input Problem"
lastRow={false}
selected={false}
typeKey="stringresponse"
updateAnswer={[MockFunction args.updateAnswer]}
updateField={[MockFunction args.updateField]}
/>
<TypeRow
answers={Array []}
correctAnswerCount={0}
key="advanced"
label="Advanced Problem"
lastRow={true}
selected={true}
typeKey="advanced"
updateAnswer={[MockFunction args.updateAnswer]}
updateField={[MockFunction args.updateField]}
/>
</SettingsOption>

View File

@@ -35,7 +35,14 @@ export const prepareEditorRef = () => {
return { editorRef, refReady, setEditorRef };
};
export const initializeAnswerContainer = (problemType) => {
export const initializeAnswerContainer = ({ answers, problemType, updateField }) => {
const hasSingleAnswer = problemType === ProblemTypeKeys.DROPDOWN || problemType === ProblemTypeKeys.SINGLESELECT;
let answerCount = 0;
answers.forEach(answer => {
if (answer.correct) {
answerCount += 1;
}
});
updateField({ correctAnswerCount: answerCount });
return { hasSingleAnswer };
};

View File

@@ -10,6 +10,7 @@ const initialState = {
problemType: null,
question: '',
answers: [],
correctAnswerCount: 0,
groupFeedbackList: [],
additionalAttributes: {},
settings: {
@@ -46,8 +47,17 @@ const problem = createSlice({
}),
updateAnswer: (state, { payload }) => {
const { id, hasSingleAnswer, ...answer } = payload;
let { correctAnswerCount } = state;
const answers = state.answers.map(obj => {
if (obj.id === id) {
if (_.has(answer, 'correct') && payload.correct) {
correctAnswerCount += 1;
return { ...obj, ...answer };
}
if (_.has(answer, 'correct') && payload.correct === false) {
correctAnswerCount -= 1;
return { ...obj, ...answer };
}
return { ...obj, ...answer };
}
// set other answers as incorrect if problem only has one answer correct
@@ -59,14 +69,19 @@ const problem = createSlice({
});
return {
...state,
correctAnswerCount,
answers,
};
},
deleteAnswer: (state, { payload }) => {
const { id } = payload;
const { id, correct } = payload;
if (state.answers.length <= 1) {
return state;
}
let { correctAnswerCount } = state;
if (correct) {
correctAnswerCount -= 1;
}
const answers = state.answers.filter(obj => obj.id !== id).map((answer, index) => {
const newId = indexToLetterMap[index];
if (answer.id === newId) {
@@ -76,6 +91,7 @@ const problem = createSlice({
});
return {
...state,
correctAnswerCount,
answers,
};
},

View File

@@ -0,0 +1,147 @@
import { initialState, actions, reducer } from './reducers';
const testingState = {
...initialState,
arbitraryField: 'arbitrary',
};
describe('problem reducer', () => {
it('has initial state', () => {
expect(reducer(undefined, {})).toEqual(initialState);
});
const testValue = 'roll for initiative';
describe('handling actions', () => {
const setterTest = (action, target) => {
describe(action, () => {
it(`load ${target} from payload`, () => {
expect(reducer(testingState, actions[action](testValue))).toEqual({
...testingState,
[target]: testValue,
});
});
});
};
[
['updateQuestion', 'question'],
].map(args => setterTest(...args));
describe('load', () => {
it('sets answers', () => {
const answer = {
id: 'A',
correct: false,
feedback: '',
selectedFeedback: undefined,
title: '',
unselectedFeedback: undefined,
};
expect(reducer(testingState, actions.addAnswer(answer))).toEqual({
...testingState,
answers: [answer],
});
});
});
describe('setProblemType', () => {
it('sets problemType', () => {
const payload = { selected: 'soMePRoblEMtYPe' };
expect(reducer(testingState, actions.setProblemType(payload))).toEqual({
...testingState,
problemType: 'soMePRoblEMtYPe',
});
});
});
describe('setEnableTypeSelection', () => {
it('sets problemType to null', () => {
expect(reducer(testingState, actions.setEnableTypeSelection())).toEqual({
...testingState,
problemType: null,
});
});
});
describe('updateField', () => {
it('sets given parameter', () => {
const payload = { problemType: 'soMePRoblEMtYPe' };
expect(reducer(testingState, actions.updateField(payload))).toEqual({
...testingState,
...payload,
});
});
});
describe('updateSettings', () => {
it('sets given settings parameter', () => {
const payload = { hints: ['soMehInt'] };
expect(reducer(testingState, actions.updateSettings(payload))).toEqual({
...testingState,
settings: {
...testingState.settings,
...payload,
},
});
});
});
describe('addAnswer', () => {
it('sets answers', () => {
const answer = {
id: 'A',
correct: false,
feedback: '',
selectedFeedback: undefined,
title: '',
unselectedFeedback: undefined,
};
expect(reducer(testingState, actions.addAnswer(answer))).toEqual({
...testingState,
answers: [answer],
});
});
});
describe('updateAnswer', () => {
it('sets answers, as well as setting the correctAnswerCount ', () => {
const answer = { id: 'A', correct: true };
expect(reducer(
{
...testingState,
answers: [{
id: 'A',
correct: false,
}],
},
actions.updateAnswer(answer),
)).toEqual({
...testingState,
correctAnswerCount: 1,
answers: [{ id: 'A', correct: true }],
});
});
});
describe('deleteAnswer', () => {
it('sets answers, as well as setting the correctAnswerCount ', () => {
const answer = { id: 'A' };
expect(reducer(
{
...testingState,
correctAnswerCount: 1,
answers: [{
id: 'A',
correct: false,
},
{
id: 'B',
correct: true,
}],
},
actions.deleteAnswer(answer),
)).toEqual({
...testingState,
correctAnswerCount: 1,
answers: [
{
id: 'A',
correct: true,
}],
});
});
});
});
});

View File

@@ -6,6 +6,7 @@ const mkSimpleSelector = (cb) => createSelector([module.problemState], cb);
export const simpleSelectors = {
problemType: mkSimpleSelector(problemData => problemData.problemType),
answers: mkSimpleSelector(problemData => problemData.answers),
correctAnswerCount: mkSimpleSelector(problemData => problemData.correctAnswerCount),
settings: mkSimpleSelector(problemData => problemData.settings),
question: mkSimpleSelector(problemData => problemData.question),
completeState: mkSimpleSelector(problemData => problemData),

View File

@@ -0,0 +1,52 @@
// import * in order to mock in-file references
import { keyStore } from '../../../utils';
import * as selectors from './selectors';
jest.mock('reselect', () => ({
createSelector: jest.fn((preSelectors, cb) => ({ preSelectors, cb })),
}));
const testState = { some: 'arbitraryValue' };
const testValue = 'my VALUE';
describe('problem selectors unit tests', () => {
const {
problemState,
simpleSelectors,
} = selectors;
describe('problemState', () => {
it('returns the problem data', () => {
expect(problemState({ ...testState, problem: testValue })).toEqual(testValue);
});
});
describe('simpleSelectors', () => {
const testSimpleSelector = (key) => {
test(`${key} simpleSelector returns its value from the problem store`, () => {
const { preSelectors, cb } = simpleSelectors[key];
expect(preSelectors).toEqual([problemState]);
expect(cb({ ...testState, [key]: testValue })).toEqual(testValue);
});
};
const simpleKeys = keyStore(simpleSelectors);
describe('simple selectors link their values from problem store', () => {
[
simpleKeys.problemType,
simpleKeys.answers,
simpleKeys.correctAnswerCount,
simpleKeys.settings,
simpleKeys.question,
].map(testSimpleSelector);
});
test('simple selector completeState equals the entire state', () => {
const { preSelectors, cb } = simpleSelectors[simpleKeys.completeState];
expect(preSelectors).toEqual([problemState]);
expect(cb({
...testState,
[simpleKeys.completeState]: testValue,
})).toEqual({
...testState,
[simpleKeys.completeState]: testValue,
});
});
});
});