diff --git a/src/pages/Claims.test.tsx b/src/pages/Claims.test.tsx new file mode 100644 index 0000000..5add8ab --- /dev/null +++ b/src/pages/Claims.test.tsx @@ -0,0 +1,329 @@ +// @vitest-environment happy-dom +// ClaimDrawer wires `useClaimDetail` (TanStack Query) + a window-level +// keyboard listener. Both paths need an act-aware environment or React +// logs noisy warnings. Mirror the convention from ClaimDrawer.test.tsx. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Claims } from "./Claims"; +import { api } from "@/lib/api"; +import type { Claim, ClaimDetail } from "@/types"; + +// Module-level mock — vitest hoists vi.mock above imports. We mock the +// two API methods the test path actually touches: +// +// - `api.listClaims` is called by `useClaims` (via TanStack Query) so +// the table has data to render rows. +// - `api.getClaimDetail` is called by `useClaimDetail` from inside the +// ClaimDrawer once the URL has `?claim=…`. +// +// Same shape as ClaimDrawer.test.tsx; we just add `listClaims` to the +// mock object so the page-under-test populates its table. +vi.mock("@/lib/api", () => { + class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + } + } + return { + api: { + isConfigured: true, + listClaims: vi.fn(), + getClaimDetail: vi.fn(), + }, + ApiError, + }; +}); + +const SAMPLE_CLAIMS: Claim[] = [ + { + id: "CLM-1", + patientName: "Jane Doe", + providerNpi: "1234567890", + payerName: "Colorado Medicaid", + cptCode: "99213", + billedAmount: 123.45, + receivedAmount: 0, + status: "submitted", + submissionDate: "2026-06-15", + }, + { + id: "CLM-2", + patientName: "John Smith", + providerNpi: "1234567890", + payerName: "Aetna", + cptCode: "99214", + billedAmount: 200, + receivedAmount: 200, + status: "paid", + submissionDate: "2026-06-16", + }, +]; + +const SAMPLE_DETAIL: ClaimDetail = { + id: "CLM-1", + batchId: "B-1", + state: "submitted", + stateLabel: "Submitted", + billedAmount: 123.45, + patientName: "Jane Doe", + providerNpi: "1234567890", + providerName: "Acme Clinic", + payerName: "Colorado Medicaid", + payerId: "MEDCO", + submissionDate: "2026-06-15T00:00:00Z", + serviceDateFrom: null, + serviceDateTo: null, + parsedAt: "2026-06-15T00:00:01Z", + diagnoses: [], + serviceLines: [], + parties: { + billingProvider: { + name: "Acme Clinic", + npi: "1234567890", + taxId: "12-3456789", + address: { + line1: "123 Main St", + line2: null, + city: "Denver", + state: "CO", + zip: "80202", + }, + }, + subscriber: { + firstName: "Jane", + lastName: "Doe", + memberId: "M-1", + dob: null, + gender: "F", + }, + payer: { name: "Colorado Medicaid", id: "MEDCO" }, + }, + validation: { passed: true, errors: [], warnings: [] }, + rawSegments: [], + matchedRemittance: null, + stateHistory: [], +}; + +/** + * Point happy-dom at a known URL. happy-dom exposes `setURL` on + * `window.happyDOM` (a non-standard helper for tests) — copying the + * pattern from `useDrawerUrlState.test.ts` so the two files stay + * consistent. + */ +function setLocation(url: string): void { + (window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url); +} + +/** + * Render harness. Mirrors the pattern from Acks/Remittances test files: + * a real DOM container wrapped in `QueryClientProvider` so the page's + * TanStack Query calls (via `useClaims`) resolve against our mocked + * `api.listClaims`. + */ +function renderClaims(): { unmount: () => void } { + const container = document.createElement("div"); + document.body.appendChild(container); + const qc = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + // Default retry delay is exponential backoff — way too slow for + // unit tests. Zero keeps the happy path fast. + retryDelay: 0, + }, + }, + }); + const root: Root = createRoot(container); + act(() => { + root.render( + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement(Claims) + ) + ); + }); + return { + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +/** + * Flush microtasks + react state updates until `predicate` holds (or we + * time out). react-query's fetch resolves asynchronously, then Radix + * portals the drawer into document.body — both need a tick before the + * DOM reflects the new state. + */ +async function settle( + predicate: () => boolean, + timeoutMs = 2000 +): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error("settle: predicate did not hold within timeout"); + } + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +describe("Claims page drawer wiring", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset URL to bare claims page between tests. + setLocation("http://localhost/claims"); + (api.listClaims as unknown as ReturnType).mockResolvedValue({ + items: SAMPLE_CLAIMS, + total: SAMPLE_CLAIMS.length, + returned: SAMPLE_CLAIMS.length, + has_more: false, + }); + (api.getClaimDetail as unknown as ReturnType).mockResolvedValue( + SAMPLE_DETAIL + ); + }); + + afterEach(() => { + // Defensive: leave the URL clean for the next test file. + setLocation("http://localhost/claims"); + }); + + it("test_clicking_a_row_opens_the_drawer", async () => { + const { unmount } = renderClaims(); + + // Wait for the table to populate from the mocked api.listClaims. + await settle( + () => document.body.textContent?.includes("CLM-1") ?? false + ); + + // Drawer must not be open yet — no `?claim=` in URL means + // ClaimDrawer renders null (per its early-return when claimId is + // null). + expect(document.body.querySelector('[data-testid="claim-drawer"]')).toBeNull(); + + // Click the first row (the one showing CLM-1). The page wires + // `onClick={() => open(c.id)}` to each row, so this should + // (a) push `?claim=CLM-1` into the URL, and + // (b) cause the ClaimDrawer to mount in document.body. + // + // Note: the id cell renders "CLM-1" plus a sibling "CPT 99213" line, + // so the row's textContent is "CLM-1CPT 99213…". Match by substring + // instead of exact equality. + const row = Array.from(document.querySelectorAll("tbody tr")).find( + (r) => r.textContent?.includes("CLM-1") + ) as HTMLTableRowElement | undefined; + expect(row).toBeDefined(); + await act(async () => { + row!.click(); + }); + + // URL side effect — happy-dom's pushState updates window.location, + // so this assertion is end-to-end against the real hook. + expect(window.location.href).toContain("?claim=CLM-1"); + + // Drawer mounts in a Radix portal under document.body, so we poll + // for it. + await settle( + () => document.body.querySelector('[data-testid="claim-drawer"]') !== null + ); + expect( + document.body.querySelector('[data-testid="claim-drawer"]') + ).not.toBeNull(); + + unmount(); + }); + + it("test_deep_link_with_?claim=opens_drawer_on_mount", async () => { + // Pre-set the URL so the hook reads CLM-1 off `window.location.search` + // during its `useState` initializer — no click needed. + setLocation("http://localhost/claims?claim=CLM-1"); + renderClaims(); + + // Drawer should appear immediately, without user interaction. + await settle( + () => document.body.querySelector('[data-testid="claim-drawer"]') !== null + ); + expect( + document.body.querySelector('[data-testid="claim-drawer"]') + ).not.toBeNull(); + + // Header shows the claim id — proves the id propagated end-to-end + // (URL → useDrawerUrlState → ClaimDrawer prop → useClaimDetail → + // header render), not just that some drawer is mounted. + await settle( + () => + document.body.querySelector('[data-testid="header-id"]')?.textContent === + "CLM-1" + ); + expect( + document.body.querySelector('[data-testid="header-id"]')?.textContent + ).toBe("CLM-1"); + }); + + it("test_escape_closes_the_drawer", async () => { + setLocation("http://localhost/claims?claim=CLM-1"); + renderClaims(); + + await settle( + () => document.body.querySelector('[data-testid="claim-drawer"]') !== null + ); + + // Press Escape on `window` — useDrawerKeyboard (called from inside + // ClaimDrawer) listens there. Page wires onClose → useDrawerUrlState's + // close() → pushState + setClaimIdState(null). + await act(async () => { + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }) + ); + }); + + // Drawer should unmount (ClaimDrawer returns null when claimId is null). + await settle( + () => document.body.querySelector('[data-testid="claim-drawer"]') === null + ); + expect( + document.body.querySelector('[data-testid="claim-drawer"]') + ).toBeNull(); + // URL must no longer carry ?claim=. + expect(window.location.href).not.toContain("?claim="); + }); + + it("test_url_clears_after_close", async () => { + setLocation("http://localhost/claims?claim=CLM-1"); + renderClaims(); + + // Wait for the header to render — the close button only mounts once + // `useClaimDetail` resolves with a real ClaimDetail (the skeleton / + // error states don't render the header). + await settle( + () => document.body.querySelector('[data-testid="header-close"]') !== null + ); + + // URL currently carries the claim. + expect(window.location.href).toContain("?claim=CLM-1"); + + // Click the X close button in the header. The page wires this to + // onClose → useDrawerUrlState.close(), which pushState's a URL with + // the ?claim= param stripped. + const closeBtn = document.body.querySelector( + '[data-testid="header-close"]' + ) as HTMLButtonElement | null; + expect(closeBtn).not.toBeNull(); + await act(async () => { + closeBtn!.click(); + }); + + // URL must no longer carry ?claim=. + await settle(() => !window.location.href.includes("?claim=")); + expect(window.location.href).not.toContain("?claim="); + }); +}); diff --git a/src/pages/Claims.tsx b/src/pages/Claims.tsx index 171b37b..b6726cf 100644 --- a/src/pages/Claims.tsx +++ b/src/pages/Claims.tsx @@ -18,12 +18,14 @@ import { } from "@/components/ui/table"; import { StatusBadge } from "@/components/StatusBadge"; import { NewClaimDialog } from "@/components/NewClaimDialog"; +import { ClaimDrawer } from "@/components/ClaimDrawer"; import { Skeleton } from "@/components/ui/skeleton"; import { EmptyState } from "@/components/ui/empty-state"; import { ErrorState } from "@/components/ui/error-state"; import { FilterChips, type FilterChipOption } from "@/components/ui/filter-chips"; import { Pagination } from "@/components/ui/pagination"; import { useClaims } from "@/hooks/useClaims"; +import { useDrawerUrlState } from "@/hooks/useDrawerUrlState"; import { useAppStore } from "@/store"; import { fmt } from "@/lib/format"; import type { ClaimStatus } from "@/types"; @@ -48,6 +50,12 @@ export function Claims() { const [page, setPage] = useState(1); const searchRef = useRef(null); + // SP4 drawer wiring: the URL is the source of truth for "which claim is + // open". `open(id)` pushState's a new history entry (Back returns to the + // list); `close()` pushState's the bare URL; `setClaimId(id)` replaceState's + // so j/k navigation doesn't pollute history with one entry per keystroke. + const { claimId, open, close, setClaimId } = useDrawerUrlState(); + const providers = useAppStore((s) => s.providers); const providerMap = useMemo( () => Object.fromEntries(providers.map((p) => [p.npi, p])), @@ -89,9 +97,35 @@ export function Claims() { const filtered = status !== ALL || npi !== ALL; + // Dim the background list while the drawer is open so the user's eye + // lands on the drawer. `pointer-events-none` prevents accidental clicks + // on the dimmed list (e.g., clicking a row would open yet another + // drawer on top). + const dimBackground = claimId !== null; + return ( -
-
+ <> + ({ id: c.id }))} + onClose={close} + onNavigate={setClaimId} + onToggleHelp={() => { + // T22 wires the KeyboardCheatsheet overlay; no-op for now. + }} + /> +
+
@@ -225,8 +259,13 @@ export function Claims() { return ( open(c.id)} className={cn( - "animate-row-flash", + "animate-row-flash cursor-pointer", // Re-keying on dataUpdatedAt re-mounts the row on // every refetch (initial load, filter change, parse // invalidation) so the row-flash keyframe replays. @@ -277,5 +316,6 @@ export function Claims() { )}
+ ); }