mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
572 lines
19 KiB
TypeScript
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;
|