Skip to content
fullstackhero

Recipe

Add a page to the tenant dashboard

End-to-end recipe for adding a new page to the tenant dashboard app — API module, page component, lazy route, sidebar nav entry, permission gating, and a route-mocked Playwright test.

views 0 Last updated

This recipe walks a new screen into clients/dashboard end-to-end: the API module, the page component, the lazy route, the sidebar entry, permission gating, and the Playwright test. The running example is a Projects list-plus-create page; swap in your own resource. For the surrounding context — app shell, providers, design tokens — see the frontend architecture and dashboard app pages.

Step 1 — API module (src/api/projects.ts)

Types are hand-written — there is no codegen step. One file per feature in src/api/, exporting DTO types and thin async functions over apiFetch (from @/lib/api-client, which handles the bearer token, the tenant header, single-flight 401 refresh, and throws ApiRequestError on problem-details responses).

Condensed from the real src/api/tickets.ts:

import { apiFetch } from "@/lib/api-client";
import type { PagedResponse } from "@/api/catalog"; // or re-declare inline
export type ProjectDto = {
id: string;
name: string;
description?: string | null;
createdAtUtc: string;
};
export type CreateProjectInput = { name: string; description?: string | null };
const BASE = "/api/v1";
export function searchProjects(params: {
search?: string;
pageNumber?: number;
pageSize?: number;
} = {}): Promise<PagedResponse<ProjectDto>> {
const q = new URLSearchParams();
if (params.search) q.set("search", params.search);
q.set("pageNumber", String(params.pageNumber ?? 1));
q.set("pageSize", String(params.pageSize ?? 20));
return apiFetch<PagedResponse<ProjectDto>>(`${BASE}/projects?${q.toString()}`);
}
export function createProject(input: CreateProjectInput): Promise<string> {
return apiFetch<string>(`${BASE}/projects`, {
method: "POST",
body: JSON.stringify({ name: input.name, description: input.description ?? null }),
});
}

Dashboard conventions to match (they differ from admin): camelCase query params (pageNumber, search), create mutations usually return Promise<string> (the new id), and PagedResponse<T> is either re-declared inline or imported from an existing api module — there’s no central api-types file here.

Step 2 — Page component (src/pages/projects/projects.tsx)

Pages are named exports (no default exports anywhere) and keep useQuery/useMutation inline. Query keys are hierarchical literal arrays with the params object last; paginated lists use placeholderData: keepPreviousData.

Condensed from src/pages/tickets/tickets.tsx:

export function ProjectsPage() {
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [pageNumber, setPageNumber] = useState(1);
useEffect(() => {
const id = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => window.clearTimeout(id);
}, [search]);
useEffect(() => setPageNumber(1), [debouncedSearch]);
const query = useQuery({
queryKey: ["projects", "list", { search: debouncedSearch, pageNumber }],
queryFn: () => searchProjects({ search: debouncedSearch || undefined, pageNumber }),
placeholderData: keepPreviousData,
});
// render with EntityPageHeader + the components/list family + components/ui primitives
}

UI building blocks:

  • Primitives live in src/components/ui/ — cva-based, shadcn-style (button, card, dialog, input, badge, dropdown-menu, …). List/page furniture (the Entity* family like EntityPageHeader) lives in src/components/list/.
  • Tailwind v4 is CSS-first — no tailwind.config; tokens are CSS variables in src/styles/globals.css. Don’t hard-code colors, and keep dashboard neutrals at chroma 0.
  • Long lists should use @tanstack/react-virtual.

Forms: hand-rolled, not react-hook-form

The dashboard does not depend on react-hook-form or zod — that’s an admin-only stack. Use controlled inputs with local useState and a plain onSubmit. List + create typically live in one file, with the editor in a <Dialog> (see CreateTicketDialog in the tickets page):

function CreateProjectDialog({ open, onClose, onCreated }: Props) {
const [name, setName] = useState("");
useEffect(() => { if (open) setName(""); }, [open]); // reset on open
const mutation = useMutation({
mutationFn: (input: CreateProjectInput) => createProject(input),
onSuccess: () => { toast.success("Project created"); onCreated(); onClose(); },
onError: (err: unknown) => toast.error(describe(err)), // describe() from @/lib/list-helpers
});
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!name.trim()) return;
mutation.mutate({ name: name.trim() });
};
// <Dialog open={open}><DialogContent><form onSubmit={onSubmit}>…
}

The parent invalidates the list when the dialog reports success:

<CreateProjectDialog
open={editor.mode === "create"}
onClose={() => setEditor({ mode: "closed" })}
onCreated={() => void queryClient.invalidateQueries({ queryKey: ["projects"] })}
/>

Step 3 — Register the lazy route (src/routes.tsx)

Every page is code-split via the lazyNamed helper (adapts named exports to React.lazy’s default contract) and wrapped in withSuspense for a per-route skeleton fallback. Add the child route under AppShell — which already sits inside ProtectedRoute:

const ProjectsPage = lazyNamed(() => import("@/pages/projects/projects"), "ProjectsPage");
// inside the AppShell children array:
{ path: "projects", element: withSuspense(<ProjectsPage />) },

There is no per-route permission guard in the dashboard — ProtectedRoute is auth-only. Don’t port admin’s RouteGuard here; the server returns 403 for anything the user can’t do, and the nav hides entries they can’t reach (next step).

Step 4 — Sidebar nav entry (src/components/layout/nav-data.ts)

Nav is data-driven. Add a NavSpec to one of the sections (or topNavTop/topNavBottom), with a lucide icon:

{
id: "operations",
caption: "Operations",
icon: Activity,
items: [
// …
{ to: "/projects", label: "Projects", icon: FolderKanban },
],
},

Two optional gates control visibility:

  • perm: "Permissions.Projects.View" — hidden unless the user holds that exact permission.
  • anyPerm: [...] — visible if the user holds any of the listed permissions. Used by the Trash entry, which fronts five independently-gated tabs.

visibleSections() filters items by the user’s permission list and drops sections left empty, so a gated entry never leads to a guaranteed 403.

Step 5 — Permission gating (nav + actions, not routes)

How permissions actually flow in the dashboard (verified in src/auth/auth-context.tsx):

  1. The JWT carries only role names, not permissions. After sign-in (and on every subject change, including impersonation), AuthProvider fetches GET /api/v1/identity/permissions and caches the list in the token store (fsh.dashboard.permissions).
  2. Components read it via useAuth(): user.permissions plus a permissionsHydrated flag so gated UI doesn’t flash while the fetch is in flight.
  3. Permission strings are mirrored by hand where the UI needs them, following the server registry convention Permissions.{Resource}.{Action}. The pattern to copy is src/lib/trash-permissions.ts — a small as const map with a comment naming the server permission each value mirrors.

Gate the nav entry (step 4) and any in-page actions (hide a Restore button, hide a tab) with user.permissions.includes(...). The server keeps enforcing everything as defence-in-depth — client gating is UX, not security.

Step 6 — Playwright test (tests/projects/projects.spec.ts)

Tests are route-mocked and JWT-seeded — no real backend. playwright.config.ts auto-boots npm run dev. The harness (in tests/helpers/):

  • seedAuthedSession(page, TEST_USER) writes a fake JWT (junk signature — the client never validates it) into the fsh.dashboard.* localStorage keys via addInitScript, before React boots.
  • installShellMocks(page) stubs every call the authenticated AppShell fires on load — notifications, chat badge, profile, permissions, tenant status — and aborts the SSE/SignalR transports.
  • mockJsonResponse / mockProblemDetails (in api-mocks.ts) mock individual endpoints; paged([...]) builds a paged response body.

Condensed from tests/tickets/tickets-list.spec.ts:

import { expect, test } from "@playwright/test";
import { mockJsonResponse } from "../helpers/api-mocks";
import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed";
import { installShellMocks, paged } from "../helpers/shell-mocks";
test.beforeEach(async ({ page }) => {
await seedAuthedSession(page, TEST_USER);
await installShellMocks(page);
// Page-specific mocks AFTER shell mocks — Playwright matches the most
// recently registered route first, so these win over the broad defaults.
await mockJsonResponse(page, "**/api/v1/projects**", paged([SAMPLE_PROJECT]));
});
test("renders the list", async ({ page }) => {
await page.goto("/projects");
await expect(page.getByRole("heading", { name: /projects/i })).toBeVisible();
});

Two more harness gotchas, both verified in the existing suite:

  • Permission-gated UI: the shell mock returns [] for GET /identity/permissions, so gated entries are hidden by default. Re-mock it with the permissions your test needs (see tests/system/trash.spec.ts).
  • Strict-mode double match: list pages render a mobile card list and a desktop table of the same data. Scope assertions to one of them (the tickets spec filters to the desktop card) or getByText will find two elements.

Step 7 — Verify

Terminal window
cd clients/dashboard && npm run lint && npm run test:e2e

How the admin app differs

Same skeleton, different conventions — don’t cross-pollinate:

  • Routes are permission-gated: admin wraps elements in <RouteGuard perms={[CatalogPermissions.Products.View]}> instead of withSuspense alone, and every permission is mirrored as a constant in src/lib/permissions.ts.
  • Forms use react-hook-form + zod (useForm + zodResolver), not controlled state.
  • Query params are PascalCase (PageNumber, Search), and PagedResponse<T> imports from @/lib/api-types.
  • List and create are separate routed pages (list.tsx, create.tsx), not one file with dialogs.

See the admin app reference for the full set.

Checklist

  1. API module in src/api/{feature}.ts — hand-written types, apiFetch, camelCase params, PagedResponse<T> inline or imported from an existing module.
  2. Page in src/pages/{area}/{name}.tsxnamed export; hierarchical query key with params object last; placeholderData: keepPreviousData.
  3. Mutations pass per-call data through mutate(arg); invalidate the list key in onSuccess; hand-rolled controlled form in a <Dialog>.
  4. Route registered in src/routes.tsx via lazyNamed + withSuspense, under AppShell — no route-level permission guard.
  5. Nav entry in src/components/layout/nav-data.ts, gated with perm/anyPerm if the server endpoint requires a permission; mirror the permission string Permissions.{Resource}.{Action} style (pattern: src/lib/trash-permissions.ts).
  6. Playwright spec in tests/{area}/{name}.spec.tsseedAuthedSession + installShellMocks first, page mocks after; every on-load endpoint mocked; assertions scoped against the mobile/desktop double render.
  7. npm run lint && npm run test:e2e green.
  • Frontend architecture — shared stack, API client, runtime config, providers.
  • Dashboard app — tenant app divergences: SSE, impersonation, theming.
  • Frontend development — dev server, proxy, testing workflow.
  • AI route: point your coding agent at .agents/skills/add-react-page/SKILL.md in the repo — it scaffolds this exact recipe for either app.