@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
466 lines (413 loc) • 15.3 kB
JavaScript
/** @jsx jsx */
import {Component} from 'react'
import ReactDOM from 'react-dom'
import PropTypes from 'prop-types'
import {v4 as uuid} from 'uuid'
import {Text} from '@instructure/ui-text'
import {jsx} from '@instructure/emotion'
import t from '@instructure/quiz-i18n/format-message'
import {ItemBodyWrapper} from '@instructure/quiz-rce/components/ItemBodyWrapper/index'
import {FeedbackWrapper} from '@instructure/quiz-results-feedback'
import {trimPunctuation, getTrimmedPunctuation} from '../helpers/punctuation'
import generateStyle from './styles'
import generateComponentTheme from './theme'
import {withStyleOverrides} from '@instructure/quiz-common/util/withStyleOverrides'
/**
---
category: FillInTheBlank
---
Fill in the Blank Result component
```jsx_example
<SettingsSwitcher locales={LOCALES}>
<FillBlankResult
interactionData={{
prompt: '<p><strong>Please</strong> fill in all the blanks</p>',
stemItems: [
{ id: 'stem_uuid-1', position: 1, type: 'text', value: ' ' },
{ id: 'stem_uuid0', position: 2, type: 'blank', blankId: 'fitb_uuid1' },
{ id: 'stem_uuid1', position: 3, type: 'text', value: ' Columbus sailed in ' },
{ id: 'stem_uuid2', position: 4, type: 'blank', blankId: 'fitb_uuid2' },
{ id: 'stem_uuid3', position: 5, type: 'text', value: ' , from the country of ' },
{ id: 'stem_uuid4', position: 6, type: 'blank', blankId: 'fitb_uuid3' },
{ id: 'stem_uuid5', position: 7, type: 'text', value: ' , on the continent of ' },
{ id: 'stem_uuid6', position: 8, type: 'blank', blankId: 'fitb_uuid4' },
{ id: 'stem_uuid7', position: 9, type: 'text', value: ' , across the ' },
{ id: 'stem_uuid8', position: 10, type: 'blank', blankId: 'fitb_uuid5' },
{ id: 'stem_uuid9', position: 11, type: 'text', value: ' , and found ' },
{ id: 'stem_uuid10', position: 12, type: 'blank', blankId: 'fitb_uuid6' },
{ id: 'stem_uuid11', position: 13, type: 'text', value: ' ! Where the people were ' },
{ id: 'stem_uuid12', position: 14, type: 'blank', blankId: 'fitb_uuid7' },
{ id: 'stem_uuid13', position: 15, type: 'text', value: ' ... ' }
],
blanks: [
{ id: 'fitb_uuid1', answerType: 'openEntry' },
{ id: 'fitb_uuid2', answerType: 'openEntry' },
{ id: 'fitb_uuid3', answerType: 'openEntry' },
{ id: 'fitb_uuid4', answerType: 'openEntry' },
{ id: 'fitb_uuid5', answerType: 'openEntry' },
{
id: 'fitb_uuid6',
choices: [
{ id: 'choice_uuid11_brazil', position: 1, itemBody: 'Brazil' },
{ id: 'choice_uuid12_austria', position: 2, itemBody: 'Austria' },
{ id: 'choice_uuid13_america', position: 3, itemBody: 'America' }
],
answerType: 'wordbank'
},
{
id: 'fitb_uuid7',
choices: [
{ id: 'choice_uuid11_peaceful', position: 1, itemBody: 'peaceful' },
{ id: 'choice_uuid12_war-torn', position: 2, itemBody: 'war-torn' },
{ id: 'choice_uuid13_confused', position: 3, itemBody: 'confused' }
],
answerType: 'dropdown'
}
]
}}
scoredData={{
value: {
fitb_uuid1: {
resultScore: 1,
userResponse: 'Christopfer',
correctAnswer: 'Christopher'
},
fitb_uuid2: {
resultScore: 1,
userResponse: '1492'
},
fitb_uuid3: {
value: {
Spain: { resultScore: 1, userResponded: false },
Espana: { resultScore: 1, userResponded: false },
'Kingdom of Spain': { resultScore: 1, userResponded: false },
Portugal: { resultScore: 0, userResponded: true }
}
},
fitb_uuid4: {
value:{
Europe: { resultScore: 1, userResponded: false },
Asia: { resultScore: 0, userResponded: true }
}
},
fitb_uuid5: {
resultScore: 1,
correctAnswer: 'Atlantic',
userResponse: 'Atlantic Ocean'
},
fitb_uuid6: {
value: {
choice_uuid13_america: {
resultScore: 1,
userResponded: true
}
}
},
fitb_uuid7: {
value: {
choice_uuid13_confused: {
resultScore: 1,
userResponded: false
},
choice_uuid11_peaceful: {
resultScore: 0,
userResponded: true
}
}
}
}
}}
/>
</SettingsSwitcher>
```
**/
export default class FillBlankResult extends Component {
static displayName = 'FillBlankResult'
static componentId = `Quizzes${this.displayName}`
static propTypes = {
interactionData: PropTypes.object.isRequired,
itemBody: PropTypes.string,
richFITB: PropTypes.bool,
scoredData: PropTypes.shape({
correct: PropTypes.bool,
value: PropTypes.objectOf(PropTypes.object),
}).isRequired,
makeStyles: PropTypes.func,
styles: PropTypes.object,
}
static defaultProps = {
itemBody: '',
richFITB: false,
}
state = {
isMounted: false,
}
itemBodyWrapperId = uuid()
mutationObserver = null
imageLoadHandlers = []
pendingUpdate = null
mutationDebounceTimeout = null
componentDidUpdate(prevProps) {
this.props.makeStyles()
if (this.props.interactionData.blanks !== prevProps.interactionData.blanks) {
this.renderRichBlanks()
this.setupImageLoadListeners()
this.setupMutationObserver()
}
}
componentDidMount() {
this.props.makeStyles()
this.setState({isMounted: true})
this.setupImageLoadListeners()
this.setupMutationObserver()
}
componentWillUnmount() {
this.cleanupImageLoadListeners()
this.cleanupMutationObserver()
if (this.pendingUpdate) {
cancelAnimationFrame(this.pendingUpdate)
this.pendingUpdate = null
}
if (this.mutationDebounceTimeout) {
clearTimeout(this.mutationDebounceTimeout)
this.mutationDebounceTimeout = null
}
}
setupImageLoadListeners = () => {
if (!this.props.richFITB) return
// Clean up existing listeners
this.cleanupImageLoadListeners()
const itemBodyWrapper = document.getElementById(this.itemBodyWrapperId)
if (!itemBodyWrapper) return
const images = itemBodyWrapper.querySelectorAll('img')
images.forEach(img => {
const handleLoad = () => {
// Use requestAnimationFrame to ensure DOM has updated before re-rendering
if (this.pendingUpdate) {
cancelAnimationFrame(this.pendingUpdate)
}
this.pendingUpdate = requestAnimationFrame(() => {
this.pendingUpdate = null
this.forceUpdate()
})
}
// If image is already loaded, still trigger re-render
if (img.complete && img.naturalHeight !== 0) {
handleLoad()
} else {
img.addEventListener('load', handleLoad)
this.imageLoadHandlers.push({img, handler: handleLoad})
}
})
}
cleanupImageLoadListeners = () => {
this.imageLoadHandlers.forEach(({img, handler}) => {
img.removeEventListener('load', handler)
})
this.imageLoadHandlers = []
}
setupMutationObserver = () => {
if (!this.props.richFITB) return
// Clean up existing observer
this.cleanupMutationObserver()
const itemBodyWrapper = document.getElementById(this.itemBodyWrapperId)
if (!itemBodyWrapper) return
// Create observer to watch for blank elements being added to DOM
this.mutationObserver = new MutationObserver(mutations => {
const hasNewBlankElements = mutations.some(mutation => {
return Array.from(mutation.addedNodes).some(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
return node.id?.startsWith('blank_') || node.querySelector?.('[id^="blank_"]')
}
return false
})
})
if (hasNewBlankElements) {
// Debounce to batch multiple mutations into a single re-render
if (this.mutationDebounceTimeout) {
clearTimeout(this.mutationDebounceTimeout)
}
this.mutationDebounceTimeout = setTimeout(() => {
this.mutationDebounceTimeout = null
this.forceUpdate()
}, 50)
}
})
this.mutationObserver.observe(itemBodyWrapper, {
childList: true,
subtree: true,
})
}
cleanupMutationObserver = () => {
if (this.mutationObserver) {
this.mutationObserver.disconnect()
this.mutationObserver = null
}
if (this.mutationDebounceTimeout) {
clearTimeout(this.mutationDebounceTimeout)
this.mutationDebounceTimeout = null
}
}
__multipleChoiceResult(blank, blankResultValue, choiceResult) {
const pickedChoice = choiceResult.id && this.__getChoiceById(choiceResult.id)
const userResponseItemBody = pickedChoice && pickedChoice.itemBody
let correctAnswerItemBody
// For dropdown and wordbank the resultScore is not present if the question was not answered.
if (!choiceResult.resultScore || choiceResult.resultScore <= 0) {
const correctAnswerKey = Object.keys(blankResultValue).find(
choice => blankResultValue[choice].resultScore === 1,
)
const correctAnswer = correctAnswerKey && this.__getChoiceById(correctAnswerKey)
correctAnswerItemBody = correctAnswer && correctAnswer.itemBody
}
return {
resultScore: choiceResult.resultScore,
userResponse: userResponseItemBody,
correctAnswer: correctAnswerItemBody,
}
}
__specifiedAnswersResult(blank, blankResultValue, choiceResult) {
const correctAnswers = Object.keys(blankResultValue).filter(
answer => blankResultValue[answer].resultScore === 1,
)
return {
resultScore: choiceResult.resultScore,
userResponse: choiceResult.id,
correctAnswer: correctAnswers.join(t(', ')),
}
}
__getUserResponseFromChoices(blankResultValue) {
if (!blankResultValue) return {}
const userResponseKey = Object.keys(blankResultValue).find(
choiceKey => blankResultValue[choiceKey].userResponded,
)
return Object.assign({}, blankResultValue[userResponseKey], {id: userResponseKey})
}
__getBlankScoredData(blankId) {
const {scoredData} = this.props
const blankResult = scoredData.value ? scoredData.value[blankId] : {}
const blank = this.props.interactionData.blanks.find(blank => blank.id === blankId)
const blankResultValue = blankResult && blankResult.value
if (!blankResultValue) {
if (blankResult && Array.isArray(blankResult.correctAnswer)) {
blankResult.correctAnswer = blankResult.correctAnswer.join(t(', '))
}
return blankResult
}
const choiceResult = this.__getUserResponseFromChoices(blankResultValue)
if (
blank.answerType !== 'openEntry' &&
!this.__malformedWordBankBlankResult(blank, blankResult)
) {
return this.__multipleChoiceResult(blank, blankResultValue, choiceResult)
}
return this.__specifiedAnswersResult(blank, blankResultValue, choiceResult)
}
__malformedWordBankBlankResult(blank, blankResult) {
return blank.answerType === 'wordbank' && 'value' in blankResult && !blank['choices']
}
__getChoiceById(choiceId) {
const blanks = this.props.interactionData.blanks || []
const allChoices = blanks.reduce((choices, blank) => choices.concat(blank.choices || []), [])
return allChoices.find(blank => blank.id === choiceId)
}
__responseHidden() {
return !this.props.scoredData.value
}
__correctnessKnown() {
return typeof this.props.scoredData.correct !== 'undefined'
}
// =============
// RENDER
// =============
getBlankStatus = blankId => {
const blankResult = this.__getBlankScoredData(blankId)
let status
if (blankResult && blankResult.resultScore > 0) {
status = 'correct'
} else if ((blankResult && blankResult.resultScore <= 0) || this.__correctnessKnown()) {
status = 'incorrect'
} else {
status = 'unknown'
}
return status
}
renderBlank = (blankId, renderBefore = null, renderAfer = null) => {
const blankResult = this.__getBlankScoredData(blankId)
const status = this.getBlankStatus(blankId)
const innerStyle = {...this.props.styles.feedbackWrapper}
const mergedStyle =
status === 'incorrect'
? {...innerStyle, ...this.props.styles.incorrectFeedbackWrapper}
: innerStyle
const responseText = blankResult && blankResult.userResponse
return (
<div key={blankId} css={this.props.styles.blank}>
{renderBefore}
<div css={mergedStyle}>
<FeedbackWrapper
userResponse={blankResult && blankResult.userResponse}
correctAnswer={blankResult && blankResult.correctAnswer}
hiddenCorrectAnswerText={t('Correct answer:')}
hiddenIncorrectAnswerText={t('Incorrect answer:')}
status={status}
>
<div css={this.props.styles.userResponse}>
<Text wrap="break-word" color="primary">
{responseText ||
(this.__responseHidden() ? t('(response not displayed)') : t('(no answer)'))}
</Text>
</div>
</FeedbackWrapper>
</div>
{renderAfer}
</div>
)
}
renderStemItemBlank = stemItem => {
const punctuation = getTrimmedPunctuation(stemItem, this.props.interactionData.stemItems)
return this.renderBlank(stemItem.blankId, punctuation.start, punctuation.end)
}
renderRichBlanks = () => {
const itemBodyWrapper = document.getElementById(this.itemBodyWrapperId)
if (!itemBodyWrapper) return null
return this.props.interactionData.blanks
.map(blank => {
const targetEl = itemBodyWrapper.querySelector(`#blank_${blank.id}`)
return targetEl
? ReactDOM.createPortal(this.renderBlank(blank.id), targetEl, blank.id)
: null
})
.filter(Boolean)
}
renderStemItem = stemItem => {
if (stemItem.type === 'text') {
return (
<Text wrap="break-word" color="primary" key={stemItem.id}>
{trimPunctuation(stemItem, this.props.interactionData.stemItems)}
</Text>
)
}
return this.renderStemItemBlank(stemItem)
}
render() {
if (this.props.richFITB) {
return (
<ItemBodyWrapper id={this.itemBodyWrapperId} itemBody={this.props.itemBody}>
{this.renderRichBlanks()}
</ItemBodyWrapper>
)
}
return (
<ItemBodyWrapper itemBody={this.props.interactionData.prompt}>
<div css={this.props.styles.stem}>
{(this.props.interactionData.stemItems || [])
.slice()
.sort((a, b) => a.position - b.position)
.map(this.renderStemItem)}
</div>
</ItemBodyWrapper>
)
}
}