UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

648 lines (583 loc) • 20.7 kB
import React, {Component} from 'react' import PropTypes from 'prop-types' import striptags from 'striptags' import {v4 as uuid} from 'uuid' import getOr from 'lodash/fp/getOr' import set from 'lodash/fp/set' import sortBy from 'lodash/fp/sortBy' import findIndex from 'lodash/fp/findIndex' import filter from 'lodash/fp/filter' import last from 'lodash/fp/last' import map from 'lodash/fp/map' import xor from 'lodash/fp/xor' import remove from 'lodash/fp/remove' import {RadioInputGroup, RadioInput} from '@instructure/ui-radio-input' import {Checkbox} from '@instructure/ui-checkbox' import {ScreenReaderContent} from '@instructure/ui-a11y-content' import {IconButton} from '@instructure/ui-buttons' import {IconQuestionLine} from '@instructure/ui-icons' import {View} from '@instructure/ui-view' import {Link} from '@instructure/ui-link' import {Text} from '@instructure/ui-text' import {SimpleModal} from '@instructure/quiz-common/components/SimpleModal/index' import {FormFieldGroup} from '@instructure/quiz-common/components/FormFieldGroup/index' import ChoiceInput from '../../common/edit/components/ChoiceInput' import Footer from '../../common/edit/components/Footer' import MultipleAnswerInteractionType from '../../../records/interactions/multiple_answer' import QuestionSettingsContainer from '../../common/edit/components/QuestionSettingsContainer' import QuestionContainer from '../../common/edit/components/QuestionContainer' 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 {normalizeErrors} from '../../../util/normalizeErrors' // Scoring Algorithms: const ALL_OR_NOTHING = 'AllOrNothing' const PARTIAL_SCORE = 'PartialScore' /** --- category: MultipleAnswer --- Multiple Answer Edit component ```jsx_example function Example (props) { const exampleProps = { itemBody: 'Who was in the first cabinet of the USA?', interactionData: { choices: [ { id: 'uuid1', position: 1, itemBody: 'Thomas Jefferson' }, { id: 'uuid2', position: 2, itemBody: 'John Marshall' }, { id: 'uuid3', position: 3, itemBody: 'John Knox' }, { id: 'uuid4', position: 4, itemBody: 'Alexander Hamilton' }, { id: 'uuid5', position: 5, itemBody: 'Aaron Burr' }, { id: 'uuid6', position: 6, itemBody: 'Ben Franklin' } ] }, itemId: '1', scoringData: { value: ['uuid1', 'uuid3', 'uuid4'] }, properties: { shuffleRules: { choices: { shuffled: true, toLock: [0, 1] } } } } return ( <MultipleAnswerEdit {...exampleProps} {...props} /> ) } <SettingsSwitcher locales={LOCALES}> <EditStateProvider> <Example /> </EditStateProvider> </SettingsSwitcher> ``` **/ @withEditTools export default class MultipleAnswerEdit extends Component { static interactionType = MultipleAnswerInteractionType static propTypes = { additionalOptions: QuestionSettingsContainer.propTypes.additionalOptions, answerFeedback: ChoiceInput.propTypes.answerFeedback, calculatorType: PropTypes.string, changeItemState: PropTypes.func, // TODO: This appears to be unused, can we remove it? Hard to tell with all of the indirection (i.e. withEditTools) errors: PropTypes.object, multipleAnswerFeedbackEnabled: PropTypes.bool, errorsAreShowing: PropTypes.bool, enableRichContentEditor: PropTypes.bool, interactionData: PropTypes.shape({ choices: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.string, itemBody: PropTypes.string, position: PropTypes.number, }), ), }).isRequired, itemBody: PropTypes.string.isRequired, itemId: PropTypes.string, newId: PropTypes.func, notifyScreenreader: PropTypes.func, onModalClose: PropTypes.func, onModalOpen: PropTypes.func, oneQuestionAtATime: PropTypes.bool, overrideEditableForItem: PropTypes.bool, overrideEditableForRegrading: PropTypes.bool, partialScoringEnabled: PropTypes.bool, properties: PropTypes.shape({ shuffleRules: PropTypes.shape({ choices: PropTypes.shape({ shuffled: PropTypes.bool, toLock: PropTypes.arrayOf(PropTypes.number), }), }), }).isRequired, scoringAlgorithm: PropTypes.string, scoringData: PropTypes.shape({ value: PropTypes.arrayOf(PropTypes.string), }).isRequired, setOneQuestionAtATime: PropTypes.func, openImportModal: PropTypes.func, ...withEditTools.injectedProps, showCalculatorOption: PropTypes.bool, isSurvey: PropTypes.bool, } static defaultProps = { answerFeedback: {}, calculatorType: 'none', enableRichContentEditor: true, multipleAnswerFeedbackEnabled: false, oneQuestionAtATime: false, overrideEditableForItem: false, overrideEditableForRegrading: false, partialScoringEnabled: false, newId: uuid, notifyScreenreader: Function.prototype, setOneQuestionAtATime: Function.prototype, additionalOptions: void 0, changeItemState: void 0, errors: void 0, errorsAreShowing: void 0, itemId: void 0, onModalClose: void 0, onModalOpen: void 0, openImportModal: void 0, scoringAlgorithm: null, showCalculatorOption: true, } state = { isModalOpen: false, expanded: this.getChoices().reduce((expand, choice) => { expand[choice.id] = false // eslint-disable-line no-param-reassign return expand }, {}), } _choiceWasCreated = false stemElement = null choiceRefs = [] _timeouts = [] componentDidMount() { this.setDefaultScoringAlgorithm() } componentWillUnmount() { // prevent timeouts from being called after unmount this._timeouts.forEach(clearTimeout) } componentDidUpdate() { if (this._choiceWasCreated) { this._choiceWasCreated = false last(this.choiceRefs).focusOnAnswerInput() } this.setDefaultScoringAlgorithm() } setDefaultScoringAlgorithm() { // ALL_OR_NOTHING is the default, // but we want PARTIAL_SCORE to be the default when enabled if (!this.props.scoringAlgorithm && this.props.partialScoringEnabled) { this.props.changeItemState({scoringAlgorithm: PARTIAL_SCORE}) } } overrideEditable() { return this.props.overrideEditableForItem || this.props.overrideEditableForRegrading } // =========== // HELPERS // =========== getChoices() { return getOr([], 'interactionData.choices', this.props) } isShuffled() { return getOr(false, 'properties.shuffleRules.choices.shuffled', this.props) } isChoiceLocked(choiceId) { const index = findIndex({id: choiceId}, this.getChoices()) const lockedChoices = getOr(false, 'properties.shuffleRules.choices.toLock', this.props) if (lockedChoices) { return lockedChoices.includes(index) } return false } updateFocusOnRemove(index) { if (index === 0) { // if removing the first choice, focus on stem this.stemElement.focus() } else if (this.getChoices().length === 2) { // if removing the second choice out of two choices this._timeouts = [...this._timeouts, setTimeout(() => this.choiceRefs[index - 1].focusLast())] } else { // all the other cases this.choiceRefs[index - 1].focusLast() } } // =========== // HANDLERS // =========== handleAnswerFeedbackToggle = choiceId => { this.setState(state => { return { expanded: Object.assign({}, state.expanded, {[choiceId]: !state.expanded[choiceId]}), } }) } handleScoringAlgoChange = (e, value) => { this.props.changeItemState({scoringAlgorithm: value}, {scoringAlgorithm: value}) } handleCloseModal = () => { this.setState({isModalOpen: false}) } handleOpenModal = () => { this.setState({isModalOpen: true}) } handleCalculatorTypeChange = (e, value) => { this.props.changeItemState({ calculatorType: value, }) } handleStemRef = node => { this.stemElement = node } handleRemoveChoice = (choiceId, index) => { const choices = this.getChoices() const {shuffleRules} = this.props.properties this.updateFocusOnRemove(index) // Move the locks in conjuntion with the answers const toLock = map( value => (value < index ? value : value - 1), filter(lock => lock !== index, shuffleRules.choices.toLock), ) // Remove answer feedback for this choice const localFeedback = Object.assign({}, this.props.answerFeedback) delete localFeedback[choiceId] this.props.changeItemState({ interactionData: { ...this.props.interactionData, choices: remove({id: choiceId}, choices), }, scoringData: { ...this.props.scoringData, value: filter(v => v !== choiceId, this.props.scoringData.value), }, properties: { ...this.props.properties, shuffleRules: set('choices.toLock', toLock, shuffleRules), }, answerFeedback: localFeedback, }) } handleInputChange = (choiceId, event, {editorContent}) => { const choices = this.getChoices() const index = findIndex({id: choiceId}, choices) this.props.changeItemState({ interactionData: { ...this.props.interactionData, choices: set(`[${index}].itemBody`, editorContent, choices), }, }) } handleCreateChoice = () => { this._choiceWasCreated = true this.props.changeItemState({ interactionData: { ...this.props.interactionData, choices: [ ...this.getChoices(), { id: this.props.newId(), itemBody: '', position: Math.max(-1, ...map('position', this.getChoices())) + 1, }, ], }, }) } handleShuffleChange = event => { this.toggleShuffleChoices() } focusErrors = () => { const stemErrors = getOr([], 'itemBody', this.props.errors) const groupErrors = getOr([], 'scoringData.value', this.props.errors) const choiceErrors = getOr([], 'interactionData.choices', this.props.errors) const choiceErrorsKeys = Object.keys(choiceErrors).map(Number) if (stemErrors.length) { this.stemElement.focus() } else if (groupErrors.length) { this.choiceRefs[0].focusOnAnswerInput() } else if (choiceErrorsKeys.length) { const index = Math.min(...choiceErrorsKeys) this.choiceRefs[index].focusOnAnswerInput() } } toggleShuffleChoices() { const newShuffled = !this.isShuffled() let properties if (this.props.properties.shuffleRules !== void 0) { properties = set('shuffleRules.choices.shuffled', newShuffled, this.props.properties) } else { properties = { shuffleRules: { choices: {shuffled: newShuffled}, }, } } const messageForScreenreader = newShuffled ? t('Shuffling turned on. Navigate to a choice to lock it in place.') : t('Shuffling turned off.') this.props.notifyScreenreader(messageForScreenreader) this.props.changeItemState( {properties}, { properties: {shuffleRules: {choices: {shuffled: newShuffled}}}, }, ) } makeCheckAnswerToggler(choiceId) { return () => { this.props.changeItemState({ scoringData: { ...this.props.scoringData, value: xor([choiceId], this.props.scoringData.value), }, }) } } makeChoiceLockedToggler(choiceId) { return () => { const index = findIndex({id: choiceId}, this.getChoices()) const toLock = xor([index], this.props.properties.shuffleRules.choices.toLock) const properties = set('shuffleRules.choices.toLock', toLock, this.props.properties) const messageForScreenreader = this.isChoiceLocked(choiceId) ? t('Distractor unlocked') : t('Distractor locked') this.props.notifyScreenreader(messageForScreenreader) this.props.changeItemState( {properties}, { properties: {shuffleRules: {choices: {toLock}}}, }, ) } } // =========== // RENDERS // =========== renderChoice(choice, index) { const choiceErrors = this.props.getErrors(`interactionData.choices.${index}.itemBody`) const shouldRenderRemoveChoice = this.getChoices().length !== 1 && !this.overrideEditable() const disabledFields = [ ...(this.overrideEditable() ? ['answerInput'] : []), ...(this.props.overrideEditableForRegrading ? ['lockChoiceButton'] : []), ] const choiceInputProps = { disabledFields, errors: choiceErrors, key: choice.id, id: choice.id, ref: node => { this.choiceRefs[index] = node }, itemBody: choice.itemBody, noRCE: !this.props.enableRichContentEditor, notifyScreenreader: this.props.notifyScreenreader, onInputChange: this.handleInputChange, onRemoveChoice: () => this.handleRemoveChoice(choice.id, index), onModalClose: this.props.onModalClose, onModalOpen: this.props.onModalOpen, openImportModal: this.props.openImportModal, removeButtonScreenReaderText: t('Remove Answer Value: { choice }', { choice: striptags(choice.itemBody), }), shouldRenderRemoveChoice, shouldRenderAnswerFeedback: this.props.multipleAnswerFeedbackEnabled && !this.overrideEditable(), handleAnswerFeedbackToggle: this.handleAnswerFeedbackToggle.bind(this), expanded: this.state.expanded[choice.id], changeItemState: this.props.changeItemState, answerFeedback: this.props.answerFeedback, isRequired: true, automationData: `sdk-multiple-answer-${index}`, } if (this.isShuffled()) { choiceInputProps.showLock = true choiceInputProps.isLocked = this.isChoiceLocked(choice.id) choiceInputProps.toggleChoiceLocked = this.makeChoiceLockedToggler(choice.id) } const checked = this.props.scoringData.value.includes(choice.id) const onCheckboxChange = this.makeCheckAnswerToggler(choice.id) const labelText = choice.itemBody.trim() === '' ? t('Checkbox for blank distractor') : t('Checkbox for distractor { distractor }', {distractor: striptags(choice.itemBody)}) const renderCheckBox = () => ( <Checkbox disabled={this.props.overrideEditableForItem} onChange={onCheckboxChange} checked={checked} key={choice.id} label={<ScreenReaderContent>{labelText}</ScreenReaderContent>} value={choice.id} /> ) return ( <ChoiceInput {...choiceInputProps} renderBeforeComponent={!this.props.isSurvey ? renderCheckBox() : null} /> ) } renderInputGroup() { const choices = sortBy('position', this.getChoices()) const name = `edit_interaction_${this.props.itemId}` const errors = [ ...this.props.getErrors('interactionData.choices.$errors'), ...this.props.getErrors('scoringData.value.$errors'), ] return ( <FormFieldGroup rowSpacing="small" name={name} description={t('Possible answers')} messages={normalizeErrors(errors)} > {choices.map((choice, index) => this.renderChoice(choice, index))} </FormFieldGroup> ) } renderOptionsDescription() { return <ScreenReaderContent>{t('Multiple answer options')}</ScreenReaderContent> } renderGradingOptionsModal() { return ( <SimpleModal size="small" title={t('Grading')} label={t('Grading')} isModalOpen={this.state.isModalOpen} onModalDismiss={this.handleCloseModal} > <Text weight="bold" lineHeight="double"> {t('Partial credit with penalty')} </Text> <br /> <Text> {t( 'Students are awarded points for every correct answer selected and deducted points for every incorrect answer selected.', )} &nbsp; <Link target="_blank" href="https://community.instructure.com/en/kb/articles/661058-how-do-i-create-a-multiple-answer-question-in-new-quizzes#select-grading-option" > {t('Learn More')} </Link> </Text> <br /> <br /> <Text weight="bold" lineHeight="double"> {t('Exact match')} </Text> <br /> <Text> {t( 'Students are awarded full credit if all correct answers are selected and no incorrect answers are selected.', )} </Text> </SimpleModal> ) } render() { // clean up the references to ChoiceInputs this.choiceRefs = [] const shuffleChoicesLabel = ( <> <span>{t('Shuffle Choices')}</span> <ScreenReaderContent> {t('Lock distractor position buttons are displayed for each option when checked')} </ScreenReaderContent> </> ) return ( <div> <QuestionContainer disabled={this.overrideEditable()} enableRichContentEditor={this.props.enableRichContentEditor} itemBody={this.props.itemBody} onDescriptionChange={this.props.onDescriptionChange} onModalClose={this.props.onModalClose} onModalOpen={this.props.onModalOpen} openImportModal={this.props.openImportModal} stemErrors={this.props.getErrors('itemBody')} textareaRef={this.handleStemRef} > {this.renderInputGroup()} {!this.overrideEditable() && ( <Footer onCreateChoice={this.handleCreateChoice} notifyScreenreader={this.props.notifyScreenreader} /> )} </QuestionContainer> <QuestionSettingsContainer additionalOptions={this.props.additionalOptions}> <QuestionSettingsPanel label={t('Options')} defaultExpanded> <FormFieldGroup rowSpacing="small" description={this.renderOptionsDescription()}> {this.props.showCalculatorOption && ( <CalculatorOptionWithOqaatAlert disabled={this.props.overrideEditableForRegrading} calculatorValue={this.props.calculatorType} onCalculatorTypeChange={this.handleCalculatorTypeChange} oqaatChecked={this.props.oneQuestionAtATime} onOqaatChange={this.props.setOneQuestionAtATime} /> )} <Checkbox label={shuffleChoicesLabel} onChange={this.handleShuffleChange} checked={this.isShuffled()} disabled={this.props.overrideEditableForRegrading} data-automation="sdk-multiple-answer-shuffle-choices-checkbox" /> {!this.props.isSurvey && this.props.partialScoringEnabled && ( <View as="div" margin="medium 0" position="relative"> <RadioInputGroup onChange={this.handleScoringAlgoChange} name={t('Grading')} value={this.props.scoringAlgorithm} description={ <span> {t('Grading')} <IconButton size="small" withBackground={false} withBorder={false} renderIcon={IconQuestionLine} onClick={this.handleOpenModal} screenReaderLabel={t('Open grading option information')} /> </span> } > <RadioInput value={PARTIAL_SCORE} label={t('Partial credit with penalty')} data-automation="sdk-matching-partial" /> <RadioInput value={ALL_OR_NOTHING} label={t('Exact Match')} data-automation="sdk-matching-exact" /> </RadioInputGroup> </View> )} </FormFieldGroup> </QuestionSettingsPanel> </QuestionSettingsContainer> {this.renderGradingOptionsModal()} </div> ) } }