UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

461 lines (423 loc) • 12.6 kB
/** @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