Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

improve file upload (progress bar & validation) #317

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion opensaas-sh/app_diff/src/file-upload/operations.ts.diff
Original file line number Diff line number Diff line change
@@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
+ className='flex items-center -m-1.5 p-1.5 text-gray-900 duration-300 ease-in-out hover:text-yellow-500 dark:text-white'
>
<NavLogo />
- <span className='ml-2 text-sm font-semibold leading-6 dark:text-white'>Your Saas</span>
- <span className='ml-2 text-sm font-semibold leading-6 dark:text-white'>Your SaaS</span>
+ <span className='ml-2 text-sm font-semibold leading-6 dark:text-white'>Open SaaS</span>
</a>
</div>
Expand Down
102 changes: 70 additions & 32 deletions template/app/src/file-upload/FileUploadPage.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
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 { 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 [uploadProgress, setUploadProgress] = useState<number>(0);
const [uploadError, setUploadError] = useState<FileUploadError | null>(null);

const { data: files, error: filesError, isLoading: isFilesLoading } = useQuery(getAllFilesByUser);
const { data: files, error: filesError, isLoading: isFilesLoading, refetch: refetchFiles } = 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 },
{ enabled: false }
);

useEffect(() => {
refetchFiles();
}, []);

useEffect(() => {
if (fileToDownload.length > 0) {
refetchDownloadUrl()
Expand All @@ -36,30 +46,41 @@ export default function FileUploadPage() {
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, setUploadProgress });
formElement.reset();
refetchFiles();
} 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 {
setUploadProgress(0);
}
};

Expand All @@ -72,37 +93,54 @@ 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={uploadProgress > 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
{uploadProgress > 0 ? (
<>
<span>Uploading {uploadProgress}%</span>
<div
className='absolute bottom-0 left-0 h-1 bg-yellow-500 transition-all duration-300 ease-in-out rounded-b-md'
style={{ width: `${uploadProgress}%` }}
></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 && files.length > 0 && !isFilesLoading ? (
files.map((file: any) => (
<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 === fileToDownload && isDownloadUrlLoading,
}
)}
>
<p>{file.name}</p>
<button
Expand Down
58 changes: 58 additions & 0 deletions template/app/src/file-upload/fileUploading.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Dispatch, SetStateAction } from 'react';
import { createFile } from 'wasp/client/operations';
import axios from 'axios';

interface UploadFileProgress {
file: File;
setUploadProgress: 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; // 5 MB limit
export const ALLOWED_FILE_TYPES = [
'image/jpeg',
'image/png',
'application/pdf',
'text/*',
'video/quicktime',
'video/mp4',
];

export async function uploadFileWithProgress({ file, setUploadProgress }: UploadFileProgress) {
const fileType = file.type;
const name = file.name;

const { uploadUrl } = await createFile({ fileType, name });

return await axios.put(uploadUrl, file, {
headers: {
'Content-Type': fileType,
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const percentage = Math.round((progressEvent.loaded / progressEvent.total) * 100);
setUploadProgress(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;
}
5 changes: 1 addition & 4 deletions template/app/src/file-upload/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion template/app/src/file-upload/s3Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ export const getDownloadFileSignedURLFromS3 = async ({ key }: { key: string }) =
};
const command = new GetObjectCommand(s3Params);
return await getSignedUrl(s3Client, command, { expiresIn: 3600 });
}
}