From 0cb7e5005fbb19c63366d341ba36a04e26ed0e45 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 22 Aug 2026 10:31:36 +0000 Subject: [PATCH] feat(meta-to-blocks): lower _meta to a navigation document --- apps/blocks/package.json | 1 + apps/blocks/src/app/blocks/documents/page.tsx | 33 ++++- .../documents-showcase/document-nav-demo.tsx | 90 +++++++++++++ packages/blocks-schema/src/node.ts | 3 + packages/blocks-ui/README.md | 22 +++- packages/blocks-ui/package.json | 1 + packages/blocks-ui/src/__tests__/nav.test.tsx | 47 +++++++ packages/blocks-ui/src/index.ts | 1 + packages/blocks-ui/src/nav.tsx | 85 ++++++++++++ packages/blocks-ui/src/registry.ts | 4 + packages/meta-to-blocks/README.md | 20 +++ .../meta-to-blocks/src/__tests__/nav.test.ts | 78 +++++++++++ packages/meta-to-blocks/src/index.ts | 1 + packages/meta-to-blocks/src/nav.ts | 124 ++++++++++++++++++ packages/meta-to-blocks/src/types.ts | 32 +++++ pnpm-lock.yaml | 6 + 16 files changed, 545 insertions(+), 3 deletions(-) create mode 100644 apps/blocks/src/components/documents-showcase/document-nav-demo.tsx create mode 100644 packages/blocks-ui/src/__tests__/nav.test.tsx create mode 100644 packages/blocks-ui/src/nav.tsx create mode 100644 packages/meta-to-blocks/src/__tests__/nav.test.ts create mode 100644 packages/meta-to-blocks/src/nav.ts diff --git a/apps/blocks/package.json b/apps/blocks/package.json index 3236f2c..bff5be5 100644 --- a/apps/blocks/package.json +++ b/apps/blocks/package.json @@ -52,6 +52,7 @@ "json-schema-to-blocks": "workspace:*", "lucide-react": "^0.525.0", "marked": "^16.4.2", + "meta-to-blocks": "workspace:*", "motion": "^12.40.0", "next": "^16.1.1", "next-themes": "^0.4.6", diff --git a/apps/blocks/src/app/blocks/documents/page.tsx b/apps/blocks/src/app/blocks/documents/page.tsx index 8c31011..2823f9b 100644 --- a/apps/blocks/src/app/blocks/documents/page.tsx +++ b/apps/blocks/src/app/blocks/documents/page.tsx @@ -3,13 +3,14 @@ import type { Metadata } from 'next'; import { CodeBlock } from '@/components/docs/code-block'; import { DocSection } from '@/components/docs/doc-section'; import { DocumentFormDemo } from '@/components/documents-showcase/document-form-demo'; +import { DocumentNavDemo } from '@/components/documents-showcase/document-nav-demo'; import { OG_IMAGE, withBase } from '@/lib/site'; const TITLE = 'JSON documents'; const DESCRIPTION = 'Render a declarative JSON UI document with the default widget registry: JSON Schema, database metadata, or an agent tool produces the document, and no page hand-writes the form.'; -const INSTALL = `pnpm add blocks-schema blocks-renderer json-schema-to-blocks @constructive-io/blocks-ui`; +const INSTALL = `pnpm add blocks-schema blocks-renderer json-schema-to-blocks meta-to-blocks @constructive-io/blocks-ui`; const USAGE = `'use client'; @@ -37,6 +38,25 @@ export function PostForm() { ); }`; +const NAV = `import { DocumentRenderer } from 'blocks-renderer'; +import { defaultBlockRegistry } from '@constructive-io/blocks-ui'; +import { metaToNavDocument } from 'meta-to-blocks'; + +// One group per schema, one link per table, join tables dropped. +const nav = metaToNavDocument(meta.tables, { + href: (table) => \`/admin/\${table.schemaName}/\${table.name}\` +}); + +export function ConsoleSidebar({ pathname }: { pathname: string }) { + return ( + + ); +}`; + const OVERRIDE = `import { composeRegistry } from 'blocks-renderer'; import { defaultBlockRegistry } from '@constructive-io/blocks-ui'; @@ -95,6 +115,17 @@ export default function DocumentsPage() { + + + + {NAV} + + + `#${table.schemaName}/${table.name}`; + +export function DocumentNavDemo() { + const [pathname, setPathname] = useState('#app_public/posts'); + const document = useMemo( + () => metaToNavDocument(META, { label: 'Console', href }), + [], + ); + + return ( +
+ + + {/* The scope decides which link is current, so highlighting stays declarative. */} + +
+ + +
+
+
+
+

