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:
parent
e16aa03393
commit
d92eb25f92
12 changed files with 237 additions and 53 deletions
|
|
@ -25,7 +25,7 @@ export function DataspaceHome({ id }: { id: string }) {
|
|||
))}
|
||||
</p>
|
||||
) : null}
|
||||
<RecordBrowser rows={dataspace.rows} />
|
||||
<RecordBrowser rows={dataspace.rows} columns={dataspace.columns} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
39
ui/components/cell-renderers.tsx
Normal file
39
ui/components/cell-renderers.tsx
Normal 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);
|
||||
}
|
||||
40
ui/lib/browser-row.test.tsx
Normal file
40
ui/lib/browser-row.test.tsx
Normal 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
84
ui/lib/browser-row.ts
Normal 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(", ");
|
||||
}
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue