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
54 changes: 54 additions & 0 deletions .changeset/element-datasource-remaining-blocks-os7121.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
"@object-ui/plugin-gantt": patch
"@object-ui/plugin-timeline": patch
"@object-ui/plugin-map": patch
"@object-ui/plugin-dashboard": patch
"@object-ui/plugin-form": patch
---

`PageComponentSchema.dataSource` now reaches the remaining object-bound public
blocks: `object-gantt` / `object-timeline` / `object-map` / `object-pivot` /
`object-master-detail-form` / `embeddable-form` / `record:line_items`
(objectstack#7121).

objectstack#6953 wired the spec's per-element data binding
(`dataSource: { object, view?, filter?, sort?, limit? }`) to the eight blocks it
named and left the same declaration inert on these seven. Each gates its fetch on
its own object key and nothing mapped `dataSource.object` onto it, so a page
written the way the spec documents rendered an empty gantt / an empty timeline
rail / a map with no markers / an empty cross-tab / a field-less form — 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, through the
shared `ElementDataSourceGate` (no change to it or to the resolution layer): 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 on every one of
these blocks instead of degrading to the object's full scope.

Each block maps **only** the keys it genuinely reads, which for this batch means
several keys stay deliberately unmapped rather than being parked somewhere
plausible:

- `object-gantt` and `object-map` take `object` / `filter` / `sort`; neither has a
row cap or a field-list read site.
- `object-pivot` takes `object` / `filter`; a cross-tab orders itself by its own
row/column grouping and cannot be computed over a truncated page.
- `object-timeline` takes `object` only — its fetch is
`find(objectName, { options: { $top: 100 } })`, with no filter/sort read site
at all, so a named view is error-checked and then contributes nothing.
- `embeddable-form` and `object-master-detail-form` take `object` only (the
parent object, in the master-detail case); a form that writes one record has no
collection query for `filter` / `sort` / `limit` to narrow.
- `record:line_items` takes `object` onto **`childObject`** — the collection it
actually lists — and nothing else: its query is the parent FK plus a fixed
`$top: 500`, and its `columns` are editable `GridColumn` objects rather than a
field-name projection a view could supply.

The per-block coverage table, including every residual gap named above, is in
`content/docs/guide/data-source.md`.

No behaviour change for a block that carries no `dataSource`: the binding-free
path returns the schema by reference, so nothing remounts and nothing refetches.
42 changes: 33 additions & 9 deletions content/docs/guide/data-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,13 +252,27 @@ ignores would be accepted and dropped, which is the defect this binding removes.
| `object-kanban` | ✅ | filter | ✅ | — no ordering | — fixed window |
| `object-chart` | ✅ | filter | ✅ | — engine orders | — no page |
| `object-metric` | ✅ | filter | ✅ | — single value | — single value |
| `object-gantt` | ✅ | filter / sort | ✅ | ✅ | — no row cap |
| `object-map` | ✅ | filter / sort | ✅ | ✅ | — no row cap |
| `object-pivot` | ✅ | filter | ✅ | — grouping orders | — totals need all rows |
| `object-timeline` | ✅ | error-checked only | — no read site | — no read site | — fixed window |
| `object-form` | ✅ | error-checked only | — no collection query | — | — |
| `embeddable-form` | ✅ | error-checked only | — no collection query | — | — |
| `object-master-detail-form` | ✅ | error-checked only | — no collection query | — | — |
| `record:line_items` | ✅ (`childObject`) | error-checked only | — parent-scoped only | — no read site | — fixed window |

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
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.

On `record:related_list` the composed filter is AND-combined with the parent
relationship condition, never substituted for it: a related list is always scoped
to the record it appears on, and an *additional* criterion can only narrow that
Expand All @@ -267,12 +281,22 @@ it, so a named view contributed its columns / sort / limit while its filter was
dropped — the list could be wider than the view it named. That gap is closed; the
`filter` cell above is what closed it.)

