UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

754 lines (693 loc) • 23.4 kB
/** @jsx jsx */ import React, {Component} from 'react' import PropTypes from 'prop-types' import update from 'immutability-helper' import pick from 'lodash/fp/pick' import find from 'lodash/fp/find' import {Text} from '@instructure/ui-text' import {Alert} from '@instructure/ui-alerts' import {Button, CondensedButton, IconButton} from '@instructure/ui-buttons' import { IconBoxLine, IconEmptyLine, IconInfoLine, IconMarkerLine, IconQuestionLine, } from '@instructure/ui-icons' import {View} from '@instructure/ui-view' import {jsx} from '@instructure/emotion' import {SimpleModal} from '@instructure/quiz-common/components/SimpleModal/index' import {withStyleOverrides} from '@instructure/quiz-common/util/withStyleOverrides' import {FormFieldGroup} from '@instructure/quiz-common/components/FormFieldGroup/index' import HotSpotInteractionType from '../../../records/interactions/hotspot' import QuestionSettingsContainer from '../../common/edit/components/QuestionSettingsContainer' import QuestionContainer from '../../common/edit/components/QuestionContainer' import RemoveChoiceButton from '../../common/edit/components/RemoveChoiceButton' import DrawingContainer from './DrawingContainer' import Oval from '../common/Oval' import Polygon from '../common/Polygon' import Square from '../common/Square' import generateStyle from './styles' import generateComponentTheme from './theme' 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' import {RadioInputGroup, RadioInput} from '@instructure/ui-radio-input' import castArray from 'lodash/castArray' export function drawTypes() { return [ { type: 'square', title: t('Square'), icon: IconBoxLine, component: Square, }, { type: 'oval', title: t('Oval'), icon: IconEmptyLine, component: Oval, }, { type: 'polygon', title: t('Polygon'), icon: IconMarkerLine, component: Polygon, }, ] } const ALL_OR_NOTHING = 'AllOrNothing' /** --- category: HotSpot --- HotSpot Edit component ```jsx_example function Example (props) { const exampleProps = { itemBody: 'Which of these players is David Ferrer', interactionData: { imageUrl: 'https://i.ytimg.com/vi/LgkAebhJ7iE/maxresdefault.jpg' }, scoringData: { value: { type: 'oval', coordinates: [ { x: 0.6, y: 0.13 }, { x: 0.7, y: 0.2 } ] } } } return ( <HotSpotEdit {...exampleProps} {...props} /> ) } <SettingsSwitcher locales={LOCALES}> <EditStateProvider> <Example /> </EditStateProvider> </SettingsSwitcher> ``` **/ @withEditTools @withStyleOverrides(generateStyle, generateComponentTheme) export default class HotSpotEdit extends Component { static displayName = 'HotSpotEdit' static componentId = `Quizzes${this.displayName}` static interactionType = HotSpotInteractionType static propTypes = { additionalOptions: PropTypes.array, calculatorType: PropTypes.string, changeItemState: PropTypes.func.isRequired, enableRichContentEditor: PropTypes.bool, errors: PropTypes.shape({ itemBody: PropTypes.arrayOf(PropTypes.string), interactionData: PropTypes.shape({ imageUrl: PropTypes.arrayOf(PropTypes.string), }), scoringData: PropTypes.shape({ value: PropTypes.arrayOf( PropTypes.shape({ type: PropTypes.arrayOf(PropTypes.string), coordinates: PropTypes.arrayOf(PropTypes.string), }), ), }), }), interactionData: PropTypes.shape({ imageUrl: PropTypes.string, hotspotsCount: PropTypes.number, }).isRequired, itemBody: PropTypes.string, mediaUpload: PropTypes.func.isRequired, onModalClose: PropTypes.func, onModalOpen: PropTypes.func, oneQuestionAtATime: PropTypes.bool, openImportModal: PropTypes.func, overrideEditableForRegrading: PropTypes.bool, scoringData: PropTypes.shape({ value: PropTypes.arrayOf( PropTypes.shape({ type: PropTypes.oneOf(['square', 'oval', 'polygon']), coordinates: PropTypes.arrayOf( PropTypes.shape({ x: PropTypes.number.isRequired, y: PropTypes.number.isRequired, }), ), }), ).isRequired, }).isRequired, setOneQuestionAtATime: PropTypes.func, ...withEditTools.injectedProps, styles: PropTypes.object, multipleHotSpotEnabled: PropTypes.bool, showCalculatorOption: PropTypes.bool, } static defaultProps = { calculatorType: 'none', enableRichContentEditor: true, oneQuestionAtATime: false, overrideEditableForRegrading: false, setOneQuestionAtATime: Function.prototype, additionalOptions: void 0, errors: void 0, itemBody: void 0, onModalClose: void 0, onModalOpen: void 0, openImportModal: void 0, multipleHotSpotEnabled: false, showCalculatorOption: true, } drawingContainer = null state = { file: null, isShortcutsModalOpen: false, currentId: null, currentShapeType: 'square', tempHotspot: null, isModalOpen: false, canvasRef: null, } componentDidMount() { const {interactionData, multipleHotSpotEnabled, scoringData} = this.props if (interactionData?.imageUrl) { if (multipleHotSpotEnabled) { this.handleTypeChange(this.state.currentShapeType) } else { this.setState({ currentShapeType: scoringData?.value?.type || 'square', }) } } } componentDidUpdate() { const {multipleHotSpotEnabled} = this.props if (multipleHotSpotEnabled) { this.ensureScoringDataIsArray() } else { this.handleSingleHotSpotUpdate() } } ensureScoringDataIsArray() { const {scoringData} = this.props if (scoringData?.value && !Array.isArray(scoringData.value)) { const normalizedValue = [{...scoringData.value, id: 1}] this.updateProps({ scoringData: { value: {$set: normalizedValue}, }, }) } } handleSingleHotSpotUpdate() { const {scoringData} = this.props if (scoringData?.value?.length === 1) { const [hotSpot] = scoringData.value this.handleTypeChange(hotSpot.type, hotSpot.coordinates) } else if (scoringData?.value?.length > 1) { this.handleDrawingRemove() } } handleCanvasRef = ref => { this.setState({canvasRef: ref}) } // =========== // HELPERS // =========== updateProps = newProps => { const propsToUpdate = pick(['interactionData', 'scoringData', 'properties'], this.props) this.props.changeItemState(update(propsToUpdate, newProps)) } getConvertedCoordinates = coordinatesArray => { return coordinatesArray.map(({x, y}) => ({ x: x / this.drawingContainer.currentImageWidth(), y: y / this.drawingContainer.currentImageHeight(), })) } addNewHotspot = convertedCoordinates => { this.updateProps({ interactionData: { hotspotsCount: {$set: this.props.scoringData.value.length + 1}, }, scoringData: { value: { $push: [{...this.state.tempHotspot, coordinates: convertedCoordinates}], }, }, }) this.setState({ tempHotspot: null, currentId: this.state.tempHotspot.id, }) } updateExistingHotspot = (lastHotspotIndex, convertedCoordinates) => { this.updateProps({ scoringData: { value: { [lastHotspotIndex]: { coordinates: {$set: convertedCoordinates}, }, }, }, }) } finalizeHotspot = (lastHotspotIndex, convertedCoordinates) => { this.updateProps({ interactionData: { hotspotsCount: {$set: this.props.scoringData.value.length}, }, scoringData: { value: { [lastHotspotIndex]: { coordinates: {$set: convertedCoordinates}, }, }, }, }) } prepareNewTempHotspot = () => { const newId = this.props.scoringData.value.length + 1 this.setState({ tempHotspot: {type: this.state.currentShapeType, coordinates: [], id: newId}, }) } convertCoordinates = (coordinatesArray, isDrawing, isKeyboard = false) => { const convertedCoordinates = this.getConvertedCoordinates(coordinatesArray) if (!this.props.multipleHotSpotEnabled) { this.updateProps({ scoringData: { value: { coordinates: {$set: convertedCoordinates}, }, }, }) return } const {value} = this.props.scoringData const hotspots = castArray(value) const lastHotspotIndex = hotspots?.findIndex(hotspot => hotspot?.id === this.state.currentId) if (isDrawing) { if (this.state.tempHotspot && !isKeyboard) { this.addNewHotspot(convertedCoordinates) } else { this.updateExistingHotspot(lastHotspotIndex, convertedCoordinates) } } else { this.finalizeHotspot(lastHotspotIndex, convertedCoordinates) this.prepareNewTempHotspot() } } // =========== // HANDLERS // =========== handleCalculatorTypeChange = (e, value) => { this.props.changeItemState({ calculatorType: value, }) } handleTypeChange = (newType, coordinates = null) => { if (!this.props.multipleHotSpotEnabled) { this.updateProps({ scoringData: { value: { $set: {type: newType, coordinates: coordinates || []}, }, }, }) this.setState({currentShapeType: newType, tempHotspot: null}) return } const {value} = this.props.scoringData const hotspots = castArray(value) const lastHotspotIndex = hotspots?.findIndex(hotspot => hotspot?.id === this.state.currentId) const lastHotspot = hotspots[lastHotspotIndex] if (lastHotspot?.coordinates?.length > 0 || !this.state.currentId) { // Generate the next available sequential ID based on the current number of hotspots const newId = hotspots.length + 1 const tempHotspot = {type: newType, coordinates: [], id: newId} this.setState({currentShapeType: newType, tempHotspot: tempHotspot}) } else { this.updateProps({ scoringData: { value: { [lastHotspotIndex]: { type: {$set: newType}, // Only update the type of the existing hotspot }, }, }, }) this.setState({currentShapeType: newType, tempHotspot: null}) } } handleRemoveImage = () => { this.updateProps({ interactionData: {$set: {}}, scoringData: { value: {$set: []}, }, }) } handleUpload = url => { const hotSpot = {type: 'square', coordinates: [], id: 1} this.updateProps({ interactionData: { imageUrl: {$set: url}, hotspotsCount: {$set: 0}, }, scoringData: { value: { $set: !this.props.multipleHotSpotEnabled ? hotSpot : [hotSpot], }, }, }) this.setState({ tempHotspot: null, currentId: 1, file: null, currentShapeType: 'square', }) } openShortcutsModal = () => { this.setState({isShortcutsModalOpen: true}) } closeShortcutsModal = () => { this.setState({isShortcutsModalOpen: false}) } handleDropAccepted = ([file]) => { if (file instanceof Blob) { const reader = new FileReader() reader.onload = e => { this.setState({file: {url: e.target.result}}) } reader.readAsDataURL(file) } this.setState({file: {}}) this.props.mediaUpload(file, this.handleUpload) } handleDrawingRemove = hotspotId => { if (!this.props.multipleHotSpotEnabled) { this.drawingContainer.canvas.clearCanvas() this.drawingContainer.canvas.focus() this.handleTypeChange(this.props.scoringData?.value?.type || this.state.currentShapeType) return } const indexToRemove = this.props.scoringData.value.findIndex( hotspot => hotspot.id === hotspotId, ) if (indexToRemove !== -1 && this.props.scoringData.value.length > 1) { const updatedHotspots = update(this.props.scoringData.value, { $splice: [[indexToRemove, 1]], }) // Reassign IDs sequentially const reorderedHotspots = updatedHotspots.map((hotspot, idx) => ({ ...hotspot, id: idx + 1, // Reassign sequential ID })) this.updateProps({ interactionData: { hotspotsCount: {$set: reorderedHotspots.length}, }, scoringData: { value: {$set: reorderedHotspots}, }, }) const newCurrentId = reorderedHotspots.length > 0 ? reorderedHotspots[0].id : null this.setState( { currentId: newCurrentId, tempHotspot: { type: this.state.currentShapeType, coordinates: [], id: reorderedHotspots.length + 1, }, }, () => { this?.drawingContainer?.canvas?.focus() }, ) } else { this.updateProps({ interactionData: { hotspotsCount: {$set: 1}, }, scoringData: { value: { [indexToRemove]: { coordinates: {$set: []}, type: {$set: this.state.currentShapeType || 'square'}, }, }, }, }) this.setState({currentId: 1, tempHotspot: null}) } } handleDescriptionChange = itemBody => this.props.changeItemState({itemBody}) handleDrawingContainerRef = node => { this.drawingContainer = node } handleCloseModal = () => { this.setState({isModalOpen: false}) } handleOpenModal = () => { this.setState({isModalOpen: true}) } // =========== // RENDER // =========== renderFooter() { const {value} = this.props.scoringData const url = this.props.interactionData.imageUrl const hotspots = castArray(value) if (url && hotspots.length > 0) { return ( <React.Fragment> {hotspots.map(hotspot => { const {title} = find({type: hotspot.type}, drawTypes()) const mainText = t('{title} Hot Spot', {title}) if (hotspot.coordinates.length < 2) { return null } return ( <div key={hotspot.id} css={this.props.styles.footerContainer}> <div css={this.props.styles.footerContainerText}> <div> <Text weight="bold">{mainText}</Text> </div> <div> <Text color="primary"> {t('Clicks within the hot spot will be considered correct')} </Text> </div> </div> <RemoveChoiceButton onRemoveChoice={() => this.handleDrawingRemove(hotspot.id)} screenReaderText={t('Remove Drawing')} automationData="sdk-remove-hotspot-area-button" /> </div> ) })} </React.Fragment> ) } return null } renderOptionsDescription() { return <ScreenReaderContent>{t('Hotspot options')}</ScreenReaderContent> } renderShortcutsModal() { return ( <SimpleModal footerContent={ <Button onClick={this.closeShortcutsModal} color="primary"> {t('Close')} </Button> } isModalOpen={this.state.isShortcutsModalOpen} onModalDismiss={this.closeShortcutsModal} size="medium" title={t('Hotspot Controls & Shortcuts')} label={t('Hotspot Controls & Shortcuts')} > <div css={this.props.styles.modalContent}> <Text>{t('Hotspot Controls')}</Text> <ul> <li>{t('Use the Arrow keys to move the hotspot')}</li> <li>{t('Hold Shift and use the Arrow keys to resize the hotspot')}</li> <li>{t('Hold Shift + Alt and use the Arrow keys to shrink the hotspot')}</li> <li>{t('Press the Escape key to close the modal')}</li> </ul> <Text>{t('Shortcuts')}</Text> <ul> <li>{t('F: Expand Image')}</li> <li>{t('D: Remove Image')}</li> <li>{t('B: Browse for Image')}</li> <li>{t('I: Open Shortcuts Modal')}</li> <li>{t('C or ESC: Close Shortcuts Modal')}</li> <li>{t('R: Add Square Hotspot')}</li> <li>{t('O: Add Oval Hotspot')}</li> <li>{t('P: Draw Polygon Hotspot')}</li> <li>{t('E: Close Polygon Shape')}</li> <li>{t('Delete or Backspace: Remove Current Hotspot')}</li> <li>{t('Enter: Add Polygon Point')}</li> </ul> </div> </SimpleModal> ) } // these are added for a way to let the user know that the current supported grading options is exact match // as partial grading is to be added in the future renderGradingOptionsModal() { return ( <SimpleModal size="small" title={t('Grading')} label={t('Grading')} isModalOpen={this.state.isModalOpen} onModalDismiss={this.handleCloseModal} data-automation="sdk-hotspot-grading-options" > <Text weight="bold" lineHeight="double"> {t('Exact match')} </Text> <br /> <Text> {t( 'Students are awarded full credit if all correct answers are selected and no incorrect answers are selected.', )} </Text> </SimpleModal> ) } renderGradingOptions() { const {value} = this.props.scoringData const hotspots = castArray(value) const eligibleHotspots = hotspots.filter(hotspot => hotspot?.coordinates?.length > 1) if (eligibleHotspots.length < 2) return null return ( <View as="div" margin="medium 0" position="relative"> <RadioInputGroup onChange={() => {}} name={t('Grading')} value={ALL_OR_NOTHING} description={ <span> {t('Grading')} <IconButton size="small" withBackground={false} withBorder={false} renderIcon={IconQuestionLine} onClick={this.handleOpenModal} screenReaderLabel={t('Open grading option information')} /> </span> } > <RadioInput value={ALL_OR_NOTHING} label={t('Exact Match')} data-automation="sdk-hotspot-exact" /> </RadioInputGroup> </View> ) } render() { const hotspots = castArray(this.props.scoringData.value) const canvasErrors = this.props.multipleHotSpotEnabled ? 'scoringData.value[0].coordinates' : 'scoringData.value.coordinates' const typeErrors = this.props.multipleHotSpotEnabled ? 'scoringData.value[0].type' : 'scoringData.value.type' return ( <div> <div className="accessibilityInfo" css={this.props.styles.accessibilityInfo}> <Text color="secondary"> <IconInfoLine size="x-small" css={this.props.styles.accessibilityInfoIcon} /> {t('This question type is not accessible to users requiring screen readers.')} </Text> </div> <Alert variant="info"> <View display="inline" css={this.props.styles.alertInfoWrapper}> <Text id="hotspot-oqaat-info"> {t.rich( `Keyboard controls are available while using the hotspot. Press <1>i</1> whenever the <0>hotspot keyboard shortcuts</0> is needed. Just make sure that the hotspot image is focused to use these controls.`, [ ({children}) => ( <CondensedButton key="0" onClick={this.openShortcutsModal}> {children} </CondensedButton> ), ({children}) => <b key="1">{children}</b>, ], )} </Text> </View> </Alert> <QuestionContainer disabled={this.props.overrideEditableForRegrading} enableRichContentEditor={this.props.enableRichContentEditor} itemBody={this.props.itemBody} onDescriptionChange={this.handleDescriptionChange} onModalClose={this.props.onModalClose} onModalOpen={this.props.onModalOpen} openImportModal={this.props.openImportModal} stemErrors={this.props.getErrors('itemBody')} > <DrawingContainer ref={this.handleDrawingContainerRef} canvasErrors={this.props.getErrors(canvasErrors)} hotspots={hotspots} currentType={this.state.currentShapeType} drawTypes={drawTypes()} fileDropErrors={this.props.getErrors('interactionData.imageUrl')} onModalOpen={this.props.onModalOpen} onModalClose={this.props.onModalClose} onDropAccepted={this.handleDropAccepted} onSetType={this.handleTypeChange} convertCoordinates={this.convertCoordinates} onRemoveImage={this.handleRemoveImage} typeErrors={this.props.getErrors(typeErrors)} url={this.props.interactionData.imageUrl || (this.state.file && this.state.file.url)} isUploading={this.state.file !== null} onRemoveHotspot={this.handleDrawingRemove} onOpenShortcutsModal={this.openShortcutsModal} onCloseShortcutsModal={this.closeShortcutsModal} isShortcutsModalOpen={this.state.isShortcutsModalOpen} tempHotspot={this.state.tempHotspot} currentHotspotId={this.state.currentId} setCanvasRef={this.handleCanvasRef} canvasRef={this.state.canvasRef} multipleHotSpotEnabled={this.props.multipleHotSpotEnabled} /> {this.renderFooter()} </QuestionContainer> <QuestionSettingsContainer additionalOptions={this.props.additionalOptions}> {this.props.showCalculatorOption && ( <QuestionSettingsPanel label={t('Options')} defaultExpanded> <FormFieldGroup rowSpacing="small" description={this.renderOptionsDescription()}> <CalculatorOptionWithOqaatAlert disabled={this.props.overrideEditableForRegrading} calculatorValue={this.props.calculatorType} onCalculatorTypeChange={this.handleCalculatorTypeChange} oqaatChecked={this.props.oneQuestionAtATime} onOqaatChange={this.props.setOneQuestionAtATime} /> </FormFieldGroup> </QuestionSettingsPanel> )} </QuestionSettingsContainer> {this.state.isShortcutsModalOpen && this.renderShortcutsModal()} {this.renderGradingOptionsModal()} </div> ) } }