@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
326 lines (280 loc) • 9.56 kB
JavaScript
import React, {Component} from 'react'
import PropTypes from 'prop-types'
import update from 'immutability-helper'
import findIndex from 'lodash/findIndex'
import {IconButton} from '@instructure/ui-buttons'
import {Menu} from '@instructure/ui-menu'
import {IconDragHandleLine} from '@instructure/ui-icons'
import {ItemBodyWrapper} from '@instructure/quiz-rce/components/ItemBodyWrapper/index'
import ChoicesList from '../common/ChoicesList'
import CategoriesContainer from '../common/CategoriesContainer'
import FocusGroup from '../../common/components/FocusGroup'
import {UNCATEGORIZED, getSortedCategories} from '../common/utils'
import t from '@instructure/quiz-i18n/format-message'
import {v4 as uuid} from 'uuid'
/**
---
category: Categorization
---
Categorization Take component
```jsx_example
function Example (props) {
const exampleProps = {
itemBody: 'Match the name of the celestial body on the left with its correct classification:',
interactionData: {
categoryOrder: ['uuid1', 'uuid2', 'uuid11'],
categories: {
uuid1: { id: 'uuid1', itemBody: 'Planet' },
uuid2: { id: 'uuid2', itemBody: 'Moon' },
uuid11: { id: 'uuid11', itemBody: 'Galaxy' }
},
distractors: {
uuid3: { id: 'uuid3', itemBody: 'Mars' },
uuid4: { id: 'uuid4', itemBody: 'Europa' },
uuid5: { id: 'uuid5', itemBody: 'Venus' },
uuid6: { id: 'uuid6', itemBody: 'Phobos' },
uuid7: { id: 'uuid7', itemBody: 'Deimos' },
uuid8: { id: 'uuid8', itemBody: 'Jupiter' },
uuid9: { id: 'uuid9', itemBody: 'America' },
uuid10: { id: 'uuid10', itemBody: 'Asia' }
}
},
userResponse: {
value: [{
id: 'uuid1',
value: ['uuid3', 'uuid5', 'uuid8'],
type: 'ArrayText'
},{
id: 'uuid2',
value: ['uuid4', 'uuid6', 'uuid7'],
type: 'ArrayText'
}, {
id: 'uuid11',
value: [],
type: 'ArrayText'
}]
}
}
return (
<DndProvider backend={HTML5Backend}>
<CategorizationTake {...exampleProps} {...props} />
</DndProvider>
)
}
<SettingsSwitcher locales={LOCALES}>
<TakeStateProvider>
<Example />
</TakeStateProvider>
</SettingsSwitcher>
```
**/
export default class CategorizationTake extends Component {
static propTypes = {
handleResponseUpdate: PropTypes.func.isRequired,
interactionData: PropTypes.object.isRequired,
itemBody: PropTypes.string.isRequired,
notifyScreenreader: PropTypes.func.isRequired,
userResponse: PropTypes.object.isRequired,
}
takeId = uuid()
_timeouts = []
categoryRefs = []
componentWillUnmount() {
this._timeouts.forEach(clearTimeout)
}
constructor(props) {
super(props)
const {categories, categoryOrder} = this.props.interactionData
this.state = {sortedCategories: getSortedCategories(categories, categoryOrder)}
}
categoryIndex(idCategory) {
return findIndex(this.props.userResponse.value, {id: idCategory})
}
removeAnswerFromCategory(source, idAnswer, categoryIndex) {
const answerIndex = source[categoryIndex].value.indexOf(idAnswer)
return update(source, {
[categoryIndex]: {
value: {
$splice: [[answerIndex, 1]],
},
},
})
}
addAnswerOnCategory(source, idAnswer, categoryIndex) {
return update(source, {
[categoryIndex]: {
value: {
$push: [idAnswer],
},
},
})
}
getDistractorBody(distractorId) {
return this.props.interactionData.distractors[distractorId.split('_')[0]].itemBody
}
categorizedDistractors(userResponseValue) {
if (!userResponseValue) return new Set()
return new Set(
userResponseValue
.filter(category => category.id !== UNCATEGORIZED)
.reduce((ids, category) => [...ids, ...category.value], []),
)
}
uncategorizedDistractors(userResponseValue) {
const {interactionData} = this.props
if (!interactionData.distractors) return []
const categorized = this.categorizedDistractors(userResponseValue)
return Object.keys(interactionData.distractors).filter(id => !categorized.has(id))
}
includeUncategorized(userResponseValue) {
return [
...userResponseValue.filter(category => category.id !== UNCATEGORIZED),
{
id: UNCATEGORIZED,
value: this.uncategorizedDistractors(userResponseValue),
type: 'ArrayText',
},
]
}
// ===========
// ACTIONS
// ===========
resetFocus = () => {
this._timeouts = this._timeouts.concat(
setTimeout(() => this.focusGroup.focusFirst('[role="button"]'), 100),
)
}
onMenuItemSelected = (answerId, categoryId, removed, event, focusNewCategory) => {
event && event.preventDefault && event.preventDefault()
const distractorDoesntMatchOptions = !this.distractorExists(answerId)
if (distractorDoesntMatchOptions) {
// if distractor for different question
// is dropped into this component
return
}
const newCategoryIndex = this.categoryIndex(removed ? UNCATEGORIZED : categoryId)
let newUserResponse = this.props.userResponse.value || []
// remove answer from former category if exists
const oldCategoryIndex = newUserResponse.findIndex(category =>
category.value.includes(answerId),
)
if (oldCategoryIndex !== -1) {
newUserResponse = this.removeAnswerFromCategory(newUserResponse, answerId, oldCategoryIndex)
}
// add answer on new category
if (newCategoryIndex !== -1) {
newUserResponse = this.addAnswerOnCategory(newUserResponse, answerId, newCategoryIndex)
}
const selectedCategory = this.state.sortedCategories.find(category => {
return category.id == categoryId
})
if (selectedCategory) {
const categoryName = selectedCategory.itemBody
if (removed) {
this.props.notifyScreenreader(
t('Answer removed from category {category_name}', {category_name: categoryName}),
)
} else {
this.props.notifyScreenreader(
t('Answer added to category {category_name}', {category_name: categoryName}),
)
}
}
this.props.handleResponseUpdate(this.includeUncategorized(newUserResponse))
if (focusNewCategory && this.categoryRefs[categoryId]) {
this.categoryRefs[categoryId].focus()
}
}
onDrop = (idNewCategory, answer, focusNewCategory) => {
const [answerId, takeId] = answer.id.split('_')
if (takeId === this.takeId || !takeId) {
this.onMenuItemSelected(answerId, idNewCategory, false, null, focusNewCategory)
}
}
onDropOut = answer => {
const [answerId, takeId] = answer.id.split('_')
const categ = this.props.userResponse.value.find(categAux => categAux.value.includes(answerId))
// remove the answer from it's current category
if (categ && (takeId === this.takeId || !takeId)) {
this.onMenuItemSelected(answerId, categ.id, true)
}
}
// ===========
// HELPERS
// ===========
filterDistractors = idCategory => {
const {interactionData, userResponse} = this.props
if (!interactionData.distractors || !userResponse.value) return []
return this.includeUncategorized(userResponse.value)
.filter(category => category.id === idCategory || category.id === UNCATEGORIZED)
.reduce(
(distractors, category) => [
...distractors,
...category.value.map(id => interactionData.distractors[id]),
],
[],
)
}
distractorExists(answerId) {
const distractorIds = Object.keys(this.props.interactionData.distractors)
return distractorIds.includes(answerId)
}
focusGroupRef = node => {
this.focusGroup = node
}
handleCategoryRef = categoryId => node => {
this.categoryRefs[categoryId] = node
}
// ===========
// RENDER
// ===========
renderPopover = itemId => (
<Menu
trigger={
<IconButton
size="small"
withBackground={false}
withBorder={false}
as="span"
renderIcon={IconDragHandleLine}
screenReaderLabel={t('Select Category for {distractorBody}', {
distractorBody: this.getDistractorBody(itemId),
})}
/>
}
>
{this.state.sortedCategories.map(categ => (
<Menu.Item key={categ.id} onSelect={() => this.onDrop(categ.id, {id: itemId}, true)}>
{categ.itemBody}
</Menu.Item>
))}
</Menu>
)
render() {
return (
<ItemBodyWrapper itemBody={this.props.itemBody}>
<FocusGroup ref={this.focusGroupRef}>
<CategoriesContainer
takeId={this.takeId}
sortedCategories={this.state.sortedCategories}
distractors={this.props.interactionData.distractors}
filterDistractors={this.filterDistractors}
handleCategoryRef={this.handleCategoryRef}
isDraggable
onDrop={this.onDrop}
onDropOut={this.onDropOut}
onMenuItemSelected={this.onMenuItemSelected}
userResponseValue={this.props.userResponse.value}
/>
<ChoicesList
takeId={this.takeId}
actionsContent={this.renderPopover}
distractors={this.filterDistractors()}
onDrop={this.onDropOut}
isDraggable
/>
</FocusGroup>
</ItemBodyWrapper>
)
}
}