From fdaeab1b1f237822037a69170b27cfdcac8f3ba1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:48:04 +0000 Subject: [PATCH 1/2] fix(plugin-dashboard,types): object-metric refuses drillDown.filter and drillDown.mode on its prop type Ruling B on objectui#9002, a per-block refusal: `ObjectMetricDrillDownConfig` in `@object-ui/types` is `DrillDownConfig` with `filter?: never` and `mode?: never` tombstones naming the blocks that do read each key, and `ObjectMetricWidget`'s `drillDown` prop takes it. The shared type is unchanged. The `object-metric` registration's `drillDown` description says the same in one sentence. Refused at the TypeScript door only: no runtime validator here reads the members of an `object-metric` `drillDown`. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- .changeset/9002-metric-drilldown-refusal.md | 30 ++++++++ .../src/ObjectMetricWidget.tsx | 28 ++++--- ...etricWidget.drillDownRefusal-9002.test.tsx | 73 ++++++++++++++++++ ...objectMetricDrillDownMembers-8071.test.tsx | 10 ++- packages/plugin-dashboard/src/index.tsx | 2 +- ...ject-metric-drill-down-config-9002.test.ts | 76 +++++++++++++++++++ packages/types/src/data-display.ts | 46 +++++++++++ 7 files changed, 253 insertions(+), 12 deletions(-) create mode 100644 .changeset/9002-metric-drilldown-refusal.md create mode 100644 packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.drillDownRefusal-9002.test.tsx create mode 100644 packages/types/src/__tests__/object-metric-drill-down-config-9002.test.ts diff --git a/.changeset/9002-metric-drilldown-refusal.md b/.changeset/9002-metric-drilldown-refusal.md new file mode 100644 index 0000000000..066bc5bf7d --- /dev/null +++ b/.changeset/9002-metric-drilldown-refusal.md @@ -0,0 +1,30 @@ +--- +'@object-ui/types': patch +'@object-ui/plugin-dashboard': patch +--- + +`object-metric` refuses `drillDown.filter` and `drillDown.mode` on its prop type (objectui#9002) + +Refused at the TypeScript door only. A stored JSON metric config carrying either key is +still accepted and ignored at render, as it was before: no runtime validator in this +repository reads the members of an `object-metric` `drillDown`. + +Neither key ever had a read site on a metric. A metric is one aggregated number, so it has +no clicked point for a drill `filter` to interpolate `${event.*}` against, and no row for +`mode` to open as a record. Its drilled list is always scoped by the block's own `filter`, +which is the registration's promise that the number and the records behind it agree. Both +keys still type-checked on the widget's `drillDown` prop and then did nothing, with no +diagnostic. + +- `@object-ui/types` adds `ObjectMetricDrillDownConfig`, published on the + `@object-ui/types/data-display` subpath: `DrillDownConfig` with `filter?: never` and + `mode?: never` tombstones whose docblocks name the blocks that do read each key + (`object-chart` and `object-pivot` for `filter`, `object-data-table` for `mode`). The + shared `DrillDownConfig` is unchanged and keeps both keys for those blocks. +- `@object-ui/plugin-dashboard` types `ObjectMetricWidget`'s `drillDown` prop with it, and the + `object-metric` registration's `drillDown` description now says in one sentence that the + two keys do not apply to a metric and where they do. + +TSX code that passes `filter` or `mode` inside `ObjectMetricWidget`'s `drillDown` now fails +to compile. Delete the key: the drilled list was already scoped by the metric's own +`filter`, and nothing read `mode`. Runtime behaviour is unchanged. diff --git a/packages/plugin-dashboard/src/ObjectMetricWidget.tsx b/packages/plugin-dashboard/src/ObjectMetricWidget.tsx index bebcf3b416..3b41b51f00 100644 --- a/packages/plugin-dashboard/src/ObjectMetricWidget.tsx +++ b/packages/plugin-dashboard/src/ObjectMetricWidget.tsx @@ -9,7 +9,8 @@ import React, { useState, useEffect, useContext, useCallback, useMemo } from 'react'; import { SchemaRendererContext, useFilterScope, useDataInvalidation } from '@object-ui/react'; import { isDrillEnabled, resolveDrillTitle, isStructuredGroupBy, objectAggregateSpecQuery } from '@object-ui/core'; -import type { DrillDownConfig, I18nLabel, ObjectChartSchema } from '@object-ui/types'; +import type { I18nLabel, ObjectChartSchema } from '@object-ui/types'; +import type { ObjectMetricDrillDownConfig } from '@object-ui/types/data-display'; import { useLocalization, useDisplayLocale, @@ -180,11 +181,17 @@ export interface ObjectMetricWidgetProps { */ invert?: boolean; /** - * Drill-down config. When enabled, clicking the metric card opens a - * drawer (or modal) showing the underlying records that contributed - * to this metric, filtered by the same `filter` used for aggregation. + * Drill-down config. When enabled, clicking the metric card opens a drawer + * (or dialog) listing the records behind the number, scoped by this + * widget's own `filter`, the one the aggregate runs over. + * + * Typed `ObjectMetricDrillDownConfig`, not the shared `DrillDownConfig`: + * `drillDown.filter` and `drillDown.mode` are refused by name on this block + * (objectui#9002, ruling B). A metric has no click event for a drill filter + * to interpolate against and no row to open as a record. The shared type + * keeps both members for the blocks that read them. */ - drillDown?: DrillDownConfig; + drillDown?: ObjectMetricDrillDownConfig; /** * Title for the drill-down panel; defaults to the metric label. Same * `I18nLabel` vocabulary as {@link ObjectMetricWidgetProps.label}, and @@ -558,11 +565,12 @@ export const ObjectMetricWidget: React.FC = ({ // keeps the page size it had. `className` reproduces the height the inline // body wrapper carried. // - // `drillDown.filter` is deliberately NOT forwarded: the drilled list is - // scoped by the METRIC's own resolved filter, which is the registration's - // promise that the number and the records behind it agree. `mode` has no - // read site on the shared drawer either. Both are left to the judgement - // objectui#8970 asks for rather than settled here. + // `drillDown.filter` and `drillDown.mode` are refused on this block by its + // prop type (`ObjectMetricDrillDownConfig`, objectui#9002 ruling B), so + // neither is forwarded. The drilled list is scoped by the METRIC's own + // resolved filter, which is the registration's promise that the number and + // the records behind it agree, and a metric has no row for `mode` to open as + // a record. const drillDrawer = drillEnabled ? ( = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type IsAny = 0 extends 1 & T ? true : false; + +type MetricDrillProp = NonNullable['drillDown']>; + +describe('object-metric: drillDown.filter and drillDown.mode are refused on the prop type (objectui#9002)', () => { + it('is pinned at compile time', () => { + type _PropIsReal = Assert, false>>; + // The published component takes the per-block shape, not the shared one. + type _PropIsTheMetricShape = Assert>; + + const base = { objectName: 'deal', label: 'Revenue' } as const; + + // The refusal, one key per line so each directive owns exactly one error. + // @ts-expect-error `drillDown.filter` is refused on `object-metric` (objectui#9002). + const withFilter = ; + // @ts-expect-error `drillDown.mode` is refused on `object-metric` (objectui#9002). + const withMode = ; + + // Accept control: the members this block reads still compile. + const live = ( + + ); + + // Control: the shared type keeps both keys for chart / pivot / data table. + const shared: DrillDownConfig = { filter: { stage: 'won' }, mode: 'record' }; + + expect([withFilter, withMode, live, shared]).toHaveLength(4); + }); +}); diff --git a/packages/plugin-dashboard/src/__tests__/objectMetricDrillDownMembers-8071.test.tsx b/packages/plugin-dashboard/src/__tests__/objectMetricDrillDownMembers-8071.test.tsx index 7abb06c799..fe42954b72 100644 --- a/packages/plugin-dashboard/src/__tests__/objectMetricDrillDownMembers-8071.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/objectMetricDrillDownMembers-8071.test.tsx @@ -35,6 +35,13 @@ * would make this repo stricter than the protocol it renders, which is the * wrong direction to fix anything in. * + * ⚠️ AMENDED (objectui#9002): for two members that direction was ruled on. + * Ruling B on objectui#9002 refuses `filter` and `mode` on this block's TYPE + * (`ObjectMetricDrillDownConfig`), because a metric can never honour either. + * That refusal is compile-time only and is pinned at that level in + * `ObjectMetricWidget.drillDownRefusal-9002.test.tsx`; this file still reads + * the renderer and nothing else. + * * ## The members `ObjectMetricWidget` actually reads * * - **`enabled`** — through `isDrillEnabled`, which is `config.enabled !== @@ -83,7 +90,8 @@ * passes unchanged against the shared drawer. Their effects are pinned in * `ObjectMetricWidget.drillRoutedToSharedDrawer-8970.test.tsx`. `filter` and * `mode` are still unread here (the shared drawer honours neither), and are - * still deliberately not asserted. + * still deliberately not asserted here; objectui#9002 refused them on the + * type instead (see the amendment above). * * ## Nothing pre-existing covered this key * diff --git a/packages/plugin-dashboard/src/index.tsx b/packages/plugin-dashboard/src/index.tsx index cf3896527c..ddd36e3a78 100644 --- a/packages/plugin-dashboard/src/index.tsx +++ b/packages/plugin-dashboard/src/index.tsx @@ -248,7 +248,7 @@ ComponentRegistry.register( { name: 'fallbackValue', type: 'string', description: 'Value shown when no data source resolves. For static/demo tiles; a bound metric should not need it.' }, { name: 'trend', type: 'object', description: 'Static trend badge: `{ value, label, direction }`. Use `compareTo` instead when the trend should be computed from data.' }, { name: 'compareTo', type: 'object', description: 'Period-over-period comparison, `{ kind: "previousPeriod" }` or `{ kind: "previousYear" }` — the computed alternative to a static `trend`.' }, - { name: 'drillDown', type: 'object', description: 'Click-through config that opens the records behind the number.' }, + { name: 'drillDown', type: 'object', description: 'Click-through config that opens the records behind the number. `drillDown.filter` and `drillDown.mode` do not apply to a metric, which has no clicked point to filter by and no row to open, so the list is always scoped by this block’s own `filter`; a drill `filter` belongs on `object-chart` or `object-pivot`, and `mode` on `object-data-table`.' }, ], defaultProps: { label: 'Metric', diff --git a/packages/types/src/__tests__/object-metric-drill-down-config-9002.test.ts b/packages/types/src/__tests__/object-metric-drill-down-config-9002.test.ts new file mode 100644 index 0000000000..14dc140438 --- /dev/null +++ b/packages/types/src/__tests__/object-metric-drill-down-config-9002.test.ts @@ -0,0 +1,76 @@ +/** + * 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. + */ + +/** + * `ObjectMetricDrillDownConfig` refuses `filter` and `mode` by name, and the + * shared `DrillDownConfig` keeps both (objectui#9002, ruling B: a per-block + * refusal, not a retirement). + * + * A metric is one aggregated number: it has no click event for a drill + * `filter` to interpolate `${event.*}` against, and no row for `mode` to open + * as a record. Chart and pivot DO read `filter` (through `computeDrillFilter`) + * and the data table DOES read `mode`, so the shared type is the control here: + * narrowing it would take a working member away from three other blocks. + * + * ## These assertions are compile-time only + * + * Same mechanism as `drill-down-config-declared-keys.test.ts`: the + * `@ts-expect-error` lines and the `Assert` aliases are judged by + * `tsc -p tsconfig.test.json`, the third leg of this package's `type-check` + * script. Vitest strips types, so the `expect` at the bottom only keeps this a + * collected suite. A `@ts-expect-error` line whose error disappears is itself + * an error (TS2578), which is what makes each refusal below able to fail. + * + * The three refusals are one fresh literal per key, and one NON-fresh value: + * an omitted key is an excess-property error only on a fresh literal, so the + * widened `DrillDownConfig` handed across is the leg that tells a `?: never` + * tombstone apart from a plain `Omit`. + */ + +import { describe, it, expect } from 'vitest'; +// Through the module the published `@object-ui/types/data-display` subpath +// serves, which is where the per-block shape is declared. +import type { DrillDownConfig, ObjectMetricDrillDownConfig } from '../data-display'; + +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('ObjectMetricDrillDownConfig refuses drillDown.filter and drillDown.mode (objectui#9002)', () => { + it('is pinned at compile time', () => { + // Keeps every pin below from passing vacuously: an `any` shape would accept + // the refused keys and make each `@ts-expect-error` unused for the wrong reason. + type _MetricShapeIsReal = Assert, false>>; + + // The refusal, one key per line so each directive owns exactly one error. + // @ts-expect-error `filter` is refused on `object-metric` (objectui#9002). + const withFilter: ObjectMetricDrillDownConfig = { enabled: true, filter: { stage: 'won' } }; + // @ts-expect-error `mode` is refused on `object-metric` (objectui#9002). + const withMode: ObjectMetricDrillDownConfig = { enabled: true, mode: 'record' }; + const shared: DrillDownConfig = { enabled: true, filter: { stage: 'won' } }; + // @ts-expect-error a shared config that may carry `filter` is not a metric drill config. + const handedAcross: ObjectMetricDrillDownConfig = shared; + + // Accept control: every member the block reads still compiles. + const live: ObjectMetricDrillDownConfig = { + enabled: true, + target: 'navigate', + columns: ['name', 'amount'], + maxRows: 25, + title: 'Won deals', + report: { name: 'won_deals_by_owner' }, + }; + // Still a `DrillDownConfig`, so `isDrillEnabled` / `resolveDrillTitle` take it. + const liveAsShared: DrillDownConfig = live; + + // Control: the shared type keeps both keys for the blocks that read them. + const chartOrTable: DrillDownConfig = { filter: { stage: 'won' }, mode: 'record' }; + + expect([withFilter, withMode, handedAcross, liveAsShared, chartOrTable]).toHaveLength(5); + }); +}); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 3fb83f9142..a8934d1c74 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -2338,6 +2338,52 @@ export interface DrillDownConfig { maxRows?: number; } +/** + * The `object-metric` block's drill-down shape: {@link DrillDownConfig} with + * `filter` and `mode` REFUSED BY NAME (objectui#9002, ruling B, a per-block + * refusal). The shared type keeps both members for the blocks that read them; + * only this block's declaration takes them away. + * + * A metric is one aggregated number, so neither member has anything to act on: + * + * - `filter` is interpolated against a click event (`${event.*}`) by + * `computeDrillFilter`, and a metric tile has no click context: no row, + * column, category or series. The drilled list is scoped by the metric's + * OWN `filter` instead, which is the block's registered promise that the + * number and the records behind it always agree. An override would break + * that promise. + * - `mode` decides drill-to-record versus drill-through for a clicked ROW. A + * metric has no row. It always drills through to its records. + * + * Refused as `?: never` tombstones rather than omitted: an omitted key on an + * object literal is an excess-property error only while the literal is fresh, + * and a tombstone also carries the reason and the block that does read the key + * into the error an author (or an AI) sees at the declaration. + * + * ⚠️ This is a TypeScript declaration, so it refuses the keys where an author + * types against it (`ObjectMetricWidget`'s `drillDown` prop). A stored JSON + * config reaches the block without passing through this type. + */ +export interface ObjectMetricDrillDownConfig extends DrillDownConfig { + /** + * REFUSED BY NAME on `object-metric` (objectui#9002). A metric has no click + * event for `${event.*}` to resolve against, and its drilled list is scoped + * by the metric's own `filter`. Drill filters apply on `object-chart` and + * `object-pivot`, which hand this member to `computeDrillFilter`. + * + * @deprecated Not a member `object-metric` reads. Scope the metric with its own `filter`. + */ + filter?: never; + /** + * REFUSED BY NAME on `object-metric` (objectui#9002). A metric has no row to + * open as a record, so it always drills through to the records behind the + * number. `mode` applies on `object-data-table`, whose row click reads it. + * + * @deprecated Not a member `object-metric` reads. A metric always lists its records. + */ + mode?: never; +} + /** * Pivot table (cross-tabulation) component * From ecff106612b6b31ccb5063fa4b6258a9dd929af3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:14:49 +0000 Subject: [PATCH 2/2] fix(types): export ObjectMetricDrillDownConfig from the root barrel The widget now imports it from `@object-ui/types` like every other type in the file, so the emitted `ObjectMetricWidget.d.ts` names the root entry rather than the `./data-display` subpath (which a node10-resolution consumer cannot resolve: the package has no `typesVersions`). The changeset says the type is published on both entries. PM patch round on objectui#9002. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- .changeset/9002-metric-drilldown-refusal.md | 5 +++-- packages/plugin-dashboard/src/ObjectMetricWidget.tsx | 3 +-- packages/types/src/index.ts | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.changeset/9002-metric-drilldown-refusal.md b/.changeset/9002-metric-drilldown-refusal.md index 066bc5bf7d..2cab747bd5 100644 --- a/.changeset/9002-metric-drilldown-refusal.md +++ b/.changeset/9002-metric-drilldown-refusal.md @@ -16,8 +16,9 @@ which is the registration's promise that the number and the records behind it ag keys still type-checked on the widget's `drillDown` prop and then did nothing, with no diagnostic. -- `@object-ui/types` adds `ObjectMetricDrillDownConfig`, published on the - `@object-ui/types/data-display` subpath: `DrillDownConfig` with `filter?: never` and +- `@object-ui/types` adds `ObjectMetricDrillDownConfig`, published on the root entry + `@object-ui/types` and on the `@object-ui/types/data-display` subpath: `DrillDownConfig` + with `filter?: never` and `mode?: never` tombstones whose docblocks name the blocks that do read each key (`object-chart` and `object-pivot` for `filter`, `object-data-table` for `mode`). The shared `DrillDownConfig` is unchanged and keeps both keys for those blocks. diff --git a/packages/plugin-dashboard/src/ObjectMetricWidget.tsx b/packages/plugin-dashboard/src/ObjectMetricWidget.tsx index 3b41b51f00..eda3be85f7 100644 --- a/packages/plugin-dashboard/src/ObjectMetricWidget.tsx +++ b/packages/plugin-dashboard/src/ObjectMetricWidget.tsx @@ -9,8 +9,7 @@ import React, { useState, useEffect, useContext, useCallback, useMemo } from 'react'; import { SchemaRendererContext, useFilterScope, useDataInvalidation } from '@object-ui/react'; import { isDrillEnabled, resolveDrillTitle, isStructuredGroupBy, objectAggregateSpecQuery } from '@object-ui/core'; -import type { I18nLabel, ObjectChartSchema } from '@object-ui/types'; -import type { ObjectMetricDrillDownConfig } from '@object-ui/types/data-display'; +import type { I18nLabel, ObjectChartSchema, ObjectMetricDrillDownConfig } from '@object-ui/types'; import { useLocalization, useDisplayLocale, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b002c742ba..f719db2174 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -251,6 +251,7 @@ export type { PivotAggregation, PivotTableSchema, DrillDownConfig, + ObjectMetricDrillDownConfig, TimelineEvent, TimelineScale, TimelineSchema,