Skip to content
Merged
48 changes: 48 additions & 0 deletions .changeset/10685-drilldown-per-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
'@object-ui/types': minor
'@object-ui/plugin-dashboard': patch
---

`object-data-table` refuses `drillDown.filter`, `.maxRows`, `.report` and `target: 'navigate'`, and `object-pivot` refuses `drillDown.mode` (objectui#10685)

**Breaking for authored metadata, graded `minor` by this repo's convention (AGENTS.md: a
breaking change here is `minor`, never `major`):** `object-data-table` no longer accepts
`drillDown.filter`, `drillDown.maxRows`, `drillDown.report` or `drillDown.target: 'navigate'`
on either published face — the TypeScript `ObjectDataTableSchema.drillDown` and its zod
mirror, the door `objectui validate` / `safeValidateSchema` run — so a stored JSON table
config that parsed green before is refused by name now. Each refusal names the key and the
blocks that do read it. None of the four was ever read by this block. Migration: delete the
key; for the drill target write `'drawer'` or `'dialog'`. `enabled`, `mode`, `title` and
`columns` are accepted exactly as before.

Its row drills to the one record it already is, so a drilled list's filter, row cap and
report had nothing to act on, and `'navigate'` was drawn as a drawer. Before this change the
table's zod mirror took the shared `DrillDownConfigSchema` and accepted all four.

`object-pivot` is refused at the TypeScript door only. A stored JSON pivot config carrying
`mode` is still accepted and ignored at render, as it was before: neither `object-pivot` nor
`PivotTableSchema` has a zod mirror, so no validator in this repository reads the members of
a pivot's `drillDown`. `mode` never had a read site on a pivot. Every pivot click point is an
aggregated bucket (a cell, a row or column header, or a total), so the block always drills
through, and `mode`, which chooses drill-to-record for a clicked row, had nothing to choose.

- `@object-ui/types` adds two per-block shapes beside `ObjectMetricDrillDownConfig`, both
published on the root entry `@object-ui/types` and on the `@object-ui/types/data-display`
subpath. Each tombstone's docblock names the blocks that do read the key.
- `ObjectDataTableDrillDownConfig`: `DrillDownConfig` with `filter?: never`,
`maxRows?: never` and `report?: never`, and `target?: 'drawer' | 'dialog'`.
`ObjectDataTableSchema.drillDown` is typed with it, and the `drillDown` member of its zod
mirror (`@object-ui/types/zod`) refuses the same members by name.
- `ObjectPivotDrillDownConfig`: `DrillDownConfig` with `mode?: never`.
- The shared `DrillDownConfig` keeps every member for the blocks that read them. Its `mode`
docblock now names only `object-data-table` instead of charts, pivot tables and metric
cards, none of which read it.
- `@object-ui/plugin-dashboard` types `ObjectPivotTable`'s `schema.drillDown` with
`ObjectPivotDrillDownConfig`, and the component reads its drill config through that type
instead of through `any`. `ObjectDataTable`'s prop is `ObjectDataTableSchema`, so it takes
the table's shape with it.

