Repository navigation
Expand file tree
/
Copy pathreact-blocks.ts
More file actions
381 lines (368 loc) · 24.7 KB
/
Copy pathreact-blocks.ts
File metadata and controls
381 lines (368 loc) · 24.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
// 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 { ComponentPropsMap } from './component.zod';
import { ChartConfigSchema } from './chart.zod';
export type ReactPropKind = 'data' | 'binding' | 'controlled' | 'callback';
/**
* One hand-authored overlay prop.
*
* ⚠️ An overlay entry that shares a NAME with a prop of the block's spec schema
* SHADOWS it: `build-react-blocks-contract` publishes the overlay's type and
* description and drops the schema's, so the two can mean different things and
* nothing says so. That is not hypothetical — see {@link REACT_OVERLAY_SHADOWS}
* for the one that shipped. Add an overlay prop only when the schema does not
* declare it; when a restatement really is wanted, ledger it there.
*/
export interface ReactInteractionProp {
name: string;
type: string;
kind: 'binding' | 'controlled' | 'callback';
required?: boolean;
description: string;
/**
* Deprecate-first retirement of a react-tier spelling (commit 5383fa670, maintainer
* ruling 2026-08-23): the react tier converges on the metadata-tier
* vocabulary. A deprecated prop stays published and accepted for the whole
* deprecation window — removal is a later card, never a side effect here, and
* lands as a {@link REACT_RETIRED_OVERLAY_PROPS} entry so the lint refuses the
* retired spelling by name.
*
* `replacedBy` names the canonical prop ON THE SAME BLOCK (a spec-schema
* prop surfaced via `dataProps`, or another overlay prop); `note` is the
* authoring guidance the lint quotes verbatim in its deprecation warning.
*
* On a `required` prop the requirement is the BINDING, not the spelling:
* `validate-react-page-props` treats the canonical `replacedBy` prop as
* satisfying it, so the new spelling is accepted without the old one.
*/
deprecated?: { replacedBy: string; note: string };
}
/**
* Overlay props that deliberately RESTATE a prop the block's spec schema also
* declares, per block tag. Anything not listed here must be absent from the
* schema — `react-blocks.test.ts` asserts the ledger equals the real collision
* set, so the next accidental shadow fails a test instead of shipping.
*
* ## Why the ledger exists (#4340)
*
* `RecordRelatedListProps.objectName` is the RELATED (child) object — that is
* what `record:related_list` means on every metadata surface, what
* `validate-page-field-bindings` resolves its `columns` against, and what the
* one registry component behind both surfaces consumes. The React overlay
* nonetheless declared `objectName` a second time, glossed "The parent object"
* by analogy with the blocks whose schema genuinely has no `objectName`. The
* generated contract published only that gloss, so the react surface both
* contradicted the spec AND lost any way to name the object it renders — and
* the one live page authored against the gloss listed the wrong object.
*
* A shadow is therefore allowed only when it is deliberate and
* MEANING-PRESERVING, and the reason has to be written down next to it.
*/
export const REACT_OVERLAY_SHADOWS: Readonly<Record<string, readonly string[]>> = {
// `navigation` — same prop, same meaning. The overlay restates it to publish
// the JSX shorthand an author writes (`navigation={{ mode: 'none' }}`)
// together with the React-side rule that pairs it with `onRowClick` —
// neither of which the declarative schema has anywhere to say.
// `data` — same prop, same meaning (#14791). The overlay restates it to say
// the one thing the declarative schema cannot: a metadata page's host stamps
// the bound object, a `kind:'react'` page's does not, so the data source is
// REQUIRED to write on this tier — the binding `objectName` carried until
// its retirement, now spelled `data={{ provider: 'object', object }}` only.
ListView: ['data', 'navigation'],
};
/**
* Overlay props RETIRED from a block, per tag — the react-tier twin of
* `retiredKey()` on a metadata schema (#14791, maintainer ruling 2026-09-07:
* the aliases commit 5383fa670 deprecated go with no deprecation window). The spelling is gone
* from {@link REACT_BLOCKS}, so the generated contract no longer publishes it;
* this ledger is what lets `validate-react-page-props` refuse it BY NAME, with
* the prescription, instead of passing it as an unknown prop the renderer may
* happen to still read.
*
* `replacedBy` names the prop on the same block that carries the binding now
* (a spec-schema prop surfaced via `dataProps`, or an overlay prop); `note` is
* the one-line fix the lint quotes verbatim. An entry must not also be a live
* prop of its block — `react-blocks.test.ts` pins both directions.
*/
export const REACT_RETIRED_OVERLAY_PROPS: Readonly<
Record<string, Readonly<Record<string, { replacedBy: string; note: string }>>>
> = {
ListView: {
// Commit 5383fa670 published these as deprecated aliases of ListViewSchema's own
// `data` / `type`; #14791 retires them outright, the consumer fold having
// landed (objectui `normalizeListViewSchema`, console pin a472b071).
objectName: {
replacedBy: 'data',
note: "Write data={{ provider: 'object', object: '…' }} — the metadata-tier data source, the same spelling a metadata list view authors.",
},
viewType: {
replacedBy: 'type',
note: 'Write type="kanban" — ListViewSchema\'s own `type`, the metadata-tier view kind.',
},
},
};
/**
* 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
* (`<ObjectForm>` reads `schema.objectName`/`schema.recordId`, `<ListView>`
* 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 `<RecordHighlights>`, 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<string, string> = 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<string, string>> = {
'record:details':
'<ObjectForm objectName="…" mode="view" recordId={…} fields={[…]} /> — it binds by its own props.',
'record:highlights':
'<ObjectForm objectName="…" mode="view" recordId={…} fields={[…]} />, or read the record with useAdapter().findOne and lay the strip out in JSX.',
'record:related_list':
"<ListView data={{ provider: 'object', object: '<child object>' }} filters={['<lookup field>', '=', 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 {
/** 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: ['layout', 'columns', 'tabPosition', 'drawerSide', 'drawerWidth', 'modalSize', 'splitDirection', 'sections', 'subforms', 'submitBehavior'],
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.' },
// #9392 — catch-up with the registry inputs objectui#4648/#4901 published
// for keys ObjectFormRenderer already read (descriptions adapted from the
// `object-form` registration in objectui `plugin-form/src/index.tsx`, the
// one registration both parity channels enumerate). Published from the
// OVERLAY, not FormViewSchema `dataProps`, for the same reason as
// `<ObjectChart drillDown>` above: FormViewSchema is also what a
// declarative form VIEW parses, and only the react/registry tier
// measurably reads these. Three registry inputs are deliberately NOT
// declared (accepted as registry-only by the since-retired ADR-0082 D4
// declaration-parity baseline):
// `initialData` (alias spelling of `initialValues` — aliases are not
// promoted into spec), `mobile` (internal override, not an authoring
// surface), `navigateOnSuccess` (parked pending the action-success-
// navigation family ruling — see the baseline note on #9392).
{ name: 'customFields', type: 'any[]', kind: 'binding', description: 'Field definitions merged over the set generated from object metadata. With inline definitions and no data source, this becomes the only field source.' },
{ name: 'readOnly', type: 'boolean', kind: 'binding', description: 'Render every field read-only, whatever `mode` says.' },
{ name: 'modalCloseButton', type: 'boolean', kind: 'binding', description: "Show the modal presentation's close button (formType 'modal'; honoured by the modal form)." },
{ name: 'contentLayout', type: "'simple' | 'tabbed'", kind: 'binding', description: "How the modal presentation lays out sections; 'tabbed' needs more than one section to differ from 'simple'." },
{ name: 'confirmOnDiscard', type: 'boolean', kind: 'binding', description: 'Ask before discarding unsaved edits when a drawer/modal form is dismissed. Set false to close immediately.' },
{ name: 'submitText', type: 'string', kind: 'binding', description: 'Label of the submit button.' },
{ name: 'cancelText', type: 'string', kind: 'binding', description: 'Label of the cancel button.' },
{ name: 'nextText', type: 'string', kind: 'binding', description: 'Label of the next-step button (wizard).' },
{ name: 'prevText', type: 'string', kind: 'binding', description: 'Label of the previous-step button (wizard).' },
{ name: 'showSubmit', type: 'boolean', kind: 'binding', description: 'Show the submit button.' },
{ name: 'showCancel', type: 'boolean', kind: 'binding', description: 'Show the cancel button.' },
{ name: 'showReset', type: 'boolean', kind: 'binding', description: 'Show the reset button.' },
{ name: 'successMessage', type: 'string', kind: 'binding', description: 'Toast shown after a successful submit. Ignored when `submitBehavior` is set.' },
{ name: 'resetOnSuccess', type: 'boolean', kind: 'binding', description: 'Clear the form after a successful submit instead of keeping the saved values.' },
{ 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. Bind the object with the metadata-tier data source — data={{ provider: 'object', object: '…' }} — and pick the visualization with `type`: the same two keys a metadata list view authors, and the only spellings (the `objectName` / `viewType` aliases are retired).",
schema: ListViewSchema,
// Commit 5383fa670 (maintainer ruling 2026-08-23): the react tier converges on the
// metadata-tier vocabulary — `type` and `data` are ListViewSchema's own
// props (objectui#2890 A6). #14791 (maintainer ruling 2026-09-07) retired
// the overlay aliases `objectName` / `viewType` with no window once the
// consumer fold landed (objectui `normalizeListViewSchema`, console pin
// a472b071): the canonical spelling is the ONLY spelling, and
// `REACT_RETIRED_OVERLAY_PROPS` carries the tombstone the lint quotes.
// `type` rides the generator's explicit-allow (the #3729 ObjectChart
// precedent — the react-page wrapper parks an author `type` beside the
// SDUI discriminator as `specType`, objectui#2880).
dataProps: ['type', 'data', 'columns', 'sort', 'searchableFields', 'userFilters', 'pagination', 'grouping', 'rowHeight', 'selection', 'rowActions', 'inlineEdit'],
interactions: [
// NOT the shared OBJECT_NAME: ListView's object binding IS the schema's
// `data` data source. Restated here — ledgered in REACT_OVERLAY_SHADOWS —
// because the react tier has to say what the declarative schema cannot:
// a metadata page's host stamps the bound object, a react page's does
// not, so on this tier the data source is required to WRITE.
{
name: 'data',
type: "ViewData — { provider: 'object', object: string } binds an object",
kind: 'binding',
required: true,
description: "The object this block binds to, written as the metadata-tier data source: data={{ provider: 'object', object: '…' }}. Any ViewData provider is accepted (a 'value' provider renders static rows); nothing stamps the object on a react page, so this prop is required.",
},
{ 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' | 'new_window') => void", kind: 'callback', description: "Called for page-level navigation. The second argument is a navigation-MODE token from a CLOSED vocabulary — 'view' opens the record page, 'new_window' opens it in a new browser tab; the reference implementation emits no other value and reads no other branch." },
{ name: 'fields', type: 'string[]', kind: 'binding', description: 'Limit/order the columns shown (defaults to the object list fields).' },
{ name: 'options', type: 'Record<string, any>', kind: 'binding', description: 'View-type-specific options bag (kanban/calendar/gantt extras); prefer the typed spec props where they exist.' },
],
},
{
tag: 'ObjectChart',
schemaType: 'object-chart',
summary: 'Chart over an object’s aggregated data. Bind objectName + aggregate; the axes name the aggregate’s RESULT COLUMNS (see chartAggregateResultKeys).',
schema: ChartConfigSchema,
// The spec ChartConfig shape IS the author contract again (#3729): the
// renderer honors `type`/`xAxis.field`/`yAxis[].field`/`series[].name`
// plus the axis presentation props (objectui#2880). #3701 had trimmed
// them out because they were silently inert — that was a record of the
// runtime gap, not the target state (ADR-0082 D1: spec is the protocol).
//
// `type` is the one field that cannot ride the props bag: it is the SDUI
// envelope's component discriminator on every FLATTENED surface, so the
// react-page wrapper parks an author `type` beside it and the renderer
// reads it back. It is published here as the spec spells it; the internal
// `chartType` spelling is no longer part of the author contract.
dataProps: ['type', 'title', 'subtitle', 'description', 'height', 'xAxis', 'yAxis', 'series', 'colors', 'showLegend', 'showDataLabels', 'annotations', 'interaction'],
interactions: [
OBJECT_NAME,
{ name: 'filter', type: 'FilterArray', kind: 'controlled', description: 'ObjectQL filter scoping the data; drive from React state.' },
{ name: 'aggregate', type: "{ field?: string; function: 'count' | 'sum' | 'avg' | 'min' | 'max'; groupBy: string | { field: string; dateGranularity?: 'day' | 'week' | 'month' | 'quarter' | 'year' } }", kind: 'binding', description: 'Aggregation run against objectName. Result rows are keyed by the RAW FIELD NAMES: one column named after groupBy (the category) and one named after field (the value; the literal "count" for a fieldless count). Bind xAxis.field / yAxis[].field / series[].name to those names.' },
{ name: 'data', type: 'any[]', kind: 'binding', description: 'Static/precomputed data to chart directly instead of binding via objectName + aggregate.' },
// #5022 — the segment drill. Published from the OVERLAY, not from
// `dataProps`, and that placement is the whole point of the issue.
//
// `dataProps` reads `block.schema`, which is `ChartConfigSchema`, which is
// also what a DASHBOARD widget's `chartConfig` parses. Declaring the drill
// there would make `widget.chartConfig.drillDown` legal metadata that no
// dashboard renderer reads (objectui `DatasetWidget` forwards exactly one
// chartConfig key, `showLegend`) — the declared-but-not-delivered shape
// this campaign removes elsewhere. The overlay publishes it on the react
// tier ONLY, which is the tier that measurably reads it.
//
// The type string is not a second source of truth: `react-blocks.test.ts`
// derives the key set from `ChartDrillDownSchema` and fails if this string
// drifts from it. `renderType` would flatten the nested object to the
// useless `'object'` anyway, which is why `aggregate` above is spelled out
// the same way.
{ name: 'drillDown', type: "{ enabled?: boolean; filter?: Record<string, unknown>; title?: string; target?: 'drawer' | 'dialog' | 'navigate'; columns?: string[]; maxRows?: number }", kind: 'binding', description: "Click a segment to open the underlying records, filtered by the clicked category, in a drawer (or 'dialog', or 'navigate' to open the object's full list page instead — that arm needs host drill navigation and falls back to the drawer without it). Present = on; {} is enough. `filter`/`title` support ${event.*} interpolation; omit `filter` to derive it from aggregate.groupBy. Declared by ChartDrillDownSchema — NOT a dashboard widget key (a dataset-bound widget drills through the semantic layer instead), and not ReportSchema.drilldown (that is lowercase, boolean, report-only)." },
],
},
// NOTE: `<RecordDetails>` / `<RecordHighlights>` / `<RecordRelatedList>` /
// `<RecordPath>` 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. <Block type="object-kanban" objectName="task" /> etc. Not a way back to the record:* family: those need a record page\'s record context and are rejected here too.',
interactions: [
{ name: 'type', type: 'string', kind: 'binding', required: true, description: 'The registered component type to render.' },
],
},
];