UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

405 lines (371 loc) • 13.5 kB
/** @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