feat(claim-drawer): payer name opens PeekModal on top of drawer
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(<DrillStackProvider>{element}</DrillStackProvider>);
|
||||
});
|
||||
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(
|
||||
<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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -63,9 +68,21 @@ function PartyCard({
|
||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
{label}
|
||||
</span>
|
||||
<div className="display text-lg text-[color:var(--m-ink-primary)]">
|
||||
{name}
|
||||
</div>
|
||||
{onNameClick ? (
|
||||
<button
|
||||
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
|
||||
className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||
>
|
||||
@@ -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 (
|
||||
<section
|
||||
@@ -142,8 +166,31 @@ export function PartiesGrid({ parties }: PartiesGridProps) {
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user