feat(frontend): Claims page wires live tail + TailStatusPill
This commit is contained in:
@@ -10,6 +10,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { Claims } from "./Claims";
|
import { Claims } from "./Claims";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { useTailStore } from "@/store/tail-store";
|
||||||
import type { Claim, ClaimDetail } from "@/types";
|
import type { Claim, ClaimDetail } from "@/types";
|
||||||
|
|
||||||
// Module-level mock — vitest hoists vi.mock above imports. We mock the
|
// Module-level mock — vitest hoists vi.mock above imports. We mock the
|
||||||
@@ -38,6 +39,30 @@ vi.mock("@/lib/api", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Live-tail hook mock (sub-project 5, Phase 5 Task 22 page-level tests).
|
||||||
|
//
|
||||||
|
// We mock `useTailStream` directly (per the plan's "latter is simpler"
|
||||||
|
// guidance) so the page-level tests don't have to drive the real
|
||||||
|
// `streamTail` parser or the real AbortController-based reconnect loop.
|
||||||
|
// `useTailStream` is exercised exhaustively in its own test file
|
||||||
|
// (`useTailStream.test.ts`); the page only needs to observe that the
|
||||||
|
// returned status flows through to `<TailStatusPill>` and that items
|
||||||
|
// pushed into the tail store surface as rows via `useMergedTail`.
|
||||||
|
//
|
||||||
|
// The mock returns `status: "live"` by default — that matches what the
|
||||||
|
// real hook settles to after a `snapshot_end` event arrives, which is
|
||||||
|
// the state the page sees in production ~all of the time.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
vi.mock("@/hooks/useTailStream", () => ({
|
||||||
|
useTailStream: vi.fn(() => ({
|
||||||
|
status: "live",
|
||||||
|
lastEventAt: null,
|
||||||
|
error: null,
|
||||||
|
forceReconnect: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
const SAMPLE_CLAIMS: Claim[] = [
|
const SAMPLE_CLAIMS: Claim[] = [
|
||||||
{
|
{
|
||||||
id: "CLM-1",
|
id: "CLM-1",
|
||||||
@@ -190,6 +215,10 @@ describe("Claims page drawer wiring", () => {
|
|||||||
(api.getClaimDetail as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
(api.getClaimDetail as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
SAMPLE_DETAIL
|
SAMPLE_DETAIL
|
||||||
);
|
);
|
||||||
|
// Live-tail slice is a singleton zustand store — clear it between
|
||||||
|
// tests so the live-arrival test doesn't see rows from a previous
|
||||||
|
// case.
|
||||||
|
useTailStore.getState().reset("claims");
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -420,4 +449,86 @@ describe("Claims page drawer wiring", () => {
|
|||||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Live-tail integration (sub-project 5, Phase 5 Task 22 page tests).
|
||||||
|
//
|
||||||
|
// We mock `useTailStream` at the module level (see the top of this
|
||||||
|
// file) so the page sees a settled `status: "live"` without driving
|
||||||
|
// the real streamTail parser. To exercise the "tail arrival triggers a
|
||||||
|
// row" path we push a new claim directly into `useTailStore` — this
|
||||||
|
// is exactly what the real `useTailStream` hook does on `item` events,
|
||||||
|
// so we cover the same render path (zustand notify → useMergedTail
|
||||||
|
// re-derive → `<TableRow>` mount).
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("test_live_tail_arrival_triggers_row_in_table", async () => {
|
||||||
|
const { unmount } = renderClaims();
|
||||||
|
|
||||||
|
// Base list (SAMPLE_CLAIMS, set in beforeEach) must be visible first.
|
||||||
|
await settle(
|
||||||
|
() => document.body.textContent?.includes("CLM-1") ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The new claim id is brand new (not in base, not in tail store yet).
|
||||||
|
const TAIL_ID = "CLM-TAIL-1";
|
||||||
|
expect(
|
||||||
|
document.body.textContent?.includes(TAIL_ID) ?? false,
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
// Simulate a live-arriving claim — this is what `useTailStream`
|
||||||
|
// does when it dispatches an `item` event. Wrap in `act` because
|
||||||
|
// zustand subscribers re-render synchronously.
|
||||||
|
await act(async () => {
|
||||||
|
useTailStore.getState().addClaim({
|
||||||
|
id: TAIL_ID,
|
||||||
|
patientName: "Live Arrival",
|
||||||
|
providerNpi: "1234567890",
|
||||||
|
payerName: "Colorado Medicaid",
|
||||||
|
cptCode: "99215",
|
||||||
|
billedAmount: 999,
|
||||||
|
receivedAmount: 0,
|
||||||
|
status: "submitted",
|
||||||
|
submissionDate: "2026-06-20",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The new row must appear — proves the end-to-end path
|
||||||
|
// zustand.addClaim → useTailStore notify → useMergedTail re-derive
|
||||||
|
// → <TableBody> mounts the row with the new id.
|
||||||
|
await settle(
|
||||||
|
() => document.body.textContent?.includes(TAIL_ID) ?? false,
|
||||||
|
);
|
||||||
|
expect(document.body.textContent).toContain(TAIL_ID);
|
||||||
|
// The row's secondary cells should also be present so we know the
|
||||||
|
// row fully rendered, not just that the id appeared somewhere
|
||||||
|
// (e.g. in the search-input placeholder or similar).
|
||||||
|
expect(document.body.textContent).toContain("Live Arrival");
|
||||||
|
expect(document.body.textContent).toContain("99215");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_status_pill_shows_live_after_stream_connects", async () => {
|
||||||
|
const { unmount } = renderClaims();
|
||||||
|
|
||||||
|
// Wait for the base table to mount — once it's there, the toolbar
|
||||||
|
// (which contains the pill) has also mounted.
|
||||||
|
await settle(
|
||||||
|
() => document.body.textContent?.includes("CLM-1") ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The mocked `useTailStream` returns `status: "live"`, so the
|
||||||
|
// <TailStatusPill> should render with the human label "Live"
|
||||||
|
// (per `STATUS_LABEL` in `TailStatusPill.tsx`). We assert on the
|
||||||
|
// pill's textContent so this stays robust against future styling
|
||||||
|
// changes (the test doesn't depend on Tailwind classes).
|
||||||
|
const pill = document.body.querySelector(
|
||||||
|
'[data-testid="tail-status-pill"]',
|
||||||
|
);
|
||||||
|
expect(pill).not.toBeNull();
|
||||||
|
expect(pill?.textContent).toContain("Live");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+36
-2
@@ -27,9 +27,12 @@ import { FilterChips, type FilterChipOption } from "@/components/ui/filter-chips
|
|||||||
import { Pagination } from "@/components/ui/pagination";
|
import { Pagination } from "@/components/ui/pagination";
|
||||||
import { useClaims } from "@/hooks/useClaims";
|
import { useClaims } from "@/hooks/useClaims";
|
||||||
import { useDrawerUrlState } from "@/hooks/useDrawerUrlState";
|
import { useDrawerUrlState } from "@/hooks/useDrawerUrlState";
|
||||||
|
import { useTailStream } from "@/hooks/useTailStream";
|
||||||
|
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||||
|
import { TailStatusPill } from "@/components/TailStatusPill";
|
||||||
import { useAppStore } from "@/store";
|
import { useAppStore } from "@/store";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
import type { ClaimStatus } from "@/types";
|
import type { Claim, ClaimStatus } from "@/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const ALL = "all" as const;
|
const ALL = "all" as const;
|
||||||
@@ -74,7 +77,28 @@ export function Claims() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useClaims(params);
|
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useClaims(params);
|
||||||
const items = data?.items ?? [];
|
|
||||||
|
// SP5 live-tail wiring (sub-project 5, Phase 5 Task 22).
|
||||||
|
//
|
||||||
|
// The tail stream emits every claim that lands in the DB — including
|
||||||
|
// ones that don't match the user's current `status` / `provider_npi`
|
||||||
|
// filter. Mirror the server-side filter the page already applies via
|
||||||
|
// `useClaims` so a newly-arrived item that wouldn't pass the same
|
||||||
|
// predicate is dropped before it can show up in the table. `useMergedTail`
|
||||||
|
// applies this to the tail slice only; base items are already filtered
|
||||||
|
// server-side.
|
||||||
|
const tailFilterFn = useMemo(
|
||||||
|
() => (c: Claim) => {
|
||||||
|
if (status !== ALL && c.status !== status) return false;
|
||||||
|
if (npi !== ALL && c.providerNpi !== npi) return false;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[status, npi],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { status: tailStatus, lastEventAt: tailLastEventAt, forceReconnect } =
|
||||||
|
useTailStream("claims");
|
||||||
|
const items = useMergedTail("claims", data?.items ?? [], tailFilterFn);
|
||||||
|
|
||||||
const totals = useMemo(
|
const totals = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -190,6 +214,16 @@ export function Claims() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{/* SP5: live-tail status pill. `ml-auto` pins it to the right
|
||||||
|
edge of the toolbar so it doesn't crowd the search input. */}
|
||||||
|
<div className="ml-auto">
|
||||||
|
<TailStatusPill
|
||||||
|
status={tailStatus}
|
||||||
|
lastEventAt={tailLastEventAt}
|
||||||
|
onReconnect={forceReconnect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user