import React, { useEffect, useRef, useState } from 'react' import styles from './_file-uploader.module.scss' import Button from '../Button/Button' import FormLabel from '../FormLabel/FormLabel' import { type FormLabelProps } from '../FormLabel/FormLabel' import Icon from '../Icons/Icon' import ProgressBar from '../ProgressBar/ProgressBar' import { toast } from '../Toast/Toast' import TrimText from '../TrimText/TrimText' import { Virtual } from '../Virtual/Virtual' import { c } from '../../translations/LibraryTranslationService' import ScrollingContainer from '../ScrollingContainer/ScrollingContainer' export enum MIMETypes { Images = 'image/jpeg,image/png,image/gif,image/svg+xml,image/webp', Audio = 'audio/mpeg,audio/wav', Videos = 'video/mp4,video/x-msvideo,video/quicktime,video/x-ms-wmv,video/x-matroska,video/x-flv,video/webm,video/mpeg,video/3gpp,video/3gpp2,video/ogg,video/x-m4v', Media = `${MIMETypes.Images},${MIMETypes.Audio},${MIMETypes.Videos}`, Documents = 'application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation', Text = 'text/plain', Archives = 'application/zip,application/x-rar-compressed', Spreadsheets = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel,text/csv,application/csv', } type FileTypeWithStatus = File & { status: 'uploading' | 'success' | 'error' progress?: number errorMessage?: string } type FileTypeWithoutStatus = File & { status?: never progress?: never } export type FileType = FileTypeWithStatus | FileTypeWithoutStatus type FileUploaderBaseProps = { /** Function to save uploaded files */ uploadCallout: (files: FileType[]) => void /** Allow FileUploader to receive multiple files */ acceptMultiple?: boolean /** Optionally restrict the types of files that are accepted. If your required type is not specified in the enum MIMETypes, you can add in the necessary strings to the array. */ acceptedFileTypes?: Array /** Optionally make the component disabled */ disabled?: boolean /** Optionally hide the x button */ noFileRemoval?: boolean /** Optional prop to add a test id to the FileUploader for QA testing */ qaTestId?: string } type WithControlled = FileUploaderBaseProps & { // TODO: We want all implementations to have the controlled and files props. For now, we are handling these as optional props so we do not break existing implementations. /** Optionally allow the files to be in a controlled state */ controlled: true /** Files to upload */ files: FileType[] /** Function to remove files */ removeFileCallout: (file: FileType) => void } type WithoutControlled = FileUploaderBaseProps & { /** Files to upload */ files?: FileType[] controlled?: never removeFileCallout?: never } type DetermineAsyncType = WithControlled | WithoutControlled type WithFormLabelProps = FileUploaderBaseProps & DetermineAsyncType & { /** FormLabel component above the upload field */ formLabelProps: Omit /** Optional download link callout */ downloadCallout?: () => void } type WithoutFormLabelProps = FileUploaderBaseProps & DetermineAsyncType & { formLabelProps?: never downloadCallout?: never } export type FileUploaderProps = WithFormLabelProps | WithoutFormLabelProps const FileUploader = ({ uploadCallout, files = [], formLabelProps, downloadCallout, acceptedFileTypes, disabled = false, acceptMultiple = false, controlled, removeFileCallout, noFileRemoval = false, qaTestId = 'file-uploader', }: FileUploaderProps): React.JSX.Element => { const [uploadedFiles, setUploadedFiles] = useState(files) const [showError, setShowError] = useState(false) const fileInputRef = useRef(null) const [buttonText, setButtonText] = useState( c('chooseFile', { count: acceptMultiple ? 2 : 1, }), ) const upload = (files: FileList | File[] | null) => { if (disabled) return // Prevent upload if disabled if (!files || files.length === 0) return const newFiles = Array.isArray(files) ? files : Array.from(files) const acceptedFiles = acceptMultiple ? newFiles : [newFiles[0]] // Only take the first file if not accepting multiple const validFiles = acceptedFiles.filter((file) => { const isAcceptedType = acceptedFileTypes ? acceptedFileTypes.some((type) => { const mimeTypes = type.split(',') // Split the enum string into an array return mimeTypes.includes(file.type) }) : true if (!isAcceptedType) { setShowError(true) } return isAcceptedType }) if (validFiles.length > 0) { !controlled ? setUploadedFiles((prevFiles) => { const updatedFiles = acceptMultiple ? [...prevFiles, ...validFiles] : validFiles uploadCallout(updatedFiles) return updatedFiles }) : uploadCallout(validFiles) setShowError(false) } } const handleUpload = (event: React.ChangeEvent) => { event.preventDefault() event.stopPropagation() upload(event.target.files) event.target.value = '' } const handleDrop = (evt: React.DragEvent) => { evt.preventDefault() evt.stopPropagation() if (evt.dataTransfer.items && evt.dataTransfer.items.length > 0) { const processEntry = async (entry: any): Promise => { return new Promise((resolve) => { if (entry.isFile) { // If it's a file, get it and add it to our files list entry.file((file: File) => { resolve([file]) }) } else if (entry.isDirectory) { // If the toggle is on and we have a directory, process it const dirReader = entry.createReader() const allFiles: File[] = [] // Define a recursive function to read all directory contents const readEntries = () => { dirReader.readEntries(async (entries: any[]) => { if (entries.length === 0) { // We've read all entries in this directory resolve(allFiles) } else { // Process each entry const filePromises = entries.map(processEntry) const files = await Promise.all(filePromises) allFiles.push(...files.flat()) // Continue reading readEntries() } }) } // Start reading directory contents readEntries() } else { resolve([]) } }) } const processDroppedItems = async () => { try { // Process all items in parallel to handle multiple folders efficiently const filePromises = Array.from(evt.dataTransfer.items).map( (item) => { const entry = item.webkitGetAsEntry?.() if (entry) { return processEntry(entry) } else if (item.getAsFile) { // Fallback for browsers not supporting webkitGetAsEntry const file = item.getAsFile() return file ? Promise.resolve([file]) : Promise.resolve([]) } return Promise.resolve([]) }, ) // Wait for all files to be processed from all folders const fileArrays = await Promise.all(filePromises) const allFiles = fileArrays.flat() if (allFiles.length > 0) { if (!acceptMultiple && allFiles.length > 1) { // If not accepting multiple files, just use the first file upload([allFiles[0]]) } else { upload(allFiles) } } } catch { toast({ type: 'error', message: c('errorProcessingDroppedFiles'), }) } } processDroppedItems() } else { // Regular file upload handling upload(evt.dataTransfer.files) } evt.dataTransfer.clearData() } const removeFile = (index: number) => { !controlled ? setUploadedFiles((prevFiles) => { const updatedFiles = prevFiles.filter((_, i) => i !== index) uploadCallout(updatedFiles) return updatedFiles }) : removeFileCallout?.(files[index]) } const displayedFiles = controlled ? files : uploadedFiles useEffect(() => { if (!acceptMultiple) { const buttonText = displayedFiles.length > 0 ? c('changeFile') : c('chooseFile', { count: 1, }) setButtonText(buttonText) } }, [displayedFiles.length, acceptMultiple]) const acceptedFilesString = acceptedFileTypes ?.map((type) => type .split(',') .map((t) => t.trim()) .join(', '), ) .join(', ') const virtualRowHeight = 32 // Height of each row in the virtualized list. Each row needs 25px of height and 8px of padding return (
{formLabelProps ? ( {c('downloadTemplate')} ), } : {})} {...(showError ? { error: true } : {})} /> ) : null}
{ evt.preventDefault() evt.stopPropagation() } } >
{displayedFiles.length >= 1 ? (
{c('numOfFiles', { count: displayedFiles.length, })}
) : (
{c('dragAndDropOrChooseFile', { count: acceptMultiple ? 2 : 1, })}
)}
{showError && acceptedFileTypes ? (
{c('errorAcceptedFileTypes', { acceptedFileTypes: acceptedFilesString, })}
) : null} {displayedFiles.length > 0 ? ( {({ virtualizer, virtualizedParentRef }) => ( } height={Math.min( displayedFiles.length * virtualRowHeight + 32, 260, // 32px is the combined height of the white gradients. 260px is the max height allowed for the container. We chose 260px because it fits well with the gradient heights and file row heights. )} > {({ index }) => (
{controlled && displayedFiles[index].status ? ( ) : (
)} {!noFileRemoval && ( )}
)} )} ) : null}
) } export default FileUploader type FileStatusProps = { status: FileType['status'] progress?: number errorMessage?: string } const FileStatus = ({ status, progress = 0, errorMessage, }: FileStatusProps) => { switch (status) { case 'uploading': return case 'error': return ( ) case 'success': return ( {c('uploadComplete')} ) default: return null } }