diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 94f7fae4..0e12e5e0 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -15,6 +15,7 @@ "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", + "react-dropzone": "^15.0.0", "react-icons": "^5.6.0" }, "devDependencies": { @@ -5006,6 +5007,15 @@ "node": ">= 0.4" } }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -6683,6 +6693,18 @@ "node": ">=16.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -9327,7 +9349,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -9688,7 +9709,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10222,7 +10242,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -10314,6 +10333,23 @@ "react": "^19.1.0" } }, + "node_modules/react-dropzone": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-15.0.0.tgz", + "integrity": "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-icons": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index af356746..7fa704bd 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -11,7 +11,6 @@ "test": "jest --passWithNoTests" }, "dependencies": { - "@emotion/react": "^11.14.0", "@chakra-ui/react": "^3.33.0", "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", @@ -19,6 +18,7 @@ "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", + "react-dropzone": "^15.0.0", "react-icons": "^5.6.0" }, "devDependencies": { diff --git a/apps/frontend/src/app/components/AddExpenseModal.tsx b/apps/frontend/src/app/components/AddExpenseModal.tsx index efb9fb97..87a1855c 100644 --- a/apps/frontend/src/app/components/AddExpenseModal.tsx +++ b/apps/frontend/src/app/components/AddExpenseModal.tsx @@ -4,6 +4,8 @@ import { useState } from 'react'; import { Button, Dialog, Portal, CloseButton, Stack } from '@chakra-ui/react'; import DropdownSelector from './DropdownSelector'; import { apiFetch } from '@/lib/api'; +import FileUpload from './FileUpload'; +import { FiDollarSign } from 'react-icons/fi'; interface Project { project_id: number; @@ -32,6 +34,7 @@ export default function AddExpenseModal({ const [newDescription, setNewDescription] = useState(''); const [newAmount, setNewAmount] = useState(''); const [newProject, setNewProject] = useState(''); + const [newFile, setNewFile] = useState(null); const [dateError, setDateError] = useState(false); const [typeError, setTypeError] = useState(false); @@ -39,6 +42,7 @@ export default function AddExpenseModal({ const [amountError, setAmountError] = useState(false); const [projectError, setProjectError] = useState(false); const [submitError, setSubmitError] = useState(null); + const [fileError, setFileError] = useState(null); function resetForm() { setNewDate(''); @@ -52,6 +56,7 @@ export default function AddExpenseModal({ setAmountError(false); setProjectError(false); setSubmitError(null); + setFileError(null); } function handleClose() { @@ -65,14 +70,17 @@ export default function AddExpenseModal({ const hasDescError = !newDescription.trim(); const hasAmountError = !newAmount.trim() || isNaN(Number(newAmount)) || Number(newAmount) < 0; const hasProjectError = !newProject.trim(); + const hasFileError = !newFile; setDateError(hasDateError); setTypeError(hasTypeError); setDescError(hasDescError); setAmountError(hasAmountError); setProjectError(hasProjectError); + setFileError(hasFileError ? 'File type not supported' : null); - if (hasDateError || hasTypeError || hasDescError || hasAmountError || hasProjectError) return; + + if (hasDateError || hasTypeError || hasDescError || hasAmountError || hasProjectError || hasFileError) return; const selectedProject = projects.find((p) => p.name === newProject); if (!selectedProject) { @@ -106,7 +114,7 @@ export default function AddExpenseModal({ - + - {/* Date */} -
- - { - setNewDate(e.target.value); - setDateError(false); - }} - style={{ - border: `1px solid ${dateError ? 'var(--color-error-red)' : '#CBD5E0'}`, - borderRadius: '6px', - padding: '8px 12px', - fontSize: '14px', - outline: 'none', - width: '100%', - fontFamily: 'inherit', - color: newDate ? 'inherit' : '#A0AEC0', - }} - /> - {dateError && ( - - Select a date - - )} -
+
+ {/* Date */} +
+ +
+ { + setNewDate(e.target.value); + setDateError(false); + }} + className="date-input" + style={{ + borderTop: `1px solid ${dateError ? 'var(--color-error-red)' : 'var(--color-black-200)'}`, + borderBottom: `1px solid ${dateError ? 'var(--color-error-red)' : 'var(--color-black-200)'}`, + borderLeft: `1px solid ${dateError ? 'var(--color-error-red)' : 'var(--color-black-200)'}`, + borderRight: 'none', + borderRadius: '6px 0 0 6px', + padding: '8px 12px', + fontSize: '14px', + outline: 'none', + width: 'calc(100% - 40px)', + fontFamily: 'inherit', + color: newDate ? 'inherit' : 'var(--color-black-400)', + boxSizing: 'border-box', + }} + /> + {/* custom calendar icon box on the right */} + + + + + + + + +
+
- {/* Type of Expense */} -
- - { - setNewType(val as string); - setTypeError(false); - }} - /> - {typeError && ( - - Select a type of expense - - )} + {/* Amount */} +
+ +
+ + + + { + setNewAmount(e.target.value); + setAmountError(false); + }} + placeholder="Enter the Amount" + style={{ + border: 'none', + padding: '8px 12px', + fontSize: '14px', + outline: 'none', + width: '100%', + fontFamily: 'inherit', + }} + /> +
+ {amountError && ( + + Enter a valid amount + + )} +
+
+ {/* Type of Expense */} +
+ + { + setNewType(val as string); + setTypeError(false); + }} + /> + {typeError && ( + + Select a type of expense + + )} +
+ {/* Project */} +
+ + p.name)} + placeholder="Select a project" + multiSelect={false} + value={newProject} + onChange={(val) => { + setNewProject(val as string); + setProjectError(false); + }} + /> + {projectError && ( + + Select a project + + )} +
+
{/* Description */}
@@ -178,7 +286,7 @@ export default function AddExpenseModal({ placeholder="Placeholder" rows={4} style={{ - border: `1px solid ${descError ? 'var(--color-error-red)' : '#CBD5E0'}`, + border: `1px solid ${descError ? 'var(--color-error-red)' : 'var(--color-black-200)'}`, borderRadius: '6px', padding: '8px 12px', fontSize: '14px', @@ -195,72 +303,20 @@ export default function AddExpenseModal({ )}
- {/* Amount */} + {/* Upload Receipt */}
- -
- - $ - - { - setNewAmount(e.target.value); - setAmountError(false); - }} - placeholder="Enter the Amount" - style={{ - border: 'none', - padding: '8px 12px', - fontSize: '14px', - outline: 'none', - width: '100%', - fontFamily: 'inherit', - }} - /> -
- {amountError && ( - - Enter a valid amount - - )} -
- - {/* Project */} -
- - p.name)} - placeholder="Select a project" - multiSelect={false} - value={newProject} - onChange={(val) => { - setNewProject(val as string); - setProjectError(false); + + { + setNewFile(file); + setFileError(null); }} + onReject={() => setFileError('File type not supported')} /> - {projectError && ( - - Select a project + {fileError && ( + + {fileError} )}
@@ -286,7 +342,7 @@ export default function AddExpenseModal({ color="var(--color-core-white)" onClick={handleSubmit} > - Add Expense + Submit For Review diff --git a/apps/frontend/src/app/components/DropdownSelector.tsx b/apps/frontend/src/app/components/DropdownSelector.tsx index fb3142aa..59aebf5e 100644 --- a/apps/frontend/src/app/components/DropdownSelector.tsx +++ b/apps/frontend/src/app/components/DropdownSelector.tsx @@ -34,7 +34,7 @@ export default function DropdownSelector({ } return ( -
+
- + {collection.items.map((item) => multiSelect ? ( diff --git a/apps/frontend/src/app/components/FilePreview.tsx b/apps/frontend/src/app/components/FilePreview.tsx new file mode 100644 index 00000000..10a0b160 --- /dev/null +++ b/apps/frontend/src/app/components/FilePreview.tsx @@ -0,0 +1,93 @@ +'use client'; + +interface FilePreviewProps { + file: File; + onRemove: () => void; + onReplace: () => void; +} + +export default function FilePreview({ file, onRemove, onReplace }: FilePreviewProps) { + const url = URL.createObjectURL(file); + + return ( +
+
+ {/* File name displayed */} + + {file.name} + + +
+ {/*View pdf & download buttons*/} + + view pdf + + + download + + {/*Remove file button*/} + +
+
+ + {/* Upload Complete row */} +
+ + + + + Upload Complete +
+ + {/* Select a File button */} + +
+ ); +} \ No newline at end of file diff --git a/apps/frontend/src/app/components/FileUpload.tsx b/apps/frontend/src/app/components/FileUpload.tsx new file mode 100644 index 00000000..d83390de --- /dev/null +++ b/apps/frontend/src/app/components/FileUpload.tsx @@ -0,0 +1,135 @@ +'use client'; +import { useCallback, useRef, useState } 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 + +interface FileUploadProps { + value: File | null; + onChange: (file: File | null) => void; + onReject?: () => void; +} + +export default function FileUpload({ value, onChange, onReject }: FileUploadProps) { + const [isUploading, setIsUploading] = useState(false); + const [transferredBytes, setTransferredBytes] = useState(0); + const [pendingFile, setPendingFile] = useState(null); + const intervalRef = useRef | 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], + ); + + {/*When the file is dropped, check if it's accepted*/} + const onDrop = useCallback( + (accepted: File[], rejections: FileRejection[]) => { + if (rejections.length > 0) { + onReject?.(); + return; + } + if (accepted[0]) simulateUpload(accepted[0]); + }, + [simulateUpload, onReject], + ); + + {/*Dropzone component to allow user to drop in files*/} + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ + onDrop, + accept: { 'application/pdf': ['.pdf'] }, + maxSize: MAX_FILE_SIZE_BYTES, + maxFiles: 1, + multiple: false, + disabled: isUploading, + }); + + const borderColor = isDragActive + ? 'var(--color-core-green)' + : 'var(--color-black-200)'; + + if (isUploading && pendingFile) { + return ( + + ); + } + + // Selected-file state + if (value) { + return ( + <> + + onChange(null)} + onReplace={open} + /> + + ); + } + + {/*Empty Dropzone State*/} + return ( +
+ + + + + + + + +

+ {isDragActive ? 'Drop the PDF here' : 'Upload PDF Receipt'} +

+ +

+ PDF only +

+ +
+ ); +} \ No newline at end of file diff --git a/apps/frontend/src/app/components/UploadProgressBar.tsx b/apps/frontend/src/app/components/UploadProgressBar.tsx new file mode 100644 index 00000000..0fdc8829 --- /dev/null +++ b/apps/frontend/src/app/components/UploadProgressBar.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +interface UploadProgressBarProps { + transferredBytes: number; + totalBytes: number; + fileName: string; +} + +export default function UploadProgressBar ({ + transferredBytes, + totalBytes, + fileName, +}: UploadProgressBarProps) { + const progress = + totalBytes !== 0 ? Math.round((transferredBytes / totalBytes) * 100) : 0; + + return ( +
+ {/* bar + percent on one row */} +
+
+
+
+ + {progress}% + +
+ + {/* filename below the bar */} +

+ {fileName} uploading... +

+
+ ); +} \ No newline at end of file diff --git a/apps/frontend/src/app/globals.css b/apps/frontend/src/app/globals.css index 9b3116af..c3cbe694 100644 --- a/apps/frontend/src/app/globals.css +++ b/apps/frontend/src/app/globals.css @@ -91,4 +91,13 @@ font-family: var(--font-body); font-size: var(--font-size-callout); font-weight: 400; + } + + .date-input::-webkit-calendar-picker-indicator { + opacity: 0; + position: absolute; + right: 0; + width: 40px; + height: 100%; + cursor: pointer; } \ No newline at end of file diff --git a/apps/frontend/test/components/AddExpenseModal.test.tsx b/apps/frontend/test/components/AddExpenseModal.test.tsx new file mode 100644 index 00000000..027620af --- /dev/null +++ b/apps/frontend/test/components/AddExpenseModal.test.tsx @@ -0,0 +1,224 @@ +import { render, screen, fireEvent, waitFor } from '../utils'; +import AddExpenseModal from '@/app/components/AddExpenseModal'; +import { apiFetch } from '@/lib/api'; + +jest.mock('../../src/lib/api', () => ({ + apiFetch: jest.fn(), +})); + +// Mock DropdownSelector — render a simple native select so we can drive it +jest.mock('../../src/app/components/DropdownSelector', () => { + return function MockDropdownSelector({ + options, + placeholder, + value, + onChange, + }: { + options: string[]; + placeholder?: string; + value?: string; + onChange?: (v: string) => void; + }) { + return ( + + ); + }; +}); + +// Mock FileUpload — expose a button that selects a fake file +jest.mock('../../src/app/components/FileUpload', () => { + return function MockFileUpload({ + onChange, + }: { + onChange: (file: File | null) => void; + }) { + return ( + + ); + }; +}); + +const baseProps = { + open: true, + onClose: jest.fn(), + onSuccess: jest.fn(), + token: 'test-token', + categories: ['Travel Foreign', 'Supplies'], + projects: [ + { project_id: 1, name: 'Project Name 1' }, + { project_id: 2, name: 'Project Name 2' }, + ], +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('AddExpenseModal Component', () => { + it('renders the modal title when open', () => { + render(); + expect(screen.getByText('Add New Expense')).toBeInTheDocument(); + }); + + it('renders all field labels', () => { + render(); + expect(screen.getByText('Date*')).toBeInTheDocument(); + expect(screen.getByText('Amount*')).toBeInTheDocument(); + expect(screen.getByText('Type of Expense*')).toBeInTheDocument(); + expect(screen.getByText('Project*')).toBeInTheDocument(); + expect(screen.getByText('Description*')).toBeInTheDocument(); + expect(screen.getByText('Upload Receipt')).toBeInTheDocument(); + }); + + it('renders the date input', () => { + render(); + expect(document.querySelector('input[type="date"]')).toBeInTheDocument(); + }); + + it('renders the amount input with placeholder', () => { + render(); + expect(screen.getByPlaceholderText('Enter the Amount')).toBeInTheDocument(); + }); + + it('renders Cancel and Submit buttons', () => { + render(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + expect(screen.getByText('Submit For Review')).toBeInTheDocument(); + }); + + it('shows validation errors when submitting an empty form', () => { + render(); + fireEvent.click(screen.getByText('Submit For Review')); + + expect(screen.getByText('Enter a valid amount')).toBeInTheDocument(); + expect(screen.getByText('Select a type of expense')).toBeInTheDocument(); + expect(screen.getByText('Select a project')).toBeInTheDocument(); + expect(screen.getByText('Enter a description')).toBeInTheDocument(); + expect(screen.getByText('File type not supported')).toBeInTheDocument(); + }); + + it('does not call apiFetch when the form is invalid', () => { + render(); + fireEvent.click(screen.getByText('Submit For Review')); + expect(apiFetch).not.toHaveBeenCalled(); + }); + + it('calls apiFetch with the correct payload when the form is valid', async () => { + (apiFetch as jest.Mock).mockResolvedValueOnce({}); + render(); + + fireEvent.change(document.querySelector('input[type="date"]')!, { + target: { value: '2025-05-15' }, + }); + fireEvent.change(screen.getByPlaceholderText('Enter the Amount'), { + target: { value: '12000' }, + }); + fireEvent.change(screen.getByLabelText('Select type'), { + target: { value: 'Travel Foreign' }, + }); + fireEvent.change(screen.getByLabelText('Select a project'), { + target: { value: 'Project Name 2' }, + }); + fireEvent.change(screen.getByPlaceholderText('Placeholder'), { + target: { value: 'A test description' }, + }); + fireEvent.click(screen.getByText('mock-select-file')); + + fireEvent.click(screen.getByText('Submit For Review')); + + await waitFor(() => { + expect(apiFetch).toHaveBeenCalledWith( + '/expenditures', + expect.objectContaining({ + method: 'POST', + token: 'test-token', + body: JSON.stringify({ + projectID: 2, + amount: 12000, + category: 'Travel Foreign', + description: 'A test description', + spentOn: '2025-05-15', + }), + }), + ); + }); + }); + + it('calls onSuccess after a successful submit', async () => { + (apiFetch as jest.Mock).mockResolvedValueOnce({}); + render(); + + fireEvent.change(document.querySelector('input[type="date"]')!, { + target: { value: '2025-05-15' }, + }); + fireEvent.change(screen.getByPlaceholderText('Enter the Amount'), { + target: { value: '12000' }, + }); + fireEvent.change(screen.getByLabelText('Select type'), { + target: { value: 'Travel Foreign' }, + }); + fireEvent.change(screen.getByLabelText('Select a project'), { + target: { value: 'Project Name 2' }, + }); + fireEvent.change(screen.getByPlaceholderText('Placeholder'), { + target: { value: 'A test description' }, + }); + fireEvent.click(screen.getByText('mock-select-file')); + fireEvent.click(screen.getByText('Submit For Review')); + + await waitFor(() => { + expect(baseProps.onSuccess).toHaveBeenCalledTimes(1); + }); + }); + + it('shows a submit error if apiFetch rejects', async () => { + (apiFetch as jest.Mock).mockRejectedValueOnce(new Error('Server exploded')); + render(); + + fireEvent.change(document.querySelector('input[type="date"]')!, { + target: { value: '2025-05-15' }, + }); + fireEvent.change(screen.getByPlaceholderText('Enter the Amount'), { + target: { value: '12000' }, + }); + fireEvent.change(screen.getByLabelText('Select type'), { + target: { value: 'Travel Foreign' }, + }); + fireEvent.change(screen.getByLabelText('Select a project'), { + target: { value: 'Project Name 2' }, + }); + fireEvent.change(screen.getByPlaceholderText('Placeholder'), { + target: { value: 'A test description' }, + }); + fireEvent.click(screen.getByText('mock-select-file')); + fireEvent.click(screen.getByText('Submit For Review')); + + await waitFor(() => { + expect(screen.getByText('Server exploded')).toBeInTheDocument(); + }); + }); + + it('calls onClose when Cancel is clicked', () => { + render(); + fireEvent.click(screen.getByText('Cancel')); + expect(baseProps.onClose).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/apps/frontend/test/components/FilePreview.test.tsx b/apps/frontend/test/components/FilePreview.test.tsx new file mode 100644 index 00000000..e50572db --- /dev/null +++ b/apps/frontend/test/components/FilePreview.test.tsx @@ -0,0 +1,52 @@ +import { render, screen, fireEvent } from '../utils'; +import FilePreview from '@/app/components/FilePreview'; + +beforeAll(() => { + global.URL.createObjectURL = jest.fn(() => 'blob:mock-url'); +}); + +function makeFile(name = 'receipt.pdf') { + return new File(['dummy'], name, { type: 'application/pdf' }); +} + +describe('FilePreview Component', () => { + it('renders the file name', () => { + render( {}} onReplace={() => {}} />); + expect(screen.getByText('receipt.pdf')).toBeInTheDocument(); + }); + + it('renders view pdf and download links', () => { + render( {}} onReplace={() => {}} />); + expect(screen.getByText('view pdf')).toBeInTheDocument(); + expect(screen.getByText('download')).toBeInTheDocument(); + }); + + it('renders the Upload Complete row', () => { + render( {}} onReplace={() => {}} />); + expect(screen.getByText('Upload Complete')).toBeInTheDocument(); + }); + + it('renders the Select a File button', () => { + render( {}} onReplace={() => {}} />); + expect(screen.getByText('Select a File')).toBeInTheDocument(); + }); + + it('calls onRemove when the remove button is clicked', () => { + const onRemove = jest.fn(); + render( {}} />); + fireEvent.click(screen.getByLabelText('Remove file')); + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('calls onReplace when Select a File is clicked', () => { + const onReplace = jest.fn(); + render( {}} onReplace={onReplace} />); + fireEvent.click(screen.getByText('Select a File')); + expect(onReplace).toHaveBeenCalledTimes(1); + }); + + it('sets download attribute to the file name', () => { + render( {}} onReplace={() => {}} />); + expect(screen.getByText('download')).toHaveAttribute('download', 'ferry.pdf'); + }); +}); \ No newline at end of file diff --git a/apps/frontend/test/components/FileUpload.test.tsx b/apps/frontend/test/components/FileUpload.test.tsx new file mode 100644 index 00000000..4ddc803e --- /dev/null +++ b/apps/frontend/test/components/FileUpload.test.tsx @@ -0,0 +1,82 @@ +import { render, screen, act } from '../utils'; +import FileUpload from '@/app/components/FileUpload'; + +beforeAll(() => { + global.URL.createObjectURL = jest.fn(() => 'blob:mock-url'); +}); + +// Capture the onDrop passed to useDropzone so we can invoke it manually +let capturedOnDrop: ((accepted: File[], rejections: unknown[]) => void) | null = null; + +jest.mock('react-dropzone', () => ({ + useDropzone: (config: { onDrop: (a: File[], r: unknown[]) => void }) => { + capturedOnDrop = config.onDrop; + return { + getRootProps: () => ({}), + getInputProps: () => ({ 'data-testid': 'file-input' }), + isDragActive: false, + open: jest.fn(), + }; + }, +})); + +function makePdf(name = 'receipt.pdf') { + return new File(['dummy-content'], name, { type: 'application/pdf' }); +} + +describe('FileUpload Component', () => { + beforeEach(() => { + capturedOnDrop = null; + }); + + it('renders the empty dropzone state', () => { + render( {}} />); + expect(screen.getByText('Upload PDF Receipt')).toBeInTheDocument(); + expect(screen.getByText('PDF only')).toBeInTheDocument(); + }); + + it('renders FilePreview when a value is provided', () => { + render( {}} />); + expect(screen.getByText('receipt.pdf')).toBeInTheDocument(); + expect(screen.getByText('Upload Complete')).toBeInTheDocument(); + }); + + it('shows the upload progress bar after a valid file is dropped', () => { + jest.useFakeTimers(); + render( {}} />); + + act(() => { + capturedOnDrop!([makePdf()], []); + }); + + expect(screen.getByText(/uploading\.\.\./)).toBeInTheDocument(); + jest.useRealTimers(); + }); + + it('calls onChange with the file once the simulated upload completes', () => { + jest.useFakeTimers(); + const onChange = jest.fn(); + render(); + + act(() => { + capturedOnDrop!([makePdf()], []); + }); + act(() => { + jest.advanceTimersByTime(2000); + }); + + expect(onChange.mock.calls[0][0].name).toBe('receipt.pdf'); + jest.useRealTimers(); + }); + + it('calls onReject when a rejected file is dropped', () => { + const onReject = jest.fn(); + render( {}} onReject={onReject} />); + + act(() => { + capturedOnDrop!([], [{ file: makePdf('bad.png'), errors: [] }]); + }); + + expect(onReject).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/apps/frontend/test/components/UploadProgressBar.test.tsx b/apps/frontend/test/components/UploadProgressBar.test.tsx new file mode 100644 index 00000000..fb2e3abd --- /dev/null +++ b/apps/frontend/test/components/UploadProgressBar.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '../utils'; +import UploadProgressBar from '@/app/components/UploadProgressBar'; + +describe('UploadProgressBar Component', () => { + it('renders the filename with uploading text', () => { + render(); + expect(screen.getByText('receipt.pdf uploading...')).toBeInTheDocument(); + }); + + it('calculates and displays the correct percentage', () => { + render(); + expect(screen.getByText('50%')).toBeInTheDocument(); + }); + + it('rounds the percentage', () => { + render(); + expect(screen.getByText('33%')).toBeInTheDocument(); + }); + + it('shows 0% when totalBytes is 0 (avoids divide by zero)', () => { + render(); + expect(screen.getByText('0%')).toBeInTheDocument(); + }); + + it('shows 100% when fully transferred', () => { + render(); + expect(screen.getByText('100%')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2249973c..1c98b62d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "pg": "^8.16.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-dropzone": "^15.0.0", "react-router-dom": "^6.15.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", @@ -8565,6 +8566,15 @@ "node": ">= 4.0.0" } }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/autoprefixer": { "version": "10.4.16", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz", @@ -12730,6 +12740,24 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/file-selector/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -19689,7 +19717,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -19701,7 +19728,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/proxy-addr": { @@ -19881,6 +19907,23 @@ "react": "^18.2.0" } }, + "node_modules/react-dropzone": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-15.0.0.tgz", + "integrity": "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index f427a48a..9930bd3c 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "pg": "^8.16.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-dropzone": "^15.0.0", "react-router-dom": "^6.15.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0",