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
This commit is contained in:
tegwick 2026-10-13 23:01:09 +02:00
parent e16aa03393
commit d92eb25f92
12 changed files with 237 additions and 53 deletions

View file

@ -25,7 +25,7 @@ export function DataspaceHome({ id }: { id: string }) {
))}
</p>
) : null}
<RecordBrowser rows={dataspace.rows} />
<RecordBrowser rows={dataspace.rows} columns={dataspace.columns} />
</>
);
}

View file

@ -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", () => {

View file

@ -9,7 +9,7 @@ export function KindIndex({ kind }: { kind: KindView }) {
return (
<>
<PageHeader kicker={kind.dataspaceTitle} title={kind.plural} lede={lede} />
<RecordBrowser rows={kind.rows} />
<RecordBrowser rows={kind.rows} columns={kind.columns} />
</>
);
}

View file

@ -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<string, CellRenderer>;
}) {
const [sort, setSort] = useState<SortState | undefined>({ 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 ? <a className="underline" href={row.href}>{row.title}</a> : row.title,
},
{
key: "kindLabel",
label: "Kind",
sortable: true,
render: (row: RecordRow) => row.kindHref
? <a className="chip" href={row.kindHref}>{row.kindLabel}</a>
: <span className="chip">{row.kindLabel}</span>,
},
{ 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<SortState | undefined>({ 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 (
<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>
<ResultsTable
columns={columns}
columns={tableColumns}
rows={ordered}
rowKey={(row) => row.id}
sort={sort}

View file

@ -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 <a className="underline" href={cell.href}>{cell.label}</a>;
}
return (
<span className="inline-flex flex-wrap gap-1">
{cell.values.map((value) => (
<span key={value} className="chip">{value}</span>
))}
</span>
);
}
export const genericCellRenderers: Record<string, CellRenderer> = {
text: (cell) => renderGenericCell(cell),
link: (cell) => renderGenericCell(cell),
chips: (cell) => renderGenericCell(cell),
};
export function renderCell(
key: string,
row: BrowserRow,
registry: Record<string, CellRenderer> = genericCellRenderers,
): ReactNode {
const cell = row.cols[key];
if (!cell) return null;
const render = registry[key] ?? registry[cell.kind] ?? renderGenericCell;
return render(cell, row);
}

View file

@ -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();
});
});

84
ui/lib/browser-row.ts Normal file
View file

@ -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<string, string[]>;
cols: Record<string, CellValue>;
sortKeys: Record<string, string>;
};
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<string, string> = {
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<string, CellValue> = {
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<string, string[]> = {};
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(", ");
}

View file

@ -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", () => {

View file

@ -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<string, { forward?: string; inverse?: string }>;
kindViews?: Record<string, KindViewConfig>;
};
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<string, { forward: string; inverse: string }>;
views: Record<string, KindViewConfig>;
byId: Map<string, EntityRecord>;
};
@ -134,6 +140,7 @@ function loadAll(): Map<string, Loaded> {
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"]),
};
}

View file

@ -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 };