Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/12056-repeater-datasource.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': patch
---

The page designer writes a repeater's query into its node-level `dataSource` binding, as it already does for `element:number` (objectui#12056, the repeater half of objectui#11880).

In the page-block inspector, an `element:repeater`'s Object picker now writes `dataSource.object` and its Limit box writes `dataSource.limit`. They used to write `properties.object` and `properties.limit`, the flat keys `@objectstack/spec` retires in v18 (objectstack#11509). The repeater's Title field and Fields pickers now list the fields of the object in `dataSource.object`. The Empty text and Dividers between rows controls still write `properties`. The repeater's renderer already reads the binding first (objectui#11880), and `objectui validate` accepts the node the designer now writes, because `@object-ui/types` takes the spec gate's `object` waiver for this element in the same change.

A repeater already stored with a flat `properties.object` or `properties.limit` is not migrated. The inspector shows the binding's values in the curated controls, and a stored flat key stays visible and editable under Advanced.

Nothing is added to the package entry: no export, prop, type member or language-pack key. The controls keep their existing labels.
13 changes: 13 additions & 0 deletions .changeset/12056-repeater-object-waiver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/types': minor
---

An `element:repeater` node may omit `properties.object` when its `dataSource.object` names the object. The zod arm `ElementRepeaterBlockSchema` now applies the same `object` waiver as the spec's props gate (objectui#12056).

`ComponentPropsMap['element:repeater']` requires `object`. The spec's props gate in `@objectstack/lint` (`suppliedByDataSource`) does not report a missing `object` on any component whose `dataSource.object` is a non-empty name. `ElementNumberBlockSchema` already applies that waiver. The repeater arm did not, so it refused the node the Studio page designer now writes: `properties: {}` beside `dataSource: { object: … }`. The arm now applies the waiver the same way `element:number`'s does. The bag is the spec row with `object` alone made optional, and a node refinement puts the requirement back wherever no binding names the object.

- **WIDENS, both zod faces.** The strict face (`StrictAnyComponentSchema`, which `objectui validate` and `objectui check` run) and the tolerant face (`safeValidateSchema`) used to refuse such a repeater at `properties.object`, whatever its `dataSource` said. Both now accept it when `dataSource.object` is a non-empty string.
- **Still refused at `properties.object`.** A bag with no `object` is refused when the node has no binding that names one: no `dataSource`, or a `dataSource.object` that is empty or not a string. The refusal is a `custom` issue with `params.code` `ELEMENT_REPEATER_OBJECT_REQUIRED`, and its message names both remedies. A `properties.object` of the wrong type is still refused by the row itself.
- **TypeScript.** The inferred `ElementRepeaterBlockSchema` type now types `properties.object` as optional, and so does the authoring type `PublicBlockNodeOf<'element:repeater'>` derived from it.

**Clause-② yes (widening):** a strict face widens to the spec gate's rule. No export is added or removed.
15 changes: 9 additions & 6 deletions content/docs/guide/data-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,11 +332,14 @@ node carries both, the repeater follows the precedence `ElementDataSourceGate`
applies to the object-bound blocks above: the binding's `object` wins; `properties.filter` is AND-combined
with the binding's filter (and its view's), so neither is dropped; and for `sort`
and `limit` the binding's own key wins, the flat key wins over one the named
saved view supplies, and the view's is the baseline. The spec still **requires**
`properties.object` on this element, and so does the schema validator
(`safeValidateSchema`) on a `properties` bag that omits it, so a repeater names
its object in its bag even when it binds through `dataSource`. The Studio page
designer writes the repeater's object into `properties.object` for that reason.
saved view supplies, and the view's is the baseline. The spec's props row
declares `properties.object` required, and the spec's props gate waives it when
`dataSource.object` names an object. The schema validator (`safeValidateSchema`,
and `objectui validate` with it) applies the same waiver. A repeater that binds
through `dataSource.object` needs no `properties.object`. A `properties` bag
with neither is refused at `properties.object`. The Studio page designer writes
the repeater's object and its row limit into `dataSource`, as it does the
metric's object.

