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 { 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,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user