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

Modify File Input to accept multiple files #380

Merged
merged 30 commits into from
Oct 19, 2021
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
062ba62
Initial refactor
chawes13 Oct 8, 2019
c41dc46
Replace remove button with customizable component that accepts handler
chawes13 Oct 9, 2019
528c471
POC for workaround with files failing to load (mostly a upstream clou…
chawes13 Oct 9, 2019
c676b3b
Merge branch 'master' into ch-accept-multiple-files
chawes13 Oct 10, 2019
dc051b2
Refactor to a controlled file input (#388)
chawes13 Sep 21, 2021
19ab858
Merge branch 'master' into ch-accept-multiple-files
chawes13 Sep 21, 2021
379f400
Remove duplicate import
chawes13 Sep 21, 2021
b47fe91
Fix merge conflicts
chawes13 Sep 21, 2021
f3772d7
Add specs for multiple file input
chawes13 Sep 24, 2021
8b77378
Add coverage for inverse scenario
chawes13 Sep 24, 2021
55320c2
Merge branch 'v6' into ch-accept-multiple-files
chawes13 Sep 24, 2021
d6ab9e6
Add missing tests for remove functionality
chawes13 Sep 24, 2021
3515222
Refactor FileInput using hooks
chawes13 Sep 24, 2021
3297506
Use hook import directly
chawes13 Sep 24, 2021
d19d035
Safely mock global object in an isolated way
chawes13 Sep 27, 2021
57d74f5
Wrap component in act to ensure state updates have settled
chawes13 Sep 27, 2021
72dda17
Add tests for error capture
chawes13 Sep 27, 2021
785f5e6
Cover ternary branching conditions
chawes13 Sep 27, 2021
d34b9f3
Update migration guide
chawes13 Sep 28, 2021
afe18b3
Code review feedback
chawes13 Oct 7, 2021
883bfee
Add new test
chawes13 Oct 7, 2021
eb8a0c9
Manually updating docs
chawes13 Oct 7, 2021
9544463
Refactor to always store values in an array
chawes13 Oct 8, 2021
c019e20
Update migration guide based on input value change
chawes13 Oct 8, 2021
f7e8eef
Update prop types
chawes13 Oct 8, 2021
3a00609
Do not change initial value if not an array (it will flag form as dirty)
chawes13 Oct 8, 2021
7dae708
Add thumbnail story
chawes13 Oct 8, 2021
900c83b
Code review feedback
chawes13 Oct 11, 2021
ae1d2d3
Globally replace redux-forms
chawes13 Oct 11, 2021
34d44dc
Reference array instead of single file
chawes13 Oct 11, 2021
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 @@ -296,7 +296,7 @@ Uploader
}
}

.button-secondary-light>input{
input[type="file"]{
position:absolute;
top:0;
right:0;
Expand Down Expand Up @@ -579,4 +579,4 @@ Color Picker
.hex{
@include position(absolute, 35px null null 38px);
}
}
}
40 changes: 23 additions & 17 deletions docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,19 +423,22 @@ export default TodoForm

## CloudinaryFileInput

A wrapper around the [FileInput][38] component that automatically uploads files to cloudinary via the [cloudinaryUploader][159] HOC.
The value of this input is the public URL of the uploaded file.
A wrapper around a file input component (defaults to [FileInput][46]) that automatically uploads files to cloudinary via the [cloudinaryUploader][145] HOC.

The value of this input will only get set upon successful upload. The shape of the value will be of a file object or an array of file objects with the `url` set to the public URL of the uploaded file. The full response from Cloudinary is accessible via the value's `meta.cloudinary` key.

Additionally, the `uploadStatus` passed down from `cloudinaryUploader` will be added as a class on the input.

You can pass arguments to the instance of `cloudinaryUploader` via this component's props,
or via the `CLOUDINARY_CLOUD_NAME` and `CLOUDINARY_BUCKET` env vars (recommended).

### Parameters

- `input` **[Object][156]** A `redux-forms` [input][157] object
- `meta` **[Object][156]** A `redux-forms` [meta][158] object
- `onUploadSuccess` **[Function][150]?** A handler that gets invoked with the response from a successful upload to Cloudinary
- `onUploadFailure` **[Function][150]?** A handler that gets invoked with the error from a failed upload to Cloudinary
- `input` **[Object][142]** A `redux-forms` [input][140] object
- `meta` **[Object][142]** A `redux-forms` [meta][143] object
- `fileInput` **[Function][135]** A component that gets wrapped with Cloudinary upload logic (optional, default `FileInput`)
- `onUploadSuccess` **[Function][135]** A handler that gets invoked with the response from a successful upload to Cloudinary (optional, default `noop`)
- `onUploadFailure` **[Function][135]** A handler that gets invoked with the error from a failed upload to Cloudinary (optional, default `noop`)

