content-explorer/ui/components/RecordBrowser.history.test.tsx
tegwick 0cbc7baf49 Keep search, facets, and sort in the page URL.
CONTENT-WP-0002-T04 writes q, facet values, and sort into history so back and forward restore the table.

Assistant: grok
Assistant-Session: 01a1346c-fdff-7741-b1af-b7283661554b
2026-10-13 23:12:00 +02:00

168 lines
5.1 KiB
TypeScript

// @vitest-environment happy-dom
import { createRoot, type Root } from "react-dom/client";
import { act } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { mapBrowserRow, type ColumnDef } from "@/lib/browser-row";
import { browserViewHistory, type ViewHistory } from "@/lib/table-view";
import { RecordBrowser } from "./RecordBrowser";
declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean;
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const columns: ColumnDef[] = [
{ key: "name", label: "Name" },
{ key: "tldr", label: "Summary" },
];
const rows = [
mapBrowserRow(
{ id: "a", title: "Alpha", tldr: "First", tags: ["one"], kindLabel: "Concept", route: "/a/", kindHref: "/concept/" },
["tags"],
),
mapBrowserRow(
{ id: "z", title: "Zebra", tldr: "Last", tags: ["two"], kindLabel: "Concept", route: "/z/", kindHref: "/concept/" },
["tags"],
),
];
let root: Root | null = null;
afterEach(() => {
act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
});
function names(): string[] {
return [...document.querySelectorAll("tbody tr td:first-child")].map((cell) => cell.textContent ?? "");
}
function buttonNamed(label: string): HTMLButtonElement {
const node = [...document.querySelectorAll("button")].find((item) => item.textContent?.includes(label));
if (!(node instanceof HTMLButtonElement)) throw new Error(`missing ${label}`);
return node;
}
/** Same-document session history: back and forward keep this page and report the new query. */
function sessionHistory(): ViewHistory & { back: () => void; forward: () => void; location: () => string } {
const stack = [""];
let index = 0;
const listeners = new Set<() => void>();
const notify = () => {
for (const listener of listeners) listener();
};
return {
read: () => (stack[index] ? `?${stack[index]}` : ""),
push: (search) => {
stack.splice(index + 1);
stack.push(search);
index = stack.length - 1;
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
back: () => {
if (index === 0) return;
index -= 1;
notify();
},
forward: () => {
if (index >= stack.length - 1) return;
index += 1;
notify();
},
location: () => stack[index],
};
}
describe("record browser history", () => {
it("reads a pushed query and a popstate traversal", () => {
window.history.replaceState(null, "", "/records/");
const history = browserViewHistory();
const seen: string[] = [];
const stop = history.subscribe(() => seen.push(history.read()));
history.push("q=zebra");
expect(window.location.search).toBe("?q=zebra");
expect(seen).toEqual([]);
window.history.pushState(null, "", "/records/?tags=one");
window.dispatchEvent(new PopStateEvent("popstate"));
expect(seen).toEqual(["?tags=one"]);
stop();
});
it("restores search, facets, and sort on back and forward", async () => {
const history = sessionHistory();
const host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
await act(async () => {
root?.render(<RecordBrowser rows={rows} columns={columns} history={history} />);
});
expect(names()).toEqual(["Alpha", "Zebra"]);
const input = document.querySelector("input[aria-label='Search records']");
if (!(input instanceof HTMLInputElement)) throw new Error("missing search");
await act(async () => {
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setValue?.call(input, "zebra");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
input.form?.requestSubmit();
});
expect(history.location()).toContain("q=zebra");
expect(names()).toEqual(["Zebra"]);
await act(async () => {
buttonNamed("two").click();
});
expect(history.location()).toContain("tags=two");
expect(buttonNamed("two").getAttribute("aria-pressed")).toBe("true");
await act(async () => {
buttonNamed("Name").click();
});
expect(history.location()).toContain("sort=-name");
expect(names()).toEqual(["Zebra"]);
await act(async () => {
history.back();
});
expect(history.location()).not.toContain("sort=");
expect(history.location()).toContain("tags=two");
await act(async () => {
history.back();
});
expect(history.location()).toBe("q=zebra");
expect(buttonNamed("two").getAttribute("aria-pressed")).toBe("false");
expect(names()).toEqual(["Zebra"]);
await act(async () => {
history.back();
});
expect(history.location()).toBe("");
expect(names()).toEqual(["Alpha", "Zebra"]);
await act(async () => {
history.forward();
});
expect(history.location()).toBe("q=zebra");
expect(names()).toEqual(["Zebra"]);
await act(async () => {
history.forward();
});
expect(history.location()).toContain("tags=two");
await act(async () => {
history.forward();
});
expect(history.location()).toContain("sort=-name");
expect(names()).toEqual(["Zebra"]);
});
});