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
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue