From a653f70c6a8c0f1464d34c1581d7959996c158c0 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Tue, 11 Aug 2026 23:57:24 -0400 Subject: [PATCH] feat(frontend): standardize loading states and extract a base DataTable Every list view rebuilt the same table furniture by hand -- green header row, colgroup, empty state -- and they had drifted apart, while each page signalled loading with its own `

Loading...

`. Adds a DataTable that takes columns as data and owns the header, widths, empty state, row-click behaviour and loading skeleton, plus Spinner / LoadingState / Skeleton / TableSkeletonRows for loading affordances. Tables now keep their header and column widths while loading instead of blanking; everything else gets a centred spinner whose label stays as the accessible name. Animations live in globals.css so there is one timing curve and both honour prefers-reduced-motion. Donors and donations also drop their copy-pasted pagination in favour of the existing Pagination component. Co-authored-by: Cursor --- apps/frontend/AGENTS.md | 15 ++ .../frontend/src/app/components/DataTable.tsx | 195 +++++++++++++++++ .../src/app/components/ExpensesTable.tsx | 204 ++++++++++-------- .../src/app/components/FullPageSpinner.tsx | 18 +- .../src/app/components/LoadingState.tsx | 50 +++++ .../src/app/components/ReviewExpenseModal.tsx | 3 +- apps/frontend/src/app/components/Skeleton.tsx | 37 ++++ apps/frontend/src/app/components/Spinner.tsx | 51 +++++ .../src/app/components/TableSkeletonRows.tsx | 86 ++++++++ apps/frontend/src/app/dashboard/page.tsx | 3 +- apps/frontend/src/app/donations/page.tsx | 92 ++++---- apps/frontend/src/app/donors/page.tsx | 92 ++++---- apps/frontend/src/app/expenses/page.tsx | 9 +- apps/frontend/src/app/globals.css | 64 +++++- .../app/projects/[id]/ProjectDetailClient.tsx | 3 +- apps/frontend/src/app/projects/page.tsx | 3 +- apps/frontend/src/app/reports/page.tsx | 163 ++++++-------- .../test/components/DataTable.test.tsx | 130 +++++++++++ .../test/components/LoadingState.test.tsx | 34 +++ .../test/components/ProjectPage.test.tsx | 2 +- 20 files changed, 941 insertions(+), 313 deletions(-) create mode 100644 apps/frontend/src/app/components/DataTable.tsx create mode 100644 apps/frontend/src/app/components/LoadingState.tsx create mode 100644 apps/frontend/src/app/components/Skeleton.tsx create mode 100644 apps/frontend/src/app/components/Spinner.tsx create mode 100644 apps/frontend/src/app/components/TableSkeletonRows.tsx create mode 100644 apps/frontend/test/components/DataTable.test.tsx create mode 100644 apps/frontend/test/components/LoadingState.test.tsx diff --git a/apps/frontend/AGENTS.md b/apps/frontend/AGENTS.md index 9cc2f5bc..56d05262 100644 --- a/apps/frontend/AGENTS.md +++ b/apps/frontend/AGENTS.md @@ -73,6 +73,21 @@ Import direction is one-way and must stay that way: `api.ts` ← `authClient.ts` - Tailwind v4 via `@tailwindcss/postcss` (`postcss.config.mjs`), `@import "tailwindcss"` in `globals.css`. Custom theme tokens in the `@theme` block (`--color-core-green`, `--color-primary-*`, fonts Roboto Slab / PT Sans, heading/body sizes). - Chakra UI v3 unstyled components (`Table.Root`, `Dialog`, `Field`, `Button`, `Input`, ...) under `ChakraProvider defaultSystem`. Emotion is a Chakra dep. Inline styles appear alongside Tailwind classes in layout components. +## Shared UI + +Two families of component are **the** way to do their job — don't hand-roll a second one. + +**Tables — `components/DataTable.tsx`.** Every list view (expenses, reports, donors, donations) renders through it, so the green header row, column widths, empty state, row-click behaviour and loading skeleton stay identical. Columns are data: `{ key, header, width, align, cell, skeleton }`. Pass `selection` (see `reports/page.tsx`) for the leading checkbox column — the page keeps owning the selected ids, since that is what its bulk actions need. `ExpensesTable` is a thin wrapper that fixes the expense column set; add domain wrappers like that rather than re-deriving columns per page. + +**Loading — `Spinner` / `LoadingState` / `Skeleton` / `TableSkeletonRows`.** No more `

Loading…

