-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathfile-input.js
More file actions
281 lines (259 loc) · 9.95 KB
/
file-input.js
File metadata and controls
281 lines (259 loc) · 9.95 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
import React, { useState, useEffect, useCallback, useRef } from 'react'
import PropTypes from 'prop-types'
import { fileInputPropTypes } from '../../helpers/field-prop-types'
import {
castFormValueToArray,
hasInputError,
isImageType,
omitLabelProps,
readFilesAsDataUrls,
} from '../../helpers'
import { LabeledField } from '../../labels'
import FilePreview from './file-preview'
import ImagePreview from './image-preview'
import { noop, generateInputErrorId, isString, removeAt } from '../../../utils'
import classnames from 'classnames'
/**
* A file input that can be used in a `redux-form`-controlled form.
* The value of this input is an array of file objects, with the `url` set to the base64 encoded data URL of the loaded file(s) by default.
*
* Allowing multiple files to be selected requires setting the `multiple` prop to `true`. Multiple files can then be uploaded either all at once or piecemeal. This is different than the standard behavior of a file input, which will _replace_ any existing files with whatever is selected. Once a file has been read successfully, it is possible to remove the file object from the current set of files. 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 custom component to the `removeComponent` prop.
*
* 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 current value of the input (an array of file objects)
*
* @name FileInput
* @type Function
* @param {Object} input - A `redux-form` [input](http://redux-form.com/6.5.0/docs/api/Field.md/#input-props) object
* @param {Object} meta - A `redux-form` [meta](http://redux-form.com/6.5.0/docs/api/Field.md/#meta-props) object
* @param {Function} [readFiles=readFilesAsDataUrls] - 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
* @param {Boolean} [multiple=false] - A flag indicating whether or not to accept multiple files
* @param {String} [accept] - Value that defines the file types the file input should accept (e.g., ".doc,.docx"). More info: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept
* @param {("user"|"environment")} [capture] - Value that specifies which camera to use, if the accept attribute indicates the input type of image or video. This is not available for all devices (e.g., desktops). More info: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#capture
* @param {Function} [onRemove=noop] - A callback fired when a file is removed
* @param {Function} [previewComponent=RenderPreview] - A custom component that is used to display a preview of each attached file
* @param {Function} [removeComponent=RemoveButton] - A custom component that receives `value` and `onRemove` props
* @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
* @param {String} [selectText] - 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
*
* @example
*
* function HeadshotForm ({ handleSubmit, pristine, invalid, submitting }) {
* return (
* <form onSubmit={ handleSubmit }>
* <Field
* name="headshot"
* component={ FileInput }
* selectText="Select profile picture"
* />
* <SubmitButton {...{ pristine, invalid, submitting }}>
* Submit
* </SubmitButton>
* </form>
* )
* }
*/
const propTypes = {
...fileInputPropTypes,
thumbnail: PropTypes.string,
hidePreview: PropTypes.bool,
className: PropTypes.string,
previewComponent: PropTypes.func, // eslint-disable-line react/no-unused-prop-types
children: PropTypes.node, // eslint-disable-line react/no-unused-prop-types
multiple: PropTypes.bool,
onRemove: PropTypes.func,
readFiles: PropTypes.func,
removeComponent: PropTypes.func,
selectText: PropTypes.string,
}
const defaultProps = {
hidePreview: false,
multiple: false,
onRemove: noop,
readFiles: readFilesAsDataUrls,
removeComponent: RemoveButton,
selectText: '',
}
function usePrevious(value) {
const ref = useRef()
useEffect(() => {
ref.current = value
})
return ref.current
}
function FileInput(props) {
const {
input,
meta,
className, // eslint-disable-line no-unused-vars
submitting,
accept,
capture,
hidePreview,
multiple,
readFiles,
removeComponent: RemoveComponent,
selectText,
thumbnail,
onRemove,
...rest
} = props
const previewProps = omitLabelProps(rest)
const [errors, setErrors] = useState(null)
const inputRef = useRef()
const prevMultiple = usePrevious(multiple)
const clearFileInput = () => {
/* istanbul ignore next */
if (inputRef.current) {
inputRef.current.value = ''
}
}
const removeFile = useCallback(
async (idx) => {
const { onChange, value } = input
const [removedFile, remainingFiles] = removeAt(value, idx)
try {
await onRemove(removedFile)
// If all files have been removed, then reset the native input
if (!remainingFiles.length) clearFileInput()
return onChange(remainingFiles)
} catch (e) {
setErrors(e)
}
},
[input, onRemove]
)
// Automatically select only the first file if `multiple` changes to false
useEffect(() => {
// Only subscribe to _changes_ in the prop (not initial mount)
if (multiple || prevMultiple === undefined || prevMultiple === multiple)
return
const { value, onChange } = input
const valueToUpdate = value.slice(0, 1)
onChange(valueToUpdate)
}, [multiple, prevMultiple, input])
const inputMeta = setInputErrors(meta, errors)
const labelText = selectText || (multiple ? 'Select File(s)' : 'Select File')
const values = castFormValueToArray(input.value)
// Support rendering a custom preview component, even if no value is selected or when `thumbnail` is present
const files = values.length > 0 ? values : [null]
return (
<LabeledField {...props} meta={inputMeta}>
<div className="fileupload fileupload-exists">
{!hidePreview && (
<React.Fragment>
{files.map((file, idx) => (
<div
key={file?.name || idx}
className="fileupload-preview-container"
>
<RenderPreview
file={file}
thumbnail={thumbnail}
{...previewProps}
/>
{file && (
<RemoveComponent
file={file}
onRemove={() => removeFile(idx)}
/>
)}
</div>
))}
</React.Fragment>
)}
<div
className={classnames('button-secondary-light', {
'in-progress': submitting,
})}
>
<input
{...{
id: input.name,
name: input.name,
type: 'file',
onClick: clearFileInput, // force onChange to fire _every_ time (use case: attempting to upload the same file after a failure)
onChange: async (e) => {
setErrors(null)
try {
const files = [...e.target.files]
const newFiles = removeExistingFiles(files, values)
const newFilesWithUrls = await readFiles(newFiles)
if (!newFilesWithUrls) return
if (!multiple)
return input.onChange(newFilesWithUrls.slice(0, 1))
return input.onChange([...values, ...newFilesWithUrls])
} catch (e) {
setErrors(e)
}
},
accept,
multiple,
capture,
ref: inputRef,
'aria-describedby': hasInputError(meta)
? generateInputErrorId(input.name)
: null,
}}
/>
{/* Include after input to allowing for styling with adjacent sibling selector */}
<label htmlFor={input.name} className="fileupload-exists">
{labelText}
</label>
</div>
</div>
</LabeledField>
)
}
// Do not reload files that have been successfully loaded
function removeExistingFiles(newFiles, existingFiles) {
return newFiles.filter((file) => {
return !existingFiles.some(({ name, lastModified }) => {
return name === file.name && lastModified === file.lastModified
})
})
}
function setInputErrors(meta, fieldWideErrors) {
if (meta.error || !fieldWideErrors) return meta
return {
...meta,
error: isString(fieldWideErrors)
? fieldWideErrors
: fieldWideErrors.message,
touched: true,
invalid: true,
}
}
// eslint-disable-next-line react/prop-types
function RenderPreview({
file,
thumbnail,
previewComponent: Component,
children,
...rest
}) {
if (Component) return <Component file={file} {...rest} />
if (children) return children
const renderImagePreview = isImageType(file) || thumbnail
if (renderImagePreview) return <ImagePreview image={file?.url || thumbnail} />
return <FilePreview name={file?.name} />
}
function RemoveButton({ file, onRemove }) {
return (
<button
type="button"
className="remove-file"
onClick={onRemove}
aria-label={`Remove ${file.name}`}
>
x
</button>
)
}
FileInput.propTypes = propTypes
FileInput.defaultProps = defaultProps
export default FileInput