@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
577 lines (573 loc) • 24.6 kB
JavaScript
/** @jsx jsx */ function _array_like_to_array(arr, len) {
if (len == null || len > arr.length) len = arr.length;
for(var i = 0, arr2 = new Array(len); i < len; i++)arr2[i] = arr[i];
return arr2;
}
function _array_with_holes(arr) {
if (Array.isArray(arr)) return arr;
}
function _define_property(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {
value: value,
enumerable: true,
configurable: true,
writable: true
});
} else {
obj[key] = value;
}
return obj;
}
function _iterable_to_array_limit(arr, i) {
var _i = arr == null ? null : typeof Symbol !== "undefined" && arr[Symbol.iterator] || arr["@@iterator"];
if (_i == null) return;
var _arr = [];
var _n = true;
var _d = false;
var _s, _e;
try {
for(_i = _i.call(arr); !(_n = (_s = _i.next()).done); _n = true){
_arr.push(_s.value);
if (i && _arr.length === i) break;
}
} catch (err) {
_d = true;
_e = err;
} finally{
try {
if (!_n && _i["return"] != null) _i["return"]();
} finally{
if (_d) throw _e;
}
}
return _arr;
}
function _non_iterable_rest() {
throw new TypeError("Invalid attempt to destructure non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
}
function _object_spread(target) {
for(var i = 1; i < arguments.length; i++){
var source = arguments[i] != null ? arguments[i] : {};
var ownKeys = Object.keys(source);
if (typeof Object.getOwnPropertySymbols === "function") {
ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function(sym) {
return Object.getOwnPropertyDescriptor(source, sym).enumerable;
}));
}
ownKeys.forEach(function(key) {
_define_property(target, key, source[key]);
});
}
return target;
}
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 _object_spread_props(target, source) {
source = source != null ? source : {};
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 _sliced_to_array(arr, i) {
return _array_with_holes(arr) || _iterable_to_array_limit(arr, i) || _unsupported_iterable_to_array(arr, i) || _non_iterable_rest();
}
function _unsupported_iterable_to_array(o, minLen) {
if (!o) return;
if (typeof o === "string") return _array_like_to_array(o, minLen);
var n = Object.prototype.toString.call(o).slice(8, -1);
if (n === "Object" && o.constructor) n = o.constructor.name;
if (n === "Map" || n === "Set") return Array.from(n);
if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _array_like_to_array(o, minLen);
}
import React, { useMemo, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import find from 'lodash/fp/find';
import flatMap from 'lodash/fp/flatMap';
import flow from 'lodash/fp/flow';
import filter from 'lodash/fp/filter';
import sortBy from 'lodash/fp/sortBy';
import set from 'lodash/fp/set';
import { v4 as uuid } from 'uuid';
import striptags from 'striptags';
import { Text } from '@instructure/ui-text';
import { jsx } from '@instructure/emotion';
import { RadioInputGroup, RadioInput } from '@instructure/ui-radio-input';
import { IconButton } from '@instructure/ui-buttons';
import { IconQuestionLine } from '@instructure/ui-icons';
import { View } from '@instructure/ui-view';
import { SimpleModal, FormFieldGroup, Flex } from '@instructure/quiz-common';
import CategorizationInteractionType from '../../../records/interactions/categorization';
import ChoiceInput from '../../common/edit/components/ChoiceInput';
import Footer from '../../common/edit/components/Footer';
import QuestionSettingsContainer from '../../common/edit/components/QuestionSettingsContainer';
import QuestionContainer from '../../common/edit/components/QuestionContainer';
import { getNextItemIdFromArray } from '../../../util/focusHelpers';
import { CategorizationPresenterProvider, useCategorizationPresenter } from './CategorizationPresenter';
import CategoryForm from './CategoryForm';
import { getSortedCategories } from '../common/utils';
import withEditTools from '../../../util/withEditTools';
import t from '@instructure/quiz-i18n/format-message';
import QuestionSettingsPanel from '../../common/edit/components/QuestionSettingsPanel';
import CalculatorOptionWithOqaatAlert from '../../common/edit/components/CalculatorOptionWithOqaatAlert';
import { ScreenReaderContent } from '@instructure/ui-a11y-content';
/**
---
category: Categorization
---
Categorization Edit component
```jsx_example
function Example (props) {
const exampleProps = {
itemBody: 'Match the name of the celestial body on the left with its correct classification:',
interactionData: {
categoryOrder: ['uuid1', 'uuid2'],
categories: {
uuid1: { id: 'uuid1', itemBody: 'Planet' },
uuid2: { id: 'uuid2', itemBody: 'Moon' }
},
distractors: {
uuid3: { id: 'uuid3', itemBody: 'Mars' },
uuid4: { id: 'uuid4', itemBody: 'Europa' },
uuid5: { id: 'uuid5', itemBody: 'Venus' },
uuid6: { id: 'uuid6', itemBody: 'Phobos' },
uuid7: { id: 'uuid7', itemBody: 'Deimos' },
uuid8: { id: 'uuid8', itemBody: 'Jupiter' },
uuid9: { id: 'uuid9', itemBody: 'America' },
uuid10: { id: 'uuid10', itemBody: 'Asia' }
}
},
scoringData: {
value: [{
id: 'uuid1',
scoringAlgorithm: 'AllOrNothing',
scoringData: {
value: ['uuid3', 'uuid5', 'uuid8']
}
}, {
id: 'uuid2',
scoringAlgorithm: 'AllOrNothing',
scoringData: {
value: ['uuid4', 'uuid6', 'uuid7']
}
}]
}
}
return (
<CategorizationEdit {...exampleProps} {...props} />
)
}
<SettingsSwitcher locales={LOCALES}>
<EditStateProvider>
<Example />
</EditStateProvider>
</SettingsSwitcher>
```
**/ var BaseCategorizationEdit = /*#__PURE__*/ React.forwardRef(function(props, ref) {
var additionalOptions = props.additionalOptions, calculatorType = props.calculatorType, changeItemState = props.changeItemState, enableRichContentEditor = props.enableRichContentEditor, getErrors = props.getErrors, interactionData = props.interactionData, itemBody = props.itemBody, newId = props.newId, notifyScreenreader = props.notifyScreenreader, onModalClose = props.onModalClose, onModalOpen = props.onModalOpen, oneQuestionAtATime = props.oneQuestionAtATime, openImportModal = props.openImportModal, overrideEditableForRegrading = props.overrideEditableForRegrading, scoringData = props.scoringData, setOneQuestionAtATime = props.setOneQuestionAtATime, showCalculatorOption = props.showCalculatorOption;
var categories = interactionData.categories, categoryOrder = interactionData.categoryOrder;
var _useState = _sliced_to_array(useState(false), 2), isModalOpen = _useState[0], setModalOpen = _useState[1];
var sortedCategories = useMemo(function() {
return getSortedCategories(categories, categoryOrder);
}, [
categories,
categoryOrder
]);
var _useState1 = _sliced_to_array(useState(function() {
return sortBy('itemBody', interactionData.distractors).map(function(param) {
var id = param.id;
return id;
});
}), 2), distractorOrder = _useState1[0], setDistractorOrder = _useState1[1];
var _useCategorizationPresenter = useCategorizationPresenter(), onCategoryInputChange = _useCategorizationPresenter.onCategoryInputChange, onCreateAnswer = _useCategorizationPresenter.onCreateAnswer, onCreateCategory = _useCategorizationPresenter.onCreateCategory, onDistractorInputChange = _useCategorizationPresenter.onDistractorInputChange, onRemoveAnswer = _useCategorizationPresenter.onRemoveAnswer, onRemoveCategory = _useCategorizationPresenter.onRemoveCategory, onRemoveDistractor = _useCategorizationPresenter.onRemoveDistractor;
var distractorRefsRef = useRef({});
var categoryRefRef = useRef(null);
var stemElementRef = useRef(null);
// ===========
// HELPERS
// ===========
var getViewDistractors = function() {
var answerIds = flatMap(function(param) {
var value = param.scoringData.value;
return value;
}, scoringData.value);
return flow(Object.values, filter(function(param) {
var id = param.id;
return !answerIds.includes(id);
}), sortBy(function(param) {
var id = param.id;
return distractorOrder.indexOf(id);
}))(interactionData.distractors);
};
var getCategoryErrors = function(categoryId) {
return getErrors("interactionData.categories.".concat(categoryId, ".itemBody"));
};
var getDistractorErrors = function(distractorId) {
return getErrors("interactionData.distractors.".concat(distractorId, ".itemBody"));
};
var getAnswerIds = function(categoryId) {
var _categoryScoringData_scoringData;
var categoryScoringData = find({
id: categoryId
}, scoringData.value);
// since kinesis doesnt save empty array
return (categoryScoringData === null || categoryScoringData === void 0 ? void 0 : (_categoryScoringData_scoringData = categoryScoringData.scoringData) === null || _categoryScoringData_scoringData === void 0 ? void 0 : _categoryScoringData_scoringData.value) || [];
};
// ===========
// HANDLERS
// ===========
var handleScoringAlgoChange = function(e, value) {
// Update scoreMethod in scoringData, not scoringAlgorithm at item level
var newScoringData = _object_spread_props(_object_spread({}, props.scoringData), {
scoreMethod: value
});
props.changeItemState({
scoringData: newScoringData
});
};
var handleOpenModal = function() {
setModalOpen(true);
};
var handleCloseModal = function() {
setModalOpen(false);
};
var handleCalculatorTypeChange = function(e, value) {
changeItemState({
calculatorType: value
});
};
var handleRemoveDistractor = function(distractorId) {
var nextDistractorId = getNextItemIdFromArray(distractorId, getViewDistractors());
var nextDistractorRef = distractorRefsRef.current[nextDistractorId];
if (nextDistractorRef) {
nextDistractorRef.removeChoicebutton.focus();
} else {
// focus on add category button
categoryRefRef.current.footerRef.focus();
}
onRemoveDistractor(distractorId);
};
var handleRemoveCategory = function(categoryId) {
var removingFirstCategory = categoryOrder && categoryOrder.indexOf(categoryId) === 0;
if (removingFirstCategory) {
stemElementRef.current.focus();
} else {
var nextCategoryId = getNextItemIdFromArray(categoryId, sortedCategories);
var nextAddAnswerRef = categoryRefRef.current.categoryRefs[nextCategoryId];
if (nextAddAnswerRef) {
// will focus the previous category's "+Answer" button
nextAddAnswerRef.focus();
}
}
onRemoveCategory(categoryId);
};
var handleRemoveAnswer = function(answerId, categoryId) {
var answerIds = getAnswerIds(categoryId);
var removingFirstAnswer = answerIds.indexOf(answerId) === 0;
if (removingFirstAnswer) {
categoryRefRef.current.categoryAnswerInputRefs[categoryId].answerInput.focus();
} else {
var nextAnswerId = getNextItemIdFromArray(answerId, answerIds);
var nextAnswerRef = categoryRefRef.current.answerRefs[nextAnswerId];
if (nextAnswerRef) {
nextAnswerRef.removeChoicebutton.focus();
}
}
onRemoveAnswer(answerId, categoryId);
};
var handleCreateDistractor = function() {
var id = newId();
var newDistractor = {
id: id,
itemBody: ''
};
setDistractorOrder(function(prevDistractorOrder) {
return prevDistractorOrder.concat([
id
]);
});
changeItemState({
interactionData: set("distractors[".concat(id, "]"), newDistractor, interactionData)
});
};
var handleStemRef = function(node) {
stemElementRef.current = node;
};
var handleCategoryRef = function(node) {
categoryRefRef.current = node;
};
var handleDescriptionChange = function(itemBody) {
changeItemState({
itemBody: itemBody
});
};
// ===========
// RENDER
// ===========
function renderGradingOptions() {
var ALL_OR_NOTHING = 'all_or_nothing';
var PARTIAL_CREDIT = 'partial_credit';
// Read scoreMethod from scoringData
var currentScoreMethod = props.scoringData.scoreMethod || ALL_OR_NOTHING;
return /*#__PURE__*/ jsx(View, {
as: "div",
margin: "medium 0",
position: "relative"
}, /*#__PURE__*/ jsx("div", {
style: {
marginBottom: '0.5rem'
}
}, /*#__PURE__*/ jsx(Text, null, t('Grading')), /*#__PURE__*/ jsx(IconButton, {
size: "small",
withBackground: false,
withBorder: false,
renderIcon: IconQuestionLine,
onClick: handleOpenModal,
screenReaderLabel: t('Open grading option information')
})), /*#__PURE__*/ jsx(RadioInputGroup, {
onChange: handleScoringAlgoChange,
value: currentScoreMethod,
name: t('Grading'),
description: /*#__PURE__*/ jsx(ScreenReaderContent, null, t('Grading'))
}, /*#__PURE__*/ jsx(RadioInput, {
value: ALL_OR_NOTHING,
label: t('Exact match'),
"data-automation": "sdk-grading-exact-match-radio-input"
}), /*#__PURE__*/ jsx(RadioInput, {
value: PARTIAL_CREDIT,
label: t('Partial credit'),
"data-automation": "sdk-grading-partial-credit-radio-input"
})));
}
function renderGradingOptionsModal() {
return /*#__PURE__*/ jsx(SimpleModal, {
size: "small",
title: t('Grading'),
label: t('Grading'),
isModalOpen: isModalOpen,
onModalDismiss: handleCloseModal
}, /*#__PURE__*/ jsx(Text, {
weight: "bold",
lineHeight: "double",
as: "div"
}, t('Exact match')), /*#__PURE__*/ jsx(Text, {
as: "div"
}, t('Students are awarded full credit if all correct items are categorized correctly and all distractors are left uncategorized.')), /*#__PURE__*/ jsx("br", null), /*#__PURE__*/ jsx(Text, {
weight: "bold",
lineHeight: "double",
as: "div"
}, t('Partial credit')), /*#__PURE__*/ jsx(Text, null, t('Students earn points for each correct decision: placing correct items in the right category AND leaving distractors uncategorized. Each decision is worth an equal portion of the total points.')));
}
var renderDistractor = function(distractor, focusOnMount) {
return /*#__PURE__*/ jsx(ChoiceInput, {
key: distractor.id,
id: distractor.id,
disabledFields: overrideEditableForRegrading ? [
'answerInput'
] : [],
itemBody: distractor.itemBody,
errors: getDistractorErrors(distractor.id),
onInputChange: onDistractorInputChange,
onModalClose: onModalClose,
onModalOpen: onModalOpen,
onRemoveChoice: function() {
return handleRemoveDistractor(distractor.id);
},
ref: function(node) {
distractorRefsRef.current[distractor.id] = node;
},
renderLabel: t('Distractor'),
isRequired: true,
focusOnMount: focusOnMount,
shouldRenderRemoveChoice: !overrideEditableForRegrading,
noRCE: true,
notifyScreenreader: notifyScreenreader,
screenReaderText: t('Additional Distractor {itemBody}', {
itemBody: striptags(distractor.itemBody)
})
});
};
var renderDistractorSection = function() {
var distractors = getViewDistractors();
return /*#__PURE__*/ jsx(Flex, {
direction: "column",
gap: "small",
className: "distractorsContainer",
padding: "x-small 0 0 0"
}, /*#__PURE__*/ jsx(Text, {
color: "primary"
}, t('Additional Distractors')), /*#__PURE__*/ jsx(Flex, {
direction: "column",
gap: "small"
}, distractors.map(function(distractor, i, source) {
var focusOnMount = i + 1 === source.length && !distractor.itemBody;
return renderDistractor(distractor, focusOnMount);
})), !overrideEditableForRegrading && /*#__PURE__*/ jsx(Flex.Item, {
overflowY: "visible"
}, /*#__PURE__*/ jsx(Footer, {
onCreateChoice: handleCreateDistractor,
buttonText: t('Distractor'),
screenReaderText: t('Add Distractor'),
notifyScreenreader: notifyScreenreader,
automationData: "add-categorization-distractor-button"
})));
};
var renderOptionsDescription = function() {
return /*#__PURE__*/ jsx(ScreenReaderContent, null, t('Categorization options'));
};
distractorRefsRef.current = {};
return /*#__PURE__*/ jsx("div", null, /*#__PURE__*/ jsx(QuestionContainer, {
disabled: overrideEditableForRegrading,
enableRichContentEditor: enableRichContentEditor,
itemBody: itemBody,
onDescriptionChange: handleDescriptionChange,
onModalClose: onModalClose,
onModalOpen: onModalOpen,
openImportModal: openImportModal,
stemErrors: getErrors('itemBody'),
textareaRef: handleStemRef
}, /*#__PURE__*/ jsx(CategoryForm, {
categoriesErrors: getErrors('scoringData.value.$errors'),
// These error callbacks are passed in as new functions, to force
// the component to re-render when errors change. Not ideal - we
// should be passing the errors in directly
getCategoryErrors: function(categoryId) {
return getCategoryErrors(categoryId);
},
distractorErrors: function(distractorId) {
return getDistractorErrors(distractorId);
},
categoryScoringData: getAnswerIds,
distractors: interactionData.distractors,
disabled: overrideEditableForRegrading,
sortedCategories: sortedCategories,
notifyScreenreader: notifyScreenreader,
onCategoryInputChange: onCategoryInputChange,
onCreateAnswer: onCreateAnswer,
onCreateCategory: onCreateCategory,
onDistractorInputChange: onDistractorInputChange,
onModalClose: onModalClose,
onModalOpen: onModalOpen,
onRemoveAnswer: handleRemoveAnswer,
onRemoveCategory: handleRemoveCategory,
ref: handleCategoryRef
}), renderDistractorSection()), /*#__PURE__*/ jsx(QuestionSettingsContainer, {
additionalOptions: additionalOptions
}, showCalculatorOption && /*#__PURE__*/ jsx(QuestionSettingsPanel, {
label: t('Options'),
defaultExpanded: true
}, /*#__PURE__*/ jsx(FormFieldGroup, {
rowSpacing: "small",
description: renderOptionsDescription()
}, /*#__PURE__*/ jsx(CalculatorOptionWithOqaatAlert, {
disabled: overrideEditableForRegrading,
calculatorValue: calculatorType,
onCalculatorTypeChange: handleCalculatorTypeChange,
oqaatChecked: oneQuestionAtATime,
onOqaatChange: setOneQuestionAtATime
}), props.partialDeepScoringEnabled && renderGradingOptions()))), renderGradingOptionsModal());
});
BaseCategorizationEdit.displayName = 'CategorizationEdit';
BaseCategorizationEdit.componentId = 'QuizzesCategorizationEdit';
BaseCategorizationEdit.interactionType = CategorizationInteractionType;
BaseCategorizationEdit.propTypes = {
additionalOptions: QuestionSettingsContainer.propTypes.additionalOptions,
calculatorType: PropTypes.string,
changeItemState: PropTypes.func,
enableRichContentEditor: PropTypes.bool,
partialDeepScoringEnabled: PropTypes.bool,
scoringAlgorithm: PropTypes.string,
errors: PropTypes.shape({
itemBody: PropTypes.arrayOf(PropTypes.string),
interactionData: PropTypes.shape({
distractors: PropTypes.objectOf(PropTypes.shape({
itemBody: PropTypes.arrayOf(PropTypes.string)
})),
categories: PropTypes.objectOf(PropTypes.shape({
itemBody: PropTypes.arrayOf(PropTypes.string)
}))
}),
scoringData: PropTypes.shape({
value: PropTypes.shape({
$errors: PropTypes.arrayOf(PropTypes.string)
})
})
}),
interactionData: PropTypes.shape({
categoryOrder: PropTypes.arrayOf(PropTypes.string),
categories: PropTypes.objectOf(PropTypes.shape({
id: PropTypes.string,
itemBody: PropTypes.string
})),
distractors: PropTypes.objectOf(PropTypes.shape({
id: PropTypes.string,
itemBody: PropTypes.string
}))
}).isRequired,
itemBody: PropTypes.string,
notifyScreenreader: PropTypes.func.isRequired,
onModalClose: PropTypes.func,
onModalOpen: PropTypes.func,
oneQuestionAtATime: PropTypes.bool,
openImportModal: PropTypes.func.isRequired,
overrideEditableForRegrading: PropTypes.bool,
scoringData: PropTypes.shape({
value: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string
}))
}).isRequired,
setOneQuestionAtATime: PropTypes.func,
newId: PropTypes.func,
getErrors: PropTypes.func,
styles: PropTypes.object,
showCalculatorOption: PropTypes.bool
};
BaseCategorizationEdit.defaultProps = {
calculatorType: 'none',
partialDeepScoringEnabled: false,
scoringAlgorithm: void 0,
enableRichContentEditor: true,
oneQuestionAtATime: false,
overrideEditableForRegrading: false,
newId: uuid,
setOneQuestionAtATime: Function.prototype,
additionalOptions: void 0,
changeItemState: void 0,
errors: void 0,
itemBody: void 0,
onModalClose: void 0,
onModalOpen: void 0,
showCalculatorOption: true
};
var CategorizationEditWithEditTools = withEditTools(BaseCategorizationEdit);
var CategorizationEdit = /*#__PURE__*/ React.forwardRef(function(props, ref) {
var interactionData = props.interactionData, scoringData = props.scoringData, changeItemState = props.changeItemState, _props_newId = props.newId, newId = _props_newId === void 0 ? uuid : _props_newId;
return /*#__PURE__*/ jsx(CategorizationPresenterProvider, {
interactionData: interactionData,
scoringData: scoringData,
changeItemState: changeItemState,
newId: newId
}, /*#__PURE__*/ jsx(CategorizationEditWithEditTools, _object_spread_props(_object_spread({}, props), {
ref: ref
})));
});
CategorizationEdit.displayName = 'CategorizationEdit';
CategorizationEdit.componentId = 'QuizzesCategorizationEdit';
CategorizationEdit.interactionType = CategorizationInteractionType;
export default CategorizationEdit;