I'm trying to make a reusable multiple file upload component using the KendoReact Upload component. What I have is functional but I'm fairly new to React so was hoping there is a more concise way to achieve this.
By default this component takes a URL of the endpoint for the upload / remove request, but the saveUrl / removeUrl props can also take a function to allow manipulation of uploaded files. I am using FileReader to convert the Files to base64 and taking a function that can be used for external state updates once files are uploaded.
I have the following implementation but it feels heavy as I need
- a collection of UploadFileInfo in state for files used by the Kendo control
- a collection of IFileUploadViewModel in state for the files including base64 content to be passed out
- a useEffect call to trigger external state updates to ensure updated state is passed out
- function state updates in the saveRequestPromise and removeRequestPromise to keep the control and external file collections in sync
import React, { useEffect, useState } from "react";
import { Upload, UploadFileInfo, UploadOnAddEvent, UploadOnProgressEvent, UploadOnRemoveEvent, UploadOnStatusChangeEvent } from "@progress/kendo-react-upload";
export interface IFileUploadViewModel {
uid: string;
fileName: string;
fileSize: number;
content?: string | undefined;
}
export function KendoMultipleSmallFileUpload(props: {
allowedExtensions?: string[] | undefined;
minFileSizeMb?: number | undefined;
maxFileSizeMb?: number | undefined;
onFileChange: (files: Array<IFileUploadViewModel>) => void;
}) {
// The Upload component accepts files from users only, an initial 'system' state e.g. pre-selected file determined by a viewmodel is not a valid use case.
const [files, setFiles] = useState(new Array<UploadFileInfo>());
const [fileUploads, setFileUploads] = useState(new Array<IFileUploadViewModel>());
useEffect(() => {
console.log(fileUploads);
props.onFileChange(fileUploads);
}, [fileUploads]);
const convertMbToBytes = (mb: number) => mb * 1024 * 1024;
// The Upload component does not allow empty files, convert file size restrictions to bytes and set a minimum value if not provided.
const minFileSizeBytes = props.minFileSizeMb ? convertMbToBytes(props.minFileSizeMb) : 1;
const maxFileSizeBytes = props.maxFileSizeMb ? convertMbToBytes(props.maxFileSizeMb) : undefined;
// Fires when user clicks on the Remove button while the file upload is in progress. Can be used when the saveUrl option is set to a function that cancels custom requests.
// For small files, conversion to a byte array is so quick it is impossible to Cancel through the UI and in any case is automatically followed by Remove so no implementation is required.
// For large files we may need to consider aborting the upload process through the FileReader API.
function onCancel(event: any) {}
function onAdd(event: UploadOnAddEvent) {
setFiles(event.newState);
}
function onRemove(event: UploadOnRemoveEvent) {
setFiles(event.newState);
}
function onProgress(event: UploadOnProgressEvent) {
setFiles(event.newState);
}
function onStatusChange(event: UploadOnStatusChangeEvent) {
setFiles(event.newState);
}
function onSaveRequest(
files: UploadFileInfo[],
options: { formData: FormData; requestOptions: any },
onProgress: (uid: string, event: ProgressEvent<EventTarget>) => void
): Promise<{ uid: string }> {
const currentFile = files[0] as UploadFileInfo;
const uid = currentFile.uid;
console.log("onSaveRequest for " + currentFile.name);
const saveRequestPromise = new Promise<{ uid: string }>(async (resolve, reject) => {
// as currently configured it is impossible to request a save from the UI if there are validation errors, but that can be changed so keeping this check in place
if (currentFile.validationErrors && currentFile.validationErrors.length > 0) {
reject({ uid: uid });
} else {
const reader = new FileReader();
// onload is executed when the load even is fired i.e. when content read with readAsArrayBuffer, readAsBinaryString, readAsDataURL or readAsText is available
reader.onload = () => {
if (reader.result && typeof reader.result === "string") {
// stripping the data-url declaration as per https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL
const base64Result = reader.result.split(",")[1];
// update viewModel and resolve
const fileUpload: IFileUploadViewModel = { uid: uid, fileName: currentFile.name, fileSize: currentFile.size!, content: base64Result };
setFileUploads((state) => [...state, fileUpload]);
resolve({ uid: uid });
} else {
reject({ uid: uid });
}
};
// onprogress is fired periodically as the FileReader reads data and the ProgressEvent can be passed directly to the Upload control, handy!
reader.onprogress = (data) => {
onProgress(uid, data);
};
// if the read is not completed due to error or user intervention, reject
reader.onabort = () => {
reject({ uid: uid });
};
reader.onerror = () => {
reject({ uid: uid });
};
reader.readAsDataURL(currentFile.getRawFile!());
}
});
return saveRequestPromise;
}
function onRemoveRequest(files: UploadFileInfo[], options: { formData: FormData; requestOptions: any }): Promise<{ uid: string }> {
const currentFile = files[0] as UploadFileInfo;
const uid = currentFile.uid;
const removeRequestPromise = new Promise<{ uid: string }>((resolve) => {
const updatedFileUploads = fileUploads.filter((f) => f.uid !== uid);
setFileUploads(updatedFileUploads);
props.onFileChange(updatedFileUploads);
resolve({ uid: uid });
});
return removeRequestPromise;
}
return (
<>
<Upload
className="mb-2"
autoUpload={true}
batch={false}
files={files}
multiple={true}
onAdd={onAdd}
onCancel={onCancel}
onProgress={onProgress}
onRemove={onRemove}
onStatusChange={onStatusChange}
withCredentials={false}
removeUrl={onRemoveRequest}
saveUrl={onSaveRequest}
restrictions={{ allowedExtensions: props.allowedExtensions, minFileSize: minFileSizeBytes, maxFileSize: maxFileSizeBytes }}
/>
<div className="d-flex flex-row flex-wrap small">
{props.minFileSizeMb && (
<div className="d-flex flex-row mr-2 mb-2">
<span className="label font-weight-bold mr-1">Minimum File Size: </span>
<span>{props.minFileSizeMb} MB.</span>
</div>
)}
{props.maxFileSizeMb && (
<div className="d-flex flex-row mr-2 mb-2">
<span className="label font-weight-bold mr-1">Maximum File Size: </span>
<span>{props.maxFileSizeMb} MB.</span>
</div>
)}
{props.allowedExtensions && (
<div className="d-flex flex-row mr-2 mb-2">
<span className="label font-weight-bold mr-1">Supported File Types: </span>
<span>{props.allowedExtensions.join(", ")}.</span>
</div>
)}
</div>
</>
);
}
Any suggestions would be appreciated.