From 9fe36d88b4afdbfcae532878704ea5d21556dc92 Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Fri, 11 Oct 2019 12:56:30 -0500 Subject: [PATCH 01/17] Initial proof-of-concept --- docs.md | 2 +- src/forms/inputs/cloudinary-file-input.js | 17 +-- src/forms/inputs/file-input/file-input.js | 128 ++++++++++++++-------- src/utils/index.js | 1 + 4 files changed, 97 insertions(+), 51 deletions(-) diff --git a/docs.md b/docs.md index 182986e4..6ab510f8 100644 --- a/docs.md +++ b/docs.md @@ -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`) diff --git a/src/forms/inputs/cloudinary-file-input.js b/src/forms/inputs/cloudinary-file-input.js index 798bd71d..bb30a409 100644 --- a/src/forms/inputs/cloudinary-file-input.js +++ b/src/forms/inputs/cloudinary-file-input.js @@ -62,15 +62,18 @@ function CloudinaryFileInput ({ uploadStatus, ...rest }) { - const { onChange } = input + // const { onChange } = input return ( upload(fileData, file) - .then((res) => { - onChange(res.url) - return onUploadSuccess(res) - }, (err) => onUploadFailure(err)) + input={ input } + onRead={({ fileData, file }) => { + return upload(fileData, file) + .then((res) => { + // onChange(res.url) + onUploadSuccess(res) + return { file, fileUpload: res } + }, (err) => onUploadFailure(err)) + } } className={ classnames(uploadStatus, className) } { ...rest } diff --git a/src/forms/inputs/file-input/file-input.js b/src/forms/inputs/file-input/file-input.js index 37ee0d19..ce34473b 100644 --- a/src/forms/inputs/file-input/file-input.js +++ b/src/forms/inputs/file-input/file-input.js @@ -4,7 +4,7 @@ import { buttonClasses, fieldPropTypes, hasInputError, isImageType, omitLabelPro import { LabeledField } from '../../labels' import FilePreview from './file-preview' import ImagePreview from './image-preview'; -import { noop, generateInputErrorId, removeAt } from '../../../utils' +import { get, noop, generateInputErrorId, removeAt, castArray, isString } from '../../../utils' /** * @@ -26,7 +26,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 @@ -50,7 +50,7 @@ import { noop, generateInputErrorId, removeAt } from '../../../utils' const propTypes = { ...fieldPropTypes, - onLoad: PropTypes.func, + onRead: PropTypes.func, thumbnail: PropTypes.string, hidePreview: PropTypes.bool, className: PropTypes.string, @@ -63,10 +63,11 @@ const propTypes = { const defaultProps = { multiple: false, - onLoad: noop, + onRead: noop, onRemove: noop, } +// Read a file and convert it to a base64 string (promisified) function readFile (file) { return new Promise((resolve, reject) => { const reader = new FileReader() @@ -83,63 +84,96 @@ class FileInput extends React.Component { constructor (props) { super(props) - this.state = { files: [] } - this.loadFiles = this.loadFiles.bind(this) + // this.state = { files: [] } // TODO: Should we be doing this? + this.readFiles = this.readFiles.bind(this) this.onChange = this.onChange.bind(this) this.removeFile = this.removeFile.bind(this) + + this.fileInput = null + this.setFileInputRef = element => { + this.fileInput = element + } + this.clearFileInput = () => { + // Focus the text input using the raw DOM API + if (this.fileInput) this.fileInput.value = "" + } } - loadFiles (e) { + readFiles (e) { + const existingFiles = castArray(get('value', this.props) || []) // or this.state.files + // 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 !existingFiles.some(({ name }) => name === file.name) }) return filesToLoad.map((file) => { return readFile(file) .then((fileData) => { - this.onChange(fileData, file) + // Pass metadata related to file, but not actual File object (properties are not enumerable / visible in Redux) + return this.onChange({ file: { name: file.name, size: file.size, type: file.type }, fileData }) }) }) } - // TODO: Should this fire once or per file? - onChange (fileData, file) { + async onChange (fileInfo) { // Call redux forms onChange and onLoad callback - const { input: { onChange, value }, onLoad, multiple } = this.props + 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] }) + // Only add value to form if successfully loads + try { + const result = await Promise.resolve(onRead(fileInfo)) // wrap in a promise (just in case) + + + // Add to array of multiple files are allowed, otherwise replace + const filesToKeep = multiple ? existingFiles : [] + const fileToAdd = result ? result : fileInfo + + onChange([...filesToKeep, fileToAdd]) + + } catch (e) { + // eslint-disable-next-line + console.error(e) } - // TODO: should this be required to fulfill successfully before firing onChange / set state? - onLoad(fileData, file) + // if (multiple) { + // onChange([...value, fileData]) + // this.setState((state) => { + // return { files: [ ...state.files, file ] } + // }) + // } else { + // onChange(fileData) + // this.setState({ files: [file] }) + // } + // onLoad(fileData, file) } - removeFile (idx) { + async removeFile (idx) { const { input: { onChange, value }, onRemove } = this.props - const [removedValue, remainingValues] = removeAt(value, idx) - const [removedFile, remainingFiles] = removeAt(this.state.files, idx) + const [removedFile, remainingFiles] = removeAt(value, idx) + // const [removedFile, remainingFiles] = removeAt(this.state.files, idx) - onChange(remainingValues) - onRemove(removedValue, removedFile) - this.setState({ files: remainingFiles }) + try { + await Promise.resolve(onRemove(removedFile)) // wrap in a promise (just in case) + onChange(remainingFiles) + + // If all files have been removed, then reset the native input + if (!remainingFiles.length) this.clearFileInput() + } catch (e) { + // do nothing -- or maybe throw a field error? + } + + // this.setState({ files: remainingFiles }) } 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, @@ -148,39 +182,47 @@ class FileInput extends React.Component { removeComponent: RemoveComponent = RemoveButton, ...rest } = omitLabelProps(this.props) - const { files } = this.state + // const { files } = this.state const wrapperClass = buttonClasses({ style: 'secondary-light', submitting }) const labelText = multiple ? 'Select File(s)' : 'Select File' + const values = castArray(value || []) + return (
{ !hidePreview && - files.map((file, idx) => ( -
- - { multiple && this.removeFile(idx) } /> } -
- )) + values.map((value, idx) => { + const fileValue = isString(value) ? value : (get('fileUpload.url', value) || get('fileData', value)) + const file = isString(value) ? { name: 'Uploaded File' } : get('file', value) + + return ( +
+ + { multiple && this.removeFile(idx) } /> } +
+ ) + }) }
{ labelText } - { e.target.value = "" }, // force onChange to fire every time - onChange: this.loadFiles, + onClick: this.clearFileInput, // force onChange to fire _every_ time (use case: attempting to upload the same file after a failure) + onChange: this.readFiles, accept, multiple, 'aria-labelledby': name + '-label', 'aria-describedby': hasInputError(meta) ? generateInputErrorId(name) : null, + ref: this.setFileInputRef, }} />
diff --git a/src/utils/index.js b/src/utils/index.js index dd175856..dbab2f93 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -6,6 +6,7 @@ export { startCase, range, noop, + isString, union as addToArray, xor as removeFromArray, } from 'lodash' From f349e9e0eb427f65a7c3bc0156a7291b0dda8b99 Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Fri, 11 Oct 2019 14:15:52 -0500 Subject: [PATCH 02/17] Clean up logic and add dev hints via prop types --- src/forms/helpers/field-prop-types.js | 15 ++++ src/forms/inputs/cloudinary-file-input.js | 4 +- src/forms/inputs/file-input/file-input.js | 95 ++++++++++++----------- src/utils/index.js | 1 + 4 files changed, 69 insertions(+), 46 deletions(-) diff --git a/src/forms/helpers/field-prop-types.js b/src/forms/helpers/field-prop-types.js index 9eb61dcb..cae19857 100644 --- a/src/forms/helpers/field-prop-types.js +++ b/src/forms/helpers/field-prop-types.js @@ -128,3 +128,18 @@ 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.oneOf([ + file, + PropTypes.arrayOf(file).isRequired, + ]).isRequired, +) diff --git a/src/forms/inputs/cloudinary-file-input.js b/src/forms/inputs/cloudinary-file-input.js index bb30a409..3f006608 100644 --- a/src/forms/inputs/cloudinary-file-input.js +++ b/src/forms/inputs/cloudinary-file-input.js @@ -1,7 +1,7 @@ import React from 'react' import PropTypes from 'prop-types' import FileInput from './file-input' -import { fieldPropTypes } from '../helpers' +import { fileInputPropTypes } from '../helpers' import { compose, cloudinaryUploader, noop } from '../../utils' import classnames from 'classnames' @@ -41,7 +41,7 @@ import classnames from 'classnames' */ const propTypes = { - ...fieldPropTypes, + ...fileInputPropTypes, onUploadFailure: PropTypes.func, onUploadSuccess: PropTypes.func, upload: PropTypes.func.isRequired, diff --git a/src/forms/inputs/file-input/file-input.js b/src/forms/inputs/file-input/file-input.js index ce34473b..6c247b9a 100644 --- a/src/forms/inputs/file-input/file-input.js +++ b/src/forms/inputs/file-input/file-input.js @@ -1,10 +1,10 @@ import React from 'react' import PropTypes from 'prop-types' -import { buttonClasses, fieldPropTypes, hasInputError, isImageType, omitLabelProps } from '../../helpers' +import { buttonClasses, fileInputPropTypes, hasInputError, isImageType, omitLabelProps } from '../../helpers' import { LabeledField } from '../../labels' import FilePreview from './file-preview' import ImagePreview from './image-preview'; -import { get, noop, generateInputErrorId, removeAt, castArray, isString } from '../../../utils' +import { first, get, noop, generateInputErrorId, removeAt, castArray, isString, identity } from '../../../utils' /** * @@ -49,7 +49,7 @@ import { get, noop, generateInputErrorId, removeAt, castArray, isString } from ' */ const propTypes = { - ...fieldPropTypes, + ...fileInputPropTypes, onRead: PropTypes.func, thumbnail: PropTypes.string, hidePreview: PropTypes.bool, @@ -63,7 +63,7 @@ const propTypes = { const defaultProps = { multiple: false, - onRead: noop, + onRead: identity, onRemove: noop, } @@ -80,11 +80,39 @@ function readFile (file) { }) } +// Copy metadata related to a file, but not the actual File object. These +// properties are not enumerable / visible in Redux. +function deepCloneFile (file) { + return { + name: file.name, + size: file.size, + type: file.type, + lastModified: file.lastModified, + } +} + +function castToFileArray (values) { + if (!values) return [] + return castArray(values) +} + +function getFileObject (fileInfo) { + if (isString(fileInfo)) return ({ name: 'Uploaded File' }) + return fileInfo.file +} + +// Checks for url and then data (if not a string) +function getFileValue (fileInfo) { + if (isString(fileInfo)) return fileInfo + return get('fileUpload.url', fileInfo) || get('fileData', fileInfo) +} + + class FileInput extends React.Component { constructor (props) { super(props) - // this.state = { files: [] } // TODO: Should we be doing this? + this.readFiles = this.readFiles.bind(this) this.onChange = this.onChange.bind(this) this.removeFile = this.removeFile.bind(this) @@ -94,16 +122,13 @@ class FileInput extends React.Component { this.fileInput = element } this.clearFileInput = () => { - // Focus the text input using the raw DOM API if (this.fileInput) this.fileInput.value = "" } } readFiles (e) { - const existingFiles = castArray(get('value', this.props) || []) // or this.state.files - - // Read files as data URL and call change handlers - const files = [...e.target.files] // when multiple=false, `files` is still array-like + const files = [...e.target.files] + const existingFiles = castToFileArray(this.props.input.value) // Do not reload files that have been successfully loaded const filesToLoad = files.filter((file) => { @@ -113,60 +138,43 @@ class FileInput extends React.Component { return filesToLoad.map((file) => { return readFile(file) .then((fileData) => { - // Pass metadata related to file, but not actual File object (properties are not enumerable / visible in Redux) - return this.onChange({ file: { name: file.name, size: file.size, type: file.type }, fileData }) + return this.onChange({ file: deepCloneFile(file), fileData }) }) }) } async onChange (fileInfo) { - // Call redux forms onChange and onLoad callback const { input: { onChange, value: existingFiles }, onRead, multiple } = this.props - // Only add value to form if successfully loads try { - const result = await Promise.resolve(onRead(fileInfo)) // wrap in a promise (just in case) - - - // Add to array of multiple files are allowed, otherwise replace - const filesToKeep = multiple ? existingFiles : [] - const fileToAdd = result ? result : fileInfo - - onChange([...filesToKeep, fileToAdd]) + // Only add value to form if successfully loads + const fileToAdd = await Promise.resolve(onRead(fileInfo)) // wrap in a promise (just in case) + if (!multiple) return onChange(fileToAdd) + return onChange([...existingFiles, fileToAdd]) } catch (e) { // eslint-disable-next-line console.error(e) } - - // if (multiple) { - // onChange([...value, fileData]) - // this.setState((state) => { - // return { files: [ ...state.files, file ] } - // }) - // } else { - // onChange(fileData) - // this.setState({ files: [file] }) - // } - // onLoad(fileData, file) } async removeFile (idx) { - const { input: { onChange, value }, onRemove } = this.props + const { input: { onChange, value }, multiple, onRemove } = this.props const [removedFile, remainingFiles] = removeAt(value, idx) - // const [removedFile, remainingFiles] = removeAt(this.state.files, idx) try { await Promise.resolve(onRemove(removedFile)) // wrap in a promise (just in case) - onChange(remainingFiles) // If all files have been removed, then reset the native input if (!remainingFiles.length) this.clearFileInput() + + if (multiple) return onChange(remainingFiles) + + return onChange(first(remainingFiles)) } catch (e) { - // do nothing -- or maybe throw a field error? + // eslint-disable-next-line + console.error(e) } - - // this.setState({ files: remainingFiles }) } render () { @@ -182,11 +190,9 @@ class FileInput extends React.Component { removeComponent: RemoveComponent = RemoveButton, ...rest } = omitLabelProps(this.props) - // const { files } = this.state const wrapperClass = buttonClasses({ style: 'secondary-light', submitting }) const labelText = multiple ? 'Select File(s)' : 'Select File' - - const values = castArray(value || []) + const values = castToFileArray(value) return ( @@ -194,8 +200,9 @@ class FileInput extends React.Component { { !hidePreview && values.map((value, idx) => { - const fileValue = isString(value) ? value : (get('fileUpload.url', value) || get('fileData', value)) - const file = isString(value) ? { name: 'Uploaded File' } : get('file', value) + // Maintain basic backwards compatability by accepting a string value and coercing it into the right shapee + const file = getFileObject(value) + const fileValue = getFileValue(value) return (
diff --git a/src/utils/index.js b/src/utils/index.js index dbab2f93..908fbc94 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,6 +1,7 @@ export { castArray, has, + first, identity, isNil, startCase, From fc076a0f9b74fb8b7712fa6e91354f67bd549838 Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Fri, 11 Oct 2019 14:19:55 -0500 Subject: [PATCH 03/17] Always return an array --- src/forms/helpers/field-prop-types.js | 5 +---- src/forms/inputs/file-input/file-input.js | 9 ++++----- src/utils/index.js | 1 - 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/forms/helpers/field-prop-types.js b/src/forms/helpers/field-prop-types.js index cae19857..84cd03a6 100644 --- a/src/forms/helpers/field-prop-types.js +++ b/src/forms/helpers/field-prop-types.js @@ -138,8 +138,5 @@ const file = PropTypes.shape({ }) export const fileInputPropTypes = fieldPropTypesWithValue( - PropTypes.oneOf([ - file, - PropTypes.arrayOf(file).isRequired, - ]).isRequired, + PropTypes.arrayOf(file).isRequired, ) diff --git a/src/forms/inputs/file-input/file-input.js b/src/forms/inputs/file-input/file-input.js index 6c247b9a..589e3e01 100644 --- a/src/forms/inputs/file-input/file-input.js +++ b/src/forms/inputs/file-input/file-input.js @@ -4,7 +4,7 @@ import { buttonClasses, fileInputPropTypes, hasInputError, isImageType, omitLabe import { LabeledField } from '../../labels' import FilePreview from './file-preview' import ImagePreview from './image-preview'; -import { first, get, noop, generateInputErrorId, removeAt, castArray, isString, identity } from '../../../utils' +import { get, noop, generateInputErrorId, removeAt, castArray, isString, identity } from '../../../utils' /** * @@ -149,9 +149,9 @@ class FileInput extends React.Component { try { // Only add value to form if successfully loads const fileToAdd = await Promise.resolve(onRead(fileInfo)) // wrap in a promise (just in case) + const filesToKeep = multiple ? existingFiles : [] // overwrite existing files for a single file input - if (!multiple) return onChange(fileToAdd) - return onChange([...existingFiles, fileToAdd]) + return onChange([...filesToKeep, fileToAdd]) } catch (e) { // eslint-disable-next-line console.error(e) @@ -169,8 +169,7 @@ class FileInput extends React.Component { if (!remainingFiles.length) this.clearFileInput() if (multiple) return onChange(remainingFiles) - - return onChange(first(remainingFiles)) + return onChange([]) } catch (e) { // eslint-disable-next-line console.error(e) diff --git a/src/utils/index.js b/src/utils/index.js index 908fbc94..dbab2f93 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,7 +1,6 @@ export { castArray, has, - first, identity, isNil, startCase, From 32fbdde35169d56bb369caeb998d18d51549c0c9 Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Fri, 11 Oct 2019 14:54:08 -0500 Subject: [PATCH 04/17] Read files synchronously to avoid overwrites --- src/forms/inputs/file-input/file-input.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/forms/inputs/file-input/file-input.js b/src/forms/inputs/file-input/file-input.js index 589e3e01..5dcae377 100644 --- a/src/forms/inputs/file-input/file-input.js +++ b/src/forms/inputs/file-input/file-input.js @@ -126,7 +126,7 @@ class FileInput extends React.Component { } } - readFiles (e) { + async readFiles (e) { const files = [...e.target.files] const existingFiles = castToFileArray(this.props.input.value) @@ -135,12 +135,17 @@ class FileInput extends React.Component { return !existingFiles.some(({ name }) => name === file.name) }) - return filesToLoad.map((file) => { - return readFile(file) - .then((fileData) => { - return this.onChange({ file: deepCloneFile(file), fileData }) - }) - }) + // Read files synchronously to ensure that files are not written over when referencing the current value of the input + // eslint-disable-next-line + for (const fileToLoad of filesToLoad) { + try { + const fileData = await readFile(fileToLoad) + await this.onChange({ file: deepCloneFile(fileToLoad), fileData }) + } catch (e) { + // eslint-disable-next-line + console.error('Could not read '+file.name, e) + } + } } async onChange (fileInfo) { From e0d1ec331aed4e49643ab5d26a09c642aa41b6bf Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Fri, 11 Oct 2019 17:23:14 -0500 Subject: [PATCH 05/17] Modify markup to allow for focus styling --- .storybook/styles/components/_forms.scss | 4 +-- src/forms/inputs/file-input/file-input.js | 35 ++++++++++++----------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/.storybook/styles/components/_forms.scss b/.storybook/styles/components/_forms.scss index d21ee7bd..9f1865c8 100755 --- a/.storybook/styles/components/_forms.scss +++ b/.storybook/styles/components/_forms.scss @@ -301,7 +301,7 @@ Uploader } } - .button-secondary-light>input{ + input[type="file"]{ position:absolute; top:0; right:0; @@ -572,4 +572,4 @@ Color Picker .hex{ @include position(absolute, 35px null null 38px); } -} \ No newline at end of file +} diff --git a/src/forms/inputs/file-input/file-input.js b/src/forms/inputs/file-input/file-input.js index 5dcae377..2f8b49b8 100644 --- a/src/forms/inputs/file-input/file-input.js +++ b/src/forms/inputs/file-input/file-input.js @@ -5,6 +5,7 @@ import { LabeledField } from '../../labels' import FilePreview from './file-preview' import ImagePreview from './image-preview'; import { get, noop, generateInputErrorId, removeAt, castArray, isString, identity } from '../../../utils' +import classnames from 'classnames' /** * @@ -107,7 +108,6 @@ function getFileValue (fileInfo) { return get('fileUpload.url', fileInfo) || get('fileData', fileInfo) } - class FileInput extends React.Component { constructor (props) { @@ -220,22 +220,23 @@ class FileInput extends React.Component { ) }) } -
- { labelText } - +
+ + {/* Include after input to allowing for styling with adjacent sibling selector */} + { labelText }
From 367f116276f7dbab74ab07ea0e60a9a8692ee3d9 Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Mon, 14 Oct 2019 14:03:47 -0500 Subject: [PATCH 06/17] Code review feedback --- src/forms/inputs/file-input/file-input.js | 122 +++++--------------- src/forms/inputs/file-input/file-preview.js | 11 +- src/forms/inputs/file-input/helpers.js | 49 ++++++++ src/utils/index.js | 1 + 4 files changed, 85 insertions(+), 98 deletions(-) create mode 100644 src/forms/inputs/file-input/helpers.js diff --git a/src/forms/inputs/file-input/file-input.js b/src/forms/inputs/file-input/file-input.js index 2f8b49b8..010c729e 100644 --- a/src/forms/inputs/file-input/file-input.js +++ b/src/forms/inputs/file-input/file-input.js @@ -1,14 +1,14 @@ import React from 'react' import PropTypes from 'prop-types' import { buttonClasses, fileInputPropTypes, hasInputError, isImageType, omitLabelProps } from '../../helpers' -import { LabeledField } from '../../labels' +import { InputError, LabeledField } from '../../labels' import FilePreview from './file-preview' import ImagePreview from './image-preview'; -import { get, noop, generateInputErrorId, removeAt, castArray, isString, identity } 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. * @@ -59,61 +59,21 @@ const propTypes = { children: PropTypes.node, multiple: PropTypes.bool, onRemove: PropTypes.func, - removeText: PropTypes.string, + selectText: PropTypes.string, } const defaultProps = { multiple: false, onRead: identity, onRemove: noop, -} - -// Read a file and convert it to a base64 string (promisified) -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) - }) -} - -// Copy metadata related to a file, but not the actual File object. These -// properties are not enumerable / visible in Redux. -function deepCloneFile (file) { - return { - name: file.name, - size: file.size, - type: file.type, - lastModified: file.lastModified, - } -} - -function castToFileArray (values) { - if (!values) return [] - return castArray(values) -} - -function getFileObject (fileInfo) { - if (isString(fileInfo)) return ({ name: 'Uploaded File' }) - return fileInfo.file -} - -// Checks for url and then data (if not a string) -function getFileValue (fileInfo) { - if (isString(fileInfo)) return fileInfo - return get('fileUpload.url', fileInfo) || get('fileData', fileInfo) + selectText: '', } class FileInput extends React.Component { - constructor (props) { super(props) - this.readFiles = this.readFiles.bind(this) + this.state = { errors: null } this.onChange = this.onChange.bind(this) this.removeFile = this.removeFile.bind(this) @@ -125,41 +85,20 @@ class FileInput extends React.Component { if (this.fileInput) this.fileInput.value = "" } } - - async readFiles (e) { - const files = [...e.target.files] - const existingFiles = castToFileArray(this.props.input.value) - - // Do not reload files that have been successfully loaded - const filesToLoad = files.filter((file) => { - return !existingFiles.some(({ name }) => name === file.name) - }) - - // Read files synchronously to ensure that files are not written over when referencing the current value of the input - // eslint-disable-next-line - for (const fileToLoad of filesToLoad) { - try { - const fileData = await readFile(fileToLoad) - await this.onChange({ file: deepCloneFile(fileToLoad), fileData }) - } catch (e) { - // eslint-disable-next-line - console.error('Could not read '+file.name, e) - } - } - } - async onChange (fileInfo) { + async onChange (fileValues) { const { input: { onChange, value: existingFiles }, onRead, multiple } = this.props try { - // Only add value to form if successfully loads - const fileToAdd = await Promise.resolve(onRead(fileInfo)) // wrap in a promise (just in case) - const filesToKeep = multiple ? existingFiles : [] // overwrite existing files for a single file input - - return onChange([...filesToKeep, fileToAdd]) + // Only add value to form if file(s) successfully load + const filesToAdd = await Promise.resolve(onRead(fileValues)) // wrap in a promise (just in case) + 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 }) } } @@ -174,10 +113,11 @@ class FileInput extends React.Component { if (!remainingFiles.length) this.clearFileInput() if (multiple) return onChange(remainingFiles) - return onChange([]) + return onChange(null) } catch (e) { // eslint-disable-next-line console.error(e) + this.setState({ errors: e }) } } @@ -192,10 +132,12 @@ class FileInput extends React.Component { hidePreview, multiple, removeComponent: RemoveComponent = RemoveButton, + selectText, ...rest } = omitLabelProps(this.props) + 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 ( @@ -204,17 +146,9 @@ class FileInput extends React.Component { { !hidePreview && values.map((value, idx) => { - // Maintain basic backwards compatability by accepting a string value and coercing it into the right shapee - const file = getFileObject(value) - const fileValue = getFileValue(value) - return ( -
- +
+ { multiple && this.removeFile(idx) } /> }
) @@ -227,7 +161,11 @@ class FileInput extends React.Component { name, type: 'file', onClick: this.clearFileInput, // force onChange to fire _every_ time (use case: attempting to upload the same file after a failure) - onChange: this.readFiles, + 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', @@ -238,6 +176,7 @@ class FileInput extends React.Component { {/* Include after input to allowing for styling with adjacent sibling selector */} { labelText }
+ { errors && }
) @@ -246,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 + if (Component) return if (children) return children - const renderImagePreview = isImageType(file) || thumbnail - if (renderImagePreview) return - return + const renderImagePreview = isImageType(value.type) || thumbnail + if (renderImagePreview) return + return } function RemoveButton ({ onRemove }) { diff --git a/src/forms/inputs/file-input/file-preview.js b/src/forms/inputs/file-input/file-preview.js index fcf30a7c..535f815f 100644 --- a/src/forms/inputs/file-input/file-preview.js +++ b/src/forms/inputs/file-input/file-preview.js @@ -4,20 +4,19 @@ import PropTypes from 'prop-types' // Default FileInput preview component for non-image files const propTypes = { - file: PropTypes.object, + name: PropTypes.string, } const defaultProps = { - file: {}, + name: '', } -function FilePreview ({ file }) { - if (!file) return null - return

{ file.name }

+function FilePreview ({ name }) { + if (!name) return null + return

{ name }

} FilePreview.propTypes = propTypes - FilePreview.defaultProps = defaultProps export default FilePreview diff --git a/src/forms/inputs/file-input/helpers.js b/src/forms/inputs/file-input/helpers.js new file mode 100644 index 00000000..75013614 --- /dev/null +++ b/src/forms/inputs/file-input/helpers.js @@ -0,0 +1,49 @@ +import { castArray } from '../../../utils' + +export async function readFiles ({ newFiles, existingFiles }) { + // Do not reload files that have been successfully loaded + const filesToLoad = newFiles.filter((file) => { + return !existingFiles.some(({ name, lastModified }) => { + return name === file.name && lastModified === file.lastModified + }) + }) + + const filePromises = filesToLoad.map(async (file) => { + const fileData = await readFileData(file) + return createFileValueObject(file, fileData) + }) + + return Promise.all(filePromises) +} + +export function castToFileArray (values) { + if (!values) return [] + return castArray(values) +} + +// ----- PRIVATE ------ + +// Read a file and convert it to a base64 string (promisified) +function readFileData (file) { + return new Promise((resolve, reject) => { + const reader = new 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, + } +} diff --git a/src/utils/index.js b/src/utils/index.js index dbab2f93..908fbc94 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,6 +1,7 @@ export { castArray, has, + first, identity, isNil, startCase, From 864974b9cb05b8f72f60ffc8337ec43eeff91f48 Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Mon, 14 Oct 2019 14:04:14 -0500 Subject: [PATCH 07/17] Make cloudinary accept a file input and set the response appropriately --- src/forms/inputs/cloudinary-file-input.js | 41 ++++++++++++++--------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/src/forms/inputs/cloudinary-file-input.js b/src/forms/inputs/cloudinary-file-input.js index 3f006608..7fa53b48 100644 --- a/src/forms/inputs/cloudinary-file-input.js +++ b/src/forms/inputs/cloudinary-file-input.js @@ -1,12 +1,11 @@ import React from 'react' import PropTypes from 'prop-types' -import FileInput from './file-input' +import DefaultFileInput from './file-input' import { fileInputPropTypes } from '../helpers' -import { compose, cloudinaryUploader, noop } from '../../utils' +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. @@ -53,28 +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 ( { - return upload(fileData, file) - .then((res) => { - // onChange(res.url) - onUploadSuccess(res) - return { file, fileUpload: res } - }, (err) => onUploadFailure(err)) + 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) { + onUploadFailure(e) } - } + }} className={ classnames(uploadStatus, className) } { ...rest } /> From 25b0c50268ab0734540b140125284ab471337b81 Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Mon, 14 Oct 2019 18:31:59 -0500 Subject: [PATCH 08/17] Extract helpers into shared folder --- docs.md | 1 - src/forms/helpers/cast-form-value-to-array.js | 9 +++++ src/forms/helpers/index.js | 2 + src/forms/helpers/read-files-as-data-urls.js | 39 +++++++++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/forms/helpers/cast-form-value-to-array.js create mode 100644 src/forms/helpers/read-files-as-data-urls.js diff --git a/docs.md b/docs.md index 6ab510f8..5b96dbf4 100644 --- a/docs.md +++ b/docs.md @@ -636,7 +636,6 @@ 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`) -- `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`) diff --git a/src/forms/helpers/cast-form-value-to-array.js b/src/forms/helpers/cast-form-value-to-array.js new file mode 100644 index 00000000..95395e41 --- /dev/null +++ b/src/forms/helpers/cast-form-value-to-array.js @@ -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 diff --git a/src/forms/helpers/index.js b/src/forms/helpers/index.js index 4d84384a..c26d029d 100644 --- a/src/forms/helpers/index.js +++ b/src/forms/helpers/index.js @@ -1,5 +1,6 @@ export blurDirty from './blur-dirty' export buttonClasses from './button-classes' +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' @@ -9,3 +10,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' diff --git a/src/forms/helpers/read-files-as-data-urls.js b/src/forms/helpers/read-files-as-data-urls.js new file mode 100644 index 00000000..4cb0c318 --- /dev/null +++ b/src/forms/helpers/read-files-as-data-urls.js @@ -0,0 +1,39 @@ +// 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) => { + const reader = new 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 + From a7dac14050007371d9198891595814bb4a25953e Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Mon, 14 Oct 2019 18:33:23 -0500 Subject: [PATCH 09/17] Override read files utility and clean up based on code review --- src/forms/inputs/cloudinary-file-input.js | 22 ++++-- src/forms/inputs/file-input/file-input.js | 94 +++++++++++++---------- src/forms/inputs/file-input/helpers.js | 49 ------------ 3 files changed, 69 insertions(+), 96 deletions(-) delete mode 100644 src/forms/inputs/file-input/helpers.js diff --git a/src/forms/inputs/cloudinary-file-input.js b/src/forms/inputs/cloudinary-file-input.js index 7fa53b48..acc23e43 100644 --- a/src/forms/inputs/cloudinary-file-input.js +++ b/src/forms/inputs/cloudinary-file-input.js @@ -1,7 +1,7 @@ import React from 'react' import PropTypes from 'prop-types' import DefaultFileInput from './file-input' -import { fileInputPropTypes } from '../helpers' +import { fileInputPropTypes, readFilesAsDataUrls } from '../helpers' import { compose, cloudinaryUploader, noop, set } from '../../utils' import classnames from 'classnames' @@ -41,6 +41,7 @@ import classnames from 'classnames' const propTypes = { ...fileInputPropTypes, + fileInput: PropTypes.func, onUploadFailure: PropTypes.func, onUploadSuccess: PropTypes.func, upload: PropTypes.func.isRequired, @@ -48,6 +49,7 @@ const propTypes = { } const defaultProps = { + fileInput: DefaultFileInput, onUploadSuccess: noop, onUploadFailure: noop, } @@ -66,25 +68,29 @@ function CloudinaryFileInput ({ onUploadSuccess, upload, uploadStatus, - fileInput: FileInput = DefaultFileInput, - ...rest + fileInput: FileInput, + ...rest }) { return ( { + readFiles={async (files) => { + let uploadedFiles = null try { - const uploadFilePromises = files.map(async (file) => { + const filesWithDataUrls = await readFilesAsDataUrls(files) + const uploadFilePromises = filesWithDataUrls.map(async (file) => { const cloudinaryRes = await upload(file.url, file) return mapCloudinaryResponse(file, cloudinaryRes) }) - const uploadedFiles = await Promise.all(uploadFilePromises) - onUploadSuccess(uploadedFiles) - return uploadedFiles + uploadedFiles = await Promise.all(uploadFilePromises) } catch (e) { onUploadFailure(e) + throw e } + + onUploadSuccess(uploadedFiles) + return input.onChange(uploadedFiles) }} className={ classnames(uploadStatus, className) } { ...rest } diff --git a/src/forms/inputs/file-input/file-input.js b/src/forms/inputs/file-input/file-input.js index 010c729e..81e6ce5c 100644 --- a/src/forms/inputs/file-input/file-input.js +++ b/src/forms/inputs/file-input/file-input.js @@ -1,12 +1,19 @@ import React from 'react' import PropTypes from 'prop-types' -import { buttonClasses, fileInputPropTypes, hasInputError, isImageType, omitLabelProps } from '../../helpers' -import { InputError, LabeledField } from '../../labels' +import { + buttonClasses, + castFormValueToArray, + fileInputPropTypes, + hasInputError, + isImageType, + omitLabelProps, + readFilesAsDataUrls, +} from '../../helpers' +import { LabeledField } from '../../labels' import FilePreview from './file-preview' import ImagePreview from './image-preview'; -import { first, noop, generateInputErrorId, removeAt, identity } from '../../../utils' +import { first, noop, generateInputErrorId, removeAt } from '../../../utils' import classnames from 'classnames' -import { castToFileArray, readFiles } from './helpers' /** * A file input that can be used in a `redux-forms`-controlled form. @@ -27,7 +34,6 @@ import { castToFileArray, readFiles } from './helpers' * @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} [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 @@ -51,7 +57,6 @@ import { castToFileArray, readFiles } from './helpers' const propTypes = { ...fileInputPropTypes, - onRead: PropTypes.func, thumbnail: PropTypes.string, hidePreview: PropTypes.bool, className: PropTypes.string, @@ -59,13 +64,16 @@ const propTypes = { children: PropTypes.node, multiple: PropTypes.bool, onRemove: PropTypes.func, + readFiles: PropTypes.func, + removeComponent: PropTypes.func, selectText: PropTypes.string, } const defaultProps = { multiple: false, - onRead: identity, onRemove: noop, + readFiles: readFilesAsDataUrls, + removeComponent: RemoveButton, selectText: '', } @@ -74,7 +82,6 @@ class FileInput extends React.Component { super(props) this.state = { errors: null } - this.onChange = this.onChange.bind(this) this.removeFile = this.removeFile.bind(this) this.fileInput = null @@ -86,22 +93,6 @@ class FileInput extends React.Component { } } - async onChange (fileValues) { - const { input: { onChange, value: existingFiles }, onRead, multiple } = this.props - - 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) - 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 }) - } - } - async removeFile (idx) { const { input: { onChange, value }, multiple, onRemove } = this.props const [removedFile, remainingFiles] = removeAt(value, idx) @@ -115,41 +106,39 @@ class FileInput extends React.Component { 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 - onRead, // eslint-disable-line no-unused-vars + input: { name, onChange, value }, + meta, className, // eslint-disable-line no-unused-vars submitting, accept, hidePreview, multiple, - removeComponent: RemoveComponent = RemoveButton, + readFiles, + removeComponent: RemoveComponent, selectText, ...rest } = omitLabelProps(this.props) - const { errors } = this.state + const inputMeta = setInputErrors(meta, this.state.errors) const wrapperClass = buttonClasses({ style: 'secondary-light', submitting }) const labelText = selectText || (multiple ? 'Select File(s)' : 'Select File') - const values = castToFileArray(value) + const values = castFormValueToArray(value) return ( - +
{ !hidePreview && values.map((value, idx) => { return ( -
+
- { multiple && this.removeFile(idx) } /> } + { multiple && this.removeFile(idx) } /> }
) }) @@ -163,8 +152,17 @@ class FileInput extends React.Component { 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) + try { + const files = [...e.target.files] + const newFiles = removeExistingFiles(files, values) + const filesWithUrls = await readFiles(newFiles) + + if (!filesWithUrls) return + if (!multiple) return onChange(first(filesWithUrls)) + return onChange(filesWithUrls) + } catch (e) { + this.setState({ errors: e }) + } }, accept, multiple, @@ -174,15 +172,33 @@ class FileInput extends React.Component { }} /> {/* Include after input to allowing for styling with adjacent sibling selector */} - { labelText } + { labelText }
- { errors && }
) } } +// 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: fieldWideErrors.message, + touched: true, + invalid: true, + } +} + // eslint-disable-next-line react/prop-types function RenderPreview ({ value, diff --git a/src/forms/inputs/file-input/helpers.js b/src/forms/inputs/file-input/helpers.js deleted file mode 100644 index 75013614..00000000 --- a/src/forms/inputs/file-input/helpers.js +++ /dev/null @@ -1,49 +0,0 @@ -import { castArray } from '../../../utils' - -export async function readFiles ({ newFiles, existingFiles }) { - // Do not reload files that have been successfully loaded - const filesToLoad = newFiles.filter((file) => { - return !existingFiles.some(({ name, lastModified }) => { - return name === file.name && lastModified === file.lastModified - }) - }) - - const filePromises = filesToLoad.map(async (file) => { - const fileData = await readFileData(file) - return createFileValueObject(file, fileData) - }) - - return Promise.all(filePromises) -} - -export function castToFileArray (values) { - if (!values) return [] - return castArray(values) -} - -// ----- PRIVATE ------ - -// Read a file and convert it to a base64 string (promisified) -function readFileData (file) { - return new Promise((resolve, reject) => { - const reader = new 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, - } -} From 9235b964bb14353d25f9f1cc5a5bf891d028203f Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Mon, 14 Oct 2019 18:33:49 -0500 Subject: [PATCH 10/17] filter invalid props from getting passed to error label (incl. label = false) --- src/forms/labels/input-error.js | 4 ++-- src/forms/labels/labeled-field.js | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/forms/labels/input-error.js b/src/forms/labels/input-error.js index b98b409a..47a4a0ee 100644 --- a/src/forms/labels/input-error.js +++ b/src/forms/labels/input-error.js @@ -1,7 +1,7 @@ import React from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' -import { generateInputErrorId } from '../../utils' +import { filterInvalidDOMProps, generateInputErrorId } from '../../utils' import { hasInputError } from '../helpers' /** @@ -73,7 +73,7 @@ function InputError ({ error, invalid, touched, name, className, ...rest }) { ? { formatError(error) } diff --git a/src/forms/labels/labeled-field.js b/src/forms/labels/labeled-field.js index e88cebb7..f3704415 100644 --- a/src/forms/labels/labeled-field.js +++ b/src/forms/labels/labeled-field.js @@ -87,11 +87,12 @@ function LabeledField ({ labelComponent: LabelComponent = InputLabel, children, hideErrorLabel, + label, ...rest }) { return (
- + { children } { !hideErrorLabel && }
From 4eb491fa567e6124ab05b56776ac59e9e430c3c6 Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Tue, 15 Oct 2019 07:08:49 -0500 Subject: [PATCH 11/17] Return files on cloudinary read --- src/forms/inputs/cloudinary-file-input.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forms/inputs/cloudinary-file-input.js b/src/forms/inputs/cloudinary-file-input.js index acc23e43..f51ab53b 100644 --- a/src/forms/inputs/cloudinary-file-input.js +++ b/src/forms/inputs/cloudinary-file-input.js @@ -90,7 +90,7 @@ function CloudinaryFileInput ({ } onUploadSuccess(uploadedFiles) - return input.onChange(uploadedFiles) + return uploadedFiles }} className={ classnames(uploadStatus, className) } { ...rest } From 997e117ce34698e130aac76eac69dec9021a79d9 Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Tue, 15 Oct 2019 07:44:44 -0500 Subject: [PATCH 12/17] Code clean-up --- src/forms/inputs/file-input/file-input.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/forms/inputs/file-input/file-input.js b/src/forms/inputs/file-input/file-input.js index 81e6ce5c..03be5854 100644 --- a/src/forms/inputs/file-input/file-input.js +++ b/src/forms/inputs/file-input/file-input.js @@ -12,7 +12,7 @@ import { import { LabeledField } from '../../labels' import FilePreview from './file-preview' import ImagePreview from './image-preview'; -import { first, noop, generateInputErrorId, removeAt } from '../../../utils' +import { first, noop, generateInputErrorId, isString, removeAt } from '../../../utils' import classnames from 'classnames' /** @@ -98,7 +98,7 @@ class FileInput extends React.Component { const [removedFile, remainingFiles] = removeAt(value, idx) try { - await Promise.resolve(onRemove(removedFile)) // wrap in a promise (just in case) + await onRemove(removedFile) // If all files have been removed, then reset the native input if (!remainingFiles.length) this.clearFileInput() @@ -166,13 +166,12 @@ class FileInput extends React.Component { }, accept, multiple, - 'aria-labelledby': name + '-label', - 'aria-describedby': hasInputError(meta) ? generateInputErrorId(name) : null, ref: this.setFileInputRef, + 'aria-describedby': hasInputError(meta) ? generateInputErrorId(name) : null, }} /> {/* Include after input to allowing for styling with adjacent sibling selector */} - { labelText } +
@@ -193,7 +192,7 @@ function setInputErrors (meta, fieldWideErrors) { if (meta.error || !fieldWideErrors) return meta return { ...meta, - error: fieldWideErrors.message, + error: isString(fieldWideErrors) ? fieldWideErrors : fieldWideErrors.message, touched: true, invalid: true, } From 8cac28ddf0d3dee858d7dc2bb1d57c8aa75942e6 Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Tue, 15 Oct 2019 07:52:48 -0500 Subject: [PATCH 13/17] Update CloudinaryFileInput docs --- docs.md | 11 +++++++---- src/forms/inputs/cloudinary-file-input.js | 12 +++++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs.md b/docs.md index 5b96dbf4..68c400ff 100644 --- a/docs.md +++ b/docs.md @@ -471,8 +471,10 @@ export default TodoForm ## CloudinaryFileInput -A wrapper around the [FileInput][46] component that automatically uploads files to cloudinary via the [cloudinaryUploader][145] 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, @@ -482,8 +484,9 @@ or via the `CLOUDINARY_CLOUD_NAME` and `CLOUDINARY_BUCKET` env vars (recommended - `input` **[Object][142]** A `redux-forms` [input][140] object - `meta` **[Object][142]** A `redux-forms` [meta][143] object -- `onUploadSuccess` **[Function][135]?** A handler that gets invoked with the response from a successful upload to Cloudinary -- `onUploadFailure` **[Function][135]?** A handler that gets invoked with the error from a failed upload to Cloudinary +- `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 diff --git a/src/forms/inputs/cloudinary-file-input.js b/src/forms/inputs/cloudinary-file-input.js index f51ab53b..4cb4b2ab 100644 --- a/src/forms/inputs/cloudinary-file-input.js +++ b/src/forms/inputs/cloudinary-file-input.js @@ -6,20 +6,22 @@ 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. + * 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. + * * 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 {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 }) { From 6a41fe612938648bca7f4e79fffbf10aa9c722c2 Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Tue, 15 Oct 2019 08:10:36 -0500 Subject: [PATCH 14/17] Update FileInput docs --- docs.md | 16 +++++++++------- src/forms/inputs/file-input/file-input.js | 19 +++++++++++-------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/docs.md b/docs.md index 68c400ff..e827998c 100644 --- a/docs.md +++ b/docs.md @@ -621,27 +621,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. -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][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 files can be uploaded) +- `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 @@ -653,6 +654,7 @@ function HeadshotForm ({ handleSubmit, pristine, invalid, submitting }) { name="headshot" component={ FileInput } onLoad={ (fileData, file) => console.log('Loaded file!', file) } + selectText="Select profile picture" /> Submit diff --git a/src/forms/inputs/file-input/file-input.js b/src/forms/inputs/file-input/file-input.js index 03be5854..12c679cd 100644 --- a/src/forms/inputs/file-input/file-input.js +++ b/src/forms/inputs/file-input/file-input.js @@ -17,26 +17,28 @@ import classnames from 'classnames' /** * 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. - * - * 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. + * 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). + * + * 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. * - * 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) * * @name FileInput * @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} [readFiles] - 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 {Function} [onRemove] - A callback fired when the file is removed (only available when multiple files can be uploaded) + * @param {Function} [onRemove=noop] - A callback fired when the file is removed (only available when `multiple` is set to `true`) + * @param {Function} [removeComponent=RemoveButton] - A custom component that receives the `onRemove` callback (only available when `multiple` is set to `true`) * @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 }) { @@ -46,6 +48,7 @@ import classnames from 'classnames' * name="headshot" * component={ FileInput } * onLoad={ (fileData, file) => console.log('Loaded file!', file) } + * selectText="Select profile picture" * /> * * Submit From 7b1bfc0e363eb3d0112a933c01e5aff657cf014d Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Tue, 21 Sep 2021 10:28:17 -0500 Subject: [PATCH 15/17] Bump size limit --- .size-limit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index a54f283e..09d95297 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -1,7 +1,7 @@ module.exports = [ { path: 'lib', - limit: '165 KB', + limit: '170 KB', ignore: ['react-dom'], } -] \ No newline at end of file +] From 9eb2a219b781ca6e8891aeb477b3df875ed53fca Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Tue, 21 Sep 2021 10:30:19 -0500 Subject: [PATCH 16/17] Bump to milestone --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5f733e78..6a8e55aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@launchpadlab/lp-components", - "version": "3.26.2", + "version": "6.0.0", "engines": { "node": "^8.0.0 || ^10.13.0" }, From 59b9a6dd4b2a64c5e25cc4c359a222537e80767c Mon Sep 17 00:00:00 2001 From: Conor Hawes Date: Tue, 21 Sep 2021 12:15:16 -0500 Subject: [PATCH 17/17] Fix failing specs --- src/forms/inputs/cloudinary-file-input.js | 26 +++++---- src/forms/inputs/file-input/file-input.js | 53 ++++++++++-------- stories/forms/inputs/file-input.story.js | 4 +- .../inputs/cloudinary-file-input.test.js | 54 ++++++++++++------- test/forms/inputs/file-input.test.js | 39 +++++++------- 5 files changed, 102 insertions(+), 74 deletions(-) diff --git a/src/forms/inputs/cloudinary-file-input.js b/src/forms/inputs/cloudinary-file-input.js index 4cb4b2ab..84cb7de4 100644 --- a/src/forms/inputs/cloudinary-file-input.js +++ b/src/forms/inputs/cloudinary-file-input.js @@ -2,16 +2,16 @@ import React from 'react' import PropTypes from 'prop-types' import DefaultFileInput from './file-input' import { fileInputPropTypes, readFilesAsDataUrls } from '../helpers' -import { compose, cloudinaryUploader, noop, set } from '../../utils' +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. - * + * * 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). * @@ -20,15 +20,16 @@ import classnames from 'classnames' * @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} [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 ( *
- * ) diff --git a/src/forms/inputs/file-input/file-input.js b/src/forms/inputs/file-input/file-input.js index 12c679cd..383abd69 100644 --- a/src/forms/inputs/file-input/file-input.js +++ b/src/forms/inputs/file-input/file-input.js @@ -16,9 +16,9 @@ import { first, noop, generateInputErrorId, isString, removeAt } from '../../../ import classnames from 'classnames' /** - * A file input that can be used in a `redux-forms`-controlled form. + * A file input that can be used in a `redux-forms`-controlled form. * 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). - * + * * 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. * * By default, this component displays a thumbnail preview of the loaded file(s). This preview can be customized @@ -26,7 +26,7 @@ import classnames from 'classnames' * * A component passed using `previewComponent` will receive the following props: * - `value`: the current value of the input (file object or array of file objects) - * + * * @name FileInput * @type Function * @param {Object} input - A `redux-forms` [input](http://redux-form.com/6.5.0/docs/api/Field.md/#input-props) object @@ -40,14 +40,13 @@ import classnames from 'classnames' * @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 ( * - * console.log('Loaded file!', file) } + * * @@ -73,6 +72,7 @@ const propTypes = { } const defaultProps = { + hidePreview: false, multiple: false, onRemove: noop, readFiles: readFilesAsDataUrls, @@ -83,10 +83,10 @@ const defaultProps = { class FileInput extends React.Component { constructor (props) { super(props) - + this.state = { errors: null } this.removeFile = this.removeFile.bind(this) - + this.fileInput = null this.setFileInputRef = element => { this.fileInput = element @@ -95,17 +95,17 @@ class FileInput extends React.Component { if (this.fileInput) this.fileInput.value = "" } } - + async removeFile (idx) { const { input: { onChange, value }, multiple, onRemove } = this.props const [removedFile, remainingFiles] = removeAt(value, idx) - + try { await onRemove(removedFile) - + // 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) { @@ -125,26 +125,34 @@ class FileInput extends React.Component { readFiles, removeComponent: RemoveComponent, selectText, + thumbnail, ...rest } = omitLabelProps(this.props) const inputMeta = setInputErrors(meta, this.state.errors) const wrapperClass = buttonClasses({ style: 'secondary-light', submitting }) const labelText = selectText || (multiple ? 'Select File(s)' : 'Select File') const values = castFormValueToArray(value) - + return (
- { - !hidePreview && - values.map((value, idx) => { + {!hidePreview && + + {values.length === 0 && + + } + {values.map((value, idx) => { return (
- + { multiple && this.removeFile(idx) } /> }
- ) - }) + )})} +
}
if (children) return children - const renderImagePreview = isImageType(value.type) || thumbnail + const renderImagePreview = isImageType(value) || thumbnail if (renderImagePreview) return return } diff --git a/stories/forms/inputs/file-input.story.js b/stories/forms/inputs/file-input.story.js index bf42fe77..4c2f1d7d 100644 --- a/stories/forms/inputs/file-input.story.js +++ b/stories/forms/inputs/file-input.story.js @@ -23,7 +23,7 @@ function FilenamePreview ({ file }) { storiesOf('FileInput', module) .add('with defaults', () => ( )) @@ -45,6 +45,6 @@ storiesOf('FileInput', module) )) diff --git a/test/forms/inputs/cloudinary-file-input.test.js b/test/forms/inputs/cloudinary-file-input.test.js index dbc0dbc8..68b255f0 100644 --- a/test/forms/inputs/cloudinary-file-input.test.js +++ b/test/forms/inputs/cloudinary-file-input.test.js @@ -4,7 +4,7 @@ import { CloudinaryFileInput } from '../../../src/' import { createMockFileReader } from './file-input.test' const name = 'name.of.field' -const value = 'value of field' +const value = { name: 'existingFileName', url: 'value of field' } const onChange = () => {} const input = { name, value, onChange } const PUBLIC_URL = 'url-of-uploaded-file' @@ -17,7 +17,6 @@ const bucket = 'bucket' // These tests rely on the mock implementation of cloudinaryUploader in __mocks__, // which just passes all props through to its child. - test('CloudinaryFileInput adds uploadStatus to className', () => { const className = 'foo' const props = { input, meta: {}, className, upload, uploadStatus, cloudName, bucket } @@ -25,45 +24,60 @@ test('CloudinaryFileInput adds uploadStatus to className', () => { expect(wrapper.find('fieldset.foo.upload-success').exists()).toEqual(true) }) -test('CloudinaryFileInput sets returned url as value', () => { - const fakeFileEvent = { target: { files: [] }} +test('CloudinaryFileInput sets returned url within value', () => { + const fakeFileEvent = { target: { files: [{ name: 'fileName', type: 'image/png' }] }} window.FileReader = createMockFileReader() const onChange = jest.fn() const props = { input: { ...input, onChange }, meta: {}, upload, uploadStatus, cloudName, bucket } const wrapper = mount() const internalOnChange = wrapper.find('input').prop('onChange') - // internally calls upload, which resolves with url - internalOnChange(fakeFileEvent) - return Promise.resolve().then(() => - expect(onChange).toHaveBeenCalledWith(PUBLIC_URL) - ) + // internally calls upload, which resolves with file + return internalOnChange(fakeFileEvent).then(() => { + expect(onChange).toHaveBeenCalled() + expect(onChange.mock.calls[0][0].url).toBe(PUBLIC_URL) + }) }) -test('CloudinaryFileInput calls success handler with response on successful upload', () => { - const fakeFileEvent = { target: { files: [] }} +test('CloudinaryFileInput calls success handler with response on successful upload of a single file', () => { + const fakeFileEvent = { target: { files: [{ name: 'fileName' }] }} const onUploadSuccess = jest.fn() + const props = { input: { ...input, onChange: jest.fn() }, meta: {}, upload, cloudName, bucket, onUploadSuccess } const wrapper = mount() const internalOnChange = wrapper.find('input').prop('onChange') - internalOnChange(fakeFileEvent) - - return Promise.resolve().then(() => { - expect(onUploadSuccess).toHaveBeenCalledWith(uploadResponse) + + return internalOnChange(fakeFileEvent).then(() => { + expect(onUploadSuccess).toHaveBeenCalled() + expect(onUploadSuccess.mock.calls[0][0].url).toBe(PUBLIC_URL) + }) +}) + +test('CloudinaryFileInput calls success handler with array of responses on successful uploads of multiple files', () => { + const fakeFileEvent = { target: { files: [{ name: 'fileName' }] }} + const onUploadSuccess = jest.fn() + + const props = { input: { ...input, onChange: jest.fn() }, meta: {}, upload, cloudName, bucket, onUploadSuccess, multiple: true } + const wrapper = mount() + const internalOnChange = wrapper.find('input').prop('onChange') + + return internalOnChange(fakeFileEvent).then(() => { + expect(onUploadSuccess).toHaveBeenCalled() + expect(onUploadSuccess.mock.calls[0][0][0].url).toBe(PUBLIC_URL) }) }) test('CloudinaryFileInput calls error handler with error on failed upload', () => { - const fakeFileEvent = { target: { files: [] }} + const fakeFileEvent = { target: { files: [{}] }} const onUploadFailure = jest.fn() const failureResponse = { errors: "Invalid filename" } const upload = () => Promise.reject(failureResponse) - + window.FileReader = createMockFileReader() + const props = { input: { ...input, onChange: jest.fn() }, meta: {}, upload, cloudName, bucket, onUploadFailure } const wrapper = mount() const internalOnChange = wrapper.find('input').prop('onChange') - internalOnChange(fakeFileEvent) - - return Promise.resolve().then(() => { + + return internalOnChange(fakeFileEvent).then(() => { expect(onUploadFailure).toHaveBeenCalledWith(failureResponse) }) }) diff --git a/test/forms/inputs/file-input.test.js b/test/forms/inputs/file-input.test.js index 9db16fb0..9cd156f7 100644 --- a/test/forms/inputs/file-input.test.js +++ b/test/forms/inputs/file-input.test.js @@ -5,19 +5,16 @@ import { FileInput } from '../../../src/' const name = 'my.file.input' test('FileInput renders thumbnail with value as src when file is an image', () => { - const value = 'foo' - const file = { name: 'fileName', type: 'image/png' } - const props = { input: { name, value }, meta: {} } + const file = { name: 'fileName', type: 'image/png', url: 'foo' } + const props = { input: { name, value: file }, meta: {} } const wrapper = mount() - wrapper.setState({ file }) - expect(wrapper.find('img').props().src).toEqual(value) + expect(wrapper.find('img').props().src).toEqual(file.url) }) test('FileInput renders file name when file is non-image type or value is empty', () => { const file = { name: 'fileName', type: 'application/pdf' } - const props = { input: { name, value: '' }, meta: {} } + const props = { input: { name, value: file }, meta: {} } const wrapper = mount() - wrapper.setState({ file }) expect(wrapper.find('p').text()).toEqual('fileName') }) @@ -42,11 +39,10 @@ test('FileInput sets custom preview from children', () => { }) test('FileInput sets custom preview from props', () => { - const Preview = ({ file, }) =>

