UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

499 lines (427 loc) • 15 kB
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, } }