diff --git a/.changeset/10685-drilldown-per-block.md b/.changeset/10685-drilldown-per-block.md new file mode 100644 index 0000000000..9e73e744e0 --- /dev/null +++ b/.changeset/10685-drilldown-per-block.md @@ -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. diff --git a/.changeset/6576-widget-schema-anchors.md b/.changeset/6576-widget-schema-anchors.md index edd2fed16b..ba6e6cd87d 100644 --- a/.changeset/6576-widget-schema-anchors.md +++ b/.changeset/6576-widget-schema-anchors.md @@ -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 diff --git a/.changeset/7352-drill-down-config-mirror.md b/.changeset/7352-drill-down-config-mirror.md index b3d40dd2e0..56f13e4de8 100644 --- a/.changeset/7352-drill-down-config-mirror.md +++ b/.changeset/7352-drill-down-config-mirror.md @@ -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. diff --git a/packages/plugin-dashboard/src/ObjectPivotTable.tsx b/packages/plugin-dashboard/src/ObjectPivotTable.tsx index b37d1993cc..eeba53e84a 100644 --- a/packages/plugin-dashboard/src/ObjectPivotTable.tsx +++ b/packages/plugin-dashboard/src/ObjectPivotTable.tsx @@ -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). @@ -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; @@ -302,7 +310,7 @@ export const ObjectPivotTable: React.FC = ({ 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) @@ -350,7 +358,7 @@ export const ObjectPivotTable: React.FC = ({ schema, data dataSource={dataSource} columns={drillDown?.columns} maxRows={drillDown?.maxRows} - report={(drillDown as any)?.report} + report={drillDown?.report} /> ); }; diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.schemaAnchor-6576.test.ts b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.schemaAnchor-6576.test.ts index 2dcc0c3aa6..18bbebc1c3 100644 --- a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.schemaAnchor-6576.test.ts +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.schemaAnchor-6576.test.ts @@ -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 = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; @@ -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>; +export type assertionDrillDownDeclared = Expect>; /** * objectui#9799 widened the anchor's own declaration to an OPTIONAL second * parameter; this pin reads through `ObjectDataTableProps['schema']`, so it is @@ -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' }; diff --git a/packages/plugin-dashboard/src/__tests__/ObjectPivotTable.drillDownRefusal-10685.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectPivotTable.drillDownRefusal-10685.test.tsx new file mode 100644 index 0000000000..e374986354 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/ObjectPivotTable.drillDownRefusal-10685.test.tsx @@ -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[], + }; + + // @ts-expect-error `drillDown.mode` is refused on `object-pivot` (objectui#10685). + const withMode = ; + 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 = ; + + // Accept control: the members this block reads still compile. + const live = ( + + ); + + expect([withMode, handedAcross, live]).toHaveLength(3); + }); +}); diff --git a/packages/types/src/__tests__/drill-down-config-declared-keys.test.ts b/packages/types/src/__tests__/drill-down-config-declared-keys.test.ts index de6e6f575e..c51dd2fa78 100644 --- a/packages/types/src/__tests__/drill-down-config-declared-keys.test.ts +++ b/packages/types/src/__tests__/drill-down-config-declared-keys.test.ts @@ -70,10 +70,12 @@ describe('DrillDownConfig declares no key that no renderer reads', () => { type _MaxRowsSurvives = Assert>; type _TargetSurvives = Assert>; - // `'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, 'drawer' | 'dialog' | 'navigate'> >; diff --git a/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts b/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts index 0bfcd8debd..a5ba32a7aa 100644 --- a/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts +++ b/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts @@ -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'], @@ -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); }); diff --git a/packages/types/src/__tests__/drill-down-per-block-10685.test.ts b/packages/types/src/__tests__/drill-down-per-block-10685.test.ts new file mode 100644 index 0000000000..8378a6ca6a --- /dev/null +++ b/packages/types/src/__tests__/drill-down-per-block-10685.test.ts @@ -0,0 +1,192 @@ +/** + * 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. + */ + +/** + * Each drill block refuses, by name, the `DrillDownConfig` members it can + * never honour (objectui#10685, applying objectui#9002's ruling B, a per-block + * refusal, to the sibling blocks of `object-metric`, whose pin is + * `object-metric-drill-down-config-9002.test.ts`). + * + * One `describe` per block. Each refuses its members one per line, so each + * `@ts-expect-error` owns exactly one error, and each carries a control that + * compiles: the members the block DOES read. + * + * - `object-pivot`: `mode`. Every pivot click point is an aggregated bucket, + * so the pivot always drills through; there is no row to open as a record. + * - `object-chart`: `mode` and `report`. Its drill shape is the spec's + * `ChartDrillDown`, the chart subset that declares neither (bound by + * objectui#8885, whose pin holds that binding with `Equal`). A chart segment + * is an aggregated bucket too, and the chart's own drawer renders a record + * list, never a report. This half was already refused before this card, by + * omission from the spec type; it is pinned here so each block's refused + * set sits in one place. + * - `object-data-table`: `filter`, `maxRows`, `report`, and `target: + * 'navigate'`. A row drills to the one record it already is, so a drilled + * list's filter, row cap and report have nothing to act on, and + * `'navigate'` (the object's list page) is the wrong destination. This is + * the one block with a zod door that reads its drill members + * (`safeValidateSchema`, which `objectui validate` runs), so it is pinned on + * both doors. + * + * ## Two instruments + * + * The `@ts-expect-error` lines are judged by `tsc -p tsconfig.test.json`, the + * third leg of this package's `type-check` script. Vitest strips types, so for + * those blocks the `expect` only keeps the block a collected suite. A + * `@ts-expect-error` line whose error disappears is itself an error (TS2578), + * which is what lets each refusal fail. The zod `describe` at the bottom is a + * RUNTIME pin, judged by vitest. + */ + +import { describe, it, expect } from 'vitest'; +// The per-block shapes through the module the published +// `@object-ui/types/data-display` subpath serves; the chart through the barrel. +import type { DrillDownConfig, ObjectDataTableDrillDownConfig, ObjectPivotDrillDownConfig } from '../data-display'; +import type { ObjectChartSchema, ObjectDataTableSchema } from '../index'; +import { safeValidateSchema } from '../zod/index.zod'; + +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type IsAny = 0 extends 1 & T ? true : false; + +describe('object-pivot refuses drillDown.mode (objectui#10685)', () => { + it('is pinned at compile time', () => { + // Keeps the refusals below from passing vacuously: an `any` shape would + // accept `mode` and leave each `@ts-expect-error` unused for the wrong reason. + type _PivotShapeIsReal = Assert, false>>; + + // @ts-expect-error `mode` is refused on `object-pivot` (objectui#10685). + const withMode: ObjectPivotDrillDownConfig = { enabled: true, mode: 'filter' }; + const shared: DrillDownConfig = { enabled: true, mode: 'record' }; + // @ts-expect-error a shared config that may carry `mode` is not a pivot drill config. + const handedAcross: ObjectPivotDrillDownConfig = shared; + + // Control: every member the pivot reads still compiles, `report` included. + const live: ObjectPivotDrillDownConfig = { + enabled: true, + filter: { stage: '${event.rowKey}' }, + title: '${event.rowLabel} × ${event.colLabel}', + target: 'navigate', + columns: ['name', 'amount'], + maxRows: 25, + report: { name: 'won_deals_by_owner' }, + }; + // Still a `DrillDownConfig`, so `computeDrillFilter` and friends take it. + const liveAsShared: DrillDownConfig = live; + + // Control: the shared type keeps `mode` for `object-data-table`. + const table: DrillDownConfig = { enabled: true, mode: 'record' }; + + expect([withMode, handedAcross, liveAsShared, table]).toHaveLength(4); + }); +}); + +describe('object-chart refuses drillDown.mode and drillDown.report (objectui#10685)', () => { + it('is pinned at compile time', () => { + type _ChartDrillIsReal = Assert>, false>>; + + const base = { type: 'object-chart', chartType: 'bar', objectName: 'opportunity' } as const; + + // @ts-expect-error `mode` is not a chart drill member: a segment always drills through. + const withMode: ObjectChartSchema = { ...base, drillDown: { enabled: true, mode: 'record' } }; + // @ts-expect-error `report` is not a chart drill member: the chart's drawer lists records. + const withReport: ObjectChartSchema = { ...base, drillDown: { enabled: true, report: { name: 'pipeline' } } }; + + // Control: every member the chart reads still compiles. + const live: ObjectChartSchema = { + ...base, + drillDown: { + enabled: true, + filter: { stage: '${event.category}' }, + title: '${event.category}', + target: 'navigate', + columns: ['name', 'amount'], + maxRows: 50, + }, + }; + + expect([withMode, withReport, live]).toHaveLength(3); + }); +}); + +describe('object-data-table refuses drillDown.filter, .maxRows, .report and target navigate (objectui#10685)', () => { + it('is pinned at compile time, on the per-block shape and on the schema the prop is anchored to', () => { + type _TableShapeIsReal = Assert, false>>; + // The anchored schema takes the per-block shape, not the shared one. + type _SchemaTakesTheTableShape = Assert< + Equal + >; + + // One refused member per line, so each directive owns exactly one error. + // @ts-expect-error `filter` is refused on `object-data-table` (objectui#10685). + const withFilter: ObjectDataTableDrillDownConfig = { enabled: true, filter: { stage: 'won' } }; + // @ts-expect-error `maxRows` is refused on `object-data-table` (objectui#10685). + const withMaxRows: ObjectDataTableDrillDownConfig = { enabled: true, maxRows: 25 }; + // @ts-expect-error `report` is refused on `object-data-table` (objectui#10685). + const withReport: ObjectDataTableDrillDownConfig = { enabled: true, report: { name: 'pipeline' } }; + // @ts-expect-error `target: 'navigate'` is refused on `object-data-table` (objectui#10685). + const withNavigate: ObjectDataTableDrillDownConfig = { enabled: true, target: 'navigate' }; + const shared: DrillDownConfig = { enabled: true, mode: 'record' }; + // @ts-expect-error a shared config that may carry any of them is not a table drill config. + const handedAcross: ObjectDataTableDrillDownConfig = shared; + + const base = { type: 'object-data-table', objectName: 'opportunity' } as const; + // @ts-expect-error the same refusal through the node an author writes. + const nodeWithFilter: ObjectDataTableSchema = { ...base, drillDown: { enabled: true, filter: { stage: 'won' } } }; + // @ts-expect-error the same refusal through the node an author writes. + const nodeWithNavigate: ObjectDataTableSchema = { ...base, drillDown: { enabled: true, target: 'navigate' } }; + + // Control: every member the block reads still compiles, `enabled` and `mode` first. + const live: ObjectDataTableDrillDownConfig = { + enabled: true, + mode: 'record', + title: 'Opportunity', + columns: ['name', 'amount'], + target: 'dialog', + }; + const liveNode: ObjectDataTableSchema = { ...base, drillDown: { enabled: false, mode: 'filter', target: 'drawer' } }; + // Still a `DrillDownConfig`, so `isDrillEnabled` takes it. + const liveAsShared: DrillDownConfig = live; + + expect([ + withFilter, withMaxRows, withReport, withNavigate, handedAcross, + nodeWithFilter, nodeWithNavigate, liveNode, liveAsShared, + ]).toHaveLength(9); + }); +}); + +describe('object-data-table: the zod door refuses the same members by name (objectui#10685)', () => { + const table = (drillDown: unknown) => ({ type: 'object-data-table', objectName: 'opportunity', drillDown }); + + it('accepts a config carrying every member the block reads', () => { + for (const drillDown of [ + {}, + { enabled: true, mode: 'record', title: 'Opportunity', columns: ['name', 'amount'], target: 'dialog' }, + { enabled: false, mode: 'filter', target: 'drawer' }, + ]) { + const r = safeValidateSchema(table(drillDown)); + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true); + } + }); + + it.each([ + ['filter', { filter: { stage: 'won' } }, 'invalid_type'], + ['maxRows', { maxRows: 25 }, 'invalid_type'], + ['report', { report: { name: 'pipeline' } }, 'invalid_type'], + ['target', { target: 'navigate' }, 'invalid_value'], + ] as const)('refuses drillDown.%s, and the issue names the key', (key, member, code) => { + const r = safeValidateSchema(table({ enabled: true, ...member })); + expect(r.success).toBe(false); + const issues = r.success ? [] : r.error.issues; + const mine = issues.filter((i) => i.path.join('.') === `drillDown.${key}`); + expect(mine, JSON.stringify(issues)).toHaveLength(1); + expect(mine[0].code).toBe(code); + // The named subject, not the wording: the message spells out the key it refuses. + expect(mine[0].message).toContain(`drillDown.${key}`); + }); +}); diff --git a/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts b/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts index 3c6e02853f..2d48b72a5d 100644 --- a/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts +++ b/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts @@ -65,7 +65,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import type { BaseSchema } from '../base.js'; -import type { DrillDownConfig } from '../data-display.js'; +import type { ObjectDataTableDrillDownConfig } from '../data-display.js'; import type { ObjectGallerySchema, ObjectDataTableSchema } from '../objectql.js'; import { ObjectGallerySchema as ObjectGalleryMirror, @@ -88,8 +88,13 @@ export type assertionGalleryExtendsBase = Expect>; export type assertionGalleryTypeIsRegistryKey = Expect>; export type assertionDataTableTypeIsRegistryKey = Expect>; -/** objectui#6914 — declared with the types measured at their prior homes. */ -export type assertionDrillDownDeclared = Expect>; +/** + * objectui#6914 — declared with the types measured at their prior homes. + * `drillDown` has since moved to this block's own shape, the shared + * `DrillDownConfig` with `filter` / `maxRows` / `report` refused and `target` + * narrowed (objectui#10685); `Equal` keeps it from drifting back to the shared type. + */ +export type assertionDrillDownDeclared = Expect>; /** * ⭐ STILL measured at its prior home, which is the point of leaving this pin * `Equal` rather than relaxing it: objectui#6576 declared `onRowClick` here with diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 7b8b3142ce..add5c58358 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -2313,9 +2313,15 @@ export type PivotAggregation = 'sum' | 'count' | 'avg' | 'min' | 'max'; * * ## Declared = delivered (objectui#3354) * - * Every key below is read by at least one of the five widgets that share this - * interface, and `target` is honoured by all of them. Two keys used to break - * that rule and were removed rather than left as authoring bait: + * Every key below is read by at least one block that takes this interface or a + * per-block shape derived from it. A block that can never honour a key refuses + * it by name on its own shape rather than accepting and dropping it + * ({@link ObjectMetricDrillDownConfig}, {@link ObjectPivotDrillDownConfig}, + * {@link ObjectDataTableDrillDownConfig}; objectui#9002, objectui#10685), so + * every `target` arm a block's shape admits is one that block honours: + * `object-data-table`'s shape admits `'drawer'` and `'dialog'` only. Two keys + * used to break that rule for every block and were removed rather than left as + * authoring bait: * * - `view?: string` — self-described as "reserved"; no renderer ever looked it * up, so the drawer rendered its inline `object-data-table` regardless. @@ -2330,19 +2336,21 @@ export interface DrillDownConfig { /** Master switch. Set to true (or supply any other field) to enable. */ enabled?: boolean; /** - * Which drill interaction the widget performs: + * Which drill interaction a row click performs. Read by `object-data-table` + * alone, on its row click (objectui#10685): * - * - `'filter'` (default) — **drill-through**: the click point is an - * aggregated bucket (pivot cell, chart segment, KPI). The drawer lists - * the underlying records filtered by the click context. Used by charts, - * pivot tables and metric cards. - * - `'record'` — **drill-to-record**: the click point already *is* a single - * record (a row in a table / list widget). The drawer shows that record's - * detail instead of a filtered list. This is the default for table / list - * widgets, mirroring Salesforce list-view row → record and Power BI's - * "see records" row interaction. + * - `'record'` (the default) — **drill-to-record**: the clicked row already + * *is* a single record, so the drawer shows that record's detail. This + * mirrors Salesforce list-view row → record and Power BI's "see records" + * row interaction. + * - `'filter'` — **drill-through**: the interaction of a click point that is + * an aggregated bucket, listing the records behind it filtered by the click + * context. A table row is not such a bucket, and `object-data-table` has no + * drill-through arm, so there this value turns the row drill off. * - * When omitted the consuming widget picks the natural default for its type. + * A block whose every click point is an aggregate always drills through, so + * it has nothing for this key to choose and refuses it on its own drill shape + * ({@link ObjectPivotDrillDownConfig}, {@link ObjectMetricDrillDownConfig}). */ mode?: 'filter' | 'record'; /** @@ -2451,6 +2459,108 @@ export interface ObjectMetricDrillDownConfig extends DrillDownConfig { mode?: never; } +/** + * The `object-pivot` block's drill-down shape: {@link DrillDownConfig} with + * `mode` REFUSED BY NAME (objectui#10685, which applies objectui#9002's ruling + * B, a per-block refusal, to the sibling blocks of `object-metric`). + * + * Every click point on a pivot is an aggregated bucket: a cell, a row or + * column header, or a total, and never a single record. So a pivot always + * drills through to the records behind the clicked value, and `mode`, which + * chooses drill-to-record for a clicked ROW, has nothing to choose here. Every + * other member is read: `enabled` by `isDrillEnabled`, `filter` by + * `computeDrillFilter`, `title` by `resolveDrillTitle`, and `target`, + * `columns`, `maxRows` and `report` by the `DrillDownDrawer` the block opens. + * + * A `?: never` tombstone rather than an omission, for the reason + * {@link ObjectMetricDrillDownConfig} gives: it also refuses a value that is + * not a fresh literal, and it carries the reason into the error an author (or + * an AI) sees. + * + * ⚠️ This is a TypeScript declaration, so it refuses the key where an author + * types against it (`ObjectPivotTable`'s `schema.drillDown`). Neither + * `object-pivot` nor `PivotTableSchema` has a zod mirror, so a stored JSON + * config is checked by no validator and reaches the block unchanged. + */ +export interface ObjectPivotDrillDownConfig extends DrillDownConfig { + /** + * REFUSED BY NAME on `object-pivot` (objectui#10685). A pivot's click point + * is always an aggregated bucket, so it always drills through to the records + * behind it, and there is no row to open as a record. `mode` applies on + * `object-data-table`, whose row click reads it. + * + * @deprecated Not a member `object-pivot` reads. A pivot always lists the records behind the clicked value. + */ + mode?: never; +} + +/** + * The `object-data-table` block's drill-down shape: {@link DrillDownConfig} + * with `filter`, `maxRows` and `report` REFUSED BY NAME and `target` narrowed + * to its two in-place arms (objectui#10685, which applies objectui#9002's ruling + * B, a per-block refusal, to the sibling blocks of `object-metric`). + * + * A data table drills to the RECORD its clicked row already is: the row click + * opens that record in `RecordDetailDrawer`, which lists nothing. The three + * refused members each configure a drilled record LIST (the filter that scopes + * it, the cap on its rows, the report that replaces it), and this block has no + * list drill for them to apply to: `mode: 'filter'` turns its row drill off + * rather than drilling through. `target: 'navigate'` opens the object's full + * LIST page through the host's `openRecordList`, which is the wrong destination + * for one record, and the block used to draw it as a drawer instead. + * + * What the block reads: `enabled` by `isDrillEnabled`; `mode`, where `'record'` + * (the default) opens the row and `'filter'` turns the drill off; `title`, a + * non-template title that heads the record drawer; `columns`, the record + * drawer's field whitelist; and `target`, `'drawer'` or `'dialog'`. + * + * `?: never` tombstones for the reason {@link ObjectMetricDrillDownConfig} + * gives: they refuse a value that is not a fresh literal too, and they carry + * the reason into the error an author (or an AI) sees. + * + * Twin of the `drillDown` member of `ObjectDataTableSchema`'s zod mirror + * (`zod/objectql.zod.ts`), which refuses the same members by name, so a stored + * JSON config carrying one is refused at `objectui validate` as well. + */ +export interface ObjectDataTableDrillDownConfig extends DrillDownConfig { + /** + * REFUSED BY NAME on `object-data-table` (objectui#10685). A drill `filter` + * scopes a drilled record list, and this block drills to the one record its + * row already is. Drill filters apply on `object-chart` and `object-pivot`, + * which hand this member to `computeDrillFilter`. + * + * @deprecated Not a member `object-data-table` reads. A row opens the record it already is. + */ + filter?: never; + /** + * REFUSED BY NAME on `object-data-table` (objectui#10685). `maxRows` caps a + * drilled record list, and this block drills to one record. It applies on + * `object-chart`, `object-pivot` and `object-metric`, whose drill lists page + * by it. + * + * @deprecated Not a member `object-data-table` reads. A row opens the record it already is. + */ + maxRows?: never; + /** + * REFUSED BY NAME on `object-data-table` (objectui#10685). A drill `report` + * replaces a drilled record list, and this block drills to one record. It + * applies on `object-pivot` and `object-metric`, whose `DrillDownDrawer` + * renders it. + * + * @deprecated Not a member `object-data-table` reads. A row opens the record it already is. + */ + report?: never; + /** + * Where the record drill lands: `'drawer'` (the default) or `'dialog'`. + * + * `'navigate'` is REFUSED on `object-data-table` (objectui#10685): it opens + * the object's full list page through the host's `openRecordList`, and this + * block's row opens one record. `'navigate'` applies on `object-chart`, + * `object-pivot` and `object-metric`. + */ + target?: 'drawer' | 'dialog'; +} + /** * Pivot table (cross-tabulation) component * diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ac470ef897..48f4753cdf 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -252,6 +252,8 @@ export type { PivotTableSchema, DrillDownConfig, ObjectMetricDrillDownConfig, + ObjectPivotDrillDownConfig, + ObjectDataTableDrillDownConfig, TimelineEvent, TimelineScale, TimelineSchema, diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 06024d7291..c64c993bc9 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -23,7 +23,7 @@ import type { BaseSchema } from './base.js'; // and read here by `ObjectKanbanSchema.columns` (objectui#8913) so the two // kanban faces judge a card the same way. Type-only: no runtime edge. import type { KanbanCard } from './complex.js'; -import type { DrillDownConfig } from './data-display.js'; +import type { ObjectDataTableDrillDownConfig } from './data-display.js'; // `QueryParams` is the destination `ObjectGallerySchema.filter`'s own docblock // names — the value is forwarded into that slot, context tokens resolved — so the declaration // is an INDEXED ACCESS on it rather than a copy of its arms (objectui#9309). @@ -4781,8 +4781,15 @@ export interface ObjectDataTableSchema extends BaseSchema { /** * Drill-to-record: clicking a row opens that record in a detail drawer. * `DashboardRenderer` defaults object-backed table widgets to `{ enabled: true }`. + * + * This block's own drill shape, not the shared `DrillDownConfig` + * (objectui#10685): `filter`, `maxRows` and `report` are refused by name, + * because a row drills to the one record it already is and there is no + * drilled list for them to act on, and `target` is `'drawer'` or `'dialog'`, + * because `'navigate'` opens the object's list page. The zod mirror's + * `drillDown` refuses the same members. */ - drillDown?: DrillDownConfig; + drillDown?: ObjectDataTableDrillDownConfig; /** * Row click handler — a RUNTIME SLOT a React host supplies through this * interface, never through authored JSON (objectui#6124; the zod mirror diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 69a991aca8..18e78505c7 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -2711,7 +2711,10 @@ export const ObjectGallerySchema = BaseSchema.extend({ * and left it unmirrored — minting the mirror was a new export outside * that ruling — so `UnmirroredDeclared` carried it, as it had carried * `ChartSchema.drillDown` since objectui#6058; both entries left the - * ledger with that mirror. + * ledger with that mirror. Since objectui#10685 it is this block's OWN + * drill shape, the twin of `ObjectDataTableDrillDownConfig`: the shared + * mirror extended so that `filter`, `maxRows` and `report` are refused by + * name and `target` takes `'drawer'` or `'dialog'` only. * - `dataProvider` is a RETIREMENT TOMBSTONE (objectui#7353), `?: never` on * the TS twin: refused by name, pointing at `objectName`. Deleting it * instead would leave an authored value KEPT unchecked, because @@ -2730,8 +2733,41 @@ export const ObjectDataTableSchema = BaseSchema.extend({ columns: z.array(z.any()).optional().describe('Column definitions (names or column objects)'), searchable: z.boolean().optional().describe('Forwarded to the rendered data-table'), pagination: z.boolean().optional().describe('Forwarded to the rendered data-table'), - drillDown: DrillDownConfigSchema.optional().describe( - 'Drill-to-record: clicking a row opens that record in a detail drawer (DashboardRenderer defaults object-backed table widgets to { enabled: true, mode: record })', + // objectui#10685 — this block's OWN drill shape, the twin of + // `ObjectDataTableDrillDownConfig` (`../data-display.ts`). A row drills to the + // one record it already is, so the three members that configure a drilled + // record LIST have nothing to act on, and `'navigate'` (the object's list + // page) is the wrong destination for one record. `retirementTombstone` is the + // repo's one declared-and-refused mechanism; here it refuses per block, not a + // retirement: the shared mirror keeps all three keys for the blocks that read + // them. `enabled`, `mode`, `title` and `columns` parse exactly as the shared + // mirror parses them. + drillDown: DrillDownConfigSchema.extend({ + filter: retirementTombstone( + 'REFUSED on `object-data-table` (objectui#10685) — `drillDown.filter` scopes a drilled record list, and ' + + 'this block drills to the one record its row already is, so there is no list to filter. A drill `filter` ' + + 'applies on `object-chart` and `object-pivot`. Delete the key.', + ), + maxRows: retirementTombstone( + 'REFUSED on `object-data-table` (objectui#10685) — `drillDown.maxRows` caps a drilled record list, and ' + + 'this block drills to the one record its row already is. It applies on `object-chart`, `object-pivot` ' + + 'and `object-metric`, whose drill lists page by it. Delete the key.', + ), + report: retirementTombstone( + 'REFUSED on `object-data-table` (objectui#10685) — `drillDown.report` replaces a drilled record list with ' + + 'a report, and this block drills to the one record its row already is. It applies on `object-pivot` and ' + + '`object-metric`, whose drill drawer renders it. Delete the key.', + ), + target: z.enum(['drawer', 'dialog'], { + error: '`drillDown.target` on `object-data-table` is `\'drawer\'` or `\'dialog\'` (objectui#10685). ' + + '`\'navigate\'` is refused here: it opens the object\'s full list page, and this block\'s row opens one ' + + 'record. `\'navigate\'` applies on `object-chart`, `object-pivot` and `object-metric`.', + }).optional().describe( + "Where the record drill lands: 'drawer' (default) or 'dialog'. 'navigate' is refused on this block (objectui#10685)", + ), + }).optional().describe( + 'Drill-to-record: clicking a row opens that record in a detail drawer (DashboardRenderer defaults object-backed table widgets to { enabled: true, mode: record }). ' + + 'This block\'s own drill shape: filter, maxRows and report are refused by name, and target is drawer or dialog (objectui#10685)', ), onRowClick: handlerKeyRefusal('onRowClick', 'runtime-slot', 'Row click handler (overrides drill-to-record)'), body: retirementTombstone(