@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
569 lines (512 loc) • 18.2 kB
JavaScript
/** @jsx jsx */
import React, {useMemo, useRef, useState} from 'react'
import PropTypes from 'prop-types'
import find from 'lodash/fp/find'
import flatMap from 'lodash/fp/flatMap'
import flow from 'lodash/fp/flow'
import filter from 'lodash/fp/filter'
import sortBy from 'lodash/fp/sortBy'
import set from 'lodash/fp/set'
import {v4 as uuid} from 'uuid'
import striptags from 'striptags'
import {Text} from '@instructure/ui-text'
import {jsx} from '@instructure/emotion'
import {RadioInputGroup, RadioInput} from '@instructure/ui-radio-input'
import {IconButton} from '@instructure/ui-buttons'
import {IconQuestionLine} from '@instructure/ui-icons'
import {View} from '@instructure/ui-view'
import {SimpleModal, FormFieldGroup, Flex} from '@instructure/quiz-common'
import CategorizationInteractionType from '../../../records/interactions/categorization'
import ChoiceInput from '../../common/edit/components/ChoiceInput'
import Footer from '../../common/edit/components/Footer'
import QuestionSettingsContainer from '../../common/edit/components/QuestionSettingsContainer'
import QuestionContainer from '../../common/edit/components/QuestionContainer'
import {getNextItemIdFromArray} from '../../../util/focusHelpers'
import {
CategorizationPresenterProvider,
useCategorizationPresenter,
} from './CategorizationPresenter'
import CategoryForm from './CategoryForm'
import {getSortedCategories} from '../common/utils'
import withEditTools from '../../../util/withEditTools'
import t from '@instructure/quiz-i18n/format-message'
import QuestionSettingsPanel from '../../common/edit/components/QuestionSettingsPanel'
import CalculatorOptionWithOqaatAlert from '../../common/edit/components/CalculatorOptionWithOqaatAlert'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
/**
---
category: Categorization
---
Categorization Edit 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'],
categories: {
uuid1: { id: 'uuid1', itemBody: 'Planet' },
uuid2: { id: 'uuid2', itemBody: 'Moon' }
},
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' }
}
},
scoringData: {
value: [{
id: 'uuid1',
scoringAlgorithm: 'AllOrNothing',
scoringData: {
value: ['uuid3', 'uuid5', 'uuid8']
}
}, {
id: 'uuid2',
scoringAlgorithm: 'AllOrNothing',
scoringData: {
value: ['uuid4', 'uuid6', 'uuid7']
}
}]
}
}
return (
<CategorizationEdit {...exampleProps} {...props} />
)
}
<SettingsSwitcher locales={LOCALES}>
<EditStateProvider>
<Example />
</EditStateProvider>
</SettingsSwitcher>
```
**/
const BaseCategorizationEdit = React.forwardRef((props, ref) => {
const {
additionalOptions,
calculatorType,
changeItemState,
enableRichContentEditor,
getErrors,
interactionData,
itemBody,
newId,
notifyScreenreader,
onModalClose,
onModalOpen,
oneQuestionAtATime,
openImportModal,
overrideEditableForRegrading,
scoringData,
setOneQuestionAtATime,
showCalculatorOption,
} = props
const {categories, categoryOrder} = interactionData
const [isModalOpen, setModalOpen] = useState(false)
const sortedCategories = useMemo(
() => getSortedCategories(categories, categoryOrder),
[categories, categoryOrder],
)
const [distractorOrder, setDistractorOrder] = useState(() =>
sortBy('itemBody', interactionData.distractors).map(({id}) => id),
)
const {
onCategoryInputChange,
onCreateAnswer,
onCreateCategory,
onDistractorInputChange,
onRemoveAnswer,
onRemoveCategory,
onRemoveDistractor,
} = useCategorizationPresenter()
const distractorRefsRef = useRef({})
const categoryRefRef = useRef(null)
const stemElementRef = useRef(null)
// ===========
// HELPERS
// ===========
const getViewDistractors = () => {
const answerIds = flatMap(({scoringData: {value}}) => value, scoringData.value)
return flow(
Object.values,
filter(({id}) => !answerIds.includes(id)),
sortBy(({id}) => distractorOrder.indexOf(id)),
)(interactionData.distractors)
}
const getCategoryErrors = categoryId => {
return getErrors(`interactionData.categories.${categoryId}.itemBody`)
}
const getDistractorErrors = distractorId => {
return getErrors(`interactionData.distractors.${distractorId}.itemBody`)
}
const getAnswerIds = categoryId => {
const categoryScoringData = find({id: categoryId}, scoringData.value)
// since kinesis doesnt save empty array
return categoryScoringData?.scoringData?.value || []
}
// ===========
// HANDLERS
// ===========
const handleScoringAlgoChange = (e, value) => {
// Update scoreMethod in scoringData, not scoringAlgorithm at item level
const newScoringData = {
...props.scoringData,
scoreMethod: value,
}
props.changeItemState({scoringData: newScoringData})
}
const handleOpenModal = () => {
setModalOpen(true)
}
const handleCloseModal = () => {
setModalOpen(false)
}
const handleCalculatorTypeChange = (e, value) => {
changeItemState({
calculatorType: value,
})
}
const handleRemoveDistractor = distractorId => {
const nextDistractorId = getNextItemIdFromArray(distractorId, getViewDistractors())
const nextDistractorRef = distractorRefsRef.current[nextDistractorId]
if (nextDistractorRef) {
nextDistractorRef.removeChoicebutton.focus()
} else {
// focus on add category button
categoryRefRef.current.footerRef.focus()
}
onRemoveDistractor(distractorId)
}
const handleRemoveCategory = categoryId => {
const removingFirstCategory = categoryOrder && categoryOrder.indexOf(categoryId) === 0
if (removingFirstCategory) {
stemElementRef.current.focus()
} else {
const nextCategoryId = getNextItemIdFromArray(categoryId, sortedCategories)
const nextAddAnswerRef = categoryRefRef.current.categoryRefs[nextCategoryId]
if (nextAddAnswerRef) {
// will focus the previous category's "+Answer" button
nextAddAnswerRef.focus()
}
}
onRemoveCategory(categoryId)
}
const handleRemoveAnswer = (answerId, categoryId) => {
const answerIds = getAnswerIds(categoryId)
const removingFirstAnswer = answerIds.indexOf(answerId) === 0
if (removingFirstAnswer) {
categoryRefRef.current.categoryAnswerInputRefs[categoryId].answerInput.focus()
} else {
const nextAnswerId = getNextItemIdFromArray(answerId, answerIds)
const nextAnswerRef = categoryRefRef.current.answerRefs[nextAnswerId]
if (nextAnswerRef) {
nextAnswerRef.removeChoicebutton.focus()
}
}
onRemoveAnswer(answerId, categoryId)
}
const handleCreateDistractor = () => {
const id = newId()
const newDistractor = {id, itemBody: ''}
setDistractorOrder(prevDistractorOrder => prevDistractorOrder.concat([id]))
changeItemState({
interactionData: set(`distractors[${id}]`, newDistractor, interactionData),
})
}
const handleStemRef = node => {
stemElementRef.current = node
}
const handleCategoryRef = node => {
categoryRefRef.current = node
}
const handleDescriptionChange = itemBody => {
changeItemState({itemBody})
}
// ===========
// RENDER
// ===========
function renderGradingOptions() {
const ALL_OR_NOTHING = 'all_or_nothing'
const PARTIAL_CREDIT = 'partial_credit'
// Read scoreMethod from scoringData
const currentScoreMethod = props.scoringData.scoreMethod || ALL_OR_NOTHING
return (
<View as="div" margin="medium 0" position="relative">
<div style={{marginBottom: '0.5rem'}}>
<Text>{t('Grading')}</Text>
<IconButton
size="small"
withBackground={false}
withBorder={false}
renderIcon={IconQuestionLine}
onClick={handleOpenModal}
screenReaderLabel={t('Open grading option information')}
/>
</div>
<RadioInputGroup
onChange={handleScoringAlgoChange}
value={currentScoreMethod}
name={t('Grading')}
description={<ScreenReaderContent>{t('Grading')}</ScreenReaderContent>}
>
<RadioInput
value={ALL_OR_NOTHING}
label={t('Exact match')}
data-automation="sdk-grading-exact-match-radio-input"
/>
<RadioInput
value={PARTIAL_CREDIT}
label={t('Partial credit')}
data-automation="sdk-grading-partial-credit-radio-input"
/>
</RadioInputGroup>
</View>
)
}
function renderGradingOptionsModal() {
return (
<SimpleModal
size="small"
title={t('Grading')}
label={t('Grading')}
isModalOpen={isModalOpen}
onModalDismiss={handleCloseModal}
>
<Text weight="bold" lineHeight="double" as="div">
{t('Exact match')}
</Text>
<Text as="div">
{t(
'Students are awarded full credit if all correct items are categorized correctly and all distractors are left uncategorized.',
)}
</Text>
<br />
<Text weight="bold" lineHeight="double" as="div">
{t('Partial credit')}
</Text>
<Text>
{t(
'Students earn points for each correct decision: placing correct items in the right category AND leaving distractors uncategorized. Each decision is worth an equal portion of the total points.',
)}
</Text>
</SimpleModal>
)
}
const renderDistractor = (distractor, focusOnMount) => {
return (
<ChoiceInput
key={distractor.id}
id={distractor.id}
disabledFields={overrideEditableForRegrading ? ['answerInput'] : []}
itemBody={distractor.itemBody}
errors={getDistractorErrors(distractor.id)}
onInputChange={onDistractorInputChange}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
onRemoveChoice={() => handleRemoveDistractor(distractor.id)}
ref={node => {
distractorRefsRef.current[distractor.id] = node
}}
renderLabel={t('Distractor')}
isRequired={true}
focusOnMount={focusOnMount}
shouldRenderRemoveChoice={!overrideEditableForRegrading}
noRCE
notifyScreenreader={notifyScreenreader}
screenReaderText={t('Additional Distractor {itemBody}', {
itemBody: striptags(distractor.itemBody),
})}
/>
)
}
const renderDistractorSection = () => {
const distractors = getViewDistractors()
return (
<Flex direction="column" gap="small" className="distractorsContainer" padding="x-small 0 0 0">
<Text color="primary">{t('Additional Distractors')}</Text>
<Flex direction="column" gap="small">
{distractors.map((distractor, i, source) => {
const focusOnMount = i + 1 === source.length && !distractor.itemBody
return renderDistractor(distractor, focusOnMount)
})}
</Flex>
{!overrideEditableForRegrading && (
<Flex.Item overflowY="visible">
<Footer
onCreateChoice={handleCreateDistractor}
buttonText={t('Distractor')}
screenReaderText={t('Add Distractor')}
notifyScreenreader={notifyScreenreader}
automationData="add-categorization-distractor-button"
/>
</Flex.Item>
)}
</Flex>
)
}
const renderOptionsDescription = () => {
return <ScreenReaderContent>{t('Categorization options')}</ScreenReaderContent>
}
distractorRefsRef.current = {}
return (
<div>
<QuestionContainer
disabled={overrideEditableForRegrading}
enableRichContentEditor={enableRichContentEditor}
itemBody={itemBody}
onDescriptionChange={handleDescriptionChange}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
openImportModal={openImportModal}
stemErrors={getErrors('itemBody')}
textareaRef={handleStemRef}
>
<CategoryForm
categoriesErrors={getErrors('scoringData.value.$errors')}
// These error callbacks are passed in as new functions, to force
// the component to re-render when errors change. Not ideal - we
// should be passing the errors in directly
getCategoryErrors={categoryId => getCategoryErrors(categoryId)}
distractorErrors={distractorId => getDistractorErrors(distractorId)}
categoryScoringData={getAnswerIds}
distractors={interactionData.distractors}
disabled={overrideEditableForRegrading}
sortedCategories={sortedCategories}
notifyScreenreader={notifyScreenreader}
onCategoryInputChange={onCategoryInputChange}
onCreateAnswer={onCreateAnswer}
onCreateCategory={onCreateCategory}
onDistractorInputChange={onDistractorInputChange}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
onRemoveAnswer={handleRemoveAnswer}
onRemoveCategory={handleRemoveCategory}
ref={handleCategoryRef}
/>
{renderDistractorSection()}
</QuestionContainer>
<QuestionSettingsContainer additionalOptions={additionalOptions}>
{showCalculatorOption && (
<QuestionSettingsPanel label={t('Options')} defaultExpanded>
<FormFieldGroup rowSpacing="small" description={renderOptionsDescription()}>
<CalculatorOptionWithOqaatAlert
disabled={overrideEditableForRegrading}
calculatorValue={calculatorType}
onCalculatorTypeChange={handleCalculatorTypeChange}
oqaatChecked={oneQuestionAtATime}
onOqaatChange={setOneQuestionAtATime}
/>
{props.partialDeepScoringEnabled && renderGradingOptions()}
</FormFieldGroup>
</QuestionSettingsPanel>
)}
</QuestionSettingsContainer>
{renderGradingOptionsModal()}
</div>
)
})
BaseCategorizationEdit.displayName = 'CategorizationEdit'
BaseCategorizationEdit.componentId = 'QuizzesCategorizationEdit'
BaseCategorizationEdit.interactionType = CategorizationInteractionType
BaseCategorizationEdit.propTypes = {
additionalOptions: QuestionSettingsContainer.propTypes.additionalOptions,
calculatorType: PropTypes.string,
changeItemState: PropTypes.func,
enableRichContentEditor: PropTypes.bool,
partialDeepScoringEnabled: PropTypes.bool,
scoringAlgorithm: PropTypes.string,
errors: PropTypes.shape({
itemBody: PropTypes.arrayOf(PropTypes.string),
interactionData: PropTypes.shape({
distractors: PropTypes.objectOf(
PropTypes.shape({
itemBody: PropTypes.arrayOf(PropTypes.string),
}),
),
categories: PropTypes.objectOf(
PropTypes.shape({
itemBody: PropTypes.arrayOf(PropTypes.string),
}),
),
}),
scoringData: PropTypes.shape({
value: PropTypes.shape({
$errors: PropTypes.arrayOf(PropTypes.string),
}),
}),
}),
interactionData: PropTypes.shape({
categoryOrder: PropTypes.arrayOf(PropTypes.string),
categories: PropTypes.objectOf(
PropTypes.shape({
id: PropTypes.string,
itemBody: PropTypes.string,
}),
),
distractors: PropTypes.objectOf(
PropTypes.shape({
id: PropTypes.string,
itemBody: PropTypes.string,
}),
),
}).isRequired,
itemBody: PropTypes.string,
notifyScreenreader: PropTypes.func.isRequired,
onModalClose: PropTypes.func,
onModalOpen: PropTypes.func,
oneQuestionAtATime: PropTypes.bool,
openImportModal: PropTypes.func.isRequired,
overrideEditableForRegrading: PropTypes.bool,
scoringData: PropTypes.shape({
value: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string,
}),
),
}).isRequired,
setOneQuestionAtATime: PropTypes.func,
newId: PropTypes.func,
getErrors: PropTypes.func,
styles: PropTypes.object,
showCalculatorOption: PropTypes.bool,
}
BaseCategorizationEdit.defaultProps = {
calculatorType: 'none',
partialDeepScoringEnabled: false,
scoringAlgorithm: void 0,
enableRichContentEditor: true,
oneQuestionAtATime: false,
overrideEditableForRegrading: false,
newId: uuid,
setOneQuestionAtATime: Function.prototype,
additionalOptions: void 0,
changeItemState: void 0,
errors: void 0,
itemBody: void 0,
onModalClose: void 0,
onModalOpen: void 0,
showCalculatorOption: true,
}
const CategorizationEditWithEditTools = withEditTools(BaseCategorizationEdit)
const CategorizationEdit = React.forwardRef((props, ref) => {
const {interactionData, scoringData, changeItemState, newId = uuid} = props
return (
<CategorizationPresenterProvider
interactionData={interactionData}
scoringData={scoringData}
changeItemState={changeItemState}
newId={newId}
>
<CategorizationEditWithEditTools {...props} ref={ref} />
</CategorizationPresenterProvider>
)
})
CategorizationEdit.displayName = 'CategorizationEdit'
CategorizationEdit.componentId = 'QuizzesCategorizationEdit'
CategorizationEdit.interactionType = CategorizationInteractionType
export default CategorizationEdit