@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
546 lines (527 loc) • 20.6 kB
JavaScript
import _classCallCheck from "@babel/runtime/helpers/esm/classCallCheck";
import _createClass from "@babel/runtime/helpers/esm/createClass";
import _defineProperty from "@babel/runtime/helpers/esm/defineProperty";
import update from 'immutability-helper';
import find from 'lodash/fp/find';
import clone from 'lodash/clone';
import compact from 'lodash/compact';
import findIndex from 'lodash/findIndex';
import omit from 'lodash/omit';
import sortBy from 'lodash/sortBy';
import { v4 as uuid } from 'uuid';
var EditController = /*#__PURE__*/function () {
function EditController(_props) {
var _this = this;
_classCallCheck(this, EditController);
_defineProperty(this, "UNSAFE_componentWillReceiveProps", function (props) {
_this.props = props;
});
_defineProperty(this, "sortBlanks", function (blanks, stemItems) {
return blanks.sort(function (blankA, blankB) {
return find({
blankId: blankA.id
}, stemItems).position - find({
blankId: blankB.id
}, stemItems).position;
});
});
_defineProperty(this, "textForStemItem", function (stemItem) {
if (stemItem.type !== 'blank') {
return stemItem.value;
}
var matchingData = find({
id: stemItem.blankId
}, _this.props.scoringData.value);
return matchingData.scoringData.blankText;
});
_defineProperty(this, "sortedStemItems", function () {
return _this.stemItems().sort(function (a, b) {
return a.position - b.position;
});
});
_defineProperty(this, "blanks", function () {
return (_this.props.interactionData.blanks || []).slice();
});
_defineProperty(this, "stemItems", function () {
return (_this.props.interactionData.stemItems || []).slice();
});
_defineProperty(this, "sortedBlanks", function () {
return _this.sortBlanks(_this.blanks(), _this.stemItems());
});
// =============
// ACTIONS
// =============
_defineProperty(this, "onCreateBlank", function () {
// get basic data
var blankId = uuid();
var selection = window.getSelection();
var trimmedSelection = selection.toString().trim();
if (trimmedSelection === '') {
return;
}
// construct blank data
var newBlank = _this.__defaultBlank(blankId);
var blanks = update(_this.blanks(), {
$push: [newBlank]
});
// construct new stem item data
var stemItems = sortBy(_this.__updateStemItems(blankId, selection), ['position']);
// construct scoring item data
var newScoringDataForBlank = _this.__newScoringDataForBlank(blankId, trimmedSelection);
var newScoringData = update(_this.props.scoringData, {
value: {
$push: [newScoringDataForBlank]
}
});
stemItems = _this.__normalizePositions(_this.__ensureTextBetweenBlanks(stemItems));
// persist the changes
_this.changeItemState({
interactionData: {
prompt: _this.props.interactionData.prompt,
blanks: _this.sortBlanks(blanks, stemItems),
stemItems: stemItems
},
scoringData: newScoringData
});
window.getSelection().removeAllRanges();
});
_defineProperty(this, "onDestroyBlank", function (stemItem, event) {
event.preventDefault();
event.stopPropagation();
// Preserve blank ID to clear api validation errors for the destroyed blank
var blankId = stemItem.blankId;
// remove the StemItem
var modifiedStemItems = _this.__removeItemFromArray(_this.stemItems(), stemItem.id);
// update stemItem positions
var updatedStemItems = _this.__updatePositionsGreaterThan(stemItem.position, modifiedStemItems, -1);
// merge stemItems if needed (use position-1 and position)
var stemItems = _this.__mergeStemItems(stemItem, updatedStemItems, find({
position: stemItem.position - 1
}, updatedStemItems), find({
position: stemItem.position
}, updatedStemItems));
// remove the blank
var blanks = _this.blanks().filter(function (blank) {
return blank.id !== stemItem.blankId;
});
var newScoringData = _this.__scoringDataForBlankWithoutBlank(stemItem.blankId);
var finalItems = _this.__normalizePositions(stemItems);
var firstItem = clone(finalItems[0]);
if (firstItem.type === 'text' && firstItem.value[0] === ' ' && firstItem.value.length > 1) {
firstItem.value = firstItem.value.trimLeft();
}
finalItems[0] = firstItem;
_this.changeItemState({
interactionData: {
prompt: _this.props.interactionData.prompt,
blanks: blanks,
stemItems: finalItems
},
scoringData: newScoringData
}, blankId);
});
// UPDATES
_defineProperty(this, "updateScoringDataForBlank", function (blankId, scoringDataMods) {
var scoringData = _this.__modifyScoringDataForBlank(blankId, scoringDataMods);
_this.changeItemState({
scoringData: scoringData
}, blankId);
});
// Setting choices to null will remove the choice from the blank. This is necessary
// because when switching between blank types we need a way to remove the choices
// data from the interactionData since it won't be relevant to the new type (not
// to mention that leaving it will expose the openEntry's answer)
_defineProperty(this, "updateBlank", function (blankId, modifications) {
var indexForBlank = _this.__indexForBlank(blankId);
var blankData = _this.props.interactionData.blanks[indexForBlank];
var mods = modifications;
if (modifications.choices === null) {
mods = omit(modifications, ['choices']);
blankData = omit(_this.props.interactionData.blanks[indexForBlank], ['choices']);
}
var finalMods = update(blankData, {
$merge: mods
});
var interactionData = update(_this.props.interactionData, {
blanks: _defineProperty({}, indexForBlank, {
$set: finalMods
})
});
_this.changeItemState({
interactionData: interactionData
}, blankId);
});
// =================
// STEM CHANGE
// =================
_defineProperty(this, "onStemChange", function (stemItem, e) {
var stemItems = _this.stemItems();
var newText = e.target.innerText;
if (stemItems.length > 1 && newText === '') {
newText = ' ';
}
var modifiedItem = Object.assign({}, stemItem, {
value: newText
});
var modifiedStemItems = _this.__removeItemFromArray(stemItems, stemItem.id);
var newStemItems = modifiedStemItems.concat(modifiedItem);
_this.updateInteractionData({
stemItems: newStemItems
});
});
// =============
// GENERAL
// =============
_defineProperty(this, "handleCalculatorTypeChange", function (e, value) {
_this.changeItemState({
calculatorType: value
});
});
_defineProperty(this, "updateInteractionData", function (mods) {
var interactionData = update(_this.props.interactionData, {
$merge: mods
});
_this.changeItemState({
interactionData: interactionData
});
});
_defineProperty(this, "changeItemState", function (modifications, blankId) {
var newestIntData = Object.assign({}, _this.props.interactionData, modifications.interactionData);
var stemItems = newestIntData.stemItems || {};
var newItemBody = _this.__makeItemBody(stemItems);
var newProperties = _this.__makeNewProperties(newestIntData);
var newMods = {
itemBody: newItemBody,
properties: newProperties
};
if (modifications.scoringData) {
var newScoringData = _this.__sortScoringData(modifications.scoringData, stemItems);
newMods.scoringData = newScoringData;
}
var newModifications = Object.assign({}, modifications, newMods);
_this.props.changeItemState(newModifications, blankId);
});
_defineProperty(this, "__makeNewProperties", function (interactionData) {
// syncs shuffling properties data with new interaction data.
// this runs each time we changeItemState so that we dont have
// to manage syncing properties and interactionData on each individual
// change to blank type or blank creation/deletion
var blankRules = interactionData.blanks.reduce(function (memo, blank, blankIndex) {
// eslint-disable-next-line no-param-reassign
memo[blankIndex] = blank.answerType === 'openEntry' ? {
children: null
} : {
children: {
choices: {
shuffled: true
}
}
};
return memo;
}, {});
var newShuffleRules = {
shuffleRules: {
blanks: {
children: blankRules
}
}
};
return Object.assign({}, _this.props.properties, newShuffleRules);
});
this.props = _props;
}
return _createClass(EditController, [{
key: "__sortScoringData",
value:
// ====================
// PRIVATE METHODS
// ====================
function __sortScoringData(scoringData, stemItems) {
var orderedBlankIds = compact(stemItems.map(function (si) {
return si.blankId;
}));
var orderedScoringDataValue = compact(orderedBlankIds.map(function (blankId) {
return scoringData.value.find(function (val) {
return val.id === blankId;
});
}));
return {
value: orderedScoringDataValue
};
}
/*
The main issue here is that when you create a blank by selecting a blank space (eg "columbus ") you don't
want the blank space to be part of the answer. To solve this the selection is trimmed.
The problem is that the blank space that was removed wasn't being appended to the next stemItem.
To work around this, when your selection has a blank space in the beginning or at the end of the selection,
the blank space is "shifted" to the next (or previous) stem item.
If there's no text stem item before it (eg. the previous stem item is also a blank),
it's created a text stem item for this blank space to keep the consistency.
*/
}, {
key: "__getUnselectedStartStemFromSelection",
value: function __getUnselectedStartStemFromSelection(selectedStemItemString, range, selectedStemItem) {
var startString = selectedStemItemString.substring(0, range.startOffset);
var startStringFirstChar = selectedStemItemString.substring(0, 1);
var newStartString = null;
if (startString.length > 0) {
newStartString = startString;
// if the first char of the selection is a blank space, it is appended on the previous stem item value
var selectionFirstChar = selectedStemItemString.substring(range.startOffset, range.startOffset + 1);
if (selectionFirstChar === ' ') {
newStartString += ' ';
}
} else if (startStringFirstChar === ' ') {
// if there's no chars before the selection but the first char of the selection is a blank space
// a new text stem item is created containing this blank space
newStartString = ' ';
}
if (newStartString && newStartString.length > 0) {
var startStemItem = Object.assign({}, selectedStemItem, {
value: newStartString
});
return startStemItem;
}
}
}, {
key: "__getUnselectedEndStemFromSelection",
value: function __getUnselectedEndStemFromSelection(selectedStemItemString, range, selectedStemItem, stemItemIsLast) {
var selectedText = range.toString();
// using range.endOffset would be more elegant, but behaves weirdly in IE
var endString = selectedStemItemString.substring(range.startOffset + selectedText.length);
var endStringLastChar = selectedStemItemString.substring(selectedStemItemString.length - 1);
var newEndString = null;
if (endString.length > 0) {
newEndString = endString;
// if the selection last char is a blank space, it is preppended on the next stem item value
var selectionLastChar = selectedText[selectedText.length - 1];
if (selectionLastChar === ' ') {
newEndString = " ".concat(newEndString);
}
} else if (endStringLastChar === ' ') {
// if there's no chars left on the start of the selection but the first char of the selection is a blank space
// a new text stem item is created containing this blank space
newEndString = ' ';
} else if (stemItemIsLast) {
newEndString = '';
}
if (!newEndString && stemItemIsLast || newEndString && newEndString.length > 0) {
var unselectedEndStemItem = {
id: uuid(),
type: 'text',
value: newEndString,
position: selectedStemItem.position + 2
};
return unselectedEndStemItem;
}
}
}, {
key: "__ensureTextBetweenBlanks",
value: function __ensureTextBetweenBlanks(newStemItems) {
var finalStemItems = [];
var previousType = null;
var increaseIndex = 0;
var positionIndex = 0;
newStemItems.forEach(function (si) {
var type = si.type;
if (type === 'blank' && (previousType === type || previousType === null)) {
increaseIndex++;
finalStemItems[positionIndex] = {
id: uuid(),
position: positionIndex + increaseIndex,
type: 'text',
value: ' '
};
positionIndex++;
finalStemItems[positionIndex] = Object.assign({}, si, {
position: si.position + increaseIndex
});
} else {
finalStemItems[positionIndex] = Object.assign({}, si, {
position: si.position + increaseIndex
});
}
previousType = type;
positionIndex++;
});
return finalStemItems;
}
}, {
key: "__getNewBlankStem",
value: function __getNewBlankStem(blankId, position) {
return {
id: uuid(),
blankId: blankId,
type: 'blank',
position: position + 1
};
}
}, {
key: "__updateStemItems",
value: function __updateStemItems(blankId, selection) {
var range = selection.getRangeAt(0);
var stemItems = this.stemItems();
var selectedStemItemID = selection.focusNode.id || selection.focusNode.parentNode && selection.focusNode.parentNode.id;
var selectedStemItem = find({
id: selectedStemItemID
}, stemItems);
var stemItemIsLast = selectedStemItem.position === stemItems.length;
// Remove original stem item and update positions of stem items list
var stemItemsWithoutOriginal = this.__removeItemFromArray(this.stemItems(), selectedStemItemID);
var updatedStemItems = this.__updatePositionsGreaterThan(selectedStemItem.position, stemItemsWithoutOriginal, 2);
// unselected start stem
var unselectedStartStemItem = this.__getUnselectedStartStemFromSelection(selectedStemItem.value, range, selectedStemItem);
if (unselectedStartStemItem) {
updatedStemItems.push(unselectedStartStemItem);
}
// new blank stem
updatedStemItems.push(this.__getNewBlankStem(blankId, selectedStemItem.position));
// unselected end stem
var unselectedEndStemItem = this.__getUnselectedEndStemFromSelection(selectedStemItem.value, range, selectedStemItem, stemItemIsLast);
if (unselectedEndStemItem) {
updatedStemItems.push(unselectedEndStemItem);
}
return this.__ensureBlankIsNotFirst(updatedStemItems); // I thnk this may be unnecessary now
}
}, {
key: "__ensureBlankIsNotFirst",
value: function __ensureBlankIsNotFirst(stemItems) {
var firstItem = sortBy(stemItems, ['position'])[0];
if (firstItem.type === 'text') {
return stemItems;
}
var newItem = {
type: 'text',
position: -1,
value: ' ',
id: uuid()
};
return this.__normalizePositions(stemItems.concat([newItem]));
}
}, {
key: "__defaultBlank",
value: function __defaultBlank(id, text) {
return {
id: "".concat(id),
answerType: 'openEntry'
};
}
}, {
key: "__mergeStemItems",
value: function __mergeStemItems(removedStemItem, stemItems, stemItemA, stemItemB) {
var newValue = this.textForStemItem(removedStemItem);
var stemItemIdsToRemove = [];
if (stemItemA && stemItemA.type === 'text') {
newValue = "".concat(stemItemA.value.trim(), " ").concat(newValue).trim();
stemItemIdsToRemove.push(stemItemA.id);
}
if (stemItemB && stemItemB.type === 'text') {
newValue = "".concat(newValue, " ").concat(stemItemB.value.trim()).trim();
stemItemIdsToRemove.push(stemItemB.id);
}
stemItemIdsToRemove.push(removedStemItem.id);
var newStemItemsList = stemItems.filter(function (item) {
return !stemItemIdsToRemove.includes(item.id);
});
var mergedStemItem = Object.assign({}, removedStemItem, {
value: newValue,
type: 'text',
position: removedStemItem.position - 1
});
// since merged, update positions
var updatedStemItems = this.__updatePositionsGreaterThan(mergedStemItem.position, newStemItemsList, -1);
// add the merged stemItem back in.
updatedStemItems.push(mergedStemItem);
return updatedStemItems;
}
}, {
key: "__updatePositionsGreaterThan",
value: function __updatePositionsGreaterThan(comparisonPosition, items, amount) {
return items.map(function (i) {
var newPosition = i.position <= comparisonPosition ? i.position : i.position + amount;
var newI = Object.assign({}, i, {
position: newPosition
});
return newI;
});
}
// GENERAL HELPERS
}, {
key: "__removeItemFromArray",
value: function __removeItemFromArray(array, removeId) {
return array.filter(function (item) {
return item.id !== removeId;
});
}
}, {
key: "__indexForBlank",
value: function __indexForBlank(blankId) {
return findIndex(this.props.interactionData.blanks, function (blank) {
return blank.id === blankId;
});
}
}, {
key: "__newScoringDataForBlank",
value: function __newScoringDataForBlank(blankId, selection) {
return {
id: blankId,
scoringAlgorithm: 'TextContainsAnswer',
scoringData: {
value: selection,
blankText: selection
}
};
}
}, {
key: "__scoringDataForBlankWithoutBlank",
value: function __scoringDataForBlankWithoutBlank(blankId) {
var indexForBlank = this.__indexForBlank(blankId);
return update(this.props.scoringData, {
value: {
$splice: [[indexForBlank, 1]]
}
});
}
}, {
key: "__makeItemBody",
value: function __makeItemBody(stemItems) {
var stemItemsCopied = stemItems.slice();
// cannot use this.sortedStemItems() because this method
// is passed the working StemItems
var sortedStemItems = stemItemsCopied.sort(function (a, b) {
return a.position - b.position;
});
return sortedStemItems.reduce(function (itemBody, stemItem) {
if (stemItem.type === 'text') {
var stemItemValue = stemItem.value || '';
return itemBody + stemItemValue;
} else {
return "".concat(itemBody, "_____");
}
}, '');
}
}, {
key: "__modifyScoringDataForBlank",
value: function __modifyScoringDataForBlank(blankId, blankRootScoringDataMods) {
var indexForBlank = this.__indexForBlank(blankId);
return update(this.props.scoringData, {
value: _defineProperty({}, indexForBlank, {
$merge: blankRootScoringDataMods
})
});
}
// There's a bug where sometimes positions get re-indexed to start at 0
// rather than 1 on certain modifications. We should remove the position field
// entirely, but this is a hacky workaround until then.
}, {
key: "__normalizePositions",
value: function __normalizePositions(stemItems) {
return sortBy(stemItems, ['position']).map(function (item, idx) {
return Object.assign({}, item, {
position: idx + 1
});
});
}
}]);
}();
export { EditController as default };