From 92538bc148b95500ff9aa57e629b28510b7d2758 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:31:14 +0800 Subject: [PATCH] docs(skill): react-tier component prop contract (so AI authors props, not guesses) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines + generates the contract for the components injected into kind:'react' page source: a single source-of-truth JSON (contracts/react-blocks.contract.json) listing each block's props — name, type, kind (data/controlled/callback), required, description — and a generator (build-ref.mjs) that emits the AI-facing reference (references/react-blocks.md). The objectstack-ui SKILL now points the build agent at it: 'do not guess props — read the contract'. Phase 1 covers the core blocks (ObjectForm, ListView, ObjectGrid, ObjectMasterDetailForm, ObjectChart, Block) accurately from the real registry inputs + component callback/controlled types. data props mirror the registry inputs (the html-tier contract); controlled/callback are the React surface. Follow-up: more blocks + an os build prop-validation gate (phase 2). --- skills/objectstack-ui/SKILL.md | 8 ++ skills/objectstack-ui/contracts/build-ref.mjs | 54 +++++++++++ .../contracts/react-blocks.contract.json | 90 +++++++++++++++++ .../objectstack-ui/references/react-blocks.md | 97 +++++++++++++++++++ 4 files changed, 249 insertions(+) create mode 100644 skills/objectstack-ui/contracts/build-ref.mjs create mode 100644 skills/objectstack-ui/contracts/react-blocks.contract.json create mode 100644 skills/objectstack-ui/references/react-blocks.md diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index 92f1320b28..f8b71592f9 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -777,6 +777,14 @@ blocks for data. Real component props/callbacks flow through — e.g. `` honors `objectName` / `fields` / `onRowClick` / `navigation`. +> **Do not guess props — read the contract.** Each injected block's full prop set +> (name, type, `data`/`controlled`/`callback` kind, required, description) is the +> **[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 ``/``/… +> take?" — author against it, not from memory. Regenerate after editing the JSON +> with `node skills/objectstack-ui/contracts/build-ref.mjs`. + Master/detail (click a row → edit it → save refreshes the list): ```tsx diff --git a/skills/objectstack-ui/contracts/build-ref.mjs b/skills/objectstack-ui/contracts/build-ref.mjs new file mode 100644 index 0000000000..5b79436668 --- /dev/null +++ b/skills/objectstack-ui/contracts/build-ref.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Generates the AI-facing markdown reference from react-blocks.contract.json. +// Run: node skills/objectstack-ui/contracts/build-ref.mjs +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const contract = JSON.parse(readFileSync(join(here, 'react-blocks.contract.json'), 'utf8')); +const out = join(here, '..', 'references', 'react-blocks.md'); + +const esc = (s) => String(s).replace(/\|/g, '\\|'); +const KIND_ORDER = { data: 0, controlled: 1, callback: 2, layout: 3 }; + +const lines = []; +lines.push('---'); +lines.push('title: React-tier component contract'); +lines.push('description: The props each injected component accepts in kind:\'react\' page source (ADR-0081). GENERATED from contracts/react-blocks.contract.json — do not edit by hand.'); +lines.push('---'); +lines.push(''); +lines.push('{/* GENERATED by contracts/build-ref.mjs from react-blocks.contract.json — do not edit. */}'); +lines.push(''); +lines.push(`# React-tier component contract (${contract.adr})`); +lines.push(''); +lines.push(contract.note); +lines.push(''); +lines.push('Prop **kind**: `data` = static/declarative (mirrors the registry inputs) · `controlled` = drive from React state · `callback` = a React function the component calls.'); +lines.push(''); + +for (const b of contract.blocks) { + lines.push(`## \`<${b.tag}>\` — \`${b.schemaType}\``); + lines.push(''); + lines.push(b.summary); + lines.push(''); + lines.push('| prop | type | kind | required | description |'); + lines.push('|------|------|------|:--------:|-------------|'); + const props = [...b.props].sort((a, z) => (KIND_ORDER[a.kind] - KIND_ORDER[z.kind])); + for (const p of props) { + lines.push(`| \`${p.name}\` | \`${esc(p.type)}\` | ${p.kind} | ${p.required ? '✓' : ''} | ${esc(p.description)} |`); + } + lines.push(''); +} + +if (contract.alsoAvailable) { + lines.push('## Also available'); + lines.push(''); + lines.push(contract.alsoAvailable.note); + lines.push(''); + lines.push('Injected scope (closure variables, reference directly — not props): ' + contract.alsoAvailable.scope.map((s) => `\`${s}\``).join(', ') + '.'); + lines.push(''); +} + +writeFileSync(out, lines.join('\n')); +console.log('✅ wrote', out, `(${contract.blocks.length} blocks)`); diff --git a/skills/objectstack-ui/contracts/react-blocks.contract.json b/skills/objectstack-ui/contracts/react-blocks.contract.json new file mode 100644 index 0000000000..d578baea07 --- /dev/null +++ b/skills/objectstack-ui/contracts/react-blocks.contract.json @@ -0,0 +1,90 @@ +{ + "version": 1, + "adr": "ADR-0081", + "note": "Curated contract for the components injected into kind:'react' page source. Reference these by their PascalCase `tag` in JSX. `data` props mirror the objectui registry `inputs` (the same surface the html tier validates); `controlled` props are React-driven state (recordId, mode, filters…); `callback` props are React functions the component invokes. Layout is plain HTML + Tailwind — these blocks are for DATA. Filters use the ObjectQL array form: [field, op, value], e.g. ['status','=','active'] (ops: =, !=, >, <, contains, in). Live data: `const adapter = useAdapter(); await adapter.find(obj, {$filter, $top}); .findOne; .create; .update`.", + "blocks": [ + { + "tag": "ObjectForm", + "schemaType": "object-form", + "summary": "Server-connected create / edit / view form for one object. Auto-builds fields from the object schema. Built-in drawer/modal variants via formType.", + "props": [ + { "name": "objectName", "type": "string", "kind": "data", "required": true, "description": "The object whose record this form edits." }, + { "name": "mode", "type": "'create' | 'edit' | 'view'", "kind": "controlled", "description": "Create a new record, edit/view an existing one." }, + { "name": "recordId", "type": "string | number", "kind": "controlled", "description": "Which record to load (edit/view mode). Drive from React state for master/detail." }, + { "name": "fields", "type": "string[]", "kind": "data", "description": "Limit/order the fields shown (defaults to the object's form fields)." }, + { "name": "initialValues", "type": "Record", "kind": "data", "description": "Prefill values in create mode." }, + { "name": "formType", "type": "'simple' | 'tabbed' | 'wizard' | 'split' | 'drawer' | 'modal'", "kind": "data", "description": "Form presentation; 'drawer'/'modal' render the form in a built-in overlay (use drawerSide/drawerWidth/modalSize)." }, + { "name": "onSuccess", "type": "(record) => void", "kind": "callback", "description": "Called after a successful create/update with the saved record. Typical: close a panel + bump a reload key." }, + { "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", "kind": "callback", "description": "Custom persistence instead of the default create/update (the form just validates and hands over values)." }, + { "name": "navigateOnSuccess", "type": "string", "kind": "data", "description": "URL to navigate to after save (supports {id})." } + ] + }, + { + "tag": "ListView", + "schemaType": "list-view", + "summary": "Server-connected object table with toolbar (search/filter/sort), and switchable views (grid/kanban/gallery/calendar/timeline/gantt/map). The primary object-list block.", + "props": [ + { "name": "objectName", "type": "string", "kind": "data", "required": true, "description": "The object to list." }, + { "name": "fields", "type": "string[]", "kind": "data", "description": "Columns to show." }, + { "name": "filters", "type": "FilterArray", "kind": "controlled", "description": "ObjectQL filter, e.g. ['status','=','active'] or ['and', [...], [...]]. Drive from React state for tabbed/searched lists." }, + { "name": "sort", "type": "SortArray", "kind": "data", "description": "Sort spec." }, + { "name": "viewType", "type": "'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map'", "kind": "data", "description": "Which view to render (default grid). This is how you get a kanban/calendar/gantt of the object." }, + { "name": "navigation", "type": "{ mode: 'page' | 'drawer' | 'modal' | 'split' | 'none' }", "kind": "data", "description": "What a row click does. Set { mode: 'none' } when you handle clicks yourself 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) => void", "kind": "callback", "description": "Called for page-level navigation (action: 'view' | 'edit')." }, + { "name": "searchableFields", "type": "string[]", "kind": "data", "description": "Fields the toolbar search matches." } + ] + }, + { + "tag": "ObjectGrid", + "schemaType": "object-grid", + "summary": "Lower-level data grid for an object (columns + filters). Prefer ListView for a full toolbar; use ObjectGrid for an embedded table.", + "props": [ + { "name": "objectName", "type": "string", "kind": "data", "required": true, "description": "The object to render." }, + { "name": "columns", "type": "ColumnDef[]", "kind": "data", "description": "Column definitions." }, + { "name": "filters", "type": "FilterArray", "kind": "controlled", "description": "ObjectQL filter." } + ] + }, + { + "tag": "ObjectMasterDetailForm", + "schemaType": "object-master-detail-form", + "summary": "Parent record + child line-items, edited and saved atomically (e.g. Invoice + line items).", + "props": [ + { "name": "objectName", "type": "string", "kind": "data", "required": true, "description": "Parent object." }, + { "name": "mode", "type": "'create' | 'edit'", "kind": "controlled", "description": "Create or edit the parent + children." }, + { "name": "recordId", "type": "string | number", "kind": "controlled", "description": "Parent record to edit." }, + { "name": "childObject", "type": "string", "kind": "data", "required": true, "description": "Child (line-item) object." }, + { "name": "relationshipField", "type": "string", "kind": "data", "required": true, "description": "Field on the child that points to the parent." }, + { "name": "columns", "type": "ColumnDef[]", "kind": "data", "required": true, "description": "Child line-item grid columns." }, + { "name": "totalField", "type": "string", "kind": "data", "description": "Parent field that rolls up the child amounts." }, + { "name": "onSuccess", "type": "(record) => void", "kind": "callback", "description": "After the atomic save." }, + { "name": "onCancel", "type": "() => void", "kind": "callback", "description": "On cancel." } + ] + }, + { + "tag": "ObjectChart", + "schemaType": "object-chart", + "summary": "Chart over an object's aggregated data (bar/line/area/pie/donut/…).", + "props": [ + { "name": "objectName", "type": "string", "kind": "data", "required": true, "description": "The object to aggregate." }, + { "name": "chartType", "type": "'bar' | 'line' | 'area' | 'pie' | 'donut' | 'scatter' | 'radar' | '…'", "kind": "data", "description": "Chart variant." }, + { "name": "aggregate", "type": "{ field, function, groupBy }", "kind": "data", "description": "Aggregation: function (sum/avg/count) over field, grouped by groupBy." }, + { "name": "filter", "type": "FilterArray", "kind": "controlled", "description": "ObjectQL filter to scope the data." } + ] + }, + { + "tag": "Block", + "schemaType": "(any)", + "summary": "Escape hatch — render any registered component by type when it isn't injected as a named block.", + "props": [ + { "name": "type", "type": "string", "kind": "data", "required": true, "description": "The registered component type, e.g. 'object-kanban'." } + ] + } + ], + "alsoAvailable": { + "note": "Other curated public blocks are reachable too. Kanban/calendar/gantt/timeline/map of an object = . Metrics/dashboards/pivots and record:* panels can be rendered via / until they are added to this contract.", + "scope": ["React", "useAdapter", "data", "variables", "page"] + } +} diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md new file mode 100644 index 0000000000..72ffd32ec4 --- /dev/null +++ b/skills/objectstack-ui/references/react-blocks.md @@ -0,0 +1,97 @@ +--- +title: React-tier component contract +description: The props each injected component accepts in kind:'react' page source (ADR-0081). GENERATED from contracts/react-blocks.contract.json — do not edit by hand. +--- + +{/* GENERATED by contracts/build-ref.mjs from react-blocks.contract.json — do not edit. */} + +# React-tier component contract (ADR-0081) + +Curated contract for the components injected into kind:'react' page source. Reference these by their PascalCase `tag` in JSX. `data` props mirror the objectui registry `inputs` (the same surface the html tier validates); `controlled` props are React-driven state (recordId, mode, filters…); `callback` props are React functions the component invokes. Layout is plain HTML + Tailwind — these blocks are for DATA. Filters use the ObjectQL array form: [field, op, value], e.g. ['status','=','active'] (ops: =, !=, >, <, contains, in). Live data: `const adapter = useAdapter(); await adapter.find(obj, {$filter, $top}); .findOne; .create; .update`. + +Prop **kind**: `data` = static/declarative (mirrors the registry inputs) · `controlled` = drive from React state · `callback` = a React function the component calls. + +## `` — `object-form` + +Server-connected create / edit / view form for one object. Auto-builds fields from the object schema. Built-in drawer/modal variants via formType. + +| prop | type | kind | required | description | +|------|------|------|:--------:|-------------| +| `objectName` | `string` | data | ✓ | The object whose record this form edits. | +| `fields` | `string[]` | data | | Limit/order the fields shown (defaults to the object's form fields). | +| `initialValues` | `Record` | data | | Prefill values in create mode. | +| `formType` | `'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'` | data | | Form presentation; 'drawer'/'modal' render the form in a built-in overlay (use drawerSide/drawerWidth/modalSize). | +| `navigateOnSuccess` | `string` | data | | URL to navigate to after save (supports {id}). | +| `mode` | `'create' \| 'edit' \| 'view'` | controlled | | Create a new record, edit/view an existing one. | +| `recordId` | `string \| number` | controlled | | Which record to load (edit/view mode). Drive from React state for master/detail. | +| `onSuccess` | `(record) => void` | callback | | Called after a successful create/update with the saved record. Typical: close a panel + bump a reload key. | +| `onError` | `(error: Error) => void` | callback | | Called when the save fails. | +| `onCancel` | `() => void` | callback | | Called when the user cancels. | +| `submitHandler` | `(values) => any \| Promise` | callback | | Custom persistence instead of the default create/update (the form just validates and hands over values). | + +## `` — `list-view` + +Server-connected object table with toolbar (search/filter/sort), and switchable views (grid/kanban/gallery/calendar/timeline/gantt/map). The primary object-list block. + +| prop | type | kind | required | description | +|------|------|------|:--------:|-------------| +| `objectName` | `string` | data | ✓ | The object to list. | +| `fields` | `string[]` | data | | Columns to show. | +| `sort` | `SortArray` | data | | Sort spec. | +| `viewType` | `'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map'` | data | | Which view to render (default grid). This is how you get a kanban/calendar/gantt of the object. | +| `navigation` | `{ mode: 'page' \| 'drawer' \| 'modal' \| 'split' \| 'none' }` | data | | What a row click does. Set { mode: 'none' } when you handle clicks yourself via onRowClick. | +| `searchableFields` | `string[]` | data | | Fields the toolbar search matches. | +| `filters` | `FilterArray` | controlled | | ObjectQL filter, e.g. ['status','=','active'] or ['and', [...], [...]]. Drive from React state for tabbed/searched lists. | +| `onRowClick` | `(record) => void` | callback | | Called with the clicked row's record. The hook for master/detail. | +| `onNavigate` | `(recordId, action) => void` | callback | | Called for page-level navigation (action: 'view' \| 'edit'). | + +## `` — `object-grid` + +Lower-level data grid for an object (columns + filters). Prefer ListView for a full toolbar; use ObjectGrid for an embedded table. + +| prop | type | kind | required | description | +|------|------|------|:--------:|-------------| +| `objectName` | `string` | data | ✓ | The object to render. | +| `columns` | `ColumnDef[]` | data | | Column definitions. | +| `filters` | `FilterArray` | controlled | | ObjectQL filter. | + +## `` — `object-master-detail-form` + +Parent record + child line-items, edited and saved atomically (e.g. Invoice + line items). + +| prop | type | kind | required | description | +|------|------|------|:--------:|-------------| +| `objectName` | `string` | data | ✓ | Parent object. | +| `childObject` | `string` | data | ✓ | Child (line-item) object. | +| `relationshipField` | `string` | data | ✓ | Field on the child that points to the parent. | +| `columns` | `ColumnDef[]` | data | ✓ | Child line-item grid columns. | +| `totalField` | `string` | data | | Parent field that rolls up the child amounts. | +| `mode` | `'create' \| 'edit'` | controlled | | Create or edit the parent + children. | +| `recordId` | `string \| number` | controlled | | Parent record to edit. | +| `onSuccess` | `(record) => void` | callback | | After the atomic save. | +| `onCancel` | `() => void` | callback | | On cancel. | + +## `` — `object-chart` + +Chart over an object's aggregated data (bar/line/area/pie/donut/…). + +| prop | type | kind | required | description | +|------|------|------|:--------:|-------------| +| `objectName` | `string` | data | ✓ | The object to aggregate. | +| `chartType` | `'bar' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'scatter' \| 'radar' \| '…'` | data | | Chart variant. | +| `aggregate` | `{ field, function, groupBy }` | data | | Aggregation: function (sum/avg/count) over field, grouped by groupBy. | +| `filter` | `FilterArray` | controlled | | ObjectQL filter to scope the data. | + +## `` — `(any)` + +Escape hatch — render any registered component by type when it isn't injected as a named block. + +| prop | type | kind | required | description | +|------|------|------|:--------:|-------------| +| `type` | `string` | data | ✓ | The registered component type, e.g. 'object-kanban'. | + +## Also available + +Other curated public blocks are reachable too. Kanban/calendar/gantt/timeline/map of an object = . Metrics/dashboards/pivots and record:* panels can be rendered via / until they are added to this contract. + +Injected scope (closure variables, reference directly — not props): `React`, `useAdapter`, `data`, `variables`, `page`.