UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

482 lines (435 loc) • 16 kB
/** @jsx jsx */ import {Component} from 'react' import PropTypes from 'prop-types' import {v4 as uuid} from 'uuid' import striptags from 'striptags' import assignIn from 'lodash/fp/assignIn' import set from 'lodash/fp/set' import last from 'lodash/fp/last' import omit from 'lodash/omit' import without from 'lodash/without' import {jsx} from '@instructure/emotion' import {ScreenReaderContent, PresentationContent} from '@instructure/ui-a11y-content' import {Grid} from '@instructure/ui-grid' import {Checkbox} from '@instructure/ui-checkbox' import {Text} from '@instructure/ui-text' import {View} from '@instructure/ui-view' import AnswerInput from '../../common/edit/components/AnswerInput' import Footer from '../../common/edit/components/Footer' import OrderingInteractionType from '../../../records/interactions/ordering' import QuestionContainer from '../../common/edit/components/QuestionContainer' import RemoveChoiceButton from '../../common/edit/components/RemoveChoiceButton' import withEditTools from '../../../util/withEditTools' import Card from '../../common/components/Card' import ReorderChoiceButton from '../common/ReorderChoiceButton' import QuestionSettingsContainer from '../../common/edit/components/QuestionSettingsContainer' import t from '@instructure/quiz-i18n/format-message' import QuestionSettingsPanel from '../../common/edit/components/QuestionSettingsPanel' import CalculatorOptionWithOqaatAlert from '../../common/edit/components/CalculatorOptionWithOqaatAlert' import {TextInput} from '@instructure/quiz-common/components/TextInput/index' import {Flex} from '@instructure/quiz-common/components/Flex/index' import {FormFieldGroup} from '@instructure/quiz-common/components/FormFieldGroup/index' /** --- category: Ordering --- Ordering Edit component ```jsx_example function Example (props) { const exampleProps = { itemBody: 'Order these characters from tallest to shortest:', interactionData: { choices: { uuid6: { id: 'uuid6', itemBody: 'Gandalf' }, uuid5: { id: 'uuid5', itemBody: 'Legolas' }, uuid4: { id: 'uuid4', itemBody: 'Aragorn' }, uuid3: { id: 'uuid3', itemBody: 'Gimli' }, uuid2: { id: 'uuid2', itemBody: 'Frodo' }, uuid1: { id: 'uuid1', itemBody: 'Gollum' }, } }, properties: { displayAnswersParagraph: true, includeLabels: true, topLabel: 'Taller', bottomLabel: 'Shorter' }, scoringData: { value: ['uuid6','uuid5','uuid4','uuid3','uuid2','uuid1'] } } return ( <DndProvider backend={HTML5Backend}> <OrderingEdit {...exampleProps} {...props} /> </DndProvider> ) } <SettingsSwitcher locales={LOCALES}> <EditStateProvider> <Example /> </EditStateProvider> </SettingsSwitcher> ``` **/ @withEditTools export default class OrderingEdit extends Component { static displayName = 'OrderingEdit' static componentId = `Quizzes${this.displayName}` static interactionType = OrderingInteractionType static propTypes = { additionalOptions: QuestionSettingsContainer.propTypes.additionalOptions, calculatorType: PropTypes.string, enableRichContentEditor: PropTypes.bool, interactionData: PropTypes.shape({ choices: PropTypes.objectOf( PropTypes.shape({ id: PropTypes.string.isRequired, itemBody: PropTypes.string.isRequired, }), ).isRequired, }).isRequired, itemBody: PropTypes.string, notifyScreenreader: PropTypes.func, onModalClose: PropTypes.func, onModalOpen: PropTypes.func, oneQuestionAtATime: PropTypes.bool, openImportModal: PropTypes.func, overrideEditableForRegrading: PropTypes.bool, properties: PropTypes.shape({ displayAnswersParagraph: PropTypes.bool, includeLabels: PropTypes.bool, topLabel: PropTypes.string, bottomLabel: PropTypes.string, }).isRequired, scoringData: PropTypes.shape({ value: PropTypes.arrayOf(PropTypes.string).isRequired, }).isRequired, setOneQuestionAtATime: PropTypes.func, ...withEditTools.injectedProps, styles: PropTypes.object, showCalculatorOption: PropTypes.bool, } static defaultProps = { additionalOptions: void 0, calculatorType: 'none', enableRichContentEditor: true, itemBody: void 0, onModalClose: void 0, onModalOpen: void 0, oneQuestionAtATime: false, openImportModal: void 0, overrideEditableForRegrading: false, notifyScreenreader: Function.prototype, setOneQuestionAtATime: Function.prototype, showCalculatorOption: true, } stemElement = null topLabelRef = null bottomLabelRef = null inputRefs = [] reorderRefs = [] removeChoiceRefs = [] _timeouts = [] _choiceWasCreated = false componentWillUnmount() { this._timeouts.forEach(clearTimeout) } componentDidUpdate() { if (this._choiceWasCreated) { this._choiceWasCreated = false last(this.inputRefs).focus() } } // =========== // HANDLERS // =========== handleStemRef = node => { this.stemElement = node } handleTopLabelRef = node => { this.topLabelRef = node } handleBottomLabelRef = node => { this.bottomLabelRef = node } handleCalculatorTypeChange = (e, value) => { this.props.changeItemState({ calculatorType: value, }) } handleMoveChoice = (currI, targetI) => { // Create a new array to preserve immutability const choices = this.props.scoringData.value.slice(0) // Use destructuring to swap the two values ;[choices[targetI], choices[currI]] = [choices[currI], choices[targetI]] this.props.changeItemState({ scoringData: { ...this.props.scoringData, value: choices, }, }) this._timeouts = [...this._timeouts, setTimeout(() => this.reorderRefs[targetI].focus())] } handleRemoveChoice = (choiceId, index) => { const choiceOrder = this.props.scoringData.value if (index === 0) { if (this.props.properties.includeLabels) { this.topLabelRef.focus() } else { // added timeout to compensate for RCE sluggishness this._timeouts = [...this._timeouts, setTimeout(() => this.stemElement.focus())] } } else { if (choiceOrder.length > 3) { this.removeChoiceRefs[index - 1].focus() } else { this.reorderRefs[index - 1].focus() } } this.props.changeItemState({ interactionData: { ...this.props.interactionData, choices: omit(this.props.interactionData.choices, choiceId), }, scoringData: { ...this.props.scoringData, value: without(choiceOrder, choiceId), }, }) } handleCreateChoice = () => { const id = uuid() const newChoice = {id, itemBody: ''} this._choiceWasCreated = true this.props.changeItemState({ interactionData: { ...this.props.interactionData, choices: assignIn(this.props.interactionData.choices, {[id]: newChoice}), }, scoringData: { ...this.props.scoringData, value: [...this.props.scoringData.value, id], }, }) this.props.notifyScreenreader(t('Navigate up to find new choice input')) } handleIncludeLabelsChange = e => { const {properties} = this.props if (!properties.includeLabels) { this.props.notifyScreenreader(t('Navigate up to find label fields')) } this.props.changeItemState({ properties: { ...this.props.properties, includeLabels: !properties['includeLabels'], }, }) } handleDisplayAnswersParagraphChange = e => { const {properties} = this.props this.props.changeItemState({ properties: { ...this.props.properties, displayAnswersParagraph: !properties['displayAnswersParagraph'], }, }) } handleInputChange = (choiceId, event, {editorContent}) => { this.props.changeItemState({ interactionData: set( `choices[${choiceId}].itemBody`, editorContent, this.props.interactionData, ), }) } // =========== // RENDER // =========== renderChoice(choice, isFinalChoice, index) { const choicesLength = this.props.scoringData.value.length const nonEditable = this.props.overrideEditableForRegrading return ( <Card id={choice.id} index={index} key={choice.id} moveCard={this.handleMoveChoice}> <Grid colSpacing="small"> <Grid.Row> <Grid.Col width="auto"> <View as="div" margin="xx-large 0 0 0" themeOverride={{marginXxLarge: '39px'}}> <Flex direction="row" alignItems="start"> <Flex.Item> <ScreenReaderContent> {t('position {position, number}', {position: index + 1})} </ScreenReaderContent> <PresentationContent> <Text color="primary">{t.number(index + 1)}</Text> </PresentationContent> </Flex.Item> </Flex> </View> </Grid.Col> <Grid.Col vAlign="middle"> <View as="div" background="primary"> <AnswerInput disabled={nonEditable} errors={this.props.getErrors(`interactionData.choices[${choice.id}].itemBody`)} id={choice.id} itemBody={choice.itemBody} noRCE={!this.props.enableRichContentEditor} onChangeHandler={this.handleInputChange} onModalClose={this.props.onModalClose} onModalOpen={this.props.onModalOpen} openImportModal={this.props.openImportModal} ref={node => { this.inputRefs[index] = node }} automationData={`sdk-ordering-answer-${choice.id}`} isRequired={true} /> </View> </Grid.Col> <Grid.Col width="auto"> <View as="div" margin="xx-large 0 0 0" themeOverride={{marginXxLarge: '30px'}}> <Flex direction="row" justifyItems="start"> <Flex.Item> <ReorderChoiceButton ref={node => { this.reorderRefs[index] = node }} id={choice.id} isFinalChoice={isFinalChoice} isFirstChoice={index === 0} onMoveChoiceUp={() => this.handleMoveChoice(index, index - 1)} onMoveChoiceDown={() => this.handleMoveChoice(index, index + 1)} screenReaderText={t('Reorder Choice: {choice}', { choice: striptags(choice.itemBody), })} /> </Flex.Item> {choicesLength > 2 && !nonEditable && ( <Flex.Item> <RemoveChoiceButton choiceId={choice.id} ref={node => { this.removeChoiceRefs[index] = node }} onRemoveChoice={() => this.handleRemoveChoice(choice.id, index)} screenReaderText={t('Remove Choice: {choice}', { choice: striptags(choice.itemBody), })} /> </Flex.Item> )} </Flex> </View> </Grid.Col> </Grid.Row> </Grid> </Card> ) } renderChoices() { const choiceOrder = this.props.scoringData.value const choicesLength = choiceOrder.length return choiceOrder.map((choiceId, i) => { const isFinalChoice = i + 1 === choicesLength const choice = this.props.interactionData.choices[choiceId] return ( <View key={`orderchoice-${choiceId}`} as="div" margin="x-small 0"> {this.renderChoice(choice, isFinalChoice, i)} </View> ) }) } renderLabel(labelName, description) { let automation = 'sdk-ordering-edit-' + labelName return ( <View as="div" padding="x-small 0"> <TextInput ref={labelName === 'topLabel' ? this.handleTopLabelRef : this.handleBottomLabelRef} renderLabel={description} isRequired={false} interaction={this.props.overrideEditableForRegrading ? 'disabled' : 'enabled'} name={labelName} defaultValue={this.props.properties[labelName] || ''} onChange={event => { this.props.changeItemState({ properties: { ...this.props.properties, [labelName]: event.target.value, }, }) }} width="20rem" messages={this.props.getErrors(`properties.${labelName}`)} data-automation={automation} /> </View> ) } renderOptionsDescription() { return <ScreenReaderContent>{t('Ordering options')}</ScreenReaderContent> } render() { const {includeLabels, displayAnswersParagraph} = this.props.properties const nonEditable = this.props.overrideEditableForRegrading // clean up the references this.inputRefs = [] this.removeChoiceRefs = [] this.reorderRefs = [] return ( <div> <QuestionContainer disabled={nonEditable} 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} > {includeLabels && this.renderLabel('topLabel', t('Top Label'))} <View as="div">{this.renderChoices()}</View> {!nonEditable && ( <Footer onCreateChoice={this.handleCreateChoice} notifyScreenreader={this.props.notifyScreenreader} automationData="sdk-ordering-add-answer" /> )} {includeLabels && this.renderLabel('bottomLabel', t('Bottom Label'))} </QuestionContainer> <QuestionSettingsContainer additionalOptions={this.props.additionalOptions}> <QuestionSettingsPanel label={t('Options')} defaultExpanded> <FormFieldGroup rowSpacing="small" description={this.renderOptionsDescription()}> {this.props.showCalculatorOption && ( <CalculatorOptionWithOqaatAlert disabled={nonEditable} calculatorValue={this.props.calculatorType} onCalculatorTypeChange={this.handleCalculatorTypeChange} oqaatChecked={this.props.oneQuestionAtATime} onOqaatChange={this.props.setOneQuestionAtATime} /> )} <Checkbox label={t('Display Answers in a Paragraph')} onChange={this.handleDisplayAnswersParagraphChange} checked={displayAnswersParagraph || false} disabled={nonEditable} data-automation="sdk-display-answer-in-paragraph-checkbox" /> <Checkbox label={t('Include Labels')} onChange={this.handleIncludeLabelsChange} checked={includeLabels || false} disabled={nonEditable} data-automation="sdk-include-labels-checkbox" /> </FormFieldGroup> </QuestionSettingsPanel> </QuestionSettingsContainer> </div> ) } }