@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
623 lines (553 loc) • 20.5 kB
JavaScript
/** @jsx jsx */
import {Component} from 'react'
import PropTypes from 'prop-types'
import update from 'immutability-helper'
import sortBy from 'lodash/sortBy'
import get from 'lodash/get'
import omit from 'lodash/omit'
import NumberInput from '@instructure/quiz-number-input/components/NumberInput/index'
import {PresentationContent, ScreenReaderContent} from '@instructure/ui-a11y-content'
import {Text} from '@instructure/ui-text'
import {Table} from '@instructure/ui-table'
import {jsx} from '@instructure/emotion'
import {isScientificNotation} from '@instructure/quiz-scientific-notation'
import QuestionSettingsContainer from '../../common/edit/components/QuestionSettingsContainer'
import QuestionContainer from '../../common/edit/components/QuestionContainer'
import * as util from './util'
import FormulaSection from './FormulaSection'
import GenerateSolutionsService from './GenerateSolutionsService'
import VariableInput from './VariableInput'
import {mathjsIsLoaded, loadMathjs} from '../common/util'
import {toErrors} from '../../../util/instUIMessages'
import {variablesFromItemBody, parseFormulaDecimalSeparator} from '../../../util/formula'
import FormulaInteractionType from '../../../records/interactions/formula'
import withEditTools from '../../../util/withEditTools'
import withAsyncDeps from '../../../util/withAsyncDeps'
import generateStyle from './styles'
import generateComponentTheme from './theme'
import t from '@instructure/quiz-i18n/format-message'
import QuestionSettingsPanel from '../../common/edit/components/QuestionSettingsPanel'
import CalculatorOptionWithOqaatAlert from '../../common/edit/components/CalculatorOptionWithOqaatAlert'
import {withStyleOverrides} from '@instructure/quiz-common/util/withStyleOverrides'
import {FormFieldGroup} from '@instructure/quiz-common/components/FormFieldGroup/index'
/**
---
category: Formula
---
Formula Edit component
```jsx_example
class Example extends React.Component {
render () {
const variables = 'abcdefghijklmnopqrstuvwxyz'.split('')
const exampleProps = {
itemBody: variables.map(v => `\`${v}\``).join('+'),
scoringData: {
value: {
answerCount: '10',
answerPrecision: 0,
formula: variables.join('+'),
generatedSolutions: [],
numeric: {
marginType: 'absolute',
margin: 1,
},
scientificNotation: false,
variables: variables.map(char => ({
name: char,
min: 90000,
max: 99999,
precision: 0
}))
}
},
overrideEditableForRegrading: false,
additionalOptions: [{
key: 'outcomes',
title: 'Align to Outcomes',
component: 'Placeholder'
}]
}
return (
<FormulaEdit {...exampleProps} {...this.props} />
)
}
}
<SettingsSwitcher locales={LOCALES}>
<EditStateProvider>
<Example />
</EditStateProvider>
</SettingsSwitcher>
```
**/
const formatNumber = n => (isScientificNotation(n) ? n : Number(n))
const normalizeVariables = variables =>
variables.map(group => ({
...group,
min: formatNumber(group.min),
max: formatNumber(group.max),
precision: Number(group.precision),
}))
export default class FormulaEdit extends Component {
static displayName = 'FormulaEdit'
static componentId = `Quizzes${this.displayName}`
static interactionType = FormulaInteractionType
static propTypes = {
additionalOptions: PropTypes.array,
calculatorType: PropTypes.string,
changeItemState: PropTypes.func,
enableRichContentEditor: PropTypes.bool,
errorsAreShowing: PropTypes.bool,
interactionData: PropTypes.object,
itemBody: PropTypes.string,
notifyScreenreader: PropTypes.func.isRequired,
onModalClose: PropTypes.func,
onModalOpen: PropTypes.func,
oneQuestionAtATime: PropTypes.bool,
openImportModal: PropTypes.func,
overrideEditableForRegrading: PropTypes.bool,
properties: PropTypes.object,
scoringData: PropTypes.object,
setOneQuestionAtATime: PropTypes.func,
...withEditTools.injectedProps,
styles: PropTypes.object,
showCalculatorOption: PropTypes.bool,
separatorConfig: PropTypes.shape({
decimalSeparator: PropTypes.string,
thousandSeparator: PropTypes.string,
}),
}
static defaultProps = {
additionalOptions: [],
calculatorType: 'none',
enableRichContentEditor: true,
oneQuestionAtATime: false,
overrideEditableForRegrading: false,
setOneQuestionAtATime: Function.prototype,
changeItemState: void 0,
errorsAreShowing: void 0,
interactionData: void 0,
itemBody: void 0,
onModalClose: void 0,
onModalOpen: void 0,
openImportModal: void 0,
properties: void 0,
scoringData: void 0,
showCalculatorOption: true,
}
static contextTypes = {
locale: PropTypes.string,
}
constructor(props) {
super(props)
this.generateSolutionsService = new GenerateSolutionsService({
onStart: this.serviceOnStart,
onSuccess: this.serviceOnComplete(util.STATUS_STOPPED),
onFailure: this.serviceOnComplete(util.STATUS_FAILED),
onCancel: this.serviceOnCancel,
})
this.state = {
status: util.STATUS_STOPPED,
}
}
// ==============================
// HOOKS FOR GENERATING SOLUTIONS
// ==============================
serviceOnStart = () => {
this.setState({status: util.STATUS_RUNNING})
}
serviceOnComplete = status => solutions => {
this.setState({status: status})
const scoringData = update(this.props.scoringData, {
value: {
generatedSolutions: {$set: solutions},
},
})
this.props.changeItemState({scoringData})
let message = util.buildSolutionsGeneratedMessage(status, solutions.length)
this.props.notifyScreenreader(`${t('Solutions updated.')} ${message}`)
}
serviceOnCancel = () => {
this.setState({status: util.STATUS_CANCELED})
}
// ====================
// INPUT EVENT HANDLERS
// ====================
handleCalculatorTypeChange = (e, value) => {
this.props.changeItemState({
calculatorType: value,
})
}
handleItemBodyChange = itemBody => {
const newVariableNames = variablesFromItemBody(itemBody)
const oldVariables = this.props.scoringData.value.variables
const newVariables = []
newVariableNames.forEach(variableName => {
const defaultVariable = {
name: variableName,
min: 0,
max: 10,
precision: 0,
}
const newVariable = oldVariables.find(v => v.name === variableName) || defaultVariable
newVariables.push(newVariable)
})
const scoringData = update(this.props.scoringData, {
value: {
generatedSolutions: {$set: []},
variables: {$set: sortBy(newVariables, v => v.name)},
},
})
this.props.changeItemState({itemBody, scoringData})
this.generateSolutionsService.cancel()
}
handleVariableChange = (variableIdx, field) => value => {
const scoringData = update(this.props.scoringData, {
value: {
generatedSolutions: {$set: []},
variables: {
[variableIdx]: {
[field]: {$set: value},
},
},
},
})
this.props.changeItemState({scoringData})
this.generateSolutionsService.cancel()
}
handlePrecisionChange = variableIdx => (e, value, normalized) => {
if (normalized === null) return
const variable = this.props.scoringData.value.variables[variableIdx]
if (normalized == variable.precision) return // intentional double-equals
const scoringData = update(this.props.scoringData, {
value: {
generatedSolutions: {$set: []},
variables: {
[variableIdx]: {
$set: {
name: variable.name,
precision: normalized,
min: util.toPrecision(variable.min, normalized),
max: util.toPrecision(variable.max, normalized),
},
},
},
},
})
this.props.changeItemState({scoringData})
this.generateSolutionsService.cancel()
}
handleFormulaChange = e => {
const scoringData = update(this.props.scoringData, {
value: {
generatedSolutions: {$set: []},
formula: {$set: e.target.value},
},
})
this.props.changeItemState({scoringData})
this.generateSolutionsService.cancel()
}
handleMarginOfErrorTypeChange = (e, {value}) => {
const scoringData = update(this.props.scoringData, {
value: {
numeric: {
marginType: {$set: value},
},
},
})
this.props.changeItemState({scoringData})
}
handleMarginOfErrorChange = (e, value, normalizedValue) => {
if (normalizedValue == this.props.scoringData.value.numeric.margin) return // intentional double-equals
const scoringData = update(this.props.scoringData, {
value: {
numeric: {
margin: {$set: Number(normalizedValue).toString()},
},
},
})
this.props.changeItemState({scoringData})
}
handleScientificNotationChange = e => {
const scientificNotation = !this.props.scoringData.value.scientificNotation
// The numeric scoring algorithm doesn't support scientific notation for margin of error
const numeric = scientificNotation
? {type: 'exactResponse'}
: {type: 'marginOfError', marginType: 'absolute', margin: 0}
const scoringData = {
...this.props.scoringData,
value: {
...this.props.scoringData.value,
generatedSolutions: [],
numeric,
scientificNotation,
},
}
this.props.changeItemState({scoringData})
}
handleAnswerCountChange = (e, answerCount) => {
const scoringData = update(this.props.scoringData, {
value: {
generatedSolutions: {$set: []},
answerCount: {$set: answerCount},
},
})
this.props.changeItemState({scoringData})
this.generateSolutionsService.cancel()
}
handleAnswerPrecisionChange = (_e, _answerPrecision, answerPrecisionNormalized) => {
const answerPrecision = Number(answerPrecisionNormalized)
if (answerPrecision === Number(this.props.scoringData.value.answerPrecision || 0)) return
const scoringData = update(this.props.scoringData, {
value: {
generatedSolutions: {$set: []},
answerPrecision: {$set: answerPrecision},
},
})
this.props.changeItemState({scoringData})
this.generateSolutionsService.cancel()
}
handleGenerateSolutions = () => {
const {answerCount, answerPrecision, variables, formula, scientificNotation} =
this.props.scoringData.value
const parsedAnswerCount = parseInt(answerCount, 10) || 0
const scoringDataSetupErrors = this.scoringDataSetupErrors()
if (Object.keys(scoringDataSetupErrors).length > 0) {
this.notifyScreenreaderOfSetupErrors(scoringDataSetupErrors)
this.setState({status: util.STATUS_FORMULA_SETUP_INVALID})
return
} else if (this.state.status === util.STATUS_FORMULA_SETUP_INVALID) {
this.setState({status: util.STATUS_STOPPED})
}
this.generateSolutionsService.start(
parsedAnswerCount,
normalizeVariables(variables),
parseFormulaDecimalSeparator(this.getLocale(), formula),
answerPrecision,
scientificNotation,
)
}
// =================
// UTILITY FUNCTIONS
// =================
scoringDataSetupErrors() {
const scoringDataErrors = get(this.errors(), ['scoringData', 'value'], {})
return omit(scoringDataErrors, ['generatedSolutions'])
}
notifyScreenreaderOfSetupErrors(sdSetupErrors) {
const vars = Object.keys(sdSetupErrors.variables || {}).map(v =>
this.props.scoringData.value.variables[v].name.replace(/`/g, ''),
)
let errorMsg
if (vars.length > 0) {
errorMsg = t('variables containing errors: {vars}', {vars: vars.join(', ')})
}
if (this.props.scoringData.value.variables.length === 0) {
errorMsg = t('must define at least one variable')
}
if (errorMsg) {
this.props.notifyScreenreader(
t('The following error prevented generating solutions: {errorMsg}', {errorMsg}),
)
}
}
getLocale = () => {
return this.context.locale || window?.document?.documentElement?.lang || 'en-US'
}
// ===================
// RENDERING FUNCTIONS
// ===================
renderVariable = (variableRecord, idx) => {
const variableName = variableRecord.name
const precision = Number(variableRecord.precision)
const errorPath = ['scoringData', 'value', 'variables', idx]
return (
<Table.Row key={variableName}>
<Table.RowHeader>
<PresentationContent>{variableName}</PresentationContent>
<ScreenReaderContent tabIndex={0}>
{t('Variable {variable}', {variable: variableName})}
</ScreenReaderContent>
</Table.RowHeader>
<Table.Cell>
<VariableInput
disabled={this.props.overrideEditableForRegrading}
decimalPrecision={precision}
messages={toErrors(this.errorsFor([...errorPath, 'min']))}
onUpdate={this.handleVariableChange(idx, 'min')}
value={variableRecord.min}
width="6rem"
renderLabel={
<ScreenReaderContent>
{t('Minimum value for variable {variable}', {variable: variableName})}
</ScreenReaderContent>
}
/>
</Table.Cell>
<Table.Cell>
<VariableInput
disabled={this.props.overrideEditableForRegrading}
decimalPrecision={precision}
messages={toErrors(this.errorsFor([...errorPath, 'max']))}
onUpdate={this.handleVariableChange(idx, 'max')}
value={variableRecord.max}
width="6rem"
renderLabel={
<ScreenReaderContent>
{t('Maximum value for variable {variable}', {variable: variableName})}
</ScreenReaderContent>
}
/>
</Table.Cell>
<Table.Cell>
<NumberInput
disabled={this.props.overrideEditableForRegrading}
max={10}
messages={toErrors(this.errorsFor([...errorPath, 'precision']))}
min={0}
onChange={this.handlePrecisionChange(idx)}
showArrows
step={1}
value={variableRecord.precision}
width="6rem"
renderLabel={
<ScreenReaderContent>
{t('decimals of precision for variable {variable}', {variable: variableName})}
</ScreenReaderContent>
}
/>
</Table.Cell>
</Table.Row>
)
}
renderVariablesTable() {
const variables = normalizeVariables(this.props.scoringData.value.variables)
return (
<Table caption="">
<Table.Body>
<Table.Row>
<Table.ColHeader id="formula-edit-variable">{t('Variable')}</Table.ColHeader>
<Table.ColHeader id="formula-edit-min">
<ScreenReaderContent>{t('Minimum Value')}</ScreenReaderContent>
<PresentationContent>
<div title={t('Minimum Value')}>{t('Min')}</div>
</PresentationContent>
</Table.ColHeader>
<Table.ColHeader id="formula-edit-max">
<ScreenReaderContent>{t('Maximum Value')}</ScreenReaderContent>
<PresentationContent>
<div title={t('Maximum Value')}>{t('Max')}</div>
</PresentationContent>
</Table.ColHeader>
<Table.ColHeader id="formula-edit-decimals">{t('Decimals')}</Table.ColHeader>
</Table.Row>
{variables.map(this.renderVariable)}
</Table.Body>
</Table>
)
}
errorsFor(path) {
if (!this.props.errorsAreShowing && this.state.status !== util.STATUS_FORMULA_SETUP_INVALID) {
return []
}
return get(this.errors(), path, [])
}
errors() {
return new FormulaInteractionType({
...omit(this.props, ['getErrors', 'scoringData']),
scoringData: this.props.scoringData,
}).getErrors()
}
renderOptionsDescription() {
return <ScreenReaderContent>{t('Formula options')}</ScreenReaderContent>
}
render() {
return (
<div>
<div>
<Text color="primary">
{t(
'Enter your question, build a formula, and generate a set of possible answer' +
' combinations. Students will see the question with a randomly selected set' +
' of variables filled in and have to type the correct numerical answer.',
)}
</Text>
</div>
<div css={this.props.styles.sectionHeading}>
<Text size="large">{t('Question')}</Text>
</div>
<div css={this.props.styles.instructions}>
<Text color="primary">
{t(
'You can define variables by typing variable names surrounded by backticks (e.g., "what is 5 plus `x`?")',
)}
</Text>
</div>
<QuestionContainer
disabled={this.props.overrideEditableForRegrading}
enableRichContentEditor={this.props.enableRichContentEditor}
itemBody={this.props.itemBody}
onDescriptionChange={this.handleItemBodyChange}
onModalClose={this.props.onModalClose}
onModalOpen={this.props.onModalOpen}
openImportModal={this.props.openImportModal}
stemErrors={this.errorsFor(['itemBody'])}
textareaRef={this.handleStemRef}
>
<div css={this.props.styles.sectionHeading}>
<Text size="large">{t('Answers')}</Text>
</div>
<div css={this.props.styles.instructions}>
<Text color="primary">
{t(
'Once you have entered your variables above, you should see them' +
' listed below. You can specify the range of possible values for' +
' each variable below.',
)}
</Text>
</div>
<div data-section="variable_definitions">{this.renderVariablesTable()}</div>
<div data-section="formula">
<FormulaSection
locale={this.getLocale()}
formulaErrors={this.errorsFor(['scoringData', 'value', 'formula'])}
generatedSolutionsErrors={this.errorsFor([
'scoringData',
'value',
'generatedSolutions',
'$errors',
])}
handleAnswerCountChange={this.handleAnswerCountChange}
handleAnswerPrecisionChange={this.handleAnswerPrecisionChange}
handleFormulaChange={this.handleFormulaChange}
handleGenerateSolutions={this.handleGenerateSolutions}
handleMarginOfErrorTypeChange={this.handleMarginOfErrorTypeChange}
handleMarginOfErrorChange={this.handleMarginOfErrorChange}
handleScientificNotationChange={this.handleScientificNotationChange}
overrideEditableForRegrading={this.props.overrideEditableForRegrading}
scoringData={this.props.scoringData}
status={this.state.status}
/>
</div>
</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>
</div>
)
}
}