### Examples

Expand Down Expand Up @@ -570,26 +573,28 @@ export default TodoForm
## FileInput

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.
The value of this input is a file object or an array of file objects with the `url` set to the base64 encoded data URL of the loaded file(s).

An optional callback can be fired when the file is loaded: `onLoad(fileData, file)`.
This callback will be passed the data URL of the file, as well as the `File` object itself.
Allowing multiple files to be selected requires passing in the `multiple` prop set to `true`. Multiple files can then be uploaded either all at once or piecemeal. Once a file has successfully been loaded, it is possible to remove the file object from the current set of values. An optional callback can be fired when a file is removed: `onRemove(removedFile)`. To customize the component that receives this `onRemove` handler, pass in a cutom component to the `removeComponent` prop.
chawes13 marked this conversation as resolved.
Show resolved Hide resolved

By default, this component displays a thumbnail preview of the loaded file. This preview can be customized
By default, this component displays a thumbnail preview of the loaded file(s). This preview can be customized
by using the `thumbnail` or `hidePreview` props, as well as by passing a custom preview via `previewComponent` or `children`.

A component passed using `previewComponent` will receive the following props:

- `file`: the uploaded file object, or `null` if no file has been uploaded.
- `value`: the current value of the input (data URL or empty string)
- `value`: the current value of the input (file object or array of file objects)

### Parameters

- `input` **[Object][156]** A `redux-forms` [input][157] object
- `meta` **[Object][156]** A `redux-forms` [meta][158] object
- `onLoad` **[Function][150]?** A callback fired when the file is loaded
- `thumbnail` **[String][149]?** A placeholder image to display before the file is loaded
- `hidePreview` **[Boolean][151]?** A flag indicating whether or not to hide the file preview
- `input` **[Object][142]** A `redux-forms` [input][140] object
- `meta` **[Object][142]** A `redux-forms` [meta][143] object
- `readFiles` **[Function][135]?** A callback that is fired with new files and is expected to return an array of file objects with the `url` key set to the "read" value. This can be either a data URL or the public URL from a 3rd party API
- `multiple` **[Boolean][136]** A flag indicating whether or not to accept multiple files (optional, default `false`)
- `onRemove` **[Function][135]** A callback fired when the file is removed (only available when `multiple` is set to `true`) (optional, default `noop`)
- `removeComponent` **[Function][135]** A custom component that receives the `onRemove` callback (only available when `multiple` is set to `true`) (optional, default `RemoveButton`)
- `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`)
- `selectText` **[String][134]?** An override for customizing the text that is displayed on the input's label. Defaults to 'Select File' or 'Select File(s)' depending on the `multiple` prop value

### Examples

Expand All @@ -601,6 +606,7 @@ function HeadshotForm ({ handleSubmit, pristine, invalid, submitting }) {
name="headshot"
component={ FileInput }
onLoad={ (fileData, file) => console.log('Loaded file!', file) }
selectText="Select profile picture"
/>
<SubmitButton {...{ pristine, invalid, submitting }}>
Submit
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@launchpadlab/lp-components",
"version": "5.4.1",
"version": "6.0.0",
"engines": {
"node": "^8.0.0 || ^10.13.0 || ^12.0.0"
},
Expand Down
9 changes: 9 additions & 0 deletions src/forms/helpers/cast-form-value-to-array.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { castArray } from '../../utils'

// Casts value(s) to an array
function castFormValueToArray (value) {
if (!value) return []
return castArray(value)
}

export default castFormValueToArray
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,
)
2 changes: 2 additions & 0 deletions src/forms/helpers/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export blurDirty from './blur-dirty'
export castFormValueToArray from './cast-form-value-to-array'
export convertNameToLabel from './convert-name-to-label'
export DropdownSelect from './dropdown-select'
export fromHex from './from-hex'
Expand All @@ -8,3 +9,4 @@ export omitLabelProps from './omit-label-props'
export replaceEmptyStringValue from './replace-empty-string-value'
export toHex from './to-hex'
export hasInputError from './has-input-error'
export readFilesAsDataUrls from './read-files-as-data-urls'
43 changes: 43 additions & 0 deletions src/forms/helpers/read-files-as-data-urls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { isServer } from '../../utils'

