feat: Group, General Feedback Settings, Randomization

This Ticket adds three new settings widgets to the Problem Editor:
Randomization: This is a setting for advanced problems only which deals with python scripts.
General Feedback: This is feedback which is only applied to certain problem types for mass-adding feedback to incorrect problems.
Group Feedback: For certain problems, the user can provide specific feedback for a combination of specific answers.
This commit is contained in:
connorhaugh
2023-02-10 08:50:32 -05:00
committed by GitHub
parent 7c0309189f
commit d69d3e1ce7
32 changed files with 1303 additions and 22 deletions

View File

@@ -7,19 +7,24 @@ exports[`SettingsWidget snapshot snapshot: renders Settings widget page 1`] = `
<div
className="mb-3 settingsCardTopdiv"
>
<injectIntl(ShimmedIntlComponent)
<TypeCard
problemType="stringresponse"
/>
</div>
<div
className="my-3"
>
<injectIntl(ShimmedIntlComponent) />
<ScoringCard />
</div>
<div
className="mt-3"
>
<injectIntl(ShimmedIntlComponent) />
<HintsCard />
</div>
<div
className="mt-3"
>
<GroupFeedback />
</div>
<div>
<Advanced
@@ -51,27 +56,27 @@ exports[`SettingsWidget snapshot snapshot: renders Settings widget page 1`] = `
<div
className="my-3"
>
<injectIntl(ShimmedIntlComponent) />
<ShowAnswerCard />
</div>
<div
className="my-3"
>
<injectIntl(ShimmedIntlComponent) />
<ResetCard />
</div>
<div
className="my-3"
>
<injectIntl(ShimmedIntlComponent) />
<TimerCard />
</div>
<div
className="my-3"
>
<injectIntl(ShimmedIntlComponent) />
<MatlabCard />
</div>
<div
className="my-3"
>
<injectIntl(ShimmedIntlComponent)
<SwitchToAdvancedEditorCard
problemType="stringresponse"
/>
</div>
@@ -87,19 +92,24 @@ exports[`SettingsWidget snapshot snapshot: renders Settings widget page advanced
<div
className="mb-3 settingsCardTopdiv"
>
<injectIntl(ShimmedIntlComponent)
<TypeCard
problemType="stringresponse"
/>
</div>
<div
className="my-3"
>
<injectIntl(ShimmedIntlComponent) />
<ScoringCard />
</div>
<div
className="mt-3"
>
<injectIntl(ShimmedIntlComponent) />
<HintsCard />
</div>
<div
className="mt-3"
>
<GroupFeedback />
</div>
<div>
<Advanced
@@ -131,27 +141,27 @@ exports[`SettingsWidget snapshot snapshot: renders Settings widget page advanced
<div
className="my-3"
>
<injectIntl(ShimmedIntlComponent) />
<ShowAnswerCard />
</div>
<div
className="my-3"
>
<injectIntl(ShimmedIntlComponent) />
<ResetCard />
</div>
<div
className="my-3"
>
<injectIntl(ShimmedIntlComponent) />
<TimerCard />
</div>
<div
className="my-3"
>
<injectIntl(ShimmedIntlComponent) />
<MatlabCard />
</div>
<div
className="my-3"
>
<injectIntl(ShimmedIntlComponent)
<SwitchToAdvancedEditorCard
problemType="stringresponse"
/>
</div>

View File

@@ -13,17 +13,23 @@ import ResetCard from './settingsComponents/ResetCard';
import MatlabCard from './settingsComponents/MatlabCard';
import TimerCard from './settingsComponents/TimerCard';
import TypeCard from './settingsComponents/TypeCard';
import GeneralFeedbackCard from './settingsComponents/GeneralFeedback/index';
import GroupFeedbackCard from './settingsComponents/GroupFeedback/index';
import SwitchToAdvancedEditorCard from './settingsComponents/SwitchToAdvancedEditorCard';
import messages from './messages';
import { showAdvancedSettingsCards } from './hooks';
import './index.scss';
import { ProblemTypeKeys } from '../../../../../data/constants/problem';
import Randomization from './settingsComponents/Randomization';
// This widget should be connected, grab all settings from store, update them as needed.
export const SettingsWidget = ({
problemType,
// redux
answers,
generalFeedback,
groupFeedbackList,
blockTitle,
correctAnswerCount,
settings,
@@ -33,6 +39,30 @@ export const SettingsWidget = ({
updateAnswer,
}) => {
const { isAdvancedCardsVisible, showAdvancedCards } = showAdvancedSettingsCards();
const feedbackCard = () => {
if (problemType === ProblemTypeKeys.ADVANCED) {
return (<></>);
}
if ([ProblemTypeKeys.MULTISELECT, ProblemTypeKeys.TEXTINPUT, ProblemTypeKeys.NUMERIC].includes(problemType)) {
return (
<div className="mt-3"><GroupFeedbackCard
groupFeedbacks={groupFeedbackList}
updateSettings={updateField}
answers={answers}
/>
</div>
);
}
return (
<div className="mt-3"><GeneralFeedbackCard
generalFeedback={generalFeedback}
updateSettings={updateField}
/>
</div>
);
};
return (
<div className="settingsWidget ml-4">
<div className="mb-3 settingsCardTopdiv">
@@ -52,7 +82,7 @@ export const SettingsWidget = ({
<div className="mt-3">
<HintsCard hints={settings.hints} updateSettings={updateSettings} />
</div>
{feedbackCard()}
<div>
<Collapsible.Advanced open={!isAdvancedCardsVisible}>
<Collapsible.Body className="collapsible-body small">
@@ -80,6 +110,13 @@ export const SettingsWidget = ({
<div className="my-3">
<ResetCard showResetButton={settings.showResetButton} updateSettings={updateSettings} />
</div>
{
problemType === ProblemTypeKeys.ADVANCED && (
<div className="my-3">
<Randomization randomization={settings.randomization} updateSettings={updateSettings} />
</div>
)
}
<div className="my-3">
<TimerCard timeBetween={settings.timeBetween} updateSettings={updateSettings} />
</div>
@@ -103,6 +140,16 @@ SettingsWidget.propTypes = {
title: PropTypes.string,
unselectedFeedback: PropTypes.string,
})).isRequired,
generalFeedback: PropTypes.string.isRequired,
groupFeedbackList: PropTypes.arrayOf(
PropTypes.shape(
{
id: PropTypes.number,
feedback: PropTypes.string,
answers: PropTypes.arrayOf(PropTypes.string),
},
),
).isRequired,
blockTitle: PropTypes.string.isRequired,
correctAnswerCount: PropTypes.number.isRequired,
problemType: PropTypes.string.isRequired,
@@ -115,6 +162,8 @@ SettingsWidget.propTypes = {
};
const mapStateToProps = (state) => ({
generalFeedback: selectors.problem.generalFeedback(state),
groupFeedbackList: selectors.problem.groupFeedbackList(state),
settings: selectors.problem.settings(state),
answers: selectors.problem.answers(state),
blockTitle: selectors.app.blockTitle(state),

View File

@@ -9,6 +9,18 @@ jest.mock('./hooks', () => ({
showAdvancedSettingsCards: jest.fn(),
}));
jest.mock('./settingsComponents/GeneralFeedback', () => 'GeneralFeedback');
jest.mock('./settingsComponents/GroupFeedback', () => 'GroupFeedback');
jest.mock('./settingsComponents/Randomization', () => 'Randomization');
jest.mock('./settingsComponents/HintsCard', () => 'HintsCard');
jest.mock('./settingsComponents/MatlabCard', () => 'MatlabCard');
jest.mock('./settingsComponents/ResetCard', () => 'ResetCard');
jest.mock('./settingsComponents/ScoringCard', () => 'ScoringCard');
jest.mock('./settingsComponents/ShowAnswerCard', () => 'ShowAnswerCard');
jest.mock('./settingsComponents/SwitchToAdvancedEditorCard', () => 'SwitchToAdvancedEditorCard');
jest.mock('./settingsComponents/TimerCard', () => 'TimerCard');
jest.mock('./settingsComponents/TypeCard', () => 'TypeCard');
describe('SettingsWidget', () => {
const props = {
problemType: ProblemTypeKeys.TEXTINPUT,

View File

@@ -205,5 +205,4 @@ export const messages = {
description: 'Solution Explanation text',
},
};
export default messages;

View File

@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RandomizationCard snapshot snapshot: renders general feedback setting card 1`] = `
<SettingsOption
className=""
extraSections={Array []}
none={false}
summary={
Object {
"defaultMessage": "sUmmary",
}
}
title="General Feedback"
>
<div
className="halfSpacedMessage"
>
<span>
<FormattedMessage
defaultMessage="Enter the feedback to appear when a student submits a wrong answer. This will be overridden if you add answer-specific feedback."
description="description for general feedback input, clariying useage"
id="authoring.problemeditor.settings.generalFeedbackInputDescription"
/>
</span>
</div>
<Form.Group>
<Form.Control
floatingLabel="Enter General Feedback"
onChange={[MockFunction randomizationCardHooks.handleChange]}
value="sOmE_vAlUE"
/>
</Form.Group>
</SettingsOption>
`;

View File

@@ -0,0 +1,35 @@
import { useState, useEffect } from 'react';
import _ from 'lodash-es';
import messages from './messages';
import * as module from './hooks';
export const state = {
summary: (val) => useState(val),
};
export const generalFeedbackHooks = (generalFeedback, updateSettings) => {
const [summary, setSummary] = module.state.summary({
message: messages.noGeneralFeedbackSummary, values: {}, intl: true,
});
useEffect(() => {
if (_.isEmpty(generalFeedback)) {
setSummary({ message: messages.noGeneralFeedbackSummary, values: {}, intl: true });
} else {
setSummary({
message: generalFeedback,
values: {},
intl: false,
});
}
}, [generalFeedback]);
const handleChange = (event) => {
updateSettings({ generalFeedback: event.target.value });
};
return {
summary,
handleChange,
};
};

View File

@@ -0,0 +1,43 @@
import { useEffect } from 'react';
import { MockUseState } from '../../../../../../../../testUtils';
import messages from './messages';
import * as hooks from './hooks';
jest.mock('react', () => {
const updateState = jest.fn();
return {
updateState,
useEffect: jest.fn(),
useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])),
};
});
const state = new MockUseState(hooks);
describe('Problem settings hooks', () => {
let output;
let updateSettings;
let generalFeedback;
beforeEach(() => {
updateSettings = jest.fn();
generalFeedback = 'sOmE_vAlUe';
state.mock();
});
afterEach(() => {
state.restore();
useEffect.mockClear();
});
describe('Show advanced settings', () => {
beforeEach(() => {
output = hooks.generalFeedbackHooks(generalFeedback, updateSettings);
});
test('test default state is false', () => {
expect(output.summary.message).toEqual(messages.noGeneralFeedbackSummary);
});
test('test showAdvancedCards sets state to true', () => {
const mockEvent = { target: { value: 'sOmE_otheR_ValUe' } };
output.handleChange(mockEvent);
expect(updateSettings).toHaveBeenCalledWith({ generalFeedback: mockEvent.target.value });
});
});
});

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
import { Form } from '@edx/paragon';
import PropTypes from 'prop-types';
import SettingsOption from '../../SettingsOption';
import messages from './messages';
import { generalFeedbackHooks } from './hooks';
export const GeneralFeedbackCard = ({
generalFeedback,
updateSettings,
// inject
intl,
}) => {
const { summary, handleChange } = generalFeedbackHooks(generalFeedback, updateSettings);
return (
<SettingsOption
title={intl.formatMessage(messages.generalFeebackSettingTitle)}
summary={summary.intl ? intl.formatMessage(summary.message) : summary.message}
none={!generalFeedback}
>
<div className="halfSpacedMessage">
<span>
<FormattedMessage {...messages.generalFeedbackDescription} />
</span>
</div>
<Form.Group>
<Form.Control
value={generalFeedback}
onChange={handleChange}
floatingLabel={intl.formatMessage(messages.generalFeedbackInputLabel)}
/>
</Form.Group>
</SettingsOption>
);
};
GeneralFeedbackCard.propTypes = {
generalFeedback: PropTypes.string.isRequired,
updateSettings: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(GeneralFeedbackCard);

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from '../../../../../../../../testUtils';
import { GeneralFeedbackCard } from './index';
import { generalFeedbackHooks } from './hooks';
jest.mock('./hooks', () => ({
generalFeedbackHooks: jest.fn(),
}));
describe('RandomizationCard', () => {
const props = {
generalFeedback: 'sOmE_vAlUE',
updateSettings: jest.fn().mockName('args.updateSettings'),
intl: { formatMessage },
};
const randomizationCardHooksProps = {
summary: { message: { defaultMessage: 'sUmmary' } },
handleChange: jest.fn().mockName('randomizationCardHooks.handleChange'),
};
generalFeedbackHooks.mockReturnValue(randomizationCardHooksProps);
describe('behavior', () => {
it(' calls generalFeedbackHooks with props when initialized', () => {
shallow(<GeneralFeedbackCard {...props} />);
expect(generalFeedbackHooks).toHaveBeenCalledWith(
props.generalFeedback, props.updateSettings,
);
});
});
describe('snapshot', () => {
test('snapshot: renders general feedback setting card', () => {
expect(shallow(<GeneralFeedbackCard {...props} />)).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,23 @@
export const messages = {
generalFeebackSettingTitle: {
id: 'authoring.problemeditor.settings.generalFeebackSettingTitle',
defaultMessage: 'General Feedback',
description: 'label for general feedback setting',
},
generalFeedbackInputLabel: {
id: 'authoring.problemeditor.settings.generalFeedbackInputLabel',
defaultMessage: 'Enter General Feedback',
description: 'label for general feedback input describing rules',
},
generalFeedbackDescription: {
id: 'authoring.problemeditor.settings.generalFeedbackInputDescription',
defaultMessage: 'Enter the feedback to appear when a student submits a wrong answer. This will be overridden if you add answer-specific feedback.',
description: 'description for general feedback input, clariying useage',
},
noGeneralFeedbackSummary: {
id: 'authoring.problemeditor.settings.generalFeedback.noFeedbackSummary',
defaultMessage: 'None',
description: 'message which informs use there is no general feedback set.',
},
};
export default messages;

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
ActionRow, Form, Icon, IconButton, Row,
} from '@edx/paragon';
import { DeleteOutline } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import messages from '../../messages';
export const GroupFeedbackRow = ({
value,
handleAnswersSelectedChange,
handleFeedbackChange,
handleDelete,
answers,
// injected
intl,
}) => (
<div className="mb-4">
<ActionRow className="mb-2">
<Form.Control
value={value.feedback}
onChange={handleFeedbackChange}
/>
<div className="d-flex flex-row flex-nowrap">
<IconButton
src={DeleteOutline}
iconAs={Icon}
alt={intl.formatMessage(messages.settingsDeleteIconAltText)}
onClick={handleDelete}
variant="primary"
/>
</div>
</ActionRow>
<Form.CheckboxSet
onChange={handleAnswersSelectedChange}
value={value.answers}
>
<Row className="mx-0">
{answers.map((letter) => (
<Form.Checkbox
className="mr-4 mt-1"
value={letter.id}
checked={value.answers.indexOf(letter.id)}
isValid={value.answers.indexOf(letter.id)}
>{letter.id}
</Form.Checkbox>
))}
</Row>
</Form.CheckboxSet>
</div>
);
GroupFeedbackRow.propTypes = {
answers: PropTypes.arrayOf(PropTypes.shape({
correct: PropTypes.bool,
id: PropTypes.string,
selectedFeedback: PropTypes.string,
title: PropTypes.string,
unselectedFeedback: PropTypes.string,
})).isRequired,
handleAnswersSelectedChange: PropTypes.func.isRequired,
handleFeedbackChange: PropTypes.func.isRequired,
handleDelete: PropTypes.func.isRequired,
value: PropTypes.shape({
id: PropTypes.number.isRequired,
answers: PropTypes.arrayOf(PropTypes.string),
feedback: PropTypes.string,
}).isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(GroupFeedbackRow);

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from '../../../../../../../../testUtils';
import { GroupFeedbackRow } from './GroupFeedbackRow';
describe('GroupFeedbackRow', () => {
const props = {
value: { answers: ['A', 'C'], feedback: 'sOmE FeEDBACK' },
answers: ['A', 'B', 'C', 'D'],
handleAnswersSelectedChange: jest.fn().mockName('handleAnswersSelectedChange'),
handleFeedbackChange: jest.fn().mockName('handleFeedbackChange'),
handleEmptyFeedback: jest.fn().mockName('handleEmptyFeedback'),
handleDelete: jest.fn().mockName('handleDelete'),
intl: { formatMessage },
};
describe('snapshot', () => {
test('snapshot: renders hints row', () => {
expect(shallow(<GroupFeedbackRow {...props} />)).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,60 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GroupFeedbackRow snapshot snapshot: renders hints row 1`] = `
<div
className="mb-4"
>
<ActionRow
className="mb-2"
>
<Form.Control
onChange={[MockFunction handleFeedbackChange]}
value="sOmE FeEDBACK"
/>
<div
className="d-flex flex-row flex-nowrap"
>
<IconButton
alt="Delete answer"
iconAs="Icon"
onClick={[MockFunction handleDelete]}
variant="primary"
/>
</div>
</ActionRow>
<Component
onChange={[MockFunction handleAnswersSelectedChange]}
value={
Array [
"A",
"C",
]
}
>
<Row
className="mx-0"
>
<Form.Checkbox
checked={-1}
className="mr-4 mt-1"
isValid={-1}
/>
<Form.Checkbox
checked={-1}
className="mr-4 mt-1"
isValid={-1}
/>
<Form.Checkbox
checked={-1}
className="mr-4 mt-1"
isValid={-1}
/>
<Form.Checkbox
checked={-1}
className="mr-4 mt-1"
isValid={-1}
/>
</Row>
</Component>
</div>
`;

View File

@@ -0,0 +1,168 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HintsCard snapshot snapshot: renders groupFeedbacks setting card multiple groupFeedbacks 1`] = `
<SettingsOption
className=""
extraSections={Array []}
none={false}
summary=""
title="Group Feedback"
>
<div
className="py-3"
>
<FormattedMessage
defaultMessage="Group feedback will appear when a student selects a specific set of answers."
description="label for group feedback input"
id="authoring.problemeditor.settings.GroupFeedbackInputLabel"
/>
</div>
<injectIntl(ShimmedIntlComponent)
answers={
Array [
"A",
"B",
"C",
]
}
id={1}
key="1"
props="propsValue"
value={
Object {
"answers": Array [
"A",
"C",
],
"feedback": "sOmE FeEDBACK",
"id": 1,
"value": "groupFeedback1",
}
}
/>
<injectIntl(ShimmedIntlComponent)
answers={
Array [
"A",
"B",
"C",
]
}
id={2}
key="2"
props="propsValue"
value={
Object {
"answers": Array [
"A",
],
"feedback": "sOmE FeEDBACK oTher FeEdback",
"id": 2,
"value": "",
}
}
/>
<Button
className="m-0 p-0 font-weight-bold"
onClick={[MockFunction groupFeedbacksCardHooks.handleAdd]}
size="sm"
text={null}
variant="add"
>
<FormattedMessage
defaultMessage="Add group feedback"
description="addGroupFeedbackButtonText"
id="authoring.problemeditor.settings.addGroupFeedbackButtonText"
/>
</Button>
</SettingsOption>
`;
exports[`HintsCard snapshot snapshot: renders groupFeedbacks setting card no groupFeedbacks 1`] = `
<SettingsOption
className=""
extraSections={Array []}
none={true}
summary="None"
title="Group Feedback"
>
<div
className="py-3"
>
<FormattedMessage
defaultMessage="Group feedback will appear when a student selects a specific set of answers."
description="label for group feedback input"
id="authoring.problemeditor.settings.GroupFeedbackInputLabel"
/>
</div>
<Button
className="m-0 p-0 font-weight-bold"
onClick={[MockFunction groupFeedbacksCardHooks.handleAdd]}
size="sm"
text={null}
variant="add"
>
<FormattedMessage
defaultMessage="Add group feedback"
description="addGroupFeedbackButtonText"
id="authoring.problemeditor.settings.addGroupFeedbackButtonText"
/>
</Button>
</SettingsOption>
`;
exports[`HintsCard snapshot snapshot: renders groupFeedbacks setting card one groupFeedback 1`] = `
<SettingsOption
className=""
extraSections={Array []}
none={false}
summary="groupFeedback1"
title="Group Feedback"
>
<div
className="py-3"
>
<FormattedMessage
defaultMessage="Group feedback will appear when a student selects a specific set of answers."
description="label for group feedback input"
id="authoring.problemeditor.settings.GroupFeedbackInputLabel"
/>
</div>
<injectIntl(ShimmedIntlComponent)
answers={
Array [
"A",
"B",
"C",
]
}
id={1}
key="1"
props="propsValue"
value={
Object {
"answers": Array [
"A",
"C",
],
"feedback": "sOmE FeEDBACK",
"id": 1,
"value": "groupFeedback1",
}
}
/>
<Button
className="m-0 p-0 font-weight-bold"
onClick={[MockFunction groupFeedbacksCardHooks.handleAdd]}
size="sm"
text={null}
variant="add"
>
<FormattedMessage
defaultMessage="Add group feedback"
description="addGroupFeedbackButtonText"
id="authoring.problemeditor.settings.addGroupFeedbackButtonText"
/>
</Button>
</SettingsOption>
`;

View File

@@ -0,0 +1,95 @@
import { useState, useEffect } from 'react';
import _ from 'lodash-es';
import messages from './messages';
import * as module from './hooks';
export const state = {
summary: (val) => useState(val),
};
export const groupFeedbackCardHooks = (groupFeedbacks, updateSettings, answerslist) => {
const [summary, setSummary] = module.state.summary({ message: messages.noGroupFeedbackSummary, values: {} });
useEffect(() => {
if (groupFeedbacks.length === 0) {
setSummary({ message: messages.noGroupFeedbackSummary, values: {} });
} else {
const feedbacksInList = groupFeedbacks.map(({ answers, feedback }) => {
const answerIDs = answerslist.map((a) => a.id);
const answersString = answers.filter((value) => answerIDs.includes(value));
return `${answersString} ${feedback}\n`;
});
setSummary({
message: messages.groupFeedbackSummary,
values: { groupFeedback: feedbacksInList },
});
}
}, [groupFeedbacks, answerslist]);
const handleAdd = () => {
let newId = 0;
if (!_.isEmpty(groupFeedbacks)) {
newId = Math.max(...groupFeedbacks.map(feedback => feedback.id)) + 1;
}
const groupFeedback = { id: newId, answers: [], feedback: '' };
const modifiedGroupFeedbacks = [...groupFeedbacks, groupFeedback];
updateSettings({ groupFeedbackList: modifiedGroupFeedbacks });
};
return {
summary,
handleAdd,
};
};
export const groupFeedbackRowHooks = ({ id, groupFeedbacks, updateSettings }) => {
// Hooks for the answers associated with a groupfeedback
const addSelectedAnswer = ({ value }) => {
const oldGroupFeedback = groupFeedbacks.find(x => x.id === id);
const newAnswers = [...oldGroupFeedback.answers, value];
const newFeedback = { ...oldGroupFeedback, answers: newAnswers };
const remainingFeedbacks = groupFeedbacks.filter((item) => (item.id !== id));
const updatedFeedbackList = [newFeedback, ...remainingFeedbacks].sort((a, b) => a.id - b.id);
updateSettings({ groupFeedbackList: updatedFeedbackList });
};
const removedSelectedAnswer = ({ value }) => {
const oldGroupFeedback = groupFeedbacks.find(x => x.id === id);
const newAnswers = oldGroupFeedback.answers.filter(item => item !== value);
const newFeedback = { ...oldGroupFeedback, answers: newAnswers };
const remainingFeedbacks = groupFeedbacks.filter((item) => (item.id !== id));
const updatedFeedbackList = [newFeedback, ...remainingFeedbacks].sort((a, b) => a.id - b.id);
updateSettings({ groupFeedbackList: updatedFeedbackList });
};
const handleAnswersSelectedChange = (event) => {
const { checked, value } = event.target;
if (checked) {
addSelectedAnswer({ value });
} else {
removedSelectedAnswer({ value });
}
};
// Delete Button
const handleDelete = () => {
const modifiedGroupFeedbacks = groupFeedbacks.filter((item) => (item.id !== id));
updateSettings({ groupFeedbackList: modifiedGroupFeedbacks });
};
// Hooks for the feedback associated with a groupfeedback
const handleFeedbackChange = (event) => {
const { value } = event.target;
const modifiedGroupFeedback = groupFeedbacks.map(groupFeedback => {
if (groupFeedback.id === id) {
return { ...groupFeedback, feedback: value };
}
return groupFeedback;
});
updateSettings({ groupFeedbackList: modifiedGroupFeedback });
};
return {
handleAnswersSelectedChange, handleFeedbackChange, handleDelete,
};
};

View File

@@ -0,0 +1,92 @@
import { useEffect } from 'react';
import { MockUseState } from '../../../../../../../../testUtils';
import messages from './messages';
import * as hooks from './hooks';
jest.mock('react', () => {
const updateState = jest.fn();
return {
updateState,
useEffect: jest.fn(),
useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])),
};
});
const state = new MockUseState(hooks);
describe('groupFeedbackCardHooks', () => {
let output;
let updateSettings;
let groupFeedbacks;
beforeEach(() => {
updateSettings = jest.fn();
groupFeedbacks = [];
state.mock();
});
afterEach(() => {
state.restore();
useEffect.mockClear();
});
describe('Show advanced settings', () => {
beforeEach(() => {
output = hooks.groupFeedbackCardHooks(groupFeedbacks, updateSettings);
});
test('test default state is false', () => {
expect(output.summary.message).toEqual(messages.noGroupFeedbackSummary);
});
test('test Event adds a new feedback ', () => {
output.handleAdd();
expect(updateSettings).toHaveBeenCalledWith({ groupFeedbackList: [{ id: 0, answers: [], feedback: '' }] });
});
});
});
describe('groupFeedbackRowHooks', () => {
const mockId = 'iD';
const mockAnswer = 'moCkAnsweR';
const mockFeedback = 'mOckFEEdback';
let groupFeedbacks;
let output;
let updateSettings;
beforeEach(() => {
updateSettings = jest.fn();
groupFeedbacks = [{ id: mockId, answers: [mockAnswer], feedback: mockFeedback }];
state.mock();
});
afterEach(() => {
state.restore();
useEffect.mockClear();
});
describe('Show advanced settings', () => {
beforeEach(() => {
output = hooks.groupFeedbackRowHooks({ id: mockId, groupFeedbacks, updateSettings });
});
test('test associate an answer with the feedback object', () => {
const mockNewAnswer = 'nEw VAluE';
output.handleAnswersSelectedChange({ target: { checked: true, value: mockNewAnswer } });
expect(updateSettings).toHaveBeenCalledWith(
{ groupFeedbackList: [{ id: mockId, answers: [mockAnswer, mockNewAnswer], feedback: mockFeedback }] },
);
});
test('test unassociate an answer with the feedback object', () => {
output.handleAnswersSelectedChange({ target: { checked: false, value: mockAnswer } });
expect(updateSettings).toHaveBeenCalledWith(
{ groupFeedbackList: [{ id: mockId, answers: [], feedback: mockFeedback }] },
);
});
test('test update feedback text with a groupfeedback', () => {
const mockNewFeedback = 'nEw fEedBack';
output.handleFeedbackChange({ target: { checked: false, value: mockNewFeedback } });
expect(updateSettings).toHaveBeenCalledWith(
{ groupFeedbackList: [{ id: mockId, answers: [mockAnswer], feedback: mockNewFeedback }] },
);
});
test('Delete a Row from the list of feedbacks', () => {
output.handleDelete();
expect(updateSettings).toHaveBeenCalledWith(
{ groupFeedbackList: [] },
);
});
});
});

View File

@@ -0,0 +1,65 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
import SettingsOption from '../../SettingsOption';
import messages from './messages';
import { groupFeedbackCardHooks, groupFeedbackRowHooks } from './hooks';
import GroupFeedbackRow from './GroupFeedbackRow';
import Button from '../../../../../../../sharedComponents/Button';
export const GroupFeedbackCard = ({
groupFeedbacks,
updateSettings,
answers,
// inject
intl,
}) => {
const { summary, handleAdd } = groupFeedbackCardHooks(groupFeedbacks, updateSettings, answers);
return (
<SettingsOption
title={intl.formatMessage(messages.groupFeedbackSettingTitle)}
summary={intl.formatMessage(summary.message, { ...summary.values })}
none={!groupFeedbacks.length}
>
<div className="py-3">
<FormattedMessage {...messages.groupFeedbackInputLabel} />
</div>
{groupFeedbacks.map((groupFeedback) => (
<GroupFeedbackRow
key={groupFeedback.id}
id={groupFeedback.id}
value={groupFeedback}
answers={answers}
{...groupFeedbackRowHooks({ id: groupFeedback.id, groupFeedbacks, updateSettings })}
/>
))}
<Button
className="m-0 p-0 font-weight-bold"
variant="add"
onClick={handleAdd}
size="sm"
>
<FormattedMessage {...messages.addGroupFeedbackButtonText} />
</Button>
</SettingsOption>
);
};
GroupFeedbackCard.propTypes = {
intl: intlShape.isRequired,
groupFeedbacks: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.number.isRequired,
feedback: PropTypes.string.isRequired,
answers: PropTypes.arrayOf(PropTypes.string).isRequired,
})).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,
};
export default injectIntl(GroupFeedbackCard);

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from '../../../../../../../../testUtils';
import { GroupFeedbackCard } from './index';
import { groupFeedbackRowHooks, groupFeedbackCardHooks } from './hooks';
import messages from './messages';
jest.mock('./hooks', () => ({
groupFeedbackCardHooks: jest.fn(),
groupFeedbackRowHooks: jest.fn(),
}));
describe('HintsCard', () => {
const answers = ['A', 'B', 'C'];
const groupFeedback1 = {
id: 1, value: 'groupFeedback1', answers: ['A', 'C'], feedback: 'sOmE FeEDBACK',
};
const groupFeedback2 = {
id: 2, value: '', answers: ['A'], feedback: 'sOmE FeEDBACK oTher FeEdback',
};
const groupFeedbacks0 = [];
const groupFeedbacks1 = [groupFeedback1];
const groupFeedbacks2 = [groupFeedback1, groupFeedback2];
const props = {
intl: { formatMessage },
groupFeedbacks: groupFeedbacks0,
updateSettings: jest.fn().mockName('args.updateSettings'),
answers,
};
const groupFeedbacksRowHooksProps = { props: 'propsValue' };
groupFeedbackRowHooks.mockReturnValue(groupFeedbacksRowHooksProps);
describe('behavior', () => {
it(' calls groupFeedbacksCardHooks when initialized', () => {
const groupFeedbacksCardHooksProps = {
summary: { message: messages.noGroupFeedbackSummary },
handleAdd: jest.fn().mockName('groupFeedbacksCardHooks.handleAdd'),
};
groupFeedbackCardHooks.mockReturnValue(groupFeedbacksCardHooksProps);
shallow(<GroupFeedbackCard {...props} />);
expect(groupFeedbackCardHooks).toHaveBeenCalledWith(groupFeedbacks0, props.updateSettings, answers);
});
});
describe('snapshot', () => {
test('snapshot: renders groupFeedbacks setting card no groupFeedbacks', () => {
const groupFeedbacksCardHooksProps = {
summary: { message: messages.noGroupFeedbackSummary, values: {} },
handleAdd: jest.fn().mockName('groupFeedbacksCardHooks.handleAdd'),
};
groupFeedbackCardHooks.mockReturnValue(groupFeedbacksCardHooksProps);
expect(shallow(<GroupFeedbackCard {...props} />)).toMatchSnapshot();
});
test('snapshot: renders groupFeedbacks setting card one groupFeedback', () => {
const groupFeedbacksCardHooksProps = {
summary: {
message: messages.groupFeedbackSummary,
values: { groupFeedback: groupFeedback1.value, count: 1 },
},
handleAdd: jest.fn().mockName('groupFeedbacksCardHooks.handleAdd'),
};
groupFeedbackCardHooks.mockReturnValue(groupFeedbacksCardHooksProps);
expect(shallow(<GroupFeedbackCard {...props} groupFeedbacks={groupFeedbacks1} />)).toMatchSnapshot();
});
test('snapshot: renders groupFeedbacks setting card multiple groupFeedbacks', () => {
const groupFeedbacksCardHooksProps = {
summary: {
message: messages.groupFeedbackSummary,
values: { groupFeedback: groupFeedback2.value, count: 2 },
},
handleAdd: jest.fn().mockName('groupFeedbacksCardHooks.handleAdd'),
};
groupFeedbackCardHooks.mockReturnValue(groupFeedbacksCardHooksProps);
expect(shallow(<GroupFeedbackCard {...props} groupFeedbacks={groupFeedbacks2} />)).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,28 @@
export const messages = {
noGroupFeedbackSummary: {
id: 'authoring.problemeditor.settings.GroupFeedbackSummary.message',
defaultMessage: 'None',
description: 'message to confirm that a user wants to use the advanced editor',
},
groupFeedbackSummary: {
id: 'authoring.problemeditor.settings.GroupFeedbackSummary.message',
defaultMessage: '{groupFeedback}',
description: 'summary of current feedbacks provided for multiple problems',
},
addGroupFeedbackButtonText: {
id: 'authoring.problemeditor.settings.addGroupFeedbackButtonText',
defaultMessage: 'Add group feedback',
description: 'addGroupFeedbackButtonText',
},
groupFeedbackInputLabel: {
id: 'authoring.problemeditor.settings.GroupFeedbackInputLabel',
defaultMessage: 'Group feedback will appear when a student selects a specific set of answers.',
description: 'label for group feedback input',
},
groupFeedbackSettingTitle: {
id: 'authoring.problemeditor.settings.GroupFeedbackSettingTitle',
defaultMessage: 'Group Feedback',
description: 'label for group feedback setting',
},
};
export default messages;

View File

@@ -0,0 +1,55 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RandomizationCard snapshot snapshot: renders reset true setting card 1`] = `
<SettingsOption
className=""
extraSections={Array []}
none={false}
summary="sUmmary"
title="Randomization"
>
<div
className="halfSpacedMessage"
>
<span>
<FormattedMessage
defaultMessage="Defines when to randomize the variables specified in the associated Python script. For problems that do not randomize values, specify \\"Never\\"."
description="Description of Possibilities for value in Randomization widget"
id="authoring.problemeditor.settings.randomization.SettingText"
/>
</span>
</div>
<Form.Group>
<Form.Control
as="select"
onChange={[MockFunction randomizationCardHooks.handleChange]}
value="sOmE_vAlUE"
>
<option
key="always"
value="always"
>
Always
</option>
<option
key="never"
value="never"
>
Never
</option>
<option
key="on_reset"
value="on_reset"
>
On Reset
</option>
<option
key="per_student"
value="per_student"
>
Per Student
</option>
</Form.Control>
</Form.Group>
</SettingsOption>
`;

View File

@@ -0,0 +1,24 @@
import { useState, useEffect } from 'react';
import messages from './messages';
import { RandomizationTypes } from '../../../../../../../data/constants/problem';
import * as module from './hooks';
export const state = {
summary: (val) => useState(val),
};
export const useRandomizationSettingStatus = ({ randomization, updateSettings }) => {
const [summary, setSummary] = module.state.summary({ message: messages.noRandomizationSummary, values: {} });
useEffect(() => {
setSummary({
message: randomization ? RandomizationTypes[randomization] : messages.noRandomizationSummary,
});
}, [randomization]);
const handleChange = (event) => {
updateSettings({ randomization: event.target.value });
};
return { summary, handleChange };
};
export default useRandomizationSettingStatus;

View File

@@ -0,0 +1,43 @@
import { useEffect } from 'react';
import { MockUseState } from '../../../../../../../../testUtils';
import messages from './messages';
import * as hooks from './hooks';
jest.mock('react', () => {
const updateState = jest.fn();
return {
updateState,
useEffect: jest.fn(),
useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])),
};
});
const state = new MockUseState(hooks);
describe('Problem settings hooks', () => {
let output;
let updateSettings;
let randomization;
beforeEach(() => {
updateSettings = jest.fn();
randomization = 'sOmE_vAlUe';
state.mock();
});
afterEach(() => {
state.restore();
useEffect.mockClear();
});
describe('Show advanced settings', () => {
beforeEach(() => {
output = hooks.useRandomizationSettingStatus({ randomization, updateSettings });
});
test('test default state is false', () => {
expect(output.summary).toEqual({ message: messages.noRandomizationSummary, values: {} });
});
test('test showAdvancedCards sets state to true', () => {
const mockEvent = { target: { value: 'sOmE_otheR_ValUe' } };
output.handleChange(mockEvent);
expect(updateSettings).toHaveBeenCalledWith({ randomization: mockEvent.target.value });
});
});
});

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
import { Form } from '@edx/paragon';
import PropTypes from 'prop-types';
import SettingsOption from '../../SettingsOption';
import messages from './messages';
import { useRandomizationSettingStatus } from './hooks';
import { RandomizationTypesKeys, RandomizationTypes } from '../../../../../../../data/constants/problem';
export const RandomizationCard = ({
randomization,
updateSettings,
// inject
intl,
}) => {
const { summary, handleChange } = useRandomizationSettingStatus({ randomization, updateSettings });
return (
<SettingsOption
title={intl.formatMessage(messages.randomizationSettingTitle)}
summary={intl.formatMessage(summary.message)}
none={!randomization}
>
<div className="halfSpacedMessage">
<span>
<FormattedMessage {...messages.randomizationSettingText} />
</span>
</div>
<Form.Group>
<Form.Control
as="select"
value={randomization}
onChange={handleChange}
>
{Object.values(RandomizationTypesKeys).map((randomizationType) => (
<option
key={randomizationType}
value={randomizationType}
>
{intl.formatMessage(RandomizationTypes[randomizationType])}
</option>
))}
</Form.Control>
</Form.Group>
</SettingsOption>
);
};
RandomizationCard.propTypes = {
randomization: PropTypes.string.isRequired,
updateSettings: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(RandomizationCard);

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from '../../../../../../../../testUtils';
import { RandomizationCard } from './index';
import { useRandomizationSettingStatus } from './hooks';
jest.mock('./hooks', () => ({
useRandomizationSettingStatus: jest.fn(),
}));
describe('RandomizationCard', () => {
const props = {
randomization: 'sOmE_vAlUE',
updateSettings: jest.fn().mockName('args.updateSettings'),
intl: { formatMessage },
};
const randomizationCardHooksProps = {
summary: { message: { defaultMessage: 'sUmmary' } },
handleChange: jest.fn().mockName('randomizationCardHooks.handleChange'),
};
useRandomizationSettingStatus.mockReturnValue(randomizationCardHooksProps);
describe('behavior', () => {
it(' calls useRandomizationSettingStatus when initialized', () => {
shallow(<RandomizationCard {...props} />);
expect(useRandomizationSettingStatus).toHaveBeenCalledWith(
{ updateSettings: props.updateSettings, randomization: props.randomization },
);
});
});
describe('snapshot', () => {
test('snapshot: renders reset true setting card', () => {
expect(shallow(<RandomizationCard {...props} />)).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,18 @@
export const messages = {
randomizationSettingTitle: {
id: 'authoring.problemeditor.settings.randomization.SettingTitle',
defaultMessage: 'Randomization',
description: 'Settings Title for Randomization widget',
},
randomizationSettingText: {
id: 'authoring.problemeditor.settings.randomization.SettingText',
defaultMessage: 'Defines when to randomize the variables specified in the associated Python script. For problems that do not randomize values, specify "Never".',
description: 'Description of Possibilities for value in Randomization widget',
},
noRandomizationSummary: {
id: 'authoring.problemeditor.settings.randomization.noRandomizationSummary',
defaultMessage: 'No Python based randomization is present in this problem.',
description: 'text shown when no randomization option is given',
},
};
export default messages;

View File

@@ -373,6 +373,24 @@ export class OLXParser {
return problemType;
}
getGeneralFeedback({ answers, problemType }) {
/* Feedback is Generalized for a Problem IFF:
1. The problem is of Types: Single Select or Dropdown.
2. All the problem's incorrect, if Selected answers are equivalent strings, and there is no other feedback.
*/
if (problemType === ProblemTypeKeys.SINGLESELECT || problemType === ProblemTypeKeys.DROPDOWN) {
const firstIncorrectAnswerText = answers.find(answer => answer.correct === false).selectedFeedback;
const isAllIncorrectSelectedFeedbackTheSame = answers.every(answer => (answer.correct
? true
: answer?.selectedFeedback === firstIncorrectAnswerText
));
if (isAllIncorrectSelectedFeedbackTheSame) {
return firstIncorrectAnswerText;
}
}
return '';
}
getParsedOLXData() {
if (_.isEmpty(this.problem)) {
return {};
@@ -410,7 +428,7 @@ export class OLXParser {
// if problem is unset, return null
return {};
}
const generalFeedback = this.getGeneralFeedback({ answers: answersObject.answers, problemType });
if (_.has(answersObject, 'additionalStringAttributes')) {
additionalAttributes = { ...answersObject.additionalStringAttributes };
}
@@ -428,6 +446,7 @@ export class OLXParser {
answers,
problemType,
additionalAttributes,
generalFeedback,
groupFeedbackList,
};
}

View File

@@ -65,7 +65,21 @@ class ReactStateOLXParser {
const choice = [];
let compoundhint = [];
let widget = {};
const { answers } = this.problemState;
// eslint-disable-next-line prefer-const
let { answers, generalFeedback } = this.problemState;
// general feedback replaces selected feedback if all incorrect selected feedback is the same.
if (generalFeedback !== ''
&& answers.every(
answer => (
answer.correct
? true
: answer?.selectedFeedback === answers.find(a => a.correct === false).selectedFeedback
),
)) {
answers = answers.map(answer => (!answer?.correct
? { ...answer, selectedFeedback: generalFeedback }
: answer));
}
answers.forEach((answer) => {
const feedback = [];
let singleAnswer = {};

View File

@@ -16,6 +16,7 @@ class ReactStateSettingsParser {
settings = popuplateItem(settings, 'afterAttempts', 'attempts_before_showanswer_button', stateSettings.showAnswer);
settings = popuplateItem(settings, 'showResetButton', 'show_reset_button', stateSettings);
settings = popuplateItem(settings, 'timeBetween', 'submission_wait_seconds', stateSettings);
settings = popuplateItem(settings, 'randomization', 'rerandomize', stateSettings);
return settings;
}

View File

@@ -1,6 +1,6 @@
import _ from 'lodash-es';
import { ShowAnswerTypes } from '../../../data/constants/problem';
import { ShowAnswerTypes, RandomizationTypesKeys } from '../../../data/constants/problem';
export const popuplateItem = (parentObject, itemName, statekey, metadata) => {
let parent = parentObject;
@@ -57,8 +57,13 @@ export const parseSettings = (metadata) => {
if (!_.isEmpty(showAnswer)) {
settings = { ...settings, showAnswer };
}
const randomizationType = _.get(metadata, 'rerandomize', {});
if (!_.isEmpty(randomizationType) && Object.values(RandomizationTypesKeys).includes(randomizationType)) {
settings = popuplateItem(settings, 'rerandomize', 'randomization', metadata);
}
settings = popuplateItem(settings, 'show_reset_button', 'showResetButton', metadata);
settings = popuplateItem(settings, 'submission_wait_seconds', 'timeBetween', metadata);
return settings;
};

View File

@@ -184,3 +184,30 @@ export const ShowAnswerTypes = StrictDict({
defaultMessage: 'Attempted',
},
});
export const RandomizationTypesKeys = StrictDict({
ALWAYS: 'always',
NEVER: 'never',
ONRESET: 'on_reset',
PERSTUDENT: 'per_student',
});
export const RandomizationTypes = StrictDict({
[RandomizationTypesKeys.ALWAYS]:
{
id: 'authoring.problemeditor.settings.RandomizationTypes.always',
defaultMessage: 'Always',
},
[RandomizationTypesKeys.NEVER]:
{
id: 'authoring.problemeditor.settings.RandomizationTypes.never',
defaultMessage: 'Never',
},
[RandomizationTypesKeys.ONRESET]: {
id: 'authoring.problemeditor.settings.RandomizationTypes.onreset',
defaultMessage: 'On Reset',
},
[RandomizationTypesKeys.PERSTUDENT]: {
id: 'authoring.problemeditor.settings.RandomizationTypes.perstudent',
defaultMessage: 'Per Student',
},
});

View File

@@ -12,8 +12,10 @@ const initialState = {
answers: [],
correctAnswerCount: 0,
groupFeedbackList: [],
generalFeedback: '',
additionalAttributes: {},
settings: {
randomization: null,
scoring: {
weight: 0,
attempts: {

View File

@@ -5,6 +5,8 @@ export const problemState = (state) => state.problem;
const mkSimpleSelector = (cb) => createSelector([module.problemState], cb);
export const simpleSelectors = {
problemType: mkSimpleSelector(problemData => problemData.problemType),
generalFeedback: mkSimpleSelector(problemData => problemData.generalFeedback),
groupFeedbackList: mkSimpleSelector(problemData => problemData.groupFeedbackList),
answers: mkSimpleSelector(problemData => problemData.answers),
correctAnswerCount: mkSimpleSelector(problemData => problemData.correctAnswerCount),
settings: mkSimpleSelector(problemData => problemData.settings),