M-029 Pass 2: migrate CertificatesPage to useListParams (Pass 2 complete)

M-029 Pass 2 surface turned out to be much smaller than the audit estimated:

the only page with real UI-driven pagination + filter state stored in

useState was CertificatesPage. Most other pages either fetch filter-dropdown

data with hardcoded per_page (sidecars, not pagination) or use

useSearchParams directly already. So Pass 2 is a single-page migration.

What changed:

  - 9 useState hooks (statusFilter, envFilter, issuerFilter, ownerFilter,

    profileFilter, teamFilter, expiresBefore, sortBy, page, perPage) collapse

    into a single useListParams({ pageSize: 50 }) call.

  - All filter onChange handlers now call setFilter('<key>', value).

  - setFilter automatically resets page to 1 on every filter / sort change,

    so the manual setPage(1) calls at three sites (team / expires_before /

    sort) are no longer needed — the F-1 contract is now enforced by the

    hook, not by hand-rolled setPage calls scattered through onChange.

  - Pagination handler simplified: onPerPageChange: setPageSize (the hook

    drops the page param from the URL when pageSize changes).

Behavior preserved:

  - The 8 filter keys (status / environment / issuer_id / owner_id /

    profile_id / team_id / expires_before / sort) still flow through

    getCertificates with the same param names — pinned by the existing

    CertificatesPage.test.tsx F-1 contract tests.

  - Default pageSize stays at 50 (matches the F-1 baseline; the hook's

    global default is 25 but the per-page override takes precedence).

  - Page reset on filter / per_page change preserved (now hook-enforced).

Side benefit: filter / sort / pagination state is now URL-resident (browser

deep-link + back-button correct). Sharing a filtered list view is now a

URL copy, not a 'recreate this filter combo by hand' message.

Verification:

  legacy useMutation count           still 0 (Pass 1 invariant intact)

  CertificatesPage useListParams     0 -> 1 site

  CertificatesPage local pagination  removed
