improve file upload (progress bar & validation) (#317)

* validate file

* fix app_diff

* rename types and functions

* use accessible custom progress bar over native html
This commit is contained in:
vincanger 2024-12-10 17:24:33 +01:00 committed by GitHub
parent 49ce9aee9e
commit a436c1b658
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 143 additions and 49 deletions

View File

@ -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);
}

View File

@ -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<string>('');
const [fileKeyForS3, setFileKeyForS3] = useState<File['key']>('');
const [uploadProgressPercent, setUploadProgressPercent] = useState<number>(0);
const [uploadError, setUploadError] = useState<FileUploadError | null>(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<HTMLFormElement>) => {
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() {
</h2>
</div>
<p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-gray-600 dark:text-white'>
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 🤝
</p>
<div className='my-8 border rounded-3xl border-gray-900/10 dark:border-gray-100/10'>
<div className='space-y-10 my-10 py-8 px-4 mx-auto sm:max-w-lg'>
<form onSubmit={handleUpload} className='flex flex-col gap-2'>
<input
type='file'
id='file-upload'
name='file-upload'
accept='image/jpeg, image/png, .pdf, text/*'
className='text-gray-600 '
accept={ALLOWED_FILE_TYPES.join(',')}
className='text-gray-600'
onChange={() => setUploadError(null)}
/>
<button
type='submit'
className='min-w-[7rem] font-medium text-gray-800/90 bg-yellow-50 shadow-md ring-1 ring-inset ring-slate-200 py-2 px-4 rounded-md hover:bg-yellow-100 duration-200 ease-in-out focus:outline-none focus:shadow-none hover:shadow-none'
disabled={uploadProgressPercent > 0}
className='min-w-[7rem] relative font-medium text-gray-800/90 bg-yellow-50 shadow-md ring-1 ring-inset ring-slate-200 py-2 px-4 rounded-md hover:bg-yellow-100 duration-200 ease-in-out focus:outline-none focus:shadow-none hover:shadow-none disabled:cursor-progress'
>
Upload
{uploadProgressPercent > 0 ? (
<>
<span>Uploading {uploadProgressPercent}%</span>
<div
role="progressbar"
aria-valuenow={uploadProgressPercent}
aria-valuemin={0}
aria-valuemax={100}
className="absolute bottom-0 left-0 h-1 bg-yellow-500 transition-all duration-300 ease-in-out rounded-b-md"
style={{ width: `${uploadProgressPercent}%` }}
></div>
</>
) : (
'Upload'
)}
</button>
{uploadError && <div className='text-red-500'>{uploadError.message}</div>}
</form>
<div className='border-b-2 border-gray-200 dark:border-gray-100/10'></div>
<div className='space-y-4 col-span-full'>
<h2 className='text-xl font-bold'>Uploaded Files</h2>
{isFilesLoading && <p>Loading...</p>}
{filesError && <p>Error: {filesError.message}</p>}
{!!files && files.length > 0 ? (
files.map((file: any) => (
{allUserFiles.isLoading && <p>Loading...</p>}
{allUserFiles.error && <p>Error: {allUserFiles.error.message}</p>}
{!!allUserFiles.data && allUserFiles.data.length > 0 && !allUserFiles.isLoading ? (
allUserFiles.data.map((file: File) => (
<div
key={file.key}
className={cn('flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3', {
'opacity-70': file.key === fileToDownload && isDownloadUrlLoading,
})}
className={cn(
'flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3',
{
'opacity-70': file.key === fileKeyForS3 && isDownloadUrlLoading,
}
)}
>
<p>{file.name}</p>
<button
onClick={() => setFileToDownload(file.key)}
disabled={file.key === fileToDownload && isDownloadUrlLoading}
onClick={() => setFileKeyForS3(file.key)}
disabled={file.key === fileKeyForS3 && isDownloadUrlLoading}
className='min-w-[7rem] text-sm text-gray-800/90 bg-purple-50 shadow-md ring-1 ring-inset ring-slate-200 py-1 px-2 rounded-md hover:bg-purple-100 duration-200 ease-in-out focus:outline-none focus:shadow-none hover:shadow-none disabled:cursor-not-allowed'
>
{file.key === fileToDownload && isDownloadUrlLoading ? 'Loading...' : 'Download'}
{file.key === fileKeyForS3 && isDownloadUrlLoading ? 'Loading...' : 'Download'}
</button>
</div>
))

View File

@ -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<SetStateAction<number>>;
}
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;
}

View File

@ -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;

View File

@ -36,4 +36,4 @@ export const getDownloadFileSignedURLFromS3 = async ({ key }: { key: string }) =
};
const command = new GetObjectCommand(s3Params);
return await getSignedUrl(s3Client, command, { expiresIn: 3600 });
}
}