Skip to content
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
12 changes: 11 additions & 1 deletion apps/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"@chakra-ui/react": "^3.33.0",
"next": "15.5.4",
"react": "19.1.0",
"react-dom": "19.1.0"
"react-dom": "19.1.0",
"react-icons": "^5.6.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
Expand Down
253 changes: 253 additions & 0 deletions apps/frontend/src/app/Donors.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
'use client'
import React, { useState } from 'react';
import { HStack, Input, Button, Table, Dialog, Portal, CloseButton, Stack } from "@chakra-ui/react";
import TextInputField from './components/TextInputField';
import { CiFilter } from "react-icons/ci";
import { LuArrowDownUp } from "react-icons/lu";
import { FaPlus, FaAngleLeft, FaAngleRight } from "react-icons/fa";
import DropdownSelector from './components/DropdownSelector';
type Donor = {
donor_id: number;
organization: string;
contact_name: string | null;
contact_email: string | null;
num_projects: number;
last_donation: string | null;
};

const mockDonors: Donor[] = [
{ donor_id: 1, organization: 'Green Future Foundation', contact_name: 'Alice Chen', contact_email: 'alice@greenfuture.org', num_projects: 4, last_donation: '03/12/2024' },
{ donor_id: 2, organization: 'Horizon Trust', contact_name: 'James Patel', contact_email: 'james@horizontrust.org', num_projects: 2, last_donation: '01/05/2024' },
{ donor_id: 3, organization: 'Bright Path Nonprofit', contact_name: null, contact_email: null, num_projects: 7, last_donation: '02/28/2024' },
{ donor_id: 4, organization: 'Unity Giving Circle', contact_name: 'Maria Lopez', contact_email: 'maria@unitygiving.org', num_projects: 1, last_donation: '03/30/2024' },
{ donor_id: 5, organization: 'Sunrise Community Fund', contact_name: 'David Kim', contact_email: 'david@sunrisefund.org', num_projects: 3, last_donation: '04/01/2024' },
{ donor_id: 6, organization: 'Blue Ridge Giving', contact_name: 'Sarah Thompson', contact_email: 'sarah@blueridge.org', num_projects: 5, last_donation: '02/14/2024' },
{ donor_id: 7, organization: 'Maple Leaf Charitable Trust', contact_name: null, contact_email: null, num_projects: 2, last_donation: '01/20/2024' },
{ donor_id: 8, organization: 'Evergreen Partners', contact_name: 'Rachel Singh', contact_email: 'rachel@evergreenpartners.org', num_projects: 6, last_donation: '03/05/2024' },
{ donor_id: 9, organization: 'New Horizons Society', contact_name: 'Tom Bradley', contact_email: 'tom@newhorizons.org', num_projects: 9, last_donation: '04/10/2024' },
{ donor_id: 10, organization: 'Coastal Care Foundation', contact_name: 'Nina Rossi', contact_email: 'nina@coastalcare.org', num_projects: 3, last_donation: '03/22/2024' },
];

