@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
655 lines (583 loc) • 20.2 kB
JavaScript
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>
```
**/
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>
)
}
}