Skip to content

Commit 07ed35f

Browse files
committed
fix(components,app-shell): page elements bind data through the node-level dataSource only
element:repeater reads its query (object, filter, sort, limit) from the dataSource binding, gate-wrapped like element:number; element:record_picker and element:number drop their flat-key fallbacks and the number's AND with properties.filter; the Studio designer writes dataSource.object (and the repeater's dataSource.limit) at node level, and its field pickers read the object from there. Docs and changeset follow. Part of objectui#11880 (items 1-4). Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude <noreply@anthropic.com>
1 parent 989b190 commit 07ed35f

7 files changed

Lines changed: 217 additions & 216 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/components': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
Page elements bind data through the node-level `dataSource` only (objectui#11880, the objectui half of objectstack#11509, ruled A-narrow).
7+
8+
- `element:repeater` reads its query from the `dataSource` binding: `object`, `filter`, `sort` and `limit`, composed with the saved view its `view` names. A repeater bound only through `dataSource` used to render "No records". An unresolvable `view` now shows the configuration-error panel, and a resolving one the loading panel. Its registration declares the binding through the injected `dataSource` input and no longer publishes `object`, `filter`, `sort` or `limit`.
9+
- `element:record_picker` and `element:number` no longer read the flat `properties.object` / `filter` / `sort` / `limit` beside the binding, and no longer publish them as inputs. `element:number`'s filter is the binding's alone: the AND with `properties.filter` is gone. An element whose node names no `dataSource.object` issues no query.
10+
- The Studio page designer writes `dataSource.object` for `element:repeater` and `element:number`, and `dataSource.limit` for the repeater's row cap, at node level. Their field pickers read the object from `dataSource.object`. A stored `properties.object` stays visible in the inspector's Advanced section.
11+
12+
Breaking for an author who wrote the flat keys on these three elements: move them into `dataSource`. `@objectstack/spec` retires the flat keys in v18.

‎content/docs/guide/data-source.md‎

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ ignores would be accepted and dropped, which is the defect this binding removes.
256256
| `object-grid` | ✅ | ✅ | ✅ | ✅ | ✅ |
257257
| `element:record_picker` | ✅ | ✅ | ✅ | ✅ | ✅ |
258258
| `element:number` | ✅ | filter | ✅ | — single value | — single value |
259+
| `element:repeater` | ✅ | filter / sort / limit | ✅ | ✅ | ✅ |
259260
| `record:related_list` | ✅ | columns / filter / sort / limit | ✅ | ✅ | ✅ |
260261
| `object-calendar` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling |
261262
| `object-kanban` | ✅ | filter / limit | ✅ | — no ordering | ✅ (`limit`) |
@@ -303,20 +304,37 @@ rows, a form's inline fields — `customFields`, or `sections` whose every field
303304
inline — a chart's `dataset`, a `bind` path, or a metric's `fallbackValue`) draws
304305
from that source and shows no hint.
305306