`. + +- `LoadingState` for a region whose content has not arrived (`variant="section"` reserves height; `"inline"` for menus and dialog bodies). The label is the accessible name and is hidden unless `showLabel`. +- `DataTable isLoading` for tables — skeleton rows keep the header and column widths on screen. Set `skeletonRows` to the page size so nothing resizes when data lands. +- Chakra's `Button loading` prop for in-flight actions; it renders its own spinner. +- `Spinner` is the primitive; it takes its colour from `currentColor` and only gets a `label` when nothing around it is already `role="status"`. + +The animations live in `globals.css` (`.branch-spinner`, `.branch-skeleton`, and their keyframes), not in the components — one timing curve for the whole app, and `FullPageSpinner` can render before any component library is mounted. Both honour `prefers-reduced-motion`. + ## Conventions - Page/interactive components start with `'use client'`. diff --git a/apps/frontend/src/app/components/DataTable.tsx b/apps/frontend/src/app/components/DataTable.tsx new file mode 100644 index 00000000..b9f9357c --- /dev/null +++ b/apps/frontend/src/app/components/DataTable.tsx @@ -0,0 +1,195 @@ +'use client'; + +import type React from 'react'; +import { Checkbox, Table } from '@chakra-ui/react'; +import TableSkeletonRows, { type SkeletonColumn } from './TableSkeletonRows'; + +export interface DataTableColumn { + /** Stable identity for the column; doubles as the React key. */ + key: string; + header: React.ReactNode; + /** Width for the ``; percentages keep the table fluid. */ + width?: string; + align?: 'left' | 'center' | 'right'; + cell: (row: T) => React.ReactNode; + /** Shape of this column's loading placeholder — a pill, a short bar, etc. */ + skeleton?: SkeletonColumn; +} + +/** + * Row selection is driven from outside: the pages that support it already own + * the selected ids because that is what their bulk actions operate on. + */ +export interface DataTableSelection { + isSelected: (row: T) => boolean; + onToggleRow: (row: T) => void; + allSelected: boolean; + someSelected: boolean; + onToggleAll: () => void; + /** Accessible name for the header checkbox, e.g. "Select all reports". */ + label?: string; + disabled?: boolean; +} + +interface DataTableProps { + columns: DataTableColumn[]; + rows: T[]; + rowKey: (row: T) => React.Key; + /** Replaces the body with skeleton rows, keeping the header and widths. */ + isLoading?: boolean; + loadingLabel?: string; + /** Ideally the page size, so the table does not resize when data lands. */ + skeletonRows?: number; + emptyMessage?: React.ReactNode; + onRowClick?: (row: T) => void; + selection?: DataTableSelection; + variant?: 'line' | 'outline'; +} + +const CHECKBOX_CONTROL_CSS = { + backgroundColor: 'var(--color-core-white)', + borderColor: 'var(--color-core-green)', + '&[data-state="checked"]': { + backgroundColor: 'var(--color-primary-800)', + borderColor: 'var(--color-core-green)', + }, +}; + +/** + * The app's one table. Every list view goes through here so the green header + * row, column sizing, empty state and loading skeleton stay identical + * everywhere — previously each page rebuilt all four by hand and they drifted. + * + * Columns are data, not markup: give each one a `cell` renderer and, where the + * default bar is wrong, a `skeleton` shape. + */ +export default function DataTable({ + columns, + rows, + rowKey, + isLoading = false, + loadingLabel = 'Loading…', + skeletonRows = 5, + emptyMessage = 'Nothing to show yet.', + onRowClick, + selection, + variant, +}: DataTableProps) { + const columnCount = columns.length + (selection ? 1 : 0); + const hasWidths = columns.some((column) => column.width); + + const skeletonColumns: SkeletonColumn[] = [ + // The checkbox slot gets a square rather than a bar, so the loading table + // reads as the same shape as the loaded one. + ...(selection ? [{ width: '18px', height: 18 } as SkeletonColumn] : []), + ...columns.map((column) => ({ + align: column.align, + ...column.skeleton, + })), + ]; + + return ( + + {hasWidths && ( + + {selection && } + {columns.map((column) => ( + + ))} + + )} + + + + {selection && ( + + + + + + + )} + {columns.map((column) => ( + +
{column.header}
+
+ ))} +
+
+ + + {isLoading ? ( + + ) : rows.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + rows.map((row) => ( + onRowClick(row) : undefined} + // Rows that act like buttons have to be reachable without a + // mouse; the target check keeps Enter on a nested control (a + // receipt link, a checkbox) from also opening the row. + tabIndex={onRowClick ? 0 : undefined} + onKeyDown={ + onRowClick + ? (event) => { + if (event.target !== event.currentTarget) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onRowClick(row); + } + } + : undefined + } + cursor={onRowClick ? 'pointer' : undefined} + _hover={onRowClick ? { backgroundColor: 'var(--color-primary-100)' } : undefined} + > + {selection && ( + event.stopPropagation()}> + selection.onToggleRow(row)} + disabled={selection.disabled} + > + + + + + )} + {columns.map((column) => ( + + {column.cell(row)} + + ))} + + )) + )} + +
+ ); +} diff --git a/apps/frontend/src/app/components/ExpensesTable.tsx b/apps/frontend/src/app/components/ExpensesTable.tsx index 3f5c693c..3303a2b8 100644 --- a/apps/frontend/src/app/components/ExpensesTable.tsx +++ b/apps/frontend/src/app/components/ExpensesTable.tsx @@ -1,5 +1,7 @@ -import { Table } from '@chakra-ui/react'; +'use client'; + import { Expenditure } from '@/types'; +import DataTable, { type DataTableColumn } from './DataTable'; import StatusBadge from './StatusBadge'; interface ExpensesTableProps { @@ -9,6 +11,17 @@ interface ExpensesTableProps { projectNames?: Record; onViewReceipt?: (expenditure: Expenditure) => void; onRowClick?: (expenditure: Expenditure) => void; + /** Fills the body with skeleton rows, keeping the header and widths in place. */ + isLoading?: boolean; + /** Set this to the page size so the table does not resize when data lands. */ + skeletonRows?: number; +} + +function formatAmount(amount: string) { + return `$${parseFloat(amount).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; } export default function ExpensesTable({ @@ -17,98 +30,107 @@ export default function ExpensesTable({ projectNames = {}, onViewReceipt, onRowClick, + isLoading = false, + skeletonRows = 5, }: ExpensesTableProps) { - const columnCount = showProject ? 7 : 6; - - return ( - - - - - - {showProject && } - - - - - - - -
Expense ID
-
Date
-
Type of Expense
- {showProject && ( -
Project
- )} -
Amount
-
Receipt
-
Status
-
-
+ // Percentages are shared out among whichever columns are on, so dropping one + // widens the rest instead of leaving a gap at the end of the row. + const widths = showProject + ? { id: '11.5%', date: '15.3%', type: '16.6%', project: '21.8%', amount: '14%', receipt: '11%', status: '9.8%' } + : { id: '14%', date: '19%', type: '21%', project: '0', amount: '18%', receipt: '14%', status: '14%' }; - - {expenditures.length === 0 ? ( - - - No expenditures found. - - + const columns: DataTableColumn[] = [ + { + key: 'id', + header: 'Expense ID', + width: widths.id, + cell: (e) => `#${String(e.expenditure_id).padStart(6, '0')}`, + skeleton: { width: '80%' }, + }, + { + key: 'date', + header: 'Date', + width: widths.date, + cell: (e) => + new Date(e.spent_on).toLocaleDateString('en-US', { + month: '2-digit', + day: '2-digit', + year: 'numeric', + }), + skeleton: { width: '75%' }, + }, + { + key: 'type', + header: 'Type of Expense', + width: widths.type, + cell: (e) => e.category ?? '—', + }, + ...(showProject + ? [ + { + key: 'project', + header: 'Project', + width: widths.project, + cell: (e: Expenditure) => projectNames[e.project_id] ?? '---', + }, + ] + : []), + { + key: 'amount', + header: 'Amount', + width: widths.amount, + cell: (e) => formatAmount(e.amount), + skeleton: { width: '60%' }, + }, + { + key: 'receipt', + header: 'Receipt', + width: widths.receipt, + cell: (e) => + e.receipt_url ? ( + ) : ( - expenditures.map((e) => ( - onRowClick(e) : undefined} - style={onRowClick ? { cursor: 'pointer' } : undefined} - > - #{String(e.expenditure_id).padStart(6, '0')} - - {new Date(e.spent_on).toLocaleDateString('en-US', { - month: '2-digit', - day: '2-digit', - year: 'numeric', - })} - - {e.category ?? '—'} - {showProject && ( - {projectNames[e.project_id] ?? '---'} - )} - - ${parseFloat(e.amount).toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} - - - {e.receipt_url ? ( - - ) : ( - '---' - )} - - - - - - )) - )} - -
+ '---' + ), + skeleton: { width: '70%' }, + }, + { + key: 'status', + header: 'Status', + width: widths.status, + cell: (e) => , + // Matches the pill the loaded row shows rather than a text bar. + skeleton: { width: '81px', height: 29, className: '!rounded-[14px]' }, + }, + ]; + + return ( + e.expenditure_id} + onRowClick={onRowClick} + isLoading={isLoading} + loadingLabel="Loading expenses…" + skeletonRows={skeletonRows} + emptyMessage="No expenditures found." + /> ); } diff --git a/apps/frontend/src/app/components/FullPageSpinner.tsx b/apps/frontend/src/app/components/FullPageSpinner.tsx index e9663b5d..26057840 100644 --- a/apps/frontend/src/app/components/FullPageSpinner.tsx +++ b/apps/frontend/src/app/components/FullPageSpinner.tsx @@ -1,5 +1,7 @@ 'use client'; +import Spinner from './Spinner'; + /** * Neutral full-viewport placeholder shown while the session resolves or a * redirect is in flight. @@ -7,7 +9,8 @@ * Deliberately reveals nothing about the app shell — the whole point of the * guard is that unauthenticated visitors never see it — and deliberately has no * component-library dependency, because it renders from AuthGate and the root - * page, above anything that could be relied on to be mounted. + * page, above anything that could be relied on to be mounted. `Spinner` is + * plain markup over `globals.css`, which the root layout always loads. */ export default function FullPageSpinner({ label = 'Loading…', @@ -25,19 +28,10 @@ export default function FullPageSpinner({ alignItems: 'center', justifyContent: 'center', backgroundColor: '#f9fafb', + color: '#2E6038', }} > - -
+
); } diff --git a/apps/frontend/src/app/components/LoadingState.tsx b/apps/frontend/src/app/components/LoadingState.tsx new file mode 100644 index 00000000..aa2a010d --- /dev/null +++ b/apps/frontend/src/app/components/LoadingState.tsx @@ -0,0 +1,50 @@ +'use client'; + +import Spinner, { type SpinnerSize } from './Spinner'; + +interface LoadingStateProps { + /** + * Announced to screen readers, and shown as text when `showLabel` is set. + * Say what is loading — several of these can be on screen at once. + */ + label?: string; + /** Renders the label under the spinner. Off by default: the spinner says it. */ + showLabel?: boolean; + size?: SpinnerSize; + /** + * `section` reserves vertical space so a page region does not collapse and + * then jolt when the data lands; `inline` hugs its content for tight spots + * such as a dropdown menu or a dialog body. + */ + variant?: 'section' | 'inline'; + className?: string; +} + +/** + * The standard placeholder for a region whose content has not arrived yet: + * a centred spinner in place of the old "Loading…" paragraphs. + * + * For tables prefer `TableSkeletonRows`, which keeps the header and column + * widths on screen instead of blanking the whole grid. + */ +export default function LoadingState({ + label = 'Loading…', + showLabel = false, + size = 'md', + variant = 'section', + className = '', +}: LoadingStateProps) { + const spacing = variant === 'section' ? 'min-h-[240px] !py-10' : '!py-3'; + + return ( +
+ + {showLabel &&

{label}

} +
+ ); +} diff --git a/apps/frontend/src/app/components/ReviewExpenseModal.tsx b/apps/frontend/src/app/components/ReviewExpenseModal.tsx index cf8643ec..33ea27f7 100644 --- a/apps/frontend/src/app/components/ReviewExpenseModal.tsx +++ b/apps/frontend/src/app/components/ReviewExpenseModal.tsx @@ -13,6 +13,7 @@ import { type ExpenditureDetail, type ExpenditureStatus, } from '@/types'; +import LoadingState from './LoadingState'; import StatusBadge from './StatusBadge'; interface ReviewExpenseModalProps { @@ -157,7 +158,7 @@ export default function ReviewExpenseModal({ - {loading &&

Loading expense...

} + {loading && } {loadError &&

{loadError}

} {!loading && !loadError && detail && ( diff --git a/apps/frontend/src/app/components/Skeleton.tsx b/apps/frontend/src/app/components/Skeleton.tsx new file mode 100644 index 00000000..54e2af12 --- /dev/null +++ b/apps/frontend/src/app/components/Skeleton.tsx @@ -0,0 +1,37 @@ +'use client'; + +import type { CSSProperties } from 'react'; + +interface SkeletonProps { + /** Any CSS length; percentages let a bar track its cell. */ + width?: string | number; + height?: string | number; + /** Offsets the shimmer so a stack of skeletons animates as a wave. */ + delayMs?: number; + className?: string; +} + +/** + * A shimmering placeholder bar. Purely decorative — it is `aria-hidden`, so + * whatever renders it owns the `role="status"` announcement. + */ +export default function Skeleton({ + width = '100%', + height = 14, + delayMs = 0, + className = '', +}: SkeletonProps) { + return ( + + ); +} diff --git a/apps/frontend/src/app/components/Spinner.tsx b/apps/frontend/src/app/components/Spinner.tsx new file mode 100644 index 00000000..846d4c76 --- /dev/null +++ b/apps/frontend/src/app/components/Spinner.tsx @@ -0,0 +1,51 @@ +'use client'; + +/** + * The app's one spinner. Every "something is in flight" affordance should end + * up here rather than rolling its own div or borrowing Chakra's, so the size + * ramp and timing stay consistent. + * + * Colour comes from `currentColor` — set a text colour on the parent (or via + * `className`) to put a spinner on a dark surface. + */ +export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg'; + +/** Ring thickness scales with the diameter, otherwise small sizes read as blobs. */ +const SIZES: Record = { + xs: { box: 14, border: 2 }, + sm: { box: 20, border: 2 }, + md: { box: 32, border: 3 }, + lg: { box: 40, border: 4 }, +}; + +interface SpinnerProps { + size?: SpinnerSize; + /** + * Accessible name. Provide it only when the spinner is the sole indication + * that something is loading — when it sits inside an element that is already + * `role="status"` (LoadingState, FullPageSpinner), leave it off so screen + * readers announce the region once instead of twice. + */ + label?: string; + className?: string; +} + +export default function Spinner({ size = 'md', label, className = '' }: SpinnerProps) { + const { box, border } = SIZES[size]; + + const ring = ( + + ); + + if (!label) return ring; + + return ( + + {ring} + + ); +} diff --git a/apps/frontend/src/app/components/TableSkeletonRows.tsx b/apps/frontend/src/app/components/TableSkeletonRows.tsx new file mode 100644 index 00000000..7864a624 --- /dev/null +++ b/apps/frontend/src/app/components/TableSkeletonRows.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { Table } from '@chakra-ui/react'; +import Skeleton from './Skeleton'; + +export interface SkeletonColumn { + /** + * Width of the bar inside the cell. Percentages get a per-row jitter so the + * block reads like text rather than a bar chart; absolute lengths are left + * exactly as given, for fixed slots such as a checkbox or an icon. + */ + width?: string; + align?: 'left' | 'center' | 'right'; + height?: number; + /** Extra classes on the bar, e.g. `!rounded-full` for a status pill. */ + className?: string; +} + +interface TableSkeletonRowsProps { + /** Ideally the page size, so the table does not resize when data lands. */ + rows?: number; + /** A count for evenly-filled cells, or per-column shapes. */ + columns: number | SkeletonColumn[]; + label?: string; +} + +/** Deterministic — `Math.random()` here would churn on every re-render. */ +const JITTER = [1, 0.82, 0.93, 0.71, 0.88, 0.78]; + +/** + * Skeleton rows to drop inside a `Table.Body` while its data loads, so the + * header, column widths and page height stay put and the table fades in + * instead of popping. + * + * ```tsx + * + * {loading ? : rows.map(...)} + * + * ``` + */ +export default function TableSkeletonRows({ + rows = 5, + columns, + label = 'Loading…', +}: TableSkeletonRowsProps) { + const shape: SkeletonColumn[] = + typeof columns === 'number' ? Array.from({ length: columns }, () => ({})) : columns; + + return ( + <> + {Array.from({ length: rows }, (_, rowIndex) => ( + + {shape.map((column, columnIndex) => { + const width = column.width ?? '70%'; + const jitter = JITTER[(rowIndex * 3 + columnIndex) % JITTER.length]; + const align = column.align ?? 'left'; + + return ( + + {rowIndex === 0 && columnIndex === 0 && ( + + {label} + + )} + + + + + ); + })} + + ))} + + ); +} diff --git a/apps/frontend/src/app/dashboard/page.tsx b/apps/frontend/src/app/dashboard/page.tsx index f5dfd992..4a277e8d 100644 --- a/apps/frontend/src/app/dashboard/page.tsx +++ b/apps/frontend/src/app/dashboard/page.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import NavBar from '../components/Navbar'; import Header from '../components/Header'; import ProjectCard from '../components/ProjectCard'; +import LoadingState from '../components/LoadingState'; import { useApi } from '@/hooks/useApi'; import { useAuth } from '@/context/AuthContext'; @@ -67,7 +68,7 @@ export default function DashboardPage() { {firstName ? `Welcome back, ${firstName}` : 'Dashboard'} - {isLoading &&

Loading projects…

} + {isLoading && } {error &&

{error}

} {!isLoading && !error && projects.length === 0 && (

You are not a member of any projects yet.

diff --git a/apps/frontend/src/app/donations/page.tsx b/apps/frontend/src/app/donations/page.tsx index 33c60627..31014556 100644 --- a/apps/frontend/src/app/donations/page.tsx +++ b/apps/frontend/src/app/donations/page.tsx @@ -1,12 +1,14 @@ 'use client' import React, { useState } from 'react'; import NavBar from "../components/Navbar"; -import { HStack, Input, Button, Table, Dialog, Portal, CloseButton, Stack } from "@chakra-ui/react"; +import { HStack, Input, Button, 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 { FaPlus } from "react-icons/fa"; import DropdownSelector from '../components/DropdownSelector'; +import DataTable, { type DataTableColumn } from '../components/DataTable'; +import Pagination from '../components/Pagination'; type Donation = { donor_id: number; @@ -31,6 +33,31 @@ const mockDonations: Donation[] = [ { donor_id: 10, date: '03/22/2024', project_name: 'After-School Arts', amount: 600 }, ]; +const donationColumns: DataTableColumn[] = [ + { + key: 'date', + header: 'Date', + width: '15%', + cell: (donation) => donation.date ?? '—', + skeleton: { width: '70%' }, + }, + { + key: 'donor', + header: 'Donor ID', + width: '15%', + cell: (donation) => `#${String(donation.donor_id).padStart(6, '0')}`, + skeleton: { width: '80%' }, + }, + { key: 'project', header: 'Project Name', width: '55%', cell: (donation) => donation.project_name }, + { + key: 'amount', + header: 'Amount', + width: '15%', + cell: (donation) => `$${donation.amount.toLocaleString()}`, + skeleton: { width: '55%' }, + }, +]; + export default function DonationsPage() { const [currentPage, setCurrentPage] = useState(1); const rowsPerPage = 10; @@ -41,13 +68,6 @@ export default function DonationsPage() { 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(''); const [showSort, setShowSort] = useState(false); @@ -208,50 +228,18 @@ export default function DonationsPage() { - - - - - - - - - - Date - Donor ID - Project Name - Amount - - - - {currentDonations.map((donation) => ( - - {donation.date ?? '—'} - #{String(donation.donor_id).padStart(6, '0')} - {donation.project_name} - ${donation.amount.toLocaleString()} - - ))} - - + donation.donor_id} + emptyMessage="No donations found." + /> -
- - 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 === '...' - ? - : - ))} - 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)' }} - /> - -
+ diff --git a/apps/frontend/src/app/donors/page.tsx b/apps/frontend/src/app/donors/page.tsx index 152d00ab..4014e291 100644 --- a/apps/frontend/src/app/donors/page.tsx +++ b/apps/frontend/src/app/donors/page.tsx @@ -1,12 +1,14 @@ 'use client' import React, { useState } from 'react'; import NavBar from "../components/Navbar"; -import { HStack, Input, Button, Table, Dialog, Portal, CloseButton, Stack } from "@chakra-ui/react"; +import { HStack, Input, Button, 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 { FaPlus } from "react-icons/fa"; import DropdownSelector from '../components/DropdownSelector'; +import DataTable, { type DataTableColumn } from '../components/DataTable'; +import Pagination from '../components/Pagination'; type Donor = { donor_id: number; @@ -30,6 +32,31 @@ const mockDonors: Donor[] = [ { donor_id: 10, organization: 'Coastal Care Foundation', contact_name: 'Nina Rossi', contact_email: 'nina@coastalcare.org', num_projects: 3, last_donation: '03/22/2024' }, ]; +const donorColumns: DataTableColumn[] = [ + { + key: 'id', + header: 'Donor ID', + width: '15%', + cell: (donor) => `#${String(donor.donor_id).padStart(6, '0')}`, + skeleton: { width: '80%' }, + }, + { key: 'organization', header: 'Donor Name', width: '55%', cell: (donor) => donor.organization }, + { + key: 'projects', + header: '# of Projects', + width: '15%', + cell: (donor) => donor.num_projects, + skeleton: { width: '35%' }, + }, + { + key: 'last_donation', + header: 'Last Donation', + width: '15%', + cell: (donor) => donor.last_donation ?? '—', + skeleton: { width: '70%' }, + }, +]; + export default function DonorsPage() { const [currentPage, setCurrentPage] = useState(1); const rowsPerPage = 10; @@ -40,13 +67,6 @@ export default function DonorsPage() { 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(''); const donorNames = mockDonors.map(d => d.organization); @@ -186,50 +206,18 @@ export default function DonorsPage() { - - - - - - - - - - Donor ID - Donor Name - # of Projects - Last Donation - - - - {currentDonors.map((donor) => ( - - #{String(donor.donor_id).padStart(6, '0')} - {donor.organization} - {donor.num_projects} - {donor.last_donation ?? '—'} - - ))} - - + donor.donor_id} + emptyMessage="No donors found." + /> -
- - 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 === '...' - ? - : - ))} - 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)' }} - /> - -
+ diff --git a/apps/frontend/src/app/expenses/page.tsx b/apps/frontend/src/app/expenses/page.tsx index e56dcda4..3fa0a44e 100644 --- a/apps/frontend/src/app/expenses/page.tsx +++ b/apps/frontend/src/app/expenses/page.tsx @@ -296,13 +296,14 @@ function ExpensePageContent() { - {/* Loading / Error */} - {loading &&

Loading expenditures...

} {error &&

{error}

} - {/* Table */} - {!loading && !error && ( + {/* Table — the skeleton lives inside it, so the header and column + widths stay put while the rows load. */} + {!error && ( and . They + live here rather than in the components so every loading affordance in the + app shares one timing curve, and so a spinner can render before any + component library is mounted (see FullPageSpinner). */ + +@keyframes branch-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes branch-shimmer { + to { + transform: translateX(100%); + } +} + +/* Takes its colour from `currentColor`, so a spinner on a dark surface only + needs a text colour rather than its own variant. */ +.branch-spinner { + display: inline-block; + flex-shrink: 0; + border-radius: 9999px; + border-style: solid; + border-color: color-mix(in srgb, currentColor 22%, transparent); + border-top-color: currentColor; + animation: branch-spin 0.7s linear infinite; +} + +.branch-skeleton { + position: relative; + overflow: hidden; + background-color: var(--color-black-100); + border-radius: 4px; +} + +.branch-skeleton::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background-image: linear-gradient( + 90deg, + transparent, + color-mix(in srgb, var(--color-core-white) 70%, transparent), + transparent + ); + animation: branch-shimmer 1.4s ease-in-out infinite; + /* Staggered per row so a table of skeletons reads as one wave. */ + animation-delay: var(--branch-skeleton-delay, 0s); +} + +@media (prefers-reduced-motion: reduce) { + .branch-spinner { + animation-duration: 2.4s; + } + + .branch-skeleton::after { + animation: none; + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx b/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx index 62c71009..7593b31e 100644 --- a/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx +++ b/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx @@ -5,6 +5,7 @@ import { FaEdit } from 'react-icons/fa'; import { RxCaretRight } from 'react-icons/rx'; import NavBar from '../../components/Navbar'; import ExpensesTable from '../../components/ExpensesTable'; +import LoadingState from '../../components/LoadingState'; import StaffCard from '../../components/StaffCard'; import { useApi } from '@/hooks/useApi'; import { Project, Expenditure, Member } from '@/types'; @@ -60,7 +61,7 @@ export default function ProjectPage() {
-

Loading project...

+
); diff --git a/apps/frontend/src/app/projects/page.tsx b/apps/frontend/src/app/projects/page.tsx index 1034e880..d2b09f10 100644 --- a/apps/frontend/src/app/projects/page.tsx +++ b/apps/frontend/src/app/projects/page.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import NavBar from '../components/Navbar'; import Header from '../components/Header'; import ProjectCard from '../components/ProjectCard'; +import LoadingState from '../components/LoadingState'; import { useApi } from '@/hooks/useApi'; /** @@ -65,7 +66,7 @@ export default function ProjectsListPage() { Projects - {isLoading &&

Loading projects…

} + {isLoading && } {error &&

{error}

} {!isLoading && !error && projects.length === 0 && (

No projects to show.

diff --git a/apps/frontend/src/app/reports/page.tsx b/apps/frontend/src/app/reports/page.tsx index 86454366..ad4d693c 100644 --- a/apps/frontend/src/app/reports/page.tsx +++ b/apps/frontend/src/app/reports/page.tsx @@ -8,13 +8,12 @@ import Pagination from '../components/Pagination'; import { HStack, Button, - Table, - Checkbox, NativeSelect, Dialog, Portal, VStack, } from '@chakra-ui/react'; +import DataTable, { type DataTableColumn } from '../components/DataTable'; import { useApi } from '@/hooks/useApi'; import { type Project } from '@/lib/reports'; import UploadReportModal from '../components/UploadReportModal'; @@ -220,6 +219,52 @@ function ReportsPageContent() { setIsUploadModalOpen(true); } + const reportColumns: DataTableColumn[] = [ + { + key: 'date', + header: 'Date Created', + width: '18%', + cell: (report) => formatDate(report.date_created), + skeleton: { width: '70%' }, + }, + { + key: 'title', + header: 'Report Name', + width: '32%', + cell: (report) => ( + + ), + }, + { + key: 'emails', + header: 'Emails', + width: '35%', + cell: (report) => + report.emails && report.emails.length > 0 ? report.emails.join(', ') : '—', + skeleton: { width: '85%' }, + }, + { + key: 'format', + header: 'Format', + width: '15%', + align: 'right', + cell: (report) => getFormatLabel(report.object_url), + skeleton: { width: '45%' }, + }, + ]; + return (
@@ -297,106 +342,32 @@ function ReportsPageContent() { - {/* Loading / Error */} - {loading &&

Loading reports...

} {error &&

{error}

} {actionError && (

{actionError}

)} {/* Reports tab content */} - {!loading && !error && activeTab === 'reports' && ( - - - - - - - - - - - Date Created - - - Report Name - - - Emails - - - Format - - - - - {reports.length === 0 && ( - - - No reports found. - - - )} - {reports.map((report) => ( - - - toggleOne(report.report_id)} - > - - - - - {formatDate(report.date_created)} - - - - - {report.emails && report.emails.length > 0 ? report.emails.join(', ') : '—'} - - - {getFormatLabel(report.object_url)} - - - ))} - - + {!error && activeTab === 'reports' && ( + report.report_id} + isLoading={loading} + loadingLabel="Loading reports…" + skeletonRows={ROWS_PER_PAGE} + emptyMessage="No reports found." + selection={{ + label: 'Select all reports', + isSelected: (report) => selectedIds.includes(report.report_id), + onToggleRow: (report) => toggleOne(report.report_id), + allSelected, + someSelected, + onToggleAll: toggleAll, + disabled: loading, + }} + /> )} {/* Schedule tab content */} diff --git a/apps/frontend/test/components/DataTable.test.tsx b/apps/frontend/test/components/DataTable.test.tsx new file mode 100644 index 00000000..ca126b88 --- /dev/null +++ b/apps/frontend/test/components/DataTable.test.tsx @@ -0,0 +1,130 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen, fireEvent, within } from '../utils'; +import DataTable, { type DataTableColumn } from '@/app/components/DataTable'; + +type Row = { id: number; name: string; amount: string }; + +const rows: Row[] = [ + { id: 1, name: 'First', amount: '$10' }, + { id: 2, name: 'Second', amount: '$20' }, +]; + +const columns: DataTableColumn[] = [ + { key: 'name', header: 'Name', width: '70%', cell: (row) => row.name }, + { key: 'amount', header: 'Amount', width: '30%', align: 'right', cell: (row) => row.amount }, +]; + +function renderTable(props: Partial>> = {}) { + return render( + row.id} {...props} />, + ); +} + +describe('DataTable', () => { + it('renders a header and a row per record', () => { + renderTable(); + + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Amount')).toBeInTheDocument(); + expect(screen.getByText('First')).toBeInTheDocument(); + expect(screen.getByText('$20')).toBeInTheDocument(); + }); + + it('shows the empty message when there are no rows', () => { + renderTable({ rows: [], emptyMessage: 'Nothing here.' }); + + expect(screen.getByText('Nothing here.')).toBeInTheDocument(); + }); + + describe('when loading', () => { + it('keeps the header and replaces the rows with skeletons', () => { + renderTable({ isLoading: true, skeletonRows: 4, loadingLabel: 'Loading things…' }); + + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.queryByText('First')).not.toBeInTheDocument(); + // Header row plus one row per skeleton. + expect(screen.getAllByRole('row')).toHaveLength(5); + }); + + it('announces itself once', () => { + renderTable({ isLoading: true, loadingLabel: 'Loading things…' }); + + expect(screen.getByRole('status')).toHaveTextContent('Loading things…'); + }); + + it('does not fall back to the empty message', () => { + renderTable({ isLoading: true, rows: [], emptyMessage: 'Nothing here.' }); + + expect(screen.queryByText('Nothing here.')).not.toBeInTheDocument(); + }); + }); + + describe('row interaction', () => { + it('calls onRowClick when a row is clicked', () => { + const onRowClick = jest.fn(); + renderTable({ onRowClick }); + + fireEvent.click(screen.getByText('Second')); + + expect(onRowClick).toHaveBeenCalledWith(rows[1]); + }); + + it('opens a row from the keyboard', () => { + const onRowClick = jest.fn(); + renderTable({ onRowClick }); + + const row = screen.getByText('First').closest('tr') as HTMLElement; + fireEvent.keyDown(row, { key: 'Enter' }); + + expect(onRowClick).toHaveBeenCalledWith(rows[0]); + }); + + it('leaves rows unfocusable when they are not clickable', () => { + renderTable(); + + const row = screen.getByText('First').closest('tr') as HTMLElement; + expect(row).not.toHaveAttribute('tabindex'); + }); + }); + + describe('selection', () => { + const selection = { + label: 'Select all rows', + isSelected: (row: Row) => row.id === 1, + onToggleRow: jest.fn(), + allSelected: false, + someSelected: true, + onToggleAll: jest.fn(), + }; + + beforeEach(() => jest.clearAllMocks()); + + it('adds a checkbox column', () => { + renderTable({ selection }); + + expect(screen.getByLabelText('Select all rows')).toBeInTheDocument(); + }); + + it('toggles a single row', async () => { + const user = userEvent.setup(); + renderTable({ selection }); + + const row = screen.getByText('Second').closest('tr') as HTMLElement; + await user.click(within(row).getByRole('checkbox')); + + expect(selection.onToggleRow).toHaveBeenCalledWith(rows[1]); + }); + + it('does not open the row when its checkbox is clicked', async () => { + const user = userEvent.setup(); + const onRowClick = jest.fn(); + renderTable({ selection, onRowClick }); + + const row = screen.getByText('First').closest('tr') as HTMLElement; + await user.click(within(row).getByRole('checkbox')); + + expect(selection.onToggleRow).toHaveBeenCalledTimes(1); + expect(onRowClick).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/frontend/test/components/LoadingState.test.tsx b/apps/frontend/test/components/LoadingState.test.tsx new file mode 100644 index 00000000..1ea4eb43 --- /dev/null +++ b/apps/frontend/test/components/LoadingState.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '../utils'; +import LoadingState from '@/app/components/LoadingState'; +import Spinner from '@/app/components/Spinner'; + +describe('LoadingState', () => { + it('names the region for screen readers without showing the text', () => { + render(); + + expect(screen.getByRole('status', { name: 'Loading projects…' })).toBeInTheDocument(); + expect(screen.queryByText('Loading projects…')).not.toBeInTheDocument(); + }); + + it('can show the label as text', () => { + render(); + + expect(screen.getByText('Loading projects…')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Loading projects…'); + }); +}); + +describe('Spinner', () => { + it('is decorative unless it is given a label', () => { + const { container } = render(); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(container.querySelector('.branch-spinner')).toBeInTheDocument(); + }); + + it('announces itself when it is the only loading cue', () => { + render(); + + expect(screen.getByRole('status', { name: 'Saving…' })).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/test/components/ProjectPage.test.tsx b/apps/frontend/test/components/ProjectPage.test.tsx index e033802f..1f039539 100644 --- a/apps/frontend/test/components/ProjectPage.test.tsx +++ b/apps/frontend/test/components/ProjectPage.test.tsx @@ -40,7 +40,7 @@ beforeEach(() => { describe('Project Page', () => { it('renders loading state initially', () => { render(); - expect(screen.getByText('Loading project...')).toBeInTheDocument(); + expect(screen.getByRole('status', { name: 'Loading project…' })).toBeInTheDocument(); }); it('renders the project name as heading', async () => {