import { React, PropTypes, FileDrop, classnames } from '@gravityforms/libraries';
import { useStateWithDep } from '@gravityforms/react-utils';
import { formatFileSize, spacerClasses, trigger } from '@gravityforms/utils';
import Box from '../Box';
import Icon from '../Icon';
import Text from '../Text';
import Button from '../Button';
const { forwardRef, useState, useEffect, useRef } = React;
const NEEDS_I18N_LABEL = 'Needs i18n';
const DEFAULT_FILE_ARRAY = [];
const DEFAULT_FILE_EXT_ICON = 'document-text';
const DEFAULT_FILE_EXT_ICON_CONFIG = {
// documents
pdf: 'pdf',
xls: 'grid-row-alt',
xlsx: 'grid-row-alt',
xlsm: 'grid-row-alt',
ods: 'grid-row-alt',
numbers: 'grid-row-alt',
// audio
mp3: 'play',
wav: 'play',
ogg: 'play',
flac: 'play',
aac: 'play',
m4a: 'play',
aiff: 'play',
wma: 'play',
opus: 'play',
// video
mp4: 'play',
mov: 'play',
avi: 'play',
mkv: 'play',
webm: 'play',
wmv: 'play',
flv: 'play',
m4v: 'play',
// images
jpg: 'photograph',
jpeg: 'photograph',
png: 'photograph',
gif: 'photograph',
bmp: 'photograph',
webp: 'photograph',
svg: 'photograph',
tiff: 'photograph',
tif: 'photograph',
heic: 'photograph',
heif: 'photograph',
};
/**
* @module FileUpload
* @description A file upload component.
*
* @since 1.1.15
*
* @param {object} props Component props.
* @param {string} props.acceptedFileTypes The allowed file types for the input's accept attribute.
* @param {Array} props.allowedFileTypes The allowed file types helper text.
* @param {boolean} props.allowMultiUpload Allow multi-file upload.
* @param {JSX.Element} props.aboveDropZoneChildren Slot content for React element children rendered above the drop zone.
* @param {JSX.Element} props.belowDropZoneChildren Slot content for React element children rendered below the drop zone.
* @param {boolean} props.clickable Whether clicking the upload area triggers the file upload UI.
* @param {object} props.customAttributes Custom attributes for the component.
* @param {string|Array|object} props.customClasses Custom classes for the component.
* @param {object} props.customWrapperAttributes Custom attributes for the component wrapper.
* @param {string|Array|object} props.customWrapperClasses Custom classes for the component wrapper.
* @param {boolean} props.disabled Whether this component should be disabled.
* @param {boolean} props.externalManager Whether to use the external file manager.
* @param {Array} props.fileArray The files to be uploaded.
* @param {string} props.fileId The ID of the file.
* @param {string} props.fileURL The url for an already uploaded file.
* @param {boolean} props.hasDrop Whether to enable file dropping functionality.
* @param {object} props.i18n Translated strings for the UI.
* @param {string} props.id ID of the file input.
* @param {boolean} props.imagePreview Whether to show an image specific or generic file preview. Only supported for single file uploads.
* @param {string|number} props.maxHeight The maximum height for the image.
* @param {string|number} props.maxWidth The maximum width for the image.
* @param {object} props.multiPreviewActionButtonAttributes Custom attributes for the multi-file preview action button.
* @param {string|Array|object} props.multiPreviewActionButtonClasses Custom classes for the multi-file preview action button.
* @param {string} props.name The name attribute for the file input.
* @param {Function} props.onFileSelect Handler for file selection.
* @param {object} props.previewAttributes Attributes for the preview.
* @param {Array} props.previewClasses Classes for the preview.
* @param {boolean} props.showAllowedFileTypesText Whether or not to show the allowed file types helper text.
* @param {boolean} props.showAllowedImageDimensionsText Whether or not to show the allowed image dimensions helper text.
* @param {boolean} props.showMultiPreviewActions Whether or not to show the multi-file preview actions.
* @param {string|number|Array|object} props.spacing The spacing for the component.
* @param {string} props.theme The theme for the component.
* @param {string} props.uploadIcon The icon to show for the upload button.
* @param {string} props.uploadIconPrefix The prefix to use for the upload button icon.
* @param {object} props.wrapperAttributes Custom attributes for the wrapper element.
* @param {string|Array|object} props.wrapperClasses Custom classes for the wrapper element.
* @param {object|null} ref Ref to the component.
*
* @return {JSX.Element} The file upload component.
*
* @example
* import FileUpload from '@gravityforms/components/react/admin/elements/FileUpload';
*
* return <FileUpload name="file-upload" />;
*
*/
const FileUpload = forwardRef( ( {
acceptedFileTypes = [],
allowedFileTypes = [],
allowMultiUpload = false,
aboveDropZoneChildren = null,
belowDropZoneChildren = null,
clickable = true,
customAttributes = {},
customClasses = [],
customWrapperAttributes = {},
customWrapperClasses = [],
disabled = false,
externalManager = false,
fileArray = DEFAULT_FILE_ARRAY,
fileURL = '',
fileId = 0,
hasDrop = true,
i18n = {},
id = '',
imagePreview = true,
maxHeight = '',
maxWidth = '',
multiPreviewActionButtonAttributes = {},
multiPreviewActionButtonClasses = [],
name = '',
onFileSelect = () => {},
previewAttributes = {},
previewClasses = [],
showAllowedFileTypesText = true,
showAllowedImageDimensionsText = true,
showMultiPreviewActions = false,
spacing = '',
theme = 'cosmos',
uploadIcon = 'upload-file',
uploadIconPrefix = 'gravity-component-icon',
wrapperAttributes = {},
wrapperClasses = [],
}, ref ) => {
const [ selectedFile, setSelectedFile ] = useState( '' );
const [ selectedFiles, setSelectedFiles ] = useStateWithDep( fileArray );
const [ selectedExternalManagerFile, setSelectedExternalManagerFile ] = useStateWithDep( fileURL );
const [ selectedExternalManagerId, setSelectedExternalManagerId ] = useStateWithDep( fileId );
const [ preview, setPreview ] = useStateWithDep( fileURL );
const fileInputRef = useRef( null );
useEffect( () => {
if ( ! externalManager ) {
return;
}
document.addEventListener( 'gform/file_upload/external_manager/file_selected', handleExternalManager );
// Clean up the event listener when component unmounts
return () => {
document.removeEventListener( 'gform/file_upload/external_manager/file_selected', handleExternalManager );
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [] );
useEffect( () => {
if ( allowMultiUpload ) {
return;
}
if ( ! selectedFile ) {
fileInputRef.current.value = null;
return;
}
const objectUrl = URL.createObjectURL( selectedFile );
setPreview( objectUrl );
// This frees up memory once we're done with the URL.
return () => URL.revokeObjectURL( objectUrl );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ selectedFile ] );
const handleExternalManager = ( event ) => {
if ( allowMultiUpload ) {
// TODO: multi-file external manager integration (ids/urls array → selectedFiles)
return;
}
// exit if the id of the event doesn't match this instance id
if ( event.detail?.fileUploadId !== id ) {
return;
}
setSelectedExternalManagerFile( event.detail.url );
setSelectedExternalManagerId( event.detail.id );
setPreview( event.detail.url );
};
/**
* @function onSelectFile
* @description Handler for selecting a file.
*
* @since 1.1.15
*
* @param {Array|FileList} files Array of files.
*
* @return {Function} Function to clear timeout.
*/
const onSelectFile = ( files ) => {
if ( externalManager ) {
if ( allowMultiUpload ) {
// NOTE: For multi-file + externalManager (e.g., WP Media Library), we will
// want to emit a custom event that supports multiple selections and then
// update `selectedFiles` from that result. Placeholder for future integration.
//trigger( { event: 'gform/file_upload/external_manager/save_many', data: { id, event, file: files }, native: false } );
} else {
trigger( { event: 'gform/file_upload/external_manager/save', data: { id, event, file: files?.[ 0 ] }, native: false } );
}
}
const delayed = setTimeout( () => {
if ( ! files || files.length === 0 ) {
// User canceled the dialog; keep existing selection intact in both modes
return;
}
if ( allowMultiUpload ) {
const newFiles = Array.from( files );
const merged = [ ...selectedFiles, ...newFiles ];
setSelectedFiles( merged );
onFileSelect( merged );
} else {
const file = files[ 0 ];
onFileSelect( file );
setSelectedFile( file );
fileInputRef.current.files = files;
}
}, 0 );
return () => clearTimeout( delayed );
};
/**
* @function onFileInputChange
* @description Handler for change event on file input.
*
* @since 1.1.15
*
* @param {object} event Event object.
*
* @return {void}
*/
const onFileInputChange = ( event ) => {
const { files } = event.target;
onSelectFile( files );
};
/**
* @function onFileInputKeyDown
* @description Handler for keydown event on file input.
*
* @since 2.0.1
*
* @param {object} event Event object.
*
* @return {void}
*/
const onFileInputKeyDown = ( event ) => {
if ( ( event.key === ' ' || event.key === 'Enter' ) && externalManager ) {
event.preventDefault();
trigger( { event: 'gform/file_upload/external_manager/open', data: { id, event }, native: false } );
}
};
/**
* @function onTargetClick
* @description Handler for click event on file drop target.
*
* @since 1.1.15
*
* @param {object} event Event object.
*
* @return {void}
*/
const onTargetClick = ( event ) => {
event.preventDefault();
if ( disabled || ! clickable ) {
return;
}
if ( externalManager ) {
trigger( { event: 'gform/file_upload/external_manager/open', data: { id, event }, native: false } );
} else {
fileInputRef.current.click();
}
};
/**
* @function handleRemove
* @description Handler for removing file from single-file upload field.
*
* @since 1.1.15
*
* @param {object} event Event object.
*
* @return {void}
*/
const handleRemove = ( event ) => {
trigger( { event: 'gform/file_upload/external_manager/file_remove', data: { id, event }, native: false } );
setPreview( '' );
onFileSelect( null );
setSelectedFile( '' );
setSelectedExternalManagerFile( '' );
setSelectedExternalManagerId( 0 );
};
/**
* @function handleRemoveMultiFile
* @description Handler for removing file from multi-file upload field.
*
* @since 5.8.5
*
* @param {object} event Event object.
* @param {number} index Index of the file to remove.
*
* @return {void}
*/
const handleRemoveMultiFile = ( event, index ) => {
trigger( { event: 'gform/file_upload/external_manager/file_remove', data: { id, event }, native: false } );
event?.preventDefault?.();
const updated = selectedFiles.filter( ( _f, i ) => i !== index );
setSelectedFiles( updated );
onFileSelect( updated );
if ( fileInputRef.current && updated.length === 0 ) {
fileInputRef.current.value = null;
}
};
/**
* @function renderFileOptions
* @description Renders allowed file type options.
*
* @since 1.1.15
*
* @return {string} String list of allowed file types.
*/
const renderFileOptions = () => {
return allowedFileTypes.join( ', ' );
};
const getFileIcon = ( file ) => {
const defaultIcon = DEFAULT_FILE_EXT_ICON;
if ( ! file?.name ) {
return defaultIcon;
}
const ext = file.name.split( '.' ).pop().toLowerCase();
return DEFAULT_FILE_EXT_ICON_CONFIG[ ext ] || defaultIcon;
};
const outerWrapperProps = {
...customWrapperAttributes,
className: classnames( {
'gform-file-upload__wrapper-outer': true,
...spacerClasses( spacing ),
}, customWrapperClasses ),
ref,
};
const wrapperProps = {
...wrapperAttributes,
className: classnames( {
'gform-file-upload__wrapper': true,
[ `gform-file-upload__wrapper--theme-${ theme }` ]: true,
'gform-file-upload__wrapper--has-preview': ! allowMultiUpload && preview,
'gform-file-upload__wrapper--disabled': disabled,
}, wrapperClasses ),
ref,
};
const uploaderProps = {
...customAttributes,
className: classnames( {
'gform-file-upload': true,
[ `gform-file-upload--theme-${ theme }` ]: true,
}, customClasses ),
};
const previewProps = {
...previewAttributes,
className: classnames( {
'gform-file-upload__preview': true,
'gform-file-upload__preview--image': true,
[ `gform-file-upload__preview--theme-${ theme }` ]: true,
}, previewClasses ),
};
const previewImgProps = {
src: preview,
alt: selectedFile.name,
};
const fileDropProps = {
onTargetClick,
onDrop: ( files ) => {
if ( disabled || ! hasDrop ) {
return;
}
onSelectFile( files );
},
};
const fileInputProps = {
onChange: onFileInputChange,
onKeyDown: onFileInputKeyDown,
ref: fileInputRef,
type: 'file',
id,
name: allowMultiUpload ? `${ name }[]` : name,
multiple: allowMultiUpload,
accept: acceptedFileTypes.length > 0 ? acceptedFileTypes.join( ',' ) : undefined,
};
const buttonsWrapperProps = {
className: classnames( {
'gform-file-upload__buttons-wrapper': true,
} ),
};
const removeButtonProps = {
onClick: handleRemove,
customClasses: [ 'gform-file-upload__remove' ],
icon: 'trash',
iconPosition: 'leading',
iconPrefix: uploadIconPrefix,
label: i18n.delete || NEEDS_I18N_LABEL,
size: 'size-height-s',
type: 'white',
};
const replaceButtonProps = {
onClick: onTargetClick,
customClasses: [ 'gform-file-upload__replace' ],
icon: 'arrow-path',
iconPosition: 'leading',
iconPrefix: uploadIconPrefix,
label: i18n.replace || NEEDS_I18N_LABEL,
size: 'size-height-s',
type: 'white',
};
const multiPreviewActionButtonProps = {
customClasses: classnames( [
'gform-file-upload__preview-files-action',
], multiPreviewActionButtonClasses ),
icon: 'upload-file',
iconPosition: 'leading',
iconPrefix: uploadIconPrefix,
label: i18n.multi_preview_action_button_label || NEEDS_I18N_LABEL,
size: 'size-height-m',
type: 'white',
...multiPreviewActionButtonAttributes,
};
const fileUrlInputProps = {
name: `${ name }[file_url]`,
type: 'hidden',
value: externalManager ? selectedExternalManagerFile : fileURL,
};
const fileIdInputProps = {
name: `${ name }[attachment_id]`,
type: 'hidden',
value: externalManager ? selectedExternalManagerId : fileId,
};
return (
<div { ...outerWrapperProps }>
{ aboveDropZoneChildren }
<div { ...wrapperProps }>
{ ( ! allowMultiUpload && imagePreview && preview ) &&
<div { ...previewProps }>
{ /* eslint-disable-next-line jsx-a11y/alt-text */ }
<img { ...previewImgProps } />
</div>
}
{ ( ! allowMultiUpload && ! imagePreview && preview ) && (
<Box
customClasses={ [ 'gform-file-upload__preview', 'gform-file-upload__preview--file' ] }
display="flex"
x={ 425 }
>
<Icon
customClasses={ [ 'gform-file-upload__preview-icon' ] }
icon={ 'document-text' }
iconPrefix={ uploadIconPrefix }
/>
<Text
customClasses={ [ 'gform-file-upload__preview-file-name' ] }
size="text-sm"
weight="medium"
>
{ selectedFile && selectedFile.name }
</Text>
</Box>
) }
<div { ...uploaderProps }>
<FileDrop { ...fileDropProps }>
<Icon
customClasses={ [ 'gform-file-upload__icon' ] }
icon={ uploadIcon }
iconPrefix={ uploadIconPrefix }
spacing={ 2 }
/>
<Text customClasses={ [ 'gform-file-upload__message' ] } size="text-sm" spacing={ 1 }>
<span className="gform-file-upload__bold-text">{ i18n.click_to_upload || NEEDS_I18N_LABEL }</span> { i18n.drag_n_drop || NEEDS_I18N_LABEL }
</Text>
{ ( showAllowedFileTypesText || showAllowedImageDimensionsText ) &&
<Text customClasses={ [ 'gform-file-upload__filetypes' ] } size="text-xs">
{ showAllowedFileTypesText && renderFileOptions() }
{ showAllowedFileTypesText && showAllowedImageDimensionsText && ' ' }
{ showAllowedImageDimensionsText && (
<>
({ i18n.max || NEEDS_I18N_LABEL } { maxWidth }x{ maxHeight }px)
</>
) }
</Text>
}
{ i18n.custom_helper &&
<Text customClasses={ [ 'gform-file-upload__custom-helper-text' ] } size="text-xs">
{ i18n.custom_helper }
</Text>
}
</FileDrop>
<input className="gform-file-upload__input" { ...fileInputProps } />
{ ! allowMultiUpload && fileUrlInputProps.value !== '' && <input { ...fileUrlInputProps } /> }
{ ! allowMultiUpload && Number( fileIdInputProps.value ) !== 0 && <input { ...fileIdInputProps } /> }
</div>
{ ( ! allowMultiUpload && ( selectedFile || preview ) ) &&
<div { ...buttonsWrapperProps }>
<Button { ...replaceButtonProps } />
<Button { ...removeButtonProps } />
</div>
}
</div>
{ belowDropZoneChildren }
{ allowMultiUpload && selectedFiles.length > 0 && (
<Box
customClasses={ [ 'gform-file-upload__preview', 'gform-file-upload__preview--multi' ] }
display="flex"
>
<ul className="gform-file-upload__preview-files">
{ selectedFiles.map( ( file, idx ) => {
return (
<li key={ `file-preview-${ file.lastModified }-${ file.type }-${ idx }` } className="gform-file-upload__preview-file">
<div className="gform-file-upload__preview-file-content">
<div className="gform-file-upload__preview-icon">
<Icon icon={ getFileIcon( file ) } iconPrefix={ uploadIconPrefix } />
</div>
<div className="gform-file-upload__preview-meta">
<Text size="text-sm" weight="medium" customClasses={ [ 'gform-file-upload__preview-file-name' ] }>
{ file.name }
</Text>
<Text color="comet" size="text-sm" customClasses={ [ 'gform-file-upload__preview-file-size' ] }>
{ formatFileSize( file.size ) }
</Text>
</div>
</div>
<div className="gform-file-upload__preview-file-actions">
<Button
onClick={ ( e ) => handleRemoveMultiFile( e, idx ) }
customClasses={ [ 'gform-file-upload__preview-file-action-remove' ] }
icon="trash"
iconPosition="leading"
iconPrefix={ uploadIconPrefix }
label={ i18n.delete || NEEDS_I18N_LABEL }
size="size-height-s"
type="icon-grey"
/>
</div>
</li>
);
} ) }
</ul>
{ showMultiPreviewActions && (
<div className="gform-file-upload__preview-files-actions">
<Button { ...multiPreviewActionButtonProps } />
</div>
) }
</Box>
) }
</div>
);
} );
FileUpload.propTypes = {
acceptedFileTypes: PropTypes.array,
allowedFileTypes: PropTypes.array,
allowMultiUpload: PropTypes.bool,
aboveDropZoneChildren: PropTypes.oneOfType( [
PropTypes.arrayOf( PropTypes.node ),
PropTypes.node,
] ),
belowDropZoneChildren: PropTypes.oneOfType( [
PropTypes.arrayOf( PropTypes.node ),
PropTypes.node,
] ),
clickable: PropTypes.bool,
customAttributes: PropTypes.object,
customClasses: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.array,
PropTypes.object,
] ),
customWrapperAttributes: PropTypes.object,
customWrapperClasses: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.array,
PropTypes.object,
] ),
disabled: PropTypes.bool,
externalManager: PropTypes.bool,
fileArray: PropTypes.array,
fileURL: PropTypes.string,
fileId: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.number,
] ),
hasDrop: PropTypes.bool,
i18n: PropTypes.object,
id: PropTypes.string,
imagePreview: PropTypes.bool,
maxHeight: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.number,
] ),
maxWidth: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.number,
] ),
multiPreviewActionButtonAttributes: PropTypes.object,
multiPreviewActionButtonClasses: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.array,
PropTypes.object,
] ),
name: PropTypes.string,
onFileSelect: PropTypes.func,
previewAttributes: PropTypes.object,
previewClasses: PropTypes.array,
showAllowedFileTypesText: PropTypes.bool,
showAllowedImageDimensionsText: PropTypes.bool,
showMultiPreviewActions: PropTypes.bool,
spacing: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.number,
PropTypes.array,
PropTypes.object,
] ),
theme: PropTypes.string,
uploadIcon: PropTypes.string,
uploadIconPrefix: PropTypes.string,
wrapperAttributes: PropTypes.object,
wrapperClasses: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.array,
PropTypes.object,
] ),
};
FileUpload.displayName = 'FileUpload';
export default FileUpload;