export default function Donors() {

const [currentPage, setCurrentPage] = useState(1);
const rowsPerPage = 10;

const totalPages = Math.ceil(mockDonors.length / rowsPerPage);
const currentDonors = mockDonors.slice(
(currentPage - 1) * rowsPerPage,
currentPage * rowsPerPage
);

const getPageNumbers = () => {
if (totalPages <= 5) return Array.from({ length: totalPages }, (_, i) => i + 1);

if (currentPage <= 3) return [1, 2, 3, '...', totalPages];
if (currentPage >= totalPages - 2) return [1, '...', totalPages - 2, totalPages - 1, totalPages];
return [1, '...', currentPage - 1, currentPage, currentPage + 1, '...', totalPages];
};

const [showFilter, setShowFilter] = useState(false);
const [selectedDonor, setSelectedDonor] = useState<string>('');
const donorNames = mockDonors.map(d => d.organization);

const [showSort, setShowSort] = useState(false);
const[selectedSort, setSelectedSort] = useState<string>('');
const sortOptions = ['# of Projects', 'Last Donated']

const [showNewDonor, setShowNewDonor] = useState(false);
const [newOrganization, setNewOrganization] = useState('');
const [newContactName, setNewContactName] = useState('');
const [newContactEmail, setNewContactEmail] = useState('');
const [orgError, setOrgError] = useState(false);
const [nameError, setNameError] = useState(false);
const [emailError, setEmailError] = useState(false);
const handleSave = () => {
const hasOrgError = !newOrganization.trim();
const hasNameError = !newContactName.trim();
const hasEmailError = !newContactEmail.trim();

setOrgError(hasOrgError);
setNameError(hasNameError);
setEmailError(hasEmailError);

if (hasOrgError || hasNameError || hasEmailError) return;

// need to implement the save logic later (like saving it to the db)

setShowNewDonor(false);
};

return (
<div style={{margin: '2%', display: 'flex', flexDirection: 'column', minHeight: '90vh'}}>
<h1 style={{ fontWeight: 600, fontFamily: 'var(--font-heading)', fontSize: 'var(--font-size-heading-1)' }}>Donors</h1>
<HStack width="100%" justify="space-between" paddingTop="3%" paddingBottom="3%">
<HStack width='30%'>
<Input placeholder="🔍︎ Search..." variant="outline"/>
</HStack>
<HStack>
<div style={{ position: 'relative' }}>
<Button
backgroundColor={'var(--color-core-white)'}
color={'var(--color-core-black)'}
border={'1px solid'}
borderColor={'var(--color-black-500)'}
onClick={() => setShowFilter(prev => !prev)}
>
<CiFilter />
Filter By
</Button>
{showFilter && (
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10 }}>
<DropdownSelector
options={donorNames}
placeholder="Filter by donor..."
multiSelect={true}
value={selectedDonor}
onChange={(val) => setSelectedDonor(val as string)}
/>
</div>
)}
</div>
<div style={{ position: 'relative' }}>
<Button
backgroundColor={'var(--color-core-white)'}
color={'var(--color-core-black)'}
border={'1px solid'}
borderColor={'var(--color-black-500)'}
onClick={() => setShowSort(prev => !prev)}
>
<LuArrowDownUp />
Sort By
</Button>
{showSort && (
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10 }}>
<DropdownSelector
options={sortOptions}
placeholder="Sort by..."
multiSelect={true}
value={selectedSort}
onChange={(val) => setSelectedSort(val as string)}
/>
</div>
)}
</div>
<Button backgroundColor={'var(--color-core-green)'} color={'var(--color-core-white)'} onClick={() => setShowNewDonor(true)}>
<FaPlus />
New Donor
</Button>
</HStack>
</HStack>

<Dialog.Root open={showNewDonor} onOpenChange={(e) => setShowNewDonor(e.open)}>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header display="flex" justifyContent="space-between" alignItems="center">
<Dialog.Title fontFamily={'var(--font-heading)'} fontSize={'var(--font-size-heading-3)'} fontWeight={600}>Add New Donor</Dialog.Title>
<CloseButton onClick={() => setShowNewDonor(false)} />
</Dialog.Header>
<Dialog.Body>
<Stack gap={4}>
<TextInputField
label="Organization Name*"
placeholder="Organization name"
value={newOrganization}
onChange={(val) => { setNewOrganization(val); setOrgError(false); }}
isError={orgError}
errorMessage="Enter valid name"
/>
<TextInputField
label="Contact Name*"
placeholder="Contact name"
value={newContactName}
onChange={(val) => { setNewContactName(val); setNameError(false); }}
isError={nameError}
errorMessage="Enter valid name"
/>
<TextInputField
label="Contact Email*"
placeholder="Contact email"
value={newContactEmail}
onChange={(val) => { setNewContactEmail(val); setEmailError(false); }}
isError={emailError}
errorMessage="Enter valid email"
/>
</Stack>
</Dialog.Body>
<Dialog.Footer>
<Button variant="outline" borderColor={'var(--color-core-green)'} onClick={() => setShowNewDonor(false)}>Cancel</Button>
<Button backgroundColor={'var(--color-core-green)'} color={'var(--color-core-white)'} onClick={handleSave}>Add Donor</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>

<Table.Root>
<Table.ColumnGroup>
<Table.Column width="15%" />
<Table.Column width="55%" />
<Table.Column width="15%" />
<Table.Column width="15%" />
</Table.ColumnGroup>

<Table.Header>
<Table.Row backgroundColor={'var(--color-primary-800)'}>
<Table.ColumnHeader color={'var(--color-core-white)'}>Donor ID</Table.ColumnHeader>
<Table.ColumnHeader color={'var(--color-core-white)'}>Donor Name</Table.ColumnHeader>
<Table.ColumnHeader color={'var(--color-core-white)'}># of Projects</Table.ColumnHeader>
<Table.ColumnHeader color={'var(--color-core-white)'}>Last Donation</Table.ColumnHeader>
</Table.Row>
</Table.Header>

