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