merge: ui/batches-browser — list + detail drawer for /api/batches

This commit is contained in:
Tyler
2026-06-20 16:59:44 -06:00
6 changed files with 1160 additions and 0 deletions
+425
View File
@@ -0,0 +1,425 @@
// @vitest-environment happy-dom
// React Query's state updates need an act-aware environment or it
// logs noisy warnings. Matches the convention from Acks.test.tsx.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Batches } from "./Batches";
import { api, ApiError } from "@/lib/api";
vi.mock("@/lib/api", () => ({
api: {
isConfigured: true,
listBatches: vi.fn(),
getBatch: vi.fn(),
},
ApiError: class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
},
}));
// Stub react-router-dom's NavLink-aware hooks by wrapping in a memory
// router. The page itself doesn't use react-router state, but if any
// future refactor does this keeps the test future-proof.
function renderIntoContainer(
element: React.ReactElement,
initialEntries: string[] = ["/batches"]
): { container: HTMLDivElement; unmount: () => void } {
const container = document.createElement("div");
document.body.appendChild(container);
const qc = new QueryClient({
defaultOptions: {
queries: {
retry: false,
// The Batches page's `useBatchDetail` hook overrides `retry`
// with a function that retries up to 3 times for non-404
// errors. The default retryDelay (~1s, 2s, 4s backoff) would
// blow past the test timeout, so we pin retries to 10ms —
// the retry behavior we want to assert is *that retries
// happen* (i.e. the not_found branch's no-retry contract),
// not the pacing.
retryDelay: 10,
},
},
});
const root: Root = createRoot(container);
act(() => {
root.render(
<MemoryRouter initialEntries={initialEntries}>
<QueryClientProvider client={qc}>{element}</QueryClientProvider>
</MemoryRouter>
);
});
return {
container,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
async function waitForText(
text: string,
timeoutMs = 2000
): Promise<void> {
const start = Date.now();
while (!document.body.textContent?.includes(text)) {
if (Date.now() - start > timeoutMs) {
throw new Error(
`waitForText: "${text}" did not appear within ${timeoutMs}ms`
);
}
await act(async () => {
await Promise.resolve();
});
}
}
/**
* Poll for a DOM node matching a CSS selector. react-query's
* `useQuery` returns to `idle` between microtasks; a single
* `Promise.resolve()` isn't always enough to flush a queryFn that
* synchronously rejects. This helper keeps yielding until either
* the selector matches or the deadline expires.
*/
async function waitForSelector(
selector: string,
timeoutMs = 2000
): Promise<void> {
const start = Date.now();
while (!document.querySelector(selector)) {
if (Date.now() - start > timeoutMs) {
throw new Error(
`waitForSelector: "${selector}" did not appear within ${timeoutMs}ms`
);
}
await act(async () => {
await Promise.resolve();
});
}
}
// 837P-shaped payload: `claims` rows have `claim_id`. The page uses
// the presence of `payer_claim_control_number` on the first row as
// the discriminator between 837P and 835; 837P rows lack it.
function make837(id: string) {
return {
envelope: {
sender_id: "SENDER",
receiver_id: "RECEIVER",
control_number: id,
transaction_date: "2026-06-15",
transaction_time: "12:00",
},
summary: {
input_file: "x.837",
control_number: id,
transaction_date: "2026-06-15",
total_claims: 2,
passed: 2,
failed: 0,
failed_claim_ids: [],
issues_by_rule: {},
},
claims: [
{ claim_id: "CLM-1" },
{ claim_id: "CLM-2" },
],
};
}
describe("Batches page", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
// happy-dom's window.location persists between tests; reset the
// URL so the URL-state hook starts each test from a clean slate.
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
.happyDOM.setURL("http://localhost/batches");
});
it("renders the list skeleton when loading", async () => {
// Never resolve — keeps the query in the loading state forever.
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockReturnValue(
new Promise(() => {})
);
const { unmount } = renderIntoContainer(<Batches />);
// The skeleton primitive carries the data-testid the page sets
// on the wrapping container; check the attribute instead of text.
await act(async () => {
await Promise.resolve();
});
expect(
document.querySelector('[data-testid="batches-skeleton"]')
).not.toBeNull();
unmount();
});
it("renders the empty state when the backend returns no batches", async () => {
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const { unmount } = renderIntoContainer(<Batches />);
// Eyebrow copy unique to the empty state.
await waitForText("awaiting first ingest");
unmount();
});
it("renders rows with the correct kind badge colors", async () => {
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "B-1",
kind: "837p",
inputFilename: "co_medicaid_837p.txt",
parsedAt: "2026-06-15T12:00:00Z",
claimCount: 5,
},
{
id: "B-2",
kind: "835",
inputFilename: "co_medicaid_835.txt",
parsedAt: "2026-06-15T13:00:00Z",
claimCount: 7,
},
]);
const { unmount } = renderIntoContainer(<Batches />);
// Wait for at least one row to appear.
await waitForText("co_medicaid_837p.txt");
// Both kinds render with their own data-testid.
expect(
document.querySelector('[data-testid="kind-badge-837p"]')
).not.toBeNull();
expect(
document.querySelector('[data-testid="kind-badge-835"]')
).not.toBeNull();
// Class-name spot checks: 837p is sky-* (cool blue), 835 is
// amber-* (warm). We match `text-sky-300` for 837p and
// `text-amber-300` for 835 — the literal class strings baked
// into `KindBadge`. This catches accidental swaps of the color
// palettes between the two variants.
const sky = document.querySelector('[data-testid="kind-badge-837p"]');
const amber = document.querySelector('[data-testid="kind-badge-835"]');
expect(sky?.className).toContain("text-sky-300");
expect(amber?.className).toContain("text-amber-300");
unmount();
});
it("clicking a row opens the detail drawer", async () => {
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "B-9",
kind: "837p",
inputFilename: "demo.837",
parsedAt: "2026-06-15T12:00:00Z",
claimCount: 3,
},
]);
// Resolve the detail call so the drawer renders content rather
// than a skeleton — we just need to confirm it opens.
(api.getBatch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
make837("B-9")
);
const { unmount } = renderIntoContainer(<Batches />);
await waitForText("demo.837");
// Drawer should NOT be in the DOM before click.
expect(
document.querySelector('[data-testid="batch-detail-drawer"]')
).toBeNull();
// Click the row.
const row = document.querySelector(
'[data-testid="batch-row-B-9"]'
) as HTMLElement | null;
expect(row).not.toBeNull();
await act(async () => {
row?.click();
// Let the query kick off.
await Promise.resolve();
});
// Drawer now mounted and showing the detail content.
expect(
document.querySelector('[data-testid="batch-detail-drawer"]')
).not.toBeNull();
// Once the mock resolves, the drawer renders content (not
// skeleton, not error).
await waitForText("Envelope");
expect(
document.querySelector('[data-testid="batch-detail-content"]')
).not.toBeNull();
unmount();
});
it("Escape closes the drawer", async () => {
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "B-9",
kind: "837p",
inputFilename: "demo.837",
parsedAt: "2026-06-15T12:00:00Z",
claimCount: 3,
},
]);
(api.getBatch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
make837("B-9")
);
const { unmount } = renderIntoContainer(<Batches />);
await waitForText("demo.837");
// Open the drawer.
const row = document.querySelector(
'[data-testid="batch-row-B-9"]'
) as HTMLElement | null;
await act(async () => {
row?.click();
await Promise.resolve();
});
await waitForText("Envelope");
expect(
document.querySelector('[data-testid="batch-detail-drawer"]')
).not.toBeNull();
// Dispatch Escape from `document.body` — `useDrawerKeyboard`
// ignores events whose target is an editable element, so
// dispatching from `body` simulates the user pressing Esc while
// the page (not an input) has focus.
await act(async () => {
const ev = new KeyboardEvent("keydown", {
key: "Escape",
bubbles: true,
});
document.body.dispatchEvent(ev);
await Promise.resolve();
});
// Drawer closed — null result because the Dialog returns early.
expect(
document.querySelector('[data-testid="batch-detail-drawer"]')
).toBeNull();
unmount();
});
it("renders the not-found error state on a 404 from getBatch", async () => {
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "B-missing",
kind: "837p",
inputFilename: "ghost.837",
parsedAt: "2026-06-15T12:00:00Z",
claimCount: 0,
},
]);
// getBatch throws the same plain-Error shape `api.ts` produces:
// `${status} ${statusText} — ${detail}`. The hook rewraps the 404
// into ApiError(404) before it reaches the drawer's branching
// helper, so we mirror that contract here — assert that the
// drawer's error state is the not_found variant, not the
// generic network variant.
(api.getBatch as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("404 Not Found — batch not found")
);
const { unmount } = renderIntoContainer(<Batches />);
await waitForText("ghost.837");
const row = document.querySelector(
'[data-testid="batch-row-B-missing"]'
) as HTMLElement | null;
await act(async () => {
row?.click();
});
await waitForSelector('[data-testid="batch-detail-error-not_found"]');
// not_found branch: must be present, network branch must not.
expect(
document.querySelector(
'[data-testid="batch-detail-error-not_found"]'
)
).not.toBeNull();
expect(
document.querySelector(
'[data-testid="batch-detail-error-network"]'
)
).toBeNull();
// The not_found variant has no retry affordance — a retry won't
// bring back a batch that doesn't exist.
expect(
document.querySelector('[data-testid="batch-detail-error-retry"]')
).toBeNull();
unmount();
});
it("renders the network error state on a non-404 failure", async () => {
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "B-bad",
kind: "835",
inputFilename: "broken.835",
parsedAt: "2026-06-15T12:00:00Z",
claimCount: 0,
},
]);
// Non-404 failure (server error). getBatch throws plain Error;
// the hook only rewraps 404s, so this surfaces as a generic
// "network" error with a retry button.
(api.getBatch as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("500 Internal Server Error — upstream")
);
const { unmount } = renderIntoContainer(<Batches />);
await waitForText("broken.835");
const row = document.querySelector(
'[data-testid="batch-row-B-bad"]'
) as HTMLElement | null;
await act(async () => {
row?.click();
});
await waitForSelector('[data-testid="batch-detail-error-network"]');
expect(
document.querySelector('[data-testid="batch-detail-error-network"]')
).not.toBeNull();
expect(
document.querySelector('[data-testid="batch-detail-error-not_found"]')
).toBeNull();
// Network variant offers retry.
expect(
document.querySelector('[data-testid="batch-detail-error-retry"]')
).not.toBeNull();
unmount();
});
});
// Keep `ApiError` referenced so its import isn't tree-shaken by
// vitest's transformer when the mock factory above is hoisted.
void ApiError;
+270
View File
@@ -0,0 +1,270 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { BatchesList, BatchesListSkeleton } from "@/components/BatchesList";
import {
BatchDetailContent,
BatchDetailError,
BatchDetailSkeleton,
batchErrorKind,
} from "@/components/BatchDetail";
import { useBatches } from "@/hooks/useBatches";
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
import { api, ApiError, type BatchSummary } from "@/lib/api";
import { cn } from "@/lib/utils";
import type { ParseResult837, ParseResult835 } from "@/types";
// ---------------------------------------------------------------------------
// Inline: batch drawer URL state.
//
// `useDrawerUrlState` (in src/hooks/) is hard-coded to `?claim=` — it
// exists to drive the claim detail drawer. Mirroring that shape for
// `?batch=` keeps the two drawers decoupled: opening a claim from
// elsewhere doesn't disturb the batch URL, and vice versa. Inlining
// (rather than creating a parallel hook file) keeps the change inside
// this workstream's scope.
// ---------------------------------------------------------------------------
function readBatchId(): string | null {
const params = new URLSearchParams(window.location.search);
const value = params.get("batch");
return value === "" ? null : value;
}
function buildBatchUrl(batchId: string | null): string {
const url = new URL(window.location.href);
if (batchId === null) {
url.searchParams.delete("batch");
} else {
url.searchParams.set("batch", batchId);
}
return url.pathname + url.search + url.hash;
}
function useBatchDrawerUrlState(): {
batchId: string | null;
open: (id: string) => void;
close: () => void;
setBatchId: (id: string) => void;
} {
const [batchId, setBatchIdState] = useState<string | null>(() => readBatchId());
const open = useCallback((id: string) => {
window.history.pushState(null, "", buildBatchUrl(id));
setBatchIdState(id);
}, []);
const setBatchId = useCallback((id: string) => {
window.history.replaceState(null, "", buildBatchUrl(id));
setBatchIdState(id);
}, []);
const close = useCallback(() => {
window.history.pushState(null, "", buildBatchUrl(null));
setBatchIdState(null);
}, []);
useEffect(() => {
const onPopState = () => setBatchIdState(readBatchId());
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
return { batchId, open, close, setBatchId };
}
// ---------------------------------------------------------------------------
// Inline: per-batch detail query.
//
// `api.getBatch` throws plain `Error` with the HTTP status code at the
// start of the message (e.g. `"404 Not Found — batch not found"`),
// not `ApiError`. To drive the drawer's not-found branch via the same
// `ApiError.status === 404` check used everywhere else, this hook
// detects the `"404"` prefix in the message and rethrows as
// `ApiError(404, …)`. Other errors pass through unchanged.
//
// 30-second staleTime mirrors `useClaimDetail` so j/k navigation
// reuses prior fetches within the drawer session.
// ---------------------------------------------------------------------------
function useBatchDetail(id: string | null): {
data: (ParseResult837 | ParseResult835) | null;
isLoading: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
} {
const q = useQuery<ParseResult837 | ParseResult835>({
queryKey: ["batch-detail", id],
queryFn: async () => {
try {
return await api.getBatch(id as string);
} catch (err) {
if (
err instanceof Error &&
err.message.startsWith("404")
) {
throw new ApiError(404, err.message);
}
throw err;
}
},
enabled: id !== null,
staleTime: 30 * 1000,
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status === 404) return false;
return failureCount < 3;
},
});
if (id === null) {
return {
data: null,
isLoading: false,
isError: false,
error: null,
refetch: () => {},
};
}
return {
data: q.data ?? null,
isLoading: q.isLoading,
isError: q.isError,
error: q.error,
refetch: () => {
void q.refetch();
},
};
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
const HELP_NOOP = () => {};
export function Batches() {
const { data, isLoading, isError, error, refetch } = useBatches(100);
const items: BatchSummary[] = data ?? [];
const { batchId, open, close, setBatchId } = useBatchDrawerUrlState();
const { data: detail, isLoading: detailLoading, error: detailError, refetch: refetchDetail } =
useBatchDetail(batchId);
// j/k navigation: derive next/prev batch IDs based on the current
// batch's index in the loaded list. Wrap around at both ends. If
// the current batch isn't in the list, the callbacks are no-ops —
// defensive against bad input.
const { onNext, onPrev } = useMemo(() => {
if (batchId === null || items.length === 0) {
return { onNext: () => {}, onPrev: () => {} };
}
const idx = items.findIndex((b) => b.id === batchId);
if (idx === -1) return { onNext: () => {}, onPrev: () => {} };
return {
onNext: () => {
const nextId = items[(idx + 1) % items.length].id;
setBatchId(nextId);
},
onPrev: () => {
const prevId = items[(idx - 1 + items.length) % items.length].id;
setBatchId(prevId);
},
};
}, [batchId, items, setBatchId]);
useDrawerKeyboard({
enabled: batchId !== null,
onNext,
onPrev,
onClose: close,
// The Batches page doesn't ship a keyboard cheatsheet overlay —
// ? is silently swallowed here. Wiring it later is a no-op
// because useDrawerKeyboard only fires the callback.
onToggleHelp: HELP_NOOP,
});
const dimBackground = batchId !== null;
const errKind = batchErrorKind(detailError);
return (
<>
<Dialog
open={batchId !== null}
onOpenChange={(open) => {
if (!open) close();
}}
>
<DialogContent
// Right-anchored side panel, matching the claim-drawer's
// visual identity. We override the Dialog primitive's
// centered defaults so it behaves as a drawer (not a modal).
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-border bg-card p-0"
data-testid="batch-detail-drawer"
aria-describedby={undefined}
>
{batchId === null ? null : detailLoading ? (
<BatchDetailSkeleton />
) : errKind ? (
<BatchDetailError
kind={errKind}
onRetry={() => {
void refetchDetail();
}}
onClose={close}
/>
) : detail ? (
<BatchDetailContent data={detail} onClose={close} />
) : null}
</DialogContent>
</Dialog>
<div
data-testid="batches-page-body"
className={cn(
"space-y-8 animate-fade-in transition-opacity",
dimBackground && "pointer-events-none opacity-60",
)}
>
<header>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
Batches
</div>
<h1 className="text-[22px] font-semibold tracking-tight">
Parsed batches
</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Every 837P and 835 file the backend has ingested. Click a row
for envelope, summary, and a peek at the contained claims.
</p>
</header>
{isError ? (
<ErrorState
message="Couldn't load batches from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
/>
) : null}
<div className="surface rounded-xl overflow-hidden">
{isLoading ? (
<BatchesListSkeleton />
) : items.length === 0 ? (
<EmptyState
eyebrow="Batches · awaiting first ingest"
message="Upload an 837P or 835 file on the Upload page to populate this list."
/>
) : (
<BatchesList items={items} openId={batchId} onOpen={open} />
)}
</div>
</div>
</>
);
}