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