@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
338 lines (309 loc) • 12.4 kB
JavaScript
/** @jsx jsx */
import React, {useMemo, 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 update from 'immutability-helper'
import {Checkbox} from '@instructure/ui-checkbox'
import {jsx} from '@instructure/emotion'
import QuestionSettingsContainer from '../../common/edit/components/QuestionSettingsContainer'
import QuestionContainer from '../../common/edit/components/QuestionContainer'
import RichFillBlankInteractionType from '../../../records/interactions/rich_fill_blank'
import BlankTypeSelect from '../../fill_blank/Edit/answer_field/BlankTypeSelect'
import WordBankDistractors from './WordBankDistractors'
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 {Text} from '@instructure/ui-text'
import {Flex} from '@instructure/quiz-common/components/Flex/index'
import {withStyleOverrides} from '@instructure/quiz-common/util/withStyleOverrides'
import {FormFieldGroup} from '@instructure/quiz-common/components/FormFieldGroup/index'
import useBlankManagement from './useBlankManagement'
function BlanksOptions({
blanks,
scoringDataValue,
fitbCommonWordbankEnabled,
overrideEditableForRegrading,
wordBankChoices,
notifyScreenreader,
onModalClose,
onModalOpen,
validationErrorsFromApi,
getErrors,
updateBlank,
updateScoringDataForBlank,
isSurvey,
}) {
const hasIndividualWordBanks = blanks.some(blank => {
return blank.answerType === 'wordbank' && blank.choices && blank.choices.length
})
return blanks.map((blank, blankIndex) => {
const blankScoringData = find({id: blank.id}, scoringDataValue) || {}
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}
choices={blank.choices}
disabled={overrideEditableForRegrading}
interactionDataErrors={getErrors(`interactionData.blanks[${blankIndex}]`) || {}}
name={blank.id}
notifyScreenreader={notifyScreenreader}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
scoringDataErrors={getErrors(`scoringData.value[${blankIndex}].scoringData`) || {}}
choicesErrors={choicesErrors}
validationErrorsFromApi={validationErrorsFromApi[blank.id] || []}
wordBankChoices={wordBankChoices}
updateBlank={updateBlank(blank.id)}
updateBlankScoringData={updateScoringDataForBlank(blank.id)}
useCommonWordbank={fitbCommonWordbankEnabled && !hasIndividualWordBanks}
isSurvey={isSurvey}
/>
)
})
}
BlanksOptions.propTypes = {
blanks: PropTypes.array.isRequired,
scoringDataValue: PropTypes.array,
fitbCommonWordbankEnabled: PropTypes.bool,
overrideEditableForRegrading: PropTypes.bool,
wordBankChoices: PropTypes.array,
notifyScreenreader: PropTypes.func.isRequired,
onModalClose: PropTypes.func,
onModalOpen: PropTypes.func,
validationErrorsFromApi: PropTypes.object,
getErrors: PropTypes.func.isRequired,
updateBlank: PropTypes.func.isRequired,
updateScoringDataForBlank: PropTypes.func.isRequired,
isSurvey: PropTypes.bool,
}
const RichFillBlankEdit = React.forwardRef((props, ref) => {
const [deletedBlanksPool, setDeletedBlanksPool] = useState([])
const [deletedDistractors, setDeletedDistractors] = useState([])
const validBlankRegex = useMemo(() => {
return new RegExp(props.interactionType.validBlankRegex, 'g')
}, [props.interactionType.validBlankRegex])
// 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 (props.errorsAreShowing) {
return get(props.errors, path, null)
}
return null
}
const {handleDescriptionChange, updateBlank, updateScoringDataForBlank} = useBlankManagement({
interactionData: props.interactionData,
scoringData: props.scoringData,
changeItemState: props.changeItemState,
properties: props.properties,
validBlankRegex,
deletedBlanksPool,
setDeletedBlanksPool,
deletedDistractors,
setDeletedDistractors,
fitbCommonWordbankEnabled: props.fitbCommonWordbankEnabled,
})
const indexForWordBankChoice = choiceId => {
return props.interactionData.wordBankChoices.findIndex(
wordBankChoice => wordBankChoice.id === choiceId,
)
}
const makeNewProperties = interactionData => {
// syncs shuffling properties data with new interaction data.
// this runs each time we changeItemState so that we dont have
// to manage syncing properties and interactionData on each individual
// change to blank type or blank creation/deletion
const blankRules = interactionData.blanks.reduce((memo, blank, blankIndex) => {
// eslint-disable-next-line no-param-reassign
memo[blankIndex] =
blank.answerType === 'openEntry'
? {children: null}
: {children: {choices: {shuffled: true}}}
return memo
}, {})
const newShuffleRules = {
shuffleRules: {
blanks: {
children: blankRules,
},
},
}
return Object.assign({}, props.properties, newShuffleRules)
}
const changeItemState = (modifications, blankId) => {
const newestIntData = Object.assign({}, props.interactionData, modifications.interactionData)
const newProperties = makeNewProperties(newestIntData)
const newMods = {properties: newProperties}
if (modifications.scoringData) {
newMods.scoringData = modifications.scoringData
}
const newModifications = Object.assign({}, modifications, newMods)
props.changeItemState(newModifications, blankId)
}
// ================
// HANDLERS
// ================
const handleCheckboxChange = event => {
const interactionData = update(props.interactionData, {
[event.target.value]: {$set: !props.interactionData[event.target.value]},
})
changeItemState({interactionData})
}
const handleCalculatorTypeChange = (e, value) => {
changeItemState({
calculatorType: value,
})
}
const stemErrors = getErrors('scoringData.workingItemBody') || []
const blankErrors = getErrors(['interactionData', 'blanks', '$errors']) || []
return (
<div ref={ref}>
<Flex direction="column" padding="0 0 small 0">
<Text>
{t(
'Type a statement and surround a word with backticks to indicate where a student will fill in the answer. (e.g., "Roses are `red`, violets are `blue`")',
)}
</Text>
</Flex>
<QuestionContainer
disabled={props.overrideEditableForRegrading}
enableRichContentEditor={props.enableRichContentEditor}
itemBody={props.scoringData.workingItemBody}
onDescriptionChange={handleDescriptionChange}
onModalClose={props.onModalClose}
onModalOpen={props.onModalOpen}
openImportModal={props.openImportModal}
stemErrors={stemErrors.concat(blankErrors) || []}
>
<BlanksOptions
blanks={props.interactionData.blanks}
scoringDataValue={props.scoringData.value}
fitbCommonWordbankEnabled={props.fitbCommonWordbankEnabled}
overrideEditableForRegrading={props.overrideEditableForRegrading}
wordBankChoices={props.interactionData.wordBankChoices}
notifyScreenreader={props.notifyScreenreader}
onModalClose={props.onModalClose}
onModalOpen={props.onModalOpen}
validationErrorsFromApi={props.validationErrorsFromApi}
getErrors={getErrors}
updateBlank={updateBlank}
updateScoringDataForBlank={updateScoringDataForBlank}
isSurvey={props.isSurvey}
/>
<WordBankDistractors
changeItemState={props.changeItemState}
indexForWordBankChoice={indexForWordBankChoice}
interactionData={props.interactionData}
interactionDataErrors={getErrors('interactionData.wordBankChoices') || {}}
notifyScreenreader={props.notifyScreenreader}
onModalClose={props.onModalClose}
onModalOpen={props.onModalOpen}
overrideEditableForRegrading={props.overrideEditableForRegrading}
scoringData={props.scoringData}
isSurvey={props.isSurvey}
/>
</QuestionContainer>
<QuestionSettingsContainer additionalOptions={props.additionalOptions}>
<QuestionSettingsPanel label={t('Options')} defaultExpanded>
<FormFieldGroup
rowSpacing="small"
description={
<ScreenReaderContent>{t('Fill in the blank options')}</ScreenReaderContent>
}
>
{props.interactionData.wordBankChoices && (
<Checkbox
label={t('Allow word bank choices to be reused')}
value="reuseWordBankChoices"
onChange={handleCheckboxChange}
checked={props.interactionData.reuseWordBankChoices}
disabled={props.overrideEditableForRegrading}
/>
)}
{props.showCalculatorOption && (
<CalculatorOptionWithOqaatAlert
disabled={props.overrideEditableForRegrading}
calculatorValue={props.calculatorType}
onCalculatorTypeChange={handleCalculatorTypeChange}
oqaatChecked={props.oneQuestionAtATime}
onOqaatChange={props.setOneQuestionAtATime}
/>
)}
</FormFieldGroup>
</QuestionSettingsPanel>
</QuestionSettingsContainer>
</div>
)
})
RichFillBlankEdit.displayName = 'RichFillBlankEdit'
RichFillBlankEdit.interactionType = RichFillBlankInteractionType
RichFillBlankEdit.propTypes = {
additionalOptions: PropTypes.array,
calculatorType: PropTypes.string,
changeItemState: PropTypes.func.isRequired,
enableRichContentEditor: PropTypes.bool,
errors: PropTypes.object,
errorsAreShowing: PropTypes.bool,
fitbCommonWordbankEnabled: PropTypes.bool,
interactionData: PropTypes.object.isRequired,
interactionType: PropTypes.shape({
validBlankRegex: PropTypes.string,
}).isRequired,
notifyScreenreader: PropTypes.func.isRequired,
onModalClose: PropTypes.func,
onModalOpen: PropTypes.func,
oneQuestionAtATime: PropTypes.bool,
openImportModal: PropTypes.func,
overrideEditableForRegrading: PropTypes.bool,
properties: PropTypes.shape({
shuffleRules: PropTypes.shape({
blanks: PropTypes.shape({
children: PropTypes.object,
}),
}),
}),
scoringData: PropTypes.object.isRequired,
setOneQuestionAtATime: PropTypes.func,
validationErrorsFromApi: PropTypes.object,
styles: PropTypes.object,
showCalculatorOption: PropTypes.bool,
isSurvey: PropTypes.bool,
}
RichFillBlankEdit.defaultProps = {
calculatorType: 'none',
enableRichContentEditor: true,
fitbCommonWordbankEnabled: 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,
properties: {},
scoringData: void 0,
validationErrorsFromApi: {},
showCalculatorOption: true,
}
const RichFillBlankEditWithTools = withEditTools(RichFillBlankEdit)
const RichFillBlankEditWithStyles = withStyleOverrides(
generateStyle,
generateComponentTheme,
)(RichFillBlankEditWithTools)
RichFillBlankEditWithStyles.componentId = `Quizzes${RichFillBlankEdit.displayName}`
export default RichFillBlankEditWithStyles