UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

357 lines (307 loc) • 10.2 kB
/** @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> ``` **/ @withStyleOverrides(generateStyle, generateComponentTheme) 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> ) } }