/** * The DataTable pattern, worked: a list of invoices that is searched, * filtered, sorted, paged, selected and acted on, with its loading, empty and * error states. Copy it and change the record, not the structure. * * It is complete as it stands: `ionbase-ui` and React are its only imports, * and the build type-checks it against the package's published types. The * data comes in through `loadInvoices`; everything else is here. * * Not shown, see the pattern: the query in the address (the FilteredDataTable * pattern and its example), columns people choose, a row's detail in a * SidePanel, and DestructiveConfirm before a destructive bulk action. */ import { useEffect, useState, type SVGProps } from 'react'; import { Alert, Button, EmptyState, Icon, Menu, MenuItem, MenuTrigger, MultiSelect, Pagination, SearchField, Skeleton, Stack, StatusIndicator, Table, TableBatchBar, TableBody, TableCell, TableHead, TableRow, Tag, TagGroup, useTableSelection, useTableSort, type TableSort, } from 'ionbase-ui'; export type InvoiceStatus = 'paid' | 'open' | 'overdue'; export interface Invoice { id: string; customer: string; /** In cents, so sums are exact. */ amount: number; status: InvoiceStatus; /** ISO date. */ due: string; } type Column = 'customer' | 'amount' | 'due'; const STATUS: Record< InvoiceStatus, { label: string; intent: 'success' | 'neutral' | 'error' } > = { paid: { label: 'Paid', intent: 'success' }, open: { label: 'Open', intent: 'neutral' }, overdue: { label: 'Overdue', intent: 'error' }, }; const STATUS_OPTIONS = Object.entries(STATUS).map(([value, s]) => ({ value, label: s.label, })); const PAGE_SIZE = 10; const money = new Intl.NumberFormat('en', { style: 'currency', currency: 'USD', }); const date = new Intl.DateTimeFormat('en', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC', }); /** Any SVG component is an icon. The design system does not ship a set. */ function MoreIcon(props: SVGProps) { return ( ); } type Load = | { status: 'loading' } | { status: 'error'; error: Error } | { status: 'ready'; invoices: Invoice[] }; function byColumn(sort: TableSort | null) { return (a: Invoice, b: Invoice) => { if (!sort) return 0; const d = sort.column === 'amount' ? a.amount - b.amount : a[sort.column].localeCompare(b[sort.column]); return sort.direction === 'ascending' ? d : -d; }; } export function InvoicesTable({ loadInvoices, onRemind, onMarkPaid, }: { loadInvoices: (signal: AbortSignal) => Promise; onRemind?: (ids: string[]) => void; onMarkPaid?: (id: string, paid: boolean) => void; }) { /* The three states. A retry is a new attempt, and a newer attempt aborts * the one before, so a slow old answer cannot overwrite a newer one. */ const [attempt, setAttempt] = useState(0); const [load, setLoad] = useState({ status: 'loading' }); useEffect(() => { const controller = new AbortController(); setLoad({ status: 'loading' }); loadInvoices(controller.signal).then( (invoices) => setLoad({ status: 'ready', invoices }), (error: unknown) => { if (controller.signal.aborted) return; setLoad({ status: 'error', error: error instanceof Error ? error : new Error(String(error)), }); }, ); return () => controller.abort(); // An attempt is what reloads. `loadInvoices` may be a new function every // render, so depending on it would refetch on every render. }, [attempt]); const [search, setSearch] = useState(''); const [statuses, setStatuses] = useState([]); const [page, setPage] = useState(1); const { sort, sortProps } = useTableSort({ column: 'due', direction: 'ascending', }); const all = load.status === 'ready' ? load.invoices : []; const q = search.trim().toLowerCase(); const matching = all .filter((i) => !q || i.customer.toLowerCase().includes(q)) .filter((i) => !statuses.length || statuses.includes(i.status)) .sort(byColumn(sort)); const pageCount = Math.max(1, Math.ceil(matching.length / PAGE_SIZE)); const rows = matching.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); const selection = useTableSelection({ total: matching.length }); /* A change to which rows match starts again at page 1 and drops the * selection. A page change keeps it, so rows ticked on two pages can be * acted on together. */ const refilter = () => { setPage(1); selection.clear(); }; const header = (column: Column, first?: 'descending') => { const props = sortProps(column, { firstDirection: first }); return { ...props, onSort: () => { props.onSort(); refilter(); }, }; }; const activeFilters = [ ...(q ? [{ id: 'search', label: `Search: “${search.trim()}”` }] : []), ...statuses.map((s) => ({ id: `status:${s}`, label: `Status: ${STATUS[s as InvoiceStatus].label}`, })), ]; const clearFilters = () => { setSearch(''); setStatuses([]); refilter(); }; /** The ids a bulk action reaches: the ticked ones, or every match but the unticked. */ const targets = () => { const s = selection.selection; return s.mode === 'all' ? matching.map((i) => i.id).filter((id) => !s.except.has(id)) : [...s.keys]; }; /* The header row, the same in every state, so nothing moves when the * rows land and a sort pressed during a load keeps its focus. */ const headers = ( <> Customer Status Amount Due Actions ); return ( {/* The toolbar stays usable in every state, the error included. */} { setSearch(value); refilter(); }} /> {/* Its values are the active-filter tags below, so it draws none. */} { setStatuses(value); refilter(); }} hideTags /> {activeFilters.length > 0 && ( { if (keys.has('search')) setSearch(''); setStatuses((s) => s.filter((v) => !keys.has(`status:${v}`))); refilter(); }} > {(f) => {f.label}} )} {/* Always rendered: its count is a live region, which must be in the page before the first row is ticked. Hidden while nothing is. */} {/* The count after every change, or a filter is silent. */}

{load.status === 'ready' ? `${matching.length} ${matching.length === 1 ? 'invoice' : 'invoices'}` : ''}

{load.status === 'loading' && (

Loading invoices

{/* The selection column too, where the checkboxes will be. */} Select {headers} {Array.from({ length: 5 }, (_, r) => ( {Array.from({ length: 6 }, (_, c) => ( ))} ))}
)} {load.status === 'error' && ( setAttempt((n) => n + 1)} > Try again } > {load.error.message} Your search and filters are kept. )} {/* Two empties, with opposite ways out. */} {load.status === 'ready' && all.length === 0 && ( )} {load.status === 'ready' && all.length > 0 && matching.length === 0 && ( Clear filters } /> )} {rows.length > 0 && ( <> i.id), 'Select all invoices on this page', )} > {headers} {rows.map((i) => { const paid = i.status === 'paid'; return ( {i.customer} {STATUS[i.status].label} {money.format(i.amount / 100)} {date.format(new Date(i.due))}
{pageCount > 1 && ( )} )}
); }