TSX code now fails to compile in two places. The first is a refused member inside
`ObjectDataTableSchema.drillDown`, or inside `ObjectPivotTable`'s `schema.drillDown`. The
second is a value typed as the shared `DrillDownConfig` handed to either. Delete the key.
Runtime rendering is unchanged.
10 changes: 9 additions & 1 deletion .changeset/6576-widget-schema-anchors.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@ objectui#6914).
BaseSchema`, beside the other `Object*Schema` declarations, with zod mirrors
of the same names under `@object-ui/types/zod`. `ObjectDataTableSchema`
declares the two keys the widget was reading behind casts — `drillDown`
(`DrillDownConfig`) and `onRowClick` — which no declaration carried before.
(`DrillDownConfig`, at this change) and `onRowClick` — which no declaration
carried before.

⚠️ **Dated note, 2026-09-25 — `drillDown` now takes the table's own shape —
objectui#10685.** `ObjectDataTableSchema.drillDown` is
`ObjectDataTableDrillDownConfig`, on both faces: `filter`, `maxRows` and
`report` are refused by name, and `target` is `'drawer'` or `'dialog'`. None of
the four was ever read by this block. The rest of this entry is kept as the
reading of this change.
- `@object-ui/plugin-list`: the published `ObjectGalleryProps.schema` is
`ObjectGallerySchema`. Its accept set WIDENS — every `BaseSchema` member is
writable (`visibleWhen`, a real base member, was a compile error on the
Expand Down
12 changes: 10 additions & 2 deletions .changeset/7352-drill-down-config-mirror.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,13 @@ Accept-set change on the published validator, stated plainly:

`DrillDownConfigSchema` is deliberately NOT `@objectstack/spec/ui`'s
`ChartDrillDownSchema`: that object models the chart-only subset strictly and
refuses `mode` and `report` by name, both of which are live keys on the table /
pivot / metric widgets that share `DrillDownConfig`.
refuses `mode` and `report` by name, both of which were, at this change, keys
`DrillDownConfig` declared for the table / pivot / metric widgets that share it.

⚠️ **Dated note, 2026-09-25 — `mode` is read by the table alone — objectui#10685.**
The blocks now take per-block drill shapes. `mode` is read only by
`object-data-table`, on its row click, and is refused by name on `object-pivot`
(`ObjectPivotDrillDownConfig`, objectui#10685) and on `object-metric`
(`ObjectMetricDrillDownConfig`, objectui#9002), whose click points are always
aggregates. `report` is read by `object-pivot` and `object-metric`, through the
drawer they open. The rest of this entry is kept as the reading of this change.
14 changes: 11 additions & 3 deletions packages/plugin-dashboard/src/ObjectPivotTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { Skeleton, cn } from '@object-ui/components';
import { PivotTable } from './PivotTable';
import { DrillDownDrawer } from './DrillDownDrawer';
import { resolveFilterPlaceholders } from './utils';
import type { PivotTableSchema } from '@object-ui/types';
import type { ObjectPivotDrillDownConfig, PivotTableSchema } from '@object-ui/types';

/**
* Shared empty fallback for the resolved row list (objectui#4629).
Expand Down Expand Up @@ -95,6 +95,14 @@ export interface ObjectPivotTableProps {
// takes `unknown` and routes every shape through the repo's single filter
// sink, so the drill is correct for both arms whatever this key is typed.
filter?: any;
/**
* This block's drill-down shape, not the shared `DrillDownConfig` that
* `PivotTableSchema` carries: `mode` is refused by name
* (`ObjectPivotDrillDownConfig`, objectui#10685, applying objectui#9002's
* ruling B). Every click point on a pivot is an aggregated bucket, so it
* always drills through; there is no row for `mode` to open as a record.
*/
drillDown?: ObjectPivotDrillDownConfig;
};
dataSource?: any;
className?: string;
Expand Down Expand Up @@ -302,7 +310,7 @@ export const ObjectPivotTable: React.FC<ObjectPivotTableProps> = ({ schema, data
const rowFieldLabel = schema.rowField ? fieldNameLabels[schema.rowField] : undefined;

// --- Drill-down wiring ---------------------------------------------------
const drillDown = (schema as any).drillDown;
const drillDown = schema.drillDown;

const handleDrillDown = isDrillEnabled(drillDown)
? (event: DrillEvent) => setDrillEvent(event)
Expand Down Expand Up @@ -350,7 +358,7 @@ export const ObjectPivotTable: React.FC<ObjectPivotTableProps> = ({ schema, data
dataSource={dataSource}
columns={drillDown?.columns}
maxRows={drillDown?.maxRows}
report={(drillDown as any)?.report}
report={drillDown?.report}
/>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@

import { describe, it, expect } from 'vitest';
import type { ObjectDataTableProps } from '../ObjectDataTable';
import type { BaseSchema, DrillDownConfig, ObjectDataTableSchema } from '@object-ui/types';
import type { BaseSchema, ObjectDataTableDrillDownConfig, ObjectDataTableSchema } from '@object-ui/types';

type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
Expand All @@ -51,8 +51,11 @@ export type assertionSchemaExtendsBase = Expect<[Schema] extends [BaseSchema] ?
* objectui#6914 — `Equal`, not `extends`: before the declaration both keys
* resolved to `any` through the literal's index signature, and `any` satisfies
* a one-way check. These were RED on the unmodified tree for exactly that reason.
* objectui#10685 re-pointed this row at the block's own drill shape: the shared
* `DrillDownConfig` with `filter` / `maxRows` / `report` refused and `target`
* narrowed, so the widget's prop refuses exactly what the schema refuses.
*/
export type assertionDrillDownDeclared = Expect<Equal<Schema['drillDown'], DrillDownConfig | undefined>>;
export type assertionDrillDownDeclared = Expect<Equal<Schema['drillDown'], ObjectDataTableDrillDownConfig | undefined>>;
/**
* objectui#9799 widened the anchor's own declaration to an OPTIONAL second
* parameter; this pin reads through `ObjectDataTableProps['schema']`, so it is
Expand Down Expand Up @@ -91,7 +94,7 @@ describe('ObjectDataTableProps.schema — anchored to ObjectDataTableSchema (obj
};
expect(node.drillDown?.mode).toBe('record');

// @ts-expect-error — `enabled` is a boolean on DrillDownConfig; the index signature used to hide this.
// @ts-expect-error — `enabled` is a boolean on the drill shape; the index signature used to hide this.
const wrongShape: Schema = { type: 'object-data-table', drillDown: { enabled: 'yes' } };
// @ts-expect-error — `onRowClick` is a function.
const notAFunction: Schema = { type: 'object-data-table', onRowClick: 'toast' };
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* 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` — `drillDown.mode` is refused on the prop type an author writes
* against (objectui#10685, applying objectui#9002's ruling B to the sibling
* blocks of `object-metric`).
*
* `ObjectPivotDrillDownConfig` in `@object-ui/types` declares the per-block
* shape, and its own pin lives beside it
* (`drill-down-per-block-10685.test.ts`). This file pins the other half: that
* the component this package PUBLISHES takes that shape on `schema.drillDown`,
* and not the shared `DrillDownConfig` that `PivotTableSchema` carries. The
* refusal is worth nothing if the widget's prop still admits the shared type,
* and that is a one-line regression in this package the `@object-ui/types` pin
* cannot see.
*
* Read through the package entry (`../index`), so the type under test is the
* one a consumer of `@object-ui/plugin-dashboard` reaches.
*
* ## These assertions are compile-time only
*
* Judged by `tsc -p tsconfig.test.json`, the second leg of this package's
* `type-check` script, which resolves `@object-ui/types` through its BUILT
* `.d.ts`. Vitest strips types; the `expect` only keeps this a collected suite.
* Runtime behaviour did not change, so nothing here asserts any.
*/

