/* ============================================================ DataTable.jsx — the js/ui.js dataTable, split into a headless hook and a presentational component. The split matters: Candidates needs the sort/paginate behaviour but renders its own markup (a checkbox column bound to a selection Set), so it uses useDataTable alone. The other six consumers use . Sort comparator and the ellipsis pager windowing are ported verbatim. Page size defaults to 50 and is user-settable (10 / 50 / 100); screens that paginate on the server pass the same value as the query param. ============================================================ */ import { useEffect, useMemo, useState } from 'react' import Icon from './icons' import { EmptyState } from './primitives' /** Default Per page value on every listing. 10 remains in PAGE_SIZE_OPTIONS. */ export const DEFAULT_PAGE_SIZE = 50 /** Fixed Per page choices — a dropdown, not a free-text box. */ export const PAGE_SIZE_OPTIONS = [10, 50, 100] export function clampPageSize(value, max = 100) { const allowed = PAGE_SIZE_OPTIONS.filter((n) => n <= max) const fallback = allowed.includes(DEFAULT_PAGE_SIZE) ? DEFAULT_PAGE_SIZE : (allowed[0] ?? DEFAULT_PAGE_SIZE) const n = Number.parseInt(value, 10) if (!Number.isFinite(n) || !allowed.includes(n)) return fallback return n } export function useDataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE }) { const [sort, setSort] = useState({ key: null, dir: 1 }) const [page, setPage] = useState(1) const size = Math.max(1, pageSize || DEFAULT_PAGE_SIZE) // New data (filters) starts on page 1. Changing page size keeps the current // page and only clamps if that page no longer exists. useEffect(() => setPage(1), [rows]) useEffect(() => { setPage((p) => Math.min(p, Math.max(1, Math.ceil((rows?.length ?? 0) / size)))) }, [size, rows?.length]) const sorted = useMemo(() => { if (!sort.key) return rows const col = columns.find((c) => c.key === sort.key) return [...rows].sort((a, b) => { let va = col?.sortValue ? col.sortValue(a) : a[sort.key] let vb = col?.sortValue ? col.sortValue(b) : b[sort.key] if (typeof va === 'string') { va = va.toLowerCase() vb = (vb || '').toLowerCase() } if (va < vb) return -1 * sort.dir if (va > vb) return 1 * sort.dir return 0 }) }, [rows, columns, sort]) const total = sorted.length const pages = Math.max(1, Math.ceil(total / size)) const current = Math.min(page, pages) const start = (current - 1) * size function toggleSort(key) { setSort((s) => (s.key === key ? { key, dir: s.dir * -1 } : { key, dir: 1 })) } return { pageRows: sorted.slice(start, start + size), sort, toggleSort, page: current, pages, setPage, from: total ? start + 1 : 0, to: Math.min(start + size, total), total, pageButtons: pageWindow(current, pages), } } /** 1 2 3 … 10, then 2 3 4 … 10 as you move forward. The last button is always the last page number. */ export function pageWindow(cur, pages) { const last = Math.max(1, pages) const windowSize = 3 if (last <= windowSize + 1) { return Array.from({ length: last }, (_, i) => i + 1) } let start = Math.max(1, cur) if (start + windowSize - 1 >= last) start = last - windowSize const nums = [] for (let i = 0; i < windowSize; i++) nums.push(start + i) const lastInWindow = nums[nums.length - 1] if (lastInWindow < last - 1) { nums.push('…') nums.push(last) } else if (lastInWindow < last) { nums.push(last) } return nums } /** Keep the current page when Per page changes; clamp if it is past the end. */ export function pageAfterSizeChange(currentPage, total, nextSize) { const size = Math.max(1, nextSize) const pages = Math.max(1, Math.ceil((total || 0) / size)) return Math.min(Math.max(1, currentPage || 1), pages) } /** Per page dropdown: 10, 50, 100 (values above `max` are omitted). With `allowAll`, an extra "All" entry reports the sentinel 'all' — the caller drops its top/limit param, which the inbox endpoints read as unpaged (Query(None) -> no LIMIT). Opt-in per screen. */ export function PageSizeField({ value, onChange, max = 100, label = 'Per page', id, allowAll = false }) { const options = PAGE_SIZE_OPTIONS.filter((n) => n <= max) const isAll = allowAll && value === 'all' const selected = isAll ? 'all' : clampPageSize(value, max) return ( ) } export function Pagination({ from, to, total, page, pages, setPage, pageButtons, pageSize, onPageSizeChange, pageSizeMax = 100, allowAll = false, }) { return (
Showing {from}–{to} of {total}
{onPageSizeChange && ( <> of {total} )}
{(pageButtons ?? []).map((p, i) => p === '…' ? ( ) : ( ), )}
) } /** The sortable , exported so useDataTable consumers with custom tbody markup (Candidates' checkbox column) stop copying it verbatim. */ export function DataTableHead({ columns, sort, toggleSort }) { return ( {columns.map((c) => { const isSorted = sort.key === c.key const cls = [ c.sortable ? 'sortable' : '', isSorted ? (sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '', ].filter(Boolean).join(' ') return ( toggleSort(c.key) : undefined} > {c.label} {c.sortable && ( {isSorted ? (sort.dir === 1 ? '▲' : '▼') : '⇅'} )} ) })} ) } export default function DataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE, pageSizeMax = 100, empty, onRowClick }) { const [size, setSize] = useState(pageSize) const t = useDataTable({ columns, rows, pageSize: size }) return (
{t.pageRows.length === 0 ? ( ) : ( t.pageRows.map((row, i) => ( { if (e.target.closest('button, a, select, input, textarea, label, .row-actions')) return onRowClick(row) } : undefined} onKeyDown={onRowClick ? (e) => { if (e.key === 'Enter' && e.target === e.currentTarget) onRowClick(row) } : undefined} > {columns.map((c) => ( ))} )) )}
{empty}
{c.render ? c.render(row) : (row[c.key] ?? '')}
) }