Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/blocks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 32 additions & 1 deletion apps/blocks/src/app/blocks/documents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 (
<DocumentRenderer
document={nav}
registry={defaultBlockRegistry}
scope={{ pathname }}
/>
);
}`;

const OVERRIDE = `import { composeRegistry } from 'blocks-renderer';
import { defaultBlockRegistry } from '@constructive-io/blocks-ui';

Expand Down Expand Up @@ -95,6 +115,17 @@ export default function DocumentsPage() {
<DocumentFormDemo />
</DocSection>

<DocSection
description="Navigation is a document too: metaToNavDocument lowers a _meta table list to Nav, NavGroup and NavLink nodes, so a console sidebar follows the database rather than a hand-maintained route list. It needs no query runtime, and the current link is whichever href matches scope.pathname."
id="navigation"
title="Navigation from database metadata"
>
<DocumentNavDemo />
<CodeBlock label="console-sidebar.tsx" language="tsx">
{NAV}
</CodeBlock>
</DocSection>

<DocSection
description="The registry is a plain node type to component map, so replace any subset without forking it. Data-bound nodes such as DataTable and AgentChat are deliberately unregistered: they need a query runtime, so the host supplies them."
id="composition"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
'use client';

import { defaultBlockRegistry } from '@constructive-io/blocks-ui';
import { Card, CardContent } from '@constructive-io/ui';
import { DocumentRenderer } from 'blocks-renderer';
import type { MetaTable } from 'meta-to-blocks';
import { metaToNavDocument } from 'meta-to-blocks';
import { useMemo, useState } from 'react';

/**
* The table list is the only input: `metaToNavDocument` lowers a `_meta` payload
* to a nav document, so a console's sidebar follows the database instead of a
* hand-maintained route list. `post_categories` is a join table, so it is
* dropped rather than linked.
*/
const META: MetaTable[] = [
{
name: 'posts',
schemaName: 'app_public',
description: 'Blog posts',
relations: {
manyToMany: [
{
fieldName: 'categories',
rightTable: { name: 'categories' },
junctionTable: { name: 'post_categories' },
},
],
},
},
{ name: 'categories', schemaName: 'app_public' },
{ name: 'post_categories', schemaName: 'app_public' },
{ name: 'users', schemaName: 'app_public' },
{ name: 'audit_log_entries', schemaName: 'app_private' },
{ name: 'feature_flags', schemaName: 'app_private' },
];

// A host passes its own route builder; the docs page keeps the links inert.
const href = (table: MetaTable) => `#${table.schemaName}/${table.name}`;

export function DocumentNavDemo() {
const [pathname, setPathname] = useState('#app_public/posts');
const document = useMemo(
() => metaToNavDocument(META, { label: 'Console', href }),
[],
);

return (
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardContent className="pt-6">
{/* The scope decides which link is current, so highlighting stays declarative. */}
<DocumentRenderer
document={document}
registry={defaultBlockRegistry}
scope={{ pathname }}
/>
<div className="mt-6 border-t pt-4">
<label
className="text-xs font-medium text-muted-foreground"
htmlFor="nav-demo-pathname"
>
scope.pathname
</label>
<select
className="mt-1 w-full rounded-md border bg-background px-2 py-1.5 text-sm"
id="nav-demo-pathname"
onChange={(event) => setPathname(event.target.value)}
value={pathname}
>
{META.filter((table) => table.name !== 'post_categories').map(
(table) => (
<option key={table.name} value={href(table)}>
{href(table)}
</option>
),
)}
</select>
</div>
</CardContent>
</Card>
<div>
<p className="mb-2 text-sm font-medium">Generated document</p>
<pre className="max-h-96 overflow-auto rounded-lg border bg-muted/40 p-4 text-xs leading-5">
{JSON.stringify(document, null, 2)}
</pre>
</div>
</div>
);
}
3 changes: 3 additions & 0 deletions packages/blocks-schema/src/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ export const BLOCK_NODE_TYPES = [
'ActionBar',
'Markdown',
'AgentChat',
'Nav',
'NavGroup',
'NavLink',
'Button',
'Slot',
'Fragment',
Expand Down
22 changes: 20 additions & 2 deletions packages/blocks-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ComponentType<BlockProps>>`. `useNodeField` and `FieldShell` are
Expand All @@ -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
<DocumentRenderer
document={metaToNavDocument(meta.tables)}
registry={defaultBlockRegistry}
scope={{ pathname }}
/>
```

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
Expand Down
1 change: 1 addition & 0 deletions packages/blocks-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 47 additions & 0 deletions packages/blocks-ui/src/__tests__/nav.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<DocumentRenderer document={metaToNavDocument(tables)} registry={defaultBlockRegistry} />);

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(
<DocumentRenderer
document={metaToNavDocument(tables)}
registry={defaultBlockRegistry}
scope={{ pathname: '/categories' }}
/>
);

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(<DocumentRenderer document={document} registry={defaultBlockRegistry} onAction={onAction} />);
fireEvent.click(screen.getByRole('link', { name: 'Posts' }));

expect(onAction).toHaveBeenCalledWith({ type: 'handler', handler: 'navigate' }, 'click');
});
});
1 change: 1 addition & 0 deletions packages/blocks-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
TabBlock,
TabsBlock,
} from './containers';
export { NavBlock, NavGroupBlock, NavLinkBlock } from './nav';
export {
CheckboxBlock,
CodeBlock,
Expand Down
85 changes: 85 additions & 0 deletions packages/blocks-ui/src/nav.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav
className={['flex flex-col gap-4', props.className ? String(props.className) : ''].join(' ').trim()}
{...(label ? { 'aria-label': label } : {})}
>
{children}
</nav>
);
}

export function NavGroupBlock({ props, children }: BlockProps) {
const label = text(props, 'label', 'title');
const count = typeof props.count === 'number' ? props.count : undefined;

return (
<div className="flex flex-col gap-1">
{label && (
<div className="flex items-center justify-between px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<span>{label}</span>
{count !== undefined && <span className="tabular-nums">{count}</span>}
</div>
)}
<ul className="flex flex-col gap-0.5">{children}</ul>
</div>
);
}

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 (
<li>
<a
href={href}
aria-current={active ? 'page' : undefined}
className={[
'block rounded-md px-2 py-1.5 text-sm transition-colors',
active ? 'bg-accent font-medium text-accent-foreground' : 'text-muted-foreground hover:bg-accent/50',
].join(' ')}
{...(action
? {
onClick: (event: MouseEvent) => {
event.preventDefault();
onAction?.(action, 'click');
},
}
: {})}
>
{label}
</a>
</li>
);
}
4 changes: 4 additions & 0 deletions packages/blocks-ui/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
TabBlock,
TabsBlock,
} from './containers';
import { NavBlock, NavGroupBlock, NavLinkBlock } from './nav';
import {
CheckboxBlock,
CodeBlock,
Expand Down Expand Up @@ -69,6 +70,9 @@ export const blockRegistry: BlockRegistry = {
ActionBar: ActionBarBlock,
Markdown: MarkdownBlock,
StatCard: StatCardBlock,
Nav: NavBlock,
NavGroup: NavGroupBlock,
NavLink: NavLinkBlock,
};

/**
Expand Down
Loading
Loading