HR-ATS-Portal/frontend/src/ui/DataTable.jsx

275 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* ============================================================
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>
)
}