Skip to content

Commit a436c1b

Browse files
authored
improve file upload (progress bar & validation) (wasp-lang#317)
* validate file * fix app_diff * rename types and functions * use accessible custom progress bar over native html
1 parent 49ce9ae commit a436c1b

File tree

5 files changed

+143
-49
lines changed

5 files changed

+143
-49
lines changed

opensaas-sh/app_diff/src/file-upload/operations.ts.diff

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
--- template/app/src/file-upload/operations.ts
22
+++ opensaas-sh/app/src/file-upload/operations.ts
3-
@@ -21,6 +21,18 @@
3+
@@ -18,6 +18,18 @@
44
throw new HttpError(401);
55
}
66

template/app/src/file-upload/FileUploadPage.tsx

Lines changed: 86 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,31 @@
1-
import { createFile, useQuery, getAllFilesByUser, getDownloadFileSignedURL } from 'wasp/client/operations';
2-
import axios from 'axios';
3-
import { useState, useEffect, FormEvent } from 'react';
41
import { cn } from '../client/cn';
2+
import { useState, useEffect, FormEvent } from 'react';
3+
import type { File } from 'wasp/entities';
4+
import { useQuery, getAllFilesByUser, getDownloadFileSignedURL } from 'wasp/client/operations';
5+
import { type FileUploadError, uploadFileWithProgress, validateFile, ALLOWED_FILE_TYPES } from './fileUploading';
56

