UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

571 lines (476 loc) • 17.4 kB
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' export default class EditController { constructor(props) { this.props = props } updateProps = props => { this.props = props } sortBlanks = (blanks, stemItems) => { return blanks.sort( (blankA, blankB) => find({blankId: blankA.id}, stemItems).position - find({blankId: blankB.id}, stemItems).position, ) } textForStemItem = stemItem => { if (stemItem.type !== 'blank') { return stemItem.value } const matchingData = find({id: stemItem.blankId}, this.props.scoringData.value) return matchingData.scoringData.blankText } sortedStemItems = () => { return this.stemItems().sort((a, b) => a.position - b.position) } blanks = () => { return (this.props.interactionData.blanks || []).slice() } stemItems = () => { return (this.props.interactionData.stemItems || []).slice() } sortedBlanks = () => { return this.sortBlanks(this.blanks(), this.stemItems()) } // ============= // ACTIONS // ============= onCreateBlank = () => { // get basic data const blankId = uuid() const selection = window.getSelection() const trimmedSelection = selection.toString().trim() if (trimmedSelection === '') { return } // construct blank data const newBlank = this.__defaultBlank(blankId) const blanks = update(this.blanks(), {$push: [newBlank]}) // construct new stem item data let stemItems = sortBy(this.__updateStemItems(blankId, selection), ['position']) // construct scoring item data const newScoringDataForBlank = this.__newScoringDataForBlank(blankId, trimmedSelection) const 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, }, scoringData: newScoringData, }) window.getSelection().removeAllRanges() } onDestroyBlank = (stemItem, event) => { event.preventDefault() event.stopPropagation() // Preserve blank ID to clear api validation errors for the destroyed blank const {blankId} = stemItem // remove the StemItem const modifiedStemItems = this.__removeItemFromArray(this.stemItems(), stemItem.id) // update stemItem positions const updatedStemItems = this.__updatePositionsGreaterThan( stemItem.position, modifiedStemItems, -1, ) // merge stemItems if needed (use position-1 and position) const stemItems = this.__mergeStemItems( stemItem, updatedStemItems, find({position: stemItem.position - 1}, updatedStemItems), find({position: stemItem.position}, updatedStemItems), ) // remove the blank const blanks = this.blanks().filter(blank => blank.id !== stemItem.blankId) const newScoringData = this.__scoringDataForBlankWithoutBlank(stemItem.blankId) const finalItems = this.__normalizePositions(stemItems) const 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, stemItems: finalItems, }, scoringData: newScoringData, }, blankId, ) } // UPDATES updateScoringDataForBlank = (blankId, scoringDataMods) => { const scoringData = this.__modifyScoringDataForBlank(blankId, scoringDataMods) this.changeItemState({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) updateBlank = (blankId, modifications) => { const indexForBlank = this.__indexForBlank(blankId) let blankData = this.props.interactionData.blanks[indexForBlank] let mods = modifications if (modifications.choices === null) { mods = omit(modifications, ['choices']) blankData = omit(this.props.interactionData.blanks[indexForBlank], ['choices']) } const finalMods = update(blankData, {$merge: mods}) const interactionData = update(this.props.interactionData, { blanks: { [indexForBlank]: { $set: finalMods, }, }, }) this.changeItemState({interactionData}, blankId) } // ================= // STEM CHANGE // ================= onStemChange = (stemItem, e) => { const stemItems = this.stemItems() let newText = e.target.innerText if (stemItems.length > 1 && newText === '') { newText = ' ' } const modifiedItem = Object.assign({}, stemItem, {value: newText}) const modifiedStemItems = this.__removeItemFromArray(stemItems, stemItem.id) const newStemItems = modifiedStemItems.concat(modifiedItem) this.updateInteractionData({ stemItems: newStemItems, }) } // ============= // GENERAL // ============= handleCalculatorTypeChange = (e, value) => { this.changeItemState({ calculatorType: value, }) } updateInteractionData = mods => { const interactionData = update(this.props.interactionData, {$merge: mods}) this.changeItemState({interactionData}) } changeItemState = (modifications, blankId) => { const newestIntData = Object.assign( {}, this.props.interactionData, modifications.interactionData, ) const stemItems = newestIntData.stemItems || {} const newItemBody = this.__makeItemBody(stemItems) const newProperties = this.__makeNewProperties(newestIntData) const newMods = {itemBody: newItemBody, properties: newProperties} if (modifications.scoringData) { const newScoringData = this.__sortScoringData(modifications.scoringData, stemItems) newMods.scoringData = newScoringData } const newModifications = Object.assign({}, modifications, newMods) this.props.changeItemState(newModifications, blankId) } // ==================== // PRIVATE METHODS // ==================== __sortScoringData(scoringData, stemItems) { const orderedBlankIds = compact(stemItems.map(si => si.blankId)) const orderedScoringDataValue = compact( orderedBlankIds.map(blankId => { return scoringData.value.find(val => 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. */ __getUnselectedStartStemFromSelection(selectedStemItemString, range, selectedStemItem) { const startString = selectedStemItemString.substring(0, range.startOffset) const startStringFirstChar = selectedStemItemString.substring(0, 1) let 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 const 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) { const startStemItem = Object.assign({}, selectedStemItem, {value: newStartString}) return startStemItem } } __getUnselectedEndStemFromSelection( selectedStemItemString, range, selectedStemItem, stemItemIsLast, ) { const selectedText = range.toString() // using range.endOffset would be more elegant, but behaves weirdly in IE const endString = selectedStemItemString.substring(range.startOffset + selectedText.length) const endStringLastChar = selectedStemItemString.substring(selectedStemItemString.length - 1) let 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 const selectionLastChar = selectedText[selectedText.length - 1] if (selectionLastChar === ' ') { newEndString = ` ${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)) { const unselectedEndStemItem = { id: uuid(), type: 'text', value: newEndString, position: selectedStemItem.position + 2, } return unselectedEndStemItem } } __ensureTextBetweenBlanks(newStemItems) { const finalStemItems = [] let previousType = null let increaseIndex = 0 let positionIndex = 0 newStemItems.forEach(si => { const 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 } __getNewBlankStem(blankId, position) { return { id: uuid(), blankId: blankId, type: 'blank', position: position + 1, } } __updateStemItems(blankId, selection) { const range = selection.getRangeAt(0) const stemItems = this.stemItems() const selectedStemItemID = selection.focusNode.id || (selection.focusNode.parentNode && selection.focusNode.parentNode.id) const selectedStemItem = find({id: selectedStemItemID}, stemItems) const stemItemIsLast = selectedStemItem.position === stemItems.length // Remove original stem item and update positions of stem items list const stemItemsWithoutOriginal = this.__removeItemFromArray( this.stemItems(), selectedStemItemID, ) const updatedStemItems = this.__updatePositionsGreaterThan( selectedStemItem.position, stemItemsWithoutOriginal, 2, ) // unselected start stem const 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 const unselectedEndStemItem = this.__getUnselectedEndStemFromSelection( selectedStemItem.value, range, selectedStemItem, stemItemIsLast, ) if (unselectedEndStemItem) { updatedStemItems.push(unselectedEndStemItem) } return this.__ensureBlankIsNotFirst(updatedStemItems) // I thnk this may be unnecessary now } __ensureBlankIsNotFirst(stemItems) { const firstItem = sortBy(stemItems, ['position'])[0] if (firstItem.type === 'text') { return stemItems } const newItem = { type: 'text', position: -1, value: ' ', id: uuid(), } return this.__normalizePositions(stemItems.concat([newItem])) } __makeNewProperties = 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 const blankRules = interactionData.blanks.reduce((memo, blank, blankIndex) => { // eslint-disable-next-line no-param-reassign memo[blankIndex] = blank.answerType === 'openEntry' ? {children: null} : {children: {choices: {shuffled: true}}} return memo }, {}) const newShuffleRules = { shuffleRules: { blanks: { children: blankRules, }, }, } return Object.assign({}, this.props.properties, newShuffleRules) } __defaultBlank(id, text) { return { id: `${id}`, answerType: 'openEntry', } } __mergeStemItems(removedStemItem, stemItems, stemItemA, stemItemB) { let newValue = this.textForStemItem(removedStemItem) const stemItemIdsToRemove = [] if (stemItemA && stemItemA.type === 'text') { newValue = `${stemItemA.value.trim()} ${newValue}`.trim() stemItemIdsToRemove.push(stemItemA.id) } if (stemItemB && stemItemB.type === 'text') { newValue = `${newValue} ${stemItemB.value.trim()}`.trim() stemItemIdsToRemove.push(stemItemB.id) } stemItemIdsToRemove.push(removedStemItem.id) const newStemItemsList = stemItems.filter(item => !stemItemIdsToRemove.includes(item.id)) const mergedStemItem = Object.assign({}, removedStemItem, { value: newValue, type: 'text', position: removedStemItem.position - 1, }) // since merged, update positions const updatedStemItems = this.__updatePositionsGreaterThan( mergedStemItem.position, newStemItemsList, -1, ) // add the merged stemItem back in. updatedStemItems.push(mergedStemItem) return updatedStemItems } __updatePositionsGreaterThan(comparisonPosition, items, amount) { return items.map(i => { const newPosition = i.position <= comparisonPosition ? i.position : i.position + amount const newI = Object.assign({}, i, {position: newPosition}) return newI }) } // GENERAL HELPERS __removeItemFromArray(array, removeId) { return array.filter(item => item.id !== removeId) } __indexForBlank(blankId) { return findIndex(this.props.interactionData.blanks, blank => blank.id === blankId) } __newScoringDataForBlank(blankId, selection) { return { id: blankId, scoringAlgorithm: 'TextContainsAnswer', scoringData: {value: selection, blankText: selection}, } } __scoringDataForBlankWithoutBlank(blankId) { const indexForBlank = this.__indexForBlank(blankId) return update(this.props.scoringData, { value: { $splice: [[indexForBlank, 1]], }, }) } __makeItemBody(stemItems) { const stemItemsCopied = stemItems.slice() // cannot use this.sortedStemItems() because this method // is passed the working StemItems const sortedStemItems = stemItemsCopied.sort((a, b) => a.position - b.position) return sortedStemItems.reduce((itemBody, stemItem) => { if (stemItem.type === 'text') { const stemItemValue = stemItem.value || '' return itemBody + stemItemValue } else { return `${itemBody}_____` } }, '') } __modifyScoringDataForBlank(blankId, blankRootScoringDataMods) { const indexForBlank = this.__indexForBlank(blankId) return update(this.props.scoringData, { value: { [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. __normalizePositions(stemItems) { return sortBy(stemItems, ['position']).map((item, idx) => Object.assign({}, item, {position: idx + 1}), ) } }