@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
405 lines (371 loc) • 13.5 kB
JavaScript
/** @jsx jsx */
import React, {useRef, useState} from 'react'
import PropTypes from 'prop-types'
import concat from 'lodash/fp/concat'
import find from 'lodash/fp/find'
import get from 'lodash/get'
import {Button} from '@instructure/ui-buttons'
import {Text} from '@instructure/ui-text'
import {jsx} from '@instructure/emotion'
import Errors from '../../common/edit/components/Errors'
import QuestionSettingsContainer from '../../common/edit/components/QuestionSettingsContainer'
import QuestionContainer from '../../common/edit/components/QuestionContainer'
import FillBlankInteractionType from '../../../records/interactions/fill_blank'
import BlankTypeSelect from './answer_field/BlankTypeSelect'
import EditStem from './EditStem'
import EditController from './editController'
import generateStyle from './styles'
import generateComponentTheme from './theme'
import withEditTools from '../../../util/withEditTools'
import t from '@instructure/quiz-i18n/format-message'
import QuestionSettingsPanel from '../../common/edit/components/QuestionSettingsPanel'
import CalculatorOptionWithOqaatAlert from '../../common/edit/components/CalculatorOptionWithOqaatAlert'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import {withStyleOverrides} from '@instructure/quiz-common/util/withStyleOverrides'
import {FormFieldGroup} from '@instructure/quiz-common/components/FormFieldGroup/index'
const SPACE_KEY_CODE = 32
const ENTER_KEY_CODE = 13
/**
category: FillInTheBlank
Fill in the Blank Edit component
```jsx_example
function Example (props) {
const exampleProps = {
interactionData: {
prompt: '<p><strong>Please</strong> fill in all the blanks</p>',
blanks: [{
answerType: 'openEntry',
id: 'uuid-blank1'
}, {
answerType: 'openEntry',
id: 'uuid-blank2'
}, {
answerType: 'openEntry',
id: 'uuid-blank3'
}, {
answerType: 'openEntry',
id: 'uuid-blank4'
}, {
answerType: 'openEntry',
id: 'uuid-blank5'
}, {
answerType: 'dropdown',
id: 'uuid-blank6',
choices: [
{ id: 'uuid6-choice1', position: 1, itemBody: 'with' },
{ id: 'uuid6-choice2', position: 2, itemBody: 'on' },
{ id: 'uuid6-choice3', position: 3, itemBody: 'which' }
]
}, {
answerType: 'wordbank',
id: 'uuid-blank7',
choices: [
{ id: 'uuid7-choice1', position: 1, itemBody: 'bathwater' },
{ id: 'uuid7-choice2', position: 2, itemBody: 'bathtub' },
{ id: 'uuid7-choice3', position: 3, itemBody: 'water' }
]
}],
stemItems: [
{ id: 'uuid-stem0', position: 1, type: 'text', value: ' ' },
{ id: 'uuid-stem1', position: 2, type: 'blank', blankId: 'uuid-blank1' },
{ id: 'uuid-stem2', position: 3, type: 'text', value: ' ' },
{ id: 'uuid-stem3', position: 4, type: 'blank', blankId: 'uuid-blank2' },
{ id: 'uuid-stem4', position: 5, type: 'text', value: ' ' },
{ id: 'uuid-stem5', position: 6, type: 'blank', blankId: 'uuid-blank3' },
{ id: 'uuid-stem6', position: 7, type: 'text', value: ' ' },
{ id: 'uuid-stem7', position: 8, type: 'blank', blankId: 'uuid-blank4' },
{ id: 'uuid-stem8', position: 9, type: 'text', value: ' ' },
{ id: 'uuid-stem9', position: 10, type: 'blank', blankId: 'uuid-blank5' },
{ id: 'uuid-stem10', position: 11, type: 'text', value: ' ' },
{ id: 'uuid-stem11', position: 12, type: 'blank', blankId: 'uuid-blank6' },
{ id: 'uuid-stem12', position: 13, type: 'text', value: ' <the> ' },
{ id: 'uuid-stem13', position: 14, type: 'blank', blankId: 'uuid-blank7' },
{ id: 'uuid-stem14', position: 15, type: 'text', value: '.' }
]
},
scoringData: {
value: [{
id: 'uuid-blank1',
scoringAlgorithm: 'TextCloseEnough',
scoringData: {
blankText: 'Don\'t',
editDistance: '1',
ignoreCase: true,
value: 'Don\'t'
}
}, {
id: 'uuid-blank2',
scoringAlgorithm: 'TextEquivalence',
scoringData: {
blankText: 'throw',
value: 'throw'
}
}, {
id: 'uuid-blank3',
scoringAlgorithm: 'TextContainsAnswer',
scoringData: {
blankText: 'the',
value: 'the'
}
}, {
id: 'uuid-blank4',
scoringAlgorithm: 'TextInChoices',
scoringData: {
blankText: 'baby',
value: ['baby', 'kid', 'child']
}
}, {
id: 'uuid-blank5',
scoringAlgorithm: 'TextRegex',
scoringData: {
blankText: 'out',
value: 'out'
}
}, {
id: 'uuid-blank6',
scoringAlgorithm: 'Equivalence',
scoringData: {
blankText: 'with',
value: 'uuid6-choice1'
}
}, {
id: 'uuid-blank7',
scoringAlgorithm: 'Equivalence',
scoringData: {
blankText: 'bathwater',
value: 'uuid7-choice1'
}
}]
}
}
return (
<FillBlankEdit {...exampleProps} {...props} />
)
}
<SettingsSwitcher locales={LOCALES}>
<EditStateProvider>
<Example />
</EditStateProvider>
</SettingsSwitcher>
```
**/
const BaseFillBlankEdit = React.forwardRef((props, ref) => {
const {
additionalOptions,
calculatorType,
enableRichContentEditor,
errors,
errorsAreShowing,
fitbCommonWordbankEnabled,
interactionData,
isSurvey,
notifyScreenreader,
onModalClose,
onModalOpen,
oneQuestionAtATime,
openImportModal,
overrideEditableForRegrading,
scoringData,
setOneQuestionAtATime,
validationErrorsFromApi,
styles,
showCalculatorOption,
} = props
const [selectedText, setSelectedText] = useState('')
const controllerRef = useRef(new EditController(props))
controllerRef.current.updateProps(props)
// Other edit components use the `getErrors` helper passed in from the
// withEditTools higher-order component, but fill blank has its own error
// handling logic
const getErrors = path => {
if (errorsAreShowing) {
return get(errors, path, null)
}
return null
}
// ================
// HANDLERS
// ================
const handleSetSelectedText = text => {
setSelectedText(text)
}
const handleCreateBlank = event => {
controllerRef.current.onCreateBlank(event)
setSelectedText('')
}
const handleCreateBlankHotkey = event => {
if (
event.which === ENTER_KEY_CODE ||
(event.which === SPACE_KEY_CODE && event.ctrlKey && event.altKey)
) {
event.preventDefault()
handleCreateBlank(event)
notifyScreenreader(t('Blank created. Navigate to edit blank below.'))
}
}
const handleDescriptionChange = prompt => {
controllerRef.current.updateInteractionData({prompt})
}
const updateBlank = blankId => blankMods => {
controllerRef.current.updateBlank(blankId, blankMods)
}
const updateBlankScoringData = blankId => scoringDataModifications => {
controllerRef.current.updateScoringDataForBlank(blankId, scoringDataModifications)
}
// =============
// RENDERING
// =============
const renderBlanksOptions = () => {
return controllerRef.current.sortedBlanks().map((blank, blankIndex) => {
const blankScoringData = find({id: blank.id}, scoringData.value) || {}
const choicesErrors = concat(
getErrors(`scoringData.value[${blankIndex}].scoringData.value.$errors`) || [],
getErrors(`interactionData.blanks[${blankIndex}].choices.$errors`) || [],
)
return (
<BlankTypeSelect
key={blank.id}
answerType={blank.answerType}
blankPosition={blankIndex + 1}
blankScoringAlgorithm={blankScoringData.scoringAlgorithm}
blankScoringData={blankScoringData.scoringData}
disabled={overrideEditableForRegrading}
choices={blank.choices}
fitbCommonWordbankEnabled={fitbCommonWordbankEnabled}
interactionDataErrors={getErrors(`interactionData.blanks[${blankIndex}]`) || {}}
isSurvey={isSurvey}
name={blank.id}
notifyScreenreader={notifyScreenreader}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
scoringDataErrors={getErrors(`scoringData.value[${blankIndex}].scoringData`) || {}}
choicesErrors={choicesErrors}
validationErrorsFromApi={validationErrorsFromApi[blank.id] || []}
updateBlank={updateBlank(blank.id)}
updateBlankScoringData={updateBlankScoringData(blank.id)}
useCommonWordbank={false}
/>
)
})
}
const renderCreateBlankButton = () => {
if (selectedText.trim() && !overrideEditableForRegrading) {
return (
<div css={styles.createButton}>
<Button onClick={handleCreateBlank} color="primary-inverse">
<span css={styles.createBlankText}>{t('Create Blank Space')}</span>
<span css={styles.createBlankHotkeyText}>{t('(Enter)')}</span>
</Button>
</div>
)
}
}
const renderOptionsDescription = () => {
return <ScreenReaderContent>{t('Fill in the blank options')}</ScreenReaderContent>
}
return (
<div>
<QuestionContainer
disabled={overrideEditableForRegrading}
enableRichContentEditor={enableRichContentEditor}
itemBody={interactionData.prompt}
onDescriptionChange={handleDescriptionChange}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
openImportModal={openImportModal}
placeholder={t('Add Question Stem (optional)...')}
stemErrors={[]}
>
<div css={styles.hint}>
<Text color="primary">
{t('Type a statement, select text, and press Enter to create a new blank.')}
</Text>
</div>
<EditStem
disabled={overrideEditableForRegrading}
errors={getErrors('itemBody') || []}
destroyBlank={controllerRef.current.onDestroyBlank}
onCreateBlankHotkey={handleCreateBlankHotkey}
onStemChange={controllerRef.current.onStemChange}
scoringData={scoringData}
setSelectedText={handleSetSelectedText}
stemItems={controllerRef.current.sortedStemItems()}
/>
<div>
{renderCreateBlankButton()}
<Errors errorList={getErrors(['interactionData', 'blanks', '$errors']) || []}>
{renderBlanksOptions()}
</Errors>
</div>
</QuestionContainer>
<QuestionSettingsContainer additionalOptions={additionalOptions}>
{showCalculatorOption && (
<QuestionSettingsPanel label={t('Options')} defaultExpanded>
<FormFieldGroup rowSpacing="small" description={renderOptionsDescription()}>
<CalculatorOptionWithOqaatAlert
disabled={overrideEditableForRegrading}
calculatorValue={calculatorType}
onCalculatorTypeChange={controllerRef.current.handleCalculatorTypeChange}
oqaatChecked={oneQuestionAtATime}
onOqaatChange={setOneQuestionAtATime}
/>
</FormFieldGroup>
</QuestionSettingsPanel>
)}
</QuestionSettingsContainer>
</div>
)
})
BaseFillBlankEdit.displayName = 'FillBlankEdit'
BaseFillBlankEdit.componentId = 'QuizzesFillBlankEdit'
BaseFillBlankEdit.interactionType = FillBlankInteractionType
BaseFillBlankEdit.propTypes = {
additionalOptions: PropTypes.array,
calculatorType: PropTypes.string,
enableRichContentEditor: PropTypes.bool,
errors: PropTypes.object,
errorsAreShowing: PropTypes.bool,
fitbCommonWordbankEnabled: PropTypes.bool,
interactionData: PropTypes.object.isRequired,
isSurvey: PropTypes.bool,
notifyScreenreader: PropTypes.func.isRequired,
onModalClose: PropTypes.func,
onModalOpen: PropTypes.func,
oneQuestionAtATime: PropTypes.bool,
openImportModal: PropTypes.func,
overrideEditableForRegrading: PropTypes.bool,
scoringData: PropTypes.object.isRequired,
setOneQuestionAtATime: PropTypes.func,
validationErrorsFromApi: PropTypes.object,
styles: PropTypes.object,
showCalculatorOption: PropTypes.bool,
}
BaseFillBlankEdit.defaultProps = {
calculatorType: 'none',
enableRichContentEditor: true,
fitbCommonWordbankEnabled: false,
isSurvey: false,
oneQuestionAtATime: false,
overrideEditableForRegrading: false,
setOneQuestionAtATime: Function.prototype,
additionalOptions: void 0,
errors: void 0,
errorsAreShowing: void 0,
onModalClose: void 0,
onModalOpen: void 0,
openImportModal: void 0,
scoringData: void 0,
validationErrorsFromApi: {},
showCalculatorOption: true,
}
const FillBlankEditWithStyles = withStyleOverrides(
generateStyle,
generateComponentTheme,
)(BaseFillBlankEdit)
const FillBlankEdit = withEditTools(FillBlankEditWithStyles)
FillBlankEdit.displayName = 'FillBlankEdit'
FillBlankEdit.componentId = 'QuizzesFillBlankEdit'
FillBlankEdit.interactionType = FillBlankInteractionType
export default FillBlankEdit