@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
602 lines (594 loc) • 25 kB
JavaScript
import _toConsumableArray from "@babel/runtime/helpers/esm/toConsumableArray";
import _classCallCheck from "@babel/runtime/helpers/esm/classCallCheck";
import _createClass from "@babel/runtime/helpers/esm/createClass";
import _possibleConstructorReturn from "@babel/runtime/helpers/esm/possibleConstructorReturn";
import _getPrototypeOf from "@babel/runtime/helpers/esm/getPrototypeOf";
import _inherits from "@babel/runtime/helpers/esm/inherits";
import _defineProperty from "@babel/runtime/helpers/esm/defineProperty";
var _class, _MultipleAnswerEdit;
function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) { symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); } keys.push.apply(keys, symbols); } return keys; }
function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; }
function _callSuper(_this, derived, args) {
function isNativeReflectConstruct() {
if (typeof Reflect === "undefined" || !Reflect.construct) return false;
if (Reflect.construct.sham) return false;
if (typeof Proxy === "function") return true;
try {
return !Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {}));
} catch (e) {
return false;
}
}
derived = _getPrototypeOf(derived);
return _possibleConstructorReturn(_this, isNativeReflectConstruct() ? Reflect.construct(derived, args || [], _getPrototypeOf(_this).constructor) : derived.apply(_this, args));
}
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import striptags from 'striptags';
import { v4 as uuid } from 'uuid';
import getOr from 'lodash/fp/getOr';
import set from 'lodash/fp/set';
import sortBy from 'lodash/fp/sortBy';
import findIndex from 'lodash/fp/findIndex';
import filter from 'lodash/fp/filter';
import last from 'lodash/fp/last';
import map from 'lodash/fp/map';
import xor from 'lodash/fp/xor';
import remove from 'lodash/fp/remove';
import { RadioInputGroup, RadioInput } from '@instructure/ui-radio-input';
import { Checkbox } from '@instructure/ui-checkbox';
import { ScreenReaderContent } from '@instructure/ui-a11y-content';
import { IconButton } from '@instructure/ui-buttons';
import { IconQuestionLine } from '@instructure/ui-icons';
import { View } from '@instructure/ui-view';
import { Link } from '@instructure/ui-link';
import { Text } from '@instructure/ui-text';
import { SimpleModal, FormFieldGroup } from '@instructure/quiz-common';
import ChoiceInput from '../../common/edit/components/ChoiceInput';
import Footer from '../../common/edit/components/Footer';
import MultipleAnswerInteractionType from '../../../records/interactions/multiple_answer';
import QuestionSettingsContainer from '../../common/edit/components/QuestionSettingsContainer';
import QuestionContainer from '../../common/edit/components/QuestionContainer';
import withEditTools from '../../../util/withEditTools';
import t from '@instructure/quiz-i18n/es/format-message';
import QuestionSettingsPanel from '../../common/edit/components/QuestionSettingsPanel';
import CalculatorOptionWithOqaatAlert from '../../common/edit/components/CalculatorOptionWithOqaatAlert';
import { normalizeErrors } from '../../../util/normalizeErrors';
// Scoring Algorithms:
var ALL_OR_NOTHING = 'AllOrNothing';
var PARTIAL_SCORE = 'PartialScore';
/**
---
category: MultipleAnswer
---
Multiple Answer Edit component
```jsx_example
function Example (props) {
const exampleProps = {
itemBody: 'Who was in the first cabinet of the USA?',
interactionData: {
choices: [
{ id: 'uuid1', position: 1, itemBody: 'Thomas Jefferson' },
{ id: 'uuid2', position: 2, itemBody: 'John Marshall' },
{ id: 'uuid3', position: 3, itemBody: 'John Knox' },
{ id: 'uuid4', position: 4, itemBody: 'Alexander Hamilton' },
{ id: 'uuid5', position: 5, itemBody: 'Aaron Burr' },
{ id: 'uuid6', position: 6, itemBody: 'Ben Franklin' }
]
},
itemId: '1',
scoringData: {
value: ['uuid1', 'uuid3', 'uuid4']
},
properties: {
shuffleRules: {
choices: {
shuffled: true,
toLock: [0, 1]
}
}
}
}
return (
<MultipleAnswerEdit {...exampleProps} {...props} />
)
}
<SettingsSwitcher locales={LOCALES}>
<EditStateProvider>
<Example />
</EditStateProvider>
</SettingsSwitcher>
```
**/
var MultipleAnswerEdit = withEditTools(_class = (_MultipleAnswerEdit = /*#__PURE__*/function (_Component) {
function MultipleAnswerEdit() {
var _this2;
_classCallCheck(this, MultipleAnswerEdit);
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
_this2 = _callSuper(this, MultipleAnswerEdit, [].concat(args));
_defineProperty(_this2, "state", {
isModalOpen: false
});
_defineProperty(_this2, "_choiceWasCreated", false);
_defineProperty(_this2, "stemElement", null);
_defineProperty(_this2, "choiceRefs", []);
_defineProperty(_this2, "_timeouts", []);
// ===========
// HANDLERS
// ===========
_defineProperty(_this2, "handleScoringAlgoChange", function (e, value) {
_this2.props.changeItemState({
scoringAlgorithm: value
}, {
scoringAlgorithm: value
});
});
_defineProperty(_this2, "handleCloseModal", function () {
_this2.setState({
isModalOpen: false
});
});
_defineProperty(_this2, "handleOpenModal", function () {
_this2.setState({
isModalOpen: true
});
});
_defineProperty(_this2, "handleCalculatorTypeChange", function (e, value) {
_this2.props.changeItemState({
calculatorType: value
});
});
_defineProperty(_this2, "handleStemRef", function (node) {
_this2.stemElement = node;
});
_defineProperty(_this2, "handleRemoveChoice", function (choiceId, index) {
var choices = _this2.getChoices();
var shuffleRules = _this2.props.properties.shuffleRules;
_this2.updateFocusOnRemove(index);
// Move the locks in conjuntion with the answers
var toLock = map(function (value) {
return value < index ? value : value - 1;
}, filter(function (lock) {
return lock !== index;
}, shuffleRules.choices.toLock));
_this2.props.changeItemState({
interactionData: _objectSpread(_objectSpread({}, _this2.props.interactionData), {}, {
choices: remove({
id: choiceId
}, choices)
}),
scoringData: _objectSpread(_objectSpread({}, _this2.props.scoringData), {}, {
value: filter(function (v) {
return v !== choiceId;
}, _this2.props.scoringData.value)
}),
properties: _objectSpread(_objectSpread({}, _this2.props.properties), {}, {
shuffleRules: set('choices.toLock', toLock, shuffleRules)
})
});
});
_defineProperty(_this2, "handleInputChange", function (choiceId, event, _ref) {
var editorContent = _ref.editorContent;
var choices = _this2.getChoices();
var index = findIndex({
id: choiceId
}, choices);
_this2.props.changeItemState({
interactionData: _objectSpread(_objectSpread({}, _this2.props.interactionData), {}, {
choices: set("[".concat(index, "].itemBody"), editorContent, choices)
})
});
});
_defineProperty(_this2, "handleCreateChoice", function () {
_this2._choiceWasCreated = true;
_this2.props.changeItemState({
interactionData: _objectSpread(_objectSpread({}, _this2.props.interactionData), {}, {
choices: [].concat(_toConsumableArray(_this2.getChoices()), [{
id: _this2.props.newId(),
itemBody: '',
position: Math.max.apply(Math, [-1].concat(_toConsumableArray(map('position', _this2.getChoices())))) + 1
}])
})
});
});
_defineProperty(_this2, "handleShuffleChange", function (event) {
_this2.toggleShuffleChoices();
});
_defineProperty(_this2, "focusErrors", function () {
var stemErrors = getOr([], 'itemBody', _this2.props.errors);
var groupErrors = getOr([], 'scoringData.value', _this2.props.errors);
var choiceErrors = getOr([], 'interactionData.choices', _this2.props.errors);
var choiceErrorsKeys = Object.keys(choiceErrors).map(Number);
if (stemErrors.length) {
_this2.stemElement.focus();
} else if (groupErrors.length) {
_this2.choiceRefs[0].focusOnAnswerInput();
} else if (choiceErrorsKeys.length) {
var index = Math.min.apply(Math, _toConsumableArray(choiceErrorsKeys));
_this2.choiceRefs[index].focusOnAnswerInput();
}
});
return _this2;
}
_inherits(MultipleAnswerEdit, _Component);
return _createClass(MultipleAnswerEdit, [{
key: "componentDidMount",
value: function componentDidMount() {
this.setDefaultScoringAlgorithm();
}
}, {
key: "componentWillUnmount",
value: function componentWillUnmount() {
// prevent timeouts from being called after unmount
this._timeouts.forEach(clearTimeout);
}
}, {
key: "componentDidUpdate",
value: function componentDidUpdate() {
if (this._choiceWasCreated) {
this._choiceWasCreated = false;
last(this.choiceRefs).focusOnAnswerInput();
}
this.setDefaultScoringAlgorithm();
}
}, {
key: "setDefaultScoringAlgorithm",
value: function setDefaultScoringAlgorithm() {
// ALL_OR_NOTHING is the default,
// but we want PARTIAL_SCORE to be the default when enabled
if (!this.props.scoringAlgorithm && this.props.partialScoringEnabled) {
this.props.changeItemState({
scoringAlgorithm: PARTIAL_SCORE
});
}
}
}, {
key: "overrideEditable",
value: function overrideEditable() {
return this.props.overrideEditableForItem || this.props.overrideEditableForRegrading;
}
// ===========
// HELPERS
// ===========
}, {
key: "getChoices",
value: function getChoices() {
return getOr([], 'interactionData.choices', this.props);
}
}, {
key: "isShuffled",
value: function isShuffled() {
return getOr(false, 'properties.shuffleRules.choices.shuffled', this.props);
}
}, {
key: "isChoiceLocked",
value: function isChoiceLocked(choiceId) {
var index = findIndex({
id: choiceId
}, this.getChoices());
var lockedChoices = getOr(false, 'properties.shuffleRules.choices.toLock', this.props);
if (lockedChoices) {
return lockedChoices.includes(index);
}
return false;
}
}, {
key: "updateFocusOnRemove",
value: function updateFocusOnRemove(index) {
var _this3 = this;
if (index === 0) {
// if removing the first choice, focus on stem
this.stemElement.focus();
} else if (this.getChoices().length === 2) {
// if removing the second choice out of two choices
this._timeouts = [].concat(_toConsumableArray(this._timeouts), [setTimeout(function () {
return _this3.choiceRefs[index - 1].focusLast();
})]);
} else {
// all the other cases
this.choiceRefs[index - 1].focusLast();
}
}
}, {
key: "toggleShuffleChoices",
value: function toggleShuffleChoices() {
var newShuffled = !this.isShuffled();
var properties;
if (this.props.properties.shuffleRules !== void 0) {
properties = set('shuffleRules.choices.shuffled', newShuffled, this.props.properties);
} else {
properties = {
shuffleRules: {
choices: {
shuffled: newShuffled
}
}
};
}
var messageForScreenreader = newShuffled ? t('Shuffling turned on. Navigate to a choice to lock it in place.') : t('Shuffling turned off.');
this.props.notifyScreenreader(messageForScreenreader);
this.props.changeItemState({
properties: properties
}, {
properties: {
shuffleRules: {
choices: {
shuffled: newShuffled
}
}
}
});
}
}, {
key: "makeCheckAnswerToggler",
value: function makeCheckAnswerToggler(choiceId) {
var _this4 = this;
return function () {
_this4.props.changeItemState({
scoringData: _objectSpread(_objectSpread({}, _this4.props.scoringData), {}, {
value: xor([choiceId], _this4.props.scoringData.value)
})
});
};
}
}, {
key: "makeChoiceLockedToggler",
value: function makeChoiceLockedToggler(choiceId) {
var _this5 = this;
return function () {
var index = findIndex({
id: choiceId
}, _this5.getChoices());
var toLock = xor([index], _this5.props.properties.shuffleRules.choices.toLock);
var properties = set('shuffleRules.choices.toLock', toLock, _this5.props.properties);
var messageForScreenreader = _this5.isChoiceLocked(choiceId) ? t('Distractor unlocked') : t('Distractor locked');
_this5.props.notifyScreenreader(messageForScreenreader);
_this5.props.changeItemState({
properties: properties
}, {
properties: {
shuffleRules: {
choices: {
toLock: toLock
}
}
}
});
};
}
// ===========
// RENDERS
// ===========
}, {
key: "renderChoice",
value: function renderChoice(choice, index) {
var _this6 = this;
var choiceErrors = this.props.getErrors("interactionData.choices.".concat(index, ".itemBody"));
var shouldRenderRemoveChoice = this.getChoices().length !== 1 && !this.overrideEditable();
var disabledFields = [].concat(_toConsumableArray(this.overrideEditable() ? ['answerInput'] : []), _toConsumableArray(this.props.overrideEditableForRegrading ? ['lockChoiceButton'] : []));
var choiceInputProps = {
disabledFields: disabledFields,
errors: choiceErrors,
key: choice.id,
id: choice.id,
ref: function ref(node) {
_this6.choiceRefs[index] = node;
},
itemBody: choice.itemBody,
noRCE: !this.props.enableRichContentEditor,
notifyScreenreader: this.props.notifyScreenreader,
onInputChange: this.handleInputChange,
onRemoveChoice: function onRemoveChoice() {
return _this6.handleRemoveChoice(choice.id, index);
},
onModalClose: this.props.onModalClose,
onModalOpen: this.props.onModalOpen,
openImportModal: this.props.openImportModal,
removeButtonScreenReaderText: t('Remove Answer Value: { choice }', {
choice: striptags(choice.itemBody)
}),
shouldRenderRemoveChoice: shouldRenderRemoveChoice,
isRequired: true,
automationData: "sdk-multiple-answer-".concat(index)
};
if (this.isShuffled()) {
choiceInputProps.showLock = true;
choiceInputProps.isLocked = this.isChoiceLocked(choice.id);
choiceInputProps.toggleChoiceLocked = this.makeChoiceLockedToggler(choice.id);
}
var checked = this.props.scoringData.value.includes(choice.id);
var onCheckboxChange = this.makeCheckAnswerToggler(choice.id);
var labelText = choice.itemBody.trim() === '' ? t('Checkbox for blank distractor') : t('Checkbox for distractor { distractor }', {
distractor: striptags(choice.itemBody)
});
var renderCheckBox = function renderCheckBox() {
return /*#__PURE__*/React.createElement(Checkbox, {
disabled: _this6.props.overrideEditableForItem,
onChange: onCheckboxChange,
checked: checked,
key: choice.id,
label: /*#__PURE__*/React.createElement(ScreenReaderContent, null, labelText),
value: choice.id
});
};
return /*#__PURE__*/React.createElement(ChoiceInput, Object.assign({}, choiceInputProps, {
renderBeforeComponent: renderCheckBox()
}));
}
}, {
key: "renderInputGroup",
value: function renderInputGroup() {
var _this7 = this;
var choices = sortBy('position', this.getChoices());
var name = "edit_interaction_".concat(this.props.itemId);
var errors = [].concat(_toConsumableArray(this.props.getErrors('interactionData.choices.$errors')), _toConsumableArray(this.props.getErrors('scoringData.value.$errors')));
return /*#__PURE__*/React.createElement(FormFieldGroup, {
rowSpacing: "small",
name: name,
description: t('Possible answers'),
messages: normalizeErrors(errors)
}, choices.map(function (choice, index) {
return _this7.renderChoice(choice, index);
}));
}
}, {
key: "renderOptionsDescription",
value: function renderOptionsDescription() {
return /*#__PURE__*/React.createElement(ScreenReaderContent, null, t('Multiple answer options'));
}
}, {
key: "renderGradingOptionsModal",
value: function renderGradingOptionsModal() {
return /*#__PURE__*/React.createElement(SimpleModal, {
size: "small",
title: t('Grading'),
label: t('Grading'),
isModalOpen: this.state.isModalOpen,
onModalDismiss: this.handleCloseModal
}, /*#__PURE__*/React.createElement(Text, {
weight: "bold",
lineHeight: "double"
}, t('Partial credit with penalty')), /*#__PURE__*/React.createElement("br", null), /*#__PURE__*/React.createElement(Text, null, t('Students are awarded points for every correct answer selected and deducted points for every incorrect answer selected.'), "\xA0", /*#__PURE__*/React.createElement(Link, {
target: "_blank",
href: "https://community.canvaslms.com/docs/DOC-15039-4152780608"
}, t('Learn More'))), /*#__PURE__*/React.createElement("br", null), /*#__PURE__*/React.createElement("br", null), /*#__PURE__*/React.createElement(Text, {
weight: "bold",
lineHeight: "double"
}, t('Exact match')), /*#__PURE__*/React.createElement("br", null), /*#__PURE__*/React.createElement(Text, null, t('Students are awarded full credit if all correct answers are selected and no incorrect answers are selected.')));
}
}, {
key: "renderGradingOptions",
value: function renderGradingOptions() {
return /*#__PURE__*/React.createElement(View, {
as: "div",
margin: "medium 0",
position: "relative"
}, /*#__PURE__*/React.createElement(RadioInputGroup, {
onChange: this.handleScoringAlgoChange,
name: t('Grading'),
value: this.props.scoringAlgorithm,
description: /*#__PURE__*/React.createElement("span", null, t('Grading'), /*#__PURE__*/React.createElement(IconButton, {
size: "small",
withBackground: false,
withBorder: false,
renderIcon: IconQuestionLine,
onClick: this.handleOpenModal,
screenReaderLabel: t('Open grading option information')
}))
}, /*#__PURE__*/React.createElement(RadioInput, {
value: PARTIAL_SCORE,
label: t('Partial credit with penalty'),
"data-automation": "sdk-matching-partial"
}), /*#__PURE__*/React.createElement(RadioInput, {
value: ALL_OR_NOTHING,
label: t('Exact Match'),
"data-automation": "sdk-matching-exact"
})));
}
}, {
key: "render",
value: function render() {
// clean up the references to ChoiceInputs
this.choiceRefs = [];
var shuffleChoicesLabel = /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("span", null, t('Shuffle Choices')), /*#__PURE__*/React.createElement(ScreenReaderContent, null, t('Lock distractor position buttons are displayed for each option when checked')));
return /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement(QuestionContainer, {
disabled: this.overrideEditable(),
enableRichContentEditor: this.props.enableRichContentEditor,
itemBody: this.props.itemBody,
onDescriptionChange: this.props.onDescriptionChange,
onModalClose: this.props.onModalClose,
onModalOpen: this.props.onModalOpen,
openImportModal: this.props.openImportModal,
stemErrors: this.props.getErrors('itemBody'),
textareaRef: this.handleStemRef
}, this.renderInputGroup(), !this.overrideEditable() && /*#__PURE__*/React.createElement(Footer, {
onCreateChoice: this.handleCreateChoice,
notifyScreenreader: this.props.notifyScreenreader
})), /*#__PURE__*/React.createElement(QuestionSettingsContainer, {
additionalOptions: this.props.additionalOptions
}, /*#__PURE__*/React.createElement(QuestionSettingsPanel, {
label: t('Options'),
defaultExpanded: true
}, /*#__PURE__*/React.createElement(FormFieldGroup, {
rowSpacing: "small",
description: this.renderOptionsDescription()
}, this.props.showCalculatorOption && /*#__PURE__*/React.createElement(CalculatorOptionWithOqaatAlert, {
disabled: this.props.overrideEditableForRegrading,
calculatorValue: this.props.calculatorType,
onCalculatorTypeChange: this.handleCalculatorTypeChange,
oqaatChecked: this.props.oneQuestionAtATime,
onOqaatChange: this.props.setOneQuestionAtATime
}), /*#__PURE__*/React.createElement(Checkbox, {
label: shuffleChoicesLabel,
onChange: this.handleShuffleChange,
checked: this.isShuffled(),
disabled: this.props.overrideEditableForRegrading,
"data-automation": "sdk-multiple-answer-shuffle-choices-checkbox"
}), this.props.partialScoringEnabled && this.renderGradingOptions()))), this.renderGradingOptionsModal());
}
}]);
}(Component), _defineProperty(_MultipleAnswerEdit, "interactionType", MultipleAnswerInteractionType), _defineProperty(_MultipleAnswerEdit, "propTypes", _objectSpread(_objectSpread({
additionalOptions: QuestionSettingsContainer.propTypes.additionalOptions,
calculatorType: PropTypes.string,
changeItemState: PropTypes.func,
// TODO: This appears to be unused, can we remove it? Hard to tell with all of the indirection (i.e. withEditTools)
errors: PropTypes.object,
errorsAreShowing: PropTypes.bool,
enableRichContentEditor: PropTypes.bool,
interactionData: PropTypes.shape({
choices: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
itemBody: PropTypes.string,
position: PropTypes.number
}))
}).isRequired,
itemBody: PropTypes.string.isRequired,
itemId: PropTypes.string,
newId: PropTypes.func,
notifyScreenreader: PropTypes.func,
onModalClose: PropTypes.func,
onModalOpen: PropTypes.func,
oneQuestionAtATime: PropTypes.bool,
overrideEditableForItem: PropTypes.bool,
overrideEditableForRegrading: PropTypes.bool,
partialScoringEnabled: PropTypes.bool,
properties: PropTypes.shape({
shuffleRules: PropTypes.shape({
choices: PropTypes.shape({
shuffled: PropTypes.bool,
toLock: PropTypes.arrayOf(PropTypes.number)
})
})
}).isRequired,
scoringAlgorithm: PropTypes.string,
scoringData: PropTypes.shape({
value: PropTypes.arrayOf(PropTypes.string)
}).isRequired,
setOneQuestionAtATime: PropTypes.func,
openImportModal: PropTypes.func
}, withEditTools.injectedProps), {}, {
showCalculatorOption: PropTypes.bool
})), _defineProperty(_MultipleAnswerEdit, "defaultProps", {
calculatorType: 'none',
enableRichContentEditor: true,
oneQuestionAtATime: false,
overrideEditableForItem: false,
overrideEditableForRegrading: false,
partialScoringEnabled: false,
newId: uuid,
notifyScreenreader: Function.prototype,
setOneQuestionAtATime: Function.prototype,
additionalOptions: void 0,
changeItemState: void 0,
errors: void 0,
errorsAreShowing: void 0,
itemId: void 0,
onModalClose: void 0,
onModalOpen: void 0,
openImportModal: void 0,
scoringAlgorithm: null,
showCalculatorOption: true
}), _MultipleAnswerEdit)) || _class;
export { MultipleAnswerEdit as default };