One current gap, recorded rather than papered over:

- `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.
Current gaps, recorded rather than papered over:

- `object-timeline` has no `$filter` / `$orderby` read site at all: its fetch is
`find(objectName, { options: { $top: 100 } })`. A view named there is resolved
and then contributes nothing, so the rail can be wider than the view it names.
The keys stay unmapped rather than being written where nothing reads them.
- `record:line_items` likewise takes only the object from the binding: its query is
the parent FK plus a fixed `$top: 500`, and its `columns` are editable
`GridColumn` objects (`{ field, type, … }`) rather than a field-name projection,
so a view's column list would be the wrong *shape*, not merely a wider answer.
Both of these are tracked as objectstack#7137.
- `object-form` / `embeddable-form` / `object-master-detail-form` resolve `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. On the
master-detail form the bound object is the **parent**; child collections come
from `details[]`, by FK.

Blocks not in the table (`dashboard`, the other `record:*` panels) do not consume
the binding yet.
151 changes: 151 additions & 0 deletions packages/plugin-dashboard/src/ObjectPivot.elementDataSource.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* 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.
*
* `object-pivot` consumes `PageComponentSchema.dataSource` (objectstack#7121).
*
* `ObjectPivotTable` gates its fetch effect on `schema.objectName`, and nothing
* mapped the spec's `dataSource.object` onto it: a pivot authored the way the
* spec documents rendered an empty cross-tab with no request and no diagnostic —
* the same shape its sibling `object-metric` had before objectstack#6953.
*
* `object` and `filter` are the only mapped keys: a cross-tab orders itself by
* its own `rowField` / `columnField` grouping, issues no `$top` (a total over a
* truncated page would be wrong), and its "columns" are `columnField` VALUES, not
* a field projection a saved view could supply.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
// Registers `object-pivot` via `ObjectPivotBlock` (the wiring under test).
import './index';

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

function makeAdapter(listViews: Record<string, unknown> = { hot: HOT_VIEW }) {
return {
find: vi.fn().mockResolvedValue({ data: [] }),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue({
name: 'opportunity',
fields: {
stage: { type: 'text' },
region: { type: 'text' },
rating: { type: 'text' },
amount: { type: 'number' },
},
listViews,
}),
};
}

const PIVOT = {
rowField: 'stage',
columnField: 'region',
valueField: 'amount',
aggregation: 'sum',
};

const renderBlock = (schema: Record<string, unknown>, adapter: ReturnType<typeof makeAdapter>) =>
render(
<SchemaRendererProvider dataSource={adapter as any}>
<SchemaRenderer schema={schema as any} />
</SchemaRendererProvider>,
);

describe('object-pivot — dataSource: { object, view } (objectstack#7121)', () => {
it('queries the bound object with the saved view’s filter', async () => {
const adapter = makeAdapter();
renderBlock(
{ type: 'object-pivot', ...PIVOT, dataSource: { object: 'opportunity', view: 'hot' } },
adapter,
);

await waitFor(() => expect(adapter.find).toHaveBeenCalled());
const [object, params] = adapter.find.mock.calls[0] as [string, any];
expect(object).toBe('opportunity');
expect(params.$filter).toEqual([['rating', '=', 'hot']]);
});

it('narrows, never widens: the binding’s own filter ANDs with the view’s', async () => {
const adapter = makeAdapter();
renderBlock(
{
type: 'object-pivot',
...PIVOT,
dataSource: { object: 'opportunity', view: 'hot', filter: [['owner', '=', 'me']] },
},
adapter,
);

await waitFor(() => expect(adapter.find).toHaveBeenCalled());
const [, params] = adapter.find.mock.calls[0] as [string, any];
expect(JSON.stringify(params.$filter)).toContain('rating');
expect(JSON.stringify(params.$filter)).toContain('owner');
});

it('reports an unresolvable `view` instead of cross-tabbing the whole object', async () => {
const adapter = makeAdapter();
const { container } = renderBlock(
{ type: 'object-pivot', ...PIVOT, dataSource: { object: 'opportunity', view: 'nope' } },
adapter,
);

await waitFor(() =>
expect(container.querySelector('[data-testid="object-pivot-datasource-error"]')).not.toBeNull(),
);
expect(adapter.find).not.toHaveBeenCalled();
});

it('writes no ordering and no row cap — a cross-tab reads neither', async () => {
const adapter = makeAdapter();
renderBlock(
{
type: 'object-pivot',
...PIVOT,
dataSource: { object: 'opportunity', view: 'hot', sort: [{ field: 'amount', order: 'asc' }], limit: 3 },
},
adapter,
);

await waitFor(() => expect(adapter.find).toHaveBeenCalled());
const [, params] = adapter.find.mock.calls[0] as [string, any];
// Deliberately unmapped, not overlooked: the pivot's query carries only the
// filter. Pinned so adding either mapping has to be a deliberate edit.
expect(params.$orderby).toBeUndefined();
expect(params.$top).toBeUndefined();
});

it('leaves a pivot with NO dataSource exactly as it was', async () => {
const adapter = makeAdapter();
renderBlock(
{
type: 'object-pivot',
objectName: 'opportunity',
...PIVOT,
filter: [['owner', '=', 'me']],
},
adapter,
);

await waitFor(() => expect(adapter.find).toHaveBeenCalled());
const [object, params] = adapter.find.mock.calls[0] as [string, any];
expect(object).toBe('opportunity');
expect(params.$filter).toEqual([['owner', '=', 'me']]);
});
});
42 changes: 41 additions & 1 deletion packages/plugin-dashboard/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,10 +192,50 @@ ComponentRegistry.register(
}
);

