UNPKG

@gravityforms/components

Version:

UI components for use in Gravity Forms development. Both React and vanilla js flavors.

681 lines (632 loc) 22.3 kB
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;