Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .changeset/react-blocks-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/spec": minor
---

Add the react-tier component contract index (`REACT_BLOCKS`, ADR-0081):
`packages/spec/src/ui/react-blocks.ts` maps each curated public block injected
into `kind:'react'` page source to the **spec zod schema** that defines its
declarative config props (FormView, ListView, RecordDetails/Highlights/
RelatedList/Path, Chart) plus a hand-authored React-interaction overlay
(binding/controlled/callback — objectName, recordId, mode, onSuccess,
onRowClick, …). `pnpm --filter @objectstack/spec gen:react-blocks` generates the
AI-facing contract (skills/objectstack-ui/references/react-blocks.md + .json)
from it — the `data` props come from the spec (single source, no re-authoring).
4 changes: 4 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -3266,6 +3266,10 @@
"PortalUrlNavItemSchema (const)",
"PortalViewNavItem (type)",
"PortalViewNavItemSchema (const)",
"REACT_BLOCKS (const)",
"ReactBlockDef (interface)",
"ReactInteractionProp (interface)",
"ReactPropKind (type)",
"RecordActivityProps (const)",
"RecordChatterProps (const)",
"RecordDetailsProps (const)",
Expand Down
3 changes: 2 additions & 1 deletion packages/spec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"check:liveness": "tsx scripts/liveness/check-liveness.mts"
"check:liveness": "tsx scripts/liveness/check-liveness.mts",
"gen:react-blocks": "tsx scripts/build-react-blocks-contract.ts"
},
"keywords": [
"objectstack",
Expand Down
140 changes: 140 additions & 0 deletions packages/spec/scripts/build-react-blocks-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Generates the react-tier component contract from packages/spec/src/ui/
// react-blocks.ts: the `data` (config) props are read from each block's SPEC
// zod schema via z.toJSONSchema (single source — no re-authoring); the
// binding/controlled/callback props come from the hand-authored interaction
// overlay. Emits:
// - skills/objectstack-ui/contracts/react-blocks.contract.json (machine)
// - skills/objectstack-ui/references/react-blocks.md (AI-facing)
//
// Run: pnpm --filter @objectstack/spec gen:react-blocks

process.env.OS_EAGER_SCHEMAS = '1';

import fs from 'fs';
import path from 'path';
import { z } from 'zod';
import { REACT_BLOCKS, type ReactInteractionProp } from '../src/ui/react-blocks';

const REPO = path.resolve(__dirname, '../../..');
const OUT_JSON = path.join(REPO, 'skills/objectstack-ui/contracts/react-blocks.contract.json');
const OUT_MD = path.join(REPO, 'skills/objectstack-ui/references/react-blocks.md');

// ---- JSON-schema prop extraction -----------------------------------------
function resolveRoot(js: any): any {
// zod v4 may wrap the root in { $ref: '#/$defs/X', $defs: { X: {...} } }.
if (js && js.$ref && js.$defs) {
const key = String(js.$ref).split('/').pop()!;
return js.$defs[key] ?? js;
}
return js;
}

function renderType(node: any): string {
if (!node || typeof node !== 'object') return 'any';
if (Array.isArray(node.enum)) return node.enum.map((v: any) => (typeof v === 'string' ? `'${v}'` : String(v))).join(' | ');
if (Array.isArray(node.anyOf) || Array.isArray(node.oneOf)) {
const alts = (node.anyOf ?? node.oneOf).map(renderType).filter((t: string) => t && t !== 'any');
return [...new Set(alts)].join(' | ') || 'any';
}
if (node.type === 'array') return `${renderType(node.items)}[]`;
if (node.$ref) return String(node.$ref).split('/').pop() ?? 'object';
if (node.type) return Array.isArray(node.type) ? node.type.join(' | ') : String(node.type);
if (node.properties) return 'object';
return 'any';
}

const clip = (s: unknown, n = 160): string => {
const t = String(s ?? '').replace(/\s+/g, ' ').trim();
return t.length > n ? t.slice(0, n - 1) + '…' : t;
};

interface Prop { name: string; type: string; kind: string; required: boolean; description: string }

function dataProps(schema: any, allow?: string[]): Prop[] {
let js: any;
try {
js = resolveRoot(z.toJSONSchema(schema, { unrepresentable: 'any' } as any));
} catch {
return [];
}
const props = js?.properties ?? {};
const required: string[] = Array.isArray(js?.required) ? js.required : [];
const SKIP = new Set(['aria', 'type', 'id', 'className', 'style']);
let entries = Object.entries(props).filter(([name]) => !SKIP.has(name));
if (allow && allow.length) {
const order = new Map(allow.map((n, i) => [n, i]));
entries = entries.filter(([n]) => order.has(n)).sort((a, b) => order.get(a[0])! - order.get(b[0])!);
}
return entries
.map(([name, node]: [string, any]) => ({
name,
type: renderType(node),
kind: 'data',
required: required.includes(name),
description: clip(node?.description),
}));
}

function mergeProps(dataPs: Prop[], overlay: ReactInteractionProp[]): Prop[] {
const out: Prop[] = overlay.map((o) => ({ name: o.name, type: o.type, kind: o.kind, required: !!o.required, description: o.description }));
const seen = new Set(out.map((p) => p.name));
for (const d of dataPs) if (!seen.has(d.name)) out.push(d);
return out;
}

// ---- build ----------------------------------------------------------------
const KIND_ORDER: Record<string, number> = { binding: 0, controlled: 1, callback: 2, data: 3 };
const blocks = REACT_BLOCKS.map((b) => {
const props = mergeProps(b.schema ? dataProps(b.schema, b.dataProps) : [], b.interactions).sort(
(a, z2) => (KIND_ORDER[a.kind] - KIND_ORDER[z2.kind]) || (Number(z2.required) - Number(a.required)),
);
return { tag: b.tag, schemaType: b.schemaType, summary: b.summary, specSchema: b.schema ? true : false, props };
});

const contract = {
version: 2,
adr: 'ADR-0081',
source: 'GENERATED from packages/spec/src/ui/react-blocks.ts — data props from the spec zod schemas, binding/controlled/callback from the React overlay.',
note: "Props each component accepts in kind:'react' page source. Reference blocks by their PascalCase tag. kind: data=declarative config (from the spec schema) · binding=connects to data · controlled=React state · callback=React function. Layout = plain HTML+Tailwind; these blocks are for DATA. Live data: const adapter = useAdapter(); adapter.find/findOne/create/update.",
blocks,
};
fs.writeFileSync(OUT_JSON, JSON.stringify(contract, null, 2) + '\n');

// markdown
const esc = (s: string) => String(s).replace(/\|/g, '\\|');

Check failure

Code scanning / CodeQL

Incomplete string escaping or encoding High test

This does not escape backslash characters in the input.
const L: string[] = [];
L.push('---');
L.push('title: React-tier component contract');
L.push("description: Props each injected component accepts in kind:'react' page source (ADR-0081). GENERATED from packages/spec/src/ui/react-blocks.ts — do not edit by hand.");
L.push('---');
L.push('');
L.push('{/* GENERATED by packages/spec/scripts/build-react-blocks-contract.ts — do not edit. */}');
L.push('');
L.push(`# React-tier component contract (${contract.adr})`);
L.push('');
L.push(contract.note);
L.push('');
L.push('**kind**: `data` = declarative config (from the spec schema — the authoritative source) · `binding` = connects the block to data · `controlled` = drive from React state · `callback` = a React function the block calls.');
L.push('');
for (const b of blocks) {
L.push(`## \`<${b.tag}>\` — \`${b.schemaType}\`${b.specSchema ? '' : ' *(no spec schema — overlay only)*'}`);
L.push('');
L.push(b.summary);
L.push('');
L.push('| prop | type | kind | required | description |');
L.push('|------|------|------|:--------:|-------------|');
for (const p of b.props) {
L.push(`| \`${p.name}\` | \`${esc(p.type)}\` | ${p.kind} | ${p.required ? '✓' : ''} | ${esc(p.description)} |`);
}
L.push('');
}
L.push('## Injected scope (closure variables, reference directly — not props)');
L.push('');
L.push('`React` · `useAdapter` · `data` · `variables` · `page`. Kanban/calendar/gantt/timeline/map of an object = `<ListView navigation={…} />` with the matching visualization, or `<Block type="object-kanban" …/>`.');
L.push('');
fs.writeFileSync(OUT_MD, L.join('\n'));

console.log(`✅ react-blocks contract: ${blocks.length} blocks → ${path.relative(REPO, OUT_JSON)} + ${path.relative(REPO, OUT_MD)}`);
for (const b of blocks) console.log(` <${b.tag}> ${b.props.length} props`);
1 change: 1 addition & 0 deletions packages/spec/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export * from './action.zod';
export * from './page.zod';
export * from './widget.zod';
export * from './component.zod';
export * from './react-blocks';
export * from './theme.zod';
export * from './touch.zod';
export * from './offline.zod';
Expand Down
161 changes: 161 additions & 0 deletions packages/spec/src/ui/react-blocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// React-tier component index (ADR-0081). Maps each curated public block that is
// injected into `kind:'react'` page source to (a) the SPEC zod schema that
// already defines its declarative/config props — the authoritative source, do
// not re-author — and (b) a thin hand-authored React-interaction overlay: the
// binding/controlled/callback props that are inherently React (objectName,
// recordId, mode, onSuccess, onRowClick, …) and so are absent from the
// declarative metadata schema.
//
// The contract the AI authors against (skills/objectstack-ui/references/
// react-blocks.md + .contract.json) is GENERATED from this index by
// `scripts/build-react-blocks-contract.ts` — never hand-edited.

import type { ZodTypeAny } from 'zod';
import { ListViewSchema, FormViewSchema } from './view.zod';
import {
RecordDetailsProps,
RecordRelatedListProps,
RecordHighlightsProps,
RecordPathProps,
} from './component.zod';
import { ChartConfigSchema } from './chart.zod';

export type ReactPropKind = 'data' | 'binding' | 'controlled' | 'callback';

export interface ReactInteractionProp {
name: string;
type: string;
kind: 'binding' | 'controlled' | 'callback';
required?: boolean;
description: string;
}

export interface ReactBlockDef {
/** PascalCase name the author writes in JSX, e.g. `<ObjectForm>`. */
tag: string;
/** The registry/render type, e.g. `object-form`. */
schemaType: string;
summary: string;
/**
* Spec zod schema that defines this block's declarative (config) props. The
* generator extracts these as `data` props — authoritative, with descriptions.
* Omit for blocks with no spec schema (then only the overlay is published).
*/
schema?: ZodTypeAny;
/**
* Curate which spec-schema props to surface (high-signal subset; ADR-0080
* "capability ≠ contract"). Definitions still come from the schema — this only
* selects + orders. Omit to surface all of the schema's props.
*/
dataProps?: string[];
/** React-only props absent from the declarative schema (hand-authored). */
interactions: ReactInteractionProp[];
}

// Shared overlays ----------------------------------------------------------
const OBJECT_NAME: ReactInteractionProp = {
name: 'objectName',
type: 'string',
kind: 'binding',
required: true,
description: 'The object this block binds to (server-connected).',
};

export const REACT_BLOCKS: ReactBlockDef[] = [
{
tag: 'ObjectForm',
schemaType: 'object-form',
summary: "Server-connected create/edit/view form for one object. Config props come from the spec FormView schema; bind + wire it with the React props below.",
schema: FormViewSchema,
dataProps: ['sections', 'subforms', 'submitBehavior', 'defaultSort'],
interactions: [
OBJECT_NAME,
{ name: 'mode', type: "'create' | 'edit' | 'view'", kind: 'controlled', description: 'Create a new record, or edit/view an existing one — drive from React state.' },
{ name: 'formType', type: "'simple' | 'tabbed' | 'wizard' | 'split' | 'drawer' | 'modal'", kind: 'binding', description: 'Form presentation; drawer/modal render the form in a built-in overlay (use drawerSide/drawerWidth/modalSize).' },
{ name: 'recordId', type: 'string | number', kind: 'controlled', description: 'Which record to load (edit/view). The hook for master/detail.' },
{ name: 'fields', type: 'string[]', kind: 'binding', description: 'Limit/order the fields shown (defaults to the object form fields).' },
{ name: 'initialValues', type: 'Record<string, any>', kind: 'binding', description: 'Prefill values in create mode.' },
{ name: 'onSuccess', type: '(record) => void', kind: 'callback', description: 'Called after a successful save with the saved record (e.g. close a panel + reload).' },
{ name: 'onError', type: '(error: Error) => void', kind: 'callback', description: 'Called when the save fails.' },
{ name: 'onCancel', type: '() => void', kind: 'callback', description: 'Called when the user cancels.' },
{ name: 'submitHandler', type: '(values) => any | Promise<any>', kind: 'callback', description: 'Custom persistence instead of the default create/update.' },
],
},
{
tag: 'ListView',
schemaType: 'list-view',
summary: "Server-connected object table with toolbar and switchable visualizations (grid/kanban/calendar/gantt/…). Config props come from the spec ListView schema.",
schema: ListViewSchema,
dataProps: ['columns', 'sort', 'searchableFields', 'userFilters', 'pagination', 'grouping', 'rowHeight', 'selection', 'rowActions', 'inlineEdit'],
interactions: [
OBJECT_NAME,
{ name: 'viewType', type: "'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map'", kind: 'binding', description: 'Which visualization to render (default grid). How you get a kanban/calendar/gantt of the object.' },
{ name: 'filters', type: "FilterArray e.g. ['status','=','active']", kind: 'controlled', description: 'ObjectQL base filter; drive from React state for tabbed/searched lists. ([field, op, value]; ops =, !=, >, <, contains, in; compound: [\"and\", […], […]]).' },
{ name: 'navigation', type: "{ mode: 'page' | 'drawer' | 'modal' | 'split' | 'none' }", kind: 'binding', description: 'What a row click does. Use { mode: \"none\" } when you handle clicks via onRowClick.' },
{ name: 'onRowClick', type: '(record) => void', kind: 'callback', description: "Called with the clicked row's record — the hook for master/detail." },
{ name: 'onNavigate', type: "(recordId, action: 'view' | 'edit') => void", kind: 'callback', description: 'Called for page-level navigation.' },
],
},
{
tag: 'ObjectChart',
schemaType: 'object-chart',
summary: 'Chart over an object’s aggregated data. Config props come from the spec Chart config schema.',
schema: ChartConfigSchema,
dataProps: ['title', 'series', 'xAxis', 'yAxis', 'colors', 'showLegend'],
interactions: [
OBJECT_NAME,
{ name: 'filter', type: 'FilterArray', kind: 'controlled', description: 'ObjectQL filter scoping the data; drive from React state.' },
{ name: 'aggregate', type: '{ field, function, groupBy }', kind: 'binding', description: 'Aggregation: function (sum/avg/count) over field, grouped by groupBy.' },
],
},
{
tag: 'RecordDetails',
schemaType: 'record:details',
summary: 'Field-detail panel for the bound record. Config props from the spec RecordDetails schema.',
schema: RecordDetailsProps,
interactions: [
{ name: 'recordId', type: 'string | number', kind: 'controlled', description: 'The record to show.' },
{ name: 'objectName', type: 'string', kind: 'binding', description: 'The record’s object.' },
],
},
{
tag: 'RecordHighlights',
schemaType: 'record:highlights',
summary: 'Highlights panel — a strip of key fields. Config props from the spec RecordHighlights schema.',
schema: RecordHighlightsProps,
interactions: [
{ name: 'recordId', type: 'string | number', kind: 'controlled', description: 'The record to summarize.' },
{ name: 'objectName', type: 'string', kind: 'binding', description: 'The record’s object.' },
],
},
{
tag: 'RecordRelatedList',
schemaType: 'record:related_list',
summary: 'Related child records via a lookup. Config props from the spec RecordRelatedList schema.',
schema: RecordRelatedListProps,
interactions: [
{ name: 'recordId', type: 'string | number', kind: 'controlled', description: 'The parent record.' },
{ name: 'objectName', type: 'string', kind: 'binding', description: 'The parent object.' },
],
},
{
tag: 'RecordPath',
schemaType: 'record:path',
summary: 'Stage/progress bar driven by a status field. Config props from the spec RecordPath schema.',
schema: RecordPathProps,
interactions: [
{ name: 'recordId', type: 'string | number', kind: 'controlled', description: 'The record whose stage to show.' },
{ name: 'objectName', type: 'string', kind: 'binding', description: 'The record’s object.' },
],
},
{
tag: 'Block',
schemaType: '(any)',
summary: 'Escape hatch — render any registered component by type. <Block type="object-kanban" objectName="task" /> etc.',
interactions: [
{ name: 'type', type: 'string', kind: 'binding', required: true, description: 'The registered component type to render.' },
],
},
];
6 changes: 4 additions & 2 deletions skills/objectstack-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -782,8 +782,10 @@ blocks for data. Real component props/callbacks flow through — e.g. `<ObjectFo
> **[React-tier component contract](./references/react-blocks.md)**, generated from
> [`contracts/react-blocks.contract.json`](./contracts/react-blocks.contract.json).
> It is the authoritative answer to "what props does `<ObjectForm>`/`<ListView>`/…
> take?" — author against it, not from memory. Regenerate after editing the JSON
> with `node skills/objectstack-ui/contracts/build-ref.mjs`.
> take?" — author against it, not from memory. The `data` props are sourced from the platform's spec schemas (FormView,
> ListView, RecordDetails, Chart, …) — the same protocol the server validates;
> `binding`/`controlled`/`callback` are the React overlay. Regenerate with
> `pnpm --filter @objectstack/spec gen:react-blocks`.

Master/detail (click a row → edit it → save refreshes the list):

Expand Down
Loading