306-
The two `element:*` rows keep their configuration in the node's `properties` bag,
307-
so the binding does not land on a schema key there: each reads it directly, and
308-
`dataSource.object` wins over `properties.object`. They differ on `filter`.
309-
`element:record_picker` takes the binding's (or its view's) filter in place of
310-
`properties.filter`, which applies only when neither supplies one.
311-
`element:number` AND-combines `properties.filter` with the binding's filter and
312-
its view's — the rule the gate-wrapped blocks above follow — so neither is
313-
dropped, and a filter refused while combining them shows the configuration-error
314-
panel instead of a count. On `element:number`,
307+
The three `element:*` rows keep their display configuration in the node's
308+
`properties` bag and take their query from the binding **only**: `object`,
309+
`filter`, `sort` and `limit` are read from `dataSource` and from nowhere else.
310+
There is no `properties.object`, `properties.filter`, `properties.sort` or
311+
`properties.limit` on these three elements — the renderers do not read them, and
312+
`@objectstack/spec` retires them in v18 (objectstack#11509). The binding's filter
313+
is AND-combined with its view's filter, as on every block above. A repeater is
314+
bound like this:
315+
316+
```json
317+
{
318+
"type": "element:repeater",
319+
"dataSource": {
320+
"object": "contact",
321+
"sort": [{ "field": "name", "order": "asc" }],
322+
"limit": 10
323+
},
324+
"properties": { "titleField": "name", "fields": ["email"] }
325+
}
326+
```
327+
328+
On `element:number`,
315329
`{ "dataSource": { "object": "contact" }, "properties": { "aggregate": "count" } }`
316330
is a complete metric; its `sort` and `limit` are not read, because an aggregate
317-
has no ordering and a capped count would be a wrong number. An `element:number`
318-
that sets `aggregate` but names no object in either place (no `properties.object`,
319-
no `dataSource.object`) shows a short "no object named" notice instead of a count.
331+
has no ordering and a capped count would be a wrong number, and a filter the
332+
converter refuses shows the configuration-error panel instead of a count. An
333+
element whose node names no `dataSource.object` issues no query: an
334+
`element:number` that sets `aggregate` shows a short "no object named" notice
335+
instead of a count, an `element:record_picker` offers no records, and an
336+
`element:repeater` shows its empty-state text. The Studio page designer writes
337+
these elements' object (and the repeater's row cap) into `dataSource`.
320338

321339
#### Scoping a filter to the record in view: `{record_id}`
322340

‎packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -472,23 +472,40 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
472472
block.type as string | undefined,
473473
(block.properties as Record<string, unknown>) || {},
474474
);
475+
// The node-level `dataSource` binding (objectui#11880): a field marked
476+
// `at: 'dataSource'` reads and writes its member, patched at node level the
477+
// way `visibleWhen` is below, never under `properties`.
478+
const binding: Record<string, unknown> =
479+
block.dataSource && typeof block.dataSource === 'object' && !Array.isArray(block.dataSource)
480+
? (block.dataSource as Record<string, unknown>)
481+
: {};
482+
const readBinding = (name: string): unknown => binding[name];
483+
const patchBinding = (name: string, value: unknown) =>
484+
patch({ dataSource: { ...binding, [name]: value } } as Partial<Block>);
475485
// The record page's bound object — drives `field-picker`/`field-list` with
476-
// objectFrom:'page'. (objectFrom:'self' reads a sibling block property.)
486+
// objectFrom:'page'. (objectFrom:'self' reads a sibling block property, and
487+
// objectFrom:'dataSource' the binding's object.)
477488
const pageObject = typeof (draft as any)?.object === 'string' ? ((draft as any).object as string) : undefined;
478489
const resolveObject = (src: BlockPropField & { objectFrom?: string; objectProp?: string }): string | undefined =>
479490
src.objectFrom === 'page'
480491
? pageObject
481-
: src.objectProp != null && blockProps[src.objectProp] != null
482-
? String(blockProps[src.objectProp])
483-
: undefined;
492+
: src.objectFrom === 'dataSource'
493+
? (typeof binding.object === 'string' ? binding.object : undefined)
494+
: src.objectProp != null && blockProps[src.objectProp] != null
495+
? String(blockProps[src.objectProp])
496+
: undefined;
484497
const readProp = (name: string): unknown => blockProps[name] ?? (block as any)[name];
485498
const patchProp = (name: string, value: unknown) =>
486499
patch({ properties: { ...blockProps, [name]: value } } as Partial<Block>);
487500

