feat(drill): useAckDrawerUrlState — ?ack= URL sync
This commit is contained in:
@@ -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<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 `/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<typeof vi.fn<PushState>>;
|
||||
let replaceStateMock: ReturnType<typeof vi.fn<PushState>>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(() => 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user