@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
571 lines (476 loc) • 17.4 kB
JavaScript
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}),
)
}
}