diff --git a/src/hooks/useProviderDrawerUrlState.test.ts b/src/hooks/useProviderDrawerUrlState.test.ts new file mode 100644 index 0000000..3e9ad7f --- /dev/null +++ b/src/hooks/useProviderDrawerUrlState.test.ts @@ -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(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>; + 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 ?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(); + }); +}); \ No newline at end of file diff --git a/src/hooks/useProviderDrawerUrlState.ts b/src/hooks/useProviderDrawerUrlState.ts new file mode 100644 index 0000000..e9b3112 --- /dev/null +++ b/src/hooks/useProviderDrawerUrlState.ts @@ -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(() => 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 }; +} \ No newline at end of file