From 4a8ce1a5247400717f619facef46ac678ac25cda Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 21 Jun 2026 17:32:09 -0600 Subject: [PATCH] feat(inbox): rejected + payer_rejected + done_today rows drillable --- src/pages/Inbox.test.tsx | 191 ++++++++++++++++++++++++++++++++++++++- src/pages/Inbox.tsx | 30 +++++- 2 files changed, 216 insertions(+), 5 deletions(-) diff --git a/src/pages/Inbox.test.tsx b/src/pages/Inbox.test.tsx index 4f91c9d..5ca40a0 100644 --- a/src/pages/Inbox.test.tsx +++ b/src/pages/Inbox.test.tsx @@ -1,7 +1,8 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { MemoryRouter, useLocation, useNavigate } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import Inbox from "./Inbox"; import * as inboxApi from "@/lib/inbox-api"; @@ -12,17 +13,46 @@ import * as downloadModule from "@/lib/download"; // a Router context. We use a fresh QueryClient per test so the // drawer's per-remit query doesn't leak cache between cases, and a // MemoryRouter so `useNavigate` has a router to push into. +// +// Phase 5 Task 5.4: MemoryRouter doesn't update window.location, so +// the navigation assertion uses a small LocationTracker component +// mounted under the same router that records the current pathname + +// search after each render. Tests then read `tracker.last` instead +// of `window.location`. +function LocationTracker({ on }: { on: (pathname: string, search: string) => void }) { + const loc = useLocation(); + useEffect(() => { + on(loc.pathname, loc.search); + }, [loc.pathname, loc.search, on]); + return null; +} + function renderInbox() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false, retryDelay: 0 } }, }); - return render( + // Capture the last location seen so tests can assert on navigation + // without poking at window.location (MemoryRouter doesn't touch it). + const captured = { pathname: "/inbox", search: "" }; + const view = render( + { + captured.pathname = pathname; + captured.search = search; + }} + /> , ); + // Attach the tracker so individual tests can read it. Note: the + // captured value updates asynchronously after navigation, but since + // LocationTracker runs in the same render pass as the navigate, + // tests should `waitFor` it. + (view as unknown as { tracker: typeof captured }).tracker = captured; + return view as ReturnType & { tracker: typeof captured }; } afterEach(() => { @@ -355,4 +385,161 @@ describe("Inbox page", () => { ).not.toBeNull(); }); }); + + it("SP21 Task 5.4: clicking a rejected claim row navigates to /claims?claim=ID", async () => { + // Task 5.4 wires the rejected lane's onRowClick to navigate to + // the ClaimDrawer via `?claim=ID` on the /claims route. The + // MemoryRouter (initial /inbox) lets us observe the URL change + // via the LocationTracker helper mounted in the render harness. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + rejected: [ + { + id: "REJ1", + kind: "claim", + payer_claim_control_number: "REJ1", + charge_amount: 175, + payer_id: "P1", + provider_npi: "1234567890", + state: "rejected", + rejection_reason: "999 reject", + service_date: null, + score: null, + }, + ], + payer_rejected: [], + candidates: [], + unmatched: [], + done_today: [], + }), + }), + ); + + const view = renderInbox(); + await waitFor(() => { + expect(view.container.textContent).toContain("REJ1"); + }); + + // Click the rejected row. The InboxRow renders the claim id as + // its primary text cell; clicking that row bubbles up to the + // Lane's onRowClick handler we wired in Task 5.4. + const cell = Array.from(view.container.querySelectorAll("td")).find( + (td) => td.textContent === "REJ1", + ); + const row = cell?.closest("tr"); + expect(row).toBeTruthy(); + await act(async () => { + fireEvent.click(row as HTMLElement); + }); + + // MemoryRouter navigates to /claims?claim=REJ1 — verify the + // tracker observed the new pathname + search. + await waitFor(() => { + expect(view.tracker.pathname).toBe("/claims"); + expect(view.tracker.search).toBe("?claim=REJ1"); + }); + }); + + it("SP21 Task 5.4: clicking a done_today claim row navigates to /claims?claim=ID", async () => { + // done_today rows are claim-shaped — same drill pattern as the + // rejected lane. Verifies that the wiring covers the trailing + // "shipped today" lane too, not just the error lanes. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + rejected: [], + payer_rejected: [], + candidates: [], + unmatched: [], + done_today: [ + { + id: "DONE1", + kind: "claim", + patient_control_number: "DONE1", + charge_amount: 88, + payer_id: "P1", + provider_npi: "1234567890", + state: "submitted", + service_date_from: "2026-06-21", + }, + ], + }), + }), + ); + + const view = renderInbox(); + await waitFor(() => { + expect(view.container.textContent).toContain("DONE1"); + }); + + const cell = Array.from(view.container.querySelectorAll("td")).find( + (td) => td.textContent === "DONE1", + ); + const row = cell?.closest("tr"); + expect(row).toBeTruthy(); + await act(async () => { + fireEvent.click(row as HTMLElement); + }); + + await waitFor(() => { + expect(view.tracker.pathname).toBe("/claims"); + expect(view.tracker.search).toBe("?claim=DONE1"); + }); + }); + + it("SP21 Task 5.4: clicking the row checkbox does not bubble to onRowClick", async () => { + // Per the plan's §self-review #5, the Lane's RowCheckbox already + // calls e.stopPropagation() — verify that's still the case by + // clicking the checkbox and confirming the URL didn't change to + // /claims. (If stopPropagation regressed, the row click handler + // would fire and navigate.) + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + rejected: [ + { + id: "REJ1", + kind: "claim", + payer_claim_control_number: "REJ1", + charge_amount: 175, + payer_id: "P1", + provider_npi: "1234567890", + state: "rejected", + rejection_reason: "999 reject", + service_date: null, + score: null, + }, + ], + payer_rejected: [], + candidates: [], + unmatched: [], + done_today: [], + }), + }), + ); + + const view = renderInbox(); + await waitFor(() => { + expect(view.container.textContent).toContain("REJ1"); + }); + + const checkbox = view.container.querySelector( + 'input[type="checkbox"][aria-label="Select REJ1"]', + ) as HTMLInputElement; + expect(checkbox).toBeTruthy(); + await act(async () => { + checkbox.click(); + }); + + // URL should still be /inbox — clicking the checkbox selected + // the row but did not drill into the claim drawer. + expect(view.tracker.pathname).toBe("/inbox"); + }); }); diff --git a/src/pages/Inbox.tsx b/src/pages/Inbox.tsx index 27e3f8a..ff41bb8 100644 --- a/src/pages/Inbox.tsx +++ b/src/pages/Inbox.tsx @@ -295,7 +295,16 @@ export default function Inbox() { name="REJECTED" accent="oxblood" rows={lanes.rejected} - onRowClick={() => {}} + // SP21 Phase 5 Task 5.4: rejected claims drill into the + // ClaimDrawer. All rows here are claims (kind === "claim"), + // so the branch is straightforward — the type union still + // requires the defensive check, but a row click on a claim + // here is always a claim drill. + onRowClick={(row) => { + if (row.kind === "claim") { + navigate(`/claims?claim=${encodeURIComponent(row.id)}`); + } + }} onSelectionChange={(ids) => setLaneSelected("rejected", ids)} /> {/* @@ -308,7 +317,13 @@ export default function Inbox() { name="PAYER REJECTED" accent="oxblood" rows={lanes.payer_rejected} - onRowClick={() => {}} + // SP21 Phase 5 Task 5.4: payer-rejected claims also drill + // into the ClaimDrawer — same shape as the rejected lane. + onRowClick={(row) => { + if (row.kind === "claim") { + navigate(`/claims?claim=${encodeURIComponent(row.id)}`); + } + }} onSelectionChange={(ids) => setLaneSelected("payer_rejected", ids)} /> {}} + // SP21 Phase 5 Task 5.4: done_today rows are also claim + // drills — same as rejected / payer_rejected. The drawer + // surfaces the claim's current state + recent history, + // which is exactly what an operator wants when reviewing + // "what got shipped today". + onRowClick={(row) => { + if (row.kind === "claim") { + navigate(`/claims?claim=${encodeURIComponent(row.id)}`); + } + }} onSelectionChange={(ids) => setLaneSelected("done_today", ids)} />