275 lines
9.6 KiB
JavaScript
275 lines
9.6 KiB
JavaScript
/* ============================================================
|
||
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 <DataTable/>.
|
||
|
||
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 (
|
||
<label className="page-size">
|
||
<span className="page-size-label">{label}</span>
|
||
<select
|
||
id={id}
|
||
className="select page-size-select"
|
||
value={selected}
|
||
onChange={(e) => onChange(e.target.value === 'all' ? 'all' : clampPageSize(e.target.value, max))}
|
||
aria-label={label}
|
||
>
|
||
{options.map((n) => (
|
||
<option key={n} value={n}>{n}</option>
|
||
))}
|
||
{allowAll && <option value="all">All</option>}
|
||
</select>
|
||
</label>
|
||
)
|
||
}
|
||
|
||
export function Pagination({
|
||
from, to, total, page, pages, setPage, pageButtons,
|
||
pageSize, onPageSizeChange, pageSizeMax = 100, allowAll = false,
|
||
}) {
|
||
return (
|
||
<div className="pagination">
|
||
<span className="page-info">
|
||
Showing <b>{from}–{to}</b> of <b>{total}</b>
|
||
</span>
|
||
<div className="page-controls">
|
||
{onPageSizeChange && (
|
||
<>
|
||
<PageSizeField
|
||
value={pageSize ?? DEFAULT_PAGE_SIZE}
|
||
onChange={onPageSizeChange}
|
||
max={pageSizeMax}
|
||
allowAll={allowAll}
|
||
/>
|
||
<span className="page-size-total">of <b>{total}</b></span>
|
||
</>
|
||
)}
|
||
<div className="page-nav">
|
||
<button className="page-btn" disabled={page <= 1} onClick={() => setPage(1)} aria-label="First page">
|
||
<Icon name="chevrons-left" />
|
||
</button>
|
||
<button className="page-btn" disabled={page <= 1} onClick={() => setPage(page - 1)} aria-label="Previous page">
|
||
<Icon name="chevron-left" />
|
||
</button>
|
||
<div className="page-nums">
|
||
{(pageButtons ?? []).map((p, i) =>
|
||
p === '…' ? (
|
||
<span key={`gap-${i}`} className="page-btn" style={{ cursor: 'default' }}>…</span>
|
||
) : (
|
||
<button
|
||
key={p}
|
||
className={`page-btn ${p === page ? 'active' : ''}`}
|
||
onClick={() => setPage(p)}
|
||
aria-current={p === page ? 'page' : undefined}
|
||
>
|
||
{p}
|
||
</button>
|
||
),
|
||
)}
|
||
</div>
|
||
<button className="page-btn" disabled={page >= pages} onClick={() => setPage(page + 1)} aria-label="Next page">
|
||
<Icon name="chevron-right" />
|
||
</button>
|
||
<button className="page-btn" disabled={page >= pages} onClick={() => setPage(pages)} aria-label="Last page">
|
||
<Icon name="chevrons-right" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** The sortable <thead>, exported so useDataTable consumers with custom
|
||
tbody markup (Candidates' checkbox column) stop copying it verbatim. */
|
||
export function DataTableHead({ columns, sort, toggleSort }) {
|
||
return (
|
||
<thead>
|
||
<tr>
|
||
{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 (
|
||
<th
|
||
key={c.key}
|
||
className={cls}
|
||
style={{ textAlign: c.align || 'left' }}
|
||
onClick={c.sortable ? () => toggleSort(c.key) : undefined}
|
||
>
|
||
{c.label}
|
||
{c.sortable && (
|
||
<span className="sort-ind">
|
||
{isSorted ? (sort.dir === 1 ? '▲' : '▼') : '⇅'}
|
||
</span>
|
||
)}
|
||
</th>
|
||
)
|
||
})}
|
||
</tr>
|
||
</thead>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className="dt">
|
||
<div className="table-wrap">
|
||
<table className="data">
|
||
<DataTableHead columns={columns} sort={t.sort} toggleSort={t.toggleSort} />
|
||
<tbody>
|
||
{t.pageRows.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={columns.length}>
|
||
<EmptyState>{empty}</EmptyState>
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
t.pageRows.map((row, i) => (
|
||
<tr
|
||
key={row.id ?? i}
|
||
className={onRowClick ? 'row-click' : undefined}
|
||
tabIndex={onRowClick ? 0 : undefined}
|
||
onClick={onRowClick ? (e) => {
|
||
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) => (
|
||
<td key={c.key} style={{ textAlign: c.align || 'left' }}>
|
||
{c.render ? c.render(row) : (row[c.key] ?? '')}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<Pagination {...t} pageSize={size} onPageSizeChange={setSize} pageSizeMax={pageSizeMax} />
|
||
</div>
|
||
)
|
||
}
|