Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor to a controlled file input #388

Merged
merged 17 commits into from
Sep 21, 2021
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .storybook/styles/components/_forms.scss
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ Uploader
}
}

.button-secondary-light>input{
input[type="file"]{
position:absolute;
top:0;
right:0;
Expand Down Expand Up @@ -572,4 +572,4 @@ Color Picker
.hex{
@include position(absolute, 35px null null 38px);
}
}
}
2 changes: 1 addition & 1 deletion docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ A component passed using `previewComponent` will receive the following props:
- `input` **[Object][142]** A `redux-forms` [input][140] object
- `meta` **[Object][142]** A `redux-forms` [meta][143] object
- `multiple` **[Boolean][136]** A flag indicating whether or not to accept multiple files (optional, default `false`)
- `onLoad` **[Function][135]?** A callback fired when the file is loaded
- `onRead` **[Function][135]?** A callback fired when the file data has been read
- `onRemove` **[Function][135]?** A callback fired when the file is removed (only available when multiple files can be uploaded)
- `thumbnail` **[String][134]?** A placeholder image to display before the file is loaded
- `hidePreview` **[Boolean][136]** A flag indicating whether or not to hide the file preview (optional, default `false`)
Expand Down
12 changes: 12 additions & 0 deletions src/forms/helpers/field-prop-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,15 @@ export const checkboxGroupPropTypes = fieldPropTypesWithValue(
])
)
)

const file = PropTypes.shape({
file: PropTypes.shape({
name: PropTypes.string.isRequired,
}).isRequired,
fileUpload: PropTypes.object,
fileData: PropTypes.string,
})

export const fileInputPropTypes = fieldPropTypesWithValue(
PropTypes.arrayOf(file).isRequired,
)
46 changes: 30 additions & 16 deletions src/forms/inputs/cloudinary-file-input.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import React from 'react'
import PropTypes from 'prop-types'
import FileInput from './file-input'
import { fieldPropTypes } from '../helpers'
import { compose, cloudinaryUploader, noop } from '../../utils'
import DefaultFileInput from './file-input'
import { fileInputPropTypes } from '../helpers'
import { compose, cloudinaryUploader, noop, set } from '../../utils'
import classnames from 'classnames'

/**
*
* A wrapper around the {@link FileInput} component that automatically uploads files to cloudinary via the [cloudinaryUploader](https://github.com/LaunchPadLab/lp-hoc/blob/master/docs.md#cloudinaryuploader) HOC.
* The value of this input is the public URL of the uploaded file.
* Additionally, the `uploadStatus` passed down from `cloudinaryUploader` will be added as a class on the input.
Expand Down Expand Up @@ -41,7 +40,7 @@ import classnames from 'classnames'
*/

