feat(acks): row click opens AckDrawer
This commit is contained in:
+121
-2
@@ -9,13 +9,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Acks } from "./Acks";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
isConfigured: true,
|
||||
listAcks: vi.fn(),
|
||||
getAck: vi.fn(),
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
function renderIntoContainer(element: React.ReactElement): {
|
||||
container: HTMLDivElement;
|
||||
@@ -105,6 +109,10 @@ function hasExactlyOneSelectedRow(): boolean {
|
||||
describe("Acks", () => {
|
||||
beforeEach(() => {
|
||||
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 () => {
|
||||
@@ -448,4 +456,115 @@ describe("Acks", () => {
|
||||
|
||||
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
@@ -12,6 +12,8 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ErrorState } from "@/components/ui/error-state";
|
||||
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
|
||||
import { AckDrawer } from "@/components/AckDrawer";
|
||||
import { useAckDrawerUrlState } from "@/hooks/useAckDrawerUrlState";
|
||||
import { useAcks } from "@/hooks/useAcks";
|
||||
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -91,7 +93,14 @@ function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: stri
|
||||
return (
|
||||
<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}
|
||||
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",
|
||||
@@ -113,6 +122,10 @@ function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: stri
|
||||
export function Acks() {
|
||||
const { data, isLoading, isError, error, refetch } = useAcks({ limit: 100 });
|
||||
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 [helpOpen, setHelpOpen] = useState(false);
|
||||
@@ -172,6 +185,12 @@ export function Acks() {
|
||||
open={helpOpen}
|
||||
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">
|
||||
{/* =================================================================
|
||||
HERO — DARK EDITORIAL HEADER
|
||||
@@ -650,7 +669,9 @@ export function Acks() {
|
||||
data-row-index={idx}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
aria-selected={isSelected}
|
||||
onClick={() => open(String(a.id))}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
isSelected && [
|
||||
"ring-1 ring-inset",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user