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
This commit is contained in:
parent
d92eb25f92
commit
0cbc7baf49
8 changed files with 503 additions and 9 deletions
168
ui/components/RecordBrowser.history.test.tsx
Normal file
168
ui/components/RecordBrowser.history.test.tsx
Normal file
|
|
@ -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(<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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string> = { tags: "Tags" };
|
||||
|
||||
export function RecordBrowser({
|
||||
rows,
|
||||
columns,
|
||||
heading = "Records",
|
||||
renderers,
|
||||
history,
|
||||
}: {
|
||||
rows: BrowserRow[];
|
||||
columns: ColumnDef[];
|
||||
heading?: string;
|
||||
renderers?: Record<string, CellRenderer>;
|
||||
history?: ViewHistory;
|
||||
}) {
|
||||
const [sort, setSort] = useState<SortState | undefined>({ 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 (
|
||||
<section id="records" aria-labelledby="records-heading" className="mx-auto max-w-7xl px-4 py-14 sm:px-6">
|
||||
<h2 id="records-heading" className="mb-6 text-2xl">{heading}</h2>
|
||||
<form
|
||||
className="mb-4 flex flex-wrap items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
commit({ q: draft, sort, facets: view.facets });
|
||||
}}
|
||||
>
|
||||
<label className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className="sr-only">Search records</span>
|
||||
<input
|
||||
aria-label="Search records"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
className="h-10 w-full max-w-md rounded-[0.625rem] border border-border bg-card px-3 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn">Search</button>
|
||||
</form>
|
||||
{facetKeys.map((key) => {
|
||||
const values = facetValues(rows, key);
|
||||
if (values.length === 0) return null;
|
||||
const selected = view.facets[key] ?? [];
|
||||
return (
|
||||
<div key={key} className="mb-4 flex flex-wrap items-center gap-2" role="group" aria-label={FACET_LABELS[key] ?? key}>
|
||||
<span className="kicker">{FACET_LABELS[key] ?? key}</span>
|
||||
{values.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={selected.includes(value) ? "btn btn-primary" : "btn"}
|
||||
aria-pressed={selected.includes(value)}
|
||||
onClick={() => toggleFacet(key, value)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<ResultsTable
|
||||
columns={tableColumns}
|
||||
rows={ordered}
|
||||
rows={visible}
|
||||
rowKey={(row) => 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."
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
59
ui/lib/table-view.test.ts
Normal file
59
ui/lib/table-view.test.ts
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
113
ui/lib/table-view.ts
Normal file
113
ui/lib/table-view.ts
Normal file
|
|
@ -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<string, string[]>;
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string, string[]> = {};
|
||||
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);
|
||||
}
|
||||
52
ui/package-lock.json
generated
52
ui/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue