UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

544 lines (495 loc) • 17 kB
import React, {Component} from 'react' import PropTypes from 'prop-types' import map from 'lodash/fp/map' import filter from 'lodash/fp/filter' import set from 'lodash/fp/set' import fromPairs from 'lodash/fp/fromPairs' import pullAt from 'lodash/fp/pullAt' import findIndex from 'lodash/fp/findIndex' import isEmpty from 'lodash/fp/isEmpty' import uniq from 'lodash/fp/uniq' import shuffle from 'lodash/fp/shuffle' import {v4 as uuid} from 'uuid' import FocusGroup from '../../common/components/FocusGroup' import MatchingInteractionType from '../../../records/interactions/matching' import QuestionContainer from '../../common/edit/components/QuestionContainer' import QuestionSettingsContainer from '../../common/edit/components/QuestionSettingsContainer' import DistractorList from './DistractorList' import MatchListEdit from './MatchListEdit' import withEditTools from '../../../util/withEditTools' import t from '@instructure/quiz-i18n/format-message' import {RadioInputGroup, RadioInput} from '@instructure/ui-radio-input' import {Checkbox} from '@instructure/ui-checkbox' import {View} from '@instructure/ui-view' import QuestionSettingsPanel from '../../common/edit/components/QuestionSettingsPanel' import CalculatorOptionWithOqaatAlert from '../../common/edit/components/CalculatorOptionWithOqaatAlert' import {ScreenReaderContent} from '@instructure/ui-a11y-content' import {IconButton} from '@instructure/ui-buttons' import {IconQuestionLine} from '@instructure/ui-icons' import {Text} from '@instructure/ui-text' import {SimpleModal} from '@instructure/quiz-common/components/SimpleModal/index' import {FormFieldGroup} from '@instructure/quiz-common/components/FormFieldGroup/index' const QUESTION_SELECTOR = '[data-role=questionWrapper] input' // Scoring Algorithms: const DEEP_EQUALS = 'DeepEquals' const PARTIAL_DEEP = 'PartialDeep' // return editData if supplied, otherwise reconstruct it from props export const getEditData = ({scoringData, interactionData}) => { const {editData} = scoringData if (!isEmpty(editData)) { return { matches: editData.matches || [], distractors: editData.distractors || [], } } const matches = map( ({id, itemBody}) => ({ questionId: id, questionBody: itemBody, answerBody: scoringData.value[id], }), interactionData.questions, ) const matchBodies = map('answerBody', matches) const distractors = filter( answerBody => !matchBodies.includes(answerBody), interactionData.answers, ) return {matches, distractors} } // given editData, calculate the rest of the props export const getPropsFromEditData = editData => ({ interactionData: { questions: map( match => ({ id: match.questionId, itemBody: match.questionBody, }), editData.matches, ), answers: shuffle(uniq([...map('answerBody', editData.matches), ...editData.distractors])), }, scoringData: { value: fromPairs(map(match => [match.questionId, match.answerBody], editData.matches)), editData, }, }) /** --- category: Matching --- Matching Edit component ```jsx_example function Example (props) { const exampleProps = { itemBody: 'Match the Secretary of State with the President they served under.', overrideEditableForRegrading: false, properties: { shuffleRules: { questions: { shuffled: true } } }, interactionData: { questions: [ { id: 'uuid1', itemBody: 'Condi Rice' }, { id: 'uuid2', itemBody: 'Alexander Haig' }, { id: 'uuid3', itemBody: 'John Kerry' } ], answers: [ 'Ronald Reagan', 'George W. Bush', 'Barack Obama', 'George H.W. Bush', 'Bill Clinton' ] }, scoringData: { value: { uuid1: 'George W. Bush', uuid2: 'Ronald Reagan', uuid3: 'Barack Obama' }, editData: {} } } return ( <MatchingEdit {...exampleProps} {...props} /> ) } <SettingsSwitcher locales={LOCALES}> <EditStateProvider> <Example /> </EditStateProvider> </SettingsSwitcher> ``` **/ @withEditTools export default class MatchingEdit extends Component { static interactionType = MatchingInteractionType static propTypes = { additionalOptions: QuestionSettingsContainer.propTypes.additionalOptions, calculatorType: PropTypes.string, changeItemState: PropTypes.func, enableRichContentEditor: PropTypes.bool, interactionData: PropTypes.shape({ questions: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.string, itemBody: PropTypes.string, }), ), answers: PropTypes.arrayOf(PropTypes.string), }).isRequired, itemBody: PropTypes.string, newId: PropTypes.func, onModalClose: PropTypes.func, onModalOpen: PropTypes.func, oneQuestionAtATime: PropTypes.bool, openImportModal: PropTypes.func, overrideEditableForItem: PropTypes.bool, overrideEditableForRegrading: PropTypes.bool, partialDeepScoringEnabled: PropTypes.bool, properties: PropTypes.object, scoringAlgorithm: PropTypes.string, scoringData: PropTypes.shape({ value: PropTypes.objectOf(PropTypes.string), editData: PropTypes.shape({ matches: MatchListEdit.propTypes.matches, distractors: DistractorList.propTypes.distractors, }), }), setOneQuestionAtATime: PropTypes.func, notifyScreenreader: PropTypes.func, ...withEditTools.injectedProps, showCalculatorOption: PropTypes.bool, } static defaultProps = { calculatorType: 'none', enableRichContentEditor: true, oneQuestionAtATime: false, overrideEditableForItem: false, overrideEditableForRegrading: false, newId: uuid, setOneQuestionAtATime: Function.prototype, notifyScreenreader: Function.prototype, additionalOptions: void 0, changeItemState: void 0, interactionData: void 0, itemBody: void 0, onModalClose: void 0, onModalOpen: void 0, openImportModal: void 0, partialDeepScoringEnabled: false, properties: void 0, scoringAlgorithm: null, scoringData: void 0, showCalculatorOption: true, } state = { isModalOpen: false, } componentDidMount() { this.setDefaultScoringAlgorithm() } componentWillUnmount() { this._timeouts.forEach(clearTimeout) } componentDidUpdate() { this.setDefaultScoringAlgorithm() } setDefaultScoringAlgorithm() { if (!this.props.scoringAlgorithm && this.props.partialDeepScoringEnabled) { this.props.changeItemState({scoringAlgorithm: PARTIAL_DEEP}) } } _timeouts = [] focusMatch = null focusDistractor = null stemElement = null overrideEditable() { return this.props.overrideEditableForItem || this.props.overrideEditableForRegrading } // ============= // HELPERS // ============= updateEditData(newEditData) { this.props.changeItemState(getPropsFromEditData(newEditData)) } questionErrors = index => { return this.props.getErrors(`scoringData.editData.matches[${index}].questionBody`).slice(0, 1) } answerErrors = index => { return this.props.getErrors(`scoringData.editData.matches[${index}].answerBody`).slice(0, 1) } distractorErrors = index => { return this.props.getErrors(`scoringData.editData.distractors[${index}]`).slice(0, 1) } // ============= // HANDLERS // ============= handleScoringAlgoChange = (e, value) => { this.props.changeItemState({scoringAlgorithm: value}, {scoringAlgorithm: value}) } blurDistractor = (index, e) => { this.editDistractor(index, {target: {value: e.target.value.trim()}}) } editDistractor = (index, e) => { const editData = getEditData(this.props) if (index >= 0 && index < editData.distractors.length) { this.updateEditData(set(`distractors[${index}]`, e.target.value, editData)) } } editQuestion = (questionId, text) => { const editData = getEditData(this.props) const questionIndex = findIndex({questionId}, editData.matches) if (questionIndex !== -1) { this.updateEditData(set(`matches[${questionIndex}].questionBody`, text, editData)) } } editAnswer = (questionId, text) => { const editData = getEditData(this.props) const questionIndex = findIndex({questionId}, editData.matches) if (questionIndex !== -1) { this.updateEditData(set(`matches[${questionIndex}].answerBody`, text, editData)) } } updateFocusOnRemoveMatch() { if (!this.focusMatch.previousExists('button')) { // if removing the first choice, focus on stem this.stemElement.focus() } else if (this.props.interactionData.questions.length === 2) { // if removing the second choice out of two choices this._timeouts = [ ...this._timeouts, setTimeout(() => this.focusMatch.focusLast('input'), 100), ] } else { // all the other cases this.focusMatch.focusPrevious('button') } } handleShuffleChange = event => { const properties = set( 'shuffleRules.questions.shuffled', event.target.checked, this.props.properties, ) this.props.changeItemState({properties}, {properties}) } handleRemoveMatch = questionId => { if (document.activeElement !== document.body) { // In FF clicking on a button doesn't focus it, so don't update focus in such case this.updateFocusOnRemoveMatch() } const editData = getEditData(this.props) this.updateEditData( set( 'matches', editData.matches.filter(match => match.questionId !== questionId), editData, ), ) } handleCreateMatch = () => { this._timeouts = [ ...this._timeouts, setTimeout(() => this.focusMatch.focusLast(QUESTION_SELECTOR), 100), ] const editData = getEditData(this.props) this.updateEditData( set( 'matches', [ ...editData.matches, { questionId: this.props.newId(), questionBody: '', answerBody: '', }, ], editData, ), ) } handleRemoveDistractor = index => { if (this.focusDistractor.previousExists('button')) { this.focusDistractor.focusPrevious('button') } else { this.focusMatch.focusLast() } const editData = getEditData(this.props) this.updateEditData(set('distractors', pullAt([index], editData.distractors), editData)) } handleCreateDistractor = () => { this._timeouts = [ ...this._timeouts, setTimeout(() => this.focusDistractor.focusLast('input'), 100), ] const editData = getEditData(this.props) this.updateEditData(set('distractors', [...editData.distractors, ''], editData)) } handleCalculatorTypeChange = (e, value) => { this.props.changeItemState({ calculatorType: value, }) } handleDescriptionChange = itemBody => { this.props.changeItemState({itemBody}) } handleFocusMatchRef = node => { this.focusMatch = node } handleFocusDistractorRef = node => { this.focusDistractor = node } handleStemRef = node => { this.stemElement = node } handleCloseModal = () => { this.setState({isModalOpen: false}) } handleOpenModal = () => { this.setState({isModalOpen: true}) } // ============= // RENDERING // ============= renderOptionsDescription() { return <ScreenReaderContent>{t('Matching options')}</ScreenReaderContent> } renderGradingOptions() { return ( <View as="div" margin="medium 0" position="relative"> <RadioInputGroup onChange={this.handleScoringAlgoChange} name={t('Grading')} value={this.props.scoringAlgorithm} description={<ScreenReaderContent>{t('Grading')}</ScreenReaderContent>} > <span> <Text>{t('Grading')}</Text> <IconButton size="small" withBackground={false} withBorder={false} renderIcon={IconQuestionLine} onClick={this.handleOpenModal} screenReaderLabel={t('Open grading option information')} /> </span> <RadioInput value={PARTIAL_DEEP} label={t('Partial credit')} data-automation="sdk-grading-partial-credit-radio-input" /> <RadioInput value={DEEP_EQUALS} label={t('Exact match')} data-automation="sdk-grading-exact-match-radio-input" /> </RadioInputGroup> </View> ) } 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')} </Text> <br /> <Text>{t('Students are awarded points for every correct answer.')}</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() { const {matches, distractors} = getEditData(this.props) return ( <div> <QuestionContainer disabled={this.props.overrideEditableForRegrading} enableRichContentEditor={this.props.enableRichContentEditor} itemBody={this.props.itemBody} onDescriptionChange={this.handleDescriptionChange} onModalClose={this.props.onModalClose} onModalOpen={this.props.onModalOpen} openImportModal={this.props.openImportModal} stemErrors={this.props.getErrors('itemBody')} textareaRef={this.handleStemRef} > <div> <FocusGroup ref={this.handleFocusMatchRef}> <MatchListEdit // Passed in as new function to force re-render answerErrors={index => this.answerErrors(index)} createNewMatch={this.handleCreateMatch} editAnswer={this.editAnswer} editQuestion={this.editQuestion} // Passed in as new function to force re-render questionErrors={index => this.questionErrors(index)} removeMatch={this.handleRemoveMatch} disabled={this.props.overrideEditableForRegrading} matches={matches} notifyScreenreader={this.props.notifyScreenreader} /> </FocusGroup> <FocusGroup ref={this.handleFocusDistractorRef}> <DistractorList disabled={this.props.overrideEditableForRegrading} createNewDistractor={this.handleCreateDistractor} distractors={distractors} editDistractor={this.editDistractor} blurDistractor={this.blurDistractor} // Passed in as new function to force re-render distractorErrors={index => this.distractorErrors(index)} notifyScreenreader={this.props.notifyScreenreader} removeDistractor={this.handleRemoveDistractor} /> </FocusGroup> </div> </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={t('Shuffle questions')} onChange={this.handleShuffleChange} checked={this.props.properties.shuffleRules.questions.shuffled} disabled={this.props.overrideEditableForRegrading} data-automation="sdk-shuffle-questions-checkbox" /> {this.props.partialDeepScoringEnabled && this.renderGradingOptions()} </FormFieldGroup> </QuestionSettingsPanel> </QuestionSettingsContainer> {this.renderGradingOptionsModal()} </div> ) } }