diff --git a/src/hooks/useDrawerUrlState.test.ts b/src/hooks/useDrawerUrlState.test.ts new file mode 100644 index 0000000..0699e37 --- /dev/null +++ b/src/hooks/useDrawerUrlState.test.ts @@ -0,0 +1,198 @@ +// @vitest-environment happy-dom +// Mirror the IS_REACT_ACT_ENVIRONMENT setup from the other hook tests 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 { useDrawerUrlState } from "./useDrawerUrlState"; + +/** + * Minimal renderHook shim — same pattern as `useClaimDetail.test.ts`. + * The project doesn't ship `@testing-library/react`, so we wire a Probe + * component into a real `createRoot` and read its return value through + * a shared `result` object. `act` flushes React's state updates between + * micro-tasks so assertions after a popstate dispatch see the new value. + */ +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 `/claims?claim=CLM-1` etc. + */ +function setLocation(url: string): void { + // happy-dom attaches the setURL helper under `window.happyDOM`. Cast + // through `unknown` to keep the test file free of happy-dom imports. + (window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url); +} + +describe("useDrawerUrlState", () => { + // `vi.fn()` with no args in vitest 4 is typed `Mock`, which carries a constructor signature and won't + // assign to `() => void`. Constraining the generic to `vi.fn<() => void>()` + // gives a plain function-typed mock that flows directly into `pushState` + // / `replaceState` slots. + let pushStateMock: ReturnType; + let replaceStateMock: ReturnType; + + beforeEach(() => { + pushStateMock = vi.fn(); + replaceStateMock = vi.fn(); + + // Replace `history` with a barebones stub. We only need pushState / + // replaceState (and a state slot for the History spec). `vi.stubGlobal` + // also affects `window.history` because happy-dom's window is the + // global object. + vi.stubGlobal("history", { + pushState: pushStateMock, + replaceState: replaceStateMock, + state: null, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + // Reset happy-dom's URL between tests so one test's `?claim=…` doesn't + // leak into the next test's "no param" assertion. + setLocation("http://localhost/"); + }); + + it("reads the ?claim= param from window.location.search on mount", () => { + setLocation("http://localhost/claims?claim=CLM-1"); + + const { result, unmount } = renderHook(() => useDrawerUrlState()); + + expect(result.current?.claimId).toBe("CLM-1"); + expect(typeof result.current?.open).toBe("function"); + expect(typeof result.current?.close).toBe("function"); + expect(typeof result.current?.setClaimId).toBe("function"); + + unmount(); + }); + + it("returns null claimId when no ?claim= param is set", () => { + // setLocation to a URL with no ?claim= (default afterLocation is + // bare, but afterEach resets it explicitly — be defensive). + setLocation("http://localhost/claims"); + + const { result, unmount } = renderHook(() => useDrawerUrlState()); + + expect(result.current?.claimId).toBeNull(); + + unmount(); + }); + + it("open(id) pushes a new history entry containing ?claim=id", () => { + setLocation("http://localhost/claims"); + + const { result, unmount } = renderHook(() => useDrawerUrlState()); + + act(() => { + result.current?.open("CLM-2"); + }); + + expect(pushStateMock).toHaveBeenCalledTimes(1); + expect(replaceStateMock).not.toHaveBeenCalled(); + // The third arg to pushState is the URL. The plan calls for a relative + // URL built from pathname+search+hash, so it should contain the + // ?claim= param. + const urlArg = pushStateMock.mock.calls[0][2] as string; + expect(urlArg).toContain("?claim=CLM-2"); + + unmount(); + }); + + it("setClaimId(id) replaces the current history entry (no new entry) and does NOT pushState", () => { + setLocation("http://localhost/claims?claim=CLM-1"); + + const { result, unmount } = renderHook(() => useDrawerUrlState()); + + act(() => { + result.current?.setClaimId("CLM-3"); + }); + + expect(replaceStateMock).toHaveBeenCalledTimes(1); + expect(pushStateMock).not.toHaveBeenCalled(); + const urlArg = replaceStateMock.mock.calls[0][2] as string; + expect(urlArg).toContain("?claim=CLM-3"); + + unmount(); + }); + + it("close() pushes a new history entry with the ?claim= param stripped", () => { + setLocation("http://localhost/claims?claim=CLM-1"); + + const { result, unmount } = renderHook(() => useDrawerUrlState()); + + act(() => { + result.current?.close(); + }); + + expect(pushStateMock).toHaveBeenCalledTimes(1); + expect(replaceStateMock).not.toHaveBeenCalled(); + const urlArg = pushStateMock.mock.calls[0][2] as string; + // The plan's spec: close strips the param. After close, the URL should + // not contain ?claim= (or contain it as empty — either way the param + // is effectively gone). Our implementation deletes the param entirely. + expect(urlArg).not.toContain("?claim="); + // claimId must also flip back to null synchronously after close. + expect(result.current?.claimId).toBeNull(); + + unmount(); + }); + + it("updates claimId in response to popstate (browser back/forward)", async () => { + // Start on CLM-1; simulate the user navigating forward to CLM-2 via + // browser history (back/forward fires popstate). The hook must pick up + // the new URL even though it didn't trigger the navigation itself. + setLocation("http://localhost/claims?claim=CLM-1"); + + const { result, unmount } = renderHook(() => useDrawerUrlState()); + + expect(result.current?.claimId).toBe("CLM-1"); + + // Simulate browser back/forward: the URL changes and popstate fires. + // happy-dom does not dispatch popstate automatically when setURL is + // called, so we change the URL and dispatch the event ourselves — the + // same shape a real browser would produce. + setLocation("http://localhost/claims?claim=CLM-2"); + await act(async () => { + window.dispatchEvent(new PopStateEvent("popstate")); + // Yield once so React's setState inside the listener flushes. + await Promise.resolve(); + }); + + expect(result.current?.claimId).toBe("CLM-2"); + + unmount(); + }); +}); \ No newline at end of file diff --git a/src/hooks/useDrawerUrlState.ts b/src/hooks/useDrawerUrlState.ts new file mode 100644 index 0000000..ed109f0 --- /dev/null +++ b/src/hooks/useDrawerUrlState.ts @@ -0,0 +1,104 @@ +import { useCallback, useEffect, useState } from "react"; + +/** + * Read the current `?claim=…` 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 claim IDs without surprises. + */ +function readClaimId(): string | null { + const params = new URLSearchParams(window.location.search); + return params.get("claim"); +} + +/** + * Build the URL we want to push/replace into history. + * + * - `claimId === null` → drop the `?claim=` param, preserving any + * other params (e.g. `?foo=bar` stays). + * - `claimId !== 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. `hash` is + * appended so a `#section` anchor survives navigation, matching what + * the browser would do natively. + */ +function buildUrl(claimId: string | null): string { + const url = new URL(window.location.href); + if (claimId === null) { + url.searchParams.delete("claim"); + } else { + url.searchParams.set("claim", claimId); + } + return url.pathname + url.search + url.hash; +} + +/** + * Per-claim detail drawer URL state (SP4). + * + * Reads `?claim=` from the URL on mount and keeps the value in sync with + * history as the drawer is opened, navigated (j/k), and closed. The + * drawer parent uses this hook as the single source of truth for "is a + * claim open and which one" — the URL is the durable record so that + * deep links (`/claims?claim=CLM-1`) restore the drawer state on reload. + * + * - `claimId`: 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 `?claim={id}` — so the + * browser Back button returns to the previous page (e.g. the claims + * list) and not just to the previously-open claim. + * - `setClaimId(id)`: REPLACES the current history entry — used by the + * j/k nav handler so j/k moves through the list without polluting + * history with one entry per keystroke. + * - `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. + * + * Implementation note: this is a single-operator local-only tool, so we + * call `history.pushState` / `replaceState` directly without try/catch + * wrappers — the only way they throw in modern browsers is when the URL + * is malformed, which we control. + */ +export function useDrawerUrlState(): { + claimId: string | null; + open: (id: string) => void; + close: () => void; + setClaimId: (id: string) => void; +} { + const [claimId, setClaimIdState] = useState(() => readClaimId()); + + const open = useCallback((id: string) => { + window.history.pushState(null, "", buildUrl(id)); + setClaimIdState(id); + }, []); + + const setClaimId = useCallback((id: string) => { + window.history.replaceState(null, "", buildUrl(id)); + setClaimIdState(id); + }, []); + + const close = useCallback(() => { + window.history.pushState(null, "", buildUrl(null)); + setClaimIdState(null); + }, []); + + useEffect(() => { + const onPopState = () => { + setClaimIdState(readClaimId()); + }; + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, []); + + return { claimId, open, close, setClaimId }; +} \ No newline at end of file