feat(batch-diff): claim ids drillable to /claims?claim=ID

This commit is contained in:
Tyler
2026-06-21 17:43:52 -06:00
parent ac87ed4908
commit 5c7e9b6168
2 changed files with 198 additions and 5 deletions
+22 -5
View File
@@ -5,6 +5,7 @@ import {
Plus,
type LucideIcon,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -15,6 +16,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { DrillableCell } from "@/components/drill/DrillableCell";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type {
@@ -263,13 +265,28 @@ function RowIndicator({
// ---------------------------------------------------------------------------
function ClaimIdCell({ id }: { id: string }) {
// SP21 Phase 5 Task 5.6: each claim id is drillable to
// /claims?claim=ID so the operator can jump straight from a
// "Removed from A" or "Changed" row into the ClaimDrawer.
// DrillableCell handles e.stopPropagation internally — important
// if these rows ever get a row-level onClick. For removed claims
// that no longer exist in the DB, the ClaimDrawer's 404 state
// takes over (verified in Phase 2 testing).
const navigate = useNavigate();
return (
<span
className="font-mono text-[12px] tracking-tight"
data-testid="diff-claim-id"
<DrillableCell
onClick={() =>
navigate(`/claims?claim=${encodeURIComponent(id)}`)
}
ariaLabel={`View claim ${id} in detail`}
>
{id}
</span>
<span
className="font-mono text-[12px] tracking-tight"
data-testid="diff-claim-id"
>
{id}
</span>
</DrillableCell>
);
}
+176
View File
@@ -481,8 +481,184 @@ describe("BatchDiff page", () => {
).toContain("pick a batch");
unmount();
});
it("SP21 Task 5.6: clicking an added-claim id navigates to /claims?claim=ID", async () => {
// Each ClaimIdCell wraps its id text with DrillableCell, whose
// onClick navigates to /claims?claim=ID. The MemoryRouter gives
// us a router context so the navigation completes; we observe
// it via the rendered pathname on a LocationTracker spy.
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
makeDiffPayload(),
);
const captured: { pathname: string; search: string } = {
pathname: "/batch-diff",
search: "",
};
const Tracker = () => {
const loc = useLocationSafe();
React.useEffect(() => {
captured.pathname = loc.pathname;
captured.search = loc.search;
}, [loc.pathname, loc.search]);
return null;
};
const { unmount } = renderIntoContainer(
<>
<Tracker />
<BatchDiff />
</>,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
await waitFor(
() => !!document.querySelector('[data-testid="diff-added-row-CLM-3"]'),
"added row rendered",
);
const cell = document.querySelector(
'[data-testid="diff-added-row-CLM-3"] [data-testid="diff-claim-id"]',
);
expect(cell).not.toBeNull();
// DrillableCell wraps the id text in its own <button>; click the
// nearest button ancestor so the drillable onClick fires.
const drillBtn =
(cell?.closest("button") as HTMLButtonElement | null) ?? null;
expect(drillBtn).not.toBeNull();
await act(async () => {
drillBtn!.click();
await Promise.resolve();
});
await waitFor(
() => captured.pathname === "/claims" && captured.search === "?claim=CLM-3",
"navigation to /claims?claim=CLM-3",
);
unmount();
});
it("SP21 Task 5.6: clicking a removed-claim id still navigates (ClaimDrawer handles 404)", async () => {
// The diff can list claim ids that no longer exist in the DB
// (Removed from A means "was in A, not in B" — the claim may
// still exist or may have been purged). Either way, clicking
// the id drills to /claims?claim=ID; the ClaimDrawer's 404
// state (Phase 2) handles the missing-claim case. This test
// only verifies the click path, not the drawer's 404 surface.
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
makeDiffPayload(),
);
const captured: { pathname: string; search: string } = {
pathname: "/batch-diff",
search: "",
};
const Tracker = () => {
const loc = useLocationSafe();
React.useEffect(() => {
captured.pathname = loc.pathname;
captured.search = loc.search;
}, [loc.pathname, loc.search]);
return null;
};
const { unmount } = renderIntoContainer(
<>
<Tracker />
<BatchDiff />
</>,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
await waitFor(
() => !!document.querySelector('[data-testid="diff-removed-row-CLM-2"]'),
"removed row rendered",
);
const cell = document.querySelector(
'[data-testid="diff-removed-row-CLM-2"] [data-testid="diff-claim-id"]',
);
const drillBtn =
(cell?.closest("button") as HTMLButtonElement | null) ?? null;
expect(drillBtn).not.toBeNull();
await act(async () => {
drillBtn!.click();
await Promise.resolve();
});
await waitFor(
() => captured.pathname === "/claims" && captured.search === "?claim=CLM-2",
"navigation to /claims?claim=CLM-2",
);
unmount();
});
it("SP21 Task 5.6: clicking a changed-claim id navigates to /claims?claim=ID", async () => {
// The Changed row uses the A-side id (per existing test
// assertions). Drill should fire on that id.
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
makeDiffPayload(),
);
const captured: { pathname: string; search: string } = {
pathname: "/batch-diff",
search: "",
};
const Tracker = () => {
const loc = useLocationSafe();
React.useEffect(() => {
captured.pathname = loc.pathname;
captured.search = loc.search;
}, [loc.pathname, loc.search]);
return null;
};
const { unmount } = renderIntoContainer(
<>
<Tracker />
<BatchDiff />
</>,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
await waitFor(
() => !!document.querySelector('[data-testid="diff-changed-row-CLM-1"]'),
"changed row rendered",
);
const cell = document.querySelector(
'[data-testid="diff-changed-row-CLM-1"] [data-testid="diff-claim-id"]',
);
const drillBtn =
(cell?.closest("button") as HTMLButtonElement | null) ?? null;
expect(drillBtn).not.toBeNull();
await act(async () => {
drillBtn!.click();
await Promise.resolve();
});
await waitFor(
() => captured.pathname === "/claims" && captured.search === "?claim=CLM-1",
"navigation to /claims?claim=CLM-1",
);
unmount();
});
});
// SP21 Phase 5 Task 5.6: react-router-dom's `useLocation` hook, used
// by the LocationTracker test helper below. Aliased to keep the import
// block tidy alongside React + the page import.
import { useLocation as useLocationSafe } from "react-router-dom";
// Keep `ApiError` referenced so the import isn't tree-shaken by
// vitest's transformer when the mock factory above is hoisted.
void ApiError;