Skip to content
30 changes: 30 additions & 0 deletions .changeset/12294-record-blocks-element-datasource.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@object-ui/plugin-detail': minor
'@object-ui/types': minor
'@object-ui/cli': minor
---

`record:details`, `record:highlights` and `record:quick_actions` honour the per-element `dataSource` binding the spec already declares on every page component (objectui#12294). A product `app` page can now compose a record's surfaces from these blocks, with no record page around them:

```json
{
"type": "record:highlights",
"properties": { "fields": ["name", "environment_type"] },
"dataSource": {
"object": "sys_environment",
"filter": [{ "field": "environment_type", "operator": "equals", "value": "production" }],
"limit": 1
}
}
```

- **One record per block.** A record block shows the FIRST row of the bound set by the binding's `sort`, read as one row and under the caller's own identity; the server decides what the caller may read, exactly as on any other read. When no row matches, each block draws what it draws for a record that is not there: `record:details` its "record not found" state, `record:highlights` nothing, `record:quick_actions` its empty bar. A read the server refuses draws the same, so a record the caller may not read looks absent. A read that fails for any other reason is reported in the block's place, with the family's data-source error panel.
- **`record:quick_actions` runs against the bound row.** The object's declared actions (`actionNames`) run with the bound row's id.
- **The binding resolves as on every bound block.** A named `view`, the binding's `filter` (with `{current_user_id}`-style tokens), and its `sort` mean what they mean on a list block, and an unresolvable view or a refused filter draws the same configuration panel. Reference fields come back expanded, limited to the fields the caller may read, as on the record page.
- **The bound record stays fresh.** It re-reads in place whenever a write to its object is announced — by the data source's mutation events or on the data-invalidation bus — so a save or an action elsewhere on the page refreshes it.
- **The record page is unchanged.** A block with no `dataSource` reads the record page's record exactly as before. A bound block placed on a record page shows its bound record instead, as the spec says the binding does, and is kept out of that page's inline-edit session and highlight-field registry: it is read-only there, and it does not hide fields from the page record's details.
- **The registrations declare the key.** The three blocks publish the injected `dataSource` input, so the page authoring diagnostics no longer report it as an unknown prop.
- **`@object-ui/types`: the strict authoring face accepts the binding on the three blocks.** Each arm declares `dataSource` as the spec's `ElementDataSourceSchema`, by reference, as `record:line_items` does; the TypeScript authoring face follows. `objectui validate` refused it as an undeclared key before.
- **`@object-ui/cli`: `objectui validate` warns on a record block whose binding has no `limit: 1`.** The document still validates; the warning names the block's path and asks the author to say which record the block shows. `record:related_list` is not warned about: its binding is the binding of its child rows.

`record:related_list` is unchanged: its `dataSource` still binds the rows it lists.
Original file line number Diff line number Diff line change
Expand Up @@ -117,20 +117,30 @@ function bindingDocRows(): Map<string, { object: string; view: string }> {
const DOC_ROWS = bindingDocRows();

/**
* The key the binding's `object` lands on. The doc: "it lands on the block's
* own object key, which is `objectName` everywhere except `record:line_items`"
* The key the binding's `object` lands on. The doc: "on a block with an object
* key it lands on that key, which is `objectName` everywhere except
* `record:line_items`"
* (its row spells that key in the `object` column).
*
* `undefined` for the `element:*` blocks: each reads `dataSource.object`
* itself, so the binding lands on no key of theirs (objectui#11880), and since
* objectui#12085 neither `element:number` nor `element:repeater` publishes a
* flat `object` for it to land on. Row 3 asserts that exception is exactly the
* `element:*` tags, so it cannot quietly cover an `object-*` block.
* `undefined` for two kinds of block, and only these:
* - the `element:*` blocks: each reads `dataSource.object` itself, so the
* binding lands on no key of theirs (objectui#11880), and since
* objectui#12085 neither `element:number` nor `element:repeater` publishes a
* flat `object` for it to land on;
* - the three one-record blocks {@link ONE_RECORD_BLOCKS} (objectui#12294):
* the binding's `object` and its first row become the block's RECORD SCOPE
* (`RecordContextProvider`), so it lands on no schema key either. Named one
* by one, ⛔ not a `record:` prefix: `record:related_list` and
* `record:line_items` keep their keys.
* Row 3 asserts the exception is exactly those tags, so it cannot quietly
* cover an `object-*` block.
*/
const ONE_RECORD_BLOCKS: ReadonlySet<string> = new Set(['record:details', 'record:highlights', 'record:quick_actions']);

function objectKeyOf(tag: string): string | undefined {
const named = /\(`([^`]+)`\)/.exec(DOC_ROWS.get(tag)?.object ?? '');
if (named) return named[1];
if (tag.startsWith('element:')) return undefined;
if (tag.startsWith('element:') || ONE_RECORD_BLOCKS.has(tag)) return undefined;
return 'objectName';
}

Expand Down Expand Up @@ -245,15 +255,26 @@ describe('objectui#11605 — a bound registration requires no input its binding
`<${tag}>: the binding's object lands on "${key}", which the entry does not declare`,
).toContain(key);
}
// The exception is the `element:*` tags and nothing else, and it is not
// vacuous: the two whose flat `object` objectui#12085 stopped publishing
// are bound entries of the shipped manifest that declare no `object`.
// The exception is the `element:*` tags plus the three named one-record
// blocks and nothing else, and it is not vacuous: the two whose flat
// `object` objectui#12085 stopped publishing are bound entries of the
// shipped manifest that declare no `object`, and the three one-record
// blocks (objectui#12294) are bound entries that declare no `objectName`.
const landsNowhere = BOUND.filter((tag) => objectKeyOf(tag) === undefined);
expect(landsNowhere.every((tag) => tag.startsWith('element:'))).toBe(true);
expect(landsNowhere.every((tag) => tag.startsWith('element:') || ONE_RECORD_BLOCKS.has(tag))).toBe(true);
expect(landsNowhere.filter((tag) => tag.startsWith('object-'))).toEqual([]);
for (const tag of ['element:number', 'element:repeater']) {
expect(landsNowhere, tag).toContain(tag);
expect(inputsOf(tag).map((input) => input.name), tag).not.toContain('object');
}
for (const tag of ONE_RECORD_BLOCKS) {
expect(landsNowhere, tag).toContain(tag);
expect(inputsOf(tag).map((input) => input.name), tag).not.toContain('objectName');
// The doc row says so, in the words a reader sees.
expect(DOC_ROWS.get(tag)?.object, tag).toContain('its record');
}
// Named, not a prefix: the other bound record blocks keep their keys.
expect(objectKeyOf('record:related_list')).toBe('objectName');
// Controls on the doc reading: the exception it names, and a full-view row.
expect(objectKeyOf('record:line_items')).toBe('childObject');
expect(bindingSupplies('object-grid').has('columns')).toBe(true);
Expand Down
12 changes: 12 additions & 0 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3301,6 +3301,10 @@ const MEMBER_PINS: Record<string, MemberPin> = {
file: 'packages/plugin-detail/src/renderers/__tests__/recordChatterFeedMembers-8071.test.tsx',
pins: 'The nested activity-feed config\'s MEMBERS, and the fact that authoring the object REPLACES rather than merges. `record-chatter.tsx` composes `{ position, collapsible, feed: DEFAULTS, ...schema }` — a shallow spread — so an authored `feed` displaces the renderer\'s three defaults wholesale and every member not restated falls back to `RecordActivityTimeline`\'s own default. Those defaults are NOT uniform, which is the trap this pin exists for and is asserted per member: `showCommentInput` is `!== false` (ON when absent) while `enableReactions` and `enableThreading` are `?? false` (OFF), so `feed: {}` silently takes reactions and threading down while leaving the composer up. Each of the three carries its own observable — composer placeholder, the `Add reaction` affordance, and whether a reply is pulled out of the ROOT row list — each with the absent-`feed` control (objectui#8071 slice 6).',
},
'record:details.dataSource': {
file: 'packages/plugin-detail/src/renderers/__tests__/recordBinding.appPage-12294.test.tsx',
pins: 'The per-element binding\'s members as a ONE-RECORD block reads them (objectui#12294), through the REAL registration and `SchemaRenderer` on an `app` page with no record context, asserted at the bound row\'s field value and option label it draws. `object` names the object read; `filter` reaches the read (lowered by `ElementDataSourceGate`, matched by content); `sort` picks the FIRST row and reaches the read as `$orderby`, with a two-row set stored out of order as its control; `limit` is NOT the read\'s cap: one row is always asked for (`$top: 1`), because the first row of the authored cap and of a one-row read are the same row; `view` is resolved by the gate, and an unresolvable one draws the configuration panel and reads no row. Zero rows and a refused read draw the block\'s no-record state; a failed read is reported in the block\'s place. ⚠️ The key is INJECTED by `Registry.register` (`ELEMENT_DATA_SOURCE_INPUT`), not written by the block, so the declaration lists the five members generically and this pin carries which of them THIS block reads. The record-page half (unbound = the page record; bound on a record page = the bound row, kept out of the page\'s inline-edit session and highlight registry) and the data-invalidation bus are pinned in `recordBinding.recordPageAndBus-12294.test.tsx`.',
},
'record:details.fields': {
file: 'packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts',
pins: 'Members are BARE NAMES: the spec rejects the entry-object spelling on its value, the description is asserted non-empty first and then required to teach no entry shape, and the renderer\'s more tolerant read is recorded as unexercised drift rather than a second contract.',
Expand All @@ -3325,6 +3329,10 @@ const MEMBER_PINS: Record<string, MemberPin> = {
file: 'packages/plugin-detail/src/renderers/__tests__/recordChatterFeedMembers-8071.test.tsx',
pins: 'The same key on the same renderer under its other registered name — `record:chatter` and `record:discussion` are one `RecordChatterRenderer` sharing `CHATTER_INPUTS`, so both rows point at the one file, which runs every case against BOTH names. See `record:chatter.feed` for what it constrains (objectui#8071 slice 6).',
},
'record:highlights.dataSource': {
file: 'packages/plugin-detail/src/renderers/__tests__/recordBinding.appPage-12294.test.tsx',
pins: 'The per-element binding\'s members as a ONE-RECORD block reads them (objectui#12294), through the REAL registration and `SchemaRenderer` on an `app` page with no record context, asserted at the bound row\'s field value it draws. `object` names the object read; `filter` reaches the read (lowered by `ElementDataSourceGate`, matched by content); `sort` picks the FIRST row and reaches the read as `$orderby`, with a two-row set stored out of order as its control; `limit` is NOT the read\'s cap: one row is always asked for (`$top: 1`), because the first row of the authored cap and of a one-row read are the same row; `view` is resolved by the gate, and an unresolvable one draws the configuration panel and reads no row. Zero rows and a refused read draw the block\'s no-record state; a failed read is reported in the block\'s place. ⚠️ The key is INJECTED by `Registry.register` (`ELEMENT_DATA_SOURCE_INPUT`), not written by the block, so the declaration lists the five members generically and this pin carries which of them THIS block reads. The record-page half (unbound = the page record; bound on a record page = the bound row, kept out of the page\'s inline-edit session and highlight registry) and the data-invalidation bus are pinned in `recordBinding.recordPageAndBus-12294.test.tsx`.',
},
'record:highlights.fields': {
file: 'packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts',
pins: 'Members are objects carrying `readonly` PER ENTRY — asserted to be per-entry rather than top-level, and every spec entry key must be discoverable from the `fields` description (objectui#3407).',
Expand Down Expand Up @@ -3357,6 +3365,10 @@ const MEMBER_PINS: Record<string, MemberPin> = {
file: 'packages/plugin-detail/src/renderers/__tests__/recordPathStagesMembers-8071.test.tsx',
pins: 'The stage member set `{ value, label, terminal? }`, read through the real renderer. `value` is stage IDENTITY — compared against the record\'s `statusField` to choose the current stage, controlled by moving the RECORD rather than the array, by an unmatched value leaving NO stage current, and by a record carrying some stage\'s LABEL still matching nothing. `label` is the rendered text and the `value` behind it never appears. `terminal` OUTRANKS the `WON_TOKENS`/`LOST_TOKENS` heuristic, pinned only on fixtures where the member CONTRADICTS the heuristic and each paired with the identical stage minus `terminal` to show which way the heuristic was pointing, plus an unclassified control. Deliberately the DESKTOP row only: `record-path.crossRowClassification.test.tsx` owns the cross-row agreement invariant, which is a different claim (objectui#8071 slice 6). The `label` member is an `I18nLabel`: its locale-map arm, resolved before the stages are classified or rendered, is pinned through the real `SchemaRenderer` under `zh` and `en` by `record-path.stageLabelI18nLabel-10993.test.tsx` (objectui#10993).',
},
'record:quick_actions.dataSource': {
file: 'packages/plugin-detail/src/renderers/__tests__/recordBinding.appPage-12294.test.tsx',
pins: 'The per-element binding\'s members as a ONE-RECORD block reads them (objectui#12294), through the REAL registration and `SchemaRenderer` on an `app` page with no record context, asserted at the record id a declared action runs against. `object` names the object read; `filter` reaches the read (lowered by `ElementDataSourceGate`, matched by content); `sort` picks the FIRST row and reaches the read as `$orderby`, with a two-row set stored out of order as its control; `limit` is NOT the read\'s cap: one row is always asked for (`$top: 1`), because the first row of the authored cap and of a one-row read are the same row; `view` is resolved by the gate, and an unresolvable one draws the configuration panel and reads no row. Zero rows and a refused read draw the block\'s no-record state; a failed read is reported in the block\'s place. ⚠️ The key is INJECTED by `Registry.register` (`ELEMENT_DATA_SOURCE_INPUT`), not written by the block, so the declaration lists the five members generically and this pin carries which of them THIS block reads. The record-page half (unbound = the page record; bound on a record page = the bound row, kept out of the page\'s inline-edit session and highlight registry) and the data-invalidation bus are pinned in `recordBinding.recordPageAndBus-12294.test.tsx`.',
},
'record:quick_actions.actionNames': {
file: 'packages/plugin-detail/src/__tests__/recordQuickActionsInputs.actionNamesFallback.test.tsx',
pins: 'Members are action-id STRINGS resolved against the object\'s own `actions` through the real renderer: with no `actionNames` (and no host `actions`) the metadata layer is NEVER queried and the bar\'s empty placeholder renders, while naming an id drives the same `useMetadataItem` lookup and the resolved action\'s button actually appears — the positive control that keeps the negative case from reading as "the wiring was never exercised". Pre-existing file, promoted to a pin here after being read end to end; it was written for objectui#4663 (the registered description promised a fallback that exists on no path) rather than for this direction, but its second case already drives the exact read this key needs pinned (objectui#8071).',
Expand Down
41 changes: 39 additions & 2 deletions content/docs/guide/data-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,21 +272,58 @@ ignores would be accepted and dropped, which is the defect this binding removes.
| `embeddable-form` | ✅ | error-checked only | — no collection query | — | — |
| `object-master-detail-form` | ✅ | error-checked only | — no collection query | — | — |
| `record:line_items` | ✅ (`childObject`) | filter / sort / limit | ✅ (AND parent scope) | ✅ | ✅ (`limit`) |
| `record:details` | ✅ (its record) | filter / sort | ✅ | ✅ (picks the first row) | — one row is read |
| `record:highlights` | ✅ (its record) | filter / sort | ✅ | ✅ (picks the first row) | — one row is read |
| `record:quick_actions` | ✅ (its record) | filter / sort | ✅ | ✅ (picks the first row) | — one row is read |

Reading the `view` column: it lists what a named saved view actually contributes
on that block. A view name that does not resolve is reported as a configuration
error on **every** block in the table, including the ones that take nothing else
from the view — so a typo never passes silently, whatever the block.

Reading the `object` column: it lands on the block's own object key, which is
`objectName` everywhere except `record:line_items`, where the collection the panel
Reading the `object` column: on a block with an object key it lands on that key,
which is `objectName` everywhere except `record:line_items`, where the collection the panel
lists, fetches and writes is `childObject`. Its `relationshipField` is *not* part
of the binding and stays the author's — it has to name a field on the bound child
object, so rebinding `object` without updating it is an authoring error the panel
cannot paper over. A `record:line_items` node bound this way needs no `childObject`
of its own, and the page compile accepts it; a node that names its child object in
neither place shows a configuration hint naming `childObject` and loads nothing.

Two kinds of block have no object key for the binding to land on. The
`element:*` blocks read `dataSource.object` themselves (see below). The three
one-record blocks, whose rows read "its record", take the binding's `object` as
the object of the record they show, and the bound row becomes that record.

`record:details`, `record:highlights` and `record:quick_actions` show the
record their record context carries. Bound, a block reads the FIRST row of
the bound set by the binding's `sort`, under the caller's own identity, and that
row becomes its record, so a product page shows a record without being a
`type: 'record'` page:

```json
{
"type": "record:details",
"properties": { "fields": ["name", "environment_type"] },
"dataSource": {
"object": "sys_environment",
"filter": [{ "field": "environment_type", "operator": "equals", "value": "production" }],
"limit": 1
}
}
```

When no row matches — or the server refuses the read — the block draws what it
draws for a record that is not there: `record:details` its "record not found"
state, `record:highlights` nothing, `record:quick_actions` an empty bar. A read
that fails any other way is reported in the block's place. `record:quick_actions`
runs the object's declared actions against the bound row's id. The bound row is
re-read whenever a write to its object is announced on the page (the data
source's mutation events, or `notifyDataChanged`). `objectui validate` warns on a
record block whose binding has no `limit: 1`, so the binding says which record it
means. On a record page, a block with no `dataSource` still shows the page's
record; a bound one shows its bound record instead, read-only.

Because the binding lands on `objectName`, a node bound this way needs no
`objectName` of its own. The schema validator (`StrictAnyComponentSchema`, which
`objectui validate` runs, and the tolerant `safeValidateSchema` alike) counts the binding as a record source of `list-view`,
Expand Down
Loading
Loading