diff --git a/.changeset/10528-grid-object-table-fetch.md b/.changeset/10528-grid-object-table-fetch.md new file mode 100644 index 0000000000..af23bd1f9a --- /dev/null +++ b/.changeset/10528-grid-object-table-fetch.md @@ -0,0 +1,29 @@ +--- +'@object-ui/plugin-dashboard': patch +--- + +Fix: the editable dashboard grid (`dashboard-grid`, `DashboardGridLayout`) now +treats widgets whose data is `{ provider: 'object', object }` the way the +read-only `DashboardRenderer` already does (objectui#10528). Before this change +the grid turned a table, list or pivot widget with that config into a static +node with `data: []`. The tile showed an empty table or cross-tab and sent no +query. + +**Table and list widgets now fetch their rows.** The grid builds the same node +`DashboardRenderer` builds for the widget: an `object-data-table`, which loads +the object's rows through the data source. The props match too: + +- the provider's `filter` (or the widget's `filter`) reaches the query; +- the widget's `searchable` and `pagination` are honoured, and a `list` never + gets them; +- a row click opens the record in a drawer by default, as on the read-only + dashboard. `options.drillDown` still overrides this. In edit mode, dragging + still starts only from the drag handle. + +**Pivot widgets with that config now show the retired-widget placeholder.** +It is the same placeholder `DashboardRenderer` already shows for them ("This +widget uses a retired data format. Edit it to bind a dataset."). Under ADR-0021 +a pivot is bound to a dataset, and a dataset-bound pivot still renders as +before. + +Widgets bound to inline rows are unchanged, including static-data pivots. diff --git a/.changeset/7353-retire-widget-dataprovider.md b/.changeset/7353-retire-widget-dataprovider.md index cf6cc63a0d..c1c6f1fbaf 100644 --- a/.changeset/7353-retire-widget-dataprovider.md +++ b/.changeset/7353-retire-widget-dataprovider.md @@ -15,9 +15,12 @@ as `dataProvider`, next to `objectName`. The widgets read only `objectName`, so version policy).** Compared with the released 17.6.0: - `DashboardRenderer` no longer writes `dataProvider` on the `object-data-table` - node, and `DashboardGridLayout` no longer writes it on the `data-table` and - `pivot` nodes. All three still write `objectName`, and the object-backed table - widget still fetches through it. + node, and `DashboardGridLayout` no longer writes it on any node it builds for a + `provider: 'object'` widget. Since objectui#10528 the grid builds that same + self-fetching `object-data-table` node for such a table widget, and shows the + retired-widget placeholder for such a pivot widget. The `object-data-table` + node still writes `objectName` on both surfaces, and the object-backed table + widget fetches through it. - In 17.6.0, the `schema` prop types of `ObjectPivotTable` and `ObjectDataTable` both declare `dataProvider?: { provider: string; object?: string }`. Both now declare it as a retirement tombstone (`dataProvider?: never`). TypeScript code diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index a49bed36b6..5dc89294d7 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -376,16 +376,29 @@ export const DashboardGridLayout: React.FC = ({ if (dispatch.family === 'table') { const widgetData = (widget as any).data || options.data; - // provider: 'object' — pass through object config for async data loading + // provider: 'object' — ObjectDataTable fetches the rows (objectui#10528). + // This arm used to emit a STATIC `data-table` with `data: []` and an + // `objectName` that `data-table` never reads, so the tile drew an empty + // table and issued no query, while `DashboardRenderer` fetched and drew + // the same stored widget. It now emits the node that renderer's table arm + // emits, prop for prop: the self-fetching `object-data-table`, the + // provider's `filter`, the declared `searchable` / `pagination` (never on + // a `list`), and default-on drill-to-record. The drill cannot fight the + // editor: dragging starts only from the `.drag-handle` element (see + // `dragConfig` below), and this grid has no widget selection. The two + // copies are held equal by + // `DashboardGridLayout.objectProviderFetch-10528.test.tsx`. if (isObjectProvider(widgetData)) { + const isList = widgetType === 'list'; const { data: _data, ...restOptions } = options; return { - type: 'data-table', + type: 'object-data-table', ...restOptions, objectName: widgetData.object, - data: [], - searchable: false, - pagination: false, + filter: widgetData.filter || widget.filter, + searchable: isList ? false : (widget.searchable ?? false), + pagination: isList ? false : (widget.pagination ?? false), + drillDown: options.drillDown ?? { enabled: true, mode: 'record' as const }, className: "border-0" }; } @@ -414,16 +427,20 @@ export const DashboardGridLayout: React.FC = ({ if (dispatch.family === 'pivot') { const widgetData = (widget as any).data || options.data; - // provider: 'object' — pass through object config for async data loading - if (isObjectProvider(widgetData)) { - const { data: _data, ...restOptions } = options; - return { - type: 'pivot', - ...restOptions, - objectName: widgetData.object, - data: [], - }; - } + // provider: 'object' — RETIRED, with the same placeholder object + // `DashboardRenderer`'s pivot arm returns (objectui#10528). A pivot is a + // cross-tab, and ADR-0021 puts cross-tabs on the dataset layer only: the + // shared dispatch types this family "dataset-bound only; a non-dataset + // pivot is stale metadata" (`widgetDispatch.ts`). This branch used to emit + // a static `pivot` node with `data: []` and an `objectName` that + // `PivotTable` never reads, so the tile drew an empty cross-tab and sent + // no query, while the read dashboard showed this placeholder for the same + // widget. Mapping it to the self-fetching `object-pivot` instead would + // have revived the removed inline analytics shape in the editor alone. + // A dataset-bound pivot still renders through `DatasetWidget` (the fork + // at the render site below), and the static-data pivot under this branch + // is unchanged. + if (isObjectProvider(widgetData)) return LEGACY_RETIRED_WIDGET_SCHEMA; return { type: 'pivot', diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index fb412049b6..6cfd60627e 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -826,11 +826,13 @@ const DashboardRendererInner = forwardRef [] as Array>); + +vi.mock('@object-ui/react', async () => { + const actual: any = await vi.importActual('@object-ui/react'); + const RealSchemaRenderer = actual.SchemaRenderer; + return { + ...actual, + // A listener, not a stub: the node is recorded and then rendered by the + // REAL renderer. Everything else is the real export by identity. + SchemaRenderer: (props: any) => { + if (props?.schema && typeof props.schema === 'object') received.push(props.schema); + return ; + }, + }; +}); + +import { SchemaRendererProvider } from '@object-ui/react'; +import type { DashboardComponentSchema } from '@object-ui/types'; +// Side-effect imports at MODULE scope (never inside a case or a hook), per +// AGENTS.md's flaky-test rule: `@object-ui/components` registers `data-table`, +// and the package barrel registers `object-data-table` / `pivot`. +import '@object-ui/components'; +import { DashboardRenderer, DashboardGridLayout } from '../index'; +import { LEGACY_RETIRED_WIDGET_SCHEMA } from '../legacyRetiredWidget'; + +afterEach(() => { + cleanup(); + received.length = 0; +}); + +const ROWS = [ + { id: 'a1', name: 'Acme', region: 'EMEA', amount: 3 }, + { id: 'a2', name: 'Globex', region: 'APAC', amount: 5 }, +]; + +/** A real adapter surface: `find` records every call and answers with ROWS. */ +function makeAdapter() { + return { + find: vi.fn(async (_objectName: string, _params?: unknown) => ({ data: ROWS })), + getObjectSchema: vi.fn(async (_objectName: string) => ({ + name: 'account', + fields: { name: { type: 'text', label: 'Name' }, region: { type: 'text' }, amount: { type: 'number' } }, + })), + }; +} + +const dash = (widgets: Record[]): DashboardComponentSchema => + ({ type: 'dashboard', widgets }) as unknown as DashboardComponentSchema; + +const nodesOfType = (type: string) => received.filter((n) => n.type === type); + +function renderGrid(widget: Record, adapter = makeAdapter()) { + render( + + + , + ); + return adapter; +} + +describe('objectui#10528 — grid: a provider-object table widget fetches and draws its rows', () => { + it('composes object-data-table, queries the object, and draws the returned rows', async () => { + const adapter = renderGrid({ + id: 'accounts', + type: 'table', + title: 'Accounts', + options: { data: { provider: 'object', object: 'account' } }, + }); + + // The rows the adapter returned are on screen. + expect(await screen.findByText('Acme')).toBeTruthy(); + expect(screen.getByText('Globex')).toBeTruthy(); + + // The query was issued, and only for the bound object. + expect(adapter.find).toHaveBeenCalled(); + expect(adapter.find.mock.calls.every(([objectName]) => objectName === 'account')).toBe(true); + + // The node the grid composed is the self-fetching one, bound through + // objectName, without the key objectui#7353 retired. + const widgetNodes = nodesOfType('object-data-table'); + expect(widgetNodes.length).toBeGreaterThan(0); + for (const node of widgetNodes) { + expect(node.objectName).toBe('account'); + expect(Object.keys(node)).not.toContain('dataProvider'); + } + }); + + it("forwards the provider's filter to the query", async () => { + const filter = [['region', '=', 'EMEA']]; + const adapter = renderGrid({ + id: 'emea', + type: 'table', + title: 'EMEA accounts', + options: { data: { provider: 'object', object: 'account', filter } }, + }); + + await screen.findByText('Acme'); + const params = adapter.find.mock.calls.map(([, p]) => p as { $filter?: unknown }); + expect(params.length).toBeGreaterThan(0); + expect(params.every((p) => JSON.stringify(p.$filter) === JSON.stringify(filter))).toBe(true); + }); +}); + +describe('objectui#10528 — grid and renderer compose the same object-data-table node', () => { + it.each([ + ['a plain table', { type: 'table', options: { data: { provider: 'object', object: 'account' } } }], + [ + 'a table with a provider filter, search and pagination', + { + type: 'table', + searchable: true, + pagination: true, + options: { columns: ['name', 'region'], data: { provider: 'object', object: 'account', filter: [['region', '=', 'EMEA']] } }, + }, + ], + ['a list, which never carries search or pagination chrome', { type: 'list', searchable: true, pagination: true, options: { data: { provider: 'object', object: 'account' } } }], + [ + 'a table with an authored drillDown', + { type: 'table', options: { drillDown: { enabled: false }, data: { provider: 'object', object: 'account' } } }, + ], + ])('%s', async (_label, shape) => { + const widget = { id: 'w1', title: 'Accounts', ...shape }; + + const gridAdapter = renderGrid(widget); + await waitFor(() => expect(nodesOfType('object-data-table').length).toBeGreaterThan(0)); + const gridNode = nodesOfType('object-data-table')[0]; + cleanup(); + received.length = 0; + + const rendererAdapter = makeAdapter(); + render( + + + , + ); + await waitFor(() => expect(nodesOfType('object-data-table').length).toBeGreaterThan(0)); + const rendererNode = nodesOfType('object-data-table')[0]; + + expect(gridNode).toEqual(rendererNode); + // Non-vacuity: both surfaces really bound the object. + expect(gridNode.objectName).toBe('account'); + expect(gridAdapter.find).toHaveBeenCalled(); + }); +}); + +describe('objectui#10528 — grid: a provider-object pivot widget shows the retired-widget placeholder', () => { + const pivotWidget = { + id: 'account-pivot', + type: 'pivot', + title: 'Accounts by region', + options: { + rowField: 'region', + columnField: 'stage', + valueField: 'amount', + aggregation: 'sum', + data: { provider: 'object', object: 'account' }, + }, + }; + + it('renders the placeholder, composes no pivot node, and issues no query', async () => { + const adapter = renderGrid(pivotWidget); + + expect(await screen.findByText(LEGACY_RETIRED_WIDGET_SCHEMA.content)).toBeTruthy(); + expect(received).toContain(LEGACY_RETIRED_WIDGET_SCHEMA); + expect(nodesOfType('pivot')).toHaveLength(0); + expect(nodesOfType('object-pivot')).toHaveLength(0); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('is the same placeholder object DashboardRenderer composes for the same widget', async () => { + renderGrid(pivotWidget); + await waitFor(() => expect(received).toContain(LEGACY_RETIRED_WIDGET_SCHEMA)); + cleanup(); + received.length = 0; + + const rendererAdapter = makeAdapter(); + render( + + + , + ); + // `toContain` compares by identity: both surfaces hand SchemaRenderer the + // one shared declaration, never a restated copy of it. + await waitFor(() => expect(received).toContain(LEGACY_RETIRED_WIDGET_SCHEMA)); + expect(rendererAdapter.find).not.toHaveBeenCalled(); + }); +}); + +describe('objectui#10528 — lit controls: static-data widgets on the grid draw their authored rows', () => { + it('a static-data table draws its authored rows and issues no query', async () => { + const adapter = renderGrid({ + id: 'static-table', + type: 'table', + title: 'Static', + options: { data: [{ name: 'Initech', amount: 7 }, { name: 'Umbrella', amount: 9 }] }, + }); + + expect(await screen.findByText('Initech')).toBeTruthy(); + expect(screen.getByText('Umbrella')).toBeTruthy(); + expect(nodesOfType('data-table').length).toBeGreaterThan(0); + expect(nodesOfType('object-data-table')).toHaveLength(0); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('a static-data pivot draws its authored cross-tab and issues no query', async () => { + const adapter = renderGrid({ + id: 'static-pivot', + type: 'pivot', + title: 'Static pivot', + options: { + rowField: 'region', + columnField: 'stage', + valueField: 'amount', + aggregation: 'sum', + data: [ + { region: 'North', stage: 'won', amount: 4 }, + { region: 'South', stage: 'lost', amount: 6 }, + ], + }, + }); + + const grid = await screen.findByTestId('grid-layout'); + expect(await within(grid).findByText('North')).toBeTruthy(); + expect(within(grid).getByText('South')).toBeTruthy(); + expect(nodesOfType('pivot').length).toBeGreaterThan(0); + expect(adapter.find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-dashboard/src/__tests__/widgetDataProviderRetired-7353.test.tsx b/packages/plugin-dashboard/src/__tests__/widgetDataProviderRetired-7353.test.tsx index aaadfb565a..95efacbf01 100644 --- a/packages/plugin-dashboard/src/__tests__/widgetDataProviderRetired-7353.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/widgetDataProviderRetired-7353.test.tsx @@ -82,6 +82,7 @@ import type { DashboardComponentSchema } from '@object-ui/types'; // the package barrel registers `object-data-table` / `pivot`. import '@object-ui/components'; import { DashboardRenderer, DashboardGridLayout } from '../index'; +import { LEGACY_RETIRED_WIDGET_SCHEMA } from '../legacyRetiredWidget'; import type { ObjectPivotTableProps } from '../ObjectPivotTable'; import type { ObjectDataTableProps } from '../ObjectDataTable'; @@ -147,9 +148,13 @@ describe('objectui#7353 — DashboardRenderer: the object-data-table node carrie }); describe('objectui#7353 — DashboardGridLayout: the table and pivot nodes carry no dataProvider', () => { + // The pivot row used to sit in this table as `['pivot', 'pivot', …]`, + // pinning a `pivot` node with `objectName`. objectui#10528 (amended claim + // 5831757648, option B) retired that branch: the grid's provider-object + // pivot now answers with the shared retired-widget placeholder, as + // `DashboardRenderer` does, so its row is the separate case below. it.each([ ['table', 'data-table', {}], - ['pivot', 'pivot', { rowField: 'region', valueField: 'amount' }], ])('a provider-object %s widget becomes a %s node with objectName and no dataProvider', async (widgetType, nodeType, extra) => { const adapter = makeAdapter(); render( @@ -167,6 +172,24 @@ describe('objectui#7353 — DashboardGridLayout: the table and pivot nodes carry expect(Object.keys(node)).not.toContain('dataProvider'); } }); + + it('a provider-object pivot widget becomes the retired-widget placeholder, which carries no dataProvider (objectui#10528)', async () => { + const adapter = makeAdapter(); + render( + + + , + ); + + await waitFor(() => expect(received).toContain(LEGACY_RETIRED_WIDGET_SCHEMA)); + // The shared object itself, not a copy — and it has no `dataProvider`. + expect(Object.keys(LEGACY_RETIRED_WIDGET_SCHEMA)).not.toContain('dataProvider'); + // The retired branch's node is gone: no `pivot` node, and no query. + expect(nodesOfType('pivot')).toHaveLength(0); + expect(adapter.find).not.toHaveBeenCalled(); + }); }); /*