Skip to main content

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/.

来源信息

仓库
fullstackhero/dotnet-starter-kit
最近来源活动
2026年11月4日 03:21
检测到的 SKILL.md 语言
英语
星标
6,863
Forks
2,020

安装方式

默认使用会先检查来源的 Prompt;你也可以切换为直接命令,或下载本地副本。

检查来源文件

决定是否安装前,请先阅读 SKILL.md,以及 SkillsMP 当前展示的配套文件。

正在显示 SKILL.md

SKILL.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/.
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. ```ts // 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>`.) ## Step 2 — Catalog keys (`src/locales/en-US/{ns}.json`) Every string the page shows is a key in its area's namespace. Add the English entries **before** using them: keys are typed against the en-US catalogs, so `t("{resources}.title")` fails `tsc -b` until the key exists. A new area gets a new `{ns}.json` plus one line in the lazy catalog map in `src/i18n.ts` (`LAZY_CATALOGS` in admin, `lazyCatalogs` in dashboard), and its routes list `{ns}` (Step 5). `src/locales/pt-BR/{ns}.json` is optional; a missing key renders in English. ```json { "{resources}.title": "{Resources}", "{resources}.count_one": "{{count, number}} {resource}", "{resources}.count_other": "{{count, number}} {resources}", "{resources}.created": "{Resource} created.", "{resources}.createFailed": "Could not create the {resource}." } ``` ## Step 3 — Page (`src/pages/{area}/...tsx`, named export) ```tsx export function {Resource}ListPage() { const { t } = useTranslation("{ns}"); 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), // every visible string through t(): <h1>{t("{resources}.title")}</h1> } ``` ## Step 4 — 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. ```tsx const qc = useQueryClient(); const createMut = useMutation({ mutationFn: (input: Create{Resource}Input) => create{Resource}(input), onSuccess: () => { toast.success(t("{resources}.created")); qc.invalidateQueries({ queryKey: ["{resources}"] }); }, // ProblemDetails text is already in the user's language: apiFetch sends Accept-Language. onError: (e) => toast.error(e instanceof ApiRequestError ? e.message : t("{resources}.createFailed")), }); // 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`). ## Step 5 — Register the route (`routes.tsx`) ```tsx // The last argument lists the lazy translation namespaces the page and its components use; // they load alongside the page chunk. Shell namespaces (common, auth, …) are always loaded. const {Resource}ListPage = lazyNamed(() => import("@/pages/{area}/list"), "{Resource}ListPage", ["{ns}"]); // 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 />) }, ``` ## Step 6 — (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 7 — Playwright test (`tests/{area}/{resource}.spec.ts`) ```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). ## Step 8 — Verify ```bash cd clients/{app} && npm run lint && npm run build && npm run test:e2e ``` `lint` fails on untranslated JSX text in `src/pages` / `src/components`; `build` (`tsc -b`) fails on a key its namespace lacks. Neither sees a literal in `aria-label`, `placeholder`, `title` or a `toast("…")`: check those by hand (`.agents/rules/frontend/shared.md`, Translations). ## Checklist - [ ] API module: hand-written types, `apiFetch`, correct param casing per app (Pascal=admin, camel=dashboard) - [ ] English keys in `src/locales/en-US/{ns}.json` (pt-BR optional); every visible string, toast and `aria-label` through `t()` - [ ] Page is a **named export**; `useQuery` key hierarchical + `placeholderData: keepPreviousData` - [ ] Mutation passes data via `mutate(arg)`, invalidates in `onSuccess` - [ ] Route via `lazyNamed`; admin wraps in `<RouteGuard perms>`, dashboard in `withSuspense` - [ ] (admin) permission mirrored in `lib/permissions.ts` - [ ] Playwright test: seed + shell mocks + page mocks; `lint` + `build` + `test:e2e` green
在 GitHub 查看