/**
* What `ObjectPivotTable` reads for its own query: the object it cross-tabs and
* the filter it cross-tabs over (`ObjectPivotTable.tsx` —
* `dataSource.find(schema.objectName, { $filter: resolveFilterPlaceholders(schema.filter, …) })`).
*
* `sort` / `limit` / `columns` are deliberately unmapped, none of them having a
* read site here: a pivot's ordering comes out of its own row/column grouping
* (`rowField` / `columnField`), its fetch issues no `$top` because a cross-tab
* over a truncated page would report wrong totals, and its "columns" are the
* `columnField` VALUES, not a field projection a saved view could supply.
*/
const OBJECT_PIVOT_DATA_SOURCE: ElementDataSourceMapping = {
filter: true,
};

/**
* Registry shell for `object-pivot` — the spec's per-element `dataSource`
* binding onto the schema keys {@link ObjectPivotTable} reads (objectstack#7121).
*
* Without it a pivot authored the way the spec documents (`dataSource: { object,
* view }`, no flat `objectName`) fell through the `if (!dataSource ||
* !schema.objectName) return;` guard in its fetch effect: an empty cross-tab, no
* request, no diagnostic. Same shape the sibling `object-metric` above had.
*
* Props pass through untouched, and `bound` IS the schema by reference when there
* is no binding — so the dashboard/manual paths that render this component with a
* plain schema behave exactly as before.
*/
const ObjectPivotBlock: React.FC<{ schema?: any; [key: string]: any }> = ({ schema, ...props }) => (
<ElementDataSourceGate
schema={schema}
mapping={OBJECT_PIVOT_DATA_SOURCE}
dataSource={props.dataSource}
testId="object-pivot"
errorTitle="This pivot table’s data source could not be resolved"
>
{(bound) => <ObjectPivotTable {...(props as any)} schema={bound as any} />}
</ElementDataSourceGate>
);

// Register object-aware pivot table (async data loading)
ComponentRegistry.register(
'object-pivot',
ObjectPivotTable,
ObjectPivotBlock,
{
namespace: 'plugin-dashboard',
label: 'Object Pivot Table',
Expand Down
Loading
Loading