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
58 changes: 58 additions & 0 deletions .changeset/element-datasource-block-wiring-os6953.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
"@object-ui/react": minor
"@object-ui/components": patch
"@object-ui/plugin-grid": patch
"@object-ui/plugin-form": patch
"@object-ui/plugin-kanban": patch
"@object-ui/plugin-calendar": patch
"@object-ui/plugin-charts": patch
"@object-ui/plugin-dashboard": patch
"@object-ui/plugin-detail": patch
---

`PageComponentSchema.dataSource` now reaches every object-bound block, not just
`list-view` — and `element:record_picker` stops discarding `view`
(objectstack#6953).

objectstack#5576 wired the spec's per-element data binding
(`dataSource: { object, view?, filter?, sort?, limit? }`) to `list-view` and left
the same declaration inert on every other page component. Two gaps remained, and
both were silent:

- **`element:record_picker` read four of the five keys and dropped `view`.** So
`dataSource: { object: 'account', view: 'hot' }` — the spec's own example —
built a picker over EVERY account instead of the rows the saved view selects.
Nothing threw and nothing rendered an error; the option list was simply wider
than what was authored, which also means a user could select a record the page
said was out of scope.
- **`object-grid` / `object-form` / `object-kanban` / `object-calendar` /
`object-chart` / `object-metric` / `record:related_list` read none of it.**
Each gates its fetch on its own `objectName`, and nothing mapped
`dataSource.object` onto it, so a page written the way the spec documents
rendered an empty grid / a field-less form / a board with no cards / an empty
month / an empty chart / a static metric number — with no request and no
diagnostic anywhere. Spec-valid metadata rendering nothing is the
objectstack#4413 shape.

Composition follows objectstack#5576's landed semantics unchanged on every block:
a named saved view supplies the baseline, a key written on the component itself
overrides it, an explicit binding key overrides both, `filter` AND-combines
("additional filter criteria" — a binding can narrow a view, never widen it), and
a `view` name that does not resolve renders a configuration error instead of
degrading to the object's full scope.

- `@object-ui/react` — new `useElementDataSourceSchema(schema, mapping, dataSource?)`
and `ElementDataSourceGate` apply a resolved binding to the schema keys a given
block reads, plus `ElementDataSourceErrorPanel` / `ElementDataSourceLoadingPanel`
for the two non-final states. One precedence table for all blocks rather than
one copy per block — that copy is how "additional filter criteria" would have
become two dialects.
- A mapping names **only** keys its block genuinely reads. A composed value
written onto a key the block ignores would be accepted and dropped, which is
the defect being removed, one layer deeper — so a kanban's swimlane `columns`
never receive a view's field list, and a block with no row cap leaves `limit`
unmapped. The per-block coverage table, including two residual gaps that are
named rather than papered over, is in `content/docs/guide/data-source.md`.

No behaviour changes for a block that carries no `dataSource`: the binding-free
path returns the schema by reference, so nothing remounts and nothing refetches.
39 changes: 39 additions & 0 deletions content/docs/guide/data-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,42 @@ it never degrades into an unfiltered query for the object.
renderers that need the same resolution, and `@object-ui/core` exposes the pure
parts (`isElementDataSourceConfig`, `resolveSavedView`,
`composeElementDataSource`).

### Which blocks consume it, and which keys each one honours

The binding is declared on every page component, but a component can only honour
the keys it has a read site for — a calendar has no page to cap, a metric is one
aggregated number, a form edits one record. Each block therefore maps the keys it
reads and leaves the rest alone; a key written onto a schema slot the block
ignores would be accepted and dropped, which is the defect this binding removes.

| block | `object` | `view` | `filter` | `sort` | `limit` |
|---|---|---|---|---|---|
| `list-view` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `object-grid` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `element:record_picker` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `record:related_list` | ✅ | columns / sort / limit | — (see below) | ✅ | ✅ |
| `object-calendar` | ✅ | filter / sort | ✅ | ✅ | — no row cap |
| `object-kanban` | ✅ | filter | ✅ | — no ordering | — fixed window |
| `object-chart` | ✅ | filter | ✅ | — engine orders | — no page |
| `object-metric` | ✅ | filter | ✅ | — single value | — single value |
| `object-form` | ✅ | error-checked only | — no collection query | — | — |

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.

Two current gaps, recorded rather than papered over:

- `record:related_list` declares a flat `filter` its renderer does not read (the
list scopes itself by the parent relationship alone), so a view named there
contributes columns / sort / limit and its filter is dropped — the list can be
wider than the view it names.
- `object-form` resolves `view` only to report an unresolvable name; a view that
does resolve contributes nothing, because a list view's columns are not a form
layout.

Blocks not in the table (`object-gantt`, `object-timeline`, `object-map`,
`object-pivot`, `dashboard`, the other `record:*` panels) do not consume the
binding yet.
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* `element:record_picker` honours `dataSource.view` (objectstack#6953).
*
* This block DID read the spec's per-element binding — `object`, `filter`,
* `sort`, `limit` — and dropped exactly one key: `view`. So
* `dataSource: { object: 'account', view: 'hot' }`, the spec's own example,
* built a picker over EVERY account instead of the rows the saved view selects.
*
* That symptom is quieter than the one objectstack#5576 fixed on `list-view`:
* nothing throws and nothing renders an error — the option list is simply WIDER
* than what was authored, and a picker whose list is too wide is a picker that
* lets a user select a record the page said was out of scope. It is the failure
* class an AI-authored page hides best, which is why the not-found case below
* reports instead of falling back.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { AdapterCtx, SchemaRenderer } from '@object-ui/react';
// Registers `element:record_picker` at module scope (not in a hook) — the
// objectui#3010 rule.
import '../renderers';

const HOT_VIEW = {
name: 'hot',
label: 'Hot accounts',
columns: ['name', 'rating'],
filter: [['rating', '=', 'hot']],
sort: [{ field: 'name', order: 'desc' }],
pagination: { pageSize: 7 },
};

function makeAdapter(listViews: Record<string, unknown> = { hot: HOT_VIEW }) {
return {
find: vi.fn().mockResolvedValue({
data: [
{ id: 'a1', name: 'Acme', rating: 'hot' },
{ id: 'a2', name: 'Zephyr', rating: 'hot' },
],
}),
getObjectSchema: vi.fn().mockResolvedValue({ name: 'account', fields: {}, listViews }),
};
}

const renderPicker = (schema: Record<string, unknown>, adapter: ReturnType<typeof makeAdapter>) =>
render(
<AdapterCtx.Provider value={adapter as any}>
<SchemaRenderer schema={{ type: 'element:record_picker', id: 'picker', ...schema } as any} />
</AdapterCtx.Provider>,
);

const firstQuery = (adapter: ReturnType<typeof makeAdapter>) =>
adapter.find.mock.calls[0][1] as any;

describe('element:record_picker — dataSource.view (objectstack#6953)', () => {
it('narrows the option list to the saved view’s filter, sort and cap', async () => {
const adapter = makeAdapter();
renderPicker({ dataSource: { object: 'account', view: 'hot' } }, adapter);

await waitFor(() => expect(adapter.find).toHaveBeenCalled());
expect(adapter.find.mock.calls[0][0]).toBe('account');
const query = firstQuery(adapter);
// The dropped key, now applied: before this, all three were absent and the
// picker offered every record of the object.
expect(query.$filter).toEqual([['rating', '=', 'hot']]);
expect(query.$orderby).toEqual([{ field: 'name', order: 'desc' }]);
expect(query.$top).toBe(7);
});

it('AND-combines the binding’s own filter with the view’s', async () => {
const adapter = makeAdapter();
renderPicker(
{ dataSource: { object: 'account', view: 'hot', filter: [['amount', '>', 100]] } },
adapter,
);

await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// "Additional filter criteria" (spec): the binding narrows the view, never
// widens it — a mistyped per-element filter cannot expose rows the view
// excluded.
const json = JSON.stringify(firstQuery(adapter).$filter);
expect(json).toContain('rating');
expect(json).toContain('amount');
});

it('lets explicit binding keys override the view’s sort and cap', async () => {
const adapter = makeAdapter();
renderPicker(
{ dataSource: { object: 'account', view: 'hot', sort: [{ field: 'amount', order: 'asc' }], limit: 3 } },
adapter,
);

await waitFor(() => expect(adapter.find).toHaveBeenCalled());
const query = firstQuery(adapter);
expect(query.$orderby).toEqual([{ field: 'amount', order: 'asc' }]);
expect(query.$top).toBe(3);
});

it('reports an unresolvable `view` instead of offering every record', async () => {
const adapter = makeAdapter();
const { container } = renderPicker({ dataSource: { object: 'account', view: 'nope' } }, adapter);

await waitFor(() =>
expect(container.querySelector('[data-testid="record-picker-datasource-error"]')).not.toBeNull(),
);
// The whole point: no query at all rather than an unfiltered one.
expect(adapter.find).not.toHaveBeenCalled();
expect(container.textContent).toContain('hot');
});

it('leaves a picker with NO view exactly as it was', async () => {
// The other direction of the single-variable reproduction: the four keys
// this block already read must behave identically.
const adapter = makeAdapter();
renderPicker(
{
dataSource: {
object: 'account',
filter: [['owner', '=', 'me']],
sort: [{ field: 'name', order: 'asc' }],
limit: 12,
},
},
adapter,
);

await waitFor(() => expect(adapter.find).toHaveBeenCalled());
const query = firstQuery(adapter);
expect(adapter.find.mock.calls[0][0]).toBe('account');
expect(query.$filter).toEqual([['owner', '=', 'me']]);
expect(query.$orderby).toEqual([{ field: 'name', order: 'asc' }]);
expect(query.$top).toBe(12);
// No view named ⇒ nothing about saved views is fetched.
expect(adapter.getObjectSchema).not.toHaveBeenCalled();
});

it('still honours the flat `properties.object` shorthand', async () => {
const adapter = makeAdapter();
renderPicker({ properties: { object: 'account' } }, adapter);
await waitFor(() => expect(adapter.find).toHaveBeenCalledWith('account', expect.any(Object)));
});
});
71 changes: 57 additions & 14 deletions packages/components/src/renderers/basic/record-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* record of an object and writes the selection into a page variable.
*
* Data binding follows the spec's ElementDataSource (`schema.dataSource`):
* { object, filter?, sort?, limit? }
* { object, view?, filter?, sort?, limit? }
* with `properties.object` accepted as a fallback. Display config is read off
* `schema.properties`:
* { labelField='name', valueField='id', label?, placeholder?, emptyText? }
Expand All @@ -20,11 +20,26 @@
* picker is uncontrolled (still usable, just inert) so it never throws outside
* a Page. The written value drives any predicate referencing `page.<var>`
* (e.g. another component's `visible` / `visibility`).
*
* `view` is resolved through {@link useElementDataSource} rather than read off
* the binding directly (objectstack#6953). This block used to take `object` /
* `filter` / `sort` / `limit` off `schema.dataSource` and DROP `view`, so
* `dataSource: { object: 'account', view: 'hot' }` — the spec's own example —
* built an unfiltered picker over every account instead of the rows the saved
* view selects. That symptom is quieter than the one objectstack#5576 fixed on
* `list-view`: nothing errors, the list is simply WIDER than what was authored,
* which is exactly the failure an AI-authored page hides best.
*/

import * as React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { useAdapter, usePageVariableBinding } from '@object-ui/react';
import {
ElementDataSourceErrorPanel,
ElementDataSourceLoadingPanel,
useAdapter,
useElementDataSource,
usePageVariableBinding,
} from '@object-ui/react';
import {
Select,
SelectTrigger,
Expand Down Expand Up @@ -66,22 +81,33 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) {
limit?: number;
}>(schema);

const adapter = useAdapter() as any;

// Per-element data binding (ElementDataSourceSchema) takes precedence over the
// flat `properties.object` shorthand.
const ds = (schema?.dataSource ?? {}) as {
object?: string;
filter?: unknown;
sort?: unknown;
limit?: number;
};
const object = ds.object ?? props.object;
const filter = ds.filter ?? props.filter;
const sort = ds.sort ?? props.sort;
const limit = ds.limit ?? props.limit ?? 50;
// flat `properties.object` shorthand. `dataBinding.composed` carries the
// binding's own keys already combined with the saved view its `view` names —
// the view supplies the baseline, an explicit binding key overrides it, and
// `filter` AND-combines because the spec calls the binding's filter
// *additional*.
//
// The picker's OWN adapter is passed rather than left to the hook's context
// fallback: this block reads its rows from `useAdapter()` (AppShellContext),
// and resolving `view` against a different source than the one the rows come
// from could report a view as missing on a host that has it.
const dataBinding = useElementDataSource(schema, adapter);
const composed = dataBinding.composed;
// While a named view is unresolved (or unresolvable) there is no object to
// query: reading one would fire the wide query the `view` was written to
// narrow. `undefined` parks the fetch effect below; the render returns a
// status panel instead.
const unresolved = dataBinding.status === 'loading' || dataBinding.status === 'missing';
const object = unresolved ? undefined : (composed?.object ?? props.object);
const filter = composed?.filter ?? props.filter;
const sort = composed?.sort ?? props.sort;
const limit = composed?.limit ?? props.limit ?? 50;
const labelField = props.labelField ?? 'name';
const valueField = props.valueField ?? 'id';

const adapter = useAdapter() as any;
const binding = usePageVariableBinding(schema?.id);

const [rows, setRows] = React.useState<any[]>([]);
Expand Down Expand Up @@ -137,6 +163,23 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) {
const label = toText(props.label);
const placeholder = props.placeholder ?? 'Select a record…';

// Placed AFTER every hook above so the hook order stays stable across
// resolution states. A `view` that names nothing renders a configuration
// error rather than an unfiltered picker: degrading to "all records" turns a
// typo into a silently wider answer on a page that still looks like it works.
if (dataBinding.status === 'missing') {
return (
<ElementDataSourceErrorPanel
testId="record-picker"
title="This record picker’s data source could not be resolved"
message={dataBinding.error}
/>
);
}
if (dataBinding.status === 'loading') {
return <ElementDataSourceLoadingPanel testId="record-picker" />;
}

return (
<div
className={cn('space-y-1.5', schema?.className)}
Expand Down
Loading
Loading