From 0cbc7baf49b230b41fe395802ecccf8f08cfab97 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 13 Oct 2026 23:12:00 +0200 Subject: [PATCH] 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 --- ui/components/RecordBrowser.history.test.tsx | 168 ++++++++++++++++++ ui/components/RecordBrowser.tsx | 98 +++++++++- ui/lib/table-view.test.ts | 59 ++++++ ui/lib/table-view.ts | 113 ++++++++++++ ui/package-lock.json | 52 ++++++ ui/package.json | 1 + workplans/CONTENT-WP-0002-ui-extraction.md | 4 +- .../CONTENT-WP-0002-ImplementationNotes.md | 17 +- 8 files changed, 503 insertions(+), 9 deletions(-) create mode 100644 ui/components/RecordBrowser.history.test.tsx create mode 100644 ui/lib/table-view.test.ts create mode 100644 ui/lib/table-view.ts diff --git a/ui/components/RecordBrowser.history.test.tsx b/ui/components/RecordBrowser.history.test.tsx new file mode 100644 index 0000000..e1e7b88 --- /dev/null +++ b/ui/components/RecordBrowser.history.test.tsx @@ -0,0 +1,168 @@ +// @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(); + }); + 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"]); + }); +}); diff --git a/ui/components/RecordBrowser.tsx b/ui/components/RecordBrowser.tsx index 448ca24..57439b7 100644 --- a/ui/components/RecordBrowser.tsx +++ b/ui/components/RecordBrowser.tsx @@ -1,40 +1,126 @@ "use client"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { renderCell, type CellRenderer } from "@/components/cell-renderers"; import { ResultsTable } from "@/components/ResultsTable"; import { cellSortValue, type BrowserRow, type ColumnDef } from "@/lib/browser-row"; -import { nextSort, sortRows } from "@/lib/sort-rows"; +import { nextSort } from "@/lib/sort-rows"; +import { + applyTableView, + browserViewHistory, + facetKeysOf, + facetValues, + parseTableView, + serializeTableView, + type TableView, + type ViewHistory, +} from "@/lib/table-view"; import type { SortState } from "@/lib/types"; +const FACET_LABELS: Record = { tags: "Tags" }; + export function RecordBrowser({ rows, columns, heading = "Records", renderers, + history, }: { rows: BrowserRow[]; columns: ColumnDef[]; heading?: string; renderers?: Record; + history?: ViewHistory; }) { - const [sort, setSort] = useState({ key: columns[0]?.key ?? "name", dir: 1 }); - const ordered = sortRows(rows, sort, cellSortValue); + const viewHistory = useMemo(() => history ?? browserViewHistory(), [history]); + const facetKeys = useMemo(() => facetKeysOf(rows), [rows]); + const sortable = (key: string) => columns.some((column) => column.key === key); + const baseSort: SortState = { key: columns[0]?.key ?? "name", dir: 1 }; + const [search, setSearch] = useState(""); + const [draft, setDraft] = useState(""); + const view = parseTableView(search, facetKeys, sortable); + const sort = view.sort ?? baseSort; + const visible = applyTableView(rows, { ...view, sort }); + + useEffect(() => { + const read = () => { + const next = viewHistory.read(); + setSearch(next); + setDraft(parseTableView(next, facetKeys, sortable).q); + }; + read(); + return viewHistory.subscribe(read); + }, [viewHistory, facetKeys, columns]); + + function commit(next: TableView) { + const query = serializeTableView(next, baseSort); + viewHistory.push(query); + setSearch(query ? `?${query}` : ""); + setDraft(next.q); + } + + function toggleFacet(key: string, value: string) { + const current = view.facets[key] ?? []; + const values = current.includes(value) ? current.filter((item) => item !== value) : [...current, value]; + commit({ q: view.q, sort, facets: { ...view.facets, [key]: values } }); + } + const tableColumns = columns.map((column) => ({ key: column.key, label: column.label, sortable: true, render: (row: BrowserRow) => renderCell(column.key, row, renderers), })); + return (

