feat(inbox): rejected + payer_rejected + done_today rows drillable
This commit is contained in:
+189
-2
@@ -1,7 +1,8 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import Inbox from "./Inbox";
|
import Inbox from "./Inbox";
|
||||||
import * as inboxApi from "@/lib/inbox-api";
|
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
|
// 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
|
// drawer's per-remit query doesn't leak cache between cases, and a
|
||||||
// MemoryRouter so `useNavigate` has a router to push into.
|
// 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() {
|
function renderInbox() {
|
||||||
const qc = new QueryClient({
|
const qc = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false, retryDelay: 0 } },
|
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"]}>
|
<MemoryRouter initialEntries={["/inbox"]}>
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
|
<LocationTracker
|
||||||
|
on={(pathname, search) => {
|
||||||
|
captured.pathname = pathname;
|
||||||
|
captured.search = search;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Inbox />
|
<Inbox />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</MemoryRouter>,
|
</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(() => {
|
afterEach(() => {
|
||||||
@@ -355,4 +385,161 @@ describe("Inbox page", () => {
|
|||||||
).not.toBeNull();
|
).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"
|
name="REJECTED"
|
||||||
accent="oxblood"
|
accent="oxblood"
|
||||||
rows={lanes.rejected}
|
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)}
|
onSelectionChange={(ids) => setLaneSelected("rejected", ids)}
|
||||||
/>
|
/>
|
||||||
{/*
|
{/*
|
||||||
@@ -308,7 +317,13 @@ export default function Inbox() {
|
|||||||
name="PAYER REJECTED"
|
name="PAYER REJECTED"
|
||||||
accent="oxblood"
|
accent="oxblood"
|
||||||
rows={lanes.payer_rejected}
|
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)}
|
onSelectionChange={(ids) => setLaneSelected("payer_rejected", ids)}
|
||||||
/>
|
/>
|
||||||
<Lane
|
<Lane
|
||||||
@@ -344,7 +359,16 @@ export default function Inbox() {
|
|||||||
name="DONE"
|
name="DONE"
|
||||||
accent="muted"
|
accent="muted"
|
||||||
rows={lanes.done_today}
|
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)}
|
onSelectionChange={(ids) => setLaneSelected("done_today", ids)}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user