feat(web): expand CertificatesPage filters + reusable DataTable pagination (F-1 master)

Closes two 2026-04-24 audit findings (P2):

  - cat-e-610251c8f72d: CertificatesPage exposed only 5 of the
    backend handler's 17 supported query filters. Audit recommended
    minimum-add: team_id (already first-class elsewhere),
    expires_before (drives the "expiring in N days" workflow), and
    sort (sort by notAfter for the most common operator triage).
    Fix: 3 new useState hooks + 3 new filter UIs in the toolbar +
    3 new param wires. Remaining filters (agent_id, expires_after,
    created_after, updated_after, cursor, fields, sort_desc) deferred
    until a consumer use case demands them — over-stuffing the
    toolbar is its own UX cost.

  - cat-k-e85d1099b2d7: CertificatesPage rendered the first 50
    certs returned by the backend with no way to advance. Backend
    response carries {data, total, page, per_page} — a pure render
    gap. Fix: lifted pagination into the reusable DataTable
    component as an opt-in `pagination?` prop. CertificatesPage is
    the first consumer; TargetsPage / IssuersPage / OwnersPage /
    others can adopt by passing the same prop.

DataTable changes:
- New `PaginationProps` interface (page, perPage, total,
  onPageChange, onPerPageChange?, perPageOptions?).
- New optional `pagination?` prop on DataTable.
- New `PaginationControls` subcomponent rendered in the table
  footer when `pagination` is set and `total > 0`. Renders
  "Showing X–Y of Z" + per-page selector + page counter +
  Prev/Next buttons. Disabling logic guards both boundaries.

CertificatesPage changes:
- 3 new filter useState hooks: teamFilter, expiresBefore, sortBy.
- 2 new pagination useState hooks: page (1), perPage (50).
- Added 4th cohort hook: getTeams via useQuery (mirrors the
  existing issuers/owners/profiles filter-data pattern).
- params object gains team_id, expires_before, sort, page, per_page.
- 3 new filter UIs in the toolbar (team select, expires_before
  date picker, sort select).
- DataTable gets the new pagination prop.
- Filter changes reset page=1 to keep results visible.

Verification:
- tsc --noEmit — clean
- vitest run — 9 files, 302 tests passing (no regression)
- golangci-lint v2.11.4 run ./... — 0 issues
- All sibling guardrails (S-1, G-3, D-1+D-2, B-1, L-1, H-1, C-1) pass

Audit findings closed:
- cat-e-610251c8f72d (P2)
- cat-k-e85d1099b2d7 (P2)

Deferred follow-ups:
- 8 backend filters (agent_id, expires_after, created_after,
  updated_after, cursor, fields, sort_desc, plus secondary sort
  fields) deferred until consumer demand justifies UI weight.
- TargetsPage / IssuersPage / OwnersPage / etc. opt-in to the
  pagination prop incrementally — DataTable now supports it; per-
  page adoption is a follow-up commit each.
- CertificatesPage Vitest coverage of the new filter+pagination
  paths deferred to the per-page test campaign (cat-s2-c24a548076c6).
This commit is contained in:
shankar0123
2026-04-25 17:38:54 +00:00
parent c4d231e728
commit 94ca69554b
2 changed files with 150 additions and 7 deletions
+82 -2
View File
@@ -5,6 +5,24 @@ interface Column<T> {
className?: string;
}
// F-1 closure (cat-k-e85d1099b2d7): DataTable was a render-only
// component pre-F-1 — every consumer page handed it the first 50
// rows from a paginated endpoint and there was no way for the
// operator to advance. The backend has always returned `{data,
// total, page, per_page}` but the frontend never surfaced page
// 2+. The pagination prop below opt-ins reusable controls in the
// table footer; CertificatesPage is the first consumer (and the
// audit's flagged page), but TargetsPage / IssuersPage / others
// can adopt by passing the same prop.
interface PaginationProps {
page: number;
perPage: number;
total: number;
onPageChange: (page: number) => void;
onPerPageChange?: (perPage: number) => void;
perPageOptions?: number[];
}
interface DataTableProps<T> {
columns: Column<T>[];
data: T[];
@@ -15,9 +33,10 @@ interface DataTableProps<T> {
selectable?: boolean;
selectedKeys?: Set<string>;
onSelectionChange?: (keys: Set<string>) => void;
pagination?: PaginationProps;
}
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange }: DataTableProps<T>) {
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps<T>) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-16 text-ink-muted">
@@ -111,8 +130,69 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
})}
</tbody>
</table>
{pagination && pagination.total > 0 && (
<PaginationControls {...pagination} />
)}
</div>
);
}
export type { Column };
// F-1 closure (cat-k-e85d1099b2d7): pagination footer for DataTable
// consumers that want prev/next + page counter + per-page selector
// against a paginated backend response. Disabling logic guards the
// boundaries (prev disabled on page 1; next disabled when page *
// per_page >= total).
function PaginationControls({ page, perPage, total, onPageChange, onPerPageChange, perPageOptions }: PaginationProps) {
const start = total === 0 ? 0 : (page - 1) * perPage + 1;
const end = Math.min(page * perPage, total);
const lastPage = Math.max(1, Math.ceil(total / perPage));
const isFirst = page <= 1;
const isLast = page >= lastPage;
const options = perPageOptions ?? [25, 50, 100, 200];
return (
<div className="flex items-center justify-between border-t border-surface-border px-4 py-3 text-sm text-ink-muted">
<span>
Showing <span className="font-medium text-ink">{start}</span><span className="font-medium text-ink">{end}</span> of <span className="font-medium text-ink">{total.toLocaleString()}</span>
</span>
<div className="flex items-center gap-3">
{onPerPageChange && (
<label className="flex items-center gap-2 text-xs">
<span>Rows per page:</span>
<select
value={perPage}
onChange={e => onPerPageChange(Number(e.target.value))}
className="rounded border border-surface-border bg-white px-2 py-1 text-xs text-ink focus:outline-none focus:border-brand-400"
>
{options.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</label>
)}
<span className="text-xs">
Page <span className="font-medium text-ink">{page}</span> of <span className="font-medium text-ink">{lastPage}</span>
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => onPageChange(page - 1)}
disabled={isFirst}
className="rounded border border-surface-border px-3 py-1 text-xs text-ink hover:bg-surface-muted disabled:cursor-not-allowed disabled:opacity-50"
>
Prev
</button>
<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={isLast}
className="rounded border border-surface-border px-3 py-1 text-xs text-ink hover:bg-surface-muted disabled:cursor-not-allowed disabled:opacity-50"
>
Next
</button>
</div>
</div>
</div>
);
}
export type { Column, PaginationProps };