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
52 changes: 52 additions & 0 deletions .changeset/7353-retire-widget-dataprovider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
'@object-ui/plugin-dashboard': minor
'@object-ui/types': minor
---

Retire the widget `dataProvider` key. It was declared and written, but nothing
read it (objectui#7353, ADR-0049 remove arm).

Both dashboard surfaces build a node for the table and pivot widgets from a
`provider: 'object'` widget. They copied the whole provider config onto that node
as `dataProvider`, next to `objectName`. The widgets read only `objectName`, so
`dataProvider` repeated the same information in a second place that no code used.

**`@object-ui/plugin-dashboard` — BREAKING (declared `minor` per the repo's
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.
- 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
that sets `dataProvider` on these props no longer compiles.
- To migrate, delete `dataProvider` and set `objectName` to the object the widget
should load. `ObjectPivotTable`'s node has no zod schema, so for that widget
the type check is the only place the key is refused.

**`@object-ui/types`.** Released 17.6.0 declares neither `ObjectDataTableSchema`
nor `dataProvider`, so this part changes no released declaration and no released
validator's verdict. `ObjectDataTableSchema` itself is new after 17.6.0
(objectui#6576). With this change it ships with `dataProvider` retired on both
faces:

- the TS interface declares `dataProvider?: never`;
- the zod mirror (`ObjectDataTableSchema` in `@object-ui/types/zod`) refuses the
key by name, with a message that says to write `objectName`.

The refusal applies to any value, well-formed or malformed. Other undeclared keys
are still accepted, as they are on every `BaseSchema` node.

**Why a tombstone rather than a plain deletion.** Both of these types extend
`BaseSchema`. Its TypeScript index signature and its `.passthrough()` zod schema
accept any key that is not declared. A deleted member would therefore still
compile and still parse, and it would still do nothing. The tombstone turns that
silent no-op into a named refusal that points at `objectName`. It reads nothing,
so no reader was added.

**DOM.** All three producer paths were rendered for real before the change. The
key never reached a DOM attribute: there was no `dataprovider` or
`data-provider` attribute and no React unknown-prop warning. So this change does
not close any objectui#4357-class leak.
2 changes: 0 additions & 2 deletions packages/plugin-dashboard/src/DashboardGridLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,6 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
type: 'data-table',
...restOptions,
objectName: widgetData.object,
dataProvider: widgetData,
data: [],
searchable: false,
pagination: false,
Expand Down Expand Up @@ -422,7 +421,6 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
type: 'pivot',
...restOptions,
objectName: widgetData.object,
dataProvider: widgetData,
data: [],
};
}
Expand Down
1 change: 0 additions & 1 deletion packages/plugin-dashboard/src/DashboardRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,6 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
type: 'object-data-table',
...restOptions,
objectName: widgetData.object,
dataProvider: widgetData,
filter: widgetData.filter || widget.filter,
searchable: isList ? false : (widget.searchable ?? false),
pagination: isList ? false : (widget.pagination ?? false),
Expand Down
26 changes: 23 additions & 3 deletions packages/plugin-dashboard/src/ObjectPivotTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,26 @@ const EMPTY_ROWS = Object.freeze([]) as unknown as any[];
export interface ObjectPivotTableProps {
schema: PivotTableSchema & {
objectName?: string;
dataProvider?: { provider: string; object?: string };
/**
* RETIRED (objectui#7353, ADR-0049 remove arm, ruling 5809008870) — write
* `objectName`, the key this widget fetches through.
*
* This member carried the dashboard widget's provider config
* (`{ provider, object }`), copied onto the pivot node beside `objectName`
* by `DashboardGridLayout` and read by nothing. The producer no longer
* writes it and no reader was added.
*
* A `?: never` tombstone, not a plain deletion: `PivotTableSchema` extends
* `BaseSchema`, whose `[key: string]: any` would absorb a deleted member
* silently at any value. Licensed by prong 1 of the discriminator
* (objectui#5941, #7526, as amended by objectui#7678): it names the live
* replacement, `objectName`. ⚠️ This is the ONLY refusal for this node:
* neither `object-pivot` nor `PivotTableSchema` has a zod mirror, so a
* JSON-authored value is refused by nothing at parse time.
*
* @deprecated Not read by `ObjectPivotTable` — write `objectName`.
*/
dataProvider?: never;
// The data-scope binding key is NOT re-declared here. It used to be, as a
// local member grown because no schema shape declared it — the
// second-declaration class objectui#6357 measured. `PivotTableSchema
Expand All @@ -66,8 +85,9 @@ export interface ObjectPivotTableProps {
// `ObjectCalendarSchema`, `ObjectKanbanSchema`), plus `ObjectChartSchema`'s
// two-armed union, `ObjectGallerySchema`'s `unknown` and
// `ObjectDataTableSchema`'s `any`. `object-pivot` has no such interface at
// all, so the consistent fix is to give it one carrying all three members
// grown here — `objectName`, `dataProvider`, `filter`. That widens a
// all, so the consistent fix is to give it one carrying the two live
// members grown here — `objectName`, `filter` — and the `dataProvider`
// tombstone beside them. That widens a
// published authorable surface and wants its own card and ruling, rather
// than a one-member edit smuggled into a composition fix.
//
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/**
* 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.
*/

/**
* objectui#7353 — the widget node no longer carries `dataProvider`
* (ADR-0049, remove arm; ruled on the card).
*
* ## What was retired
*
* Both dashboard surfaces turned a `provider: 'object'` widget into a node for
* the table / pivot widgets and wrote the whole provider config onto it as
* `dataProvider: widgetData`, beside `objectName: widgetData.object`. Nothing
* read `dataProvider` — the widgets read `objectName` — so the key was a second
* spelling of the same information that only the declarations kept alive
* (`ObjectDataTableSchema.dataProvider` in `@object-ui/types`, and the
* `schema.dataProvider` member of `ObjectPivotTableProps`). All three writes
* went, both typed declarations became retirement tombstones that refuse the
* key and point at `objectName`, and no reader was added.
*
* ## Why the pin reads the NODE, not the screen
*
* An unread key changes nothing visible, so a screen assertion is green on both
* sides. What the removal changes is the node each widget is HANDED — so this
* file records every node that reaches `SchemaRenderer` (the real one renders
* it; the recorder only listens) and asserts on those. The object-data-table
* case also reads the `data-table` node `ObjectDataTable` renders onward,
* because that widget spreads its own node into it: the key travelled one hop
* further than any producer wrote it.
*
* ## Controls, and what they are for
*
* - `objectName` is still written on every one of those nodes — the key the
* widgets read. Without it, "no `dataProvider`" would also be satisfied by a
* branch that stopped emitting the node's binding altogether.
* - The object-data-table widget still FETCHES through `objectName`: the
* adapter's `find` is called for that object. That is the behaviour the
* retired key was never part of, and it must not move.
*
* ## Direction, stated before the reverse verification
*
* RED on the unmodified tree: every "carries no `dataProvider`" assertion (the
* producers wrote it). GREEN on both sides: every control. The type-level half
* (both widget prop types carry `dataProvider` as a `?: never` tombstone, so
* authoring it is a compile error) is enforced by `tsconfig.test.json`, which
* this package's `type-check` chains.
*/

import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, waitFor, cleanup } from '@testing-library/react';

/**
* Every node handed to `SchemaRenderer`, in arrival order. Hoisted so the mock
* factory below can close over it.
*/
const received = vi.hoisted(() => [] as Array<Record<string, unknown>>);

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, so the widgets below mount, fetch and render as they do in
// production. Everything else is the real export by identity.
SchemaRenderer: (props: any) => {
if (props?.schema && typeof props.schema === 'object') received.push(props.schema);
return <RealSchemaRenderer {...props} />;
},
};
});

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`,
// the package barrel registers `object-data-table` / `pivot`.
import '@object-ui/components';
import { DashboardRenderer, DashboardGridLayout } from '../index';
import type { ObjectPivotTableProps } from '../ObjectPivotTable';
import type { ObjectDataTableProps } from '../ObjectDataTable';

afterEach(() => {
cleanup();
received.length = 0;
});

const ROWS = [{ id: 'a1', name: 'Acme', region: 'EMEA', amount: 3 }];

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' } },
})),
};
}

/** The live provider surface: `options.data = { provider: 'object', object }`. */
const OBJECT_PROVIDER = { provider: 'object', object: 'account' } as const;

const dash = (widgets: Record<string, unknown>[]): DashboardComponentSchema =>
({ type: 'dashboard', widgets }) as unknown as DashboardComponentSchema;

const nodesOfType = (type: string) => received.filter((n) => n.type === type);

describe('objectui#7353 — DashboardRenderer: the object-data-table node carries no dataProvider', () => {
it('writes objectName, not dataProvider, and the widget still fetches through objectName', async () => {
const adapter = makeAdapter();
render(
<SchemaRendererProvider dataSource={adapter as never}>
<DashboardRenderer
schema={dash([{ id: 'accounts', type: 'table', title: 'Accounts', options: { data: OBJECT_PROVIDER } }])}
dataSource={adapter}
/>
</SchemaRendererProvider>,
);

// Non-vacuity: the widget really mounted and fetched, and the table it
// renders onward really arrived — otherwise every "absent" below would be
// satisfied by a render that never happened.
await waitFor(() => expect(nodesOfType('data-table').length).toBeGreaterThan(0));

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');
}

// The hop the key used to travel: ObjectDataTable spreads its node into
// the `data-table` it renders.
for (const node of nodesOfType('data-table')) {
expect(Object.keys(node)).not.toContain('dataProvider');
}

// Control: the fetch is keyed on objectName, as before.
expect(adapter.find).toHaveBeenCalled();
expect(adapter.find.mock.calls.every(([objectName]) => objectName === 'account')).toBe(true);
});
});

describe('objectui#7353 — DashboardGridLayout: the table and pivot nodes carry no dataProvider', () => {
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(
<SchemaRendererProvider dataSource={adapter as never}>
<DashboardGridLayout
schema={dash([{ id: 'w1', type: widgetType, options: { ...extra, data: OBJECT_PROVIDER } }])}
/>
</SchemaRendererProvider>,
);

await waitFor(() => expect(nodesOfType(nodeType).length).toBeGreaterThan(0));
for (const node of nodesOfType(nodeType)) {
// Control: the binding the producer writes beside it is still there.
expect(node.objectName).toBe('account');
expect(Object.keys(node)).not.toContain('dataProvider');
}
});
});

/*
* The declaration half: `dataProvider` is a `?: never` RETIREMENT TOMBSTONE on
* both widget prop types (objectui#7353), so authoring it is a compile error.
*
* - `ObjectPivotTableProps['schema']` — the prop type of `ObjectPivotTable`,
* exported from this package's index. It has no zod face (neither
* `object-pivot` nor `PivotTableSchema` is mirrored), so this `tsc` refusal
* is the only one it has.
* - `ObjectDataTableProps['schema']`, anchored to `@object-ui/types`'
* `ObjectDataTableSchema`. This package reads that type through the BUILT
* `.d.ts` (its `tsconfig.test.json` empties `paths`), so these lines are the
* consumer-side half of the types tombstone: red on a stale `dist`.
*
* Why a tombstone and not a deletion: both carriers extend `BaseSchema`, whose
* `[key: string]: any` absorbs a deleted member silently at any value — an
* authored `dataProvider` would still compile and still do nothing. The
* `@ts-expect-error` directives are real enforcement because this package's
* `type-check` chains `tsc -p tsconfig.test.json`; vitest erases them.
*/
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type PivotSchema = ObjectPivotTableProps['schema'];
type DataTableSchema = ObjectDataTableProps['schema'];

/** `?: never` reads as `undefined`; a deletion would read as `any`, the old member as its object shape. */
export type assertionPivotDataProviderIsATombstone = Expect<Equal<PivotSchema['dataProvider'], undefined>>;
export type assertionDataTableDataProviderIsATombstone = Expect<Equal<DataTableSchema['dataProvider'], undefined>>;
/** Non-vacuity twins: the key both widgets read keeps its real type. */
export type assertionPivotObjectNameStaysLive = Expect<Equal<PivotSchema['objectName'], string | undefined>>;
export type assertionDataTableObjectNameStaysLive = Expect<Equal<DataTableSchema['objectName'], string | undefined>>;

describe('objectui#7353 — both widget prop types refuse an authored dataProvider', () => {
it('ObjectPivotTableProps: authoring dataProvider is a compile error; objectName is not', () => {
const node: PivotSchema = {
type: 'pivot',
rowField: 'region',
columnField: 'stage',
valueField: 'amount',
data: [],
objectName: 'account',
// @ts-expect-error `dataProvider` is a retirement tombstone (objectui#7353) — write `objectName`
dataProvider: { provider: 'object', object: 'account' },
};
// Control: an undeclared key carries no directive — the index signature
// absorbs it, which is what a deletion would have left `dataProvider` as.
const undeclared: PivotSchema = { type: 'pivot', rowField: 'r', columnField: 'c', valueField: 'v', data: [], zzUndeclared7353: 1 };
expect([node.objectName, undeclared.zzUndeclared7353]).toEqual(['account', 1]);
});

it('ObjectDataTableProps (anchored to ObjectDataTableSchema): the same, through the built types', () => {
const node: DataTableSchema = {
type: 'object-data-table',
objectName: 'account',
// @ts-expect-error `dataProvider` is a retirement tombstone (objectui#7353) — write `objectName`
dataProvider: { provider: 'object', object: 'account' },
};
const undeclared: DataTableSchema = { type: 'object-data-table', zzUndeclared7353: 1 };
expect([node.objectName, undeclared.zzUndeclared7353]).toEqual(['account', 1]);
});
});
Loading
Loading