import { FILE_URL } from "../../Config"; import API from "../../Utils/API/API"; import ModelAPI from "../../Utils/ModelAPI/ModelAPI"; import Icon from "../Icon/Icon"; import HTTPResponse from "../../../Types/API/HTTPResponse"; import CommonURL from "../../../Types/API/URL"; import Dictionary from "../../../Types/Dictionary"; import MimeType from "../../../Types/File/MimeType"; import IconProp from "../../../Types/Icon/IconProp"; import FileModel from "../../../Models/DatabaseModels/File"; import React, { FunctionComponent, ReactElement, useEffect, useState, } from "react"; import { useDropzone, type FileRejection } from "react-dropzone"; import type { AxiosProgressEvent } from "axios"; export interface ComponentProps { initialValue?: undefined | Array | FileModel; onClick?: undefined | (() => void); placeholder?: undefined | string; className?: undefined | string; onChange?: undefined | ((value: Array) => void); value?: Array | undefined; readOnly?: boolean | undefined; mimeTypes?: Array | undefined; onFocus?: (() => void) | undefined; onBlur?: (() => void) | undefined; dataTestId?: string | undefined; isMultiFilePicker?: boolean | undefined; tabIndex?: number | undefined; error?: string | undefined; } type UploadStatus = { id: string; name: string; progress: number; status: "uploading" | "error"; errorMessage?: string | undefined; }; type AddUploadStatusFunction = (status: UploadStatus) => void; type UpdateUploadStatusFunction = ( id: string, updates: Partial, ) => void; type UpdateUploadProgressFunction = ( id: string, total?: number, loaded?: number, ) => void; type RemoveUploadStatusFunction = (id: string) => void; type BuildFileSizeErrorFunction = (fileNames: Array) => string; type ResolveMimeTypeFunction = (file: File) => MimeType | undefined; type FormatFileSizeFunction = (file: FileModel) => string | null; const MAX_FILE_SIZE_BYTES: number = 10 * 1024 * 1024; // 10MB limit const FilePicker: FunctionComponent = ( props: ComponentProps, ): ReactElement => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(""); const [filesModel, setFilesModel] = useState>([]); const [acceptTypes, setAcceptTypes] = useState>>({}); const [uploadStatuses, setUploadStatuses] = useState>([]); const addUploadStatus: AddUploadStatusFunction = ( status: UploadStatus, ): void => { setUploadStatuses((current: Array) => { return [...current, status]; }); }; const updateUploadStatus: UpdateUploadStatusFunction = ( id: string, updates: Partial, ): void => { setUploadStatuses((current: Array) => { return current.map((upload: UploadStatus) => { return upload.id === id ? { ...upload, ...updates, } : upload; }); }); }; const updateUploadProgress: UpdateUploadProgressFunction = ( id: string, total?: number, loaded?: number, ): void => { setUploadStatuses((current: Array) => { return current.map((upload: UploadStatus) => { if (upload.id !== id || upload.status === "error") { return upload; } const hasTotal: boolean = Boolean(total && total > 0); const progressFromEvent: number | null = hasTotal ? Math.min(100, Math.round(((loaded || 0) / (total as number)) * 100)) : null; const fallbackProgress: number = Math.min(upload.progress + 5, 95); return { ...upload, progress: progressFromEvent !== null ? progressFromEvent : fallbackProgress, }; }); }); }; const removeUploadStatus: RemoveUploadStatusFunction = (id: string): void => { setUploadStatuses((current: Array) => { return current.filter((upload: UploadStatus) => { return upload.id !== id; }); }); }; useEffect(() => { const _acceptTypes: Dictionary> = {}; if (props.mimeTypes) { for (const key of props.mimeTypes) { _acceptTypes[key] = []; } } setAcceptTypes(_acceptTypes); }, [props.mimeTypes]); useEffect(() => { setInitialValue(); }, [props.initialValue]); const setInitialValue: VoidFunction = () => { if ( Array.isArray(props.initialValue) && props.initialValue && props.initialValue.length > 0 ) { setFilesModel(props.initialValue); } else if (props.initialValue instanceof FileModel) { setFilesModel([props.initialValue as FileModel]); } }; useEffect(() => { if (props.value && props.value.length > 0) { setFilesModel(props.value && props.value.length > 0 ? props.value : []); } else { setInitialValue(); } }, [props.value]); const buildFileSizeError: BuildFileSizeErrorFunction = ( fileNames: Array, ): string => { if (fileNames.length === 0) { return ""; } if (fileNames.length === 1) { return `"${fileNames[0]}" exceeds the 10MB limit.`; } return `These files exceed the 10MB limit: ${fileNames.join(", ")}.`; }; const { getRootProps, getInputProps, isDragActive } = useDropzone({ accept: acceptTypes, multiple: props.isMultiFilePicker, noClick: true, disabled: props.readOnly || isLoading, maxSize: MAX_FILE_SIZE_BYTES, onDropRejected: (fileRejections: Array) => { const oversizedFiles: Array = fileRejections .filter((rejection: FileRejection) => { return rejection.file.size > MAX_FILE_SIZE_BYTES; }) .map((rejection: FileRejection) => { return rejection.file.name; }); if (oversizedFiles.length > 0) { setError(buildFileSizeError(oversizedFiles)); } }, onDrop: async (acceptedFiles: Array) => { if (props.readOnly) { return; } setIsLoading(true); setError(""); try { // Upload these files. const filesResult: Array = []; const resolveMimeType: ResolveMimeTypeFunction = ( file: File, ): MimeType | undefined => { const direct: string | undefined = file.type || undefined; if (direct && Object.values(MimeType).includes(direct as MimeType)) { return direct as MimeType; } // fallback based on extension const ext: string | undefined = file.name .split(".") .pop() ?.toLowerCase(); if (!ext) { return undefined; } const map: { [key: string]: MimeType } = { png: MimeType.png, jpg: MimeType.jpg, jpeg: MimeType.jpeg, svg: MimeType.svg, gif: MimeType.gif, webp: MimeType.webp, pdf: MimeType.pdf, doc: MimeType.doc, docx: MimeType.docx, txt: MimeType.txt, log: MimeType.txt, md: MimeType.md, markdown: MimeType.md, csv: MimeType.csv, json: MimeType.json, zip: MimeType.zip, rtf: MimeType.rtf, odt: MimeType.odt, xls: MimeType.xls, xlsx: MimeType.xlsx, ods: MimeType.ods, ppt: MimeType.ppt, pptx: MimeType.pptx, odp: MimeType.odp, }; return map[ext]; }; const oversizedFiles: Array = []; for (const acceptedFile of acceptedFiles) { if (acceptedFile.size > MAX_FILE_SIZE_BYTES) { oversizedFiles.push(acceptedFile.name); continue; } const uploadId: string = `${acceptedFile.name}-${Date.now()}-${Math.random().toString(16).slice(2)}`; addUploadStatus({ id: uploadId, name: acceptedFile.name, progress: 0, status: "uploading", }); try { const fileModel: FileModel = new FileModel(); fileModel.name = acceptedFile.name; const arrayBuffer: ArrayBuffer = await acceptedFile.arrayBuffer(); const fileBuffer: Uint8Array = new Uint8Array(arrayBuffer); fileModel.file = Buffer.from(fileBuffer); fileModel.isPublic = false; fileModel.fileType = resolveMimeType(acceptedFile) || MimeType.txt; // default to text/plain to satisfy required field const result: HTTPResponse = (await ModelAPI.create({ model: fileModel, modelType: FileModel, requestOptions: { overrideRequestUrl: CommonURL.fromURL(FILE_URL), apiRequestOptions: { onUploadProgress: (progressEvent: AxiosProgressEvent) => { updateUploadProgress( uploadId, progressEvent.total, progressEvent.loaded, ); }, }, }, })) as HTTPResponse; filesResult.push(result.data as FileModel); removeUploadStatus(uploadId); } catch (uploadErr) { const friendlyMessage: string = API.getFriendlyMessage(uploadErr); updateUploadStatus(uploadId, { status: "error", errorMessage: friendlyMessage, progress: 100, }); setError(friendlyMessage); } } if (oversizedFiles.length > 0) { setError(buildFileSizeError(oversizedFiles)); } if (filesResult.length > 0) { const updatedFiles: Array = props.isMultiFilePicker ? [...filesModel, ...filesResult] : filesResult; setFilesModel(updatedFiles); props.onBlur?.(); props.onChange?.(updatedFiles); } } catch (err) { setError(API.getFriendlyMessage(err)); } finally { setIsLoading(false); } }, }); type GetThumbsFunction = () => Array; const formatFileSize: FormatFileSizeFunction = ( file: FileModel, ): string | null => { const buffer: Buffer | undefined = file.file; if (!buffer) { return null; } const sizeInKB: number = buffer.byteLength / 1024; if (sizeInKB < 1024) { return `${sizeInKB.toFixed(1)} KB`; } return `${(sizeInKB / 1024).toFixed(2)} MB`; }; const getThumbs: GetThumbsFunction = (): Array => { return filesModel.map((file: FileModel, i: number) => { const key: string = file._id?.toString() || `${file.name || "file"}-${i}`; const removeFile: VoidFunction = (): void => { const tempFileModel: Array = [...filesModel]; tempFileModel.splice(i, 1); setFilesModel(tempFileModel); props.onChange?.(tempFileModel); }; const metadata: Array = []; if (file.fileType) { metadata.push(file.fileType); } const readableSize: string | null = formatFileSize(file); if (readableSize) { metadata.push(readableSize); } return (

