diff --git a/src/components/ClaimDrawer/ClaimDrawer.test.tsx b/src/components/ClaimDrawer/ClaimDrawer.test.tsx new file mode 100644 index 0000000..05ed931 --- /dev/null +++ b/src/components/ClaimDrawer/ClaimDrawer.test.tsx @@ -0,0 +1,465 @@ +// @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 useClaimDetail.test.ts. +(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 { ClaimDrawer } from "./ClaimDrawer"; +import { api, ApiError } from "@/lib/api"; +import type { ClaimDetail } from "@/types"; + +// Module-level mock — vitest hoists `vi.mock` above imports. We mock both +// `api.getClaimDetail` (the only method the hook calls) AND `ApiError` +// (the class used in the hook's retry predicate + here for instanceof +// checks in the orchestrator's error-kind branch). Same pattern as +// useClaimDetail.test.ts. +vi.mock("@/lib/api", () => { + class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + } + } + return { + api: { + getClaimDetail: vi.fn(), + }, + ApiError, + }; +}); + +/** + * Minimal valid ClaimDetail fixture — every required key present so the + * component typechecks. Mirrors the SAMPLE_DETAIL in + * useClaimDetail.test.ts; same shape the api.getClaimDetail test uses. + */ +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: "Medicaid", + payerId: "MEDCO", + submissionDate: "2026-01-15T00:00:00Z", + serviceDateFrom: "2026-01-10", + serviceDateTo: "2026-01-10", + parsedAt: "2026-01-15T00:00:01Z", + diagnoses: [{ code: "E11.9", qualifier: "ABK" }], + serviceLines: [ + { + lineNumber: 1, + procedureQualifier: "HC", + procedureCode: "99213", + modifiers: [], + charge: 100.0, + units: 1, + unitType: "UN", + serviceDate: "2026-01-10", + }, + ], + 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: "Medicaid", id: "MEDCO" }, + }, + validation: { passed: true, errors: [], warnings: [] }, + rawSegments: [["ISA*00*"]], + matchedRemittance: null, + stateHistory: [ + { + kind: "claim_submitted", + ts: "2026-01-15T00:00:00Z", + batchId: "B-1", + remittanceId: null, + }, + ], +}; + +type RenderArgs = { + claimId: string | null; + claims?: { id: string }[]; + onClose?: () => void; + onNavigate?: (id: string) => void; + onToggleHelp?: () => void; +}; + +/** + * Test harness for `ClaimDrawer`. Renders into a real DOM container + * wrapped in `QueryClientProvider` (the hook uses TanStack Query) and + * exposes a `fireKey` helper that dispatches keydown events on `window` + * — `useDrawerKeyboard` listens there, not on a specific element. + * + * `detail` controls how the mocked `api.getClaimDetail` resolves: + * - ClaimDetail → resolves with that object + * - Error → rejects with that error (use a real `ApiError` for + * 404 or a plain `Error` for network failure) + * - null → never resolves (for the loading state) + */ +function renderDrawer( + props: RenderArgs, + detail: ClaimDetail | Error | null = SAMPLE_DETAIL +): { + container: HTMLDivElement; + unmount: () => void; + fireKey: (key: string) => void; +} { + const onClose = props.onClose ?? vi.fn<() => void>(); + const onNavigate = props.onNavigate ?? vi.fn<(id: string) => void>(); + const onToggleHelp = props.onToggleHelp ?? vi.fn<() => void>(); + const claims = props.claims ?? [{ id: "CLM-1" }]; + + const mock = api.getClaimDetail as unknown as ReturnType; + if (detail === null) { + // Never-resolving promise — leaves the query in the loading state. + mock.mockReturnValue(new Promise(() => {})); + } else if (detail instanceof Error) { + mock.mockRejectedValue(detail); + } else { + mock.mockResolvedValue(detail); + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const qc = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + // The hook's retry predicate still retries non-404 errors up + // to 3 times. Default retryDelay is exponential backoff + // (1s + 2s + 4s ≈ 7s) which is way too slow for unit tests. + // We override it to fire retries immediately. + retryDelay: 0, + }, + }, + }); + const root: Root = createRoot(container); + act(() => { + root.render( + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement(ClaimDrawer, { + claimId: props.claimId, + claims, + onClose, + onNavigate, + onToggleHelp, + }) + ) + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + fireKey: (key: string) => { + // useDrawerKeyboard listens on `window`; dispatch a real + // KeyboardEvent so the listener picks it up. + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); + }); + }, + }; +} + +/** + * Drive React Query's internal micro-task queue until it stops + * transitioning (or we time out). TanStack's queries resolve on + * promise micro-tasks, then re-render, then settle — a single + * `await new Promise(r => setTimeout(r, 0))` isn't always enough + * for the hook's retry loop (3 retries on a plain Error) or for + * the success path to propagate through React's commit phase. + * + * Each tick flushes a micro-task and a macro-task. We exit early + * as soon as `body` matches `predicate`, so the common case (a + * single successful resolve) returns fast. + * + * The render harness sets `retryDelay: 0` on the QueryClient so + * retries fire back-to-back — no exponential-backoff wait. Default + * timeout here is therefore modest (2s). + */ +async function settle( + predicate: (body: HTMLElement) => boolean, + timeoutMs = 2000 +): Promise { + const body = document.body; + const start = Date.now(); + while (!predicate(body)) { + if (Date.now() - start > timeoutMs) { + throw new Error( + `settle: predicate did not hold within ${timeoutMs}ms (body=${body.innerHTML.slice(0, 200)})` + ); + } + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } + return body; +} + +describe("ClaimDrawer", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + // Defensive: clear any pending listeners between tests. + vi.restoreAllMocks(); + }); + + it("test_renders_nothing_when_claimId_is_null", () => { + const { container, unmount } = renderDrawer({ claimId: null }); + + // The component must render no DOM at all when closed. + expect(container.children.length).toBe(0); + // And must NOT hit the network — the hook short-circuits when + // claimId is null (per useClaimDetail contract). + expect(api.getClaimDetail).not.toHaveBeenCalled(); + + unmount(); + }); + + it("test_renders_skeleton_while_loading", async () => { + // detail = null → never-resolving promise → permanent loading. + const { unmount } = renderDrawer({ claimId: "CLM-1" }, null); + + // Radix's Dialog portals content into document.body, so we query + // there rather than the React container we rendered into. + const body = await settle((b) => + b.querySelector('[data-testid="claim-drawer-skeleton"]') !== null + ); + expect(body.querySelector('[data-testid="claim-drawer-skeleton"]')) + .not.toBeNull(); + + unmount(); + }); + + it("test_renders_error_state_on_404", async () => { + const { unmount } = renderDrawer( + { claimId: "ghost" }, + new ApiError(404, "Claim ghost not found") + ); + + const body = await settle((b) => + b.querySelector('[data-testid="claim-drawer-error-not_found"]') !== null + ); + + const errorEl = body.querySelector( + '[data-testid="claim-drawer-error-not_found"]' + ); + expect(errorEl).not.toBeNull(); + + // Close button is wired up. + const closeBtn = body.querySelector( + '[data-testid="error-close"]' + ) as HTMLButtonElement | null; + expect(closeBtn).not.toBeNull(); + + unmount(); + }); + + it("test_renders_error_state_on_network_failure", async () => { + const { unmount } = renderDrawer( + { claimId: "CLM-1" }, + new Error("network down") + ); + + // The hook retries non-404 errors up to 3 times; the error UI + // only appears after the final retry gives up. The polling + // helper waits out the retries. + const body = await settle((b) => + b.querySelector('[data-testid="claim-drawer-error-network"]') !== null + ); + + const errorEl = body.querySelector( + '[data-testid="claim-drawer-error-network"]' + ); + expect(errorEl).not.toBeNull(); + + // Retry button is shown for network failures. + const retryBtn = body.querySelector( + '[data-testid="error-retry"]' + ) as HTMLButtonElement | null; + expect(retryBtn).not.toBeNull(); + + unmount(); + }); + + it("test_renders_all_sections_on_success", async () => { + const { unmount } = renderDrawer({ claimId: "CLM-1" }); + + // Wait for the header to render (it's the first section in the + // success path). All other sections will be present by the time + // the header shows up because they render in the same commit. + const body = await settle((b) => + b.querySelector('[data-testid="claim-drawer-header"]') !== null + ); + + // All 7 sections that render unconditionally (matched remit is null + // in the fixture). `validation-passed` is the testid for the happy + // path (validation.passed=true, no errors/warnings); the + // `validation-panel` testid is reserved for the failed state. + expect(body.querySelector('[data-testid="claim-drawer-header"]')) + .not.toBeNull(); + expect(body.querySelector('[data-testid="validation-passed"]')) + .not.toBeNull(); + expect(body.querySelector('[data-testid="service-lines-table"]')) + .not.toBeNull(); + expect(body.querySelector('[data-testid="diagnoses-list"]')) + .not.toBeNull(); + expect(body.querySelector('[data-testid="parties-grid"]')) + .not.toBeNull(); + expect(body.querySelector('[data-testid="raw-segments-panel"]')) + .not.toBeNull(); + expect(body.querySelector('[data-testid="state-history-section"]')) + .not.toBeNull(); + // matchedRemittance is null in the fixture → card not rendered. + expect(body.querySelector('[data-testid="matched-remit-card"]')) + .toBeNull(); + + unmount(); + }); + + it("test_j_key_calls_onNavigate_with_next_claim_id", () => { + const onNavigate = vi.fn<(id: string) => void>(); + const { fireKey, unmount } = renderDrawer({ + claimId: "B", + claims: [{ id: "A" }, { id: "B" }, { id: "C" }], + onNavigate, + }); + + fireKey("j"); + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onNavigate).toHaveBeenCalledWith("C"); + + unmount(); + }); + + it("test_k_key_calls_onNavigate_with_previous_claim_id", () => { + const onNavigate = vi.fn<(id: string) => void>(); + const { fireKey, unmount } = renderDrawer({ + claimId: "B", + claims: [{ id: "A" }, { id: "B" }, { id: "C" }], + onNavigate, + }); + + fireKey("k"); + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onNavigate).toHaveBeenCalledWith("A"); + + unmount(); + }); + + it("test_j_wraps_around_at_end", () => { + const onNavigate = vi.fn<(id: string) => void>(); + const { fireKey, unmount } = renderDrawer({ + claimId: "C", // last + claims: [{ id: "A" }, { id: "B" }, { id: "C" }], + onNavigate, + }); + + fireKey("j"); + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onNavigate).toHaveBeenCalledWith("A"); // wraps to first + + unmount(); + }); + + it("test_k_wraps_around_at_start", () => { + const onNavigate = vi.fn<(id: string) => void>(); + const { fireKey, unmount } = renderDrawer({ + claimId: "A", // first + claims: [{ id: "A" }, { id: "B" }, { id: "C" }], + onNavigate, + }); + + fireKey("k"); + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onNavigate).toHaveBeenCalledWith("C"); // wraps to last + + unmount(); + }); + + it("test_escape_key_calls_onClose", () => { + const onClose = vi.fn<() => void>(); + const { fireKey, unmount } = renderDrawer({ + claimId: "CLM-1", + onClose, + }); + + fireKey("Escape"); + expect(onClose).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it("test_question_mark_calls_onToggleHelp", () => { + const onToggleHelp = vi.fn<() => void>(); + const { fireKey, unmount } = renderDrawer({ + claimId: "CLM-1", + onToggleHelp, + }); + + fireKey("?"); + expect(onToggleHelp).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it("test_keyboard_disabled_when_claimId_is_null", () => { + const onNavigate = vi.fn<(id: string) => void>(); + const { fireKey, unmount } = renderDrawer({ + claimId: null, + claims: [{ id: "A" }, { id: "B" }, { id: "C" }], + onNavigate, + }); + + fireKey("j"); + fireKey("k"); + expect(onNavigate).not.toHaveBeenCalled(); + + unmount(); + }); + + it("test_current_claim_not_in_claims_list_does_nothing", () => { + const onNavigate = vi.fn<(id: string) => void>(); + const { fireKey, unmount } = renderDrawer({ + claimId: "GHOST", // not in claims list + claims: [{ id: "A" }, { id: "B" }, { id: "C" }], + onNavigate, + }); + + fireKey("j"); + fireKey("k"); + expect(onNavigate).not.toHaveBeenCalled(); + + unmount(); + }); +}); diff --git a/src/components/ClaimDrawer/ClaimDrawer.tsx b/src/components/ClaimDrawer/ClaimDrawer.tsx new file mode 100644 index 0000000..b01264d --- /dev/null +++ b/src/components/ClaimDrawer/ClaimDrawer.tsx @@ -0,0 +1,176 @@ +import { useMemo } from "react"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { ApiError } from "@/lib/api"; +import { useClaimDetail } from "@/hooks/useClaimDetail"; +import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard"; +import { ClaimDrawerHeader } from "./ClaimDrawerHeader"; +import { ValidationPanel } from "./ValidationPanel"; +import { ServiceLinesTable } from "./ServiceLinesTable"; +import { DiagnosesList } from "./DiagnosesList"; +import { PartiesGrid } from "./PartiesGrid"; +import { RawSegmentsPanel } from "./RawSegmentsPanel"; +import { MatchedRemitCard } from "./MatchedRemitCard"; +import { StateHistoryTimeline } from "./StateHistoryTimeline"; +import { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton"; +import { ClaimDrawerError } from "./ClaimDrawerError"; + +type ClaimDrawerProps = { + /** + * Currently-open claim id, or `null` when the drawer is closed. + * When `null`, the drawer renders nothing (no DOM) and the keyboard + * listener is disabled — per spec §3.4. + */ + claimId: string | null; + /** + * Full ordered list of claim ids in the current view. Used to derive + * j/k navigation targets without an extra round-trip to the server. + * The list must contain the current `claimId` for j/k to do + * anything; an unknown id is treated defensively as "navigation + * disabled" rather than throwing. + */ + claims: { id: string }[]; + /** Fired when the user dismisses the drawer (X button, header close, or Escape). */ + onClose: () => void; + /** Fired when the user navigates via j/k — receives the new claim id. */ + onNavigate: (newId: string) => void; + /** Fired when the user presses `?` to toggle the keyboard-help overlay. */ + onToggleHelp: () => void; +}; + +/** + * Root claim-detail drawer (SP4). + * + * Orchestrates three concerns and delegates the rest: + * + * 1. **Data** — `useClaimDetail(claimId)` returns `{ data, isLoading, + * isError, error, refetch }`. The hook short-circuits when + * `claimId === null` (no fetch, no loading state) so a closed + * drawer is free. + * + * 2. **Keyboard** — `useDrawerKeyboard` wires j/k/ArrowDown/ArrowUp/ + * Escape/`?` on `window`. The listener is only attached when the + * drawer is open (`enabled: claimId !== null`). + * + * 3. **Layout** — the dialog shell is the project's `Dialog` primitive + * repositioned to the right edge (side-panel style). It exists + * mostly for Radix's focus management + portal; the actual + * drawer's structure (header + scrollable sections) is composed + * from the leaf section components, each of which owns its own + * visual language. + * + * j/k navigation wraps around at both ends (j from last → first; k + * from first → last). `useMemo` keeps the navigation callbacks stable + * across renders so the keyboard effect doesn't re-subscribe on every + * parent update. + */ +export function ClaimDrawer({ + claimId, + claims, + onClose, + onNavigate, + onToggleHelp, +}: ClaimDrawerProps) { + const { data, isLoading, isError, error, refetch } = useClaimDetail(claimId); + + // j/k navigation: derive next/prev IDs based on the current claim's + // index in the parent list. Wrap around at both ends. If the current + // claim isn't in the list (stale deep link, etc.) the callbacks are + // no-ops — defensive against bad input from the parent. + const { onNext, onPrev } = useMemo(() => { + if (claimId === null) { + return { onNext: () => {}, onPrev: () => {} }; + } + const idx = claims.findIndex((c) => c.id === claimId); + if (idx === -1 || claims.length === 0) { + return { onNext: () => {}, onPrev: () => {} }; + } + return { + onNext: () => { + const nextId = claims[(idx + 1) % claims.length].id; + onNavigate(nextId); + }, + onPrev: () => { + // Add `claims.length` before the modulo so the negative index + // (first → wrap to last) wraps correctly without a separate + // branch. + const prevId = claims[(idx - 1 + claims.length) % claims.length].id; + onNavigate(prevId); + }, + }; + }, [claimId, claims, onNavigate]); + + useDrawerKeyboard({ + enabled: claimId !== null, + onNext, + onPrev, + onClose, + onToggleHelp, + }); + + // Closed drawer: render nothing. The Dialog's `open` prop must also + // reflect this (so Radix tears down its portal / focus trap), and the + // early-return keeps a closed drawer free of any DOM at all. + if (claimId === null) return null; + + // Branch the error into the two shapes the spec calls out: + // - ApiError(404) → "not_found" (no retry, the claim is gone) + // - anything else → "network" (retry available) + const errorKind: "not_found" | "network" | null = isError + ? error instanceof ApiError && error.status === 404 + ? "not_found" + : "network" + : null; + + return ( + { + if (!open) onClose(); + }} + > + + {isLoading ? ( + + ) : errorKind ? ( + { + void refetch(); + }} + onClose={onClose} + /> + ) : data ? ( +
+ +
+ + + + + {data.matchedRemittance ? ( + + ) : null} + + +
+
+ ) : null} +
+
+ ); +} diff --git a/src/components/ClaimDrawer/index.ts b/src/components/ClaimDrawer/index.ts index 29c2966..49cb4e4 100644 --- a/src/components/ClaimDrawer/index.ts +++ b/src/components/ClaimDrawer/index.ts @@ -1,8 +1,6 @@ // Barrel export for the ClaimDrawer module (SP4). -// -// The root ClaimDrawer component is exported separately (added in T20) -// once that file exists — listing it here now would break typecheck. +export { ClaimDrawer } from "./ClaimDrawer"; export { ClaimDrawerHeader } from "./ClaimDrawerHeader"; export { ValidationPanel } from "./ValidationPanel"; export { ServiceLinesTable } from "./ServiceLinesTable";