@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
482 lines (435 loc) • 16 kB
JavaScript
/** @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>
```
**/
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>
)
}
}