{file.name || `File ${i + 1}`}

{metadata.length > 0 && (

{metadata.join(" • ")}

)}
); }); }; const hasActiveUploads: boolean = uploadStatuses.some( (upload: UploadStatus) => { return upload.status === "uploading"; }, ); return (
{ props.onClick?.(); props.onFocus?.(); }} data-testid={props.dataTestId} className={`flex w-full justify-center rounded-md border-2 border-dashed px-6 py-8 transition ${props.readOnly ? "cursor-not-allowed bg-gray-50 border-gray-200" : "bg-white border-gray-300"} ${hasActiveUploads ? "ring-1 ring-indigo-200" : ""} ${isDragActive ? "border-indigo-400" : ""}`} >
{(filesModel.length === 0 || props.isMultiFilePicker) && ( <>

{isDragActive ? "Release to start uploading" : filesModel.length === 0 ? "Click to choose files" : "Click to add more"}{" "} or drag & drop.

{props.mimeTypes && props.mimeTypes?.length > 0 && ( Types: )} {props.mimeTypes && props.mimeTypes .map((type: MimeType) => { const enumKey: string | undefined = Object.keys(MimeType)[ Object.values(MimeType).indexOf(type) ]; return enumKey?.toUpperCase() || ""; }) .filter( ( item: string | undefined, pos: number, array: Array, ) => { return array.indexOf(item) === pos; }, ) .join(", ")} {props.mimeTypes && props.mimeTypes?.length > 0 && ( . )}{" "} Max 10MB each.

{error && (

{error}

)}
)}
{uploadStatuses.length > 0 && (

{hasActiveUploads ? "Uploading files" : "Upload status"}

{uploadStatuses.map((upload: UploadStatus) => { return (

{upload.name}

{upload.status === "error" ? "Failed" : `${upload.progress}%`}
{upload.status === "error" && upload.errorMessage && (

{upload.errorMessage}

)} {upload.status === "error" && (
)}
); })}
)} {filesModel.length > 0 && (

Uploaded files

{getThumbs()}
)} {props.error && (

{props.error}

)}
); }; export default FilePicker;