Generated document

+
+          {JSON.stringify(document, null, 2)}
+        
+
+
+ ); +} diff --git a/packages/blocks-schema/src/node.ts b/packages/blocks-schema/src/node.ts index 7117761..9804942 100644 --- a/packages/blocks-schema/src/node.ts +++ b/packages/blocks-schema/src/node.ts @@ -58,6 +58,9 @@ export const BLOCK_NODE_TYPES = [ 'ActionBar', 'Markdown', 'AgentChat', + 'Nav', + 'NavGroup', + 'NavLink', 'Button', 'Slot', 'Fragment', diff --git a/packages/blocks-ui/README.md b/packages/blocks-ui/README.md index a945f3a..09fbe2d 100644 --- a/packages/blocks-ui/README.md +++ b/packages/blocks-ui/README.md @@ -69,8 +69,8 @@ const registry = composeRegistry(defaultBlockRegistry, { Take a subset if the page chrome is yours: `widgetRegistry` (the form controls), `containerRegistry` (`Page`, `Form`, `Section`, `Grid`, `Tabs`), and -`blockRegistry` (`Button`, `ActionBar`, `Markdown`, `StatCard`) are exported -separately. +`blockRegistry` (`Button`, `ActionBar`, `Markdown`, `StatCard`, `Nav`, +`NavGroup`, `NavLink`) are exported separately. Writing an adapter from scratch needs nothing from this package — a registry is `Record>`. `useNodeField` and `FieldShell` are @@ -89,6 +89,24 @@ a real editor is a heavy dependency, so it belongs in the host that wants it. `FileUpload` records the selected file name only — the byte upload needs your storage adapter. +## Navigation + +`Nav`, `NavGroup`, and `NavLink` render the navigation documents +`meta-to-blocks`' `metaToNavDocument` lowers from `_meta`, so a console sidebar +follows the database rather than a hand-maintained route list: + +```tsx + +``` + +A link is a plain anchor, and the current one is whichever `href` matches +`scope.pathname`. Give a node a `click` action (or override `NavLink` with your +framework's `Link`) to keep client-side routing. + ## Form state Widgets own no state. Each one reads and writes the `DocumentRenderer` context diff --git a/packages/blocks-ui/package.json b/packages/blocks-ui/package.json index 3e3e0c5..69bf51a 100644 --- a/packages/blocks-ui/package.json +++ b/packages/blocks-ui/package.json @@ -68,6 +68,7 @@ "blocks-schema": "workspace:^", "jsdom": "^26.1.0", "json-schema-to-blocks": "workspace:^", + "meta-to-blocks": "workspace:^", "react": "^19.2.3", "react-dom": "^19.2.3", "tsup": "^8.5.1", diff --git a/packages/blocks-ui/src/__tests__/nav.test.tsx b/packages/blocks-ui/src/__tests__/nav.test.tsx new file mode 100644 index 0000000..9d54756 --- /dev/null +++ b/packages/blocks-ui/src/__tests__/nav.test.tsx @@ -0,0 +1,47 @@ +import { DocumentRenderer } from 'blocks-renderer'; +import { metaToNavDocument } from 'meta-to-blocks'; +import type { MetaTable } from 'meta-to-blocks'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { defaultBlockRegistry } from '../registry'; + +const tables: MetaTable[] = [ + { name: 'posts', schemaName: 'app_public' }, + { name: 'categories', schemaName: 'app_public' }, +]; + +describe('nav blocks', () => { + it('renders a _meta navigation document as links, with no data source', () => { + render(); + + expect(screen.getByRole('navigation')).toBeDefined(); + expect(screen.getByText('App public')).toBeDefined(); + expect((screen.getByRole('link', { name: 'Posts' }) as HTMLAnchorElement).getAttribute('href')).toBe('/posts'); + }); + + it('marks the link matching the scope pathname as the current page', () => { + render( + + ); + + expect(screen.getByRole('link', { name: 'Categories' }).getAttribute('aria-current')).toBe('page'); + expect(screen.getByRole('link', { name: 'Posts' }).getAttribute('aria-current')).toBeNull(); + }); + + it('defers to the node action when a host owns routing', () => { + const onAction = vi.fn(); + const document = metaToNavDocument([tables[0]]); + const link = document.page.children[0].children[0].children[0]; + link.actions = { click: { type: 'handler', handler: 'navigate' } }; + + render(); + fireEvent.click(screen.getByRole('link', { name: 'Posts' })); + + expect(onAction).toHaveBeenCalledWith({ type: 'handler', handler: 'navigate' }, 'click'); + }); +}); diff --git a/packages/blocks-ui/src/index.ts b/packages/blocks-ui/src/index.ts index 4cb110b..6294cae 100644 --- a/packages/blocks-ui/src/index.ts +++ b/packages/blocks-ui/src/index.ts @@ -11,6 +11,7 @@ export { TabBlock, TabsBlock, } from './containers'; +export { NavBlock, NavGroupBlock, NavLinkBlock } from './nav'; export { CheckboxBlock, CodeBlock, diff --git a/packages/blocks-ui/src/nav.tsx b/packages/blocks-ui/src/nav.tsx new file mode 100644 index 0000000..2733dd7 --- /dev/null +++ b/packages/blocks-ui/src/nav.tsx @@ -0,0 +1,85 @@ +'use client'; + +/** + * Navigation blocks, as generated from `_meta` by `meta-to-blocks`. + * + * A link renders as a plain anchor so a document navigates without a router; a + * host on client-side routing gives the node a `click` action (or overrides + * `NavLink` with its own framework `Link`) and the anchor defers to it. The + * active link is whichever `href` matches `scope.pathname`, so highlighting is + * declarative rather than a second source of truth. + */ + +import { useRenderer } from 'blocks-renderer'; +import type { BlockProps } from 'blocks-renderer'; +import type { UINodeProps } from 'blocks-schema'; +import type { MouseEvent } from 'react'; + +function text(props: UINodeProps, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = props[key]; + if (typeof value === 'string') return value; + } + return undefined; +} + +export function NavBlock({ props, children }: BlockProps) { + const label = text(props, 'label'); + + return ( + + ); +} + +export function NavGroupBlock({ props, children }: BlockProps) { + const label = text(props, 'label', 'title'); + const count = typeof props.count === 'number' ? props.count : undefined; + + return ( +
+ {label && ( +
+ {label} + {count !== undefined && {count}} +
+ )} +
    {children}
+
+ ); +} + +export function NavLinkBlock({ node, props }: BlockProps) { + const { scope, onAction } = useRenderer(); + const label = text(props, 'label', 'title') ?? String(props.table ?? ''); + const href = text(props, 'href') ?? '#'; + const action = node.actions?.click; + const active = props.active === true || (typeof scope.pathname === 'string' && scope.pathname === href); + + return ( +
  • + { + event.preventDefault(); + onAction?.(action, 'click'); + }, + } + : {})} + > + {label} + +
  • + ); +} diff --git a/packages/blocks-ui/src/registry.ts b/packages/blocks-ui/src/registry.ts index d350f4c..fcee8ba 100644 --- a/packages/blocks-ui/src/registry.ts +++ b/packages/blocks-ui/src/registry.ts @@ -17,6 +17,7 @@ import { TabBlock, TabsBlock, } from './containers'; +import { NavBlock, NavGroupBlock, NavLinkBlock } from './nav'; import { CheckboxBlock, CodeBlock, @@ -69,6 +70,9 @@ export const blockRegistry: BlockRegistry = { ActionBar: ActionBarBlock, Markdown: MarkdownBlock, StatCard: StatCardBlock, + Nav: NavBlock, + NavGroup: NavGroupBlock, + NavLink: NavLinkBlock, }; /** diff --git a/packages/meta-to-blocks/README.md b/packages/meta-to-blocks/README.md index d4b38a4..d23d006 100644 --- a/packages/meta-to-blocks/README.md +++ b/packages/meta-to-blocks/README.md @@ -45,6 +45,23 @@ const detail = tableToDetailDocument(table); // Page > DetailPanel > RelationLis Create forms omit the columns the database fills in (primary keys, audit timestamps); `mode: 'update'` includes them and disables the primary key. +A console needs a way in before it needs screens, and the table list is already +in `_meta`: + +```ts +import { metaToNavDocument } from 'meta-to-blocks'; + +// Page > Nav > NavGroup (one per schema) > NavLink (one per table) +const nav = metaToNavDocument(meta.tables, { + href: (table) => `/admin/${table.schemaName}/${table.name}` +}); +``` + +Join tables are dropped (they carry nothing a console can show) unless +`includeJunctionTables` is set; `tables`, `omitTables`, `tableOrder`, and +`schemaLabels` cover the rest. Rendering it needs no query runtime — +`@constructive-io/blocks-ui` registers the three nav types. + ## Customization ```ts @@ -76,6 +93,7 @@ Custom `rules` run ahead of the JSON Schema defaults unless | array column | repeatable `Section` | | foreign key | `Select` with `relation` props, labelled without the id suffix | | `hasMany` / `manyToMany` relation | `RelationList` on the detail screen | +| table list, grouped by `schemaName` | `Nav > NavGroup > NavLink` | ## API @@ -84,6 +102,8 @@ tableToFormDocument(table, options?): UIDocument tableToListDocument(table, options?): UIDocument tableToDetailDocument(table, options?): UIDocument tableToNodes(table, options?): UINode[] +metaToNavDocument(tables, options?): UIDocument +metaToNavNodes(tables, options?): UINode[] tableToSchema(table, options?): JSONSchema fieldToSchema(table, field, override?): JSONSchema typeToSchema(type): JSONSchema diff --git a/packages/meta-to-blocks/src/__tests__/nav.test.ts b/packages/meta-to-blocks/src/__tests__/nav.test.ts new file mode 100644 index 0000000..ab2de6d --- /dev/null +++ b/packages/meta-to-blocks/src/__tests__/nav.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { findNodeByKey, parseDocument, walkNodes } from 'blocks-schema'; +import { metaToNavDocument, metaToNavNodes } from '../nav'; +import type { MetaTable } from '../types'; + +const posts: MetaTable = { + name: 'posts', + schemaName: 'app_public', + description: 'Blog posts', + relations: { + manyToMany: [{ fieldName: 'categories', rightTable: { name: 'categories' }, junctionTable: { name: 'post_categories' } }], + }, +}; + +const categories: MetaTable = { name: 'categories', schemaName: 'app_public' }; +const postCategories: MetaTable = { name: 'post_categories', schemaName: 'app_public' }; +const auditLog: MetaTable = { name: 'audit_log_entries', schemaName: 'app_private' }; + +const tables = [posts, categories, postCategories, auditLog]; + +function links(document: ReturnType) { + return [...walkNodes(document.page)].filter((node) => node.type === 'NavLink'); +} + +describe('metaToNavDocument', () => { + it('produces a valid document with one group per schema', () => { + const document = metaToNavDocument(tables); + + expect(() => parseDocument(document)).not.toThrow(); + expect(document.id).toBe('nav'); + + const groups = findNodeByKey(document.page, 'nav')?.children ?? []; + expect(groups.map((group) => group.props.label)).toEqual(['App public', 'App private']); + expect(groups.map((group) => group.props.count)).toEqual([2, 1]); + }); + + it('links each table at its list route, titleized', () => { + const document = metaToNavDocument(tables); + + expect(links(document).map((link) => [link.props.label, link.props.href])).toEqual([ + ['Posts', '/posts'], + ['Categories', '/categories'], + ['Audit log entries', '/audit_log_entries'], + ]); + }); + + it('omits join tables, which carry nothing a console can show', () => { + expect(links(metaToNavDocument(tables)).map((link) => link.props.table)).not.toContain('post_categories'); + expect(links(metaToNavDocument(tables, { includeJunctionTables: true })).map((link) => link.props.table)).toContain( + 'post_categories' + ); + }); + + it('takes the host route shape, per-table overrides and an explicit order', () => { + const document = metaToNavDocument(tables, { + href: (table) => `/admin/${table.schemaName}/${table.name}`, + tables: { categories: { label: 'Taxonomy' }, audit_log_entries: { omit: true } }, + tableOrder: ['categories'], + }); + + expect(links(document).map((link) => [link.props.label, link.props.href])).toEqual([ + ['Taxonomy', '/admin/app_public/categories'], + ['Posts', '/admin/app_public/posts'], + ]); + }); + + it('emits a flat link list when grouping is off, including for schema-less meta', () => { + expect(metaToNavNodes(tables, { group: false }).map((node) => node.type)).toEqual([ + 'NavLink', + 'NavLink', + 'NavLink', + ]); + + const flat = metaToNavNodes([{ name: 'widgets' }]); + expect(flat[0].props.label).toBeUndefined(); + expect(flat[0].children[0].key).toBe('nav.widgets'); + }); +}); diff --git a/packages/meta-to-blocks/src/index.ts b/packages/meta-to-blocks/src/index.ts index f407667..cf31d6b 100644 --- a/packages/meta-to-blocks/src/index.ts +++ b/packages/meta-to-blocks/src/index.ts @@ -1,4 +1,5 @@ export * from './convert'; +export * from './nav'; export * from './naming'; export * from './schema'; export * from './types'; diff --git a/packages/meta-to-blocks/src/nav.ts b/packages/meta-to-blocks/src/nav.ts new file mode 100644 index 0000000..0c939b6 --- /dev/null +++ b/packages/meta-to-blocks/src/nav.ts @@ -0,0 +1,124 @@ +/** + * `_meta` → a navigation document. + * + * A console needs a way in before it needs screens, and the table list is + * already in `_meta`: this lowers it to `Page > Nav > NavGroup > NavLink`, one + * group per schema, one link per table, pointing at whatever route the host + * uses for a list screen. Purely declarative — no query runtime — so the same + * document renders in a docs page, an admin shell, or a server-rendered page. + */ + +import { createDocument, type UIDocument, type UINode } from 'blocks-schema'; +import { titleize } from './naming'; +import type { MetaTable, NavOptions } from './types'; + +const DEFAULT_HREF = (table: MetaTable) => `/${table.name}`; + +/** + * Junction tables carry nothing but the two keys they join, so a link to one is + * noise in a generated console; a table is one when a `manyToMany` relation + * names it as its junction. + */ +function junctionTableNames(tables: readonly MetaTable[]): Set { + const names = new Set(); + for (const table of tables) { + for (const relation of table.relations?.manyToMany ?? []) { + const junction = relation?.junctionTable?.name; + if (junction) names.add(junction); + } + } + return names; +} + +function navLink(table: MetaTable, options: NavOptions): UINode { + const override = options.tables?.[table.name]; + const href = override?.href ?? (options.href ?? DEFAULT_HREF)(table); + + return { + type: 'NavLink', + key: `nav.${table.schemaName ? `${table.schemaName}.` : ''}${table.name}`, + props: { + label: override?.label ?? titleize(table.name), + href, + table: table.name, + ...(table.schemaName ? { schemaName: table.schemaName } : {}), + ...(table.description ? { description: table.description } : {}), + }, + children: [], + }; +} + +/** Tables worth linking, in the order the groups should render. */ +function navTables(tables: readonly MetaTable[], options: NavOptions): MetaTable[] { + const junctions = options.includeJunctionTables ? new Set() : junctionTableNames(tables); + const omitted = new Set(options.omitTables ?? []); + + const visible = tables.filter( + (table) => !omitted.has(table.name) && !junctions.has(table.name) && !options.tables?.[table.name]?.omit + ); + + if (!options.tableOrder?.length) return visible; + + const rank = new Map(options.tableOrder.map((name, index) => [name, index])); + const at = (table: MetaTable) => rank.get(table.name) ?? rank.size; + return [...visible].sort((left, right) => at(left) - at(right)); +} + +/** + * Groups tables by `schemaName`, keeping first-seen schema order. Tables with no + * schema land in a single unnamed group so a flat `_meta` payload still renders. + */ +function groupBySchema(tables: readonly MetaTable[]): Map { + const groups = new Map(); + for (const table of tables) { + const schema = table.schemaName ?? ''; + const group = groups.get(schema); + if (group) group.push(table); + else groups.set(schema, [table]); + } + return groups; +} + +/** Nav nodes for a `_meta` table list, without a document envelope. */ +export function metaToNavNodes(tables: readonly MetaTable[], options: NavOptions = {}): UINode[] { + const visible = navTables(tables, options); + + if (options.group === false) { + return visible.map((table) => navLink(table, options)); + } + + return [...groupBySchema(visible)].map(([schema, group]) => ({ + type: 'NavGroup', + key: `nav.${schema || 'tables'}`, + props: { + ...(schema ? { label: options.schemaLabels?.[schema] ?? titleize(schema), schemaName: schema } : {}), + count: group.length, + }, + children: group.map((table) => navLink(table, options)), + })); +} + +/** Navigation screen: `Page > Nav > NavGroup > NavLink`. */ +export function metaToNavDocument(tables: readonly MetaTable[], options: NavOptions = {}): UIDocument { + const children = metaToNavNodes(tables, options); + + return createDocument( + { + type: 'Page', + key: options.rootKey ?? 'page', + props: { title: options.title ?? 'Navigation' }, + children: [ + { + type: 'Nav', + key: 'nav', + props: { ...(options.label ? { label: options.label } : {}) }, + children, + }, + ], + }, + { + id: options.id ?? 'nav', + meta: { source: 'meta-to-blocks', kind: 'nav' }, + } + ); +} diff --git a/packages/meta-to-blocks/src/types.ts b/packages/meta-to-blocks/src/types.ts index ce7affd..b2e0f58 100644 --- a/packages/meta-to-blocks/src/types.ts +++ b/packages/meta-to-blocks/src/types.ts @@ -158,6 +158,38 @@ export interface DetailOptions extends MetaOptions { rootKey?: string; } +/** How a table is presented in generated navigation. */ +export interface NavTableOverride { + label?: string; + href?: string; + /** Drop the table from the generated navigation. */ + omit?: boolean; +} + +export interface NavOptions { + /** Document id; defaults to `nav`. */ + id?: string; + /** Screen title; defaults to `Navigation`. */ + title?: string; + /** Label on the `Nav` node itself. */ + label?: string; + rootKey?: string; + /** Route for a table's list screen; defaults to `/`. */ + href?: (table: MetaTable) => string; + /** Group links by schema. Default `true`. */ + group?: boolean; + /** Schema name → group label; defaults to the titleized schema name. */ + schemaLabels?: Record; + /** Per-table presentation overrides, keyed by table name. */ + tables?: Record; + /** Tables to leave out. */ + omitTables?: readonly string[]; + /** Link join tables too; they are omitted by default. */ + includeJunctionTables?: boolean; + /** Explicit table order; unlisted tables keep their `_meta` order after these. */ + tableOrder?: readonly string[]; +} + /** A column lowered to a JSON Schema fragment, keyed by its column name. */ export interface LoweredField { name: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c59c552..889cde7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: marked: specifier: ^16.4.2 version: 16.4.2 + meta-to-blocks: + specifier: workspace:* + version: link:../../packages/meta-to-blocks/dist motion: specifier: ^12.40.0 version: 12.42.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -265,6 +268,9 @@ importers: json-schema-to-blocks: specifier: workspace:^ version: link:../json-schema-to-blocks/dist + meta-to-blocks: + specifier: workspace:^ + version: link:../meta-to-blocks/dist react: specifier: ^19.2.3 version: 19.2.7