import { describe, it, expect } from 'vitest';
import type { DrillDownConfig } from '@object-ui/types';
import { ObjectPivotTable } from '../index';

describe('object-pivot: drillDown.mode is refused on the prop type (objectui#10685)', () => {
it('is pinned at compile time', () => {
const base = {
type: 'pivot' as const,
objectName: 'deal',
rowField: 'stage',
columnField: 'source',
valueField: 'amount',
data: [] as Record<string, unknown>[],
};

// @ts-expect-error `drillDown.mode` is refused on `object-pivot` (objectui#10685).
const withMode = <ObjectPivotTable schema={{ ...base, drillDown: { enabled: true, mode: 'record' } }} />;
const shared: DrillDownConfig = { enabled: true, mode: 'record' };
// @ts-expect-error a shared config that may carry `mode` is not the pivot's drill shape.
const handedAcross = <ObjectPivotTable schema={{ ...base, drillDown: shared }} />;

// Accept control: the members this block reads still compile.
const live = (
<ObjectPivotTable
schema={{
...base,
drillDown: {
enabled: true,
filter: { stage: 'won' },
title: 'Won deals',
target: 'dialog',
columns: ['name'],
maxRows: 5,
report: { name: 'won_deals_by_owner' },
},
}}
/>
);

expect([withMode, handedAcross, live]).toHaveLength(3);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,12 @@ describe('DrillDownConfig declares no key that no renderer reads', () => {
type _MaxRowsSurvives = Assert<HasKey<DrillDownConfig, 'maxRows'>>;
type _TargetSurvives = Assert<HasKey<DrillDownConfig, 'target'>>;

// `'navigate'` is delivered by every widget that shares this type now:
// `DrillDownDrawer.navigateOnly` for table / pivot / metric, and
// ObjectChart's own branch since objectui#3354. Narrowing the union would
// be a spec change, not a cleanup.
// `'navigate'` is delivered by every block whose drill shape admits it:
// `DrillDownDrawer.navigateOnly` for pivot / metric, and ObjectChart's own
// branch since objectui#3354. `object-data-table` drills to the one record
// its row already is, so its own shape narrows the arm away instead
// (`ObjectDataTableDrillDownConfig`, objectui#10685); the shared union stays
// whole. Narrowing it here would be a spec change, not a cleanup.
type _TargetUnion = Assert<
Equal<NonNullable<DrillDownConfig['target']>, 'drawer' | 'dialog' | 'navigate'>
>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,19 @@ const ACCEPTED: Array<[string, DrillDownConfig]> = [
['an inline report missing objectName, which the reference arm accepts', { report: { name: 'pipeline', columns: [] } }],
];

/**
* objectui#10685 — the `object-data-table` mirror takes that block's OWN drill
* shape: the shared mirror with `filter`, `maxRows` and `report` refused by name
* and `target` narrowed to `'drawer'` / `'dialog'`
* (`drill-down-per-block-10685.test.ts` pins those refusals on both doors). So
* the table leg below runs over the ACCEPTED entries that carry none of them;
* the rest are that pin's refusals, not this ledger's acceptances.
*/
const TABLE_REFUSED_KEYS = ['filter', 'maxRows', 'report'] as const;
const TABLE_ACCEPTED = ACCEPTED.filter(
([, value]) => !TABLE_REFUSED_KEYS.some((key) => key in value) && value.target !== 'navigate',
);

/** Each is a DECLARED key with a value outside its declared type. */
const REFUSED: Array<[string, unknown, string]> = [
['enabled as a string', { enabled: 'yes' }, 'enabled'],
Expand Down Expand Up @@ -166,16 +179,26 @@ describe('objectui#7352 — DrillDownConfigSchema is the zod mirror of DrillDown

describe('objectui#7352 — both declaring mirrors read the key', () => {
it('ChartSchema and ObjectDataTableSchema declare drillDown through the shared mirror', () => {
// The table's through its per-block extension of it (objectui#10685).
expect(ChartSchema.shape.drillDown).toBeDefined();
expect(ObjectDataTableSchema.shape.drillDown).toBeDefined();
});

it('the table leg still covers every value hosts synthesise for a table (objectui#10685)', () => {
// Non-vacuity for the filtered leg below: the blocks `DashboardRenderer`,
// `DrillDownDrawer` and `ObjectChart` write onto an `object-data-table`, and
// the drill tests' disabled and filter-mode blocks, all stay in it.
expect(TABLE_ACCEPTED.map(([label]) => label)).toEqual(expect.arrayContaining([
'the empty block', 'enabled', 'disabled', 'record mode', 'filter mode', 'record mode in a dialog',
]));
});

it.each(ACCEPTED)('a chart carrying %s validates', (_label, value) => {
const r = safeValidateSchema(chart(value));
expect(r.success, r.success ? '' : JSON.stringify(r.error.issues, null, 2)).toBe(true);
});

it.each(ACCEPTED)('an object-data-table carrying %s validates', (_label, value) => {
it.each(TABLE_ACCEPTED)('an object-data-table carrying %s validates', (_label, value) => {
const r = safeValidateSchema(table(value));
expect(r.success, r.success ? '' : JSON.stringify(r.error.issues, null, 2)).toBe(true);
});
Expand Down
Loading
Loading