Skip to content
Merged
9 changes: 9 additions & 0 deletions .changeset/12099-list-view-page-spelling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/plugin-list': minor
---

A page `list-view` node is written in `@objectstack/spec`'s list view spelling, `data` and `type`, and a metadata page reads `properties.type` as the list kind (objectstack-ai/objectstack#22444, ruling C; objectui#12099).

- The `list-view` and `view:list` registrations publish `data` (the spec's `ViewData`, `{ provider: 'object', object }` naming the object) and `type` (the list kind) in place of `objectName` and `viewType`, in their `inputs` and in `list-view`'s `defaultProps`. Neither input is required: a `dataSource` binding can name the object, and an absent kind is a grid unless a bound saved view supplies one.
- On a metadata page, `{ "type": "list-view", "properties": { "data": { "provider": "object", "object": "deal" }, "type": "kanban", "kanban": { … } } }` now draws a kanban over `deal`. Before, the page renderer never copied `properties.type` onto the node (the node's own `type` is the registry key), so every such list drew a grid. The node's own `type` stays `list-view`. A kind the node states outranks a bound saved view's kind.
- **Breaking for page nodes in the old spelling:** a page node's own `objectName` and `viewType` are no longer read. Each logs one console warning naming what to write instead, and a node that names its object only as `objectName` draws the "no object named" hint, which now names `data.object`. Converting stored page nodes is the platform's job (an ADR-0087 conversion in `@objectstack/spec`); the renderer does not read them back. `ListView`'s React props (`ListViewSchema`) are unchanged and still spell the two `objectName` and `viewType`.
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ const ROWS: NodeRow[] = [
{ node: 'object-grid (regression)', shape: 'tuple', path: 'dataSource', filterParam: '$filter',
withFilter: top({ type: 'object-grid', objectName: OBJECT, columns: ['name'] }) },
{ node: 'list-view (regression)', shape: 'tuple', path: 'dataSource', filterParam: '$filter', stubChildGrid: true,
withFilter: top({ type: 'list-view', objectName: OBJECT, columns: ['name'] }) },
withFilter: top({ type: 'list-view', data: { provider: 'object', object: OBJECT }, columns: ['name'] }) },
];

type DS = ReturnType<typeof makeDataSource>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,11 @@ const BINDING = `dataSource={{object: '${OBJECT}', view: 'all'}}`;

describe("kind:'html' — the spec `dataSource` binding on a gate-wrapped block (#6678)", () => {
it('draws no unknown-prop diagnostic', () => {
expect(unknownProps(`<list-view objectName="${OBJECT}" ${BINDING} />`)).toEqual([]);
expect(unknownProps(`<list-view data={{provider:'object',object:'${OBJECT}'}} ${BINDING} />`)).toEqual([]);
});

it('still resolves the saved view it names — the binding was not silenced, it was declared', async () => {
const container = await renderHtmlPage(`<list-view objectName="${OBJECT}" ${BINDING} />`);
const container = await renderHtmlPage(`<list-view data={{provider:'object',object:'${OBJECT}'}} ${BINDING} />`);

await waitFor(() => expect(headersOf(container).length).toBeGreaterThan(1));
const headers = headersOf(container);
Expand All @@ -181,10 +181,10 @@ describe("kind:'html' — the spec `dataSource` binding on a gate-wrapped block
// The control for the pin above. If `viewName` / `view` went quiet too, the
// fix would have widened the whitelist rather than declared the one key the
// block reads — and the tier would be back to reporting nothing useful.
expect(unknownProps(`<list-view objectName="${OBJECT}" viewName="all" />`)).toEqual([
expect(unknownProps(`<list-view data={{provider:'object',object:'${OBJECT}'}} viewName="all" />`)).toEqual([
'<list-view> has no prop "viewName"',
]);
expect(unknownProps(`<list-view objectName="${OBJECT}" view="all" />`)).toEqual([
expect(unknownProps(`<list-view data={{provider:'object',object:'${OBJECT}'}} view="all" />`)).toEqual([
'<list-view> has no prop "view"',
]);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,11 +306,31 @@ const diagnosticsOf = (source: string) =>
compile(source, shipped).diagnostics.map((d) => [d.severity, d.code, d.message]);

describe('objectui#11605 — each member compiles a bound node, and leaves the neither node to the runtime', () => {
it.each(MEMBERS)('$tag — the published objectName is not required and names the binding', ({ tag }) => {
const objectName = inputsOf(tag).find((input) => input.name === 'objectName');
expect(objectName, `${tag} publishes no objectName input`).toBeDefined();
expect(objectName?.required).not.toBe(true);
expect(objectName?.description ?? '').toContain('`dataSource.object`');
// The object key is read off the binding doc (`objectKeyOf`): `objectName`
// on every member but `list-view`, whose page node names its object in
// `data` (objectui#12099).
it.each(MEMBERS)('$tag — the published object key is not required and names the binding', ({ tag }) => {
const key = objectKeyOf(tag) ?? 'objectName';
const input = inputsOf(tag).find((candidate) => candidate.name === key);
expect(input, `${tag} publishes no ${key} input`).toBeDefined();
expect(input?.required).not.toBe(true);
expect(input?.description ?? '').toContain('`dataSource.object`');
});

it('list-view — the retired objectName / viewType are not published, so this tier reports them', () => {
// objectui#12099: the page carrier is written `data` / `type`. The tier
// forbids a `type` attribute outright (the tag is the component), so the
// kind has no attribute here; a bound saved view still supplies it.
expect(objectKeyOf('list-view')).toBe('data');
expect(inputsOf('list-view').map((input) => input.name)).toEqual(expect.arrayContaining(['data', 'type']));
// By code and by the key each names, not by wording.
const retired = diagnosticsOf('<list-view objectName="account" viewType="kanban" />');
expect(retired.map(([, code]) => code)).toEqual(['unknown-prop', 'unknown-prop']);
expect(retired.map(([, , message]) => String(message))).toEqual([
expect.stringContaining('"objectName"'),
expect.stringContaining('"viewType"'),
]);
expect(diagnosticsOf('<list-view data={{ provider: "object", object: "account" }} />')).toEqual([]);
});

it.each(MEMBERS)('$tag — a dataSource-bound node with no objectName compiles ok', ({ tag, rest }) => {
Expand Down
49 changes: 36 additions & 13 deletions apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,15 @@
* required for data fetching" error and is not opted in.
*
* Each member opts in through `ElementDataSourceGate`'s `requiresObject`, and
* the gate draws the hint after the binding is applied. Rows per member:
* 1. The neither node draws the hint, naming `objectName`, and asks the data
* layer for nothing.
* 2. Control: the same node with `objectName` draws no hint.
* the gate draws the hint after the binding is applied. `list-view` and
* `view:list` draw it themselves, after the same binding, because a page
* `list-view` names its object in `data` rather than `objectName`
* (objectui#12099): their hint names `data.object`. Rows per member:
* 1. The neither node draws the hint, naming the member's object key, and asks
* the data layer for nothing.
* 2. Control: the same node naming its object (`objectName`, or `data` on the
* two list rows) draws no hint. On those two rows the retired `objectName`
* names nothing: the hint is drawn and nothing is fetched.
* 3. Control: the same node bound by `dataSource.object` draws no hint.
* 4. Control, where the block has another record source: the neither node with
* that source draws no hint, so the hint is not painted over a block that
Expand Down Expand Up @@ -57,6 +62,9 @@ async function resolveLazy(types: readonly string[]): Promise<void> {
}

const HINT = 'No object named: set objectName or dataSource.object.';
/** The page `list-view` key is `data` (objectui#12099), so its hint names `data.object`. */
const LIST_HINT = 'No object named: set data.object or dataSource.object.';
const LIST_NAMED = { data: { provider: 'object', object: 'account' } };

/**
* Each member: the tag, the gate's `testId` stem, the props a complete node
Expand All @@ -68,9 +76,13 @@ const MEMBERS: ReadonlyArray<{
testId: string;
base: Record<string, unknown>;
otherSource?: Record<string, unknown>;
/** The keys that name the object, where they are not `objectName`. */
named?: Record<string, unknown>;
/** The hint, where it names another key than `objectName`. */
hint?: string;
}> = [
{ type: 'list-view', testId: 'list-view', base: {}, otherSource: { data: { provider: 'value', items: [] } } },
{ type: 'view:list', testId: 'list-view', base: {}, otherSource: { data: { provider: 'value', items: [] } } },
{ type: 'list-view', testId: 'list-view', base: {}, otherSource: { data: { provider: 'value', items: [] } }, named: LIST_NAMED, hint: LIST_HINT },
{ type: 'view:list', testId: 'list-view', base: {}, otherSource: { data: { provider: 'value', items: [] } }, named: LIST_NAMED, hint: LIST_HINT },
{
type: 'object-form',
testId: 'object-form',
Expand Down Expand Up @@ -171,26 +183,37 @@ const SECTIONED_ROWS = (['object-form', 'view:form'] as const).flatMap((type) =>
);

describe('objectui#11605 — a node naming its object in neither place shows the no-object hint', () => {
it.each(MEMBERS)('$type — the neither node draws the hint and fetches nothing', async ({ type, testId, base }) => {
it.each(MEMBERS)('$type — the neither node draws the hint and fetches nothing', async ({ type, testId, base, hint = HINT }) => {
const r = await mount({ type, ...base });
expect(r.html, `<${type}> crashed instead of answering`).not.toContain('failed to render');
expect(r.hasHint(testId), `<${type}> drew no no-object hint:\n${r.text.slice(0, 300)}`).toBe(true);
expect(r.text).toContain(HINT);
expect(r.text).toContain(hint);
expect(r.calls).toEqual([]);
});

it.each(MEMBERS)('$type — control: with objectName, no hint', async ({ type, testId, base }) => {
const r = await mount({ type, ...base, objectName: 'account' });
it.each(MEMBERS)('$type — control: naming its object, no hint', async ({ type, testId, base, named = { objectName: 'account' }, hint = HINT }) => {
const r = await mount({ type, ...base, ...named });
expect(r.html).not.toContain('failed to render');
expect(r.hasHint(testId)).toBe(false);
expect(r.text).not.toContain(HINT);
expect(r.text).not.toContain(hint);
});

it.each(MEMBERS)('$type — control: bound by dataSource.object, no hint', async ({ type, testId, base }) => {
it.each(MEMBERS.filter((member) => member.named))(
'$type — the retired objectName names nothing: the hint is drawn and nothing is fetched',
async ({ type, testId, base, hint }) => {
const r = await mount({ type, ...base, objectName: 'account' });
expect(r.html).not.toContain('failed to render');
expect(r.hasHint(testId)).toBe(true);
expect(r.text).toContain(hint);
expect(r.calls).toEqual([]);
},
);

it.each(MEMBERS)('$type — control: bound by dataSource.object, no hint', async ({ type, testId, base, hint = HINT }) => {
const r = await mount({ type, ...base, dataSource: { object: 'account' } });
expect(r.html).not.toContain('failed to render');
expect(r.hasHint(testId)).toBe(false);
expect(r.text).not.toContain(HINT);
expect(r.text).not.toContain(hint);
});

it.each(MEMBERS.filter((member) => member.otherSource))(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,11 @@ const NO_DATA_REACH: Readonly<Record<string, string>> = {
*/
const EXPECTED_CANDIDATES = [
'object-grid',
'list-view',
// `list-view` left this population with objectui#12099: a page `list-view`
// names its object in `data: { provider: 'object', object }`, the spec's
// spelling, and publishes no `objectName`. That binding's reach to the data
// layer is pinned through the real registration by plugin-list's
// `ListViewBlock.pageCarrierSpelling-12099.test.tsx`.
'object-form',
'embeddable-form',
'object-master-detail-form',
Expand Down
20 changes: 12 additions & 8 deletions content/docs/guide/data-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ ignores would be accepted and dropped, which is the defect this binding removes.

| block | `object` | `view` | `filter` | `sort` | `limit` |
|---|---|---|---|---|---|
| `list-view` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `list-view` | ✅ (`data`) | ✅ | ✅ | ✅ | ✅ |
| `object-grid` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `element:record_picker` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `element:number` | ✅ | filter | ✅ | — single value | — single value |
Expand Down Expand Up @@ -282,7 +282,10 @@ 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: 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
which is `objectName` everywhere except `list-view` and `record:line_items`. A
`list-view` node names its object in `data` (`{ "provider": "object", "object": … }`),
the spec's list view spelling, and a bound `dataSource.object` outranks
`data.object`; the node's own `objectName` is not read. On `record:line_items` 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
Expand Down Expand Up @@ -324,21 +327,22 @@ record block whose binding has no `limit: 1`, so the binding says which record i
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
Because the binding lands on `objectName` (on `list-view`, it stands in for
`data`), a node bound this way needs no `objectName` (or `data`) 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`,
`object-grid`, `object-kanban`, `object-calendar`, `object-gantt` and `object-map`,
and still refuses one of those nodes that declares no other record source and names
its object in neither place. A binding with an empty `object` names nothing.

The page compile accepts such a node too, on every row above whose object lands on
`objectName`: those registrations do not declare `objectName` required, because the
binding can supply it. The exception is `record:related_list`, whose spec row
`objectName`, and on `list-view`: those registrations do not declare `objectName`
(on `list-view`, `data`) required, because the binding can supply it. The exception is `record:related_list`, whose spec row
requires `objectName`, so the page compile still refuses one of those nodes without
it. A node that names its object in neither place renders a short hint, "No object
named: set objectName or dataSource.object.", in place of an empty list, board,
form, chart, metric or pivot. `object-grid` shows its own "Object name required for
data fetching" error instead. A block with another record source (inline `data`
form, chart, metric or pivot. On `list-view` the hint names the key a page node
writes: "No object named: set data.object or dataSource.object.". `object-grid`
shows its own "Object name required for data fetching" error instead. A block with another record source (inline `data`
rows, a form's inline fields — `customFields`, or `sections` whose every field is
inline — a chart's `dataset`, a `bind` path, or a metric's `fallbackValue`) draws
from that source and shows no hint.
Expand Down
50 changes: 45 additions & 5 deletions packages/plugin-list/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,12 +348,51 @@ A declared `pagination.pageSize` sizes the window on every view but the
calendar, as before. With no declared size, switching between the grid view and
another view changes the window, so the list fetches again.

## On a page — `data` and `type`

A `list-view` node on a page is written in `@objectstack/spec`'s list view
spelling: `data` says where its records come from, and `type` says which kind of
list it is. On a metadata page both sit in the node's `properties`. The node's
own `type` stays `list-view`, because it is the key the page renderer looks the
component up by:

<!-- doc-json: fragment — the spec's page spelling of a list view, which `objectui validate` does not judge yet: its `list-view` face still reads `objectName` and has no `properties` member (objectui#12099) -->
```json
{
"type": "list-view",
"properties": {
"data": { "provider": "object", "object": "deal" },
"columns": ["name", "amount"],
"kanban": { "groupByField": "stage" }
}
}
```

That node draws a grid of `deal` records: without `properties.type` the list is
a grid, the spec's default kind. Add `"type": "kanban"` to the same `properties`
and it draws a kanban over `deal`, grouped by `stage`. A `type` that names no
kind this list draws is a grid too. `data` may also hand the list its rows
(`{ "provider": "value", "items": [...] }`).

A page node's own `objectName` and `viewType` are **not read**. The page
renderer removes them before the list sees the node, and logs one warning per key
naming what to write instead. A node that names its object only as `objectName`
therefore names no object, and the list draws a hint in its place: "No object
named: set data.object or dataSource.object." Converting page nodes stored in
the old spelling is the platform's job (an ADR-0087 conversion in
`@objectstack/spec`); this renderer does not read them back. `ListView`'s own
React props (the `ListViewSchema` examples above) are a separate surface and
still spell the two `objectName` and `viewType`.

`objectui validate` does not judge the page spelling yet: its `list-view` face
still reads `objectName` and counts no `data` as a record source.

## Page binding — `dataSource` (referencing a saved view by name)

On a metadata page, a `list-view` component can bind its data through the spec's
per-element data source (`PageComponentSchema.dataSource`,
`ElementDataSourceSchema`) instead of spelling `objectName` and inlining the
view's configuration:
On a metadata page, a `list-view` component can also bind its data through the
spec's per-element data source (`PageComponentSchema.dataSource`,
`ElementDataSourceSchema`) instead of naming its object in `data` and inlining
the view's configuration. A bound `dataSource.object` outranks `data`:

```json
{
Expand All @@ -373,7 +412,8 @@ copy of a view's configuration in sync with the view itself. Both the short key
this placement, and they beat the component's own same-named key. Values that
come from the named view are a *baseline*: a key written on the component itself
is more specific than the view it points at, so the component's key wins
(an empty `columns: []` counts as "not authored"). `filter` is the exception —
(an empty `columns: []` counts as "not authored"), and a kind written as
`properties.type` beats the view's kind. `filter` is the exception —
the spec calls `dataSource.filter` "additional filter criteria", so the
component's filter, the view's filter and the binding's filter all AND together.
A binding can narrow what the view selects, never widen it.
Expand Down
Loading
Loading