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/.
72
90%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
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).
src/api/{resource}.ts)Hand-write the DTO/param/input types and thin apiFetch functions.
// admin
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>.)
src/pages/{area}/...tsx, named export)export function {Resource}ListPage() {
const [search, setSearch] = useState(""); // debounce → reset page to 1 on change
const [pageNumber, setPage] = useState(1);
const query = useQuery({
queryKey: ["{resources}", { pageNumber, search }], // hierarchical; params object last
queryFn: () => search{Resources}({ pageNumber, search: search || undefined }),
placeholderData: keepPreviousData,
});
// render with components/ui/* + components/list/* (admin: PageHeader/Field…; dashboard: Entity* family)
}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"),
});
// admin: const form = useForm({ resolver: zodResolver(schema) }); form.handleSubmit(v => createMut.mutate(v))
// dashboard: controlled useState fields; onSubmit(e){ e.preventDefault(); createMut.mutate(payload); }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).
routes.tsx)const {Resource}ListPage = lazyNamed(() => import("@/pages/{area}/list"), "{Resource}ListPage");
// admin — under AppShell.children, gated:
{ path: "{resources}", element: <RouteGuard perms={[{Module}Permissions.{Resources}.View]}><{Resource}ListPage /></RouteGuard> },
// dashboard — under AppShell.children, suspense only:
{ path: "{area}/{resources}", element: withSuspense(<{Resource}ListPage />) },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.
tests/{area}/{resource}.spec.ts)test.beforeEach(async ({ page }) => {
// admin: seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] }); await installAdminShellMocks(page);
// dashboard: await seedAuthedSession(page, TEST_USER); await installShellMocks(page);
await mockJsonResponse(page, "**/api/v1/{module}/{resources}**", paged([SAMPLE])); // page mocks AFTER shell mocks
});Use mockProblemDetails(...) for error states. Dashboard: scope row assertions with .last() / dialog scoping (lists render mobile + desktop copies → strict-mode double match).
cd clients/{app} && npm run lint && npm run test:e2eapiFetch, correct param casing per app (Pascal=admin, camel=dashboard)useQuery key hierarchical + placeholderData: keepPreviousDatamutate(arg), invalidates in onSuccesslazyNamed; admin wraps in <RouteGuard perms>, dashboard in withSuspenselib/permissions.tslint + test:e2e green3f2959e
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.