feat(drill): useProviderDrawerUrlState — provider drawer URL state (mirrors remit hook)

This commit is contained in:
Tyler
2026-06-21 14:32:22 -06:00
parent b6607b2009
commit 5b3b8619e6
2 changed files with 319 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
// @vitest-environment happy-dom
// Mirror the IS_REACT_ACT_ENVIRONMENT setup from useDrawerUrlState.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 { useProviderDrawerUrlState } from "./useProviderDrawerUrlState";
/**
* 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 `/providers?provider=NPI` etc.
*/
function setLocation(url: string): void {
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url);
}
describe("useProviderDrawerUrlState", () => {
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 ?provider= param from window.location.search on mount", () => {
setLocation("http://localhost/providers?provider=1881068062");
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
expect(result.current?.providerNpi).toBe("1881068062");
expect(typeof result.current?.open).toBe("function");
expect(typeof result.current?.close).toBe("function");
expect(typeof result.current?.setProviderNpi).toBe("function");
unmount();
});
it("returns null providerNpi when no ?provider= param is set", () => {
setLocation("http://localhost/providers");
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
expect(result.current?.providerNpi).toBeNull();
unmount();
});
it("returns null providerNpi when ?provider= is present but empty", () => {
setLocation("http://localhost/providers?provider=");
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
expect(result.current?.providerNpi).toBeNull();
unmount();
});
it("open(npi) pushes a new history entry containing ?provider=npi", () => {
setLocation("http://localhost/providers");
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
act(() => {
result.current?.open("1881068062");
});
expect(pushStateMock).toHaveBeenCalledTimes(1);
expect(replaceStateMock).not.toHaveBeenCalled();
const urlArg = pushStateMock.mock.calls[0][2] as string;
expect(urlArg).toContain("?provider=1881068062");
unmount();
});
it("setProviderNpi(npi) replaces the current history entry (no new entry) and does NOT pushState", () => {
setLocation("http://localhost/providers?provider=1881068062");
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
act(() => {
result.current?.setProviderNpi("1881068063");
});
expect(replaceStateMock).toHaveBeenCalledTimes(1);
expect(pushStateMock).not.toHaveBeenCalled();
const urlArg = replaceStateMock.mock.calls[0][2] as string;
expect(urlArg).toContain("?provider=1881068063");
unmount();
});
it("open() and close() preserve other query params (only ?provider= is touched)", () => {
setLocation("http://localhost/providers?sort=name&provider=1881068062");
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
act(() => {
result.current?.open("1881068063");
});
const openUrl = pushStateMock.mock.calls[0][2] as string;
expect(openUrl).toContain("sort=name");
expect(openUrl).toMatch(/[?&]provider=1881068063/);
act(() => {
result.current?.close();
});
const closeUrl = pushStateMock.mock.calls[1][2] as string;
expect(closeUrl).toContain("sort=name");
expect(closeUrl).not.toContain("provider=");
unmount();
});
it("close() pushes a new history entry with the ?provider= param stripped", () => {
setLocation("http://localhost/providers?provider=1881068062");
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
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("?provider=");
expect(result.current?.providerNpi).toBeNull();
unmount();
});
it("updates providerNpi in response to popstate (browser back/forward)", async () => {
setLocation("http://localhost/providers?provider=1881068062");
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
expect(result.current?.providerNpi).toBe("1881068062");
setLocation("http://localhost/providers?provider=1881068063");
await act(async () => {
window.dispatchEvent(new PopStateEvent("popstate"));
await Promise.resolve();
});
expect(result.current?.providerNpi).toBe("1881068063");
unmount();
});
it("does not collide with the existing ?claim= or ?remit= params (orthogonal keys)", () => {
// The providers drawer is independent of the claims and remits
// drawers — all three can be open simultaneously in the future, and
// the hooks must not stomp each other's URL state. Opening a
// provider must leave ?claim= and ?remit= intact.
setLocation("http://localhost/?claim=CLM-1&remit=REM-1");
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
act(() => {
result.current?.open("1881068062");
});
const openUrl = pushStateMock.mock.calls[0][2] as string;
expect(openUrl).toContain("claim=CLM-1");
expect(openUrl).toContain("remit=REM-1");
expect(openUrl).toMatch(/[?&]provider=1881068062/);
unmount();
});
});
+102
View File
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useState } from "react";
/**
* Read the current `?provider=…` 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 NPIs without surprises.
*
* Param name is `?provider=` — chosen to mirror the existing
* `?provider=NPI` drilldown convention used by the dashboard's
* "Top providers" row (so deep links survive across the app) and to
* stay alphabetically parallel with `?claim=` and `?remit=` (one-word
* tokens, no collision with the existing `MatchedProviderCard`).
*/
function readProviderNpi(): string | null {
const params = new URLSearchParams(window.location.search);
const value = params.get("provider");
return value === "" ? null : value;
}
/**
* Build the URL we want to push/replace into history.
*
* - `providerNpi === null` → drop the `?provider=` param, preserving
* any other params (e.g. `?page=2&provider=…` keeps `page=2`).
* - `providerNpi !== null` → set the param to the new NPI, 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(providerNpi: string | null): string {
const url = new URL(window.location.href);
if (providerNpi === null) {
url.searchParams.delete("provider");
} else {
url.searchParams.set("provider", providerNpi);
}
return url.pathname + url.search + url.hash;
}
/**
* Per-provider detail drawer URL state (ProviderDrawer).
*
* Mirrors `useRemitDrawerUrlState` but for the providers drawer — reads
* `?provider=` from the URL on mount and keeps the value in sync with
* history as the drawer is opened, navigated (j/k), and closed.
*
* - `providerNpi`: the NPI parsed from the URL (or `null` when the
* param is absent). React state so consumers re-render on changes.
* - `open(npi)`: pushes a NEW history entry with `?provider={npi}` —
* so the browser Back button returns to the previous page (e.g. the
* providers list) and not just to the previously-open provider.
* - `setProviderNpi(npi)`: 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 NPI.
*/
export function useProviderDrawerUrlState(): {
providerNpi: string | null;
open: (npi: string) => void;
close: () => void;
setProviderNpi: (npi: string) => void;
} {
const [providerNpi, setProviderNpiState] = useState<string | null>(() => readProviderNpi());
const open = useCallback((npi: string) => {
window.history.pushState(null, "", buildUrl(npi));
setProviderNpiState(npi);
}, []);
const setProviderNpi = useCallback((npi: string) => {
window.history.replaceState(null, "", buildUrl(npi));
setProviderNpiState(npi);
}, []);
const close = useCallback(() => {
window.history.pushState(null, "", buildUrl(null));
setProviderNpiState(null);
}, []);
useEffect(() => {
const onPopState = () => {
setProviderNpiState(readProviderNpi());
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
return { providerNpi, open, close, setProviderNpi };
}