Skip to content
Draft
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
32 changes: 31 additions & 1 deletion apps/frontend/src/app/components/AddExpenseModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import { useState } from 'react';
import { Button, Dialog, Portal, CloseButton, Stack } from '@chakra-ui/react';
import DropdownSelector from './DropdownSelector';
import { useApi } from '@/hooks/useApi';
import FileUpload from './FileUpload';
import FileUpload, { UploadProgress } from './FileUpload';
import { putWithProgress } from '@/lib/upload';
import { FiDollarSign } from 'react-icons/fi';
import { Project } from '@/types';

const RECEIPT_CONTENT_TYPE = 'application/pdf';
interface AddExpenseModalProps {
open: boolean;
onClose: () => void;
Expand Down Expand Up @@ -38,13 +41,18 @@ export default function AddExpenseModal({
const [projectError, setProjectError] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [fileError, setFileError] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState<UploadProgress | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);

function resetForm() {
setNewDate('');
setNewType('');
setNewDescription('');
setNewAmount('');
setNewProject('');
setNewFile(null);
setUploadProgress(null);
setIsSubmitting(false);
setDateError(false);
setTypeError(false);
setDescError(false);
Expand Down Expand Up @@ -83,19 +91,38 @@ export default function AddExpenseModal({
return;
}

setSubmitError(null);
setIsSubmitting(true);
try {
// The receipt goes to S3 first: POST /expenditures stores the resulting
// object URL, so there is nothing to record until the file has landed.
const { uploadUrl, objectUrl } = await api.get<{ uploadUrl: string; objectUrl: string }>(
`/expenditures/upload-url?fileName=${encodeURIComponent(newFile.name)}` +
`&projectId=${selectedProject.project_id}`,
);

setUploadProgress({ transferredBytes: 0, totalBytes: newFile.size, fileName: newFile.name });
await putWithProgress(uploadUrl, newFile, RECEIPT_CONTENT_TYPE, (transferredBytes) =>
setUploadProgress({ transferredBytes, totalBytes: newFile.size, fileName: newFile.name }),
);
setUploadProgress(null);

await api.post('/expenditures', {
projectID: selectedProject.project_id,
amount: Number(newAmount),
category: newType,
description: newDescription,
spentOn: newDate,
receiptUrl: objectUrl,
});

resetForm();
onSuccess();
} catch (err) {
setUploadProgress(null);
setSubmitError(err instanceof Error ? err.message : 'Failed to create expense');
} finally {
setIsSubmitting(false);
}
}

Expand Down Expand Up @@ -304,6 +331,7 @@ export default function AddExpenseModal({
setFileError(null);
}}
onReject={() => setFileError('File type not supported')}
progress={uploadProgress}
/>
{fileError && (
<span style={{ color: 'var(--color-error-red)', fontSize: '12px', fontStyle: 'italic', fontWeight: 600 }}>
Expand Down Expand Up @@ -332,6 +360,8 @@ export default function AddExpenseModal({
backgroundColor="var(--color-core-green)"
color="var(--color-core-white)"
onClick={handleSubmit}
loading={isSubmitting}
disabled={isSubmitting}
>
Submit For Review
</Button>
Expand Down
63 changes: 22 additions & 41 deletions apps/frontend/src/app/components/FileUpload.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,32 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { useCallback } from 'react';
import { FileRejection, useDropzone } from 'react-dropzone';
import UploadProgressBar from './UploadProgressBar';
import FilePreview from './FilePreview';

const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB

export interface UploadProgress {
transferredBytes: number;
totalBytes: number;
fileName: string;
}

interface FileUploadProps {
value: File | null;
onChange: (file: File | null) => void;
onReject?: () => void;
/**
* Live progress of the real upload, driven by the parent. The file is sent
* when the form is submitted -- not on drop -- because the presigned URL is
* per project, and because a file dropped into an abandoned form would
* otherwise leave an orphaned object in the bucket.
*/
progress?: UploadProgress | null;
}

export default function FileUpload({ value, onChange, onReject }: FileUploadProps) {
const [isUploading, setIsUploading] = useState(false);
const [transferredBytes, setTransferredBytes] = useState(0);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

{/*Simulated progress for testing
TODO: update to use real progress of uploaded file */}
const simulateUpload = useCallback(
(file: File) => {
setPendingFile(file);
setIsUploading(true);
setTransferredBytes(0);

const total = file.size;
const step = total / 15; // ~15 ticks to finish
let transferred = 0;

intervalRef.current = setInterval(() => {
transferred += step;
if (transferred >= total) {
transferred = total;
if (intervalRef.current) clearInterval(intervalRef.current);
setTransferredBytes(total);
setIsUploading(false);
setPendingFile(null);
onChange(file); // flip to selected state
} else {
setTransferredBytes(transferred);
}
}, 100);
},
[onChange],
);
export default function FileUpload({ value, onChange, onReject, progress }: FileUploadProps) {
const isUploading = progress !== null && progress !== undefined;

{/*When the file is dropped, check if it's accepted*/}
const onDrop = useCallback(
Expand All @@ -54,9 +35,9 @@ export default function FileUpload({ value, onChange, onReject }: FileUploadProp
onReject?.();
return;
}
if (accepted[0]) simulateUpload(accepted[0]);
if (accepted[0]) onChange(accepted[0]);
},
[simulateUpload, onReject],
[onChange, onReject],
);

{/*Dropzone component to allow user to drop in files*/}
Expand All @@ -73,12 +54,12 @@ export default function FileUpload({ value, onChange, onReject }: FileUploadProp
? 'var(--color-core-green)'
: 'var(--color-black-200)';

if (isUploading && pendingFile) {
if (isUploading) {
return (
<UploadProgressBar
transferredBytes={transferredBytes}
totalBytes={pendingFile.size}
fileName={pendingFile.name}
transferredBytes={progress.transferredBytes}
totalBytes={progress.totalBytes}
fileName={progress.fileName}
/>
);
}
Expand Down
33 changes: 33 additions & 0 deletions apps/frontend/src/lib/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Uploads a file to a presigned S3 PUT, reporting real byte progress.
*
* XHR rather than `fetch`, which exposes no upload progress. The request is
* deliberately unauthenticated: the signature is in the URL, and an extra
* Authorization header would not be part of what was signed.
*/
export function putWithProgress(
uploadUrl: string,
file: File,
contentType: string,
onProgress: (transferredBytes: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', uploadUrl);
// Must match the ContentType the URL was signed with, or S3 rejects the
// signature -- so it comes from the caller, not from `file.type`.
xhr.setRequestHeader('Content-Type', contentType);

xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) onProgress(event.loaded);
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`Upload failed (${xhr.status})`));
});
xhr.addEventListener('error', () => reject(new Error('Upload failed')));
xhr.addEventListener('abort', () => reject(new Error('Upload cancelled')));

xhr.send(file);
});
}
Loading