Files

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, the use<X> hook split, and the route registration point in src/App.tsx.
  • Refactoring an existing page. Splitting a 600-line page, swapping a manual fetch for 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 need useDrawerUrlState for URL-driven open/close, the src/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 / useRemitDrawerUrlState hook pair is the right tool before reaching for useState + history.

Conventions

  1. Page shape. Every page in src/pages/<Name>.tsx exports a function <Name>() (most pages use a named export — see src/pages/Claims.tsx:51, Remittances.tsx:62, Dashboard.tsx:68; src/pages/Inbox.tsx:40 is the lone default export). The app shell is provided by the <Layout> route wrapper in src/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> at src/components/Sidebar.tsx.
  2. Data hook. Each page pairs with a use<X> hook in src/hooks/use<X>.ts (e.g. ClaimsuseClaims, RemittancesuseRemittances, AcksuseAcks). The hook returns { data, isLoading, isError, error, refetch } from TanStack Query's useQuery (see src/hooks/useClaims.ts:24-31, useRemittances.ts:21-25). Pages never call fetch or @/lib/api directly — the hook is the boundary so the page is testable with vi.mock("@/lib/api", ...).
  3. 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), and useMergedTail(resource, baseItems, filterFn?) (src/hooks/useMergedTail.ts:25 — merges snapshot + tail, dedup'd by id). The full wiring lives at src/pages/Claims.tsx:85-89 and Remittances.tsx:81-83. The streaming subscription belongs on the page, not in the data hook — hoisting it couples the lifecycle to whoever mounts use<X>.
  4. Drawer. Right-side detail drawers live in src/components/<DrawerName>/ (currently ClaimDrawer/, RemitDrawer/) with a barrel index.ts (src/components/ClaimDrawer/index.ts:1-14). The drawer is wired to URL state via useDrawerUrlState() for ?claim=… (src/hooks/useDrawerUrlState.ts) or useRemitDrawerUrlState() for ?remit=… (src/hooks/useRemitDrawerUrlState.ts). Open state is driven by the URL, not a useState flag, so deep-links round-trip.
  5. Tests. Every page gets a src/pages/<Name>.test.tsx sibling (e.g. Claims.test.tsx, Remittances.test.tsx, Batches.test.tsx). Every hook gets a src/hooks/use<X>.test.ts sibling (e.g. useClaims.test.ts, useRemittances.test.ts). The shared setup — // @vitest-environment happy-dom, IS_REACT_ACT_ENVIRONMENT = true, QueryClient provider, vi.mock("@/lib/api", ...) — is documented in cyclone-tests; mirror src/pages/Claims.test.tsx:1-30 for the canonical page-test shape.
  6. 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.
  7. Routing. Pages register their route in src/App.tsx as a <Route path="<name>" element={<<Name>> />} /> inside the <Layout> element wrapper (src/App.tsx:30-44). Currently every page is a static import; switch to React.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 fetch from inside a page component. All API access goes through the use<X> hook in src/hooks/use<X>.ts. Pages that reach for fetch(...) directly can't be tested with vi.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. Use useDrawerUrlState() (claim) or useRemitDrawerUrlState() (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 under src/lib/ (e.g. src/lib/format.ts for currency / date formatting). JSX is for layout; mixing in items.filter(...).sort(...) inline is hard to test and hides behavior from the hook.
  • Don't call useTailStream from inside a use<X> hook. The streaming subscription belongs on the page (see src/pages/Claims.tsx:85-89). Hoisting it into useClaims couples 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.
  • cyclone-tail — load for any live-data page (Claims, Remittances, ActivityLog). Documents the useTailStream + useMergedTail triplet, the <TailStatusPill> wiring, and the 30s stall threshold (STALL_TIMEOUT_MS = 30_000 at useTailStream.ts:53).
  • cyclone-api-router — load when a page is calling a new HTTP endpoint. Documents api_routers/<topic>.py vs. inline api.py registration and the response / error-envelope shapes.
  • cyclone-tests — load when adding the *.test.tsx sibling. Documents the // @vitest-environment happy-dom setup, IS_REACT_ACT_ENVIRONMENT = true, the @testing-library/react vs. createRoot+Probe rendering styles, and the vi.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.