elements_FileUpload_index.js

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;