feat(frontend): Claims page wires click-to-open drawer with URL sync
This commit is contained in:
@@ -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<void> {
|
||||
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<typeof vi.fn>).mockResolvedValue({
|
||||
items: SAMPLE_CLAIMS,
|
||||
total: SAMPLE_CLAIMS.length,
|
||||
returned: SAMPLE_CLAIMS.length,
|
||||
has_more: false,
|
||||
});
|
||||
(api.getClaimDetail as unknown as ReturnType<typeof vi.fn>).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=");
|
||||
});
|
||||
});
|
||||
+43
-3
@@ -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<HTMLInputElement>(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 (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<header className="flex items-end justify-between gap-6 flex-wrap">
|
||||
<>
|
||||
<ClaimDrawer
|
||||
claimId={claimId}
|
||||
// Pass the full ordered list of ids from the current page so j/k
|
||||
// can wrap around without an extra round-trip. `items` is the API
|
||||
// response (post-pagination, post-search); that's the same set the
|
||||
// user is looking at, so wrap-around feels right.
|
||||
claims={items.map((c) => ({ id: c.id }))}
|
||||
onClose={close}
|
||||
onNavigate={setClaimId}
|
||||
onToggleHelp={() => {
|
||||
// T22 wires the KeyboardCheatsheet overlay; no-op for now.
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
data-testid="claims-page-body"
|
||||
className={cn(
|
||||
"space-y-8 animate-fade-in transition-opacity",
|
||||
dimBackground && "pointer-events-none opacity-60"
|
||||
)}
|
||||
>
|
||||
<header className="flex items-end justify-between gap-6 flex-wrap">
|
||||
<div>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<span className="inline-block h-px w-6 bg-border" />
|
||||
@@ -225,8 +259,13 @@ export function Claims() {
|
||||
return (
|
||||
<TableRow
|
||||
key={`${c.id}-${dataUpdatedAt}`}
|
||||
// Click anywhere on the row to open the drawer for
|
||||
// this claim. `cursor-pointer` makes the affordance
|
||||
// obvious; the existing `hover:bg-muted/30` from the
|
||||
// TableRow primitive stays in place.
|
||||
onClick={() => 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() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user