67
export default function FileUploadPage() {
7-
const [fileToDownload, setFileToDownload] = useState<string>('');
8+
const [fileKeyForS3, setFileKeyForS3] = useState<File['key']>('');
9+
const [uploadProgressPercent, setUploadProgressPercent] = useState<number>(0);
10+
const [uploadError, setUploadError] = useState<FileUploadError | null>(null);
811

9-
const { data: files, error: filesError, isLoading: isFilesLoading } = useQuery(getAllFilesByUser);
12+
const allUserFiles = useQuery(getAllFilesByUser, undefined, {
13+
// We disable automatic refetching because otherwise files would be refetched after `createFile` is called and the S3 URL is returned,
14+
// which happens before the file is actually fully uploaded. Instead, we manually (re)fetch on mount and after the upload is complete.
15+
enabled: false,
16+
});
1017
const { isLoading: isDownloadUrlLoading, refetch: refetchDownloadUrl } = useQuery(
1118
getDownloadFileSignedURL,
12-
{ key: fileToDownload },
19+
{ key: fileKeyForS3 },
1320
{ enabled: false }
1421
);
1522

1623
useEffect(() => {
17-
if (fileToDownload.length > 0) {
24+
allUserFiles.refetch();
25+
}, []);
26+
27+
useEffect(() => {
28+
if (fileKeyForS3.length > 0) {
1829
refetchDownloadUrl()
1930
.then((urlQuery) => {
2031
switch (urlQuery.status) {
@@ -28,38 +39,49 @@ export default function FileUploadPage() {
2839
}
2940
})
3041
.finally(() => {
31-
setFileToDownload('');
42+
setFileKeyForS3('');
3243
});
3344
}
34-
}, [fileToDownload]);
45+
}, [fileKeyForS3]);
3546

3647
const handleUpload = async (e: FormEvent<HTMLFormElement>) => {
3748
try {
3849
e.preventDefault();
39-
const formData = new FormData(e.target as HTMLFormElement);
40-
const file = formData.get('file-upload') as File;
41-
if (!file || !file.name || !file.type) {
42-
throw new Error('No file selected');
50+
51+
const formElement = e.target;
52+
if (!(formElement instanceof HTMLFormElement)) {
53+
throw new Error('Event target is not a form element');
4354
}
4455

45-
const fileType = file.type;
46-
const name = file.name;
56+
const formData = new FormData(formElement);
57+
const file = formData.get('file-upload');
4758

48-
const { uploadUrl } = await createFile({ fileType, name });
49-
if (!uploadUrl) {
50-
throw new Error('Failed to get upload URL');
59+
if (!file || !(file instanceof File)) {
60+
setUploadError({
61+
message: 'Please select a file to upload.',
62+
code: 'NO_FILE',
63+
});
64+
return;
5165
}
52-
const res = await axios.put(uploadUrl, file, {
53-
headers: {
54-
'Content-Type': fileType,
55-
},
56-
});
57-
if (res.status !== 200) {
58-
throw new Error('File upload to S3 failed');
66+
67+
const validationError = validateFile(file);
68+
if (validationError) {
69+
setUploadError(validationError);
70+
return;
5971
}
72+
73+
await uploadFileWithProgress({ file, setUploadProgressPercent });
74+
formElement.reset();
75+
allUserFiles.refetch();
6076
} catch (error) {
61-
alert('Error uploading file. Please try again');
62-
console.error('Error uploading file', error);
77+
console.error('Error uploading file:', error);
78+
setUploadError({
79+
message:
80+
error instanceof Error ? error.message : 'An unexpected error occurred while uploading the file.',
81+
code: 'UPLOAD_FAILED',
82+
});
83+
} finally {
84+
setUploadProgressPercent(0);
6385
}
6486
};
6587

@@ -72,45 +94,66 @@ export default function FileUploadPage() {
7294
</h2>
7395
</div>
7496
<p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-gray-600 dark:text-white'>
75-
This is an example file upload page using AWS S3. Maybe your app needs this. Maybe it doesn't. But a lot of
76-
people asked for this feature, so here you go 🤝
97+
This is an example file upload page using AWS S3. Maybe your app needs this. Maybe it doesn't. But a
98+
lot of people asked for this feature, so here you go 🤝
7799
</p>
78100
<div className='my-8 border rounded-3xl border-gray-900/10 dark:border-gray-100/10'>
79101
<div className='space-y-10 my-10 py-8 px-4 mx-auto sm:max-w-lg'>
80102
<form onSubmit={handleUpload} className='flex flex-col gap-2'>
81103
<input
82104
type='file'
105+
id='file-upload'
83106
name='file-upload'
84-
accept='image/jpeg, image/png, .pdf, text/*'
85-
className='text-gray-600 '
107+
accept={ALLOWED_FILE_TYPES.join(',')}
108+
className='text-gray-600'
109+
onChange={() => setUploadError(null)}
86110
/>
87111
<button
88112
type='submit'
89-
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'
113+
disabled={uploadProgressPercent > 0}
114+
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'
90115
>
91-
Upload
116+
{uploadProgressPercent > 0 ? (
117+
<>
118+
<span>Uploading {uploadProgressPercent}%</span>
119+
<div
120+
role="progressbar"
121+
aria-valuenow={uploadProgressPercent}
122+
aria-valuemin={0}
123+
aria-valuemax={100}
124+
className="absolute bottom-0 left-0 h-1 bg-yellow-500 transition-all duration-300 ease-in-out rounded-b-md"
125+
style={{ width: `${uploadProgressPercent}%` }}
126+
></div>
127+
</>
128+
) : (
129+
'Upload'
130+
)}
92131
</button>
132+
{uploadError && <div className='text-red-500'>{uploadError.message}</div>}
93133
</form>
94134
<div className='border-b-2 border-gray-200 dark:border-gray-100/10'></div>
95135
<div className='space-y-4 col-span-full'>
96136
<h2 className='text-xl font-bold'>Uploaded Files</h2>
97-
{isFilesLoading && <p>Loading...</p>}
98-
{filesError && <p>Error: {filesError.message}</p>}
99-
{!!files && files.length > 0 ? (
100-
files.map((file: any) => (
137+
{allUserFiles.isLoading && <p>Loading...</p>}
138+
{allUserFiles.error && <p>Error: {allUserFiles.error.message}</p>}
139+
{!!allUserFiles.data && allUserFiles.data.length > 0 && !allUserFiles.isLoading ? (
140+
allUserFiles.data.map((file: File) => (
101141
<div
102142
key={file.key}
103-
className={cn('flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3', {
104-
'opacity-70': file.key === fileToDownload && isDownloadUrlLoading,
105-
})}
143+
className={cn(
144+
'flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3',
145+
{
146+
'opacity-70': file.key === fileKeyForS3 && isDownloadUrlLoading,
147+
}
148+
)}
106149
>
107150
<p>{file.name}</p>
108151
<button
109-
onClick={() => setFileToDownload(file.key)}
110-
disabled={file.key === fileToDownload && isDownloadUrlLoading}
152+
onClick={() => setFileKeyForS3(file.key)}
153+
disabled={file.key === fileKeyForS3 && isDownloadUrlLoading}
111154
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'
112155
>
113-
{file.key === fileToDownload && isDownloadUrlLoading ? 'Loading...' : 'Download'}
156+
{file.key === fileKeyForS3 && isDownloadUrlLoading ? 'Loading...' : 'Download'}
114157
</button>
115158
</div>
116159
))
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Dispatch, SetStateAction } from 'react';
2+
import { createFile } from 'wasp/client/operations';
3+
import axios from 'axios';
4+
5+
interface FileUploadProgress {
6+
file: File;
7+
setUploadProgressPercent: Dispatch<SetStateAction<number>>;
8+
}
9+
10+
export interface FileUploadError {
11+
message: string;
12+
code: 'NO_FILE' | 'INVALID_FILE_TYPE' | 'FILE_TOO_LARGE' | 'UPLOAD_FAILED';
13+
}
14+
15+
export const MAX_FILE_SIZE = 5 * 1024 * 1024; // Set this to the max file size you want to allow (currently 5MB).
16+
export const ALLOWED_FILE_TYPES = [
17+
'image/jpeg',
18+
'image/png',
19+
'application/pdf',
20+
'text/*',
21+
'video/quicktime',
22+
'video/mp4',
23+
];
24+
25+
export async function uploadFileWithProgress({ file, setUploadProgressPercent }: FileUploadProgress) {
26+
const { uploadUrl } = await createFile({ fileType: file.type, name: file.name });
27+
return await axios.put(uploadUrl, file, {
28+
headers: {
29+
'Content-Type': file.type,
30+
},
31+
onUploadProgress: (progressEvent) => {
32+
if (progressEvent.total) {
33+
const percentage = Math.round((progressEvent.loaded / progressEvent.total) * 100);
34+
setUploadProgressPercent(percentage);
35+
}
36+
},
37+
});
38+
}
39+
40+
export function validateFile(file: File): FileUploadError | null {
41+
if (file.size > MAX_FILE_SIZE) {
42+
return {
43+
message: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
44+
code: 'FILE_TOO_LARGE',
45+
};
46+
}
47+
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
48+
return {
49+
message: `File type '${file.type}' is not supported.`,
50+
code: 'INVALID_FILE_TYPE',
51+
};
52+
}
53+
return null;
54+
}

template/app/src/file-upload/operations.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@ import {
66
type GetDownloadFileSignedURL,
77
} from 'wasp/server/operations';
88

9-
import {
10-
getUploadFileSignedURLFromS3,
11-
getDownloadFileSignedURLFromS3
12-
} from './s3Utils';
9+
import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils';
1310

1411
type FileDescription = {
1512
fileType: string;

template/app/src/file-upload/s3Utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,4 @@ export const getDownloadFileSignedURLFromS3 = async ({ key }: { key: string }) =
3636
};
3737
const command = new GetObjectCommand(s3Params);
3838
return await getSignedUrl(s3Client, command, { expiresIn: 3600 });
39-
}
39+
}

0 commit comments

Comments
 (0)