const propTypes = {
...fieldPropTypes,
...fileInputPropTypes,
onUploadFailure: PropTypes.func,
onUploadSuccess: PropTypes.func,
upload: PropTypes.func.isRequired,
Expand All @@ -53,25 +52,40 @@ const defaultProps = {
onUploadFailure: noop,
}

function CloudinaryFileInput ({
function mapCloudinaryResponse (file, response) {
return compose(
set('url', response.url),
set('meta.cloudinary', response)
)(file)
}

function CloudinaryFileInput ({
input,
className,
onUploadFailure,
onUploadSuccess,
upload,
uploadStatus,
upload,
uploadStatus,
fileInput: FileInput = DefaultFileInput,
...rest
}) {
const { onChange } = input
return (
<FileInput
input={{ ...input, onChange: noop }}
onLoad={ (fileData, file) => upload(fileData, file)
.then((res) => {
onChange(res.url)
return onUploadSuccess(res)
}, (err) => onUploadFailure(err))
}
input={ input }
onRead={async (files) => {
try {
const uploadFilePromises = files.map(async (file) => {
const cloudinaryRes = await upload(file.url, file)
return mapCloudinaryResponse(file, cloudinaryRes)
})

const uploadedFiles = await Promise.all(uploadFilePromises)
onUploadSuccess(uploadedFiles)
return uploadedFiles
} catch (e) {
chawes13 marked this conversation as resolved.
Show resolved Hide resolved
onUploadFailure(e)
}
}}
className={ classnames(uploadStatus, className) }
{ ...rest }
/>
Expand Down
182 changes: 87 additions & 95 deletions src/forms/inputs/file-input/file-input.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import React from 'react'
import PropTypes from 'prop-types'
import { buttonClasses, fieldPropTypes, hasInputError, isImageType, omitLabelProps } from '../../helpers'
import { LabeledField } from '../../labels'
import { buttonClasses, fileInputPropTypes, hasInputError, isImageType, omitLabelProps } from '../../helpers'
import { InputError, LabeledField } from '../../labels'
import FilePreview from './file-preview'
import ImagePreview from './image-preview';
import { noop, generateInputErrorId, removeAt } from '../../../utils'
import { first, noop, generateInputErrorId, removeAt, identity } from '../../../utils'
import classnames from 'classnames'
import { castToFileArray, readFiles } from './helpers'

/**
*
* A file input that can be used in a `redux-forms`-controlled form.
* The value of this input is the data URL of the loaded file.
*
Expand All @@ -26,7 +27,7 @@ import { noop, generateInputErrorId, removeAt } from '../../../utils'
* @param {Object} input - A `redux-forms` [input](http://redux-form.com/6.5.0/docs/api/Field.md/#input-props) object
* @param {Object} meta - A `redux-forms` [meta](http://redux-form.com/6.5.0/docs/api/Field.md/#meta-props) object
* @param {Boolean} [multiple=false] - A flag indicating whether or not to accept multiple files
* @param {Function} [onLoad] - A callback fired when the file is loaded
* @param {Function} [onRead] - A callback fired when the file data has been read
* @param {Function} [onRemove] - A callback fired when the file is removed (only available when multiple files can be uploaded)
* @param {String} [thumbnail] - A placeholder image to display before the file is loaded
* @param {Boolean} [hidePreview=false] - A flag indicating whether or not to hide the file preview
Expand All @@ -49,141 +50,133 @@ import { noop, generateInputErrorId, removeAt } from '../../../utils'
*/

const propTypes = {
...fieldPropTypes,
onLoad: PropTypes.func,
...fileInputPropTypes,
onRead: PropTypes.func,
thumbnail: PropTypes.string,
hidePreview: PropTypes.bool,
className: PropTypes.string,
previewComponent: PropTypes.func,
children: PropTypes.node,
multiple: PropTypes.bool,
onRemove: PropTypes.func,
removeText: PropTypes.string,
selectText: PropTypes.string,
}

const defaultProps = {
multiple: false,
onLoad: noop,
onRead: identity,
onRemove: noop,
}

function readFile (file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (readEvent) => {
resolve(readEvent.target.result)
}
reader.onerror = reject

reader.readAsDataURL(file)
})
selectText: '',
}

class FileInput extends React.Component {

constructor (props) {
super(props)
this.state = { files: [] }
this.loadFiles = this.loadFiles.bind(this)

this.state = { errors: null }
this.onChange = this.onChange.bind(this)
this.removeFile = this.removeFile.bind(this)
}

loadFiles (e) {
// Read files as data URL and call change handlers
const files = [...e.target.files] // when multiple=false, `files` is still array-like

// Do not reload files that have been successfully loaded
const filesToLoad = files.filter((file) => {
return !this.state.files.some((existingFile) => existingFile.name === file.name)
})

return filesToLoad.map((file) => {
return readFile(file)
.then((fileData) => {
this.onChange(fileData, file)
})
})
this.fileInput = null
this.setFileInputRef = element => {
this.fileInput = element
}
this.clearFileInput = () => {
if (this.fileInput) this.fileInput.value = ""
}
}

// TODO: Should this fire once or per file?
onChange (fileData, file) {
// Call redux forms onChange and onLoad callback
const { input: { onChange, value }, onLoad, multiple } = this.props
async onChange (fileValues) {
const { input: { onChange, value: existingFiles }, onRead, multiple } = this.props

if (multiple) {
onChange([...value, fileData])
this.setState((state) => {
return { files: [ ...state.files, file ] }
})
} else {
onChange(fileData)
this.setState({ files: [file] })
try {
// Only add value to form if file(s) successfully load
const filesToAdd = await Promise.resolve(onRead(fileValues)) // wrap in a promise (just in case)
chawes13 marked this conversation as resolved.
Show resolved Hide resolved
if (!filesToAdd) return
if (!multiple) return onChange(first(filesToAdd))
return onChange([...existingFiles, ...filesToAdd])
} catch (e) {
// eslint-disable-next-line
console.error(e)
this.setState({ errors: e })
}

// TODO: should this be required to fulfill successfully before firing onChange / set state?
onLoad(fileData, file)
}

removeFile (idx) {
const { input: { onChange, value }, onRemove } = this.props
const [removedValue, remainingValues] = removeAt(value, idx)
const [removedFile, remainingFiles] = removeAt(this.state.files, idx)
async removeFile (idx) {
const { input: { onChange, value }, multiple, onRemove } = this.props
const [removedFile, remainingFiles] = removeAt(value, idx)

onChange(remainingValues)
onRemove(removedValue, removedFile)
this.setState({ files: remainingFiles })
try {
await Promise.resolve(onRemove(removedFile)) // wrap in a promise (just in case)
chawes13 marked this conversation as resolved.
Show resolved Hide resolved

// If all files have been removed, then reset the native input
if (!remainingFiles.length) this.clearFileInput()

if (multiple) return onChange(remainingFiles)
return onChange(null)
} catch (e) {
// eslint-disable-next-line
console.error(e)
this.setState({ errors: e })
}
}

render () {
const {
input: { name, value },
meta, // eslint-disable-line no-unused-vars
onLoad, // eslint-disable-line no-unused-vars
onRead, // eslint-disable-line no-unused-vars
className, // eslint-disable-line no-unused-vars
submitting,
accept,
hidePreview,
multiple,
removeComponent: RemoveComponent = RemoveButton,
selectText,
...rest
} = omitLabelProps(this.props)
const { files } = this.state
const { errors } = this.state
const wrapperClass = buttonClasses({ style: 'secondary-light', submitting })
const labelText = multiple ? 'Select File(s)' : 'Select File'
const labelText = selectText || (multiple ? 'Select File(s)' : 'Select File')
const values = castToFileArray(value)

return (
<LabeledField { ...this.props }>
<div className="fileupload fileupload-exists">
{
!hidePreview &&
files.map((file, idx) => (
<div key={file.name}>
<RenderPreview
file={file}
value={value[idx]}
{...rest}
/>
{ multiple && <RemoveComponent onRemove={() => this.removeFile(idx) } /> }
</div>
))
values.map((value, idx) => {
return (
<div key={value.name}>
<RenderPreview value={value} {...rest} />
{ multiple && <RemoveComponent onRemove={() => this.removeFile(idx) } /> }
</div>
)
})
}
<div className={ wrapperClass }>
<span className="fileupload-exists" id={name+'-label'}> { labelText } </span>
<input
{...{
id: name,
name,
type: 'file',
onClick: (e) => { e.target.value = "" }, // force onChange to fire every time
onChange: this.loadFiles,
accept,
multiple,
'aria-labelledby': name + '-label',
'aria-describedby': hasInputError(meta) ? generateInputErrorId(name) : null,
}}
/>
<div>
<input
{...{
id: name,
name,
type: 'file',
onClick: this.clearFileInput, // force onChange to fire _every_ time (use case: attempting to upload the same file after a failure)
onChange: async (e) => {
this.setState({ errors: null })
const files = await readFiles({ newFiles: [...e.target.files], existingFiles: values })
return this.onChange(files)
},
accept,
multiple,
'aria-labelledby': name + '-label',
'aria-describedby': hasInputError(meta) ? generateInputErrorId(name) : null,
ref: this.setFileInputRef,
}}
/>
{/* Include after input to allowing for styling with adjacent sibling selector */}
<span className={classnames("fileupload-exists", wrapperClass)} id={name+'-label'}> { labelText } </span>
</div>
{ errors && <InputError error={errors} className="field-warning" touched invalid /> }
</div>
</LabeledField>
)
Expand All @@ -192,18 +185,17 @@ class FileInput extends React.Component {

// eslint-disable-next-line react/prop-types
function RenderPreview ({
file,
value,
thumbnail,
previewComponent: Component,
children,
...rest
}) {
if (Component) return <Component file={ file } value={ value } { ...rest } />
if (Component) return <Component value={ value } { ...rest } />
if (children) return children
const renderImagePreview = isImageType(file) || thumbnail
if (renderImagePreview) return <ImagePreview image={ value || thumbnail } />
return <FilePreview file={ file } />
const renderImagePreview = isImageType(value.type) || thumbnail
if (renderImagePreview) return <ImagePreview image={ value.url || thumbnail } />
return <FilePreview name={ value.name } />
}

function RemoveButton ({ onRemove }) {
Expand Down
Loading