feat(inbox): rejected + payer_rejected + done_today rows drillable
This commit is contained in:
+189
-2
@@ -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(
|
||||
<MemoryRouter initialEntries={["/inbox"]}>
|
||||
<QueryClientProvider client={qc}>
|
||||
<LocationTracker
|
||||
on={(pathname, search) => {
|
||||
captured.pathname = pathname;
|
||||
captured.search = search;
|
||||
}}
|
||||
/>
|
||||
<Inbox />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
// 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<typeof render> & { 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");
|
||||
});
|
||||
});
|
||||
|
||||
+27
-3
@@ -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)}
|
||||
/>
|
||||
<Lane
|
||||
@@ -344,7 +359,16 @@ export default function Inbox() {
|
||||
name="DONE"
|
||||
accent="muted"
|
||||
rows={lanes.done_today}
|
||||
onRowClick={() => {}}
|
||||
// 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)}
|
||||
/>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user