<Table.Body>
{currentDonors.map((donor) => (
<Table.Row key={donor.donor_id}>
<Table.Cell>#{String(donor.donor_id).padStart(6, '0')}</Table.Cell>
<Table.Cell>{donor.organization}</Table.Cell>
<Table.Cell>{donor.num_projects}</Table.Cell>
<Table.Cell>{donor.last_donation ?? '—'}</Table.Cell>
</Table.Row>
))}
</Table.Body>

</Table.Root>

<div style={{ marginTop: 'auto' }}>
<HStack width="100%" justify="center" paddingTop="3%" paddingBottom="3%" gap="6">
<FaAngleLeft
onClick={() => setCurrentPage(p => Math.max(p - 1, 1))}
style={{ cursor: currentPage === 1 ? 'not-allowed' : 'pointer', opacity: currentPage === 1 ? 0.3 : 1, color: 'var(--color-core-green)' }}
/>
{getPageNumbers().map((page, index) => (
page === '...'
? <Button
key={`ellipsis-${index}`}
backgroundColor={'var(--color-core-white)'}
color={'var(--color-core-green)'}
border={'1px solid'}
borderColor={'var(--color-core-green)'}
cursor={'default'}
> ... </Button>
: <Button
key={page}
onClick={() => setCurrentPage(page as number)}
backgroundColor={currentPage === page ? 'var(--color-core-green)' : 'var(--color-core-white)'}
color={currentPage === page ? 'var(--color-core-white)' : 'var(--color-core-green)'}
border={'1px solid'}
borderColor={'var(--color-core-green)'}
>
{page}
</Button>
))}
<FaAngleRight
onClick={() => setCurrentPage(p => Math.min(p + 1, totalPages))}
style={{ cursor: currentPage === totalPages ? 'not-allowed' : 'pointer', opacity: currentPage === totalPages ? 0.3 : 1, color: 'var(--color-core-green)' }}
/>
</HStack>
</div>
</div>
)
}
59 changes: 59 additions & 0 deletions apps/frontend/test/components/Donors.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { render, screen, fireEvent, waitFor } from '../utils';
import Donors from '@/app/Donors';

describe('Login Page Component', () => {
it('renders the login heading', () => {
render(<Donors />);
expect(screen.getByText('Donors', { selector: 'h1' })).toBeInTheDocument();
});

it('renders the search input', () => {
render(<Donors />);
expect(screen.getByPlaceholderText('🔍︎ Search...')).toBeInTheDocument();
});

it('renders Filter By, Sort By, and New Donor buttons', () => {
render(<Donors />);
expect(screen.getByText('Filter By')).toBeInTheDocument();
expect(screen.getByText('Sort By')).toBeInTheDocument();
expect(screen.getByText('New Donor')).toBeInTheDocument();
});

it('renders the table with correct headers', () => {
render(<Donors />);
expect(screen.getByText('Donor ID')).toBeInTheDocument();
expect(screen.getByText('Donor Name')).toBeInTheDocument();
expect(screen.getByText('# of Projects')).toBeInTheDocument();
expect(screen.getByText('Last Donation')).toBeInTheDocument();
});

it('renders left and right pagination arrows', () => {
render(<Donors />);
expect(document.querySelector('svg')).toBeInTheDocument();
});

it('renders left and right pagination arrows', () => {
render(<Donors />);
expect(document.querySelector('svg')).toBeInTheDocument();
});

it('shows filter dropdown when Filter By is clicked', () => {
render(<Donors />);
fireEvent.click(screen.getByText('Filter By'));
expect(screen.getByRole('combobox')).toBeInTheDocument();
});

it('shows sort dropdown when Sort By is clicked', () => {
render(<Donors />);
fireEvent.click(screen.getByText('Sort By'));
expect(screen.getByRole('combobox')).toBeInTheDocument();
});

it('shows new donor modal when New Donor is clicked', async () => {
render(<Donors />);
fireEvent.click(screen.getByText('New Donor'));
await waitFor(() => {
expect(screen.getByText('Add New Donor')).toBeInTheDocument();
});
});
})
Loading