```json
{
Expand All @@ -347,7 +350,7 @@ designer writes the repeater's object into `properties.object` for that reason.
"sort": [{ "field": "due_date", "order": "asc" }],
"limit": 5
},
"properties": { "object": "task", "titleField": "subject", "fields": ["due_date"] }
"properties": { "titleField": "subject", "fields": ["due_date"] }
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
* in Studio would show its no-object notice. These rows drive the REAL
* inspector and assert the patch it hands the editor: the value lands at node
* level under `dataSource`, never under `properties`, and the measure picker
* resolves its object from `dataSource.object`. (`element:repeater` is not in
* this change: its binding is deferred, objectui#11880.)
* resolves its object from `dataSource.object`. (`element:repeater` followed in
* objectui#12056; its rows are `PageBlockInspector.repeaterDataSource-12056.test.tsx`.)
*/

import { describe, it, expect, vi, afterEach, type Mock } from 'vitest';
Expand Down Expand Up @@ -94,14 +94,6 @@ describe('the designer writes the node-level `dataSource` for element:number (ob
expect(block.dataSource).toEqual({ object: 'contact' });
});

it('CONTROL: element:repeater, outside this change, still writes `properties.object`', () => {
const onPatch = renderInspector(pageDraft({ type: 'element:repeater', properties: {} }));
fireEvent.change(screen.getByLabelText('Object'), { target: { value: 'contact' } });
const block = committedBlock(onPatch);
expect(block.properties).toEqual({ object: 'contact' });
expect(block.dataSource).toBeUndefined();
});

it('the measure picker reads its object from `dataSource.object`, not a flat `properties.object`', () => {
renderInspector(pageDraft({ type: 'element:number', dataSource: { object: 'contact' }, properties: { object: 'lead' } }));
const asked = new Set(fieldsFor.mock.calls.map((call) => call[0]));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#12056 — the page designer writes `element:repeater`'s query into
* the node-level `dataSource`, as objectui#11880 moved `element:number`'s.
*
* The repeater's renderer reads the binding first (objectui#11880), and the
* spec retires the flat `object` / `limit` in v18 (objectstack#11509, ruled
* objectui first). The inspector's curated Object picker and Limit box wrote
* `properties.object` / `properties.limit`, and its two field pickers read
* their object from that flat key. These rows drive the REAL inspector and
* assert the patch it hands the editor: both values land at node level under
* `dataSource`, never under `properties`, and both field pickers list the
* fields of `dataSource.object`.
*/

import { describe, it, expect, vi, afterEach, type Mock } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { PageSchema } from '@objectstack/spec/ui';
import { PageBlockInspector } from './PageBlockInspector';
import type { MetadataInspectorProps } from '../inspector-registry';

// The field catalog hook, observed: which object each field picker asked for.
// Each object has its own field, so a picker's rendered roster names the
// object it resolved.
const fieldsFor = vi.hoisted(() => vi.fn((_objectName: string | undefined) => undefined));
vi.mock('../previews/useObjectFields', () => ({
useObjectFields: (objectName: string | undefined) => {
fieldsFor(objectName);
const catalog: Record<string, Array<{ name: string; label: string; type: string; hidden: boolean }>> = {
contact: [{ name: 'email', label: 'Email', type: 'text', hidden: false }],
lead: [{ name: 'company', label: 'Company', type: 'text', hidden: false }],
};
return { fields: (objectName && catalog[objectName]) || [], loading: false, error: null };
},
}));
// No object catalog: the Object picker falls back to its labelled text box.
vi.mock('../useMetadata', () => ({
useMetadataClient: () => ({ get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]) }),
}));

afterEach(() => {
cleanup();
fieldsFor.mockClear();
});

const BLOCK_PATH = 'regions[0].components[0]';

/** Author a page the way a user does, parse it with the spec, and hand the RESULT to the inspector. */
function pageDraft(block: Record<string, unknown>): Record<string, unknown> {
return PageSchema.parse({
name: 'home',
label: 'Home',
type: 'home',
template: 'default',
regions: [{ name: 'main', components: [{ id: 'b1', ...block }] }],
}) as unknown as Record<string, unknown>;
}

function renderInspector(draft: Record<string, unknown>): Mock<MetadataInspectorProps['onPatch']> {
const onPatch: Mock<MetadataInspectorProps['onPatch']> = vi.fn();
render(
<PageBlockInspector
type="page"
name="home"
draft={draft}
selection={{ kind: 'block', id: BLOCK_PATH }}
onPatch={onPatch}
onClearSelection={() => {}}
readOnly={false}
locale="en-US"
/>,
);
return onPatch;
}

/** The block as the inspector last wrote it (the patch is shallow: `{ regions }`). */
const committedBlock = (onPatch: Mock<MetadataInspectorProps['onPatch']>): Record<string, unknown> =>
(onPatch.mock.calls.at(-1)![0] as { regions: Array<{ components: Array<Record<string, unknown>> }> }).regions[0]
.components[0];

describe('the designer writes the node-level `dataSource` for element:repeater (objectui#12056)', () => {
it('the Object picker writes `dataSource.object`, not `properties.object`', () => {
const onPatch = renderInspector(pageDraft({ type: 'element:repeater', properties: {} }));
fireEvent.change(screen.getByLabelText('Object'), { target: { value: 'contact' } });
const block = committedBlock(onPatch);
expect(block.dataSource).toEqual({ object: 'contact' });
// The parsed page's empty bag, untouched: no `properties.object`.
expect(block.properties).toEqual({});
});

it('the Limit box writes `dataSource.limit` beside the bound object, not `properties.limit`', () => {
const onPatch = renderInspector(pageDraft({ type: 'element:repeater', dataSource: { object: 'contact' }, properties: {} }));
fireEvent.change(screen.getByLabelText('Limit'), { target: { value: '25' } });
const block = committedBlock(onPatch);
expect(block.dataSource).toEqual({ object: 'contact', limit: 25 });
expect(block.properties).toEqual({});
});

it('CONTROL: a display control on the same block still writes `properties`, beside the binding', () => {
const onPatch = renderInspector(pageDraft({ type: 'element:repeater', dataSource: { object: 'contact', limit: 5 } }));
fireEvent.change(screen.getByLabelText('Empty text'), { target: { value: 'Nothing yet' } });
const block = committedBlock(onPatch);
expect(block.properties).toEqual({ emptyText: 'Nothing yet' });
expect(block.dataSource).toEqual({ object: 'contact', limit: 5 });
});

it('both field pickers read their object from `dataSource.object`, not a flat `properties.object`', () => {
renderInspector(
pageDraft({
type: 'element:repeater',
dataSource: { object: 'contact' },
properties: { object: 'lead', titleField: 'email', fields: ['email'] },
}),
);
const asked = new Set(fieldsFor.mock.calls.map((call) => call[0]));
expect(asked).toContain('contact');
expect(asked).not.toContain('lead');
// Each picker's own roster is the bound object's: the Title field picker is
// a dropdown showing `contact`'s field, and so is the Fields list's row.
// Resolved from the flat `lead` (or from nothing) the stored `email` would
// not be offered, and neither control would read `Email (email)`.
expect(screen.getByLabelText('Title field').getAttribute('role')).toBe('combobox');
expect(screen.getAllByText('Email (email)')).toHaveLength(2);
});

it('the Object and Limit controls show the binding, and stored flat `properties.object` / `.limit` stay visible under Advanced', () => {
renderInspector(
pageDraft({
type: 'element:repeater',
dataSource: { object: 'contact', limit: 25 },
properties: { object: 'lead', limit: 7 },
}),
);
expect((screen.getByLabelText('Object') as HTMLInputElement).value).toBe('contact');
expect((screen.getByLabelText('Limit') as HTMLInputElement).value).toBe('25');
// The Advanced editor labels a generic property by its key.
expect((screen.getByLabelText('object') as HTMLInputElement).value).toBe('lead');
expect(screen.getByLabelText('limit')).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -335,11 +335,17 @@ export const BLOCK_CONFIG: Record<string, BlockPropField[]> = {
{ name: 'columns', label: 'engine.inspector.pageBlock.field.element:definition-list.columns', kind: 'number', placeholder: { literal: '1' } },
{ name: 'inline', label: 'engine.inspector.pageBlock.field.element:definition-list.inline', kind: 'boolean' },
],
// objectui#12056 — the repeater's query is the node-level `dataSource`
// binding, which its renderer reads first (objectui#11880), so the Object
// picker writes `dataSource.object`, the Limit box writes `dataSource.limit`,
// and both field pickers read their object from there. They wrote
// `properties.object` / `properties.limit` before: the flat keys the spec
// retires in v18 (objectstack#11509), as `element:number`'s row below was.
'element:repeater': [
{ name: 'object', label: 'engine.inspector.pageBlock.field.element:repeater.object', kind: 'object-picker' },
{ name: 'titleField', label: 'engine.inspector.pageBlock.field.element:repeater.titleField', kind: 'field-picker', objectFrom: 'self', objectProp: 'object' },
{ name: 'fields', label: 'engine.inspector.pageBlock.field.element:repeater.fields', kind: 'field-list', objectFrom: 'self', objectProp: 'object' },
{ name: 'limit', label: 'engine.inspector.pageBlock.field.element:repeater.limit', kind: 'number', placeholder: { literal: '10' } },
{ name: 'object', label: 'engine.inspector.pageBlock.field.element:repeater.object', kind: 'object-picker', at: 'dataSource' },
{ name: 'titleField', label: 'engine.inspector.pageBlock.field.element:repeater.titleField', kind: 'field-picker', objectFrom: 'dataSource' },
{ name: 'fields', label: 'engine.inspector.pageBlock.field.element:repeater.fields', kind: 'field-list', objectFrom: 'dataSource' },
{ name: 'limit', label: 'engine.inspector.pageBlock.field.element:repeater.limit', kind: 'number', placeholder: { literal: '10' }, at: 'dataSource' },
{ name: 'emptyText', label: 'engine.inspector.pageBlock.field.element:repeater.emptyText', kind: 'text' },
{ name: 'divided', label: 'engine.inspector.pageBlock.field.element:repeater.divided', kind: 'boolean' },
],
Expand Down
Loading
Loading