This commit is contained in:
shankar0123
2026-04-27 02:59:35 +00:00
parent 5fc25878b8
commit 876f6bd48d
+36 -32
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { useTrackedMutation } from '../hooks/useTrackedMutation';
import { useListParams } from '../hooks/useListParams';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { getCertificates, createCertificate, revokeCertificate, getOwners, getTeams, getRenewalPolicies, getProfiles, getIssuers, bulkRevokeCertificates, bulkRenewCertificates, bulkReassignCertificates } from '../api/client'; import { getCertificates, createCertificate, revokeCertificate, getOwners, getTeams, getRenewalPolicies, getProfiles, getIssuers, bulkRevokeCertificates, bulkRenewCertificates, bulkReassignCertificates } from '../api/client';
import { useAuth } from '../components/AuthProvider'; import { useAuth } from '../components/AuthProvider';
@@ -395,29 +396,29 @@ export default function CertificatesPage() {
// with 403, but we also hide the button in the GUI to avoid a misleading // with 403, but we also hide the button in the GUI to avoid a misleading
// affordance. Authoritative gate remains server-side. // affordance. Authoritative gate remains server-side.
const { admin } = useAuth(); const { admin } = useAuth();
const [statusFilter, setStatusFilter] = useState(''); // M-029 Pass 2 (Audit M-010): filter / sort / pagination state migrated
const [envFilter, setEnvFilter] = useState(''); // from 9 local useState hooks to useListParams — URL-resident state is
const [issuerFilter, setIssuerFilter] = useState(''); // deep-linkable, browser-back-correct, and the hook auto-resets page
const [ownerFilter, setOwnerFilter] = useState(''); // to 1 on filter / sort / pageSize change (preserving the F-1 contract
const [profileFilter, setProfileFilter] = useState(''); // that previously had to be hand-rolled at every onChange site).
// F-1 closure (cat-e-610251c8f72d): pre-F-1 the page exposed only 5 of //
// the backend handler's 17 supported query filters. Three new operator- // F-1 closure (cat-e-610251c8f72d) preserved: the 8 operator-facing
// facing filters added: team_id (already first-class elsewhere), // filters (status / environment / issuer_id / owner_id / profile_id /
// expires_before (drives the "expiring in N days" workflow), and a // team_id / expires_before / sort) all flow through filters[] with
// sort selector (defaults to backend ordering). Audit-recommended // their existing keys. Default page size stays at 50 to match the
// minimum-add per the closure rationale; remaining filters // pre-migration F-1 baseline (the hook's global default is 25, but
// (agent_id, expires_after, created_after, updated_after, cursor, // the page-level default takes precedence).
// fields, sort_desc) are deferred until a consumer use case const { params: listParams, setPage, setPageSize, setFilter } = useListParams({ pageSize: 50 });
// demands them — over-stuffing the toolbar is its own UX cost. const statusFilter = listParams.filters.status ?? '';
const [teamFilter, setTeamFilter] = useState(''); const envFilter = listParams.filters.environment ?? '';
const [expiresBefore, setExpiresBefore] = useState(''); const issuerFilter = listParams.filters.issuer_id ?? '';
const [sortBy, setSortBy] = useState(''); const ownerFilter = listParams.filters.owner_id ?? '';
// F-1 closure (cat-k-e85d1099b2d7): pre-F-1 the page rendered the const profileFilter = listParams.filters.profile_id ?? '';
// first 50 certs returned by the backend with no way to advance. const teamFilter = listParams.filters.team_id ?? '';
// The reusable DataTable pagination prop (added in this same const expiresBefore = listParams.filters.expires_before ?? '';
// commit) takes the page + per_page state declared here. const sortBy = listParams.filters.sort ?? '';
const [page, setPage] = useState(1); const page = listParams.page;
const [perPage, setPerPage] = useState(50); const perPage = listParams.pageSize;
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showBulkRevoke, setShowBulkRevoke] = useState(false); const [showBulkRevoke, setShowBulkRevoke] = useState(false);
@@ -564,7 +565,7 @@ export default function CertificatesPage() {
<div className="px-6 py-3 flex gap-3 border-b border-surface-border/50"> <div className="px-6 py-3 flex gap-3 border-b border-surface-border/50">
<select <select
value={statusFilter} value={statusFilter}
onChange={e => setStatusFilter(e.target.value)} onChange={e => setFilter('status', e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink" className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
> >
<option value="">All statuses</option> <option value="">All statuses</option>
@@ -577,7 +578,7 @@ export default function CertificatesPage() {
</select> </select>
<select <select
value={envFilter} value={envFilter}
onChange={e => setEnvFilter(e.target.value)} onChange={e => setFilter('environment', e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink" className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
> >
<option value="">All environments</option> <option value="">All environments</option>
@@ -587,7 +588,7 @@ export default function CertificatesPage() {
</select> </select>
<select <select
value={issuerFilter} value={issuerFilter}
onChange={e => setIssuerFilter(e.target.value)} onChange={e => setFilter('issuer_id', e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink" className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
> >
<option value="">All issuers</option> <option value="">All issuers</option>
@@ -597,7 +598,7 @@ export default function CertificatesPage() {
</select> </select>
<select <select
value={ownerFilter} value={ownerFilter}
onChange={e => setOwnerFilter(e.target.value)} onChange={e => setFilter('owner_id', e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink" className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
> >
<option value="">All owners</option> <option value="">All owners</option>
@@ -607,7 +608,7 @@ export default function CertificatesPage() {
</select> </select>
<select <select
value={profileFilter} value={profileFilter}
onChange={e => setProfileFilter(e.target.value)} onChange={e => setFilter('profile_id', e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink" className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
> >
<option value="">All profiles</option> <option value="">All profiles</option>
@@ -618,7 +619,7 @@ export default function CertificatesPage() {
{/* F-1 closure (cat-e-610251c8f72d): team / expires_before / sort */} {/* F-1 closure (cat-e-610251c8f72d): team / expires_before / sort */}
<select <select
value={teamFilter} value={teamFilter}
onChange={e => { setTeamFilter(e.target.value); setPage(1); }} onChange={e => setFilter('team_id', e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink" className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
> >
<option value="">All teams</option> <option value="">All teams</option>
@@ -629,13 +630,13 @@ export default function CertificatesPage() {
<input <input
type="date" type="date"
value={expiresBefore} value={expiresBefore}
onChange={e => { setExpiresBefore(e.target.value); setPage(1); }} onChange={e => setFilter('expires_before', e.target.value)}
title="Expires before (drives the 'expiring in N days' workflow)" title="Expires before (drives the 'expiring in N days' workflow)"
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink" className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
/> />
<select <select
value={sortBy} value={sortBy}
onChange={e => { setSortBy(e.target.value); setPage(1); }} onChange={e => setFilter('sort', e.target.value)}
title="Sort order" title="Sort order"
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink" className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
> >
@@ -664,7 +665,10 @@ export default function CertificatesPage() {
perPage, perPage,
total: data?.total ?? 0, total: data?.total ?? 0,
onPageChange: setPage, onPageChange: setPage,
onPerPageChange: (n) => { setPerPage(n); setPage(1); }, // useListParams.setPageSize auto-drops the page param from
// the URL (page resets to 1 implicitly), preserving the
// F-1 contract without a manual setPage(1) call.
onPerPageChange: setPageSize,
}} }}
/> />
)} )}