From d1a0f2aeecc12d05ff880c92935146bc6eaf36c8 Mon Sep 17 00:00:00 2001 From: mehanana Date: Fri, 21 Aug 2026 23:17:11 -0400 Subject: [PATCH 1/2] updated donors page + modal; updated tests; implemented filter & add donor functionality --- .../frontend/src/app/components/DataTable.tsx | 64 +++--- .../src/app/components/TextInputField.tsx | 15 +- apps/frontend/src/app/donors/page.tsx | 200 +++++++++++------- apps/frontend/test/components/Donors.test.tsx | 25 +-- 4 files changed, 190 insertions(+), 114 deletions(-) diff --git a/apps/frontend/src/app/components/DataTable.tsx b/apps/frontend/src/app/components/DataTable.tsx index b9f9357c..061a0d7e 100644 --- a/apps/frontend/src/app/components/DataTable.tsx +++ b/apps/frontend/src/app/components/DataTable.tsx @@ -89,7 +89,7 @@ export default function DataTable({ ]; return ( - + {hasWidths && ( {selection && } @@ -99,34 +99,34 @@ export default function DataTable({ )} - - - {selection && ( - - - - - - - )} - {columns.map((column) => ( - + + {selection && ( + + -
{column.header}
-
- ))} -
-
+ + + + + )} + {columns.map((column) => ( + +
{column.header}
+
+ ))} + + {isLoading ? ( @@ -182,7 +182,13 @@ export default function DataTable({ )} {columns.map((column) => ( - + {column.cell(row)} ))} diff --git a/apps/frontend/src/app/components/TextInputField.tsx b/apps/frontend/src/app/components/TextInputField.tsx index 8d7585c5..99afd469 100644 --- a/apps/frontend/src/app/components/TextInputField.tsx +++ b/apps/frontend/src/app/components/TextInputField.tsx @@ -19,6 +19,7 @@ interface TextInputFieldProps { rows?: number; /** Rendered before the value, e.g. `$` on the budget field. */ prefix?: string; + icon?: React.ReactNode; inputMode?: 'text' | 'decimal' | 'numeric'; } @@ -35,6 +36,7 @@ export default function TextInputField({ multiline = false, rows = 4, prefix, + icon, inputMode, }: TextInputFieldProps) { const [internalValue, setInternalValue] = useState(''); @@ -97,9 +99,18 @@ export default function TextInputField({ {prefix} )} + {icon && ( + + {icon} + + )} [] = [ }, { key: 'organization', header: 'Donor Name', width: '55%', cell: (donor) => donor.organization }, { - key: 'projects', - header: '# of Projects', + key: 'contact_name', + header: 'Contact Name', width: '15%', - cell: (donor) => donor.num_projects, + cell: (donor) => donor.contact_name, skeleton: { width: '35%' }, }, { @@ -58,22 +75,41 @@ const donorColumns: DataTableColumn[] = [ ]; export default function DonorsPage() { + const api = useApi(); + const [currentPage, setCurrentPage] = useState(1); const rowsPerPage = 10; - const totalPages = Math.ceil(mockDonors.length / rowsPerPage); - const currentDonors = mockDonors.slice( + + // Sort by last donated. + const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(null); + + // Sorted view β€” does not mutate donors list, so re-sorting or clearing the + // sort never loses the original order. + const sortedDonors = React.useMemo(() => { + if (!sortDirection) return mockDonors; + const withDates = [...mockDonors]; + withDates.sort((a, b) => { + const aTime = a.last_donation ? new Date(a.last_donation).getTime() : 0; + const bTime = b.last_donation ? new Date(b.last_donation).getTime() : 0; + return sortDirection === 'desc' ? bTime - aTime : aTime - bTime; + }); + return withDates; + }, [sortDirection]); + + + const totalPages = Math.max(1, Math.ceil(sortedDonors.length / rowsPerPage)); + const currentDonors = sortedDonors.slice( (currentPage - 1) * rowsPerPage, currentPage * rowsPerPage ); - const [showFilter, setShowFilter] = useState(false); - const [selectedDonor, setSelectedDonor] = useState(''); - const donorNames = mockDonors.map(d => d.organization); - - const [showSort, setShowSort] = useState(false); - const [selectedSort, setSelectedSort] = useState(''); - const sortOptions = ['# of Projects', 'Last Donated']; + // Toggling re-sorts on every click: most recent first, then oldest first, + // then back to the original mock order. + function handleSortByLastDonated() { + setSortDirection((prev) => (prev === 'desc' ? 'asc' : prev === 'asc' ? null : 'desc')); + setCurrentPage(1); + } const [showNewDonor, setShowNewDonor] = useState(false); const [newOrganization, setNewOrganization] = useState(''); @@ -82,20 +118,62 @@ export default function DonorsPage() { const [orgError, setOrgError] = useState(false); const [nameError, setNameError] = useState(false); const [emailError, setEmailError] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const isFormValid = + newOrganization.trim().length > 0 && + newContactName.trim().length > 0 && + newContactEmail.trim().length > 0; + + function resetForm() { + setNewOrganization(''); + setNewContactName(''); + setNewContactEmail(''); + setOrgError(false); + setNameError(false); + setEmailError(false); + setSubmitError(null); + } - const handleSave = () => { + const handleSave = async () => { const hasOrgError = !newOrganization.trim(); const hasNameError = !newContactName.trim(); const hasEmailError = !newContactEmail.trim(); - + setOrgError(hasOrgError); setNameError(hasNameError); setEmailError(hasEmailError); - + if (hasOrgError || hasNameError || hasEmailError) return; - setShowNewDonor(false); + + try { + setSubmitting(true); + setSubmitError(null); + + // NOTE: backend expects snake_case for these two fields. + await api.post('/donors', { + organization: newOrganization.trim(), + contact_name: newContactName.trim(), + contact_email: newContactEmail.trim(), + }); + + resetForm(); + setShowNewDonor(false); + // Table still reads from mockDonors β€” another dev is wiring up + // the real GET /donors refresh here. + } catch (err) { + setSubmitError(err instanceof Error ? err.message : 'Failed to create donor'); + } finally { + setSubmitting(false); + } }; + function handleCloseModal() { + resetForm(); + setShowNewDonor(false); + } + return (
@@ -107,67 +185,33 @@ export default function DonorsPage() { -
- - {showFilter && ( -
- setSelectedDonor(val as string)} - /> -
- )} -
-
- - {showSort && ( -
- setSelectedSort(val as string)} - /> -
- )} -
+
- - setShowNewDonor(e.open)}> + + { if (!e.open) handleCloseModal(); }}> Add New Donor - setShowNewDonor(false)} /> + @@ -190,29 +234,43 @@ export default function DonorsPage() { } value={newContactEmail} onChange={(val) => { setNewContactEmail(val); setEmailError(false); }} isError={emailError} errorMessage="Enter valid email" /> + {submitError && ( +

+ {submitError} +

+ )}
- - + +
- + donor.donor_id} emptyMessage="No donors found." /> - + { expect(screen.getByPlaceholderText('πŸ”οΈŽ Search...')).toBeInTheDocument(); }); - it('renders Filter By, Sort By, and New Donor buttons', () => { + it('renders Last Donated and New Donor buttons', () => { render(); - expect(screen.getByText('Filter By')).toBeInTheDocument(); - expect(screen.getByText('Sort By')).toBeInTheDocument(); + expect(screen.getByText('Last Donated')).toBeInTheDocument(); expect(screen.getByText('New Donor')).toBeInTheDocument(); }); @@ -23,7 +22,7 @@ describe('Donors Page', () => { render(); expect(screen.getByText('Donor ID')).toBeInTheDocument(); expect(screen.getByText('Donor Name')).toBeInTheDocument(); - expect(screen.getByText('# of Projects')).toBeInTheDocument(); + expect(screen.getByText('Contact Name')).toBeInTheDocument(); expect(screen.getByText('Last Donation')).toBeInTheDocument(); }); @@ -32,16 +31,18 @@ describe('Donors Page', () => { expect(document.querySelector('svg')).toBeInTheDocument(); }); - it('shows filter dropdown when Filter By is clicked', () => { + it('toggles sort direction when Last Donated is clicked', () => { render(); - fireEvent.click(screen.getByText('Filter By')); - expect(screen.getByRole('combobox')).toBeInTheDocument(); - }); + const sortButton = screen.getByText('Last Donated').closest('button')!; - it('shows sort dropdown when Sort By is clicked', () => { - render(); - fireEvent.click(screen.getByText('Sort By')); - expect(screen.getByRole('combobox')).toBeInTheDocument(); + fireEvent.click(sortButton); + expect(screen.getByText(/↓/)).toBeInTheDocument(); + + fireEvent.click(sortButton); + expect(screen.getByText(/↑/)).toBeInTheDocument(); + + fireEvent.click(sortButton); + expect(screen.queryByText(/↓|↑/)).not.toBeInTheDocument(); }); it('shows new donor modal when New Donor is clicked', async () => { From b8ec01ad6e3007ea73220a7a679b44a200cad856 Mon Sep 17 00:00:00 2001 From: mehanana Date: Sat, 22 Aug 2026 21:25:49 -0400 Subject: [PATCH 2/2] tests updated --- apps/frontend/test/components/Donors.test.tsx | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/apps/frontend/test/components/Donors.test.tsx b/apps/frontend/test/components/Donors.test.tsx index aa29ae4e..dda34b53 100644 --- a/apps/frontend/test/components/Donors.test.tsx +++ b/apps/frontend/test/components/Donors.test.tsx @@ -12,9 +12,10 @@ describe('Donors Page', () => { expect(screen.getByPlaceholderText('πŸ”οΈŽ Search...')).toBeInTheDocument(); }); - it('renders Last Donated and New Donor buttons', () => { + it('renders Filter By, Sort By, and New Donor buttons', () => { render(); - expect(screen.getByText('Last Donated')).toBeInTheDocument(); + expect(screen.getByText('Filter By')).toBeInTheDocument(); + expect(screen.getByText('Sort By')).toBeInTheDocument(); expect(screen.getByText('New Donor')).toBeInTheDocument(); }); @@ -22,7 +23,7 @@ describe('Donors Page', () => { render(); expect(screen.getByText('Donor ID')).toBeInTheDocument(); expect(screen.getByText('Donor Name')).toBeInTheDocument(); - expect(screen.getByText('Contact Name')).toBeInTheDocument(); + expect(screen.getByText('# of Projects')).toBeInTheDocument(); expect(screen.getByText('Last Donation')).toBeInTheDocument(); }); @@ -31,18 +32,16 @@ describe('Donors Page', () => { expect(document.querySelector('svg')).toBeInTheDocument(); }); - it('toggles sort direction when Last Donated is clicked', () => { + it('shows filter dropdown when Filter By is clicked', () => { render(); - const sortButton = screen.getByText('Last Donated').closest('button')!; - - fireEvent.click(sortButton); - expect(screen.getByText(/↓/)).toBeInTheDocument(); - - fireEvent.click(sortButton); - expect(screen.getByText(/↑/)).toBeInTheDocument(); + fireEvent.click(screen.getByText('Filter By')); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); - fireEvent.click(sortButton); - expect(screen.queryByText(/↓|↑/)).not.toBeInTheDocument(); + it('shows sort dropdown when Sort By is clicked', () => { + render(); + fireEvent.click(screen.getByText('Sort By')); + expect(screen.getByRole('combobox')).toBeInTheDocument(); }); it('shows new donor modal when New Donor is clicked', async () => {