feat(acks): row click opens AckDrawer

This commit is contained in:
Tyler
2026-06-21 17:28:22 -06:00
parent 33fa899217
commit 5053a1ea8e
2 changed files with 148 additions and 8 deletions
+126 -7
View File
@@ -9,13 +9,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Acks } from "./Acks"; import { Acks } from "./Acks";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
vi.mock("@/lib/api", () => ({ vi.mock("@/lib/api", async (importOriginal) => {
api: { const actual = await importOriginal();
isConfigured: true, return {
listAcks: vi.fn(), ...actual,
getAck: vi.fn(), api: {
}, isConfigured: true,
})); listAcks: vi.fn(),
getAck: vi.fn(),
},
};
});
function renderIntoContainer(element: React.ReactElement): { function renderIntoContainer(element: React.ReactElement): {
container: HTMLDivElement; container: HTMLDivElement;
@@ -105,6 +109,10 @@ function hasExactlyOneSelectedRow(): boolean {
describe("Acks", () => { describe("Acks", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Reset URL state between tests so a previous `?ack=` doesn't leak.
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
"http://localhost/acks"
);
}); });
it("renders a single ack row with counts and ack code", async () => { it("renders a single ack row with counts and ack code", async () => {
@@ -448,4 +456,115 @@ describe("Acks", () => {
unmount(); unmount();
}); });
it("test_clicking_a_row_opens_the_ack_drawer", async () => {
// SP21 Phase 5 Task 5.3: clicking an acks row drills into the
// matching ack via `?ack=ID` URL state. The AckDrawer mounts
// but the actual content depends on `useAckDetail` — we don't
// need to verify drawer internals here, just that the URL got
// pushed and the drawer portal opens.
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 42,
sourceBatchId: "b-uuid-1",
acceptedCount: 3,
rejectedCount: 1,
receivedCount: 4,
ackCode: "P",
parsedAt: "2026-06-20T12:00:00Z",
},
],
total: 1,
returned: 1,
has_more: false,
});
// Stub the per-ack fetch so `useAckDetail` resolves cleanly
// (avoids TanStack Query's "Query data cannot be undefined"
// warning). We only assert on the drawer's presence, so the
// shape doesn't need to be precise.
(api.getAck as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 42,
sourceBatchId: "b-uuid-1",
acceptedCount: 3,
rejectedCount: 1,
receivedCount: 4,
ackCode: "P",
parsedAt: "2026-06-20T12:00:00Z",
raw_999_text: "ISA*~\n",
});
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("b-uuid-1");
// No drawer yet.
expect(
document.body.querySelector('[data-testid="ack-drawer"]')
).toBeNull();
// Click the row.
const row = rowAt(0);
expect(row).not.toBeNull();
await act(async () => {
row!.click();
await Promise.resolve();
});
await settle(
() => document.body.querySelector('[data-testid="ack-drawer"]') !== null
);
// The drawer is now in the DOM.
expect(
document.body.querySelector('[data-testid="ack-drawer"]')
).not.toBeNull();
unmount();
});
it("test_deep_link_with_ack_param_opens_drawer_on_mount", async () => {
// /acks?ack=42 deep link → drawer opens on mount without a click.
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 42,
sourceBatchId: "b-uuid-1",
acceptedCount: 3,
rejectedCount: 1,
receivedCount: 4,
ackCode: "P",
parsedAt: "2026-06-20T12:00:00Z",
},
],
total: 1,
returned: 1,
has_more: false,
});
(api.getAck as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 42,
sourceBatchId: "b-uuid-1",
acceptedCount: 3,
rejectedCount: 1,
receivedCount: 4,
ackCode: "P",
parsedAt: "2026-06-20T12:00:00Z",
raw_999_text: "ISA*~\n",
});
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
"http://localhost/acks?ack=42"
);
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("b-uuid-1");
await settle(
() => document.body.querySelector('[data-testid="ack-drawer"]') !== null
);
// The drawer is in the DOM on first render.
expect(
document.body.querySelector('[data-testid="ack-drawer"]')
).not.toBeNull();
unmount();
});
}); });
+22 -1
View File
@@ -12,6 +12,8 @@ import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state"; import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state"; import { ErrorState } from "@/components/ui/error-state";
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet"; import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
import { AckDrawer } from "@/components/AckDrawer";
import { useAckDrawerUrlState } from "@/hooks/useAckDrawerUrlState";
import { useAcks } from "@/hooks/useAcks"; import { useAcks } from "@/hooks/useAcks";
import { useRowKeyboard } from "@/hooks/useRowKeyboard"; import { useRowKeyboard } from "@/hooks/useRowKeyboard";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@@ -91,7 +93,14 @@ function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: stri
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={(e) => {
// The row's onClick drills into AckDrawer; this button is a
// nested control and we want the click to NOT bubble into the
// row's onClick handler (matches the precedent set by
// DrillableCell onClick in `src/components/drill/DrillableCell.tsx:39`).
e.stopPropagation();
onClick();
}}
disabled={busy} disabled={busy}
className={cn( className={cn(
"inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 mono text-[10.5px] uppercase tracking-[0.14em] font-semibold transition-colors", "inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 mono text-[10.5px] uppercase tracking-[0.14em] font-semibold transition-colors",
@@ -113,6 +122,10 @@ function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: stri
export function Acks() { export function Acks() {
const { data, isLoading, isError, error, refetch } = useAcks({ limit: 100 }); const { data, isLoading, isError, error, refetch } = useAcks({ limit: 100 });
const items = data?.items ?? []; const items = data?.items ?? [];
// SP21 Phase 5 Task 5.3: drill-down from an acks row into
// AckDrawer. The hook reads `?ack=` off `window.location.search`
// so deep links restore the open ack on reload.
const { ackId, open, close } = useAckDrawerUrlState();
const [selectedIndex, setSelectedIndex] = useState<number | null>(null); const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [helpOpen, setHelpOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false);
@@ -172,6 +185,12 @@ export function Acks() {
open={helpOpen} open={helpOpen}
onClose={() => setHelpOpen(false)} onClose={() => setHelpOpen(false)}
/> />
{/* SP21 Phase 5 Task 5.3: AckDrawer mount. Row click drills
into the matching ack; the drawer portals into document.body
(Radix Dialog), so the surrounding paper plane stays put
while the drawer is open. Deep links via /acks?ack=ID
restore the open ack on reload. */}
<AckDrawer ackId={ackId} onClose={close} />
<div className="space-y-0"> <div className="space-y-0">
{/* ================================================================= {/* =================================================================
HERO — DARK EDITORIAL HEADER HERO — DARK EDITORIAL HEADER
@@ -650,7 +669,9 @@ export function Acks() {
data-row-index={idx} data-row-index={idx}
data-state={isSelected ? "selected" : undefined} data-state={isSelected ? "selected" : undefined}
aria-selected={isSelected} aria-selected={isSelected}
onClick={() => open(String(a.id))}
className={cn( className={cn(
"cursor-pointer",
isSelected && [ isSelected && [
"ring-1 ring-inset", "ring-1 ring-inset",
], ],