UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

319 lines (289 loc) • 10.8 kB
/** @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