488501
// Properties already handled by curated fields — excluded from the generic
489-
// "Advanced" section so each property has exactly one editor.
502+
// "Advanced" section so each property has exactly one editor. A field homed
503+
// in the binding edits no `properties` key, so a stored `properties.<name>`
504+
// of the same name stays visible there rather than hidden behind it.
490505
const curatedNames = new Set(
491-
(blockHasConfig(block.type) ? BLOCK_CONFIG[block.type as string] : []).map((f) => f.name),
506+
(blockHasConfig(block.type) ? BLOCK_CONFIG[block.type as string] : [])
507+
.filter((f) => f.at !== 'dataSource')
508+
.map((f) => f.name),
492509
);
493510
const advancedKeys = Object.keys(blockProps).filter(
494511
(key) => !curatedNames.has(key) && !STRUCTURAL_PROP_KEYS.has(key),
@@ -779,7 +796,9 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
779796
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
780797
{t('engine.inspector.pageBlock.properties', locale)}
781798
</div>
782-
{BLOCK_CONFIG[block.type as string].map((f) => renderField(f, readProp, patchProp))}
799+
{BLOCK_CONFIG[block.type as string].map((f) =>
800+
f.at === 'dataSource' ? renderField(f, readBinding, patchBinding) : renderField(f, readProp, patchProp),
801+
)}
783802
</div>
784803
)}
785804

‎packages/app-shell/src/views/metadata-admin/previews/block-config.ts‎

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
*
66
* The page block inspector renders these as typed fields that edit the block's
77
* `properties` (the spec convention; the renderer hoists `properties.*` to the
8-
* top level). Keep each field `name` aligned with the property name the
9-
* corresponding renderer reads. Add block types here as they are needed.
8+
* top level), or — for a field marked `at: 'dataSource'` — the node-level
9+
* `dataSource` binding. Keep each field `name` aligned with the property name
10+
* the corresponding renderer reads. Add block types here as they are needed.
1011
*
1112
* Field kinds:
1213
* text | number | boolean | select — scalar props
@@ -76,8 +77,13 @@
7677

