feat(acks): row click opens AckDrawer
This commit is contained in:
+126
-7
@@ -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
@@ -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",
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user