@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
461 lines (423 loc) • 12.6 kB
JavaScript
/** @jsx jsx */
import React, {useState, useEffect, useMemo, useCallback} from 'react'
import ReactDOM from 'react-dom'
import PropTypes from 'prop-types'
import {v4 as uuid} from 'uuid'
import {jsx, css} from '@instructure/emotion'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import {Text} from '@instructure/ui-text'
import {ItemBodyWrapper} from '@instructure/quiz-rce/components/ItemBodyWrapper/index'
import BlankDropTarget from '../../drag_components/BlankDropTarget'
import {trimPunctuation, getTrimmedPunctuation} from '../../helpers/punctuation'
import generateStyle from './styles'
import generateComponentTheme from './theme'
import t from '@instructure/quiz-i18n/format-message'
import {SimpleSelect} from '@instructure/quiz-common/components/SimpleSelect/index'
import {TextInput} from '@instructure/quiz-common/components/TextInput/index'
import {withStyleOverrides} from '@instructure/quiz-common/util/withStyleOverrides'
// ============================================
// Sub-components for different blank types
// ============================================
function DropdownBlank({blank, blankSRText, value, onUpdate, readOnly, styles}) {
const selectOptionIdPrefix = 'take-stem-select-option-'
const choice = blank.choices.find(c => c.id === value)
const handleChange = useCallback(
(_event, {value}) => {
onUpdate(blank.id, value)
},
[blank.id, onUpdate],
)
return (
<>
<div css={styles.printBlank}>
<TextInput
display="inline-block"
renderLabel={<ScreenReaderContent>{t('Printed Blank')}</ScreenReaderContent>}
interaction="readonly"
defaultValue={choice && choice.itemBody}
/>
</div>
<div className="fs-mask" css={styles.noPrintBlank}>
<SimpleSelect
value={value}
renderLabel={<ScreenReaderContent>{blankSRText}</ScreenReaderContent>}
onChange={handleChange}
interaction={readOnly ? 'readonly' : 'enabled'}
mountNode={() => document.body}
optionsMaxHeight="12rem"
>
<SimpleSelect.Option
id="take-stem-select-option-empty"
value=""
style={css(styles.selectPlaceholder).styles}
>
{t('choose your answer...')}
</SimpleSelect.Option>
{blank.choices.map(choice => (
<SimpleSelect.Option
id={selectOptionIdPrefix + choice.id}
key={choice.id}
value={choice.id}
>
{choice.itemBody}
</SimpleSelect.Option>
))}
</SimpleSelect>
</div>
</>
)
}
function WordbankBlank({
blank,
blankSRText,
choice,
wordBankChoices,
onUpdate,
returnChoiceToWordbank,
readOnly,
styles,
}) {
const handleChange = useCallback(
choice => {
onUpdate(blank.id, choice)
},
[blank.id, onUpdate],
)
const handleReturnChoice = useCallback(
choiceId => {
returnChoiceToWordbank(blank.id, choiceId)
},
[blank.id, returnChoiceToWordbank],
)
return (
<>
<div css={styles.printBlank}>
<TextInput
display="inline-block"
renderLabel={<ScreenReaderContent>{t('Printed Blank')}</ScreenReaderContent>}
interaction="readonly"
defaultValue={choice && choice.itemBody}
/>
</div>
<div className="fs-mask" css={styles.noPrintBlank}>
<ScreenReaderContent>{blankSRText}</ScreenReaderContent>
<BlankDropTarget
key={blank.id}
choices={wordBankChoices}
choice={choice}
blankId={blank.id}
onChange={handleChange}
onDrop={handleChange}
readOnly={readOnly}
returnChoiceToWordbank={handleReturnChoice}
/>
</div>
</>
)
}
function OpenEntryBlank({blank, blankSRText, value, onUpdate, readOnly}) {
const handleChange = useCallback(
e => {
onUpdate(blank.id, e.target.value.trim())
},
[blank.id, onUpdate],
)
return (
<span className="fs-mask">
<TextInput
display="inline-block"
renderLabel={<ScreenReaderContent>{blankSRText}</ScreenReaderContent>}
placeholder={t('type your answer...')}
defaultValue={readOnly ? void 0 : value}
value={readOnly ? value : void 0}
onChange={handleChange}
interaction={readOnly ? 'readonly' : 'enabled'}
data-automation="sdk-fitb-taking-answer"
autoComplete="off"
/>
</span>
)
}
function Blank({
blank,
blankSRText,
value,
choice,
wordBankChoices,
onUpdate,
onUpdateWordBank,
returnChoiceToWordbank,
readOnly,
styles,
}) {
switch (blank.answerType) {
case 'openEntry':
return (
<OpenEntryBlank
blank={blank}
blankSRText={blankSRText}
value={value}
onUpdate={onUpdate}
readOnly={readOnly}
/>
)
case 'dropdown':
return (
<DropdownBlank
blank={blank}
blankSRText={blankSRText}
value={value}
onUpdate={onUpdate}
readOnly={readOnly}
styles={styles}
/>
)
default:
return (
<WordbankBlank
blank={blank}
blankSRText={blankSRText}
choice={choice}
wordBankChoices={wordBankChoices}
onUpdate={onUpdateWordBank}
returnChoiceToWordbank={returnChoiceToWordbank}
readOnly={readOnly}
styles={styles}
/>
)
}
}
function StemItem({
stemItem,
stemItems,
blanks,
getValueForBlank,
getWordbankChoiceByResponseValue,
onUpdate,
onUpdateWordBank,
returnChoiceToWordbank,
wordBankChoices,
readOnly,
styles,
}) {
if (stemItem.type === 'text') {
return (
<Text color="primary" key={stemItem.id}>
{trimPunctuation(stemItem, stemItems)}
</Text>
)
}
const blank = blanks.find(b => b.id === stemItem.blankId)
const punctuation = getTrimmedPunctuation(stemItem, stemItems)
const blankPosition = blanks.indexOf(blank) + 1
const value = getValueForBlank(blank.id)
const blankResponseValue = value
const choice = getWordbankChoiceByResponseValue(blankResponseValue)
const blankSRText = t('Question Blank { blankPosition } of { totalBlanks }', {
blankPosition,
totalBlanks: blanks.length,
})
return (
<span key={stemItem.id} className="stemItem" css={styles.stemItem}>
{punctuation.start}
<Blank
blank={blank}
blankSRText={blankSRText}
value={value}
choice={choice}
wordBankChoices={wordBankChoices}
onUpdate={onUpdate}
onUpdateWordBank={onUpdateWordBank}
returnChoiceToWordbank={returnChoiceToWordbank}
readOnly={readOnly}
styles={styles}
/>
{punctuation.end}
</span>
)
}
function RichBlanks({
isMounted,
componentId,
blanks,
getValueForBlank,
getWordbankChoiceByResponseValue,
onUpdate,
onUpdateWordBank,
returnChoiceToWordbank,
wordBankChoices,
readOnly,
styles,
}) {
if (!isMounted) {
return null
}
const questionWrapper = document.getElementById(componentId)
return blanks.map(blank => {
const blankPosition = blanks.indexOf(blank) + 1
const value = getValueForBlank(blank.id)
const blankResponseValue = value
const choice = getWordbankChoiceByResponseValue(blankResponseValue)
const blankSRText = t('Question Blank { blankPosition } of { totalBlanks }', {
blankPosition,
totalBlanks: blanks.length,
})
if (questionWrapper?.querySelector(`#blank_${blank.id}`)) {
return ReactDOM.createPortal(
<Blank
blank={blank}
blankSRText={blankSRText}
componentId={componentId}
value={value}
choice={choice}
wordBankChoices={wordBankChoices}
onUpdate={onUpdate}
onUpdateWordBank={onUpdateWordBank}
returnChoiceToWordbank={returnChoiceToWordbank}
readOnly={readOnly}
styles={styles}
/>,
questionWrapper.querySelector(`#blank_${blank.id}`),
)
}
return null
})
}
// ============================================
// Main Component
// ============================================
const TakeStem = React.forwardRef((props, ref) => {
const {
interactionData,
userResponses,
onUpdate,
readOnly,
wordBankChoices,
returnChoiceToWordbank,
styles,
itemBody,
richFITB,
} = props
const [isMounted, setIsMounted] = useState(false)
const componentId = useMemo(() => uuid(), [])
useEffect(() => {
setIsMounted(true)
}, [])
// =============
// HELPERS
// =============
const getWordbankChoiceById = useCallback(
choiceId => {
return interactionData.blanks
.map(b => (b.answerType === 'wordbank' ? b.choices : []))
.flat()
.find(choice => choice.id === choiceId)
},
[interactionData.blanks],
)
const getWordbankChoiceByResponseValue = useCallback(
responseValue => {
if (interactionData.wordBankChoices) {
return interactionData.wordBankChoices.find(choice => {
return choice.itemBody === responseValue
})
}
return getWordbankChoiceById(responseValue)
},
[interactionData.wordBankChoices, getWordbankChoiceById],
)
const updateUserResponse = useCallback(
(blankId, blankValue) => {
const newResponses = userResponses
.filter(blank => blank.id !== blankId)
.concat([{id: blankId, value: blankValue, type: 'Text'}])
onUpdate(newResponses)
},
[userResponses, onUpdate],
)
const updateWordBankUserResponse = useCallback(
(blankId, choice) => {
const {wordBankChoices: wbChoices} = interactionData
const choiceValue = wbChoices ? choice.itemBody : choice.id
updateUserResponse(blankId, choiceValue)
},
[interactionData, updateUserResponse],
)
const getValueForBlank = useCallback(
blankId => {
const objectForBlank = userResponses.find(response => response.id === blankId)
return objectForBlank && 'value' in objectForBlank ? objectForBlank.value : null
},
[userResponses],
)
if (richFITB) {
return (
<ItemBodyWrapper id={componentId} itemBody={itemBody}>
<RichBlanks
isMounted={isMounted}
componentId={componentId}
blanks={interactionData.blanks}
getValueForBlank={getValueForBlank}
getWordbankChoiceByResponseValue={getWordbankChoiceByResponseValue}
onUpdate={updateUserResponse}
onUpdateWordBank={updateWordBankUserResponse}
returnChoiceToWordbank={returnChoiceToWordbank}
wordBankChoices={wordBankChoices}
readOnly={readOnly}
styles={styles}
/>
</ItemBodyWrapper>
)
}
return (
<div css={styles.stemWrapper} data-automation="take-stem-wrapper">
{interactionData.stemItems
.slice()
.sort((a, b) => a.position - b.position)
.map(stemItem => (
<StemItem
key={stemItem.id}
stemItem={stemItem}
stemItems={interactionData.stemItems}
blanks={interactionData.blanks}
getValueForBlank={getValueForBlank}
getWordbankChoiceByResponseValue={getWordbankChoiceByResponseValue}
onUpdate={updateUserResponse}
onUpdateWordBank={updateWordBankUserResponse}
returnChoiceToWordbank={returnChoiceToWordbank}
wordBankChoices={wordBankChoices}
readOnly={readOnly}
styles={styles}
/>
))}
</div>
)
})
TakeStem.displayName = 'TakeStem'
TakeStem.propTypes = {
onUpdate: PropTypes.func.isRequired,
interactionData: PropTypes.object.isRequired,
itemBody: PropTypes.string,
returnChoiceToWordbank: PropTypes.func,
userResponses: PropTypes.array,
readOnly: PropTypes.bool,
richFITB: PropTypes.bool,
wordBankChoices: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string,
itemBody: PropTypes.string,
}),
),
styles: PropTypes.object,
}
TakeStem.defaultProps = {
itemBody: '',
returnChoiceToWordbank: void 0,
userResponses: [],
readOnly: false,
richFITB: false,
wordBankChoices: void 0,
}
const TakeStemWithStyles = withStyleOverrides(generateStyle, generateComponentTheme)(TakeStem)
TakeStemWithStyles.componentId = `Quizzes${TakeStem.displayName}`
export default TakeStemWithStyles