7778
/** Where a field/field-list picker resolves its object from:
7879
* - 'page' — the record page's bound object (draft.object)
79-
* - 'self' — a sibling property on the same block (objectProp) */
80-
export type ObjectSource = { objectFrom: 'page' } | { objectFrom: 'self'; objectProp: string };
80+
* - 'self' — a sibling property on the same block (objectProp)
81+
* - 'dataSource' — the block's node-level `dataSource.object` binding, the
82+
* one place an `element:*` block reads its object from (objectui#11880) */
83+
export type ObjectSource =
84+
| { objectFrom: 'page' }
85+
| { objectFrom: 'self'; objectProp: string }
86+
| { objectFrom: 'dataSource' };
8187

8288
/**
8389
* A field's input hint — EXACTLY ONE of two shapes, chosen where the field is
@@ -105,8 +111,16 @@ export type PlaceholderSpec = { key: string; literal?: never } | { literal: stri
105111
* One curated property editor. Every `label` is a translation key, and every
106112
* `placeholder` declares whether it is one — see the file header for the five
107113
* key shapes and the tests that enforce them.
114+
*
115+
* `at` says where the value lives on the block node (objectui#11880). Absent,
116+
* the default, is `properties.<name>`. `'dataSource'` is the node-level
117+
* `dataSource.<name>`: a member of the spec's `ElementDataSourceSchema`
118+
* binding, which `element:repeater`, `element:number` and
119+
* `element:record_picker` read their query from and nowhere else
120+
* (objectstack#11509, ruled A-narrow). Honoured on a block's top-level fields;
121+
* an `array` field's item editors always write into the item.
108122
*/
109-
export type BlockPropField =
123+
export type BlockPropField = (
110124
| { name: string; label: string; kind: 'text'; placeholder?: PlaceholderSpec }
111125
| { name: string; label: string; kind: 'number'; placeholder?: PlaceholderSpec }
112126
| { name: string; label: string; kind: 'boolean' }
@@ -123,7 +137,8 @@ export type BlockPropField =
123137
// Schema-driven pickers — dropdowns populated from the live metadata.
124138
| { name: string; label: string; kind: 'object-picker'; placeholder?: PlaceholderSpec }
125139
| ({ name: string; label: string; kind: 'field-picker'; placeholder?: PlaceholderSpec } & ObjectSource)
126-
| ({ name: string; label: string; kind: 'field-list'; placeholder?: PlaceholderSpec } & ObjectSource);
140+
| ({ name: string; label: string; kind: 'field-list'; placeholder?: PlaceholderSpec } & ObjectSource)
141+
) & { at?: 'dataSource' };
127142

128143
/** Shared alignment options. The keys name the field (`align`), so reusing this
129144
* const on a field called anything else turns the key-derivation pin red. */
@@ -320,17 +335,24 @@ export const BLOCK_CONFIG: Record<string, BlockPropField[]> = {
320335
{ name: 'columns', label: 'engine.inspector.pageBlock.field.element:definition-list.columns', kind: 'number', placeholder: { literal: '1' } },
321336
{ name: 'inline', label: 'engine.inspector.pageBlock.field.element:definition-list.inline', kind: 'boolean' },
322337
],
338+
// objectui#11880 — the list's query is the node-level `dataSource` binding,
339+
// the only place `element:repeater` reads it from, so `object` and `limit`
340+
// write `dataSource.object` / `dataSource.limit` and the field pickers read
341+
// their object from there. They wrote `properties.object` / `.limit` before:
342+
// keys the renderer no longer reads and the spec retires in v18.
323343
'element:repeater': [
324-
{ name: 'object', label: 'engine.inspector.pageBlock.field.element:repeater.object', kind: 'object-picker' },
325-
{ name: 'titleField', label: 'engine.inspector.pageBlock.field.element:repeater.titleField', kind: 'field-picker', objectFrom: 'self', objectProp: 'object' },
326-
{ name: 'fields', label: 'engine.inspector.pageBlock.field.element:repeater.fields', kind: 'field-list', objectFrom: 'self', objectProp: 'object' },
327-
{ name: 'limit', label: 'engine.inspector.pageBlock.field.element:repeater.limit', kind: 'number', placeholder: { literal: '10' } },
344+
{ name: 'object', label: 'engine.inspector.pageBlock.field.element:repeater.object', kind: 'object-picker', at: 'dataSource' },
345+
{ name: 'titleField', label: 'engine.inspector.pageBlock.field.element:repeater.titleField', kind: 'field-picker', objectFrom: 'dataSource' },
346+
{ name: 'fields', label: 'engine.inspector.pageBlock.field.element:repeater.fields', kind: 'field-list', objectFrom: 'dataSource' },
347+
{ name: 'limit', label: 'engine.inspector.pageBlock.field.element:repeater.limit', kind: 'number', placeholder: { literal: '10' }, at: 'dataSource' },
328348
{ name: 'emptyText', label: 'engine.inspector.pageBlock.field.element:repeater.emptyText', kind: 'text' },
329349
{ name: 'divided', label: 'engine.inspector.pageBlock.field.element:repeater.divided', kind: 'boolean' },
330350
],
351+
// objectui#11880 — the same move for the metric: its object is
352+
// `dataSource.object`, and the measure picker reads its object from there.
331353
'element:number': [
332-
{ name: 'object', label: 'engine.inspector.pageBlock.field.element:number.object', kind: 'object-picker' },
333-
{ name: 'field', label: 'engine.inspector.pageBlock.field.element:number.field', kind: 'field-picker', objectFrom: 'self', objectProp: 'object' },
354+
{ name: 'object', label: 'engine.inspector.pageBlock.field.element:number.object', kind: 'object-picker', at: 'dataSource' },
355+
{ name: 'field', label: 'engine.inspector.pageBlock.field.element:number.field', kind: 'field-picker', objectFrom: 'dataSource' },
334356
{
335357
name: 'aggregate',
336358
label: 'engine.inspector.pageBlock.field.element:number.aggregate',

0 commit comments

Comments
 (0)