diff --git a/.changeset/react-tier-record-blocks-withdrawn.md b/.changeset/react-tier-record-blocks-withdrawn.md new file mode 100644 index 0000000000..1eb06fd48d --- /dev/null +++ b/.changeset/react-tier-record-blocks-withdrawn.md @@ -0,0 +1,42 @@ +--- +"@objectstack/spec": minor +"@objectstack/lint": minor +--- + +fix(spec,lint): withdraw the `record:*` blocks from the react tier — no renderer read the props it published (#4413) + +The react-tier contract published `objectName` / `recordId` on +``, ``, `` and +``, and no renderer read either prop. All ten `record:*` renderers +take their record from `useRecordContext()`, which only the record route +(`RecordDetailView`) and the metadata editor's preview (`PagePreview`) ever +mount; the `kind:'react'` page renderer wraps the page in a +`SchemaRendererProvider` alone. So the blocks rendered their "bind a record to +preview" placeholder — or, for `record:related_list` (the one that does read +`schema.objectName`), refused to fetch because the parent id never arrived. A +page authored exactly to contract came back EMPTY with nothing reported +anywhere, including by `os validate`, which resolved those props' field names +against the object they named: lint standing guard over a binding that never +ran. + +Withdrawn rather than implemented. The contract was not merely unimplemented, +it was the wrong SHAPE: per-block bindings describe four independent fetches of +one record, which is exactly the coupling the shared record context exists to +prevent (`record:details` drops the fields a mounted `record:highlights` +registered; one inline-edit save bar commits them all under a single +`ifMatch`). Honoring the props would have fossilized that (Prime Directive +#12). The naming of that primitive — a record SCOPE an author wraps around the +family, one fetch, shared context — is the open design question, filed as #4444. + +`@objectstack/spec` drops the four blocks from `REACT_BLOCKS` and gains the +ledger for why, plus the working replacement per type. The family is derived +from `ComponentPropsMap`, so a record component added later is gated the day it +lands — including the six that were never in the contract but are just as +reachable through the registry-built react scope. + +`@objectstack/lint` gains `react-block-needs-record-context` (error), which +rejects them on a react page by tag and through `` +alike, quoting the block that does work: `', '=', +parentId]}>` for a related list, `` for a +field panel. A locally-declared component of the same name shadows the injected +scope and is left alone. diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 5cadf05f09..3cd54d1da2 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -290,16 +290,36 @@ filter chip, or one form field short. Checked on every injected block: ``'s `fields`/`columns`/`sort`/`grouping`/`userFilters`, ``'s `fields`, `initialValues` keys, `sections[].fields[]` and `subforms` (each against its own -`childObject`), and `` / `` / `` / -`` — those last four through the **same** descriptor table -§5 uses, so a component's field-bearing props are described once and checked on -both surfaces. `` reaches that table by the type the author -writes, so the escape hatch is covered rather than left as a hole. - -`` is the **related (child)** object whose records -are listed — the parent record is bound by `recordId`, and `relationshipField` -is the child's field pointing back at it. Passing the parent there is the -mistake this check was extended to catch. +`childObject`). `` reaches the §5 descriptor table by the type +the author writes, so the escape hatch is covered rather than left as a hole. + +### 10b. A `record:*` block on a react page + +The `record:*` family — ``, ``, +``, ``, and the rest — renders from the record +context a **record page** mounts once for the record it routed to. A +`kind:'react'` page mounts no such context, so these blocks render empty +whatever props they are given; the react contract published `objectName` / +`recordId` for four of them and no renderer ever read either. + +```jsx + +// ↑ error: renders empty here — those props are not read +``` + +They are withdrawn from the react tier, and using one is an **error** +(`react-block-needs-record-context`) — by tag, and through +`` alike. On a react page the parent record is ordinary +React state, so bind it with a block that reads its own props: `', '=', parentId]}>` for a related list, +`` for a field panel. To use the family +itself, author the page as `type:'record'`. + +On a **record page**, where these blocks do work, §5 checks their field-bearing +props, and `` is the **related (child)** object +whose records are listed — the parent record comes from the page, and +`relationshipField` is the child's field pointing back at it. Passing the parent +there is the mistake that check was extended to catch. A **filter position** is the exception that gates: diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 875a3183fe..55e4e13323 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -144,7 +144,7 @@ Components are the building blocks placed inside regions. The `type` field is a union of the standard `PageComponentType` enum and any custom string. The standard (namespaced) component types include: - **Structure:** `page:header`, `page:footer`, `page:sidebar`, `page:tabs`, `page:accordion`, `page:card`, `page:section` -- **Record context:** `record:details`, `record:highlights`, `record:related_list`, `record:activity`, `record:chatter`, `record:path`, `record:alert`, `record:quick_actions`, `record:reference_rail`, `record:history` +- **Record context:** `record:details`, `record:highlights`, `record:related_list`, `record:activity`, `record:chatter`, `record:path`, `record:alert`, `record:quick_actions`, `record:reference_rail`, `record:history` — each renders from the record context a **record page** mounts, so they belong on a `type:'record'` page. A `kind:'react'` page mounts no such context and `os validate` rejects them there (see Validating metadata §10b) - **Navigation:** `app:launcher`, `nav:menu`, `nav:breadcrumb` - **Utility:** `global:search`, `global:notifications`, `user:profile` - **AI:** `ai:chat_window`, `ai:suggestion` diff --git a/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts b/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts index aec955a207..38a20f3d14 100644 --- a/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts +++ b/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts @@ -6,16 +6,16 @@ import { definePage } from '@objectstack/spec/ui'; * Renewals Pipeline — a `kind:'react'` business scenario (ADR-0081). * * A renewals manager works a list of accounts by lifecycle stage; selecting one - * drives a 360° panel (highlights + invoices + a value-by-status chart) and a - * pre-styled `` to update the account in place. + * drives a 360° panel (account summary + invoices + a value-by-status chart) and + * a pre-styled `` to update the account in place. * Every block prop is taken straight from the react-tier contract * (skills/objectstack-ui/references/react-blocks.md). * * The 360 panel deliberately shows BOTH rollup styles side by side: * • hand-rolled — a `useAdapter()` effect counts related projects/invoices * into a KPI strip (full control, you own loading/refresh), vs - * • framework blocks — ``/`` do the same - * cross-object reads declaratively (zero data code). + * • framework blocks — ``/`` do the same cross-object + * reads declaratively (zero data code). * (This comparison absorbed the former Account Cockpit page.) * * The chart is written in the spec `ChartConfig` shape (#3729) and its axes are @@ -24,14 +24,20 @@ import { definePage } from '@objectstack/spec/ui'; * `groupBy`) and `total` (its `field`) — not by a dataset-style measure name. * `os validate` checks both halves. * - * `` binds the CHILD object it lists (`showcase_invoice`), - * not the parent — the parent is `recordId`, and `relationshipField="account"` - * is the invoice's lookup back to it. This page used to pass the parent, which - * is what #4340 found: the react contract had glossed `objectName` as "the - * parent object" while the schema (and the renderer behind both surfaces) read - * it as the related one, so the list resolved `total` against an account and - * came back empty. Every field-bearing prop on the page is now checked against - * the object it actually names. + * The selected account is bound BY REACT STATE, not by a record context: `sel` + * is the parent id, so the invoice list is an ordinary `` filtered on + * the child's lookup (`['account', '=', sel]`) and the summary is an + * ``. Both read their binding from their own props, + * which is what makes them work on this tier. + * + * This panel used to be `` + ``, and both + * rendered EMPTY here (#4413): every `record:*` block takes its record from the + * context a record page mounts, and a react page mounts none — the + * `objectName`/`recordId` the contract published for them were read by no + * renderer. They are out of the react tier now, and `os validate` rejects them + * on this surface rather than letting the next author rediscover it at runtime. + * (#4340's finding still holds where it applies: on a RECORD page + * `` is the CHILD object, never the parent.) * * Styling (ADR-0065): no Tailwind — inline `style={{}}` with `hsl(var(--token))`; * data blocks and the drawer bring their own compiled styling. The drawer sets @@ -121,7 +127,7 @@ function Page() { - +
@@ -131,7 +137,8 @@ function Page() { - +

Invoices

+ {editing ? ( `, - // ``, the `record:*` family through the SAME + // ``, `` through the SAME // `COMPONENT_FIELD_SPECS` table `validatePageFieldBindings` walks one surface // over, plus ``'s aggregate/axes (#3701/#3729) and // `searchableFields` (#4329). Squarely the charter's question, on the surface // where it had no answer at all. // + // It also carries `react-block-needs-record-context` (#4413) — a BINDING + // question rather than a resolution one: the `record:*` family reads its + // record from a record page's context, so on THIS surface the binding does + // not exist at all and the props the contract published for it were read by + // no renderer. This rule used to resolve those props' field names against + // the object they named — lint standing guard over a binding that never ran. + // It rejects the blocks now, out of the same parse. + // // It was hand-wired into `os validate` ALONE, so `os lint` and `os compile` // accepted a react page whose every field binding was stale — including the // gating ones (a missing required binding, a filter position naming no field: diff --git a/packages/lint/src/validate-react-page-props.test.ts b/packages/lint/src/validate-react-page-props.test.ts index 55d00e9683..31ba8fa31a 100644 --- a/packages/lint/src/validate-react-page-props.test.ts +++ b/packages/lint/src/validate-react-page-props.test.ts @@ -5,6 +5,7 @@ import { REACT_CHART_FIELD_UNKNOWN, REACT_CHART_AGGREGATE_INVALID, REACT_CHART_AXIS_UNKNOWN, + REACT_BLOCK_NEEDS_RECORD_CONTEXT, type ReactPropFinding as PropFinding, } from './validate-react-page-props.js'; import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js'; @@ -561,102 +562,98 @@ describe('validateReactPageProps — field props (#4340)', () => { }); }); -describe('validateReactPageProps — record:* blocks share the metadata table (#4340)', () => { - it('flags against its object', () => { - const f = unknownFields( - validateReactPageProps( - propsPage(jsx('RecordHighlights', `objectName="crm_account" recordId={1} fields={['name', 'nope']}`)), - ), - ); +/** + * #4413. These blocks were published by the react contract with + * `objectName`/`recordId` props, and no renderer read either: they take their + * record from the context a RECORD page mounts, and a react page mounts none. + * A page authored exactly to contract therefore rendered EMPTY, silently — + * and this rule, which used to resolve those props' field names against the + * object they named, was lint standing guard over a binding that never ran. + * + * The props are withdrawn; what is left to check is that the block is not here + * at all, loudly, at publish time. + */ +describe('validateReactPageProps — record:* blocks need a context this surface lacks (#4413)', () => { + const rejections = (f: PropFinding[]) => + f.filter((x) => x.rule === REACT_BLOCK_NEEDS_RECORD_CONTEXT); + + it('rejects each block the contract used to publish', () => { + for (const tag of ['RecordDetails', 'RecordHighlights', 'RecordRelatedList', 'RecordPath']) { + const f = validateReactPageProps( + propsPage(jsx(tag, `objectName="crm_account" recordId={1}`)), + ); + const rejected = rejections(f); + expect(rejected).toHaveLength(1); + expect(rejected[0].severity).toBe('error'); + expect(rejected[0].where).toBe(`page "p" › <${tag}>`); + expect(rejected[0].message).toContain('renders empty'); + } + }); + + it('rejects the record:* blocks that were never in the contract either', () => { + // The injected scope is built from the whole public registry, so these are + // just as reachable — and just as empty — as the four that were published. + const f = rejections(validateReactPageProps(propsPage(jsx('RecordActivity', `objectName="crm_account"`)))); expect(f).toHaveLength(1); - expect(f[0].path).toBe('pages[0].source › fields[1]'); - }); - - it('flags and its authored sections', () => { - const f = unknownFields( - validateReactPageProps( - propsPage( - jsx('RecordDetails', `objectName="crm_account" fields={['nope']} hideFields={['gone']}`), - ), - ), - ); - expect(f.map((x) => x.path)).toEqual([ - 'pages[0].source › fields[0]', - 'pages[0].source › hideFields[0]', - ]); + expect(f[0].message).toContain('record:activity'); }); - it('leaves alone when it is the declared string[] of section IDs', () => { - const f = validateReactPageProps( - propsPage(jsx('RecordDetails', `objectName="crm_account" layout="custom" sections={['overview', 'billing']}`)), + it('names a block that actually works in the hint', () => { + const [related] = rejections( + validateReactPageProps(propsPage(jsx('RecordRelatedList', `objectName="crm_invoice" recordId={1}`))), ); - expect(f).toEqual([]); - }); - - it('flags ', () => { - const f = unknownFields( - validateReactPageProps(propsPage(jsx('RecordPath', `objectName="crm_account" statusField="nope"`))), + expect(related.hint).toContain(' binds the CHILD object (#4340)', () => { - const related = (attrs: string) => jsx('RecordRelatedList', attrs); - it('resolves columns/sort/relationshipField against the RELATED object', () => { + it('says nothing else about a block it has already rejected', () => { + // No point resolving `columns` against an object for a block that cannot + // render here — one clear finding beats a pile of derived ones. const f = validateReactPageProps( - propsPage( - related( - `objectName="crm_invoice" recordId={1} relationshipField="account_id" columns={['name', 'total']}`, - ), - ), + propsPage(jsx('RecordRelatedList', `objectName="crm_account" columns={['nope']} statusField="gone"`)), ); - expect(f).toEqual([]); + expect(f).toHaveLength(1); + expect(f[0].rule).toBe(REACT_BLOCK_NEEDS_RECORD_CONTEXT); }); - it('flags the parent-object mix-up the old contract gloss invited', () => { - // The exact shape #4340 found live: the author passed the PARENT object and - // named the child's columns + the child's own FK. Neither `total` nor - // `account_id` is on the account, so both positions report. - const f = unknownFields( + it('rejects the same components reached through ', () => { + const f = rejections( validateReactPageProps( - propsPage( - related(`objectName="crm_account" recordId={1} relationshipField="account_id" columns={['name', 'total']}`), - ), + propsPage(jsx('Block', `type="record:highlights" objectName="crm_account" fields={['name']}`)), ), ); - expect(f.map((x) => x.path)).toEqual([ - 'pages[0].source › columns[1]', - 'pages[0].source › relationshipField', - ]); - expect(f[0].message).toContain('"total"'); - expect(f[0].message).toContain('crm_account'); + expect(f).toHaveLength(1); + expect(f[0].where).toBe('page "p" › '); + expect(f[0].message).toContain('record:highlights'); }); - it('says nothing about relationshipValueField — the parent object is unbound here', () => { + it('leaves an author-defined component of the same name alone', () => { + // A local declaration shadows the injected scope, so this `` is + // the author's own component — flagging it would block a publish over a + // name collision. const f = validateReactPageProps( propsPage( - related(`objectName="crm_invoice" recordId={1} relationshipField="account_id" relationshipValueField="nope"`), + 'function Page(){ const RecordPath = ({ v }) => {v}; return ; }', ), ); expect(f).toEqual([]); }); - it('resolves the Add picker against its OWN object', () => { - const f = unknownFields( - validateReactPageProps( - propsPage( - related( - `objectName="crm_invoice" recordId={1} relationshipField="account_id" ` + - `add={{ picker: { object: 'crm_account', labelField: 'nope' }, linkField: 'total' }}`, - ), - ), - ), - ); - expect(f).toHaveLength(1); - expect(f[0].path).toBe('pages[0].source › add.picker.labelField'); + it('says nothing on a metadata page — this is a react-surface rule', () => { + const f = validateReactPageProps({ + objects: [account], + pages: [ + { + name: 'p', + kind: 'default', + regions: [{ components: [{ type: 'record:highlights', properties: { fields: ['name'] } }] }], + }, + ], + }); + expect(f).toEqual([]); }); }); @@ -664,7 +661,7 @@ describe('validateReactPageProps — escape hatch (#4340)', () => { it('checks the props bag by the registered type the author names', () => { const f = unknownFields( validateReactPageProps( - propsPage(jsx('Block', `type="record:highlights" objectName="crm_account" fields={['nope']}`)), + propsPage(jsx('Block', `type="element:form" objectName="crm_account" fields={['nope']}`)), ), ); expect(f).toHaveLength(1); @@ -672,15 +669,6 @@ describe('validateReactPageProps — escape hatch (#4340)', () => { expect(f[0].path).toBe('pages[0].source › fields[0]'); }); - it('reaches the related-list branch through too', () => { - const f = unknownFields( - validateReactPageProps( - propsPage(jsx('Block', `type="record:related_list" objectName="crm_account" columns={['total']}`)), - ), - ); - expect(f).toHaveLength(1); - }); - it('skips a type with no descriptor, and a non-static type', () => { const noSpec = validateReactPageProps( propsPage(jsx('Block', `type="object-kanban" objectName="crm_account" fields={['nope']}`)), diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts index 1d30a1d48c..0489e63c72 100644 --- a/packages/lint/src/validate-react-page-props.ts +++ b/packages/lint/src/validate-react-page-props.ts @@ -22,6 +22,10 @@ // metadata rule `searchable-field-unknown`, sharing its core. // - EVERY OTHER field-bearing prop a react block can author (#4340) — see // `REACT_FIELD_SPECS` below and the ledger beside it. +// - a `record:*` block on this surface at all (#4413) → error. They render +// from a record page's shared record context, which a react page does not +// mount, so they come back empty however they are bound. See +// `recordContextFinding`. // // Reading values is opt-in per block and per prop: everything below evaluates // only STATIC literals (`objectName="invoice"`, an `aggregate={{…}}` object @@ -31,7 +35,13 @@ import { createRequire } from 'node:module'; import type ts from 'typescript'; -import { REACT_BLOCKS, chartAggregateResultKeys } from '@objectstack/spec/ui'; +import { + REACT_BLOCKS, + RECORD_CONTEXT_BLOCK_TAGS, + REACT_RECORD_BLOCK_ALTERNATIVES, + chartAggregateResultKeys, + isRecordContextBlockType, +} from '@objectstack/spec/ui'; import { VALID_AST_OPERATORS } from '@objectstack/spec/data'; import { checkSearchableFieldList, @@ -39,12 +49,10 @@ import { } from './validate-searchable-fields.js'; import { COMPONENT_FIELD_SPECS, - RELATED_LIST_TYPE, checkFieldRefs, componentFieldRefs, fieldRefsFrom, indexObjectFields, - relatedListFieldRefs, sortFieldRefs, type FieldRef, type PageFieldFinding, @@ -398,28 +406,25 @@ function checkObjectChart( // // ## Where the answers come from // -// The `record:*` blocks ARE the components that rule already walks — one -// registry component, two authoring surfaces — so they are not re-described -// here at all: `componentFieldRefs` / `relatedListFieldRefs` read the SAME -// `COMPONENT_FIELD_SPECS` table, keyed by the block's own `schemaType`. A prop -// added there is checked on both surfaces at once, which is the point. +// `REACT_FIELD_SPECS` below describes the blocks whose metadata twin lives +// under different prop names — `` (twin: a list page's +// `interfaceConfig`) and `` (twin: `element:form` + the +// form-layout rule) — plus ``'s `filter`. // -// `REACT_FIELD_SPECS` below covers only what the shared table cannot: the two -// blocks whose metadata twin lives under different prop names — -// `` (twin: a list page's `interfaceConfig`) and `` -// (twin: `element:form` + the form-layout rule). +// `COMPONENT_FIELD_SPECS`, the table `validate-page-field-bindings` walks on +// the metadata surface, is still read from here, keyed by `schemaType`, so a +// prop added there is checked on both surfaces at once. What reaches it is now +// only ``: this rule read that table mainly for the +// `record:*` blocks, and #4413 withdrew those from the tier entirely (they +// render from a record context this surface does not mount — see +// `recordContextFinding` below). The table's `record:*` rows are not dead, +// they are simply the metadata surface's alone again. // // ## What is deliberately NOT checked, and why // // - Anything non-static (a variable, a call, a value behind a spread) — // ADR-0072 D1: unresolvable is not wrong. `filters` is the one place this // is resolved PER POSITION rather than all-or-nothing; see below. -// - ``: it names a field on the -// PARENT object, and the react surface binds the parent by `recordId` -// only — there is no parent OBJECT to resolve against. The metadata twin -// has the page's object and checks it there. This is the ONE field-bearing -// prop in the index that stays unresolved, and the reason is a missing -// binding rather than a missing rule. // - ``'s axes: they name the aggregate's RESULT COLUMNS, not // fields, and `checkObjectChart` above already owns them. // @@ -630,8 +635,7 @@ function reactFieldRefs( * * 1. `REACT_FIELD_SPECS` — the react-only descriptors (`ListView`, * `ObjectForm`, `ObjectChart`'s `filter`). - * 2. the `record:related_list` split, when the block IS that component. - * 3. `COMPONENT_FIELD_SPECS`, keyed by the block's `schemaType` — the shared + * 2. `COMPONENT_FIELD_SPECS`, keyed by the block's `schemaType` — the shared * table the metadata surface already uses. `` reaches it * by the type the author wrote, which is what makes the escape hatch * checked rather than a hole. @@ -667,28 +671,19 @@ function checkBlockFieldProps( out.push(...checkFieldRefs(subs.parent, objectName, objectFields, where)); } - // `` renders the registered component the - // author names; every other block's type is fixed by its tag. + // `` renders the registered component the author + // names; every other block's type is fixed by its tag. A `record:*` type + // never arrives here — `recordContextFinding` rejected it upstream. const schemaType = tag === 'Block' ? strOf(values.get('type')) : SCHEMA_TYPE_BY_TAG.get(tag); - if (schemaType) { - const props = readableProps(values); - if (schemaType === RELATED_LIST_TYPE) { - const split = relatedListFieldRefs(props, path, PATH_SEP); - out.push(...checkFieldRefs(split.related, split.relatedObject, objectFields, where)); - out.push(...checkFieldRefs(split.picker, split.pickerObject, objectFields, where)); - // `split.parent` (`relationshipValueField`) is deliberately dropped: the - // react surface binds the parent RECORD (`recordId`) but never its - // object, so there is nothing to resolve it against. See the section note. - } else if (COMPONENT_FIELD_SPECS[schemaType]) { - out.push( - ...checkFieldRefs( - componentFieldRefs(schemaType, props, path, PATH_SEP) ?? [], - objectName, - objectFields, - where, - ), - ); - } + if (schemaType && COMPONENT_FIELD_SPECS[schemaType]) { + out.push( + ...checkFieldRefs( + componentFieldRefs(schemaType, readableProps(values), path, PATH_SEP) ?? [], + objectName, + objectFields, + where, + ), + ); } // The two finding shapes are structurally identical; `where`/`path` are @@ -696,6 +691,79 @@ function checkBlockFieldProps( return out as ReactPropFinding[]; } +// ─── The `record:*` family is not of this surface (#4413) ───────────────── +// +// Every `record:*` renderer takes its record from the context a RECORD PAGE +// mounts once (`RecordDetailView` fetches, N blocks render it, and they +// coordinate through it — highlights dedupe out of the detail grid, one +// inline-edit save bar commits them all under one version). A `kind:'react'` +// page mounts no such context: `useRecordContext()` returns null, and each +// block renders its designer placeholder — or, for `record:related_list`, +// refuses to fetch because the parent id never arrives. +// +// The react tier had published `objectName`/`recordId` on four of them anyway +// and no renderer read either, so a page authored exactly to contract rendered +// EMPTY with nothing reported anywhere — including by this file, which +// cheerfully resolved those props' field names against the object they named. +// #4413 withdrew the props (see the ledger in `@objectstack/spec/ui`); this +// turns what was a silent blank into a publish-time error, which is the half +// that keeps an AI author from writing them again from memory. +// +// The check is by TYPE, not by the withdrawn tag list: the scope objectui +// injects is built from the whole public registry, so every `record:*` +// component — including the six that were never in the contract +// (`record:activity`, `record:chatter`, …) — is reachable here and equally +// empty. `` is the same reach with the type spelled out. + +export const REACT_BLOCK_NEEDS_RECORD_CONTEXT = 'react-block-needs-record-context'; + +const RECORD_BLOCK_GENERIC_FIX = + 'author the page as `type:\'record\'` — a record page mounts the record context these blocks render from.'; + +function recordContextFinding( + tag: string, + schemaType: string, + where: string, + path: string, +): ReactPropFinding { + return { + severity: 'error', + rule: REACT_BLOCK_NEEDS_RECORD_CONTEXT, + where, + path, + message: + `<${tag}> renders "${schemaType}", which reads its record from the record context a ` + + `record page mounts — a kind:'react' page never mounts one, so the block renders empty ` + + `no matter how it is bound (its objectName/recordId are not read by the renderer).`, + hint: + `On a react page bind the record yourself: ` + + `${REACT_RECORD_BLOCK_ALTERNATIVES[schemaType] ?? RECORD_BLOCK_GENERIC_FIX}`, + }; +} + +/** + * Names the page source binds itself — every function/variable declaration, + * at any depth (a react page declares its helper components INSIDE `Page`). + * + * A local declaration SHADOWS the injected scope, so `const RecordPath = …` + * followed by `` is the author's own component and none of this + * rule's business. Cheap to honor and it removes the whole false-positive + * class from a gate that BLOCKS a publish — the older prop checks above are + * left alone deliberately: they only ever warn about a near-miss prop name or + * a missing required one, which is survivable advice on a shadowed tag. + */ +function localComponentNames(tsc: typeof ts, sf: ts.SourceFile): Set { + const names = new Set(); + const walk = (node: ts.Node): void => { + if (tsc.isFunctionDeclaration(node) && node.name) names.add(node.name.text); + else if (tsc.isVariableDeclaration(node) && tsc.isIdentifier(node.name)) names.add(node.name.text); + else if (tsc.isImportSpecifier(node)) names.add(node.name.text); + tsc.forEachChild(node, walk); + }; + walk(sf); + return names; +} + export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { const findings: ReactPropFinding[] = []; const objectFields = indexObjectFields(stack); @@ -723,9 +791,22 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { continue; // the syntax gate reports unparseable sources } + const locals = localComponentNames(tsc, sf); + const visit = (node: ts.Node): void => { if (tsc.isJsxOpeningElement(node) || tsc.isJsxSelfClosingElement(node)) { const tag = node.tagName.getText(sf); + const where = `page "${name}" › <${tag}>`; + const path = `pages[${p}].source`; + // A withdrawn `record:*` block, reached by its injected tag. Reported + // and then dropped: the prop checks below have nothing useful to add + // about a block that cannot render here at all. + const recordType = RECORD_CONTEXT_BLOCK_TAGS.get(tag); + if (recordType && !locals.has(tag)) { + findings.push(recordContextFinding(tag, recordType, where, path)); + tsc.forEachChild(node, visit); + return; + } const block = BLOCKS.get(tag); if (block) { let hasSpread = false; @@ -744,8 +825,19 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { ); } } - const where = `page "${name}" › <${tag}>`; - const path = `pages[${p}].source`; + // The escape hatch reaches the same withdrawn components by type — + // `` is `` spelled + // out, and just as empty. Checked here rather than by tag because + // the type is an attribute VALUE (and a non-static one is + // unresolvable, not wrong — ADR-0072 D1). + if (tag === 'Block') { + const blockType = strOf(values.get('type')); + if (blockType && isRecordContextBlockType(blockType)) { + findings.push(recordContextFinding(tag, blockType, where, path)); + tsc.forEachChild(node, visit); + return; + } + } if (!hasSpread) { for (const req of block.requiredBindings) { if (!used.has(req)) { diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 2c68a5880c..a2fcc7ac69 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3470,6 +3470,9 @@ "PluralRuleSchema (const)", "REACT_BLOCKS (const)", "REACT_OVERLAY_SHADOWS (const)", + "REACT_RECORD_BLOCK_ALTERNATIVES (const)", + "RECORD_CONTEXT_BLOCK_TAGS (const)", + "RECORD_CONTEXT_TYPE_PREFIX (const)", "ReactBlockDef (interface)", "ReactInteractionProp (interface)", "ReactPropKind (type)", @@ -3601,9 +3604,11 @@ "expandViewContainer (function)", "expandViewContainerWithDiagnostics (function)", "isAggregatedViewContainer (function)", + "isRecordContextBlockType (function)", "normalizeFilterOperator (function)", "normalizeInlineAction (function)", "pageForm (const)", + "reactBlockTagFor (function)", "reportForm (const)", "reportSelectionOrder (function)", "validateActionParams (function)", diff --git a/packages/spec/react-conformance.baseline.json b/packages/spec/react-conformance.baseline.json index 7d1715f7bc..d5f100be08 100644 --- a/packages/spec/react-conformance.baseline.json +++ b/packages/spec/react-conformance.baseline.json @@ -1,5 +1,5 @@ { - "_comment": "Accepted spec↔frontend conformance baseline (react blocks). Per block: the frontend-only prop set (component exposes, spec does not declare) and whether the block is missing. Regenerate with: MANIFEST=… check:react-conformance --baseline --update. The ratchet flags only NEW frontend-only props or newly-missing blocks.", + "_comment": "Accepted spec\u2194frontend conformance baseline (react blocks). Per block: the frontend-only prop set (component exposes, spec does not declare) and whether the block is missing. Regenerate with: MANIFEST=\u2026 check:react-conformance --baseline --update. The ratchet flags only NEW frontend-only props or newly-missing blocks.", "blocks": { "ObjectForm": { "frontendOnly": [], @@ -12,22 +12,6 @@ "ObjectChart": { "frontendOnly": [], "missing": false - }, - "RecordDetails": { - "frontendOnly": [], - "missing": false - }, - "RecordHighlights": { - "frontendOnly": [], - "missing": false - }, - "RecordRelatedList": { - "frontendOnly": [], - "missing": false - }, - "RecordPath": { - "frontendOnly": [], - "missing": false } } } diff --git a/packages/spec/src/ui/react-blocks.test.ts b/packages/spec/src/ui/react-blocks.test.ts index 12255f11a1..9cdfe14c06 100644 --- a/packages/spec/src/ui/react-blocks.test.ts +++ b/packages/spec/src/ui/react-blocks.test.ts @@ -7,7 +7,15 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; -import { REACT_BLOCKS, REACT_OVERLAY_SHADOWS } from './react-blocks'; +import { + REACT_BLOCKS, + REACT_OVERLAY_SHADOWS, + REACT_RECORD_BLOCK_ALTERNATIVES, + RECORD_CONTEXT_BLOCK_TAGS, + isRecordContextBlockType, + reactBlockTagFor, +} from './react-blocks'; +import { ComponentPropsMap } from './component.zod'; /** The prop names a block's spec schema declares, as the contract generator reads them. */ function schemaPropNames(schema: unknown): string[] { @@ -64,26 +72,60 @@ describe('REACT_BLOCKS — overlay/schema seam', () => { for (const tag of Object.keys(REACT_OVERLAY_SHADOWS)) expect(tags.has(tag)).toBe(true); }); - /** - * The `record:related_list` reading, pinned where an author and the linter - * both read it. `validate-page-field-bindings` resolves this component's - * `columns`/`sort`/`filter` against `properties.objectName` as the RELATED - * object on the metadata surface, and `validate-react-page-props` now does - * the same on the react surface — both are wrong the moment this description - * says "parent" again. - */ - it(' is published as the related (child) object', () => { - const block = REACT_BLOCKS.find((b) => b.tag === 'RecordRelatedList'); - const objectName = block?.interactions.find((i) => i.name === 'objectName'); - expect(objectName).toBeDefined(); - expect(objectName!.description).toMatch(/RELATED \(child\) object/); - expect(objectName!.description).toMatch(/NOT the parent/); - // The spec schema is the authority it must agree with. - expect(schemaPropNames(block!.schema)).toContain('objectName'); - }); - it('every block tag is unique (the contract is keyed by it)', () => { const tags = REACT_BLOCKS.map((b) => b.tag); expect(new Set(tags).size).toBe(tags.length); }); }); + +/** + * #4413. Four `record:*` blocks were published here with `objectName` / + * `recordId` overlay props that NO renderer reads: every one of them takes its + * record from the context a record page mounts, and a `kind:'react'` page + * mounts none — so a page authored exactly to contract rendered empty, silently. + * + * The index is the authority the publish gate reads, so the exclusion has to + * hold HERE or the gate rejects what the contract still advertises (or, worse, + * stops rejecting what came back). + */ +describe('REACT_BLOCKS — the record:* family is out (#4413)', () => { + it('publishes no block that needs a record context', () => { + const offenders = REACT_BLOCKS.filter((b) => isRecordContextBlockType(b.schemaType)); + expect(offenders.map((b) => b.tag)).toEqual([]); + }); + + it('covers every record:* type the spec declares, under the tag the react scope injects', () => { + const specTypes = Object.keys(ComponentPropsMap).filter(isRecordContextBlockType); + // Not a hand-kept list: a record component added to ComponentPropsMap is + // gated the day it lands, under the tag objectui's `toPascal` gives it. + expect(specTypes.length).toBeGreaterThan(0); + expect([...RECORD_CONTEXT_BLOCK_TAGS.entries()].sort()).toEqual( + specTypes.map((t) => [reactBlockTagFor(t), t]).sort(), + ); + // The four that were published, spelled out — the regression this pins. + for (const tag of ['RecordDetails', 'RecordHighlights', 'RecordRelatedList', 'RecordPath']) { + expect(RECORD_CONTEXT_BLOCK_TAGS.has(tag)).toBe(true); + } + }); + + it('classifies by the `record:` prefix, not by a tag spelling', () => { + expect(isRecordContextBlockType('record:related_list')).toBe(true); + expect(isRecordContextBlockType('record:activity')).toBe(true); + expect(isRecordContextBlockType('list-view')).toBe(false); + expect(isRecordContextBlockType('element:record_picker')).toBe(false); + }); + + it('names a working replacement for each withdrawn block', () => { + // The gate quotes these; an empty one would leave an author with a refusal + // and no way forward. + for (const type of ['record:details', 'record:highlights', 'record:related_list', 'record:path']) { + expect(REACT_RECORD_BLOCK_ALTERNATIVES[type]).toBeTruthy(); + } + // Each names a block the react tier actually publishes. + const tags = REACT_BLOCKS.map((b) => b.tag); + expect(REACT_RECORD_BLOCK_ALTERNATIVES['record:related_list']).toContain('ListView'); + expect(REACT_RECORD_BLOCK_ALTERNATIVES['record:details']).toContain('ObjectForm'); + expect(tags).toContain('ListView'); + expect(tags).toContain('ObjectForm'); + }); +}); diff --git a/packages/spec/src/ui/react-blocks.ts b/packages/spec/src/ui/react-blocks.ts index e889cda3d7..24a7e8f00e 100644 --- a/packages/spec/src/ui/react-blocks.ts +++ b/packages/spec/src/ui/react-blocks.ts @@ -14,12 +14,7 @@ import type { ZodTypeAny } from 'zod'; import { ListViewSchema, FormViewSchema } from './view.zod'; -import { - RecordDetailsProps, - RecordRelatedListProps, - RecordHighlightsProps, - RecordPathProps, -} from './component.zod'; +import { ComponentPropsMap } from './component.zod'; import { ChartConfigSchema } from './chart.zod'; export type ReactPropKind = 'data' | 'binding' | 'controlled' | 'callback'; @@ -69,11 +64,94 @@ export const REACT_OVERLAY_SHADOWS: Readonly> // the React-side rule that pairs it with `onRowClick` — neither of which the // declarative schema has anywhere to say. ListView: ['navigation'], - // Same prop, same meaning as the schema's "Related object name". Kept in the - // overlay because it IS this block's data binding (`kind: 'binding'`, and - // required, as the schema declares it) and because the trap #4340 found is - // worth spelling out where an author reads it. - RecordRelatedList: ['objectName'], +}; + +/** + * The `record:*` family is NOT part of the react tier, and never was in the + * runtime (#4413). This is the ledger of that exclusion — the reason, and what + * an author writes instead. + * + * ## Why they are out + * + * These blocks are RECORD-PAGE COMPOSITION blocks, not data blocks. Every one + * of them reads its record from the shared record context a record page mounts + * once (`RecordDetailView` fetches the record; N blocks render it), and they + * are coupled THROUGH that context: `record:details` drops the fields a + * mounted `record:highlights` registered, and the record-level inline-edit save + * bar commits one draft for all of them under a single `ifMatch` version. + * + * A `kind:'react'` page mounts no such context, so `useRecordContext()` returns + * null and each block renders its "bind a record to preview" placeholder — or, + * for `record:related_list` (the one that reads `schema.objectName`), a list + * that refuses to fetch because the parent id never arrives. + * + * The react tier nonetheless published `objectName` / `recordId` overlay props + * on four of them, which no renderer reads. That contract was not merely + * unimplemented, it was the wrong SHAPE: per-block bindings describe four + * independent fetches of one record, which is precisely the coupling the shared + * context exists to prevent. Implementing it would have fossilized that + * (ADR-0082 D1 / Prime Directive #12), so the props were withdrawn instead — + * ADR-0080 "capability ≠ contract". + * + * The react tier already has blocks that DO bind by their own props + * (`` reads `schema.objectName`/`schema.recordId`, `` + * reads `schema.objectName` + `filters`), and on a react page the parent record + * is ordinary React state — so the scenarios these blocks served are expressible + * without a context to fake. {@link REACT_RECORD_BLOCK_ALTERNATIVES} is what the + * lint tells an author who reaches for one. + * + * If the family is ever wanted here, the shape is a record SCOPE provider block + * an author wraps around them (one fetch, shared context) — not per-block props. + */ +export const RECORD_CONTEXT_TYPE_PREFIX = 'record:'; + +/** Whether a registry component type needs the record page's record context. */ +export function isRecordContextBlockType(type: string): boolean { + return type.startsWith(RECORD_CONTEXT_TYPE_PREFIX); +} + +/** + * PascalCase tag the react scope injects for a registry type — objectui's + * `toPascal` in `renderers/layout/react-page.tsx`, which builds the scope from + * EVERY public non-container block. Restated here because that is what makes a + * withdrawn block still reachable as ``, and so what the + * publish gate has to recognise. + */ +export function reactBlockTagFor(schemaType: string): string { + return schemaType + .split(/[-_:]/) + .filter(Boolean) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(''); +} + +/** + * Every `record:*` type the spec declares, keyed by the tag the react scope + * injects for it. Derived from `ComponentPropsMap` rather than restated, so a + * record component added to the spec is covered by the publish gate the day it + * lands. + */ +export const RECORD_CONTEXT_BLOCK_TAGS: ReadonlyMap = new Map( + Object.keys(ComponentPropsMap) + .filter(isRecordContextBlockType) + .map((type) => [reactBlockTagFor(type), type]), +); + +/** + * What to write instead, per withdrawn block. Read by the publish gate + * (`react-block-needs-record-context`) so the error names the working prop-bound + * block rather than only the broken one. Types without an entry fall back to the + * generic advice: author the page as `type:'record'`. + */ +export const REACT_RECORD_BLOCK_ALTERNATIVES: Readonly> = { + 'record:details': + ' — it binds by its own props.', + 'record:highlights': + ', or read the record with useAdapter().findOne and lay the strip out in JSX.', + 'record:related_list': + '\', \'=\', parentId]} columns={[…]} /> — the parent binding is an ordinary filter on a react page.', + 'record:path': + 'read the record with useAdapter().findOne and render the stage bar in JSX (layout is this tier\'s job).', }; export interface ReactBlockDef { @@ -168,57 +246,15 @@ export const REACT_BLOCKS: ReactBlockDef[] = [ { name: 'data', type: 'any[]', kind: 'binding', description: 'Static/precomputed data to chart directly instead of binding via objectName + aggregate.' }, ], }, - { - 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. `objectName` is the RELATED (child) object whose records are listed — NOT the parent; the parent record is bound by `recordId`, and `relationshipField` is the child field pointing back at it. Config props from the spec RecordRelatedList schema.', - schema: RecordRelatedListProps, - interactions: [ - { name: 'recordId', type: 'string | number', kind: 'controlled', description: 'The parent record whose children are listed.' }, - // Restates RecordRelatedListProps.objectName — ledgered in - // REACT_OVERLAY_SHADOWS. This block is the ONE whose spec schema already - // declares `objectName`, and it means the CHILD object; the overlay used - // to gloss it as the parent, which is the contract conflict #4340 opened - // on. Same meaning as the schema now, said in the words that keep an - // author from writing the parent here. - { name: 'objectName', type: 'string', kind: 'binding', required: true, description: 'The RELATED (child) object whose records this list renders — e.g. objectName="invoice" on an account page. NOT the parent object: the parent record is bound by recordId.' }, - ], - }, - { - 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.' }, - ], - }, + // NOTE: `` / `` / `` / + // `` were published here until #4413 and are deliberately gone — + // no renderer read the `objectName`/`recordId` this index declared for them. + // See REACT_RECORD_BLOCK_ALTERNATIVES above for the ledger and the blocks + // that replace them; the publish gate rejects them on a react page. { tag: 'Block', schemaType: '(any)', - summary: 'Escape hatch — render any registered component by type. etc.', + summary: 'Escape hatch — render any registered component by type. etc. Not a way back to the record:* family: those need a record page\'s record context and are rejected here too (#4413).', interactions: [ { name: 'type', type: 'string', kind: 'binding', required: true, description: 'The registered component type to render.' }, ], diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index afa474c37b..24f510aeed 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -997,7 +997,19 @@ The source is real React executed at render by the runtime. The injected scope a runtime from the public block registry (every non-container public block gets a PascalCase wrapper), so blocks like `` / `` exist even though the written contract below documents only the curated core set; - `` is the escape hatch for any other registered type + `` is the escape hatch for any other registered type. + **Exception — the `record:*` family is NOT usable here** (``, + ``, ``, ``, ``, + …): the registry injects a wrapper for each, but every one of them renders from + the record context a **record page** mounts, which a react page never does — so + they come back empty however you bind them. `os validate` rejects them here + (`react-block-needs-record-context`), by tag and via ``. + On a react page the parent record is ordinary React state, so use the blocks + that read their own props: `', '=', parentId]}>` for a related list, `` for a field panel, plain JSX over `useAdapter().findOne` for a + highlights strip or a stage bar. Need the family itself? Author the page as + `type:'record'`, where the context exists - `data` / `variables` / `page` Compose **layout with inline `style={{…}}`** (real CSS — see *Styling*, below); use the @@ -1013,10 +1025,11 @@ props/callbacks flow through — e.g. `` honors `objectName` / `mode > [`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. The `data` props are sourced from the platform's spec schemas (FormView, -> ListView, RecordDetails, Chart, …) — the same protocol the server validates; +> ListView, Chart, …) — the same protocol the server validates; > `binding`/`controlled`/`callback` are the React overlay. The contract covers > the **curated core set**; runtime-injected blocks outside it (``, -> ``, …) read their props from the block registry at render time. +> ``, …) read their props from the block registry at render time — +> except the `record:*` family, which is rejected on this surface (above). > (Maintainers: regenerate with `pnpm --filter @objectstack/spec gen:react-blocks`.) Master/detail (click a row → edit it → save refreshes the list): diff --git a/skills/objectstack-ui/contracts/react-blocks.contract.json b/skills/objectstack-ui/contracts/react-blocks.contract.json index 5b4ff2568d..615ad6c271 100644 --- a/skills/objectstack-ui/contracts/react-blocks.contract.json +++ b/skills/objectstack-ui/contracts/react-blocks.contract.json @@ -413,224 +413,10 @@ } ] }, - { - "tag": "RecordDetails", - "schemaType": "record:details", - "summary": "Field-detail panel for the bound record. Config props from the spec RecordDetails schema.", - "specSchema": true, - "props": [ - { - "name": "objectName", - "type": "string", - "kind": "binding", - "required": false, - "description": "The record’s object." - }, - { - "name": "recordId", - "type": "string | number", - "kind": "controlled", - "required": false, - "description": "The record to show." - }, - { - "name": "columns", - "type": "'1' | '2' | '3' | '4'", - "kind": "data", - "required": false, - "description": "Number of columns for field layout (1-4)" - }, - { - "name": "layout", - "type": "'auto' | 'custom'", - "kind": "data", - "required": false, - "description": "Layout mode: auto uses object highlightFields, custom uses explicit sections" - }, - { - "name": "sections", - "type": "string[]", - "kind": "data", - "required": false, - "description": "Section IDs to show (required when layout is \"custom\")" - }, - { - "name": "fields", - "type": "string[]", - "kind": "data", - "required": false, - "description": "Explicit field list to display (optional, overrides highlightFields)" - } - ] - }, - { - "tag": "RecordHighlights", - "schemaType": "record:highlights", - "summary": "Highlights panel — a strip of key fields. Config props from the spec RecordHighlights schema.", - "specSchema": true, - "props": [ - { - "name": "objectName", - "type": "string", - "kind": "binding", - "required": false, - "description": "The record’s object." - }, - { - "name": "recordId", - "type": "string | number", - "kind": "controlled", - "required": false, - "description": "The record to summarize." - }, - { - "name": "fields", - "type": "string | object[]", - "kind": "data", - "required": true, - "description": "Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or {name, label?, icon?, type?} for inline…" - }, - { - "name": "layout", - "type": "'horizontal' | 'vertical'", - "kind": "data", - "required": false, - "description": "Layout orientation for highlight fields" - } - ] - }, - { - "tag": "RecordRelatedList", - "schemaType": "record:related_list", - "summary": "Related child records via a lookup. `objectName` is the RELATED (child) object whose records are listed — NOT the parent; the parent record is bound by `recordId`, and `relationshipField` is the child field pointing back at it. Config props from the spec RecordRelatedList schema.", - "specSchema": true, - "props": [ - { - "name": "objectName", - "type": "string", - "kind": "binding", - "required": true, - "description": "The RELATED (child) object whose records this list renders — e.g. objectName=\"invoice\" on an account page. NOT the parent object: the parent record is bound by recordId." - }, - { - "name": "recordId", - "type": "string | number", - "kind": "controlled", - "required": false, - "description": "The parent record whose children are listed." - }, - { - "name": "relationshipField", - "type": "string", - "kind": "data", - "required": true, - "description": "Field on related object that points to this record (e.g., \"account_id\")" - }, - { - "name": "relationshipValueField", - "type": "string", - "kind": "data", - "required": false, - "description": "Parent-record field whose value relationshipField stores (default 'id'; e.g. 'name' for name-keyed junctions)." - }, - { - "name": "columns", - "type": "string[]", - "kind": "data", - "required": false, - "description": "Fields to display in the related list. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list …" - }, - { - "name": "sort", - "type": "string | object[]", - "kind": "data", - "required": false, - "description": "Sort order for related records" - }, - { - "name": "limit", - "type": "integer", - "kind": "data", - "required": false, - "description": "Number of records to display initially" - }, - { - "name": "filter", - "type": "object[]", - "kind": "data", - "required": false, - "description": "Additional filter criteria for related records" - }, - { - "name": "title", - "type": "string", - "kind": "data", - "required": false, - "description": "Custom title for the related list" - }, - { - "name": "showViewAll", - "type": "boolean", - "kind": "data", - "required": false, - "description": "Show \"View All\" link to see all related records" - }, - { - "name": "actions", - "type": "string[]", - "kind": "data", - "required": false, - "description": "Action IDs available for related records" - }, - { - "name": "add", - "type": "object", - "kind": "data", - "required": false, - "description": "Add-existing-via-picker config (generic m2m/junction assignment)." - } - ] - }, - { - "tag": "RecordPath", - "schemaType": "record:path", - "summary": "Stage/progress bar driven by a status field. Config props from the spec RecordPath schema.", - "specSchema": true, - "props": [ - { - "name": "objectName", - "type": "string", - "kind": "binding", - "required": false, - "description": "The record’s object." - }, - { - "name": "recordId", - "type": "string | number", - "kind": "controlled", - "required": false, - "description": "The record whose stage to show." - }, - { - "name": "statusField", - "type": "string", - "kind": "data", - "required": true, - "description": "Field name representing the current status/stage" - }, - { - "name": "stages", - "type": "object[]", - "kind": "data", - "required": false, - "description": "Explicit stage definitions (if not using field metadata)" - } - ] - }, { "tag": "Block", "schemaType": "(any)", - "summary": "Escape hatch — render any registered component by type. etc.", + "summary": "Escape hatch — render any registered component by type. etc. Not a way back to the record:* family: those need a record page's record context and are rejected here too (#4413).", "specSchema": false, "props": [ { diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index bbc168de26..d3d706a9b1 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -87,63 +87,9 @@ Chart over an object’s aggregated data. Bind objectName + aggregate; the axes | `annotations` | `object[]` | data | | Reference lines/bands drawn over the plot: { type: "line" \| "region", axis: "x" \| "y", value, endValue?, color?, label?, style? } | | `interaction` | `object` | data | | Interaction toggles: { tooltips?, brush? } | -## `` — `record:details` - -Field-detail panel for the bound record. Config props from the spec RecordDetails schema. - -| prop | type | kind | required | description | -|------|------|------|:--------:|-------------| -| `objectName` | `string` | binding | | The record’s object. | -| `recordId` | `string \| number` | controlled | | The record to show. | -| `columns` | `'1' \| '2' \| '3' \| '4'` | data | | Number of columns for field layout (1-4) | -| `layout` | `'auto' \| 'custom'` | data | | Layout mode: auto uses object highlightFields, custom uses explicit sections | -| `sections` | `string[]` | data | | Section IDs to show (required when layout is "custom") | -| `fields` | `string[]` | data | | Explicit field list to display (optional, overrides highlightFields) | - -## `` — `record:highlights` - -Highlights panel — a strip of key fields. Config props from the spec RecordHighlights schema. - -| prop | type | kind | required | description | -|------|------|------|:--------:|-------------| -| `objectName` | `string` | binding | | The record’s object. | -| `recordId` | `string \| number` | controlled | | The record to summarize. | -| `fields` | `string \| object[]` | data | ✓ | Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or {name, label?, icon?, type?} for inline… | -| `layout` | `'horizontal' \| 'vertical'` | data | | Layout orientation for highlight fields | - -## `` — `record:related_list` - -Related child records via a lookup. `objectName` is the RELATED (child) object whose records are listed — NOT the parent; the parent record is bound by `recordId`, and `relationshipField` is the child field pointing back at it. Config props from the spec RecordRelatedList schema. - -| prop | type | kind | required | description | -|------|------|------|:--------:|-------------| -| `objectName` | `string` | binding | ✓ | The RELATED (child) object whose records this list renders — e.g. objectName="invoice" on an account page. NOT the parent object: the parent record is bound by recordId. | -| `recordId` | `string \| number` | controlled | | The parent record whose children are listed. | -| `relationshipField` | `string` | data | ✓ | Field on related object that points to this record (e.g., "account_id") | -| `relationshipValueField` | `string` | data | | Parent-record field whose value relationshipField stores (default 'id'; e.g. 'name' for name-keyed junctions). | -| `columns` | `string[]` | data | | Fields to display in the related list. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list … | -| `sort` | `string \| object[]` | data | | Sort order for related records | -| `limit` | `integer` | data | | Number of records to display initially | -| `filter` | `object[]` | data | | Additional filter criteria for related records | -| `title` | `string` | data | | Custom title for the related list | -| `showViewAll` | `boolean` | data | | Show "View All" link to see all related records | -| `actions` | `string[]` | data | | Action IDs available for related records | -| `add` | `object` | data | | Add-existing-via-picker config (generic m2m/junction assignment). | - -## `` — `record:path` - -Stage/progress bar driven by a status field. Config props from the spec RecordPath schema. - -| prop | type | kind | required | description | -|------|------|------|:--------:|-------------| -| `objectName` | `string` | binding | | The record’s object. | -| `recordId` | `string \| number` | controlled | | The record whose stage to show. | -| `statusField` | `string` | data | ✓ | Field name representing the current status/stage | -| `stages` | `object[]` | data | | Explicit stage definitions (if not using field metadata) | - ## `` — `(any)` *(no spec schema — overlay only)* -Escape hatch — render any registered component by type. etc. +Escape hatch — render any registered component by type. etc. Not a way back to the record:* family: those need a record page's record context and are rejected here too (#4413). | prop | type | kind | required | description | |------|------|------|:--------:|-------------|