{ file && file.name }

// eslint-disable-line react/prop-types - const props = { input: { name, value: '' }, meta: {} } + const Preview = ({ value }) =>

{ value && value.name }

// eslint-disable-line react/prop-types + const props = { input: { name, value: { name: 'fileName', type: 'image/png' } }, meta: {} } const wrapper = mount() expect(wrapper.find('p').exists()).toEqual(true) - wrapper.setState({ file: { name: 'fileName', type: 'image/png' } }) expect(wrapper.find('p').text()).toEqual('fileName') }) @@ -58,25 +54,30 @@ test('FileInput passes extra props to custom preview', () => { }) test('FileInput passes value to custom preview', () => { - const Preview = ({ value }) =>

{ value }

// eslint-disable-line react/prop-types - const value = 'foo' - const props = { input: { name, value }, meta: {} } + const Preview = ({ value }) =>

{ value.url }

// eslint-disable-line react/prop-types + const file = { name: 'fileName', url: 'foo' } + const props = { input: { name, value: file }, meta: {} } const wrapper = mount() expect(wrapper.find('p').exists()).toEqual(true) - expect(wrapper.find('p').text()).toEqual(value) + expect(wrapper.find('p').text()).toEqual(file.url) }) -test('FileInput reads files and calls change handlers correctly', () => { +test('FileInput reads files and calls change handler correctly', () => { const FILE = { name: 'my file' } const FILEDATA = 'my file data' window.FileReader = createMockFileReader(FILEDATA) - const onLoad = jest.fn() const onChange = jest.fn() - const props = { input: { name, value: '', onChange }, meta: {}, onLoad } + const props = { input: { name, value: '', onChange }, meta: {} } const wrapper = mount() wrapper.find('input').simulate('change', { target: { files: [FILE] }}) - expect(onChange).toHaveBeenCalledWith(FILEDATA) - expect(onLoad).toHaveBeenCalledWith(FILEDATA, FILE) + + // https://github.com/enzymejs/enzyme/issues/823#issuecomment-492984956 + const asyncCheck = setImmediate(() => { + wrapper.update() + expect(onChange).toHaveBeenCalled() + }) + + global.clearImmediate(asyncCheck) }) test('FileInput passes accept attribute to input component', () => {