From a436c1b658f2597675a9e4c23282cad48cb7986c Mon Sep 17 00:00:00 2001 From: vincanger <70215737+vincanger@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:24:33 +0100 Subject: [PATCH] improve file upload (progress bar & validation) (#317) * validate file * fix app_diff * rename types and functions * use accessible custom progress bar over native html --- .../src/file-upload/operations.ts.diff | 2 +- .../app/src/file-upload/FileUploadPage.tsx | 129 ++++++++++++------ template/app/src/file-upload/fileUploading.ts | 54 ++++++++ template/app/src/file-upload/operations.ts | 5 +- template/app/src/file-upload/s3Utils.ts | 2 +- 5 files changed, 143 insertions(+), 49 deletions(-) create mode 100644 template/app/src/file-upload/fileUploading.ts diff --git a/opensaas-sh/app_diff/src/file-upload/operations.ts.diff b/opensaas-sh/app_diff/src/file-upload/operations.ts.diff index 9a73cf6..d7a58ae 100644 --- a/opensaas-sh/app_diff/src/file-upload/operations.ts.diff +++ b/opensaas-sh/app_diff/src/file-upload/operations.ts.diff @@ -1,6 +1,6 @@ --- template/app/src/file-upload/operations.ts +++ opensaas-sh/app/src/file-upload/operations.ts -@@ -21,6 +21,18 @@ +@@ -18,6 +18,18 @@ throw new HttpError(401); } diff --git a/template/app/src/file-upload/FileUploadPage.tsx b/template/app/src/file-upload/FileUploadPage.tsx index f9d7ca8..74c7145 100644 --- a/template/app/src/file-upload/FileUploadPage.tsx +++ b/template/app/src/file-upload/FileUploadPage.tsx @@ -1,20 +1,31 @@ -import { createFile, useQuery, getAllFilesByUser, getDownloadFileSignedURL } from 'wasp/client/operations'; -import axios from 'axios'; -import { useState, useEffect, FormEvent } from 'react'; import { cn } from '../client/cn'; +import { useState, useEffect, FormEvent } from 'react'; +import type { File } from 'wasp/entities'; +import { useQuery, getAllFilesByUser, getDownloadFileSignedURL } from 'wasp/client/operations'; +import { type FileUploadError, uploadFileWithProgress, validateFile, ALLOWED_FILE_TYPES } from './fileUploading'; export default function FileUploadPage() { - const [fileToDownload, setFileToDownload] = useState(''); + const [fileKeyForS3, setFileKeyForS3] = useState(''); + const [uploadProgressPercent, setUploadProgressPercent] = useState(0); + const [uploadError, setUploadError] = useState(null); - const { data: files, error: filesError, isLoading: isFilesLoading } = useQuery(getAllFilesByUser); + const allUserFiles = useQuery(getAllFilesByUser, undefined, { + // We disable automatic refetching because otherwise files would be refetched after `createFile` is called and the S3 URL is returned, + // which happens before the file is actually fully uploaded. Instead, we manually (re)fetch on mount and after the upload is complete. + enabled: false, + }); const { isLoading: isDownloadUrlLoading, refetch: refetchDownloadUrl } = useQuery( getDownloadFileSignedURL, - { key: fileToDownload }, + { key: fileKeyForS3 }, { enabled: false } ); useEffect(() => { - if (fileToDownload.length > 0) { + allUserFiles.refetch(); + }, []); + + useEffect(() => { + if (fileKeyForS3.length > 0) { refetchDownloadUrl() .then((urlQuery) => { switch (urlQuery.status) { @@ -28,38 +39,49 @@ export default function FileUploadPage() { } }) .finally(() => { - setFileToDownload(''); + setFileKeyForS3(''); }); } - }, [fileToDownload]); + }, [fileKeyForS3]); const handleUpload = async (e: FormEvent) => { try { e.preventDefault(); - const formData = new FormData(e.target as HTMLFormElement); - const file = formData.get('file-upload') as File; - if (!file || !file.name || !file.type) { - throw new Error('No file selected'); + + const formElement = e.target; + if (!(formElement instanceof HTMLFormElement)) { + throw new Error('Event target is not a form element'); } - const fileType = file.type; - const name = file.name; + const formData = new FormData(formElement); + const file = formData.get('file-upload'); - const { uploadUrl } = await createFile({ fileType, name }); - if (!uploadUrl) { - throw new Error('Failed to get upload URL'); + if (!file || !(file instanceof File)) { + setUploadError({ + message: 'Please select a file to upload.', + code: 'NO_FILE', + }); + return; } - const res = await axios.put(uploadUrl, file, { - headers: { - 'Content-Type': fileType, - }, - }); - if (res.status !== 200) { - throw new Error('File upload to S3 failed'); + + const validationError = validateFile(file); + if (validationError) { + setUploadError(validationError); + return; } + + await uploadFileWithProgress({ file, setUploadProgressPercent }); + formElement.reset(); + allUserFiles.refetch(); } catch (error) { - alert('Error uploading file. Please try again'); - console.error('Error uploading file', error); + console.error('Error uploading file:', error); + setUploadError({ + message: + error instanceof Error ? error.message : 'An unexpected error occurred while uploading the file.', + code: 'UPLOAD_FAILED', + }); + } finally { + setUploadProgressPercent(0); } }; @@ -72,45 +94,66 @@ export default function FileUploadPage() {

- This is an example file upload page using AWS S3. Maybe your app needs this. Maybe it doesn't. But a lot of - people asked for this feature, so here you go 🤝 + This is an example file upload page using AWS S3. Maybe your app needs this. Maybe it doesn't. But a + lot of people asked for this feature, so here you go 🤝

setUploadError(null)} /> + {uploadError &&
{uploadError.message}
}

