feat: add default advanced setting ui callouts (#285)

This commit is contained in:
Kristin Aoki
2023-03-23 17:37:13 -04:00
committed by GitHub
parent 4410f0a544
commit 7ef1963327
28 changed files with 511 additions and 197 deletions

View File

@@ -1,17 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ProblemEditor snapshots assets loaded, block and studio view not yet loaded, Spinner appears 1`] = `
<div
className="text-center p-6"
>
<Spinner
animation="border"
className="m-3"
screenreadertext="Loading Problem Editor"
/>
</div>
`;
exports[`ProblemEditor snapshots block failed, message appears 1`] = `
<div
className="text-center p-6"
@@ -24,36 +12,6 @@ exports[`ProblemEditor snapshots block failed, message appears 1`] = `
</div>
`;
exports[`ProblemEditor snapshots block loaded, studio view and assets not yet loaded, Spinner appears 1`] = `
<div
className="text-center p-6"
>
<Spinner
animation="border"
className="m-3"
screenreadertext="Loading Problem Editor"
/>
</div>
`;
exports[`ProblemEditor snapshots renders EditProblemView 1`] = `
<div
className="text-center p-6"
>
<FormattedMessage
defaultMessage="Problem failed to load"
description="Error message for problem block failing to load"
id="authoring.problemEditor.blockFailed"
/>
</div>
`;
exports[`ProblemEditor snapshots renders SelectTypeModal 1`] = `
<SelectTypeModal
onClose={[MockFunction props.onClose]}
/>
`;
exports[`ProblemEditor snapshots renders as expected with default behavior 1`] = `
<div
className="text-center p-6"
@@ -65,15 +23,3 @@ exports[`ProblemEditor snapshots renders as expected with default behavior 1`] =
/>
</div>
`;
exports[`ProblemEditor snapshots studio view loaded, block and assets not yet loaded, Spinner appears 1`] = `
<div
className="text-center p-6"
>
<Spinner
animation="border"
className="m-3"
screenreadertext="Loading Problem Editor"
/>
</div>
`;

View File

@@ -14,7 +14,9 @@ exports[`SettingsWidget snapshot snapshot: renders Settings widget for Advanced
<div
className="my-3"
>
<ScoringCard />
<ScoringCard
defaultValue={2}
/>
</div>
<div
className="mt-3"
@@ -53,7 +55,9 @@ exports[`SettingsWidget snapshot snapshot: renders Settings widget for Advanced
<div
className="my-3"
>
<ShowAnswerCard />
<ShowAnswerCard
defaultValue="finished"
/>
</div>
<div
className="my-3"
@@ -96,7 +100,9 @@ exports[`SettingsWidget snapshot snapshot: renders Settings widget page 1`] = `
<div
className="my-3"
>
<ScoringCard />
<ScoringCard
defaultValue={2}
/>
</div>
<div
className="mt-3"
@@ -135,7 +141,9 @@ exports[`SettingsWidget snapshot snapshot: renders Settings widget page 1`] = `
<div
className="my-3"
>
<ShowAnswerCard />
<ShowAnswerCard
defaultValue="finished"
/>
</div>
<div
className="my-3"
@@ -178,7 +186,9 @@ exports[`SettingsWidget snapshot snapshot: renders Settings widget page advanced
<div
className="my-3"
>
<ScoringCard />
<ScoringCard
defaultValue={2}
/>
</div>
<div
className="mt-3"
@@ -217,7 +227,9 @@ exports[`SettingsWidget snapshot snapshot: renders Settings widget page advanced
<div
className="my-3"
>
<ShowAnswerCard />
<ShowAnswerCard
defaultValue="finished"
/>
</div>
<div
className="my-3"

View File

@@ -11,6 +11,7 @@ export const state = {
cardCollapsed: (val) => useState(val),
summary: (val) => useState(val),
showAttempts: (val) => useState(val),
attemptDisplayValue: (val) => useState(val),
};
export const showAdvancedSettingsCards = () => {
@@ -117,19 +118,52 @@ export const resetCardHooks = (updateSettings) => {
};
};
export const scoringCardHooks = (scoring, updateSettings) => {
export const scoringCardHooks = (scoring, updateSettings, defaultValue) => {
const loadedAttemptsNumber = scoring.attempts.number === defaultValue ? `${scoring.attempts.number} (Default)` : scoring.attempts.number;
const [attemptDisplayValue, setAttemptDisplayValue] = module.state.attemptDisplayValue(loadedAttemptsNumber);
const handleUnlimitedChange = (event) => {
const isUnlimited = event.target.checked;
if (isUnlimited) {
setAttemptDisplayValue('');
updateSettings({ scoring: { ...scoring, attempts: { number: '', unlimited: true } } });
} else {
setAttemptDisplayValue(`${defaultValue} (Default)`);
updateSettings({ scoring: { ...scoring, attempts: { number: defaultValue, unlimited: false } } });
}
};
const handleMaxAttemptChange = (event) => {
let unlimitedAttempts = false;
let attemptNumber = parseInt(event.target.value);
const { value } = event.target;
if (_.isNaN(attemptNumber)) {
attemptNumber = '';
unlimitedAttempts = true;
} else if (attemptNumber < 0) {
if (value === '') {
attemptNumber = defaultValue;
setAttemptDisplayValue(`${defaultValue} (Default)`);
} else {
attemptNumber = '';
unlimitedAttempts = true;
}
} else if (attemptNumber <= 0) {
attemptNumber = 0;
} else if (attemptNumber === defaultValue) {
const attemptNumberStr = value.replace(' (Default)');
attemptNumber = parseInt(attemptNumberStr);
}
updateSettings({ scoring: { ...scoring, attempts: { number: attemptNumber, unlimited: unlimitedAttempts } } });
};
const handleOnChange = (event) => {
let newMaxAttempt = parseInt(event.target.value);
if (newMaxAttempt === defaultValue) {
newMaxAttempt = `${defaultValue} (Default)`;
} else if (_.isNaN(newMaxAttempt)) {
newMaxAttempt = '';
} else if (newMaxAttempt < 0) {
newMaxAttempt = 0;
}
setAttemptDisplayValue(newMaxAttempt);
};
const handleWeightChange = (event) => {
let weight = parseFloat(event.target.value);
if (_.isNaN(weight)) {
@@ -139,7 +173,10 @@ export const scoringCardHooks = (scoring, updateSettings) => {
};
return {
attemptDisplayValue,
handleUnlimitedChange,
handleMaxAttemptChange,
handleOnChange,
handleWeightChange,
};
};

View File

@@ -172,8 +172,21 @@ describe('Problem settings hooks', () => {
number: 5,
},
};
const defaultValue = 1;
beforeEach(() => {
output = hooks.scoringCardHooks(scoring, updateSettings);
output = hooks.scoringCardHooks(scoring, updateSettings, defaultValue);
});
test('test handleUnlimitedChange sets attempts.unlimited to true when checked', () => {
output.handleUnlimitedChange({ target: { checked: true } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith('');
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: '', unlimited: true } } });
});
test('test handleUnlimitedChange sets attempts.unlimited to false when unchecked', () => {
output.handleUnlimitedChange({ target: { checked: false } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(`${defaultValue} (Default)`);
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: defaultValue, unlimited: false } } });
});
test('test handleMaxAttemptChange', () => {
const value = 6;
@@ -193,11 +206,11 @@ describe('Problem settings hooks', () => {
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: '', unlimited: true } } });
});
test('test handleMaxAttemptChange set attempts to empty string', () => {
const value = '';
test('test handleMaxAttemptChange set attempts to default value', () => {
const value = '1 (Default)';
output.handleMaxAttemptChange({ target: { value } });
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: '', unlimited: true } } });
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: 1, unlimited: false } } });
});
test('test handleMaxAttemptChange set attempts to non-numeric value', () => {
const value = 'abc';
@@ -205,12 +218,49 @@ describe('Problem settings hooks', () => {
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: '', unlimited: true } } });
});
test('test handleMaxAttemptChange set attempts to empty value', () => {
const value = '';
output.handleMaxAttemptChange({ target: { value } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(`${defaultValue} (Default)`);
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: 1, unlimited: false } } });
});
test('test handleMaxAttemptChange set attempts to negative value', () => {
const value = -1;
output.handleMaxAttemptChange({ target: { value } });
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: 0, unlimited: false } } });
});
test('test handleOnChange', () => {
const value = 6;
output.handleOnChange({ target: { value } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(value);
});
test('test handleOnChange set attempts to zero', () => {
const value = 0;
output.handleOnChange({ target: { value } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(value);
});
test('test handleOnChange set attempts to default value from empty string', () => {
const value = '';
output.handleOnChange({ target: { value } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith('');
});
test('test handleOnChange set attempts to default value', () => {
const value = 1;
output.handleOnChange({ target: { value } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith('1 (Default)');
});
test('test handleOnChange set attempts to non-numeric value', () => {
const value = '';
output.handleOnChange({ target: { value } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(value);
});
test('test handleOnChange set attempts to negative value', () => {
const value = -1;
output.handleOnChange({ target: { value } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(0);
});
test('test handleWeightChange', () => {
const value = 2;
output.handleWeightChange({ target: { value } });

View File

@@ -36,6 +36,7 @@ export const SettingsWidget = ({
updateSettings,
updateField,
updateAnswer,
defaultSettings,
}) => {
const { isAdvancedCardsVisible, showAdvancedCards } = showAdvancedSettingsCards();
@@ -77,7 +78,11 @@ export const SettingsWidget = ({
</div>
)}
<div className="my-3">
<ScoringCard scoring={settings.scoring} updateSettings={updateSettings} />
<ScoringCard
scoring={settings.scoring}
defaultValue={defaultSettings.maxAttempts}
updateSettings={updateSettings}
/>
</div>
<div className="mt-3">
<HintsCard problemType={problemType} hints={settings.hints} updateSettings={updateSettings} />
@@ -103,6 +108,7 @@ export const SettingsWidget = ({
<div className="my-3">
<ShowAnswerCard
showAnswer={settings.showAnswer}
defaultValue={defaultSettings.showanswer}
updateSettings={updateSettings}
/>
</div>
@@ -159,6 +165,11 @@ SettingsWidget.propTypes = {
updateAnswer: PropTypes.func.isRequired,
updateField: PropTypes.func.isRequired,
updateSettings: PropTypes.func.isRequired,
defaultSettings: PropTypes.shape({
maxAttempts: PropTypes.number,
showanswer: PropTypes.string,
showReseButton: PropTypes.bool,
}).isRequired,
// eslint-disable-next-line
settings: PropTypes.any.isRequired,
};
@@ -169,6 +180,7 @@ const mapStateToProps = (state) => ({
answers: selectors.problem.answers(state),
blockTitle: selectors.app.blockTitle(state),
correctAnswerCount: selectors.problem.correctAnswerCount(state),
defaultSettings: selectors.problem.defaultSettings(state),
});
export const mapDispatchToProps = {

View File

@@ -25,6 +25,11 @@ describe('SettingsWidget', () => {
const props = {
problemType: ProblemTypeKeys.TEXTINPUT,
settings: {},
defaultSettings: {
maxAttempts: 2,
showanswer: 'finished',
showResetButton: false,
},
};
describe('behavior', () => {

View File

@@ -114,6 +114,11 @@ export const messages = {
defaultMessage: '{attempts, plural, =1 {# attempt} other {# attempts}}',
description: 'Summary text for number of attempts',
},
unlimitedAttemptsCheckboxLabel: {
id: 'authoring.problemeditor.settings.scoring.attempts.unlimitedCheckbox',
defaultMessage: 'Unlimited attempts',
description: 'Label for unlimited attempts checkbox',
},
weightSummary: {
id: 'authoring.problemeditor.settings.scoring.weight',
defaultMessage: '{weight, plural, =0 {Ungraded} other {# points}}',

View File

@@ -15,7 +15,7 @@ export const ResetCard = ({
intl,
}) => {
const { setResetTrue, setResetFalse } = resetCardHooks(updateSettings);
const advancedSettingsLink = `${useSelector(selectors.app.studioEndpointUrl)}/settings/advanced/${useSelector(selectors.app.learningContextId)}`;
const advancedSettingsLink = `${useSelector(selectors.app.studioEndpointUrl)}/settings/advanced/${useSelector(selectors.app.learningContextId)}#show_reset_button`;
return (
<SettingsOption
title={intl.formatMessage(messages.resetSettingsTitle)}

View File

@@ -1,47 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form } from '@edx/paragon';
import { Form, Hyperlink } from '@edx/paragon';
import { selectors } from '../../../../../../data/redux';
import SettingsOption from '../SettingsOption';
import messages from '../messages';
import { scoringCardHooks } from '../hooks';
export const ScoringCard = ({
scoring,
defaultValue,
updateSettings,
// inject
intl,
// redux
studioEndpointUrl,
learningContextId,
}) => {
const { handleMaxAttemptChange, handleWeightChange } = scoringCardHooks(scoring, updateSettings);
const {
handleUnlimitedChange,
handleMaxAttemptChange,
handleWeightChange,
handleOnChange,
attemptDisplayValue,
} = scoringCardHooks(scoring, updateSettings, defaultValue);
const getScoringSummary = (attempts, unlimited, weight) => {
let summary = unlimited
const getScoringSummary = (weight, attempts, unlimited) => {
let summary = intl.formatMessage(messages.weightSummary, { weight });
summary += ` ${String.fromCharCode(183)} `;
summary += unlimited
? intl.formatMessage(messages.unlimitedAttemptsSummary)
: intl.formatMessage(messages.attemptsSummary, { attempts });
summary += ` ${String.fromCharCode(183)} `;
summary += intl.formatMessage(messages.weightSummary, { weight });
return summary;
};
return (
<SettingsOption
title={intl.formatMessage(messages.scoringSettingsTitle)}
summary={getScoringSummary(scoring.attempts.number, scoring.attempts.unlimited, scoring.weight)}
summary={getScoringSummary(scoring.weight, scoring.attempts.number, scoring.attempts.unlimited)}
className="scoringCard"
>
<Form.Label className="mb-4">
<FormattedMessage {...messages.scoringSettingsLabel} />
</Form.Label>
<Form.Group>
<Form.Control
type="number"
value={scoring.attempts.number}
onChange={handleMaxAttemptChange}
floatingLabel={intl.formatMessage(messages.scoringAttemptsInputLabel)}
/>
<Form.Control.Feedback>
<FormattedMessage {...messages.attemptsHint} />
</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Control
type="number"
@@ -53,6 +55,30 @@ export const ScoringCard = ({
<FormattedMessage {...messages.weightHint} />
</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Control
value={attemptDisplayValue}
onChange={handleOnChange}
onBlur={handleMaxAttemptChange}
floatingLabel={intl.formatMessage(messages.scoringAttemptsInputLabel)}
disabled={scoring.attempts.unlimited}
/>
<Form.Control.Feedback>
<FormattedMessage {...messages.attemptsHint} />
</Form.Control.Feedback>
<Form.Checkbox
className="mt-3 decoration-control-label"
checked={scoring.attempts.unlimited}
onChange={handleUnlimitedChange}
>
<div className="small">
<FormattedMessage {...messages.unlimitedAttemptsCheckboxLabel} />
</div>
</Form.Checkbox>
</Form.Group>
<Hyperlink destination={`${studioEndpointUrl}/settings/advanced/${learningContextId}#max_attempts`} target="_blank">
<FormattedMessage {...messages.advancedSettingsLinkText} />
</Hyperlink>
</SettingsOption>
);
};
@@ -62,6 +88,17 @@ ScoringCard.propTypes = {
// eslint-disable-next-line
scoring: PropTypes.any.isRequired,
updateSettings: PropTypes.func.isRequired,
defaultValue: PropTypes.number.isRequired,
// redux
studioEndpointUrl: PropTypes.string.isRequired,
learningContextId: PropTypes.string.isRequired,
};
export default injectIntl(ScoringCard);
export const mapStateToProps = (state) => ({
studioEndpointUrl: selectors.app.studioEndpointUrl(state),
learningContextId: selectors.app.learningContextId(state),
});
export const mapDispatchToProps = {};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ScoringCard));

View File

@@ -16,6 +16,7 @@ describe('ScoringCard', () => {
number: 5,
},
updateSettings: jest.fn().mockName('args.updateSettings'),
defaultValue: 1,
intl: { formatMessage },
};
@@ -27,6 +28,8 @@ describe('ScoringCard', () => {
const scoringCardHooksProps = {
handleMaxAttemptChange: jest.fn().mockName('scoringCardHooks.handleMaxAttemptChange'),
handleWeightChange: jest.fn().mockName('scoringCardHooks.handleWeightChange'),
handleOnChange: jest.fn().mockName('scoringCardHooks.handleOnChange'),
local: 5,
};
scoringCardHooks.mockReturnValue(scoringCardHooksProps);
@@ -34,7 +37,7 @@ describe('ScoringCard', () => {
describe('behavior', () => {
it(' calls scoringCardHooks when initialized', () => {
shallow(<ScoringCard {...props} />);
expect(scoringCardHooks).toHaveBeenCalledWith(scoring, props.updateSettings);
expect(scoringCardHooks).toHaveBeenCalledWith(scoring, props.updateSettings, props.defaultValue);
});
});

View File

@@ -12,6 +12,7 @@ import { useAnswerSettings } from '../hooks';
export const ShowAnswerCard = ({
showAnswer,
updateSettings,
defaultValue,
// inject
intl,
// redux
@@ -32,7 +33,7 @@ export const ShowAnswerCard = ({
</span>
</div>
<div className="pb-4">
<Hyperlink destination={`${studioEndpointUrl}/settings/advanced/${learningContextId}`} target="_blank">
<Hyperlink destination={`${studioEndpointUrl}/settings/advanced/${learningContextId}#showanswer`} target="_blank">
<FormattedMessage {...messages.advancedSettingsLinkText} />
</Hyperlink>
</div>
@@ -42,14 +43,20 @@ export const ShowAnswerCard = ({
value={showAnswer.on}
onChange={handleShowAnswerChange}
>
{Object.values(ShowAnswerTypesKeys).map((answerType) => (
<option
key={answerType}
value={answerType}
>
{intl.formatMessage(ShowAnswerTypes[answerType])}
</option>
))}
{Object.values(ShowAnswerTypesKeys).map((answerType) => {
let optionDisplayName = ShowAnswerTypes[answerType];
if (answerType === defaultValue) {
optionDisplayName = { ...optionDisplayName, defaultMessage: `${optionDisplayName.defaultMessage} (Default)` };
}
return (
<option
key={answerType}
value={answerType}
>
{intl.formatMessage(optionDisplayName)}
</option>
);
})}
</Form.Control>
</Form.Group>
{showAttempts
@@ -84,6 +91,7 @@ ShowAnswerCard.propTypes = {
updateSettings: PropTypes.func.isRequired,
studioEndpointUrl: PropTypes.string.isRequired,
learningContextId: PropTypes.string.isRequired,
defaultValue: PropTypes.string.isRequired,
};
ShowAnswerCard.defaultProps = {
solutionExplanation: '',

View File

@@ -27,6 +27,7 @@ describe('ShowAnswerCard', () => {
};
const props = {
showAnswer,
defaultValue: 'finished',
// injected
intl: { formatMessage },
// redux

View File

@@ -23,7 +23,7 @@ exports[`ResetCard snapshot snapshot: renders reset true setting card 1`] = `
className="spacedMessage"
>
<Hyperlink
destination="sTuDioEndpOintUrl/settings/advanced/leArningCoNteXtId"
destination="sTuDioEndpOintUrl/settings/advanced/leArningCoNteXtId#show_reset_button"
target="_blank"
>
<FormattedMessage
@@ -86,7 +86,7 @@ exports[`ResetCard snapshot snapshot: renders reset true setting card 2`] = `
className="spacedMessage"
>
<Hyperlink
destination="sTuDioEndpOintUrl/settings/advanced/leArningCoNteXtId"
destination="sTuDioEndpOintUrl/settings/advanced/leArningCoNteXtId#show_reset_button"
target="_blank"
>
<FormattedMessage

View File

@@ -2,10 +2,10 @@
exports[`ScoringCard snapshot snapshot: scoring setting card 1`] = `
<SettingsOption
className=""
className="scoringCard"
extraSections={Array []}
hasExpandableTextArea={false}
summary="{attempts, plural, =1 {# attempt} other {# attempts}} · {weight, plural, =0 {Ungraded} other {# points}}"
summary="{weight, plural, =0 {Ungraded} other {# points}} · {attempts, plural, =1 {# attempt} other {# attempts}}"
title="Scoring"
>
<Form.Label
@@ -17,21 +17,6 @@ exports[`ScoringCard snapshot snapshot: scoring setting card 1`] = `
id="authoring.problemeditor.settings.scoring.label"
/>
</Form.Label>
<Form.Group>
<Form.Control
floatingLabel="Attempts"
onChange={[MockFunction scoringCardHooks.handleMaxAttemptChange]}
type="number"
value={5}
/>
<Form.Control.Feedback>
<FormattedMessage
defaultMessage="If a value is not set, unlimited attempts are allowed"
description="Summary text for scoring weight"
id="authoring.problemeditor.settings.scoring.attempts.hint"
/>
</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Control
floatingLabel="Points"
@@ -47,15 +32,54 @@ exports[`ScoringCard snapshot snapshot: scoring setting card 1`] = `
/>
</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Control
disabled={false}
floatingLabel="Attempts"
onBlur={[MockFunction scoringCardHooks.handleMaxAttemptChange]}
onChange={[MockFunction scoringCardHooks.handleOnChange]}
/>
<Form.Control.Feedback>
<FormattedMessage
defaultMessage="If a value is not set, unlimited attempts are allowed"
description="Summary text for scoring weight"
id="authoring.problemeditor.settings.scoring.attempts.hint"
/>
</Form.Control.Feedback>
<Form.Checkbox
checked={false}
className="mt-3 decoration-control-label"
>
<div
className="small"
>
<FormattedMessage
defaultMessage="Unlimited attempts"
description="Label for unlimited attempts checkbox"
id="authoring.problemeditor.settings.scoring.attempts.unlimitedCheckbox"
/>
</div>
</Form.Checkbox>
</Form.Group>
<Hyperlink
destination="undefined/settings/advanced/undefined#max_attempts"
target="_blank"
>
<FormattedMessage
defaultMessage="Set a default value in advanced settings"
description="Advanced settings link text"
id="authoring.problemeditor.settings.advancedSettingLink.text"
/>
</Hyperlink>
</SettingsOption>
`;
exports[`ScoringCard snapshot snapshot: scoring setting card max attempts 1`] = `
<SettingsOption
className=""
className="scoringCard"
extraSections={Array []}
hasExpandableTextArea={false}
summary="Unlimited attempts · {weight, plural, =0 {Ungraded} other {# points}}"
summary="{weight, plural, =0 {Ungraded} other {# points}} · Unlimited attempts"
title="Scoring"
>
<Form.Label
@@ -67,21 +91,6 @@ exports[`ScoringCard snapshot snapshot: scoring setting card max attempts 1`] =
id="authoring.problemeditor.settings.scoring.label"
/>
</Form.Label>
<Form.Group>
<Form.Control
floatingLabel="Attempts"
onChange={[MockFunction scoringCardHooks.handleMaxAttemptChange]}
type="number"
value={0}
/>
<Form.Control.Feedback>
<FormattedMessage
defaultMessage="If a value is not set, unlimited attempts are allowed"
description="Summary text for scoring weight"
id="authoring.problemeditor.settings.scoring.attempts.hint"
/>
</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Control
floatingLabel="Points"
@@ -97,15 +106,54 @@ exports[`ScoringCard snapshot snapshot: scoring setting card max attempts 1`] =
/>
</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Control
disabled={true}
floatingLabel="Attempts"
onBlur={[MockFunction scoringCardHooks.handleMaxAttemptChange]}
onChange={[MockFunction scoringCardHooks.handleOnChange]}
/>
<Form.Control.Feedback>
<FormattedMessage
defaultMessage="If a value is not set, unlimited attempts are allowed"
description="Summary text for scoring weight"
id="authoring.problemeditor.settings.scoring.attempts.hint"
/>
</Form.Control.Feedback>
<Form.Checkbox
checked={true}
className="mt-3 decoration-control-label"
>
<div
className="small"
>
<FormattedMessage
defaultMessage="Unlimited attempts"
description="Label for unlimited attempts checkbox"
id="authoring.problemeditor.settings.scoring.attempts.unlimitedCheckbox"
/>
</div>
</Form.Checkbox>
</Form.Group>
<Hyperlink
destination="undefined/settings/advanced/undefined#max_attempts"
target="_blank"
>
<FormattedMessage
defaultMessage="Set a default value in advanced settings"
description="Advanced settings link text"
id="authoring.problemeditor.settings.advancedSettingLink.text"
/>
</Hyperlink>
</SettingsOption>
`;
exports[`ScoringCard snapshot snapshot: scoring setting card zero zero weight 1`] = `
<SettingsOption
className=""
className="scoringCard"
extraSections={Array []}
hasExpandableTextArea={false}
summary="{attempts, plural, =1 {# attempt} other {# attempts}} · {weight, plural, =0 {Ungraded} other {# points}}"
summary="{weight, plural, =0 {Ungraded} other {# points}} · {attempts, plural, =1 {# attempt} other {# attempts}}"
title="Scoring"
>
<Form.Label
@@ -117,21 +165,6 @@ exports[`ScoringCard snapshot snapshot: scoring setting card zero zero weight 1`
id="authoring.problemeditor.settings.scoring.label"
/>
</Form.Label>
<Form.Group>
<Form.Control
floatingLabel="Attempts"
onChange={[MockFunction scoringCardHooks.handleMaxAttemptChange]}
type="number"
value={5}
/>
<Form.Control.Feedback>
<FormattedMessage
defaultMessage="If a value is not set, unlimited attempts are allowed"
description="Summary text for scoring weight"
id="authoring.problemeditor.settings.scoring.attempts.hint"
/>
</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Control
floatingLabel="Points"
@@ -147,5 +180,44 @@ exports[`ScoringCard snapshot snapshot: scoring setting card zero zero weight 1`
/>
</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Control
disabled={false}
floatingLabel="Attempts"
onBlur={[MockFunction scoringCardHooks.handleMaxAttemptChange]}
onChange={[MockFunction scoringCardHooks.handleOnChange]}
/>
<Form.Control.Feedback>
<FormattedMessage
defaultMessage="If a value is not set, unlimited attempts are allowed"
description="Summary text for scoring weight"
id="authoring.problemeditor.settings.scoring.attempts.hint"
/>
</Form.Control.Feedback>
<Form.Checkbox
checked={false}
className="mt-3 decoration-control-label"
>
<div
className="small"
>
<FormattedMessage
defaultMessage="Unlimited attempts"
description="Label for unlimited attempts checkbox"
id="authoring.problemeditor.settings.scoring.attempts.unlimitedCheckbox"
/>
</div>
</Form.Checkbox>
</Form.Group>
<Hyperlink
destination="undefined/settings/advanced/undefined#max_attempts"
target="_blank"
>
<FormattedMessage
defaultMessage="Set a default value in advanced settings"
description="Advanced settings link text"
id="authoring.problemeditor.settings.advancedSettingLink.text"
/>
</Hyperlink>
</SettingsOption>
`;

View File

@@ -23,7 +23,7 @@ exports[`ShowAnswerCard snapshot snapshot: show answer setting card 1`] = `
className="pb-4"
>
<Hyperlink
destination="SoMEeNDpOinT/settings/advanced/sOMEcouRseId"
destination="SoMEeNDpOinT/settings/advanced/sOMEcouRseId#showanswer"
target="_blank"
>
<FormattedMessage
@@ -69,7 +69,7 @@ exports[`ShowAnswerCard snapshot snapshot: show answer setting card 1`] = `
key="finished"
value="finished"
>
Finished
Finished (Default)
</option>
<option
key="correct_or_past_due"

View File

@@ -19,6 +19,7 @@ export const ProblemEditor = ({
blockValue,
initializeProblemEditor,
assetsFinished,
advancedSettingsFinished,
}) => {
React.useEffect(() => {
if (blockFinished && studioViewFinished && assetsFinished && !blockFailed) {
@@ -26,7 +27,7 @@ export const ProblemEditor = ({
}
}, [blockFinished, studioViewFinished, assetsFinished, blockFailed]);
if (!blockFinished || !studioViewFinished || !assetsFinished) {
if (!blockFinished || !studioViewFinished || !assetsFinished || !advancedSettingsFinished) {
return (
<div className="text-center p-6">
<Spinner
@@ -59,6 +60,7 @@ ProblemEditor.propTypes = {
onClose: PropTypes.func.isRequired,
// redux
assetsFinished: PropTypes.bool,
advancedSettingsFinished: PropTypes.bool.isRequired,
blockFinished: PropTypes.bool.isRequired,
blockFailed: PropTypes.bool.isRequired,
studioViewFinished: PropTypes.bool.isRequired,
@@ -74,6 +76,7 @@ export const mapStateToProps = (state) => ({
problemType: selectors.problem.problemType(state),
blockValue: selectors.app.blockValue(state),
assetsFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchAssets }),
advancedSettingsFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchAdvancedSettings }),
});
export const mapDispatchToProps = {

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Spinner } from '@edx/paragon';
import { thunkActions, selectors } from '../../data/redux';
import { RequestKeys } from '../../data/constants/requests';
import { ProblemEditor, mapStateToProps, mapDispatchToProps } from '.';
@@ -48,41 +48,59 @@ describe('ProblemEditor', () => {
studioViewFinished: false,
initializeProblemEditor: jest.fn().mockName('args.intializeProblemEditor'),
assetsFinished: false,
advancedSettingsFinished: false,
};
describe('snapshots', () => {
test('renders as expected with default behavior', () => {
expect(shallow(<ProblemEditor {...props} />)).toMatchSnapshot();
});
test('block loaded, studio view and assets not yet loaded, Spinner appears', () => {
expect(shallow(<ProblemEditor {...props} blockFinished />)).toMatchSnapshot();
const wrapper = shallow(<ProblemEditor {...props} blockFinished />);
expect(wrapper.containsMatchingElement(<Spinner />)).toEqual(true);
});
test('studio view loaded, block and assets not yet loaded, Spinner appears', () => {
expect(shallow(<ProblemEditor {...props} studioViewFinished />)).toMatchSnapshot();
const wrapper = shallow(<ProblemEditor {...props} studioViewFinished />);
expect(wrapper.containsMatchingElement(<Spinner />)).toEqual(true);
});
test('assets loaded, block and studio view not yet loaded, Spinner appears', () => {
expect(shallow(<ProblemEditor {...props} assetsFinished />)).toMatchSnapshot();
const wrapper = shallow(<ProblemEditor {...props} assetsFinished />);
expect(wrapper.containsMatchingElement(<Spinner />)).toEqual(true);
});
test('advanceSettings loaded, block and studio view not yet loaded, Spinner appears', () => {
const wrapper = shallow(<ProblemEditor {...props} advancedSettingsFinished />);
expect(wrapper.containsMatchingElement(<Spinner />)).toEqual(true);
});
test('block failed, message appears', () => {
expect(shallow(<ProblemEditor
const wrapper = shallow(<ProblemEditor
{...props}
blockFinished
studioViewFinished
assetsFinished
advancedSettingsFinished
blockFailed
/>)).toMatchSnapshot();
/>);
expect(wrapper).toMatchSnapshot();
});
test('renders SelectTypeModal', () => {
expect(shallow(<ProblemEditor {...props} blockFinished studioViewFinished assetsFinished />)).toMatchSnapshot();
const wrapper = shallow(<ProblemEditor
{...props}
blockFinished
studioViewFinished
assetsFinished
advancedSettingsFinished
/>);
expect(wrapper.find('SelectTypeModal')).toHaveLength(1);
});
test('renders EditProblemView', () => {
expect(shallow(<ProblemEditor
const wrapper = shallow(<ProblemEditor
{...props}
problemType="multiplechoiceresponse"
blockFinished
blockFailed
studioViewFinished
assetsFinished
/>)).toMatchSnapshot();
advancedSettingsFinished
/>);
expect(wrapper.find('EditProblemView')).toHaveLength(1);
});
});
@@ -113,6 +131,11 @@ describe('ProblemEditor', () => {
mapStateToProps(testState).assetsFinished,
).toEqual(selectors.requests.isFinished(testState, { requestKey: RequestKeys.fetchAssets }));
});
test('advancedSettingsFinished from requests.isFinished', () => {
expect(
mapStateToProps(testState).advancedSettingsFinished,
).toEqual(selectors.requests.isFinished(testState, { requestKey: RequestKeys.fetchAdvancedSettings }));
});
});
describe('mapDispatchToProps', () => {
test('initializeProblemEditor from thunkActions.problem.initializeProblem', () => {

View File

@@ -25,6 +25,6 @@ export const RequestKeys = StrictDict({
checkTranscriptsForImport: 'checkTranscriptsForImport',
importTranscript: 'importTranscript',
uploadImage: 'uploadImage',
fetchAdvanceSettings: 'fetchAdvanceSettings',
fetchAdvancedSettings: 'fetchAdvancedSettings',
fetchVideoFeatures: 'fetchVideoFeatures',
});

View File

@@ -15,6 +15,7 @@ const initialState = {
groupFeedbackList: [],
generalFeedback: '',
additionalAttributes: {},
defaultSettings: {},
settings: {
randomization: null,
scoring: {
@@ -147,10 +148,23 @@ const problem = createSlice({
},
...payload,
}),
setEnableTypeSelection: (state) => ({
...state,
problemType: null,
}),
setEnableTypeSelection: (state, { payload }) => {
const { maxAttempts, showanswer, showResetButton } = payload;
const attempts = { number: maxAttempts, unlimited: false };
if (!maxAttempts) {
attempts.unlimited = true;
}
return {
...state,
settings: {
...state.settings,
scoring: { ...state.settings.scoring, attempts },
showAnswer: { ...state.settings.showAnswer, on: showanswer },
...showResetButton,
},
problemType: null,
};
},
},
});

View File

@@ -29,8 +29,22 @@ describe('problem reducer', () => {
].map(args => setterTest(...args));
describe('setEnableTypeSelection', () => {
it('sets given problemType to null', () => {
expect(reducer(testingState, actions.setEnableTypeSelection())).toEqual({
const payload = {
maxAttempts: 1,
showanswer: 'finished',
showResetButton: false,
};
expect(reducer(testingState, actions.setEnableTypeSelection(payload))).toEqual({
...testingState,
settings: {
...testingState.settings,
scoring: {
...testingState.settings.scoring,
attempts: { number: 1, unlimited: false },
},
showAnswer: { ...testingState.settings.showAnswer, on: payload.showanswer },
...payload.showResetButton,
},
problemType: null,
});
});

View File

@@ -11,6 +11,7 @@ export const simpleSelectors = {
correctAnswerCount: mkSimpleSelector(problemData => problemData.correctAnswerCount),
settings: mkSimpleSelector(problemData => problemData.settings),
question: mkSimpleSelector(problemData => problemData.question),
defaultSettings: mkSimpleSelector(problemData => problemData.defaultSettings),
completeState: mkSimpleSelector(problemData => problemData),
};

View File

@@ -35,6 +35,7 @@ describe('problem selectors unit tests', () => {
simpleKeys.correctAnswerCount,
simpleKeys.settings,
simpleKeys.question,
simpleKeys.defaultSettings,
].map(testSimpleSelector);
});
test('simple selector completeState equals the entire state', () => {

View File

@@ -19,6 +19,7 @@ const initialState = {
[RequestKeys.checkTranscriptsForImport]: { status: RequestStates.inactive },
[RequestKeys.importTranscript]: { status: RequestStates.inactive },
[RequestKeys.fetchVideoFeatures]: { status: RequestStates.inactive },
[RequestKeys.fetchAdvancedSettings]: { status: RequestStates.inactive },
};
// eslint-disable-next-line no-unused-vars

View File

@@ -1,10 +1,12 @@
import _ from 'lodash-es';
import { actions } from '..';
import * as requests from './requests';
import { OLXParser } from '../../../containers/ProblemEditor/data/OLXParser';
import { parseSettings } from '../../../containers/ProblemEditor/data/SettingsParser';
import { ProblemTypeKeys } from '../../constants/problem';
import ReactStateOLXParser from '../../../containers/ProblemEditor/data/ReactStateOLXParser';
import { blankProblemOLX } from '../../../containers/ProblemEditor/data/mockData/olxTestData';
import { camelizeKeys } from '../../../utils';
import { fetchEditorContent } from '../../../containers/ProblemEditor/components/EditProblemView/hooks';
export const switchToAdvancedEditor = () => (dispatch, getState) => {
@@ -43,15 +45,35 @@ export const getDataFromOlx = ({ rawOLX, rawSettings }) => {
return {};
};
export const initializeProblem = (blockValue) => (dispatch) => {
const rawOLX = _.get(blockValue, 'data.data', {});
const rawSettings = _.get(blockValue, 'data.metadata', {});
export const loadProblem = ({ rawOLX, rawSettings, defaultSettings }) => (dispatch) => {
if (isBlankProblem({ rawOLX })) {
dispatch(actions.problem.setEnableTypeSelection());
dispatch(actions.problem.setEnableTypeSelection(camelizeKeys(defaultSettings)));
} else {
dispatch(actions.problem.load(getDataFromOlx({ rawOLX, rawSettings })));
}
};
export default { initializeProblem, switchToAdvancedEditor };
export const fetchAdvancedSettings = ({ rawOLX, rawSettings }) => (dispatch) => {
const advancedProblemSettingKeys = ['max_attempts', 'showanswer', 'show_reset_button'];
dispatch(requests.fetchAdvancedSettings({
onSuccess: (response) => {
const defaultSettings = {};
Object.entries(response.data).forEach(([key, value]) => {
if (advancedProblemSettingKeys.includes(key)) {
defaultSettings[key] = value.value;
}
});
dispatch(actions.problem.updateField({ defaultSettings: camelizeKeys(defaultSettings) }));
loadProblem({ rawOLX, rawSettings, defaultSettings })(dispatch);
},
onFailure: () => { loadProblem({ rawOLX, rawSettings, defaultSettings: {} })(dispatch); },
}));
};
export const initializeProblem = (blockValue) => (dispatch) => {
const rawOLX = _.get(blockValue, 'data.data', {});
const rawSettings = _.get(blockValue, 'data.metadata', {});
dispatch(fetchAdvancedSettings({ rawOLX, rawSettings }));
};
export default { initializeProblem, switchToAdvancedEditor, fetchAdvancedSettings };

View File

@@ -1,5 +1,5 @@
import { actions } from '..';
import { initializeProblem, switchToAdvancedEditor } from './problem';
import * as module from './problem';
import { checkboxesOLXWithFeedbackAndHintsOLX, advancedProblemOlX, blankProblemOLX } from '../../../containers/ProblemEditor/data/mockData/olxTestData';
import { ProblemTypeKeys } from '../../constants/problem';
@@ -17,9 +17,25 @@ jest.mock('..', () => ({
},
}));
jest.mock('./requests', () => ({
fetchAdvancedSettings: (args) => ({ fetchAdvanceSettings: args }),
}));
const blockValue = {
data: {
data: checkboxesOLXWithFeedbackAndHintsOLX.rawOLX,
metadata: {},
},
};
let rawOLX = blockValue.data.data;
const rawSettings = {};
const defaultSettings = { max_attempts: 1 };
describe('problem thunkActions', () => {
let dispatch;
let getState;
let dispatchedAction;
beforeEach(() => {
dispatch = jest.fn((action) => ({ dispatch: action }));
getState = jest.fn(() => ({
@@ -27,25 +43,51 @@ describe('problem thunkActions', () => {
},
}));
});
afterEach(() => {
jest.restoreAllMocks();
});
test('initializeProblem visual Problem :', () => {
const blockValue = { data: { data: checkboxesOLXWithFeedbackAndHintsOLX.rawOLX } };
initializeProblem(blockValue)(dispatch);
expect(dispatch).toHaveBeenCalledWith(actions.problem.load());
});
test('initializeProblem advanced Problem', () => {
const blockValue = { data: { data: advancedProblemOlX.rawOLX } };
initializeProblem(blockValue)(dispatch);
expect(dispatch).toHaveBeenCalledWith(actions.problem.load());
});
test('initializeProblem blank Problem', () => {
const blockValue = { data: { data: blankProblemOLX.rawOLX } };
initializeProblem(blockValue)(dispatch);
expect(dispatch).toHaveBeenCalledWith(actions.problem.setEnableTypeSelection());
module.initializeProblem(blockValue)(dispatch);
expect(dispatch).toHaveBeenCalled();
});
test('switchToAdvancedEditor visual Problem', () => {
switchToAdvancedEditor()(dispatch, getState);
module.switchToAdvancedEditor()(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith(
actions.problem.updateField({ problemType: ProblemTypeKeys.ADVANCED, rawOLX: mockOlx }),
);
});
describe('fetchAdvanceSettings', () => {
it('dispatches fetchAdvanceSettings action', () => {
module.fetchAdvancedSettings({ rawOLX, rawSettings })(dispatch);
[[dispatchedAction]] = dispatch.mock.calls;
expect(dispatchedAction.fetchAdvanceSettings).not.toEqual(undefined);
});
it('dispatches actions.problem.updateField and loadProblem on success', () => {
dispatch.mockClear();
module.fetchAdvancedSettings({ rawOLX, rawSettings })(dispatch);
[[dispatchedAction]] = dispatch.mock.calls;
dispatchedAction.fetchAdvanceSettings.onSuccess({ data: { key: 'test', max_attempts: 1 } });
expect(dispatch).toHaveBeenCalledWith(actions.problem.load());
});
it('calls loadProblem on failure', () => {
dispatch.mockClear();
module.fetchAdvancedSettings({ rawOLX, rawSettings })(dispatch);
[[dispatchedAction]] = dispatch.mock.calls;
dispatchedAction.fetchAdvanceSettings.onFailure();
expect(dispatch).toHaveBeenCalledWith(actions.problem.load());
});
});
describe('loadProblem', () => {
test('initializeProblem advanced Problem', () => {
rawOLX = advancedProblemOlX.rawOLX;
module.loadProblem({ rawOLX, rawSettings, defaultSettings })(dispatch);
expect(dispatch).toHaveBeenCalledWith(actions.problem.load());
});
test('initializeProblem blank Problem', () => {
rawOLX = blankProblemOLX.rawOLX;
module.loadProblem({ rawOLX, rawSettings, defaultSettings })(dispatch);
expect(dispatch).toHaveBeenCalledWith(actions.problem.setEnableTypeSelection());
});
});
});

View File

@@ -261,10 +261,10 @@ export const fetchCourseDetails = ({ ...rest }) => (dispatch, getState) => {
}));
};
export const fetchAdvanceSettings = ({ ...rest }) => (dispatch, getState) => {
export const fetchAdvancedSettings = ({ ...rest }) => (dispatch, getState) => {
dispatch(module.networkRequest({
requestKey: RequestKeys.fetchAdvanceSettings,
promise: api.fetchAdvanceSettings({
requestKey: RequestKeys.fetchAdvancedSettings,
promise: api.fetchAdvancedSettings({
studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
learningContextId: selectors.app.learningContextId(getState()),
}),
@@ -299,6 +299,6 @@ export default StrictDict({
getTranscriptFile,
checkTranscriptsForImport,
importTranscript,
fetchAdvanceSettings,
fetchAdvancedSettings,
fetchVideoFeatures,
});

View File

@@ -21,7 +21,7 @@ export const apiMethods = {
fetchCourseDetails: ({ studioEndpointUrl, learningContextId }) => get(
urls.courseDetailsUrl({ studioEndpointUrl, learningContextId }),
),
fetchAdvanceSettings: ({ studioEndpointUrl, learningContextId }) => get(
fetchAdvancedSettings: ({ studioEndpointUrl, learningContextId }) => get(
urls.courseAdvanceSettings({ studioEndpointUrl, learningContextId }),
),
uploadAsset: ({

View File

@@ -61,6 +61,11 @@ export const problemDataProps = {
afterAtempts: PropTypes.number,
}),
showResetButton: PropTypes.bool,
defaultSettings: PropTypes.shape({
max_attempts: PropTypes.number,
showanswer: PropTypes.string,
show_reset_button: PropTypes.bool,
}),
}),
};