UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

326 lines (280 loc) • 9.56 kB
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> ) } }