@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
754 lines (693 loc) • 23.4 kB
JavaScript
/** @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>
```
**/
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>
)
}
}