UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

600 lines (596 loc) • 26.4 kB
function _class_call_check(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } function _defineProperties(target, props) { for(var i = 0; i < props.length; i++){ var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } function _create_class(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } 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; } 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() { "use strict"; function EditController(props) { var _this = this; _class_call_check(this, EditController); _define_property(this, "UNSAFE_componentWillReceiveProps", function(props) { _this.props = props; }); _define_property(this, "sortBlanks", function(blanks, stemItems) { return blanks.sort(function(blankA, blankB) { return find({ blankId: blankA.id }, stemItems).position - find({ blankId: blankB.id }, stemItems).position; }); }); _define_property(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; }); _define_property(this, "sortedStemItems", function() { return _this.stemItems().sort(function(a, b) { return a.position - b.position; }); }); _define_property(this, "blanks", function() { return (_this.props.interactionData.blanks || []).slice(); }); _define_property(this, "stemItems", function() { return (_this.props.interactionData.stemItems || []).slice(); }); _define_property(this, "sortedBlanks", function() { return _this.sortBlanks(_this.blanks(), _this.stemItems()); }); // ============= // ACTIONS // ============= _define_property(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(); }); _define_property(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 _define_property(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) _define_property(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: _define_property({}, indexForBlank, { $set: finalMods }) }); _this.changeItemState({ interactionData: interactionData }, blankId); }); // ================= // STEM CHANGE // ================= _define_property(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 // ============= _define_property(this, "handleCalculatorTypeChange", function(e, value) { _this.changeItemState({ calculatorType: value }); }); _define_property(this, "updateInteractionData", function(mods) { var interactionData = update(_this.props.interactionData, { $merge: mods }); _this.changeItemState({ interactionData: interactionData }); }); _define_property(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); }); _define_property(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; } _create_class(EditController, [ { // ==================== // PRIVATE METHODS // ==================== key: "__sortScoringData", value: 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: _define_property({}, 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 }); }); } } ]); return EditController; }(); export { EditController as default };