From d92eb25f92f30ac6b17e27593e45b2b0a19e5b97 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 13 Oct 2026 23:01:09 +0200 Subject: [PATCH] Map records to generic browser rows. CONTENT-WP-0002-T03 renders manifest columns through a pure row mapper and replaceable cell renderers. Assistant: grok Assistant-Session: 01a1346c-fdff-7741-b1af-b7283661554b --- ui/components/DataspaceHome.tsx | 2 +- ui/components/EntityDetail.test.tsx | 2 + ui/components/KindIndex.tsx | 2 +- ui/components/RecordBrowser.tsx | 44 +++++----- ui/components/cell-renderers.tsx | 39 +++++++++ ui/lib/browser-row.test.tsx | 40 +++++++++ ui/lib/browser-row.ts | 84 +++++++++++++++++++ ui/lib/catalog.test.ts | 8 +- ui/lib/catalog.ts | 39 ++++++--- ui/lib/types.ts | 10 --- workplans/CONTENT-WP-0002-ui-extraction.md | 4 +- .../CONTENT-WP-0002-ImplementationNotes.md | 16 +++- 12 files changed, 237 insertions(+), 53 deletions(-) create mode 100644 ui/components/cell-renderers.tsx create mode 100644 ui/lib/browser-row.test.tsx create mode 100644 ui/lib/browser-row.ts diff --git a/ui/components/DataspaceHome.tsx b/ui/components/DataspaceHome.tsx index 98a0d80..782a1e8 100644 --- a/ui/components/DataspaceHome.tsx +++ b/ui/components/DataspaceHome.tsx @@ -25,7 +25,7 @@ export function DataspaceHome({ id }: { id: string }) { ))}

) : null} - + ); } diff --git a/ui/components/EntityDetail.test.tsx b/ui/components/EntityDetail.test.tsx index 7f69efb..c56d5fb 100644 --- a/ui/components/EntityDetail.test.tsx +++ b/ui/components/EntityDetail.test.tsx @@ -12,6 +12,8 @@ describe("kind and detail views", () => { expect(html).toContain(">Concepts<"); expect(html).toContain("Coupling"); expect(html).toContain('href="/software-architecture/concept/coupling/"'); + expect(html).toContain("dependency"); + expect(html).toContain(">Tags<"); }); it("renders the entity title, an unknown claim, and a neighbour", () => { diff --git a/ui/components/KindIndex.tsx b/ui/components/KindIndex.tsx index 5211988..a68f223 100644 --- a/ui/components/KindIndex.tsx +++ b/ui/components/KindIndex.tsx @@ -9,7 +9,7 @@ export function KindIndex({ kind }: { kind: KindView }) { return ( <> - + ); } diff --git a/ui/components/RecordBrowser.tsx b/ui/components/RecordBrowser.tsx index 323acfc..448ca24 100644 --- a/ui/components/RecordBrowser.tsx +++ b/ui/components/RecordBrowser.tsx @@ -1,36 +1,36 @@ "use client"; import { 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 type { RecordRow, SortState } from "@/lib/types"; +import type { SortState } from "@/lib/types"; -const columns = [ - { - key: "title", - label: "Title", +export function RecordBrowser({ + rows, + columns, + heading = "Records", + renderers, +}: { + rows: BrowserRow[]; + columns: ColumnDef[]; + heading?: string; + renderers?: Record; +}) { + const [sort, setSort] = useState({ key: columns[0]?.key ?? "name", dir: 1 }); + const ordered = sortRows(rows, sort, cellSortValue); + const tableColumns = columns.map((column) => ({ + key: column.key, + label: column.label, sortable: true, - render: (row: RecordRow) => row.href ? {row.title} : row.title, - }, - { - key: "kindLabel", - label: "Kind", - sortable: true, - render: (row: RecordRow) => row.kindHref - ? {row.kindLabel} - : {row.kindLabel}, - }, - { key: "tldr", label: "Summary", sortable: true, render: (row: RecordRow) => row.tldr }, -]; - -export function RecordBrowser({ rows, heading = "Records" }: { rows: RecordRow[]; heading?: string }) { - const [sort, setSort] = useState({ key: "title", dir: 1 }); - const ordered = sortRows(rows, sort, (row, key) => String(row[key as keyof RecordRow] ?? "")); + render: (row: BrowserRow) => renderCell(column.key, row, renderers), + })); return (

{heading}

row.id} sort={sort} diff --git a/ui/components/cell-renderers.tsx b/ui/components/cell-renderers.tsx new file mode 100644 index 0000000..14e85e7 --- /dev/null +++ b/ui/components/cell-renderers.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import type { BrowserRow, CellValue } from "@/lib/browser-row"; + +/** + * Generic cell renderers. Callers replace any key or cell kind. + * OnCo's table cells for molecules, flags, and domain icons are not included. + */ +export type CellRenderer = (cell: CellValue, row: BrowserRow) => ReactNode; + +export function renderGenericCell(cell: CellValue): ReactNode { + if (cell.kind === "text") return cell.text; + if (cell.kind === "link") { + return {cell.label}; + } + return ( + + {cell.values.map((value) => ( + {value} + ))} + + ); +} + +export const genericCellRenderers: Record = { + text: (cell) => renderGenericCell(cell), + link: (cell) => renderGenericCell(cell), + chips: (cell) => renderGenericCell(cell), +}; + +export function renderCell( + key: string, + row: BrowserRow, + registry: Record = genericCellRenderers, +): ReactNode { + const cell = row.cols[key]; + if (!cell) return null; + const render = registry[key] ?? registry[cell.kind] ?? renderGenericCell; + return render(cell, row); +} diff --git a/ui/lib/browser-row.test.tsx b/ui/lib/browser-row.test.tsx new file mode 100644 index 0000000..b6037b4 --- /dev/null +++ b/ui/lib/browser-row.test.tsx @@ -0,0 +1,40 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { renderCell } from "@/components/cell-renderers"; +import { cellSortValue, columnDefs, mapBrowserRow } from "./browser-row"; + +const feedback = mapBrowserRow( + { + id: "urn:ce:systems-thinking:concept:feedback", + title: "Feedback", + tldr: "Effects of actions change subsequent behavior.", + tags: ["regulation"], + kindLabel: "Concept", + route: "/systems-thinking/concept/feedback/", + kindHref: "/systems-thinking/concept/", + }, + ["tags", "phase"], +); + +describe("mapBrowserRow", () => { + it("fills generic columns and leaves unknown facets empty", () => { + expect(feedback.name).toBe("Feedback"); + expect(feedback.route).toBe("/systems-thinking/concept/feedback/"); + expect(feedback.facets).toEqual({ tags: ["regulation"], phase: [] }); + expect(feedback.cols.tags).toEqual({ kind: "chips", values: ["regulation"] }); + expect(feedback.cols.name).toEqual({ kind: "link", label: "Feedback", href: feedback.route }); + expect("molecule" in feedback).toBe(false); + expect(cellSortValue(feedback, "tags")).toBe("regulation"); + expect(columnDefs(["name", "tldr", "tags"]).map((column) => column.label)).toEqual(["Name", "Summary", "Tags"]); + }); + + it("lets a caller replace one cell renderer", () => { + const html = renderToStaticMarkup( + <>{renderCell("tags", feedback, { tags: () => "PIN" })}, + ); + expect(html).toBe("PIN"); + const linked = renderToStaticMarkup(<>{renderCell("name", feedback)}); + expect(linked).toContain('href="/systems-thinking/concept/feedback/"'); + expect(renderCell("modality", feedback)).toBeNull(); + }); +}); diff --git a/ui/lib/browser-row.ts b/ui/lib/browser-row.ts new file mode 100644 index 0000000..28a6e6b --- /dev/null +++ b/ui/lib/browser-row.ts @@ -0,0 +1,84 @@ +/* + * Field set adapted from OnCo BrowserRow (id, name, tldr, route, facets, cols, sortKeys). + * https://github.com/judegomila/OnCo @ b594d7433bf4e408b44215e25e5304696d6f840e + * Copyright (c) 2026 OnCo. MIT License. See ui/NOTICE. + * + * Domain visuals (molecule, target, schematic, cancer icon, section icon) are not part of this type. + * The mapper is a pure function from a canonical record plus the manifest facet list. + */ + +export type TextCell = { kind: "text"; text: string }; +export type LinkCell = { kind: "link"; label: string; href: string }; +export type ChipsCell = { kind: "chips"; values: string[] }; +export type CellValue = TextCell | LinkCell | ChipsCell; + +export type BrowserRow = { + id: string; + name: string; + tldr: string; + route: string; + facets: Record; + cols: Record; + sortKeys: Record; +}; + +export type ColumnDef = { key: string; label: string }; + +export type MapperEntity = { + id: string; + title: string; + tldr: string; + tags?: string[]; + kindLabel: string; + route: string; + kindHref: string; +}; + +const LABELS: Record = { + name: "Name", + tldr: "Summary", + tags: "Tags", + kind: "Kind", +}; + +export const HOME_COLUMNS = ["name", "kind", "tldr"]; + +export function columnDefs(keys: readonly string[]): ColumnDef[] { + return keys.map((key) => ({ key, label: LABELS[key] ?? key })); +} + +export function mapBrowserRow(entity: MapperEntity, facetKeys: readonly string[]): BrowserRow { + const tags = [...(entity.tags ?? [])]; + const cols: Record = { + name: { kind: "link", label: entity.title, href: entity.route }, + tldr: { kind: "text", text: entity.tldr }, + tags: { kind: "chips", values: tags }, + kind: { kind: "link", label: entity.kindLabel, href: entity.kindHref }, + }; + const facets: Record = {}; + for (const key of facetKeys) facets[key] = key === "tags" ? [...tags] : []; + return { + id: entity.id, + name: entity.title, + tldr: entity.tldr, + route: entity.route, + facets, + cols, + sortKeys: { + name: entity.title, + tldr: entity.tldr, + tags: tags.join(", "), + kind: entity.kindLabel, + }, + }; +} + +export function cellSortValue(row: BrowserRow, key: string): string { + const keyed = row.sortKeys[key]; + if (keyed !== undefined) return keyed; + const cell = row.cols[key]; + if (!cell) return ""; + if (cell.kind === "text") return cell.text; + if (cell.kind === "link") return cell.label; + return cell.values.join(", "); +} diff --git a/ui/lib/catalog.test.ts b/ui/lib/catalog.test.ts index f561bf9..050a9e4 100644 --- a/ui/lib/catalog.test.ts +++ b/ui/lib/catalog.test.ts @@ -17,9 +17,11 @@ describe("catalog", () => { const concept = findKind("systems-thinking", "concept"); const pattern = findKind("software-architecture", "pattern"); expect(concept?.plural).toBe("Concepts"); - expect(concept?.rows.map((row) => row.title).sort()).toEqual(titles("systems-thinking", "concept")); - expect(pattern?.rows.map((row) => row.title).sort()).toEqual(titles("software-architecture", "pattern")); - expect(concept?.rows.every((row) => row.href?.startsWith("/systems-thinking/concept/"))).toBe(true); + expect(concept?.rows.map((row) => row.name).sort()).toEqual(titles("systems-thinking", "concept")); + expect(pattern?.rows.map((row) => row.name).sort()).toEqual(titles("software-architecture", "pattern")); + expect(concept?.columns.map((column) => column.key)).toEqual(["name", "tldr", "tags"]); + expect(concept?.rows.every((row) => row.route.startsWith("/systems-thinking/concept/"))).toBe(true); + expect(concept?.rows.find((row) => row.name === "Feedback")?.facets.tags).toEqual(["regulation"]); }); it("resolves one entity and hides unknown routes", () => { diff --git a/ui/lib/catalog.ts b/ui/lib/catalog.ts index 2026bdd..15c85ee 100644 --- a/ui/lib/catalog.ts +++ b/ui/lib/catalog.ts @@ -1,6 +1,6 @@ import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import type { RecordRow } from "./types"; +import { columnDefs, HOME_COLUMNS, mapBrowserRow, type BrowserRow, type ColumnDef } from "./browser-row"; type KindRecord = { id: string; label: string; plural: string }; @@ -30,8 +30,11 @@ type SnapshotFile = { claims?: ClaimRecord[]; }; +type KindViewConfig = { columns?: string[]; facets?: string[] }; + type ManifestFile = { relationLabels?: Record; + kindViews?: Record; }; export type DataspaceNav = { @@ -45,7 +48,8 @@ export type DataspaceView = { title: string; description: string; kinds: { id: string; label: string; plural: string; href: string }[]; - rows: RecordRow[]; + rows: BrowserRow[]; + columns: ColumnDef[]; }; export type KindView = { @@ -54,7 +58,8 @@ export type KindView = { kindId: string; label: string; plural: string; - rows: RecordRow[]; + rows: BrowserRow[]; + columns: ColumnDef[]; }; export type RelationView = { @@ -83,6 +88,7 @@ export type EntityView = { type Loaded = { snapshot: SnapshotFile; labels: Map; + views: Record; byId: Map; }; @@ -134,6 +140,7 @@ function loadAll(): Map { loaded.set(snapshot.dataspace.id, { snapshot, labels, + views: manifest.kindViews ?? {}, byId: new Map(snapshot.entities.map((entity) => [entity.id, entity])), }); } @@ -145,17 +152,21 @@ function kindOf(loaded: Loaded, kindId: string): KindRecord | null { return loaded.snapshot.kinds.find((kind) => kind.id === kindId) ?? null; } -function rowFor(loaded: Loaded, entity: EntityRecord): RecordRow { +function rowFor(loaded: Loaded, entity: EntityRecord): BrowserRow { const kind = kindOf(loaded, entity.kindId); - return { - id: entity.id, - kindId: entity.kindId, - kindLabel: kind?.label ?? entity.kindId, - title: entity.title, - tldr: entity.tldr, - href: entityHref(loaded.snapshot.dataspace.id, entity.kindId, entity.id), - kindHref: kindHref(loaded.snapshot.dataspace.id, entity.kindId), - }; + const dataspaceId = loaded.snapshot.dataspace.id; + return mapBrowserRow( + { + id: entity.id, + title: entity.title, + tldr: entity.tldr, + tags: entity.tags, + kindLabel: kind?.label ?? entity.kindId, + route: entityHref(dataspaceId, entity.kindId, entity.id), + kindHref: kindHref(dataspaceId, entity.kindId), + }, + loaded.views[entity.kindId]?.facets ?? [], + ); } export function listNavigation(): DataspaceNav[] { @@ -184,6 +195,7 @@ export function findDataspace(id: string): DataspaceView | null { href: kindHref(loaded.snapshot.dataspace.id, kind.id), })), rows: loaded.snapshot.entities.map((entity) => rowFor(loaded, entity)), + columns: columnDefs(HOME_COLUMNS), }; } @@ -199,6 +211,7 @@ export function findKind(dataspaceId: string, kindId: string): KindView | null { label: kind.label, plural: kind.plural, rows: loaded.snapshot.entities.filter((entity) => entity.kindId === kind.id).map((entity) => rowFor(loaded, entity)), + columns: columnDefs(loaded.views[kind.id]?.columns ?? ["name", "tldr", "tags"]), }; } diff --git a/ui/lib/types.ts b/ui/lib/types.ts index 14aa7c0..3ff111c 100644 --- a/ui/lib/types.ts +++ b/ui/lib/types.ts @@ -1,11 +1 @@ -export type RecordRow = { - id: string; - kindId: string; - kindLabel: string; - title: string; - tldr: string; - href?: string; - kindHref?: string; -}; - export type SortState = { key: string; dir: 1 | -1 }; diff --git a/workplans/CONTENT-WP-0002-ui-extraction.md b/workplans/CONTENT-WP-0002-ui-extraction.md index b834d97..44478fa 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 and T02 have landed under `ui/`. T02 adds a kind index and an entity page driven by each snapshot's kind registry, for both example dataspaces. T03 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 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`. ## Work packages @@ -42,7 +42,7 @@ state_hub_task_id: "3a8dcdb5-f4b0-5a5d-99e6-7fd62ea7d002" ```task id: CONTENT-WP-0002-T03 -status: todo +status: done priority: high state_hub_task_id: "f3ccb974-153a-5dae-aa52-7a985175ca52" ``` diff --git a/workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md b/workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md index c07b212..a2a032c 100644 --- a/workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md +++ b/workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md @@ -49,4 +49,18 @@ Checks on 2026-10-13, after T02: - `python3 scripts/check_ui_boundary.py`, fixture validation, and `tests.test_contract` / `tests.test_ingest` passed. - `next dev` on `127.0.0.1:3400` returned HTTP 200 for `/`, `/systems-thinking/concept/`, `/systems-thinking/concept/feedback/`, `/systems-thinking/concept/system-boundary/`, `/software-architecture/pattern/`, and `/software-architecture/concept/coupling/`. Unknown kind and slug returned 404. The rendered pages did not contain the words oncology or cancer. The server was stopped. No browser pane was available, so this was an HTTP render check. -Not done: BrowserRow mapper, URL facets and sort, keyboard search and empty/error states, the T06 both-dataspace gate for search and filter, the formal import gate, provenance gate, or keyboard and small-screen smoke. +Not done after T02: BrowserRow mapper, URL facets and sort, keyboard search and empty/error states, the T06 both-dataspace gate for search and filter, the formal import gate, provenance gate, or keyboard and small-screen smoke. + +## T03 — generic browser row and cell renderers + +`mapBrowserRow` is a pure function. It copies the OnCo row fields `id`, `name`, `tldr`, `route`, `facets`, `cols`, and `sortKeys`. It does not copy molecule, target, schematic, cancer-icon, or section-icon fields. Known cells are a name link, summary text, tag chips, and a kind link. A facet key other than `tags` is an empty list. A column key with no cell renders nothing. + +Kind pages show the columns named in that kind's `kindViews` entry (`name`, `tldr`, `tags` in both fixtures). The dataspace home shows name, kind, and summary because that table mixes kinds. `renderCell` uses a renderer registry keyed by column or cell kind, so a caller can replace one cell without a domain widget. + +Checks on 2026-10-13, after T03: + +- `npm test` in `ui/`: 10 tests passed. The new tests cover the Feedback tag facet, an empty unknown facet, a replaced tag renderer, and the name link. +- `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.