11 KiB
name, description
| name | description |
|---|---|
| cyclone-frontend-page | Cyclone React page conventions (TanStack Query, use<X> hook, drawer, URL state, .test.tsx sibling, Layout / PageHeader / Sidebar). Use when: adding a new page, refactoring an existing one, or wiring a drawer into a page. |
cyclone-frontend-page
Cyclone pages live at src/pages/<Name>.tsx: a use<X> hook in src/hooks/use<X>.ts does the fetching (and optionally the live-tail subscription), <Layout> + <PageHeader> + <Sidebar> (src/components/) provide the app shell, and any right-side detail (claim, remittance) lives in src/components/<DrawerName>/ whose open/close state is mirrored to the URL via useDrawerUrlState. This skill codifies the conventions so additions stay consistent with the eleven pages already shipped.
As of this writing: 11 pages under src/pages/ (9 of 11 with a *.test.tsx sibling — Dashboard, Upload, and BatchDiff are not yet covered; be the first when you refactor them), ~30 hooks under src/hooks/, and 2 drawer modules at src/components/{ClaimDrawer,RemitDrawer}/. The next increment is SP22.
When to use
- Adding a new page. Mounting a new screen in
src/pages/— you need the Layout + PageHeader + table shape, theuse<X>hook split, and the route registration point insrc/App.tsx. - Refactoring an existing page. Splitting a 600-line page, swapping a manual
fetchfor a hook, or moving in-component state into the URL — load this skill to confirm the destination shape. - Wiring a drawer. Adding a new right-side detail drawer (e.g.
BatchDrawer,ActivityDrawer) — you needuseDrawerUrlStatefor URL-driven open/close, thesrc/components/<DrawerName>/folder layout, and the deep-link contract so?claim=…/?remit=…round-trips. - Sharing state via URL. Persisting filter / page / drawer state across reloads — confirm the
useDrawerUrlState/useRemitDrawerUrlStatehook pair is the right tool before reaching foruseState+ history.
Conventions
- Page shape. Every page in
src/pages/<Name>.tsxexports afunction <Name>()(most pages use a named export — seesrc/pages/Claims.tsx:51,Remittances.tsx:62,Dashboard.tsx:68;src/pages/Inbox.tsx:40is the lone default export). The app shell is provided by the<Layout>route wrapper insrc/App.tsx:30. Each page sets a<PageHeader>(src/components/PageHeader.tsx:18) and renders a table, list, or KPI grid. Sidebar nav is mounted once by<Layout>atsrc/components/Sidebar.tsx. - Data hook. Each page pairs with a
use<X>hook insrc/hooks/use<X>.ts(e.g.Claims↔useClaims,Remittances↔useRemittances,Acks↔useAcks). The hook returns{ data, isLoading, isError, error, refetch }from TanStack Query'suseQuery(seesrc/hooks/useClaims.ts:24-31,useRemittances.ts:21-25). Pages never callfetchor@/lib/apidirectly — the hook is the boundary so the page is testable withvi.mock("@/lib/api", ...). - Live tail. Pages with live data compose three hooks in order: the
use<X>initial fetch,useTailStream(resource)(src/hooks/useTailStream.ts:80— opens the NDJSON stream, drives the backoff/stall state machine), anduseMergedTail(resource, baseItems, filterFn?)(src/hooks/useMergedTail.ts:25— merges snapshot + tail, dedup'd by id). The full wiring lives atsrc/pages/Claims.tsx:85-89andRemittances.tsx:81-83. The streaming subscription belongs on the page, not in the data hook — hoisting it couples the lifecycle to whoever mountsuse<X>. - Drawer. Right-side detail drawers live in
src/components/<DrawerName>/(currentlyClaimDrawer/,RemitDrawer/) with a barrelindex.ts(src/components/ClaimDrawer/index.ts:1-14). The drawer is wired to URL state viauseDrawerUrlState()for?claim=…(src/hooks/useDrawerUrlState.ts) oruseRemitDrawerUrlState()for?remit=…(src/hooks/useRemitDrawerUrlState.ts). Open state is driven by the URL, not auseStateflag, so deep-links round-trip. - Tests. Every page gets a
src/pages/<Name>.test.tsxsibling (e.g.Claims.test.tsx,Remittances.test.tsx,Batches.test.tsx). Every hook gets asrc/hooks/use<X>.test.tssibling (e.g.useClaims.test.ts,useRemittances.test.ts). The shared setup —// @vitest-environment happy-dom,IS_REACT_ACT_ENVIRONMENT = true,QueryClientprovider,vi.mock("@/lib/api", ...)— is documented incyclone-tests; mirrorsrc/pages/Claims.test.tsx:1-30for the canonical page-test shape. - UI primitives. Use Radix-backed components from
src/components/ui/(button.tsx,dialog.tsx,table.tsx,select.tsx,pagination.tsx,empty-state.tsx,error-state.tsx,filter-chips.tsx,skeleton.tsx,input.tsx,label.tsx,card.tsx,badge.tsx,skip-link.tsx,claim-state-badge.tsx). Don't pull in a new UI library without discussion — every primitive here is already consumed by at least one shipped page. - Routing. Pages register their route in
src/App.tsxas a<Route path="<name>" element={<<Name>> />} />inside the<Layout>element wrapper (src/App.tsx:30-44). Currently every page is a static import; switch toReact.lazy(() => import(...))only if a page grows heavy (large parse/EDI libs, chart code) and the import cost shows up in the bundle report.
Patterns
Claims.tsx-style page (Layout + PageHeader + table + drawer)
Canonical page shape — composes the data hook, the tail triplet, and
useDrawerUrlState for the ?claim=… deep-link. See
src/pages/Claims.tsx:51-200 for the full file.
import { useMemo, useState } from "react";
import { Table, TableBody, TableRow, /* … */ } from "@/components/ui/table";
import { PageHeader } from "@/components/PageHeader";
import { ClaimDrawer } from "@/components/ClaimDrawer";
import { useClaims } from "@/hooks/useClaims";
import { useDrawerUrlState } from "@/hooks/useDrawerUrlState";
import { useTailStream } from "@/hooks/useTailStream";
import { useMergedTail } from "@/hooks/useMergedTail";
import { TailStatusPill } from "@/components/TailStatusPill";
export function Claims() {
const [status, setStatus] = useState<ClaimStatus | null>(null);
const params = useMemo(() => ({ status, limit: 25, offset: 0 }), [status]);
const { data } = useClaims(params);
const { status: tailStatus, lastEventAt, forceReconnect } = useTailStream("claims");
const items = useMergedTail("claims", data?.items ?? [], (c) => !status || c.status === status);
const { claimId, openClaim, closeClaim } = useDrawerUrlState();
return (
<>
<PageHeader
eyebrow="Inbox"
title="Claims"
status={<TailStatusPill status={tailStatus} lastEventAt={lastEventAt} onReconnect={forceReconnect} />}
/>
<Table>
<TableBody>
{items.map((c) => (
<TableRow key={c.id} onClick={() => openClaim(c.id)}>{/* …cells… */}</TableRow>
))}
</TableBody>
</Table>
<ClaimDrawer claimId={claimId} claims={items} onClose={closeClaim} onNavigate={openClaim} />
</>
);
}
use<X> data hook (TanStack Query)
Pattern from src/hooks/useClaims.ts:24-67 and useRemittances.ts:21-65.
Returns a stable shape so the page treats all data hooks uniformly.
The tail subscription lives on the page (see Convention 3), not here.
import { useQuery } from "@tanstack/react-query";
import { api, type ListClaimsParams, type PaginatedResponse } from "@/lib/api";
import type { Claim } from "@/types";
export function useClaims(params: ListClaimsParams) {
return useQuery<PaginatedResponse<Claim>>({
queryKey: ["claims", params],
queryFn: () => api.listClaims<Claim>(params),
enabled: api.isConfigured,
// …in-memory fallback when !api.isConfigured…
});
}
Drawer component with useDrawerUrlState
Pattern from src/components/ClaimDrawer/ClaimDrawer.tsx:1-50 +
src/hooks/useDrawerUrlState.ts. The drawer takes claimId as a
prop (driven by the URL), renders nothing when null, and uses
useDrawerKeyboard for j/k navigation + Escape to close. The page
that mounts it controls the URL — openClaim("abc") sets ?claim=abc,
removing the param closes the drawer, and reload preserves the state.
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useClaimDetail } from "@/hooks/useClaimDetail";
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
export function ClaimDrawer({ claimId, claims, onClose, onNavigate, onToggleHelp }) {
const open = claimId !== null;
const { data, isLoading, isError, error } = useClaimDetail(claimId);
useDrawerKeyboard({ open, claims, onClose, onNavigate, onToggleHelp });
if (!open) return null;
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent>{/* header + body panels from useClaimDetail… */}</DialogContent>
</Dialog>
);
}
Anti-patterns
- Don't
fetchfrom inside a page component. All API access goes through theuse<X>hook insrc/hooks/use<X>.ts. Pages that reach forfetch(...)directly can't be tested withvi.mock("@/lib/api", ...)and split the data lifecycle across files. The hook returns{ data, isLoading, isError, error, refetch }so the page is a pure renderer. - Don't open a drawer via local component state. A
const [open, setOpen] = useState(false)for a drawer breaks deep-links and reload-restore. UseuseDrawerUrlState()(claim) oruseRemitDrawerUrlState()(remit) so the URL is the single source of truth. - Don't put domain logic in JSX. Conditional renderings, table sorting, and KPI math all belong in the
use<X>hook or a pure helper undersrc/lib/(e.g.src/lib/format.tsfor currency / date formatting). JSX is for layout; mixing initems.filter(...).sort(...)inline is hard to test and hides behavior from the hook. - Don't call
useTailStreamfrom inside ause<X>hook. The streaming subscription belongs on the page (seesrc/pages/Claims.tsx:85-89). Hoisting it intouseClaimscouples the open/close lifecycle of the page to whoever mounts the hook, and breaks the one-resource-one-page ownership that the backoff/stall state machine assumes. - Don't import a new UI library to render a button, modal, or table. Radix-backed primitives in
src/components/ui/already cover every widget the shipped pages use. Reach for a new library only after the primitive gap is real and the proposal is in a spec / PR.
Related skills
cyclone-tail— load for any live-data page (Claims, Remittances, ActivityLog). Documents theuseTailStream+useMergedTailtriplet, the<TailStatusPill>wiring, and the 30s stall threshold (STALL_TIMEOUT_MS = 30_000atuseTailStream.ts:53).cyclone-api-router— load when a page is calling a new HTTP endpoint. Documentsapi_routers/<topic>.pyvs. inlineapi.pyregistration and the response / error-envelope shapes.cyclone-tests— load when adding the*.test.tsxsibling. Documents the// @vitest-environment happy-domsetup,IS_REACT_ACT_ENVIRONMENT = true, the@testing-library/reactvs.createRoot+Proberendering styles, and thevi.mock("@/lib/api")convention.cyclone-edi— load when a page renders parsed 837P / 835 / 999 / 270 / 271 / 277CA / TA1 content (ServiceLinesTable, CAS panels, ValidationPanel). Documents the parser modules, the R-coded validator rules, and the CAS / CARC / NPI / EIN helpers.