UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

466 lines (413 loc) • 15.3 kB
/** @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> ``` **/ @withStyleOverrides(generateStyle, generateComponentTheme) 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> ) } }