// Reads files and returns objects with file information and base64 encoded string url
async function readFilesAsDataUrls (files) {
const filePromises = files.map(async (file) => {
const fileData = await readFile(file)
return createFileValueObject(file, fileData)
})

return Promise.all(filePromises)
}

// ----- PRIVATE ------

// Read a file and convert it to a base64 string (promisified)
function readFile (file) {
return new Promise((resolve, reject) => {
if (isServer()) return resolve()
// eslint-disable-next-line no-undef
const reader = new window.FileReader()
reader.onload = (readEvent) => {
resolve(readEvent.target.result)
}
reader.onerror = reject

reader.readAsDataURL(file)
})
}

// Copy metadata related to a file, but not the actual File object. These
// properties are not enumerable / visible in Redux.
function createFileValueObject (file, dataUrl='') {
return {
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified,
url: dataUrl,
}
}

export default readFilesAsDataUrls

80 changes: 54 additions & 26 deletions src/forms/inputs/cloudinary-file-input.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,35 @@
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, readFilesAsDataUrls } from '../helpers'
import { compose, cloudinaryUploader, first, noop, set } from '../../utils'
import classnames from 'classnames'

/**
* A wrapper around a file input component (defaults to {@link FileInput}) 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 will only get set upon successful upload. The shape of the value will be of a file object or an array of file objects with the `url` set to the public URL of the uploaded file. The full response from Cloudinary is accessible via the value's `meta.cloudinary` key.
*
* 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.
*
*
* You can pass arguments to the instance of `cloudinaryUploader` via this component's props,
* or via the `CLOUDINARY_CLOUD_NAME` and `CLOUDINARY_BUCKET` env vars (recommended).
*
*
* @name CloudinaryFileInput
* @type Function
* @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 {Function} [onUploadSuccess] - A handler that gets invoked with the response from a successful upload to Cloudinary
* @param {Function} [onUploadFailure] - A handler that gets invoked with the error from a failed upload to Cloudinary
* @param {Function} [fileInput=FileInput] - A component that gets wrapped with Cloudinary upload logic
* @param {Boolean} [multiple=false] - A flag indicating whether or not to accept multiple files
* @param {Function} [onUploadSuccess=noop] - A handler that gets invoked with the response from a successful upload to Cloudinary
* @param {Function} [onUploadFailure=noop] - A handler that gets invoked with the error from a failed upload to Cloudinary
* @example
*
*
* function HeadshotForm ({ handleSubmit, pristine, invalid, submitting }) {
* return (
* <form onSubmit={ handleSubmit }>
* <Field
* name="headshotUrl"
* <Field
* name="headshotUrl"
* component={ CloudinaryFileInput }
* cloudName="my-cloudinary-cloud"
* bucket="my-cloudinary-bucket"
Expand All @@ -41,38 +43,64 @@ import classnames from 'classnames'
*/

const propTypes = {
...fieldPropTypes,
...fileInputPropTypes,
fileInput: PropTypes.func,
multiple: PropTypes.bool,
onUploadFailure: PropTypes.func,
onUploadSuccess: PropTypes.func,
upload: PropTypes.func.isRequired,
uploadStatus: PropTypes.string.isRequired,
}

const defaultProps = {
fileInput: DefaultFileInput,
multiple: false,
onUploadSuccess: noop,
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,
...rest
upload,
uploadStatus,
fileInput: FileInput,
multiple,
...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))
}
<FileInput
input={ input }
readFiles={async (files) => {
let uploadedFiles = null
try {
const filesWithDataUrls = await readFilesAsDataUrls(files)
const uploadFilePromises = filesWithDataUrls.map(async (file) => {
const cloudinaryRes = await upload(file.url, file)
return mapCloudinaryResponse(file, cloudinaryRes)
})

uploadedFiles = await Promise.all(uploadFilePromises)
} catch (e) {
onUploadFailure(e)
throw e
}

const successResponse = multiple ? uploadedFiles : first(uploadedFiles)
onUploadSuccess(successResponse)
return uploadedFiles
}}
className={ classnames(uploadStatus, className) }
multiple={multiple}
{ ...rest }
/>
)
Expand Down
Loading