@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
535 lines (478 loc) • 15.8 kB
JavaScript
import React, {Component} from 'react'
import PropTypes from 'prop-types'
import {v4 as uuid} from 'uuid'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import {View} from '@instructure/ui-view'
import {Grid} from '@instructure/ui-grid'
import withEditTools from '../../../util/withEditTools'
import Errors from '../../common/edit/components/Errors'
import FocusGroup from '../../common/components/FocusGroup'
import Footer from '../../common/edit/components/Footer'
import NumericInteractionType from '../../../records/interactions/numeric'
import QuestionSettingsContainer from '../../common/edit/components/QuestionSettingsContainer'
import QuestionContainer from '../../common/edit/components/QuestionContainer'
import RemoveChoiceButton from '../../common/edit/components/RemoveChoiceButton'
import t from '@instructure/quiz-i18n/format-message'
import {Decimal} from '@instructure/quiz-i18n/util/Decimal'
import QuestionSettingsPanel from '../../common/edit/components/QuestionSettingsPanel'
import CalculatorOptionWithOqaatAlert from '../../common/edit/components/CalculatorOptionWithOqaatAlert'
import ExactResponse from './ExactResponse'
import MarginOfError from './MarginOfError'
import PreciseResponse from './PreciseResponse'
import RequirementHelpModal from './RequirementHelpModal'
import WithinARange from './WithinARange'
import {
EXACT_RESPONSE,
MARGIN_OF_ERROR,
PERCENT,
PRECISE_RESPONSE,
SIGNIFICANT_DIGITS,
WITHIN_A_RANGE,
} from './constants'
import NumericTypeSelect from './NumericTypeSelect'
import {FormFieldGroup} from '@instructure/quiz-common/components/FormFieldGroup/index'
import {parseSeparators} from '@instructure/quiz-common/util/parseSeparators'
import {ApplyLocaleContext} from '@instructure/ui-i18n'
export function getNewValue(answer, type) {
const newValue = {
id: answer.id || '',
type,
}
switch (type) {
case EXACT_RESPONSE:
Object.assign(newValue, {
value: answer.value || '',
})
break
case MARGIN_OF_ERROR:
Object.assign(newValue, {
value: answer.value || '',
margin: answer.margin || '',
marginType: answer.marginType || PERCENT,
})
break
case WITHIN_A_RANGE:
Object.assign(newValue, {
start: answer.start || '',
end: answer.end || '',
})
break
case PRECISE_RESPONSE:
Object.assign(newValue, {
value: answer.value || '',
precision: answer.precision || '',
precisionType: answer.precisionType || SIGNIFICANT_DIGITS,
})
break
}
return newValue
}
/**
---
category: Numeric
---
Numeric Edit component
```jsx_example
class Example extends React.Component {
render () {
const exampleProps = {
itemId: '1',
itemBody: 'x is an integer and 9 < x^2 < 99. What\'s the max value of x, minus the minimum value of x',
scoringData: {
value: [{
type: 'exactResponse',
id: '1',
value: '1800.5'
}, {
type: 'withinARange',
id: '2',
start: '1800.5',
end: '1801.5'
}, {
type: 'preciseResponse',
id: '3',
value: '1800.5',
precision: '1',
precisionType: 'decimals'
}, {
type: 'marginOfError',
id: '4',
value: '1800.5',
margin: '0.4',
marginType: 'absolute'
}]
}
}
return (
<NumericEdit {...exampleProps} {...this.props} />
)
}
}
<SettingsSwitcher locales={LOCALES}>
<EditStateProvider>
<Example />
</EditStateProvider>
</SettingsSwitcher>
```
**/
export default class NumericEdit extends Component {
static interactionType = NumericInteractionType
static propTypes = {
additionalOptions: QuestionSettingsContainer.propTypes.additionalOptions,
calculatorType: PropTypes.string,
changeItemState: PropTypes.func,
enableRichContentEditor: PropTypes.bool,
itemBody: PropTypes.string.isRequired,
itemId: PropTypes.string,
locale: PropTypes.string,
newId: PropTypes.func,
onModalClose: PropTypes.func,
onModalOpen: PropTypes.func,
oneQuestionAtATime: PropTypes.bool,
openImportModal: PropTypes.func,
overrideEditableForRegrading: PropTypes.bool,
scoringData: PropTypes.shape({
value: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
}),
).isRequired,
}).isRequired,
setOneQuestionAtATime: PropTypes.func,
...withEditTools.injectedProps,
showCalculatorOption: PropTypes.bool,
separatorConfig: PropTypes.shape({
decimalSeparator: PropTypes.string,
thousandSeparator: PropTypes.string,
}),
}
static contextType = ApplyLocaleContext
static defaultProps = {
calculatorType: 'none',
enableRichContentEditor: true,
oneQuestionAtATime: false,
overrideEditableForRegrading: false,
newId: uuid,
setOneQuestionAtATime: Function.prototype,
additionalOptions: void 0,
changeItemState: void 0,
itemId: void 0,
locale: void 0,
onModalClose: void 0,
onModalOpen: void 0,
openImportModal: void 0,
showCalculatorOption: true,
}
typeSelectInputRefs = {}
choicesFocusGroup = null
stemElement = null
_timeouts = []
state = {
helpModalOpen: false,
}
componentWillUnmount() {
this._timeouts.forEach(clearTimeout)
}
componentDidMount() {
Decimal.accountSettingDelimiters = this.props.separatorConfig
? parseSeparators(this.props.separatorConfig)
: null
}
get locale() {
return this.props.locale || this.context.locale || 'en-US'
}
get answers() {
return this.props.scoringData.value
}
stringifyAnswer({type, value, start, end, margin, marginType, precision, precisionType}) {
const orBlank = val => val || 'blank'
return {
[EXACT_RESPONSE]: t('value of {value}', {value: orBlank(value)}),
[MARGIN_OF_ERROR]: t('value of {value} with a margin of error of {margin} {marginType}', {
value: orBlank(value),
margin: orBlank(margin),
marginType,
}),
[WITHIN_A_RANGE]: t('range from {start} to {end}', {
start: orBlank(start),
end: orBlank(end),
}),
[PRECISE_RESPONSE]: t('value of {value} with a precision of {precision} {precisionType}', {
value: orBlank(value),
precision: orBlank(precision),
precisionType,
}),
}[type]
}
updateFocusOnRemove() {
const selector = 'button'
const textContent = 'Remove Answer'
if (!this.choicesFocusGroup.previousExists(selector, textContent)) {
// if removing the first choice, focus on stem
// added timeout to compensate for RCE sluggishness
this._timeouts = [...this._timeouts, setTimeout(() => this.stemElement.focus(), 100)]
} else if (this.answers.length === 2) {
// if removing the second choice out of two choices
this._timeouts = [
...this._timeouts,
setTimeout(() => this.choicesFocusGroup.focusLast(), 100),
]
} else {
// all the other cases
this.choicesFocusGroup.focusPrevious(selector, textContent)
}
}
// ===========
// HANDLERS
// ===========
handleTypeChange = (event, {id, type}) => {
const answers = [...this.answers]
const index = answers.findIndex(answer => answer.id === id)
console.assert(index !== -1) // eslint-disable-line no-console
answers[index] = getNewValue(answers[index], type)
this.props.changeItemState({
scoringData: {
...this.props.scoringData,
value: answers,
},
})
setTimeout(() => {
this.typeSelectInputRefs[id].focus()
}, 0)
}
handleChange = (event, answer) => {
const answers = [...this.answers]
const index = answers.findIndex(({id}) => id === answer.id)
console.assert(index !== -1) // eslint-disable-line no-console
const updatedAnswer = {...answers[index], ...answer}
answers[index] = updatedAnswer
this.props.changeItemState({
scoringData: {
...this.props.scoringData,
value: answers,
},
})
}
handleCalculatorTypeChange = (e, value) => {
this.props.changeItemState({
calculatorType: value,
})
}
handleCreateAnswer = () => {
this._timeouts = [
...this._timeouts,
setTimeout(() => this.choicesFocusGroup.focusLast('select'), 100),
]
const id = this.props.newId()
const newValue = getNewValue({id}, EXACT_RESPONSE)
this.props.changeItemState({
scoringData: {
...this.props.scoringData,
value: [...this.answers, newValue],
},
})
}
handleRemoveChoice = id => {
// In FF clicking on a button doesn't focus it
if (document.activeElement !== document.body) {
this.updateFocusOnRemove()
}
const answers = this.answers.filter(answer => answer.id !== id)
this.props.changeItemState({
scoringData: {
...this.props.scoringData,
value: answers,
},
})
}
openHelpModal = () => this.setState({helpModalOpen: true})
closeHelpModal = () => this.setState({helpModalOpen: false})
handleChoicesFocusGroupRef = node => {
this.choicesFocusGroup = node
}
handleStemRef = node => {
this.stemElement = node
}
// ===========
// RENDERS
// ===========
renderAnswerField({id, value}, index) {
return (
<ExactResponse
id={id}
locale={this.locale}
messages={this.props.getErrors(`scoringData.value[${index}]`, {})}
numericTypeSelect={this.renderNumericTypeSelect(id, EXACT_RESPONSE)}
onChange={this.handleChange}
value={value}
/>
)
}
renderPrecisionAnswer({id, precision, precisionType, value}, index) {
return (
<PreciseResponse
id={id}
locale={this.locale}
messages={this.props.getErrors(`scoringData.value[${index}]`, {})}
numericTypeSelect={this.renderNumericTypeSelect(id, PRECISE_RESPONSE)}
onChange={this.handleChange}
precision={precision}
precisionType={precisionType}
value={value}
/>
)
}
renderMarginAnswer({id, margin, marginType, value}, index) {
return (
<MarginOfError
id={id}
locale={this.locale}
margin={margin}
marginType={marginType}
messages={this.props.getErrors(`scoringData.value[${index}]`, {})}
numericTypeSelect={this.renderNumericTypeSelect(id, MARGIN_OF_ERROR)}
onChange={this.handleChange}
value={value}
/>
)
}
renderRangeAnswer({id, start, end}, index) {
return (
<WithinARange
end={end}
id={id}
locale={this.locale}
messages={this.props.getErrors(`scoringData.value[${index}]`, {})}
numericTypeSelect={this.renderNumericTypeSelect(id, WITHIN_A_RANGE)}
onChange={this.handleChange}
start={start}
/>
)
}
renderNumericTypeSelect(id, value) {
return (
<NumericTypeSelect
id={id}
inputRef={this.setTypeSelectInputRef}
onChange={this.handleTypeChange}
onClickHelp={this.openHelpModal}
value={value}
/>
)
}
setTypeSelectInputRef = (id, node) => {
this.typeSelectInputRefs[id] = node
}
renderAnswerContents(answer) {
const index = this.answers.findIndex(({id}) => id === answer.id)
console.assert(index !== -1) // eslint-disable-line no-console
switch (answer.type) {
case WITHIN_A_RANGE:
return this.renderRangeAnswer(answer, index)
case MARGIN_OF_ERROR:
return this.renderMarginAnswer(answer, index)
case PRECISE_RESPONSE:
return this.renderPrecisionAnswer(answer, index)
default:
return this.renderAnswerField(answer, index)
}
}
renderAnswerWrapper = answer => {
const {id, type} = answer
return (
<Grid startAt="medium" key={id} colSpacing="small">
<Grid.Row>
<Grid.Col rowSpacing="none">
<FormFieldGroup
vAlign="top"
rowSpacing="none"
name={id}
layout="columns"
description={<ScreenReaderContent>{t('Possible answer')}</ScreenReaderContent>}
>
{this.renderAnswerContents(answer)}
</FormFieldGroup>
</Grid.Col>
{this.answers.length > 1 && (
<Grid.Col width="auto">
<View as="div" margin="large 0 0" themeOverride={{marginLarge: '1.875rem'}}>
<RemoveChoiceButton
onClick={() => this.handleRemoveChoice(answer.id)}
choiceId={type}
screenReaderText={t('Remove Answer: {answer}', {
answer: this.stringifyAnswer(answer),
})}
/>
</View>
</Grid.Col>
)}
</Grid.Row>
</Grid>
)
}
renderOptionsDescription() {
return <ScreenReaderContent>{t('Numeric options')}</ScreenReaderContent>
}
render() {
const name = `edit_interaction_${this.props.itemId}`
return (
<div>
<QuestionContainer
disabled={this.props.overrideEditableForRegrading}
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}
>
<FocusGroup
ref={this.handleChoicesFocusGroupRef}
asComponent={Errors}
asProps={{
errorList: this.props.getErrors('scoringData.errors'),
}}
>
<FormFieldGroup
vAlign="bottom"
rowSpacing="medium"
name={name}
description={
<ScreenReaderContent>{t('A list of possible answers')}</ScreenReaderContent>
}
>
{this.answers.map(this.renderAnswerWrapper)}
</FormFieldGroup>
</FocusGroup>
<Footer
buttonText={t('Possible Answer')}
onCreateChoice={this.handleCreateAnswer}
screenReaderText={t('Add Possible Answer')}
notifyScreenreader={this.props.notifyScreenreader}
automationData="sdk-add-possible-answer-button"
/>
</QuestionContainer>
<QuestionSettingsContainer additionalOptions={this.props.additionalOptions}>
{this.props.showCalculatorOption && (
<QuestionSettingsPanel label={t('Options')} defaultExpanded>
<FormFieldGroup rowSpacing="small" description={this.renderOptionsDescription()}>
<CalculatorOptionWithOqaatAlert
disabled={this.props.overrideEditableForRegrading}
calculatorValue={this.props.calculatorType}
onCalculatorTypeChange={this.handleCalculatorTypeChange}
oqaatChecked={this.props.oneQuestionAtATime}
onOqaatChange={this.props.setOneQuestionAtATime}
/>
</FormFieldGroup>
</QuestionSettingsPanel>
)}
</QuestionSettingsContainer>
<RequirementHelpModal onDismiss={this.closeHelpModal} open={this.state.helpModalOpen} />
</div>
)
}
}