UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

623 lines (553 loc) • 20.5 kB
/** @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), })) @withAsyncDeps(mathjsIsLoaded, loadMathjs) @withEditTools @withStyleOverrides(generateStyle, generateComponentTheme) 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> ) } }