@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
357 lines (307 loc) • 10.2 kB
JavaScript
/** @jsx jsx */
import {Component} from 'react'
import PropTypes from 'prop-types'
import assign from 'lodash/fp/assign'
import find from 'lodash/fp/find'
import map from 'lodash/fp/map'
import concat from 'lodash/fp/concat'
import sortBy from 'lodash/fp/sortBy'
import remove from 'lodash/fp/remove'
import uniqBy from 'lodash/fp/uniqBy'
import flow from 'lodash/fp/flow'
import {jsx} from '@instructure/emotion'
import {Text} from '@instructure/ui-text'
import {IconUploadLine} from '@instructure/ui-icons'
import {Alert} from '@instructure/ui-alerts'
import {ItemBodyWrapper} from '@instructure/quiz-rce/components/ItemBodyWrapper/index'
import isImage from '../common/isImage'
import FocusGroup from '../../common/components/FocusGroup'
import FilesList from '../common/FilesList'
import generateStyle from './styles'
import generateComponentTheme from './theme'
import t from '@instructure/quiz-i18n/format-message'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import {withStyleOverrides} from '@instructure/quiz-common/util/withStyleOverrides'
import {FileDrop} from '@instructure/quiz-common/components/FileDrop/index'
const FILEDROP_SELECTOR = 'input[type=file]'
const REMOVE_BUTTON_SELECTOR = '[data-role=removeButton] button'
/**
---
category: FileUpload
---
File Upload Take component
```jsx_example
function Example (props) {
const exampleProps = {
itemBody: 'Give three examples of people',
interactionData: {
restrictCount: true,
filesCount: 3
},
properties: {
restrictTypes: false,
allowedTypes: ''
},
userResponse: {
value: [{
id: Date.now(),
name: 'slide-3.jpg',
url: 'http://instructure.github.io/images/slide-3.jpg',
size: 49067
}]
}
}
return (
<FileUploadTake {...exampleProps} {...props} />
)
}
<SettingsSwitcher locales={LOCALES}>
<TakeStateProvider>
<Example />
</TakeStateProvider>
</SettingsSwitcher>
```
**/
export default class FileUploadTake extends Component {
static displayName = 'FileUploadTake'
static componentId = `Quizzes${this.displayName}`
static propTypes = {
itemBody: PropTypes.string.isRequired,
readOnly: PropTypes.bool,
handleResponseUpdate: PropTypes.func.isRequired,
mediaUpload: PropTypes.func.isRequired,
cancelMediaUpload: PropTypes.func,
interactionData: PropTypes.shape({
restrictCount: PropTypes.bool.isRequired,
filesCount: PropTypes.string.isRequired,
}).isRequired,
properties: PropTypes.shape({
restrictTypes: PropTypes.bool.isRequired,
allowedTypes: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
}).isRequired,
userResponse: PropTypes.shape({
value: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
}),
),
}).isRequired,
styles: PropTypes.object,
}
static defaultProps = {
cancelMediaUpload: () => {},
readOnly: false,
}
state = {
files: [],
messages: [],
}
focusGroup = null
renderDescription = () => {
return `${this.fileCountA11yDescription()} ${this.renderAllowedTypesDescription() || ''}`
}
fileCountA11yDescription = () => {
if (this.props.interactionData.restrictCount) {
const fileResponseLength = this.allResponses().length
if (fileResponseLength === 0)
return t('Maximum { fileCount } file(s) allowed', {fileCount: this.fileCountNumber()})
const requiredFilesCount = this.parseFilesCount()
return t('{ filesSubmitted } of a maximum of { filesRequired } file(s) submitted.', {
filesRequired: requiredFilesCount,
filesSubmitted: fileResponseLength,
})
}
return t('{ fileCount } file(s) submitted.', {fileCount: this.fileCountNumber()})
}
fileCountNumber = () => {
return this.props.interactionData.restrictCount
? this.parseFilesCount() - this.allResponses().length
: this.allResponses().length
}
parseFilesCount() {
return parseInt(this.props.interactionData.filesCount, 10)
}
parseResponse() {
const {userResponse} = this.props
if (!userResponse || !userResponse.value || !userResponse.value.length) {
return []
}
return userResponse.value.filter(f => !!f)
}
makeUploadCallback(id, {name, size}) {
return url => {
// First add the new file to userResponse and THEN delete from state
// So that we never lose track of a file (causes lost focus issues)
// This means that for one rerender we'll have a file both in state & userResponse
const response = this.parseResponse()
this.props.handleResponseUpdate(concat(response, {id, url, name, size}))
this.setState({
files: remove({id}, this.state.files),
})
}
}
makeLocalLoadHandler = id => {
return e => {
const files = map(file => {
if (file.id !== id) {
return file
}
return assign(file, {url: e.target.result})
}, this.state.files)
this.setState({files})
}
}
handleDropRejected = files => {
this.setState({
messages: [
{
text: t('Invalid file type'),
type: 'error',
},
],
})
}
handleDropAccepted = files => {
if (this.props.readOnly) {
return
}
let uploadFiles
if (this.props.interactionData.restrictCount) {
const responseLength = this.parseResponse().length
uploadFiles = files.slice(0, this.parseFilesCount() - responseLength)
} else {
uploadFiles = files
}
const parsedFiles = uploadFiles.map((file, index) => {
const id = Date.now() + index
const callback = this.makeUploadCallback(id, file)
this.props.mediaUpload(id, file, callback)
if (isImage(file.name)) {
const reader = new FileReader()
reader.onload = this.makeLocalLoadHandler(id)
reader.readAsDataURL(file)
}
return {
id,
url: null,
name: file.name,
size: file.size,
isUploading: true,
}
})
this.setState(
{
files: concat(this.state.files, parsedFiles),
messages: [],
},
() => {
// After browsing & selecting 1 or more files, focus on the last one
this.focusGroup.focusLast('div')
},
)
}
makeRemoveHandler = ({id}) => {
return () => {
const stateFiles = this.state.files
const response = this.parseResponse()
if (stateFiles.length + response.length === 1) {
// Focus on FileDrop
this.focusGroup.focusNext(FILEDROP_SELECTOR)
} else {
// Focus on another remove button
if (this.focusGroup.previousExists(REMOVE_BUTTON_SELECTOR)) {
this.focusGroup.focusPrevious(REMOVE_BUTTON_SELECTOR)
} else {
this.focusGroup.focusNext(REMOVE_BUTTON_SELECTOR)
}
}
if (find({id}, stateFiles)) {
this.props.cancelMediaUpload(id)
this.setState({
files: remove({id}, stateFiles),
})
} else {
this.props.handleResponseUpdate(remove({id}, response))
}
}
}
handleShifterRef = node => {
this.focusGroup = node
}
renderFileDropContent() {
return (
<div css={this.props.styles.fileDropContent}>
<div css={this.props.styles.fileDropContentIcon}>
<IconUploadLine />
</div>
<div css={this.props.styles.fileDropContentLabel}>
<Text color="primary">
{t.rich('Drag and Drop here or <0>Browse</0>', [
({children}) => (
<span key="1" css={this.props.styles.fileDropContentLabelBrowse}>
{children}
</span>
),
])}
</Text>
</div>
</div>
)
}
renderAllowedTypesDescription() {
const {restrictTypes, allowedTypes} = this.props.properties
return restrictTypes && allowedTypes ? t('{ types } only!', {types: allowedTypes}) : null
}
allResponses = () => {
return flow(concat(this.state.files), sortBy('id'), uniqBy('id'))(this.parseResponse())
}
render() {
const {restrictCount} = this.props.interactionData
const {itemBody, properties, readOnly} = this.props
const {restrictTypes, allowedTypes} = properties
const accept = restrictTypes ? allowedTypes : ''
// The uniqBy is needed because in one rerender there's a file in both state & userResponse
const hideFileDrop = restrictCount && this.allResponses().length === this.parseFilesCount()
return (
<div>
<div css={this.props.styles.printOnly}>
<Alert variant="warning">{t('This question type cannot be printed')}</Alert>
</div>
<FocusGroup ref={this.handleShifterRef} asComponent={ItemBodyWrapper} asProps={{itemBody}}>
<FilesList
files={this.allResponses()}
makeRemoveHandler={this.makeRemoveHandler}
readOnly={this.props.readOnly}
/>
{hideFileDrop ? null : (
<div>
<div>
<FileDrop
messages={this.state.messages}
accept={accept}
onDropAccepted={this.handleDropAccepted}
onDropRejected={this.handleDropRejected}
shouldAllowMultiple
readOnly={readOnly}
renderLabel={this.renderFileDropContent()}
/>
</div>
{restrictCount ? (
<div>
<Alert variant="info" margin="x-small 0" hasShadow={false}>
{this.renderDescription()}
</Alert>
</div>
) : null}
</div>
)}
</FocusGroup>
<ScreenReaderContent>{this.renderDescription()}</ScreenReaderContent>
</div>
)
}
}