12 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 twelve pages already shipped (Login added in SP23).
As of this writing: **12 pages** under src/pages/(10 of 12 with a*.test.tsxsibling — Dashboard and BatchDiff are not yet covered; be the first when you refactor them; the Login page does not need a sibling), **~30 hooks** undersrc/hooks/, and **4 drawer modules** at src/components/{ClaimDrawer,RemitDrawer,ProviderDrawer,AckDrawer}/`. The most recently shipped increment is SP41 (in-window rebill pipeline); the next free increment after this SP42 doc-pass is SP43.
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.