diff --git a/src/components/ClaimDrawer/ClaimDrawer.test.tsx b/src/components/ClaimDrawer/ClaimDrawer.test.tsx index 05ed931..facf7d7 100644 --- a/src/components/ClaimDrawer/ClaimDrawer.test.tsx +++ b/src/components/ClaimDrawer/ClaimDrawer.test.tsx @@ -9,6 +9,7 @@ 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 { ClaimDrawer } from "./ClaimDrawer"; +import { DrillStackProvider } from "@/components/drill/DrillStackProvider"; import { api, ApiError } from "@/lib/api"; import type { ClaimDetail } from "@/types"; @@ -162,14 +163,22 @@ function renderDrawer( React.createElement( QueryClientProvider, { client: qc }, - React.createElement(ClaimDrawer, { - claimId: props.claimId, - claims, - onClose, - onNavigate, - onToggleHelp, - }) - ) + // SP21 Phase 5 Task 5.8: PartiesGrid (mounted by ClaimDrawer) + // calls useDrillStack(). Wrap the drawer in DrillStackProvider + // so the hook has a context. (The provider is also mounted at + // the App root in production.) + React.createElement( + DrillStackProvider, + null, + React.createElement(ClaimDrawer, { + claimId: props.claimId, + claims, + onClose, + onNavigate, + onToggleHelp, + }), + ), + ), ); }); diff --git a/src/components/ClaimDrawer/PartiesGrid.test.tsx b/src/components/ClaimDrawer/PartiesGrid.test.tsx index cc16137..b68ce4d 100644 --- a/src/components/ClaimDrawer/PartiesGrid.test.tsx +++ b/src/components/ClaimDrawer/PartiesGrid.test.tsx @@ -6,13 +6,23 @@ import React, { act } from "react"; 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 { DrillStackProvider } from "@/components/drill/DrillStackProvider"; import type { ClaimDetailAddress, ClaimDetailParties, } 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): { container: HTMLDivElement; unmount: () => void; @@ -20,8 +30,11 @@ function renderIntoContainer(element: React.ReactElement): { const container = document.createElement("div"); document.body.appendChild(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(() => { - root.render(element); + root.render({element}); }); return { container, @@ -279,4 +292,112 @@ describe("PartiesGrid", () => { 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( + + ); + + 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).mockReturnValue({ + data: null, + isLoading: true, + }); + + const { container, unmount } = renderIntoContainer( + + ); + + 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).mockImplementation( + mockUsePayerSummary, + ); + + const { container, unmount } = renderIntoContainer( + + ); + + // 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).mockReturnValue({ + data: null, + isLoading: true, + }); }); diff --git a/src/components/ClaimDrawer/PartiesGrid.tsx b/src/components/ClaimDrawer/PartiesGrid.tsx index 1abc39d..3a9f70c 100644 --- a/src/components/ClaimDrawer/PartiesGrid.tsx +++ b/src/components/ClaimDrawer/PartiesGrid.tsx @@ -1,4 +1,7 @@ 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 = { parties: ClaimDetail["parties"]; @@ -48,12 +51,14 @@ function PartyCard({ name, identity, address, + onNameClick, }: { testId: string; label: string; name: string; identity: React.ReactNode; address?: AddressLike; + onNameClick?: () => void; }) { return (
{label} -
- {name} -
+ {onNameClick ? ( + + ) : ( +
+ {name} +
+ )}
@@ -88,6 +105,13 @@ function PartyCard({ */ export function PartiesGrid({ parties }: PartiesGridProps) { 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 (
ID {payer.id}
} + // 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 })} />
+ + {/* 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" ? ( + + + + ) : null} ); }