@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
281 lines (249 loc) • 8.56 kB
JavaScript
import React, {Component} from 'react'
import PropTypes from 'prop-types'
import {v4 as uuid} from 'uuid'
import {Text} from '@instructure/ui-text'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import {ItemBodyWrapper} from '@instructure/quiz-rce/components/ItemBodyWrapper/index'
import {
RichContentInput,
RCE_THREE_LINES_HEIGHT,
} from '@instructure/quiz-rce/components/RichContentInput/index'
import {RichContentRenderer} from '@instructure/quiz-rce/components/RichContentRenderer/index'
import {WordCount} from '../helpers/WordCount'
import t from '@instructure/quiz-i18n/format-message'
import {
DOCUMENT_TREE_DEPTH_LIMIT,
CONTENT_SIZE_LIMIT,
CONTENT_SIZE_BY_ANSWER,
} from '@instructure/quiz-common/constants'
import {TextArea} from '@instructure/quiz-common/components/TextArea/index'
/**
---
category: Essay
---
Essay Take component
```jsx_example
function Example (props) {
const exampleProps = {
itemBody: 'Why did the Roman Empire fall?',
interactionData: {
rce: true,
spellCheck: true,
wordCount: true,
wordLimitEnabled: true,
wordLimitMax: 10,
wordLimitMin: 1,
notes: 'Teachers grading notes'
},
userResponse: {
value: 'So it could learn to pick itself back up again.'
}
}
return (
<EssayTake {...exampleProps} {...props} />
)
}
<SettingsSwitcher locales={LOCALES}>
<TakeStateProvider>
<Example />
</TakeStateProvider>
</SettingsSwitcher>
```
**/
export default class EssayTake extends Component {
static propTypes = {
handleResponseUpdate: PropTypes.func,
interactionData: PropTypes.object.isRequired,
itemBody: PropTypes.string.isRequired,
userResponse: PropTypes.object,
notifyScreenreader: PropTypes.func,
openImportModal: PropTypes.func,
readOnly: PropTypes.bool,
disableDocumentAccess: PropTypes.bool,
}
static defaultProps = {
handleResponseUpdate: () => {},
openImportModal: () => {},
readOnly: false,
userResponse: void 0,
notifyScreenreader: void 0,
disableDocumentAccess: false,
}
static contextTypes = {
disableTextAreaAutoGrow: PropTypes.bool,
}
constructor(props) {
super(props)
this.uniqId = uuid()
const workingEssayContent = (props.userResponse && props.userResponse.value) || ''
const documentTreeDepthOverLimit = this.isDocumentTreeDepthOverLimit(workingEssayContent)
const dynamoRecordSize = this.getContentSize(workingEssayContent)
this.state = {
workingEssayContent,
documentTreeDepthOverLimit,
dynamoRecordSize,
contentId: uuid(),
}
}
componentDidUpdate(prevProps) {
const {userResponse} = this.props
const prevValue = prevProps.userResponse?.value
const currentValue = userResponse?.value
if (currentValue && prevValue === undefined) {
this.setState({
workingEssayContent: currentValue || '',
contentId: uuid(),
})
}
}
verifyErrors = text => {
const documentTreeDepthOverLimit = this.isDocumentTreeDepthOverLimit(text)
const dynamoRecordSize = this.getContentSize(text)
this.setState({documentTreeDepthOverLimit, dynamoRecordSize})
}
isDocumentTreeDepthOverLimit = text => {
if (!text) return false
try {
let template = document.createElement('template')
template.innerHTML = text
return this.isLevelOfChildrenOverLimit(template.content)
} catch (e) {
console.error(e)
return false
}
}
isLevelOfChildrenOverLimit = (element, level = 0) => {
if (level > DOCUMENT_TREE_DEPTH_LIMIT) return true
if (!element) return false
const children = element.childNodes
if (children.length === 0) return false
const childrenLevel = level + 1
return Array.from(children).some(e => this.isLevelOfChildrenOverLimit(e, childrenLevel))
}
getContentSize = text => {
if (!text) return 0
const match = new RegExp('[\&\<\>]', 'g') // eslint-disable-line no-useless-escape
// In backend the whole response is serialized into Unicode
// every answer occupies CONTENT_SIZE_BY_ANSWER
// every text answer is serialized twice
// and then every ampersand, greater than and less than character
// when converted to Unicode occupies 5 more bytes twice
// Example, an & is turned into \u0026
return CONTENT_SIZE_BY_ANSWER + text.length * 2 + (text.match(match) || []).length * 10
}
handleOnBlur = (event, rceContent) => {
if (!this.props.readOnly) {
const newVal = this.props.interactionData.rce ? rceContent.editorContent : event.target.value
if (newVal !== void 0 && newVal !== this.state.workingEssayContent) {
this.props.handleResponseUpdate(newVal)
this.verifyErrors(newVal)
this.setState({workingEssayContent: newVal})
}
}
}
handleOnChange = (event, rceContent) => {
if (!this.props.readOnly) {
const newVal = this.props.interactionData.rce ? rceContent.editorContent : event.target.value
if (newVal !== void 0 && newVal !== this.state.workingEssayContent) {
this.props.handleResponseUpdate(newVal)
this.setState({workingEssayContent: newVal})
}
}
}
renderEditor() {
const commonProps = {
label: (
<ScreenReaderContent>
<RichContentRenderer content={this.props.itemBody} />
</ScreenReaderContent>
),
readOnly: this.props.readOnly,
onChange: this.handleOnChange,
onBlur: this.handleOnBlur,
}
if (this.props.interactionData.rce) {
const {spellCheck, wordCount} = this.props.interactionData
return (
<RichContentInput
{...commonProps}
textareaId={`rceTextArea_${this.uniqId}`}
onKeyUp={this.handleOnChange}
openImportModal={this.props.openImportModal}
defaultContent={this.state.workingEssayContent}
stem={this.props.itemBody}
height={RCE_THREE_LINES_HEIGHT}
editorOptions={{
spellCheck,
wordCount,
}}
key={this.state.contentId}
disableDocumentAccess={this.props.disableDocumentAccess}
/>
)
}
return (
<TextArea
{...commonProps}
value={this.state.workingEssayContent}
resize="vertical"
spellCheck={this.props.interactionData.spellCheck}
autoGrow={this.context.disableTextAreaAutoGrow ? false : null}
/>
)
}
renderErrors() {
const {documentTreeDepthOverLimit, dynamoRecordSize} = this.state
return (
<div>
<Text color="danger" size="small">
{documentTreeDepthOverLimit
? t(
'Formatting error. Please ensure that the text entry has not been pasted from a different source. Type text to submit response.',
)
: ''}
{dynamoRecordSize > CONTENT_SIZE_LIMIT
? t('This submission is too large. Please adjust and try to submit again.')
: ''}
</Text>
</div>
)
}
renderWordCountMessage() {
const {wordCount, wordLimitMin, wordLimitEnabled, wordLimitMax} = this.props.interactionData
if (wordCount || wordLimitEnabled) {
return (
<ScreenReaderContent>
{wordLimitEnabled && wordLimitMin
? t('This essay has a minimum word count of {wordLimitMin}.', {wordLimitMin})
: ''}
{wordLimitEnabled && wordLimitMax
? t('This essay has a maximum word count of {wordLimitMax}.', {wordLimitMax})
: ''}
{wordCount ? t('There is a word count below the text area.') : ''}
</ScreenReaderContent>
)
}
}
render() {
const {interactionData, itemBody, notifyScreenreader} = this.props
return (
<ItemBodyWrapper itemBody={itemBody}>
{this.renderWordCountMessage()}
<div className="fs-mask">{this.renderEditor()}</div>
<div>
<WordCount
rce={interactionData.rce}
isEditing={true}
essay={this.state.workingEssayContent}
wordCount={interactionData.wordCount}
wordLimitEnabled={interactionData.wordLimitEnabled}
wordLimitMin={parseInt(interactionData.wordLimitMin, 10)}
wordLimitMax={parseInt(interactionData.wordLimitMax, 10)}
notifyScreenreader={notifyScreenreader}
/>
</div>
{this.renderErrors()}
</ItemBodyWrapper>
)
}
}