name: add-react-page
description: Add a list+create page to a React app (clients/admin or clients/dashboard) — API module, page, lazy route, (admin) permission gate, Playwright test. Use when adding any frontend screen. See .agents/rules/frontend/.
argument-hint: [admin|dashboard] [Area] [Resource]
Add React Page
The frontend slice. Read .agents/rules/frontend/shared.md plus the app file (frontend/admin.md /
frontend/dashboard.md) — the two apps deliberately diverge:
| admin (operator) | dashboard (tenant) |
|---|
| Query params | PascalCase (PageNumber, Search) | camelCase (pageNumber, search) |
PagedResponse<T> | import from @/lib/api-types | re-declare inline in the api module |
| Path constant | const BASE = "/api/v1/..." | inline the full path per call |
| Forms | react-hook-form + zod | hand-rolled controlled inputs (no RHF/zod) |
| List + create | separate routed pages (list.tsx, create.tsx) | one file with <Dialog> editors |
| Route wrapper | <RouteGuard perms={[…]}> | withSuspense(<X/>) (no permission gate) |
| Permissions | mirror in src/lib/permissions.ts | fetched from GET /identity/permissions (not JWT); nav gating via perm/anyPerm in nav-data.ts; no route guard — server 403 backstops |
Shared everywhere: types are hand-written (no codegen); apiFetch<T> from @/lib/api-client; cn() from @/lib/cn; env.apiBase from runtime /config.json; CVA components/ui + components/list primitives; Tailwind v4 CSS-first (tokens in src/styles/globals.css); toast from sonner; pages are named exports; placeholderData: keepPreviousData (v5).
Step 1 — API module (src/api/{resource}.ts)
Hand-write the DTO/param/input types and thin apiFetch functions.
import { apiFetch } from "@/lib/api-client";
import type { PagedResponse } from "@/lib/api-types";
const BASE = "/api/v1/{module}/{resources}";
export type {Resource}Dto = { id: string; name: string; };
export async function search{Resources}(p: { pageNumber?: number; search?: string } = {}) {
const q = new URLSearchParams();
q.set("PageNumber", String(p.pageNumber ?? 1));
q.set("PageSize", "10");
if (p.search?.trim()) q.set("Search", p.search.trim());
return apiFetch<PagedResponse<{Resource}Dto>>(`${BASE}/search?${q}`);
}
export async function create{Resource}(input: Create{Resource}Input) {
return apiFetch<{ id: string }>(BASE, { method: "POST", body: JSON.stringify(input) });
}
(dashboard: inline type PagedResponse<T> = …, inline the path, camelCase params, mutations often return Promise<string>.)
Step 2 — Page (src/pages/{area}/...tsx, named export)
export function {Resource}ListPage() {
const [search, setSearch] = useState("");
const [pageNumber, setPage] = useState(1);
const query = useQuery({
queryKey: ["{resources}", { pageNumber, search }],
queryFn: () => search{Resources}({ pageNumber, search: search || undefined }),
placeholderData: keepPreviousData,
});
}
Step 3 — Mutation (race-safe mutate(arg))
Pass per-call data through mutate(arg); read it from the callback variables — never from a closed-over render variable.
const qc = useQueryClient();
const createMut = useMutation({
mutationFn: (input: Create{Resource}Input) => create{Resource}(input),
onSuccess: () => { toast.success("Created"); qc.invalidateQueries({ queryKey: ["{resources}"] }); },
onError: (e) => toast.error(e instanceof ApiRequestError ? e.message : "Failed"),
});
If you need to track the in-flight item (e.g. a per-row busy state), use onMutate: (arg) => setBusyId(arg) reading the mutate(arg) value (pattern: admin/src/pages/settings/sessions.tsx).
Step 4 — Register the route (routes.tsx)
const {Resource}ListPage = lazyNamed(() => import("@/pages/{area}/list"), "{Resource}ListPage");
{ path: "{resources}", element: <RouteGuard perms={[{Module}Permissions.{Resources}.View]}><{Resource}ListPage /></RouteGuard> },
{ path: "{area}/{resources}", element: withSuspense(<{Resource}ListPage />) },
Step 5 — (admin only) mirror the permission
Add the constant to src/lib/permissions.ts ({Module}Permissions.{Resources}.View = "Permissions.{Resources}.View"), and a PERMISSION_CATALOG entry if it belongs in the Role editor. See add-permission.
Step 6 — Playwright test (tests/{area}/{resource}.spec.ts)
test.beforeEach(async ({ page }) => {
await mockJsonResponse(page, "**/api/v1/{module}/{resources}**", paged([SAMPLE]));
});
Use mockProblemDetails(...) for error states. Dashboard: scope row assertions with .last() / dialog scoping (lists render mobile + desktop copies → strict-mode double match).
Step 7 — Verify
cd clients/{app} && npm run lint && npm run test:e2e
Checklist