Uploaded Files

- {isFilesLoading &&

Loading...

} - {filesError &&

Error: {filesError.message}

} - {!!files && files.length > 0 ? ( - files.map((file: any) => ( + {allUserFiles.isLoading &&

Loading...

} + {allUserFiles.error &&

Error: {allUserFiles.error.message}

} + {!!allUserFiles.data && allUserFiles.data.length > 0 && !allUserFiles.isLoading ? ( + allUserFiles.data.map((file: File) => (

{file.name}

)) diff --git a/template/app/src/file-upload/fileUploading.ts b/template/app/src/file-upload/fileUploading.ts new file mode 100644 index 0000000..50ec5d5 --- /dev/null +++ b/template/app/src/file-upload/fileUploading.ts @@ -0,0 +1,54 @@ +import { Dispatch, SetStateAction } from 'react'; +import { createFile } from 'wasp/client/operations'; +import axios from 'axios'; + +interface FileUploadProgress { + file: File; + setUploadProgressPercent: Dispatch>; +} + +export interface FileUploadError { + message: string; + code: 'NO_FILE' | 'INVALID_FILE_TYPE' | 'FILE_TOO_LARGE' | 'UPLOAD_FAILED'; +} + +export const MAX_FILE_SIZE = 5 * 1024 * 1024; // Set this to the max file size you want to allow (currently 5MB). +export const ALLOWED_FILE_TYPES = [ + 'image/jpeg', + 'image/png', + 'application/pdf', + 'text/*', + 'video/quicktime', + 'video/mp4', +]; + +export async function uploadFileWithProgress({ file, setUploadProgressPercent }: FileUploadProgress) { + const { uploadUrl } = await createFile({ fileType: file.type, name: file.name }); + return await axios.put(uploadUrl, file, { + headers: { + 'Content-Type': file.type, + }, + onUploadProgress: (progressEvent) => { + if (progressEvent.total) { + const percentage = Math.round((progressEvent.loaded / progressEvent.total) * 100); + setUploadProgressPercent(percentage); + } + }, + }); +} + +export function validateFile(file: File): FileUploadError | null { + if (file.size > MAX_FILE_SIZE) { + return { + message: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`, + code: 'FILE_TOO_LARGE', + }; + } + if (!ALLOWED_FILE_TYPES.includes(file.type)) { + return { + message: `File type '${file.type}' is not supported.`, + code: 'INVALID_FILE_TYPE', + }; + } + return null; +} diff --git a/template/app/src/file-upload/operations.ts b/template/app/src/file-upload/operations.ts index fbc5df5..59ab513 100644 --- a/template/app/src/file-upload/operations.ts +++ b/template/app/src/file-upload/operations.ts @@ -6,10 +6,7 @@ import { type GetDownloadFileSignedURL, } from 'wasp/server/operations'; -import { - getUploadFileSignedURLFromS3, - getDownloadFileSignedURLFromS3 -} from './s3Utils'; +import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils'; type FileDescription = { fileType: string; diff --git a/template/app/src/file-upload/s3Utils.ts b/template/app/src/file-upload/s3Utils.ts index 2480649..709f805 100644 --- a/template/app/src/file-upload/s3Utils.ts +++ b/template/app/src/file-upload/s3Utils.ts @@ -36,4 +36,4 @@ export const getDownloadFileSignedURLFromS3 = async ({ key }: { key: string }) = }; const command = new GetObjectCommand(s3Params); return await getSignedUrl(s3Client, command, { expiresIn: 3600 }); -} \ No newline at end of file +}