@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
319 lines (289 loc) • 10.8 kB
JavaScript
/** @jsx jsx */
import React, {useEffect, useImperativeHandle, useLayoutEffect, useRef} from 'react'
import PropTypes from 'prop-types'
import {jsx} from '@instructure/emotion'
import {Flex} from '@instructure/quiz-common/components/Flex/index'
import {withStyleOverrides} from '@instructure/quiz-common/util/withStyleOverrides'
import {FormFieldGroup} from '@instructure/quiz-common/components/FormFieldGroup/index'
import {Text} from '@instructure/ui-text'
import ChoiceInput from '../../../common/edit/components/ChoiceInput'
import AnswerInput from '../../../common/edit/components/AnswerInput'
import RemoveChoiceButton from '../../../common/edit/components/RemoveChoiceButton'
import Footer from '../../../common/edit/components/Footer'
import generateStyle from './styles'
import generateComponentTheme from './theme'
import t from '@instructure/quiz-i18n/format-message'
import {normalizeErrors} from '../../../../util/normalizeErrors'
const BaseCategoryForm = React.forwardRef((props, ref) => {
const {
categoriesErrors,
getCategoryErrors,
categoryScoringData,
disabled,
distractors,
distractorErrors,
notifyScreenreader,
onCategoryInputChange,
onCreateAnswer,
onCreateCategory,
onDistractorInputChange,
onModalClose,
onModalOpen,
onRemoveAnswer,
onRemoveCategory,
sortedCategories,
styles,
} = props
const answerRefsRef = useRef({})
const categoryRefsRef = useRef({})
const categoryAnswerInputRefsRef = useRef({})
const answerRemoveHandlersRef = useRef({})
const categoryRemoveHandlersRef = useRef({})
const createAnswerHandlersRef = useRef({})
const isMountedRef = useRef(false)
const footerRef = useRef(null)
const prevPropsRef = useRef({
onCreateAnswer,
onRemoveAnswer,
onRemoveCategory,
})
useEffect(() => {
// focusOnMount is conditional on this component being mounted so that focus
// is not stolen on initial render when creating a new categorization question
isMountedRef.current = true
}, [])
useLayoutEffect(() => {
if (prevPropsRef.current.onCreateAnswer !== onCreateAnswer) {
createAnswerHandlersRef.current = {}
}
if (prevPropsRef.current.onRemoveAnswer !== onRemoveAnswer) {
answerRemoveHandlersRef.current = {}
}
if (prevPropsRef.current.onRemoveCategory !== onRemoveCategory) {
categoryRemoveHandlersRef.current = {}
}
prevPropsRef.current = {
onCreateAnswer,
onRemoveAnswer,
onRemoveCategory,
}
}, [onCreateAnswer, onRemoveAnswer, onRemoveCategory])
// Expose refs to parent component
useImperativeHandle(ref, () => ({
footerRef: footerRef.current,
categoryRefs: categoryRefsRef.current,
categoryAnswerInputRefs: categoryAnswerInputRefsRef.current,
answerRefs: answerRefsRef.current,
}))
const getCreateAnswerHandler = categoryId => {
let createAnswerHandler = createAnswerHandlersRef.current[categoryId]
if (createAnswerHandler) return createAnswerHandler
createAnswerHandler = onCreateAnswer.bind(null, categoryId)
createAnswerHandlersRef.current[categoryId] = createAnswerHandler
return createAnswerHandler
}
const getAnswerRemoveHandler = (answerId, categoryId) => {
let answerRemoveHandler = answerRemoveHandlersRef.current[categoryId]
if (answerRemoveHandler) return answerRemoveHandler
answerRemoveHandler = onRemoveAnswer.bind(null, answerId, categoryId)
answerRemoveHandlersRef.current[answerId] = answerRemoveHandler
return answerRemoveHandler
}
const getCategoryRemoveHandler = categoryId => {
let categoryRemoveHandler = categoryRemoveHandlersRef.current[categoryId]
if (categoryRemoveHandler) return categoryRemoveHandler
categoryRemoveHandler = onRemoveCategory.bind(null, categoryId)
categoryRemoveHandlersRef.current[categoryId] = categoryRemoveHandler
return categoryRemoveHandler
}
const handleFooterRef = node => {
footerRef.current = node
}
// ===========
// RENDER
// ===========
const renderAnswer = (answer, categoryId, focusOnMount, index) => {
const onRemoveAnswer = getAnswerRemoveHandler(answer.id, categoryId)
return (
<ChoiceInput
key={`answerInput${answer.id}`}
ref={node => {
answerRefsRef.current[answer.id] = node
}}
disabledFields={disabled ? ['answerInput'] : []}
id={answer.id}
itemBody={answer.itemBody}
errors={distractorErrors(answer.id)}
onInputChange={onDistractorInputChange}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
onRemoveChoice={onRemoveAnswer}
shouldRenderRemoveChoice={!disabled}
focusOnMount={focusOnMount && isMountedRef.current}
noRCE
notifyScreenreader={notifyScreenreader}
isRequired={true}
automationData={`sdk-category-${index + 1}-answer`}
/>
)
}
const renderRemoveButton = (category, index) => {
const onRemoveCategory = getCategoryRemoveHandler(category.id)
const singleCategory = sortedCategories.length === 1
if (!singleCategory && !disabled) {
return (
<RemoveChoiceButton
choiceId={category.id}
screenReaderText={t('Remove Category {index}: {category}', {
index: index,
category: category.itemBody,
})}
onRemoveChoice={onRemoveCategory}
automationData={`sdk-remove-category-${category.id}`}
/>
)
}
}
const renderCategory = (category, index, focusOnMount) => {
const onCreateAnswer = getCreateAnswerHandler(category.id)
const categoryAnswerInputRefs = node => {
categoryAnswerInputRefsRef.current[category.id] = node
}
const categoryRef = node => {
categoryRefsRef.current[category.id] = node
}
return (
<div key={category.id} className="categoryContainer" css={styles.categoryContainer}>
<div css={styles.categoryContainerWrapper}>
<div css={styles.itemContainer}>
<div css={styles.itemContainerBody}>
<Text color="primary">{t('Category {position, number}', {position: index + 1})}</Text>
</div>
{renderRemoveButton(category, index + 1)}
</div>
<div css={styles.categoryBody}>
<div css={styles.categoryBodyDescriptionInput}>
<AnswerInput
ref={categoryAnswerInputRefs}
disabled={disabled}
onChangeHandler={onCategoryInputChange}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
id={category.id}
itemBody={category.itemBody}
errors={getCategoryErrors(category.id)}
renderLabel={t('Description')}
screenReaderText={t('Category Description')}
focusOnMount={focusOnMount && isMountedRef.current}
noRCE
automationData={`sdk-category-${index + 1}-description`}
isRequired={true}
/>
</div>
<Flex padding="x-small" direction="column" gap="small">
{categoryScoringData(category.id).map((answerId, answerIndex, source) => {
const answer = distractors[answerId]
const answerFocusOnMount = answerIndex + 1 === source.length && !answer.itemBody
return (
<Flex.Item key={`answerInput${answer.id}`}>
{renderAnswer(answer, category.id, answerFocusOnMount, answerIndex)}
</Flex.Item>
)
})}
</Flex>
<div css={styles.categoryBodyAnswers}></div>
{!disabled && (
<Footer
onCreateChoice={onCreateAnswer}
ref={categoryRef}
screenReaderText={t('Add answer to category {position, number}', {
position: index + 1,
})}
notifyScreenreader={notifyScreenreader}
automationData={`sdk-add-answer-to-category-${index + 1}-button`}
/>
)}
</div>
</div>
</div>
)
}
return (
<div css={styles.categoriesWrapper}>
<FormFieldGroup description={t('Categories')} messages={normalizeErrors(categoriesErrors)}>
<div css={styles.categoriesContainer}>
{sortedCategories.map((category, index, source) => {
const focusOnMount = index + 1 === source.length && !category.itemBody
return renderCategory(category, index, focusOnMount)
})}
</div>
</FormFieldGroup>
{!disabled && (
<Footer
ref={handleFooterRef}
onCreateChoice={onCreateCategory}
buttonText={t('Category')}
screenReaderText={t('Add Category')}
notifyScreenreader={notifyScreenreader}
automationData="sdk-add-category-button"
/>
)}
</div>
)
})
BaseCategoryForm.displayName = 'CategoryForm'
BaseCategoryForm.componentId = 'QuizzesCategoryForm'
BaseCategoryForm.propTypes = {
categoriesErrors: PropTypes.arrayOf(
PropTypes.shape({
text: PropTypes.string,
type: PropTypes.string,
}),
),
getCategoryErrors: PropTypes.func,
categoryScoringData: PropTypes.func,
disabled: PropTypes.bool,
distractors: PropTypes.objectOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
itemBody: PropTypes.string.isRequired,
}),
).isRequired,
distractorErrors: PropTypes.func,
notifyScreenreader: PropTypes.func.isRequired,
onCategoryInputChange: PropTypes.func,
onCreateAnswer: PropTypes.func,
onCreateCategory: PropTypes.func,
onDistractorInputChange: PropTypes.func,
onModalClose: PropTypes.func,
onModalOpen: PropTypes.func,
onRemoveAnswer: PropTypes.func,
onRemoveCategory: PropTypes.func,
sortedCategories: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
itemBody: PropTypes.string,
}),
).isRequired,
styles: PropTypes.object,
}
BaseCategoryForm.defaultProps = {
categoriesErrors: [],
getCategoryErrors: void 0,
categoryScoringData: void 0,
disabled: void 0,
distractorErrors: void 0,
onCategoryInputChange: void 0,
onCreateAnswer: void 0,
onCreateCategory: void 0,
onDistractorInputChange: void 0,
onModalClose: void 0,
onModalOpen: void 0,
onRemoveAnswer: void 0,
onRemoveCategory: void 0,
}
const CategoryForm = withStyleOverrides(generateStyle, generateComponentTheme)(BaseCategoryForm)
CategoryForm.displayName = 'CategoryForm'
CategoryForm.componentId = 'QuizzesCategoryForm'
export {BaseCategoryForm}
export default CategoryForm