@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
499 lines (427 loc) • 15 kB
JavaScript
import {v4 as uuid} from 'uuid'
import update from 'immutability-helper'
import escape from 'lodash/escape'
/**
* Custom hook for managing blank operations in the Rich Fill Blank Edit component
* Handles blank creation, deletion, updates, and synchronization between scoring/interaction data
*/
const ANSWER_TYPES = {
OPEN_ENTRY: 'openEntry',
DROPDOWN: 'dropdown',
WORDBANK: 'wordbank',
}
const DEFAULT_SCORING_ALGORITHM = 'TextContainsAnswer'
const htmlDecode = input => {
const doc = new DOMParser().parseFromString(input, 'text/html')
return doc.documentElement.textContent
}
const extractBlankMatches = (itemBody, validBlankRegex) => {
return (itemBody.match(validBlankRegex) || []).map(match => htmlDecode(match.slice(1, -1)))
}
const createNewBlank = blankText => {
const id = uuid()
return {
scoringData: defaultBlankScoringData(blankText, id),
interactionData: defaultBlankInteractionData(blankText, id),
}
}
const hasWordbankAnswerType = blanks => {
return blanks.some(blank => blank.answerType === ANSWER_TYPES.WORDBANK)
}
const defaultBlankScoringData = (blankValue, id) => {
return {
id,
scoringAlgorithm: DEFAULT_SCORING_ALGORITHM,
scoringData: {value: blankValue, blankText: blankValue},
}
}
const defaultBlankInteractionData = (blankValue, id) => {
return {
id,
answerType: ANSWER_TYPES.OPEN_ENTRY,
}
}
const findExistingBlank = (blankId, availableInteractionBlanks) => {
const existingInteractionIndex = availableInteractionBlanks.findIndex(
blank => blank.id === blankId,
)
if (existingInteractionIndex === -1) return null
return availableInteractionBlanks.splice(existingInteractionIndex, 1)[0]
}
const findDeletedBlankIndex = (blankText, availableDeletedBlanks) => {
return availableDeletedBlanks.findIndex(
deletedBlank => deletedBlank.scoringData.scoringData.blankText === blankText,
)
}
const restoreBlankFromPool = (blankText, availableDeletedBlanks) => {
const deletedBlankIndex = findDeletedBlankIndex(blankText, availableDeletedBlanks)
if (deletedBlankIndex === -1) return null
const [restoredBlank] = availableDeletedBlanks.splice(deletedBlankIndex, 1)
return {
scoringData: restoredBlank.scoringData,
interactionData: cleanupInteractionData(restoredBlank.interactionData),
}
}
const makeNewProperties = (properties, interactionDataToSync) => {
const blankRules = interactionDataToSync.blanks.reduce((memo, blank, blankIndex) => {
// eslint-disable-next-line no-param-reassign
memo[blankIndex] =
blank.answerType === ANSWER_TYPES.OPEN_ENTRY
? {children: null}
: {children: {choices: {shuffled: true}}}
return memo
}, {})
const newShuffleRules = {
shuffleRules: {
blanks: {
children: blankRules,
},
},
}
return Object.assign({}, properties, newShuffleRules)
}
const cleanupInteractionData = interactionData => {
const {choices, ...cleaned} = interactionData
return choices === null ? cleaned : interactionData
}
const findBestMatchIndex = (blankText, newIndex, availableScoringData, existingScoringData) => {
return availableScoringData.reduce(
(best, scoringData, availIndex) => {
if (scoringData.scoringData.blankText !== blankText) return best
const originalIndex = existingScoringData.indexOf(scoringData)
const distance = Math.abs(originalIndex - newIndex)
return distance < best.distance ? {index: availIndex, distance} : best
},
{index: -1, distance: Infinity},
).index
}
const processBlankMatch = (
blankText,
newIndex,
availableScoringData,
availableInteractionBlanks,
availableDeletedBlanks,
existingScoringData,
) => {
const bestMatchIndex = findBestMatchIndex(
blankText,
newIndex,
availableScoringData,
existingScoringData,
)
if (bestMatchIndex > -1) {
const existingScoring = availableScoringData.splice(bestMatchIndex, 1)[0]
const existingInteraction = findExistingBlank(existingScoring.id, availableInteractionBlanks)
if (!existingInteraction) {
return createNewBlank(blankText)
}
return {
scoringData: existingScoring,
interactionData: cleanupInteractionData(existingInteraction),
}
}
const restored = restoreBlankFromPool(blankText, availableDeletedBlanks)
return restored || createNewBlank(blankText)
}
const processBlankMatches = (
blankMatches,
existingScoringData,
existingInteractionBlanks,
deletedBlanksPool,
) => {
const availableScoringData = [...existingScoringData]
const availableInteractionBlanks = [...existingInteractionBlanks]
const availableDeletedBlanks = [...deletedBlanksPool]
const results = blankMatches.map((blankText, newIndex) =>
processBlankMatch(
blankText,
newIndex,
availableScoringData,
availableInteractionBlanks,
availableDeletedBlanks,
existingScoringData,
),
)
return {
newScoringDataValue: results.map(r => r.scoringData),
newInteractionDataBlanks: results.map(r => r.interactionData),
updatedDeletedBlanksPool: createDeletedBlanksPool(
availableScoringData,
availableInteractionBlanks,
availableDeletedBlanks,
),
}
}
const createDeletedBlanksPool = (
availableScoringData,
availableInteractionBlanks,
existingDeletedBlanks,
) => {
const newDeletedBlanks = availableScoringData.reduce((pool, scoringData) => {
const interactionDataIndex = availableInteractionBlanks.findIndex(
blank => blank.id === scoringData.id,
)
if (interactionDataIndex > -1) {
pool.push({
scoringData,
interactionData: availableInteractionBlanks[interactionDataIndex],
})
}
return pool
}, [])
return [...newDeletedBlanks, ...existingDeletedBlanks]
}
const extractChoiceIds = scoringDataList => {
return scoringDataList.map(data => data.scoringData.choiceId).filter(Boolean)
}
const changeItemStateWithProperties = (
interactionData,
properties,
changeItemState,
modifications,
blankId,
) => {
const newestIntData = Object.assign({}, interactionData, modifications.interactionData)
const newProperties = makeNewProperties(properties, newestIntData)
const newMods = {properties: newProperties}
if (modifications.scoringData) {
newMods.scoringData = modifications.scoringData
}
const newModifications = Object.assign({}, modifications, newMods)
changeItemState(newModifications, blankId)
}
const manageWordbankChoices = (
fitbCommonWordbankEnabled,
deletedDistractors,
newScoringDataValue,
newInteractionDataBlanks,
currentWordBankChoices,
hasWordbankBlanks,
hadWordbankBlanks,
) => {
if (!fitbCommonWordbankEnabled) return currentWordBankChoices
let wordBankChoices = currentWordBankChoices
// Restore distractors when wordbank blanks are restored
if (hasWordbankBlanks && !hadWordbankBlanks && deletedDistractors.length > 0) {
wordBankChoices = wordBankChoices ? [...wordBankChoices] : []
deletedDistractors.forEach(distractor => {
if (!wordBankChoices.some(choice => choice.id === distractor.id)) {
wordBankChoices.push(distractor)
}
})
}
// Restore wordbank choices for restored blanks
return newScoringDataValue.reduce((updated, scoringData, index) => {
const interactionData = newInteractionDataBlanks[index]
if (interactionData.answerType !== 'wordbank' || !scoringData.scoringData.choiceId) {
return updated
}
const choiceExists = updated?.some(choice => choice.id === scoringData.scoringData.choiceId)
if (!choiceExists) {
const choice = {
id: scoringData.scoringData.choiceId,
itemBody: scoringData.scoringData.blankText,
}
return updated ? [...updated, choice] : [choice]
}
return updated
}, wordBankChoices)
}
const updateDeletedDistractors = (
deletedDistractors,
interactionData,
hasWordbankBlanks,
hadWordbankBlanks,
wordBankChoices,
existingScoringData,
) => {
if (!hasWordbankBlanks && hadWordbankBlanks && wordBankChoices?.length > 0) {
const correctChoiceIds = extractChoiceIds(existingScoringData)
return wordBankChoices.filter(choice => !correctChoiceIds.includes(choice.id))
}
if (hasWordbankBlanks && deletedDistractors.length > 0) {
return []
}
return [...deletedDistractors]
}
function indexForBlank(blankId, interactionData) {
return interactionData.blanks.findIndex(blank => blank.id === blankId)
}
const indexForWordBankChoice = (choiceId, interactionData) => {
return interactionData.wordBankChoices.findIndex(wordBankChoice => wordBankChoice.id === choiceId)
}
export default function useBlankManagement({
interactionData,
scoringData,
changeItemState,
properties,
validBlankRegex,
deletedBlanksPool,
setDeletedBlanksPool,
deletedDistractors,
setDeletedDistractors,
fitbCommonWordbankEnabled,
}) {
const updateScoringDataForBlank = blankId => scoringDataMods => {
const indexForBlankValue = indexForBlank(blankId, interactionData)
const {workingItemBody} = scoringData
let index = 0
const newWorkingItemBody = workingItemBody.replace(validBlankRegex, match =>
index++ === indexForBlankValue
? `\`${escape(scoringDataMods.scoringData.blankText)}\``
: match,
)
const newScoringData = update(scoringData, {
workingItemBody: {$set: newWorkingItemBody},
value: {[indexForBlankValue]: {$merge: scoringDataMods}},
})
let newInteractionData = interactionData
// if the text of a wordbank choice was updated,
// reflect the change in the wordBankChoices list
if (scoringDataMods.scoringData.choiceId && interactionData.wordBankChoices) {
const wordBankChoiceIndex = indexForWordBankChoice(
scoringDataMods.scoringData.choiceId,
interactionData,
)
if (wordBankChoiceIndex > -1) {
newInteractionData = update(newInteractionData, {
wordBankChoices: {
[wordBankChoiceIndex]: {
itemBody: {$set: scoringDataMods.scoringData.blankText},
},
},
})
}
}
changeItemStateWithProperties(
interactionData,
properties,
changeItemState,
{scoringData: newScoringData, interactionData: newInteractionData},
blankId,
)
}
const updateBlank = blankId => modifications => {
const indexForBlankValue = indexForBlank(blankId, interactionData)
let blankData = interactionData.blanks[indexForBlankValue]
let mods = modifications
if (modifications.choices === null) {
const {choices: _, ...modsWithoutChoices} = modifications
mods = modsWithoutChoices
const {choices: __, ...blankDataWithoutChoices} = interactionData.blanks[indexForBlankValue]
blankData = blankDataWithoutChoices
}
// if there's a new wordbank choice, add it to the wordBankChoice list
let wordBankChoicesMods
if (modifications.choice) {
wordBankChoicesMods = update(
interactionData.wordBankChoices || [{id: uuid(), itemBody: ''}],
{$push: [modifications.choice]},
)
}
// omit "choice" because "choice" is added to wordBankChoices, not the blank itself
const {choice: ___, ...modsWithoutChoice} = modifications
mods = modsWithoutChoice
const finalMods = update(blankData, {$merge: mods})
let newInteractionData = update(interactionData, {
blanks: {
[indexForBlankValue]: {$set: finalMods},
},
...(wordBankChoicesMods && {wordBankChoices: {$set: wordBankChoicesMods}}),
})
// if the blank no longer has answerType "wordbank",
// remove the choice from wordBankChoices
const noLongerWordbank =
blankData.answerType === 'wordbank' &&
modifications.answerType &&
modifications.answerType !== 'wordbank'
if (noLongerWordbank && newInteractionData.wordBankChoices) {
const scoringDataForBlank = scoringData.value[indexForBlankValue]
const wordBankChoiceIndex = indexForWordBankChoice(
scoringDataForBlank.scoringData.choiceId,
interactionData,
)
newInteractionData = update(newInteractionData, {
wordBankChoices: {$splice: [[wordBankChoiceIndex, 1]]},
})
}
// if there are aren't any wordbank blanks,
// clear out wordBankChoices list
if (!newInteractionData.blanks.some(blank => blank.answerType === 'wordbank')) {
newInteractionData = update(newInteractionData, {$unset: ['wordBankChoices']})
}
changeItemStateWithProperties(
interactionData,
properties,
changeItemState,
{interactionData: newInteractionData},
blankId,
)
}
const handleDescriptionChange = itemBody => {
const blankMatches = extractBlankMatches(itemBody, validBlankRegex)
const existingScoringData = scoringData.value || []
const existingInteractionBlanks = interactionData.blanks || []
const {newScoringDataValue, newInteractionDataBlanks, updatedDeletedBlanksPool} =
processBlankMatches(
blankMatches,
existingScoringData,
existingInteractionBlanks,
deletedBlanksPool,
)
const hasWordbankBlanks = hasWordbankAnswerType(newInteractionDataBlanks)
const hadWordbankBlanks = hasWordbankAnswerType(existingInteractionBlanks)
let newWordBankChoices = manageWordbankChoices(
fitbCommonWordbankEnabled,
deletedDistractors,
newScoringDataValue,
newInteractionDataBlanks,
interactionData.wordBankChoices ? [...interactionData.wordBankChoices] : null,
hasWordbankBlanks,
hadWordbankBlanks,
)
// Remove choices for deleted wordbank blanks
const removedWordBankChoiceIds = extractChoiceIds(
(scoringData.value || []).filter(
(_, index) =>
!newScoringDataValue.some(newData => newData.id === (scoringData.value || [])[index]?.id),
),
)
if (newWordBankChoices && removedWordBankChoiceIds.length > 0) {
newWordBankChoices = newWordBankChoices.filter(
choice => !removedWordBankChoiceIds.includes(choice.id),
)
}
const newDeletedDistractors = updateDeletedDistractors(
deletedDistractors,
interactionData,
hasWordbankBlanks,
hadWordbankBlanks,
newWordBankChoices,
existingScoringData,
)
if (!hasWordbankBlanks) {
newWordBankChoices = null
}
setDeletedBlanksPool(updatedDeletedBlanksPool)
setDeletedDistractors(newDeletedDistractors)
const newInteractionData = update(interactionData, {
blanks: {$set: newInteractionDataBlanks},
...(newWordBankChoices !== null
? {wordBankChoices: {$set: newWordBankChoices}}
: {$unset: ['wordBankChoices']}),
})
changeItemState({
interactionData: newInteractionData,
scoringData: {
workingItemBody: itemBody,
value: newScoringDataValue,
},
})
}
return {
handleDescriptionChange,
updateBlank,
updateScoringDataForBlank,
}
}