con un clic
add-react-page
// 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/.
// 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/.
Add a domain entity/aggregate with EF configuration and a migration to an existing FSH module. Use when adding a new database-backed entity. Pairs with add-feature and create-migration.
Add a vertical-slice feature (command/query + handler + validator + endpoint) to an existing FSH module. Use when adding an API endpoint or business operation to a module that already exists.
Build a capability end-to-end — backend vertical slice (Contracts→handler→validator→endpoint) AND the React page wired to it. Use when delivering a user-facing feature across API + UI. Composes add-feature + add-react-page.
Publish a cross-module integration event via the Outbox and handle it idempotently in another module. Use when one module must react to something that happened in another. See .agents/rules/eventing.md.
Create a new module (bounded context) — runtime + Contracts projects, IModule, DbContext, permissions, migrations, and the four registration sites. Use when adding a distinct business domain. For a feature in an existing module, use add-feature.
Add a new permission end-to-end — server constant + endpoint gate, and (admin app) mirror it into the permissions catalog + route guard. Use when a new endpoint needs authorization. See modules/identity.md + frontend/admin.md.
| 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/. |
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 | none — JWT claims + server 403 |
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:e2e
apiFetch, 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 green