From 6fdbceefc25d72c8e53bbcd661f2bb4ef3cd9dae Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 21 Jun 2026 17:24:23 -0600 Subject: [PATCH] =?UTF-8?q?feat(drill):=20useAckDrawerUrlState=20=E2=80=94?= =?UTF-8?q?=20=3Fack=3D=20URL=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useAckDrawerUrlState.test.ts | 219 +++++++++++++++++++++++++ src/hooks/useAckDrawerUrlState.ts | 105 ++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 src/hooks/useAckDrawerUrlState.test.ts create mode 100644 src/hooks/useAckDrawerUrlState.ts diff --git a/src/hooks/useAckDrawerUrlState.test.ts b/src/hooks/useAckDrawerUrlState.test.ts new file mode 100644 index 0000000..1246622 --- /dev/null +++ b/src/hooks/useAckDrawerUrlState.test.ts @@ -0,0 +1,219 @@ +// @vitest-environment happy-dom +// Mirror the IS_REACT_ACT_ENVIRONMENT setup from useProviderDrawerUrlState.test.ts +// so React doesn't log act() warnings about the createRoot render/unmount +// and the popstate-driven state updates. +(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 { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { useAckDrawerUrlState } from "./useAckDrawerUrlState"; + +/** + * Minimal renderHook shim — same pattern as the other hook tests. + */ +function renderHook(setup: () => TResult): { + result: { current: TResult | undefined }; + unmount: () => void; +} { + const result: { current: TResult | undefined } = { current: undefined }; + const container = document.createElement("div"); + document.body.appendChild(container); + + function Probe() { + result.current = setup(); + return null; + } + + const root: Root = createRoot(container); + act(() => { + root.render(React.createElement(Probe)); + }); + + return { + result, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +/** + * Point happy-dom's URL at a known value. happy-dom v20 doesn't expose a + * writable `window.location.search`, but `window.happyDOM.setURL` updates + * the URL the window reports without triggering a navigation — exactly + * what we want for mounting the hook at `/acks?ack=42` etc. + */ +function setLocation(url: string): void { + (window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url); +} + +describe("useAckDrawerUrlState", () => { + type PushState = (state: unknown, unused: string, url?: string | URL | null) => void; + let pushStateMock: ReturnType>; + let replaceStateMock: ReturnType>; + + beforeEach(() => { + pushStateMock = vi.fn(); + replaceStateMock = vi.fn(); + + vi.stubGlobal("history", { + pushState: pushStateMock, + replaceState: replaceStateMock, + state: null, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + setLocation("http://localhost/"); + }); + + it("reads the ?ack= param from window.location.search on mount", () => { + setLocation("http://localhost/acks?ack=42"); + + const { result, unmount } = renderHook(() => useAckDrawerUrlState()); + + expect(result.current?.ackId).toBe("42"); + expect(typeof result.current?.open).toBe("function"); + expect(typeof result.current?.close).toBe("function"); + expect(typeof result.current?.setAckId).toBe("function"); + + unmount(); + }); + + it("returns null ackId when no ?ack= param is set", () => { + setLocation("http://localhost/acks"); + + const { result, unmount } = renderHook(() => useAckDrawerUrlState()); + + expect(result.current?.ackId).toBeNull(); + + unmount(); + }); + + it("returns null ackId when ?ack= is present but empty", () => { + setLocation("http://localhost/acks?ack="); + + const { result, unmount } = renderHook(() => useAckDrawerUrlState()); + + expect(result.current?.ackId).toBeNull(); + + unmount(); + }); + + it("open(id) pushes a new history entry containing ?ack=ID", () => { + setLocation("http://localhost/acks"); + + const { result, unmount } = renderHook(() => useAckDrawerUrlState()); + + act(() => { + result.current?.open("42"); + }); + + expect(pushStateMock).toHaveBeenCalledTimes(1); + expect(replaceStateMock).not.toHaveBeenCalled(); + const urlArg = pushStateMock.mock.calls[0][2] as string; + expect(urlArg).toContain("?ack=42"); + + unmount(); + }); + + it("setAckId(id) replaces the current history entry (no new entry) and does NOT pushState", () => { + setLocation("http://localhost/acks?ack=42"); + + const { result, unmount } = renderHook(() => useAckDrawerUrlState()); + + act(() => { + result.current?.setAckId("43"); + }); + + expect(replaceStateMock).toHaveBeenCalledTimes(1); + expect(pushStateMock).not.toHaveBeenCalled(); + const urlArg = replaceStateMock.mock.calls[0][2] as string; + expect(urlArg).toContain("?ack=43"); + + unmount(); + }); + + it("open() and close() preserve other query params (only ?ack= is touched)", () => { + setLocation("http://localhost/acks?sort=date&ack=42"); + + const { result, unmount } = renderHook(() => useAckDrawerUrlState()); + + act(() => { + result.current?.open("43"); + }); + const openUrl = pushStateMock.mock.calls[0][2] as string; + expect(openUrl).toContain("sort=date"); + expect(openUrl).toMatch(/[?&]ack=43/); + + act(() => { + result.current?.close(); + }); + const closeUrl = pushStateMock.mock.calls[1][2] as string; + expect(closeUrl).toContain("sort=date"); + expect(closeUrl).not.toContain("ack="); + + unmount(); + }); + + it("close() pushes a new history entry with the ?ack= param stripped", () => { + setLocation("http://localhost/acks?ack=42"); + + const { result, unmount } = renderHook(() => useAckDrawerUrlState()); + + act(() => { + result.current?.close(); + }); + + expect(pushStateMock).toHaveBeenCalledTimes(1); + expect(replaceStateMock).not.toHaveBeenCalled(); + const urlArg = pushStateMock.mock.calls[0][2] as string; + expect(urlArg).not.toContain("?ack="); + expect(result.current?.ackId).toBeNull(); + + unmount(); + }); + + it("updates ackId in response to popstate (browser back/forward)", async () => { + setLocation("http://localhost/acks?ack=42"); + + const { result, unmount } = renderHook(() => useAckDrawerUrlState()); + + expect(result.current?.ackId).toBe("42"); + + setLocation("http://localhost/acks?ack=43"); + await act(async () => { + window.dispatchEvent(new PopStateEvent("popstate")); + await Promise.resolve(); + }); + + expect(result.current?.ackId).toBe("43"); + + unmount(); + }); + + it("does not collide with the existing ?claim=, ?remit=, or ?provider= params (orthogonal keys)", () => { + // The acks drawer is independent of the claim/remit/provider + // drawers — opening an ack must leave the others intact so a user + // can deep-link to multiple states simultaneously (though the UI + // currently only shows one drawer at a time, the URL params don't + // know that). + setLocation("http://localhost/?claim=CLM-1&remit=REM-1&provider=1881068062"); + + const { result, unmount } = renderHook(() => useAckDrawerUrlState()); + + act(() => { + result.current?.open("42"); + }); + const openUrl = pushStateMock.mock.calls[0][2] as string; + expect(openUrl).toContain("claim=CLM-1"); + expect(openUrl).toContain("remit=REM-1"); + expect(openUrl).toContain("provider=1881068062"); + expect(openUrl).toMatch(/[?&]ack=42/); + + unmount(); + }); +}); diff --git a/src/hooks/useAckDrawerUrlState.ts b/src/hooks/useAckDrawerUrlState.ts new file mode 100644 index 0000000..2a8b142 --- /dev/null +++ b/src/hooks/useAckDrawerUrlState.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useState } from "react"; + +/** + * Read the current `?ack=…` query param off `window.location.search`. + * Returns `null` when the param is absent or empty. + * + * `URLSearchParams` is the standard, locale-free way to parse query + * strings in the browser. Using it (rather than hand-rolled string + * slicing) means we correctly handle multiple params and percent-encoded + * characters in ack ids without surprises. + * + * Param name is `?ack=` — chosen to mirror the existing `?provider=…`, + * `?claim=…`, and `?remit=…` drilldown conventions (one-word token, + * alphabetical brevity, no collision with the existing `Acks` table + * columns). The id value is treated as a string in the URL so deep + * links (`/acks?ack=42`) round-trip identically across the app, even + * though the backend `Ack.id` is a numeric — `AckDrawer` does the + * `Number()` coercion when calling `api.getAck`. + */ +function readAckId(): string | null { + const params = new URLSearchParams(window.location.search); + const value = params.get("ack"); + return value === "" ? null : value; +} + +/** + * Build the URL we want to push/replace into history. + * + * - `ackId === null` → drop the `?ack=` param, preserving any + * other params (e.g. `?page=2&ack=…` keeps `page=2`). + * - `ackId !== null` → set the param to the new id, also preserving + * any other params. + * + * We return `pathname + search + hash` (a relative URL) rather than the + * full href — `history.pushState` accepts a relative URL and rewriting + * only the relative form keeps the document's origin stable. + */ +function buildUrl(ackId: string | null): string { + const url = new URL(window.location.href); + if (ackId === null) { + url.searchParams.delete("ack"); + } else { + url.searchParams.set("ack", ackId); + } + return url.pathname + url.search + url.hash; +} + +/** + * Per-ack detail drawer URL state (AckDrawer). + * + * Mirrors `useProviderDrawerUrlState` / `useRemitDrawerUrlState` but + * for the 999-ACK drawer — reads `?ack=` from the URL on mount and + * keeps the value in sync with history as the drawer is opened and + * closed. + * + * - `ackId`: the id parsed from the URL (or `null` when the param + * is absent). React state so consumers re-render on changes. + * - `open(id)`: pushes a NEW history entry with `?ack={id}` — so the + * browser Back button returns to the previous page (e.g. the + * acks list) and not just to the previously-open ack. + * - `setAckId(id)`: REPLACES the current history entry — kept for + * symmetry with the other drawer hooks so a future j/k nav + * handler doesn't have to special-case ack ids. + * - `close()`: pushes a NEW entry that strips the param, so Back + * from the closed drawer returns to whatever page the user was + * on before opening the drawer. + * + * The hook subscribes to `popstate` so that browser Back/Forward + * (which fire popstate rather than our own pushState) propagate into + * the React state. Without this, hitting Back would change the URL + * but leave the drawer open on the stale id. + */ +export function useAckDrawerUrlState(): { + ackId: string | null; + open: (id: string) => void; + close: () => void; + setAckId: (id: string) => void; +} { + const [ackId, setAckIdState] = useState(() => readAckId()); + + const open = useCallback((id: string) => { + window.history.pushState(null, "", buildUrl(id)); + setAckIdState(id); + }, []); + + const setAckId = useCallback((id: string) => { + window.history.replaceState(null, "", buildUrl(id)); + setAckIdState(id); + }, []); + + const close = useCallback(() => { + window.history.pushState(null, "", buildUrl(null)); + setAckIdState(null); + }, []); + + useEffect(() => { + const onPopState = () => { + setAckIdState(readAckId()); + }; + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, []); + + return { ackId, open, close, setAckId }; +}