UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

655 lines (583 loc) • 20.2 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 find from 'lodash/fp/find' 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 {Checkbox} from '@instructure/ui-checkbox' import {RadioInput} from '@instructure/ui-radio-input' import {ScreenReaderContent} from '@instructure/ui-a11y-content' import ChoiceInput from '../../common/edit/components/ChoiceInput' import Footer from '../../common/edit/components/Footer' import MultipleChoiceInteractionType from '../../../records/interactions/multiple_choice' import QuestionSettingsContainer from '../../common/edit/components/QuestionSettingsContainer' import QuestionContainer from '../../common/edit/components/QuestionContainer' import withEditTools from '../../../util/withEditTools' import interactionPoints from '../../../util/interactionPoints' import t from '@instructure/quiz-i18n/format-message' import QuestionSettingsPanel from '../../common/edit/components/QuestionSettingsPanel' import CalculatorOptionWithOqaatAlert from '../../common/edit/components/CalculatorOptionWithOqaatAlert' import {FormFieldGroup} from '@instructure/quiz-common/components/FormFieldGroup/index' import {normalizeErrors} from '../../../util/normalizeErrors' /** --- category: MultipleChoice --- Multiple Choice Edit component ```jsx_example function Example (props) { const exampleProps = { itemBody: 'Who was the first President of the United States?', interactionData: { choices: [ { id: 'uuid1', itemBody: 'George Washington', position: 1 }, { id: 'uuid2', itemBody: 'Alexander Hamilton.', position: 2 }, { id: 'uuid3', itemBody: 'John Adams', position: 3 }, { id: 'uuid4', itemBody: 'Thomas Jefferson', position: 4 } ] }, properties: { varyPointsByAnswer: false, shuffleRules: { choices: { shuffled: true, toLock: [0, 1] } } }, scoringData: { value: 'uuid1' }, additionalOptions: [{ key: 'outcomes', title: 'Align to Outcomes', component: 'Placeholder' }] } return ( <MultipleChoiceEdit {...exampleProps} {...props} /> ) } <SettingsSwitcher locales={LOCALES}> <EditStateProvider> <Example /> </EditStateProvider> </SettingsSwitcher> ``` **/ @withEditTools export default class MultipleChoiceEdit extends Component { static interactionType = MultipleChoiceInteractionType static propTypes = { additionalOptions: QuestionSettingsContainer.propTypes.additionalOptions, answerFeedbackEnabled: PropTypes.bool, answerFeedback: ChoiceInput.propTypes.answerFeedback, calculatorType: PropTypes.string, changeItemState: PropTypes.func, enableRichContentEditor: PropTypes.bool, errors: PropTypes.object, // TODO: This appears unused, figure out if we can delete it errorsAreShowing: PropTypes.bool, interactionData: PropTypes.shape({ choices: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.string, itemBody: PropTypes.string, position: PropTypes.number, }), ), }).isRequired, itemBody: PropTypes.string, itemId: PropTypes.string, newId: PropTypes.func, notifyScreenreader: PropTypes.func, onModalClose: PropTypes.func, onModalOpen: PropTypes.func, oneQuestionAtATime: PropTypes.bool, overrideEditableForItem: PropTypes.bool, overrideEditableForRegrading: PropTypes.bool, parentType: PropTypes.string, pointsChange: PropTypes.func, properties: PropTypes.shape({ varyPointsByAnswer: PropTypes.bool, shuffleRules: PropTypes.shape({ choices: PropTypes.shape({ shuffled: PropTypes.bool, toLock: PropTypes.arrayOf(PropTypes.number), }), }), }).isRequired, scoringData: PropTypes.shape({ value: PropTypes.string, values: PropTypes.arrayOf( PropTypes.shape({ value: PropTypes.string, points: PropTypes.number, }), ), }).isRequired, openImportModal: PropTypes.func, setOneQuestionAtATime: PropTypes.func, ...withEditTools.injectedProps, showCalculatorOption: PropTypes.bool, isSurvey: PropTypes.bool.isRequired, } static defaultProps = { answerFeedbackEnabled: false, answerFeedback: {}, calculatorType: 'none', enableRichContentEditor: true, newId: uuid, notifyScreenreader: Function.prototype, oneQuestionAtATime: false, overrideEditableForItem: false, overrideEditableForRegrading: false, parentType: '', pointsChange: Function.prototype, additionalOptions: void 0, changeItemState: void 0, errors: void 0, errorsAreShowing: void 0, itemBody: void 0, itemId: void 0, onModalClose: void 0, onModalOpen: void 0, openImportModal: void 0, setOneQuestionAtATime: void 0, showCalculatorOption: true, isSurvey: false, } state = { expanded: this.getChoices().reduce((expand, choice) => { expand[choice.id] = false // eslint-disable-line no-param-reassign return expand }, {}), } stemElement = null choiceRefs = [] _timeouts = [] _choiceWasCreated = false componentWillUnmount() { this._timeouts.forEach(clearTimeout) } componentDidUpdate() { if (this._choiceWasCreated) { this._choiceWasCreated = false last(this.choiceRefs).focusOnAnswerInput() } } // =========== // HELPERS // =========== getChoices() { return getOr([], 'interactionData.choices', this.props) } isShuffled() { return getOr(false, 'properties.shuffleRules.choices.shuffled', this.props) } shouldVaryPoints() { return getOr(false, 'properties.varyPointsByAnswer', 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 } getScoringDataValues() { return ( this.props.scoringData.values || map(({id}) => ({value: id, points: 0}), this.getChoices()) ) } updateScoringData(changes) { const scoringData = Object.assign({}, this.props.scoringData, changes) // Only calls pointsChange when necessary if (this.shouldVaryPoints()) { if (scoringData.values === null) { // TODO should values be allowed to be undefined? delete scoringData.values } // TODO Raising pointsChange with undefined would allow consumer to reset to points of their choice this.props.pointsChange( interactionPoints({properties: this.props.properties, scoringData}) || 1, ) } this.props.changeItemState({scoringData}, {scoringData: changes}) } 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]}), } }) } handleStemRef = node => { this.stemElement = node } handleCalculatorTypeChange = (e, value) => { this.props.changeItemState({ calculatorType: value, }) } handleRemoveChoice = (choiceId, index) => { this.updateFocusOnRemove(index) // Update scoringData const choices = this.getChoices() const scoringData = {} if (this.props.scoringData.value === choiceId) { scoringData.value = null } if (this.shouldVaryPoints()) { scoringData.values = remove({value: choiceId}, this.props.scoringData.values) } this.updateScoringData(scoringData) // Update interactionData and properties const {shuffleRules} = this.props.properties // Move the locks in conjuntion with the answers const toLock = map( value => (value < index ? value : value - 1), filter(lock => lock !== index, shuffleRules.choices.toLock), ) const localFeedback = Object.assign({}, this.props.answerFeedback) delete localFeedback[choiceId] this.props.changeItemState({ interactionData: { ...this.props.interactionData, choices: remove({id: choiceId}, choices), }, 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 = () => { const id = this.props.newId() if (this.shouldVaryPoints()) { this.updateScoringData({ values: [...this.props.scoringData.values, {value: id, points: 0}], }) } this._choiceWasCreated = true this.props.changeItemState({ interactionData: { ...this.props.interactionData, choices: [ ...this.getChoices(), { id, itemBody: '', position: Math.max(-1, ...map('position', this.getChoices())) + 1, }, ], }, }) } handleVaryPointsByAnswerChange = event => { this.toggleVaryPointsByAnswer(event) } 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() } } override() { return this.props.overrideEditableForItem || this.props.overrideEditableForRegrading } toggleVaryPointsByAnswer(event) { let scoringAlgorithm if (event.target.checked) { scoringAlgorithm = 'VaryPointsByAnswer' this.updateScoringData({ values: this.getScoringDataValues(), }) } else { scoringAlgorithm = 'Equivalence' this.updateScoringData({ values: null, }) } const update = { scoringAlgorithm, properties: { varyPointsByAnswer: event.target.checked, }, } this.props.changeItemState( { ...update, properties: { ...this.props.properties, ...update.properties, }, }, update, ) } 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}}}, }, ) } makeAnswerPointsChangeHandler(choiceId) { return points => { this.updateScoringData({ values: map( item => (item.value !== choiceId ? item : {value: choiceId, points}), this.getScoringDataValues(), ), }) } } 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('Choice unlocked') : t('Choice locked') this.props.notifyScreenreader(messageForScreenreader) this.props.changeItemState( {properties}, { properties: {shuffleRules: {choices: {toLock}}}, }, ) } } // =========== // RENDERS // =========== renderChoice(choice, name, index) { const shouldVaryPoints = this.shouldVaryPoints() const disabledFields = [ ...(this.override() ? ['answerInput'] : []), ...(this.props.overrideEditableForRegrading ? ['lockChoiceButton'] : []), ] const choiceInputProps = { disabledFields, key: choice.id, id: choice.id, itemBody: choice.itemBody, ref: node => { this.choiceRefs[index] = node }, errors: this.props.getErrors(`interactionData.choices.${index}.itemBody`), noRCE: !this.props.enableRichContentEditor, onAnswerPointsChange: this.makeAnswerPointsChangeHandler(choice.id), onInputChange: this.handleInputChange, onModalClose: this.props.onModalClose, onModalOpen: this.props.onModalOpen, onRemoveChoice: () => this.handleRemoveChoice(choice.id, index), parentType: this.props.parentType, shouldRenderRemoveChoice: !this.override() && this.getChoices().length !== 1, shouldRenderAnswerPoints: shouldVaryPoints, shouldRenderAnswerFeedback: !this.override() && this.props.answerFeedbackEnabled, openImportModal: this.props.openImportModal, readOnlyFields: this.override() ? ['answerInput'] : [], handleAnswerFeedbackToggle: this.handleAnswerFeedbackToggle.bind(this), expanded: this.state.expanded[choice.id], changeItemState: this.props.changeItemState, answerFeedback: this.props.answerFeedback, notifyScreenreader: this.props.notifyScreenreader, removeButtonScreenReaderText: t('Remove Answer Value: { choice }', { choice: striptags(choice.itemBody), }), isRequired: true, automationData: `sdk-multiple-choice-${index}`, } if (shouldVaryPoints) { const answerValue = find({value: choice.id}, this.props.scoringData.values) || {} choiceInputProps.answerPoints = answerValue.points || 0 } if (this.isShuffled()) { choiceInputProps.showLock = true choiceInputProps.isLocked = this.isChoiceLocked(choice.id) choiceInputProps.toggleChoiceLocked = this.makeChoiceLockedToggler(choice.id) } const labelText = choice.itemBody.trim() === '' ? t('Radio button for blank answer') : t('Radio button for answer { answer }', {answer: striptags(choice.itemBody)}) const radioName = `${name}_${choice.id}` return ( <ChoiceInput {...choiceInputProps} renderBeforeComponent={ !this.props.isSurvey ? ( <RadioInput disabled={this.props.overrideEditableForItem} onChange={() => this.updateScoringData({value: choice.id})} name={radioName} label={<ScreenReaderContent>{labelText}</ScreenReaderContent>} value={choice.id} checked={this.props.scoringData.value === choice.id} /> ) : 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'), ] return ( <FormFieldGroup rowSpacing="small" name={name} description={t('Possible answers')} messages={normalizeErrors(errors)} > {choices.map((choice, index) => this.renderChoice(choice, name, index))} </FormFieldGroup> ) } renderOptionsDescription() { return <ScreenReaderContent>{t('Multiple choice options')}</ScreenReaderContent> } render() { // clean up the references to ChoiceInputs this.choiceRefs = [] const varyPointsByAnswerLabel = ( <> <span>{t('Vary points by answer')}</span> <ScreenReaderContent> {t('New points possible text inputs are displayed for each option when checked')} </ScreenReaderContent> </> ) 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.override()} enableRichContentEditor={this.props.enableRichContentEditor} itemBody={this.props.itemBody} onDescriptionChange={this.props.onDescriptionChange} onModalClose={this.props.onModalClose} onModalOpen={this.props.onModalOpen} openImportModal={this.props.openImportModal} readOnly={this.override()} stemErrors={this.props.getErrors('itemBody')} textareaRef={this.handleStemRef} > {this.renderInputGroup()} {!this.override() && ( <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} /> )} {!this.props.isSurvey && ( <Checkbox label={varyPointsByAnswerLabel} onChange={this.handleVaryPointsByAnswerChange} checked={this.shouldVaryPoints()} disabled={this.props.overrideEditableForRegrading} data-automation="sdk-vary-points-by-answer-checkbox" /> )} <Checkbox label={shuffleChoicesLabel} onChange={this.handleShuffleChange} checked={this.isShuffled()} disabled={this.props.overrideEditableForRegrading} data-automation="sdk-multiple-choice-shuffle-choices-checkbox" /> </FormFieldGroup> </QuestionSettingsPanel> </QuestionSettingsContainer> </div> ) } }