{heading}

+
{ + event.preventDefault(); + commit({ q: draft, sort, facets: view.facets }); + }} + > + + +
+ {facetKeys.map((key) => { + const values = facetValues(rows, key); + if (values.length === 0) return null; + const selected = view.facets[key] ?? []; + return ( +
+ {FACET_LABELS[key] ?? key} + {values.map((value) => ( + + ))} +
+ ); + })} row.id} sort={sort} - onSort={(key) => setSort((current) => nextSort(current, key))} + onSort={(key) => commit({ q: view.q, facets: view.facets, sort: nextSort(sort, key) })} + empty="No records match this view." />
); diff --git a/ui/lib/table-view.test.ts b/ui/lib/table-view.test.ts new file mode 100644 index 0000000..e0c8cb9 --- /dev/null +++ b/ui/lib/table-view.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { mapBrowserRow } from "./browser-row"; +import { applyTableView, parseTableView, serializeTableView } from "./table-view"; + +const rows = [ + mapBrowserRow( + { + id: "a", + title: "Alpha", + tldr: "First idea", + tags: ["one"], + kindLabel: "Concept", + route: "/a/", + kindHref: "/concept/", + }, + ["tags"], + ), + mapBrowserRow( + { + id: "z", + title: "Zebra", + tldr: "Last idea", + tags: ["two"], + kindLabel: "Concept", + route: "/z/", + kindHref: "/concept/", + }, + ["tags"], + ), +]; + +const sortable = (key: string) => key === "name" || key === "tldr" || key === "tags"; +const base = { key: "name", dir: 1 as const }; + +describe("table view", () => { + it("round-trips search, repeated facets, and descending sort", () => { + const view = { + q: "idea", + facets: { tags: ["one", "two"] }, + sort: { key: "name", dir: -1 as const }, + }; + const search = serializeTableView(view, base); + expect(search).toContain("q=idea"); + expect(search).toContain("tags=one"); + expect(search).toContain("tags=two"); + expect(search).toContain("sort=-name"); + expect(parseTableView(`?${search}`, ["tags"], sortable)).toEqual(view); + expect(serializeTableView({ q: "", facets: {}, sort: base }, base)).toBe(""); + }); + + it("filters by facet and query, then sorts", () => { + const both = applyTableView(rows, parseTableView("?tags=one,two&sort=-name", ["tags"], sortable)); + expect(both.map((row) => row.name)).toEqual(["Zebra", "Alpha"]); + const one = applyTableView(rows, parseTableView("?q=first&tags=one", ["tags"], sortable)); + expect(one.map((row) => row.name)).toEqual(["Alpha"]); + const none = applyTableView(rows, parseTableView("?q=missing", ["tags"], sortable)); + expect(none).toEqual([]); + }); +}); diff --git a/ui/lib/table-view.ts b/ui/lib/table-view.ts new file mode 100644 index 0000000..614a8a2 --- /dev/null +++ b/ui/lib/table-view.ts @@ -0,0 +1,113 @@ +/* + * URL shape adapted from OnCo viewParams: `q` for search, repeated facet keys, + * and `sort` with a leading "-" for descending. + * https://github.com/judegomila/OnCo @ b594d7433bf4e408b44215e25e5304696d6f840e + * Copyright (c) 2026 OnCo. MIT License. See ui/NOTICE. + * + * The compact `v` parameter, table scopes, and domain value normalisers are not included. + */ + +import { cellSortValue, type BrowserRow } from "./browser-row"; +import { sortRows } from "./sort-rows"; +import type { SortState } from "./types"; + +export type TableView = { + q: string; + facets: Record; + sort?: SortState; +}; + +export type ViewHistory = { + read: () => string; + push: (search: string) => void; + subscribe: (listener: () => void) => () => void; +}; + +export function browserViewHistory(): ViewHistory { + return { + read: () => (typeof window === "undefined" ? "" : window.location.search), + push: (search) => { + const path = window.location.pathname; + window.history.pushState(null, "", search ? `${path}?${search}` : path); + }, + subscribe: (listener) => { + window.addEventListener("popstate", listener); + return () => window.removeEventListener("popstate", listener); + }, + }; +} + +export function facetKeysOf(rows: readonly BrowserRow[]): string[] { + const keys = new Set(); + for (const row of rows) { + for (const key of Object.keys(row.facets)) keys.add(key); + } + return [...keys].sort(); +} + +export function facetValues(rows: readonly BrowserRow[], key: string): string[] { + const values = new Set(); + for (const row of rows) { + for (const value of row.facets[key] ?? []) values.add(value); + } + return [...values].sort((a, b) => a.localeCompare(b)); +} + +function splitValues(params: URLSearchParams, key: string): string[] { + const values: string[] = []; + for (const raw of params.getAll(key)) { + for (const part of raw.split(",")) { + const trimmed = part.trim(); + if (trimmed && !values.includes(trimmed)) values.push(trimmed); + } + } + return values; +} + +export function parseTableView( + search: string, + facetKeys: readonly string[], + sortable: (key: string) => boolean, +): TableView { + const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); + const facets: Record = {}; + for (const key of facetKeys) { + const values = splitValues(params, key); + if (values.length > 0) facets[key] = values; + } + const rawSort = params.get("sort"); + let sort: SortState | undefined; + if (rawSort) { + const key = rawSort.replace(/^-/, ""); + if (sortable(key)) sort = { key, dir: rawSort.startsWith("-") ? -1 : 1 }; + } + return { q: params.get("q") ?? "", facets, sort }; +} + +export function serializeTableView(view: TableView, baseSort: SortState): string { + const params = new URLSearchParams(); + for (const key of Object.keys(view.facets).sort()) { + for (const value of view.facets[key] ?? []) params.append(key, value); + } + const q = view.q.trim(); + if (q) params.set("q", q); + if (view.sort && (view.sort.key !== baseSort.key || view.sort.dir !== baseSort.dir)) { + params.set("sort", `${view.sort.dir === -1 ? "-" : ""}${view.sort.key}`); + } + return params.toString(); +} + +export function applyTableView(rows: readonly BrowserRow[], view: TableView): BrowserRow[] { + const q = view.q.trim().toLowerCase(); + const filtered = rows.filter((row) => { + for (const [key, selected] of Object.entries(view.facets)) { + if (selected.length === 0) continue; + const have = new Set(row.facets[key] ?? []); + if (!selected.some((value) => have.has(value))) return false; + } + if (!q) return true; + const haystack = [row.name, row.tldr, ...Object.values(row.facets).flat()].join("\n").toLowerCase(); + return haystack.includes(q); + }); + return sortRows(filtered, view.sort, cellSortValue); +} diff --git a/ui/package-lock.json b/ui/package-lock.json index 280716d..f4f9abb 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -17,6 +17,7 @@ "@types/node": "^24.7.2", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", + "happy-dom": "^18.0.1", "tailwindcss": "^4.1.14", "typescript": "^5.9.3", "vitest": "^3.2.4" @@ -1876,6 +1877,13 @@ "@types/react": "^19.3.0" } }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -2244,6 +2252,39 @@ "dev": true, "license": "ISC" }, + "node_modules/happy-dom": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-18.0.1.tgz", + "integrity": "sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "^20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/happy-dom/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -3030,6 +3071,7 @@ "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -3195,6 +3237,16 @@ } } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/ui/package.json b/ui/package.json index 4e0dd3c..e3a55de 100644 --- a/ui/package.json +++ b/ui/package.json @@ -18,6 +18,7 @@ "@types/node": "^24.7.2", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", + "happy-dom": "^18.0.1", "tailwindcss": "^4.1.14", "typescript": "^5.9.3", "vitest": "^3.2.4" diff --git a/workplans/CONTENT-WP-0002-ui-extraction.md b/workplans/CONTENT-WP-0002-ui-extraction.md index 44478fa..c1f68a0 100644 --- a/workplans/CONTENT-WP-0002-ui-extraction.md +++ b/workplans/CONTENT-WP-0002-ui-extraction.md @@ -16,7 +16,7 @@ state_hub_workstream_id: "55c1d92f-fe1a-552a-998d-2d6905fb6b99" # UI extraction and experience skeleton -Deliver the strongest OnCo-style interaction patterns on generic data. CONTENT-WP-0000 and CONTENT-WP-0001 are finished, so this plan is eligible. T01 through T03 have landed under `ui/`. T03 maps each record to a generic browser row and renders the columns named by that kind's manifest view. T04 is next. See `workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md`. +Deliver the strongest OnCo-style interaction patterns on generic data. CONTENT-WP-0000 and CONTENT-WP-0001 are finished, so this plan is eligible. T01 through T04 have landed under `ui/`. T04 keeps search, facet selection, and sort in the page URL, and back and forward restore that view. T05 is next. See `workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md`. ## Work packages @@ -51,7 +51,7 @@ state_hub_task_id: "f3ccb974-153a-5dae-aa52-7a985175ca52" ```task id: CONTENT-WP-0002-T04 -status: todo +status: done priority: high state_hub_task_id: "bacd58df-4834-5157-834e-26da4c6ba470" ``` diff --git a/workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md b/workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md index a2a032c..ec22cbd 100644 --- a/workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md +++ b/workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md @@ -63,4 +63,19 @@ Checks on 2026-10-13, after T03: - `npm run typecheck`, `scripts/check_ui_boundary.py`, fixture validation, and the contract and ingest tests passed. - `next dev` on `127.0.0.1:3400` returned the home table with a Kind column, and the concept and pattern indexes with Tags (`regulation`, `translation`). The server was stopped. No browser pane was available. -Not done: URL facets and sort, keyboard search and empty/error states, the T06 gate for search and filter, the formal import gate, the provenance gate, or keyboard and small-screen smoke. Facet values are on the row for T04; they do not filter the table yet. +Not done after T03: URL facets and sort, keyboard search and empty/error states, the T06 gate for search and filter, the formal import gate, the provenance gate, or keyboard and small-screen smoke. Facet values were on the row; they did not filter the table yet. + +## T04 — shareable query, facets, and sort + +The query string follows the pinned table shape: `q` is the search text, a facet key repeats for each selected value (`tags=regulation&tags=structure`), and `sort` is the column key with a leading `-` when descending. The default sort is omitted. Search is written when the form is submitted. A facet button or a sort button writes immediately. Each write is a history entry. + +`applyTableView` filters, then sorts. Within one facet, any selected value matches. Every selected facet key must match. Search looks at the name, the summary, and facet values. + +Checks on 2026-10-13, after T04: + +- `npm test` in `ui/`: 14 tests passed. The view tests round-trip a query and filter rows. The history test submits a search, selects a tag, reverses the sort, then goes back to the unfiltered table and forward to the sorted tag view. +- The same test dispatches `popstate` on `window` after `pushState` and checks that the history reader reports the new query. +- `npm run typecheck`, `scripts/check_ui_boundary.py`, fixture validation, and the contract and ingest tests passed. +- `next dev` on `127.0.0.1:3400` rendered `/systems-thinking/concept/` with the search field and the tag `regulation`. The server was stopped. No desktop browser was available, so back and forward were exercised in the history test rather than in a graphical browser. + +Not done: keyboard search and empty/error/loading states, the T06 gate for both dataspaces across search and filter, the formal import gate, the provenance gate, or keyboard and small-screen smoke. A facet key other than `tags` still has no values in these fixtures.