feat(frontend): useDrawerUrlState hook for ?claim= sync
This commit is contained in:
@@ -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<TResult>(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<Procedure |
|
||||
// Constructable>`, 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<typeof vi.fn>;
|
||||
let replaceStateMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(() => 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user