feat(claim-drawer): payer name opens PeekModal on top of drawer

This commit is contained in:
Tyler
2026-06-21 17:53:43 -06:00
parent 6c773c1159
commit 786ead8c94
3 changed files with 190 additions and 13 deletions
@@ -9,6 +9,7 @@ import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ClaimDrawer } from "./ClaimDrawer"; import { ClaimDrawer } from "./ClaimDrawer";
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
import { api, ApiError } from "@/lib/api"; import { api, ApiError } from "@/lib/api";
import type { ClaimDetail } from "@/types"; import type { ClaimDetail } from "@/types";
@@ -162,14 +163,22 @@ function renderDrawer(
React.createElement( React.createElement(
QueryClientProvider, QueryClientProvider,
{ client: qc }, { client: qc },
React.createElement(ClaimDrawer, { // SP21 Phase 5 Task 5.8: PartiesGrid (mounted by ClaimDrawer)
claimId: props.claimId, // calls useDrillStack(). Wrap the drawer in DrillStackProvider
claims, // so the hook has a context. (The provider is also mounted at
onClose, // the App root in production.)
onNavigate, React.createElement(
onToggleHelp, DrillStackProvider,
}) null,
) React.createElement(ClaimDrawer, {
claimId: props.claimId,
claims,
onClose,
onNavigate,
onToggleHelp,
}),
),
),
); );
}); });
+123 -2
View File
@@ -6,13 +6,23 @@
import React, { act } from "react"; import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi, beforeEach } from "vitest";
import { PartiesGrid } from "./PartiesGrid"; import { PartiesGrid } from "./PartiesGrid";
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
import type { import type {
ClaimDetailAddress, ClaimDetailAddress,
ClaimDetailParties, ClaimDetailParties,
} from "@/types"; } from "@/types";
// Mock the api module so PayerPeekContent's usePayerSummary call
// doesn't make a real network request. The spy is set per-test in
// beforeEach.
vi.mock("@/hooks/usePayerSummary", () => ({
usePayerSummary: vi.fn(),
}));
import { usePayerSummary } from "@/hooks/usePayerSummary";
function renderIntoContainer(element: React.ReactElement): { function renderIntoContainer(element: React.ReactElement): {
container: HTMLDivElement; container: HTMLDivElement;
unmount: () => void; unmount: () => void;
@@ -20,8 +30,11 @@ function renderIntoContainer(element: React.ReactElement): {
const container = document.createElement("div"); const container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
const root: Root = createRoot(container); const root: Root = createRoot(container);
// SP21 Phase 5 Task 5.8: PartiesGrid now uses useDrillStack() to
// open payer peeks. Wrap every render in a DrillStackProvider so
// the hook has a context to read from.
act(() => { act(() => {
root.render(element); root.render(<DrillStackProvider>{element}</DrillStackProvider>);
}); });
return { return {
container, container,
@@ -279,4 +292,112 @@ describe("PartiesGrid", () => {
unmount(); unmount();
}); });
it("SP21 Task 5.8: payer name is drillable (renders as a button)", () => {
// The payer card's name now wraps in a button with the
// `drillable` affordance + `data-testid="party-payer-name-drill"`.
// The other two cards (billing-provider, subscriber) keep the
// name as a plain div.
const { container, unmount } = renderIntoContainer(
<PartiesGrid parties={makeParties()} />
);
const payerNameBtn = container.querySelector(
'[data-testid="party-payer-name-drill"]',
);
expect(payerNameBtn).not.toBeNull();
expect(payerNameBtn?.textContent).toContain("Aetna");
// The billing-provider and subscriber names stay plain divs.
expect(
container.querySelector(
'[data-testid="party-billing-provider-name-drill"]',
),
).toBeNull();
expect(
container.querySelector(
'[data-testid="party-subscriber-name-drill"]',
),
).toBeNull();
unmount();
});
it("SP21 Task 5.8: clicking the payer name opens the PeekModal", async () => {
// Mock the payer summary fetch so PayerPeekContent resolves
// without a real network call. The peek modal renders
// regardless of fetch state (loading skeleton → content).
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: null,
isLoading: true,
});
const { container, unmount } = renderIntoContainer(
<PartiesGrid parties={makeParties()} />
);
const payerNameBtn = container.querySelector(
'[data-testid="party-payer-name-drill"]',
) as HTMLButtonElement | null;
expect(payerNameBtn).not.toBeNull();
await act(async () => {
payerNameBtn!.click();
await Promise.resolve();
});
// The PeekModal is a Radix Dialog that portals into document.body.
// Look for the [role="dialog"] (modal) with our eyebrow "Payer".
const dialogs = document.body.querySelectorAll('[role="dialog"]');
// One dialog is the modal itself (the peek). PartiesGrid doesn't
// open a drawer, so we expect exactly one.
expect(dialogs.length).toBeGreaterThanOrEqual(1);
const peekDialog = Array.from(dialogs).find((d) =>
d.textContent?.includes("Payer"),
);
expect(peekDialog).not.toBeNull();
expect(peekDialog?.textContent).toContain("Aetna");
unmount();
});
it("SP21 Task 5.8: peek uses the X12 payer id, not the human name", () => {
// The PayerPeekContent is given the X12 payer id from
// `payer.id`. Verify the click flow passes the right id by
// checking usePayerSummary was called with "PAYER01".
const mockUsePayerSummary = vi.fn().mockReturnValue({
data: null,
isLoading: true,
});
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockImplementation(
mockUsePayerSummary,
);
const { container, unmount } = renderIntoContainer(
<PartiesGrid parties={makeParties()} />
);
// No peek yet → no usePayerSummary call.
expect(mockUsePayerSummary).not.toHaveBeenCalled();
const payerNameBtn = container.querySelector(
'[data-testid="party-payer-name-drill"]',
) as HTMLButtonElement | null;
act(() => {
payerNameBtn!.click();
});
// After the click, PayerPeekContent mounts and calls
// usePayerSummary("PAYER01") — NOT the human name "Aetna".
expect(mockUsePayerSummary).toHaveBeenCalledWith("PAYER01");
unmount();
});
});
beforeEach(() => {
// Reset the mock between tests so per-test implementations stick.
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: null,
isLoading: true,
});
}); });
+50 -3
View File
@@ -1,4 +1,7 @@
import type { ClaimDetail, ClaimDetailAddress } from "@/types"; import type { ClaimDetail, ClaimDetailAddress } from "@/types";
import { useDrillStack } from "@/components/drill/DrillStackProvider";
import { PeekModal } from "@/components/drill/PeekModal";
import { PayerPeekContent } from "@/components/drill/PayerPeekContent";
type PartiesGridProps = { type PartiesGridProps = {
parties: ClaimDetail["parties"]; parties: ClaimDetail["parties"];
@@ -48,12 +51,14 @@ function PartyCard({
name, name,
identity, identity,
address, address,
onNameClick,
}: { }: {
testId: string; testId: string;
label: string; label: string;
name: string; name: string;
identity: React.ReactNode; identity: React.ReactNode;
address?: AddressLike; address?: AddressLike;
onNameClick?: () => void;
}) { }) {
return ( return (
<div <div
@@ -63,9 +68,21 @@ function PartyCard({
<span className="eyebrow text-[color:var(--m-ink-tertiary)]"> <span className="eyebrow text-[color:var(--m-ink-tertiary)]">
{label} {label}
</span> </span>
<div className="display text-lg text-[color:var(--m-ink-primary)]"> {onNameClick ? (
{name} <button
</div> type="button"
onClick={onNameClick}
data-testid={`${testId}-name-drill`}
aria-label={`Drill into ${label.toLowerCase()} ${name}`}
className="display text-lg text-[color:var(--m-ink-primary)] text-left cursor-pointer drillable rounded-sm px-0 -mx-0"
>
{name}
</button>
) : (
<div className="display text-lg text-[color:var(--m-ink-primary)]">
{name}
</div>
)}
<div <div
className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums" className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums"
> >
@@ -88,6 +105,13 @@ function PartyCard({
*/ */
export function PartiesGrid({ parties }: PartiesGridProps) { export function PartiesGrid({ parties }: PartiesGridProps) {
const { billingProvider, subscriber, payer } = parties; const { billingProvider, subscriber, payer } = parties;
// SP21 Phase 5 Task 5.8: payer name opens a PeekModal on top of
// the drawer — the peek stack (DrillStackProvider) holds at most
// one peek at a time, so opening payer-peek replaces any earlier
// peek. The X12 payer id (`payer.id`) is what the peek endpoint
// expects, NOT the human name.
const { stack, openPeek, closeTop } = useDrillStack();
const topPeek = stack[stack.length - 1];
return ( return (
<section <section
@@ -142,8 +166,31 @@ export function PartiesGrid({ parties }: PartiesGridProps) {
<div>ID {payer.id}</div> <div>ID {payer.id}</div>
</> </>
} }
// SP21 Phase 5 Task 5.8: payer name is drillable. Clicking
// pushes a payer peek onto the drill stack; the PeekModal
// at the bottom of this section renders when the top of
// the stack is "payer". The payer id used here is the X12
// payer_id (e.g. "SKCO0") — verified in ClaimDetailPayer
// type — which is what usePayerSummary expects.
onNameClick={() => openPeek({ kind: "payer", payerId: payer.id })}
/> />
</div> </div>
{/* SP21 Phase 5 Task 5.8: payer peek. Mounted at the bottom
of PartiesGrid so the peek sits on top of the drawer (Radix
Dialog portals). Closing the peek pops the stack. Only one
peek renders at a time (the stack caps at 1) — when the
user opens a payer peek, any earlier peek is replaced. */}
{topPeek?.kind === "payer" ? (
<PeekModal
open
onClose={closeTop}
eyebrow="Payer"
title={payer.name}
>
<PayerPeekContent payerId={topPeek.payerId} />
</PeekModal>
) : null}
</section> </section>
); );
} }