Files
oneuptime/Common/UI/Components/FilePicker/FilePicker.tsx

572 lines
19 KiB
TypeScript

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> | FileModel;
onClick?: undefined | (() => void);
placeholder?: undefined | string;
className?: undefined | string;
onChange?: undefined | ((value: Array<FileModel>) => void);
value?: Array<FileModel> | undefined;
readOnly?: boolean | undefined;
mimeTypes?: Array<MimeType> | 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<UploadStatus>,
) => void;
type UpdateUploadProgressFunction = (
id: string,
total?: number,
loaded?: number,
) => void;
type RemoveUploadStatusFunction = (id: string) => void;
type BuildFileSizeErrorFunction = (fileNames: Array<string>) => 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<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [filesModel, setFilesModel] = useState<Array<FileModel>>([]);
const [acceptTypes, setAcceptTypes] = useState<Dictionary<Array<string>>>({});
const [uploadStatuses, setUploadStatuses] = useState<Array<UploadStatus>>([]);
const addUploadStatus: AddUploadStatusFunction = (
status: UploadStatus,
): void => {
setUploadStatuses((current: Array<UploadStatus>) => {
return [...current, status];
});
};
const updateUploadStatus: UpdateUploadStatusFunction = (
id: string,
updates: Partial<UploadStatus>,
): void => {
setUploadStatuses((current: Array<UploadStatus>) => {
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<UploadStatus>) => {
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<UploadStatus>) => {
return current.filter((upload: UploadStatus) => {
return upload.id !== id;
});
});
};
useEffect(() => {
const _acceptTypes: Dictionary<Array<string>> = {};
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>,
): 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<FileRejection>) => {
const oversizedFiles: Array<string> = 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<File>) => {
if (props.readOnly) {
return;
}
setIsLoading(true);
setError("");
try {
// Upload these files.
const filesResult: Array<FileModel> = [];
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<string> = [];
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<FileModel> =
(await ModelAPI.create<FileModel>({
model: fileModel,
modelType: FileModel,
requestOptions: {
overrideRequestUrl: CommonURL.fromURL(FILE_URL),
apiRequestOptions: {
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
updateUploadProgress(
uploadId,
progressEvent.total,
progressEvent.loaded,
);
},
},
},
})) as HTTPResponse<FileModel>;
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<FileModel> = props.isMultiFilePicker
? [...filesModel, ...filesResult]
: filesResult;
setFilesModel(updatedFiles);
props.onBlur?.();
props.onChange?.(updatedFiles);
}
} catch (err) {
setError(API.getFriendlyMessage(err));
} finally {
setIsLoading(false);
}
},
});
type GetThumbsFunction = () => Array<ReactElement>;
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<ReactElement> => {
return filesModel.map((file: FileModel, i: number) => {
const key: string = file._id?.toString() || `${file.name || "file"}-${i}`;
const removeFile: VoidFunction = (): void => {
const tempFileModel: Array<FileModel> = [...filesModel];
tempFileModel.splice(i, 1);
setFilesModel(tempFileModel);
props.onChange?.(tempFileModel);
};
const metadata: Array<string> = [];
if (file.fileType) {
metadata.push(file.fileType);
}
const readableSize: string | null = formatFileSize(file);
if (readableSize) {
metadata.push(readableSize);
}
return (
<div
key={key}
className="flex w-full items-center justify-between gap-4 rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-sm"
>
<div className="flex items-start gap-3 text-left">
<div className="flex h-10 w-10 items-center justify-center rounded border border-gray-200 bg-gray-50">
<Icon icon={IconProp.File} className="text-gray-500" />
</div>
<div className="flex flex-col">
<p className="text-sm font-medium text-gray-900">
{file.name || `File ${i + 1}`}
</p>
{metadata.length > 0 && (
<p className="text-xs text-gray-500">{metadata.join(" • ")}</p>
)}
</div>
</div>
<button
type="button"
className="rounded-md border border-gray-200 px-3 py-1 text-xs font-medium text-gray-700 transition hover:bg-gray-50"
onClick={removeFile}
>
Remove
</button>
</div>
);
});
};
const hasActiveUploads: boolean = uploadStatuses.some(
(upload: UploadStatus) => {
return upload.status === "uploading";
},
);
return (
<div className="space-y-4 w-full">
<div
onClick={() => {
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" : ""}`}
>
<div
{...getRootProps({
className:
"w-full flex flex-col items-center justify-center space-y-3 text-center",
"aria-busy": hasActiveUploads || isLoading,
})}
>
{(filesModel.length === 0 || props.isMultiFilePicker) && (
<>
<div className="flex flex-col items-center space-y-2">
<svg
className="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
aria-hidden="true"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
<div className="flex flex-col items-center text-sm text-gray-600 space-y-1">
<label className="relative cursor-pointer rounded-md bg-white px-4 py-2 font-medium text-indigo-600 hover:text-indigo-500">
<span>
{props.placeholder
? props.placeholder
: filesModel.length > 0
? "Add more files"
: "Upload files"}
</span>
<input
tabIndex={props.tabIndex}
{...(getInputProps() as any)}
id="file-upload"
name="file-upload"
type="file"
className="sr-only"
/>
</label>
<p className="text-gray-500">
{isDragActive
? "Release to start uploading"
: filesModel.length === 0
? "Click to choose files"
: "Click to add more"}{" "}
or drag & drop.
</p>
<p className="text-xs text-gray-500">
{props.mimeTypes && props.mimeTypes?.length > 0 && (
<span>Types: </span>
)}
{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<string | undefined>,
) => {
return array.indexOf(item) === pos;
},
)
.join(", ")}
{props.mimeTypes && props.mimeTypes?.length > 0 && (
<span>.</span>
)}{" "}
Max 10MB each.
</p>
{error && (
<p className="text-xs text-red-500 font-medium">{error}</p>
)}
</div>
</div>
</>
)}
</div>
</div>
{uploadStatuses.length > 0 && (
<div className="space-y-2 w-full">
<p className="text-sm font-medium text-gray-700 text-left">
{hasActiveUploads ? "Uploading files" : "Upload status"}
</p>
<div className="space-y-2">
{uploadStatuses.map((upload: UploadStatus) => {
return (
<div
key={upload.id}
className={`rounded border px-3 py-2 ${upload.status === "error" ? "border-red-200 bg-red-50" : "border-gray-200 bg-white"}`}
>
<div className="flex items-center justify-between text-sm">
<p className="font-medium text-gray-800 truncate">
{upload.name}
</p>
<span
className={`text-xs ${upload.status === "error" ? "text-red-600" : "text-gray-500"}`}
>
{upload.status === "error"
? "Failed"
: `${upload.progress}%`}
</span>
</div>
<div className="mt-2 h-2 rounded bg-gray-200 overflow-hidden">
<div
className={`h-full transition-all duration-300 ${upload.status === "error" ? "bg-red-400" : "bg-indigo-500"}`}
style={{ width: `${Math.min(upload.progress, 100)}%` }}
></div>
</div>
{upload.status === "error" && upload.errorMessage && (
<p className="mt-2 text-xs text-red-600 text-left">
{upload.errorMessage}
</p>
)}
{upload.status === "error" && (
<div className="mt-2 text-right">
<button
type="button"
className="text-xs font-medium text-gray-600 hover:text-gray-800"
onClick={() => {
removeUploadStatus(upload.id);
}}
>
Dismiss
</button>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{filesModel.length > 0 && (
<div className="space-y-2 w-full">
<p className="text-sm font-medium text-gray-700 text-left">
Uploaded files
</p>
<div className="flex flex-wrap gap-4">{getThumbs()}</div>
</div>
)}
{props.error && (
<p data-testid="error-message" className="text-sm text-red-400">
{props.error}
</p>
)}
</div>
);
};
export default FilePicker;