UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

569 lines (512 loc) • 18.2 kB
/** @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