UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

535 lines (478 loc) • 15.8 kB
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> ``` **/ @withEditTools 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> ) } }