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
31 changes: 31 additions & 0 deletions .changeset/9002-metric-drilldown-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@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 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.
- `@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.
27 changes: 17 additions & 10 deletions packages/plugin-dashboard/src/ObjectMetricWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +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 { DrillDownConfig, I18nLabel, ObjectChartSchema } from '@object-ui/types';
import type { I18nLabel, ObjectChartSchema, ObjectMetricDrillDownConfig } from '@object-ui/types';
import {
useLocalization,
useDisplayLocale,
Expand Down Expand Up @@ -180,11 +180,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
Expand Down Expand Up @@ -558,11 +564,12 @@ export const ObjectMetricWidget: React.FC<ObjectMetricWidgetProps> = ({
// 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 ? (
<DrillDownDrawer
open
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* 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-metric` — `drillDown.filter` and `drillDown.mode` are refused on the
* prop type an author writes against (objectui#9002, ruling B).
*
* `ObjectMetricDrillDownConfig` in `@object-ui/types` declares the per-block
* shape, and its own pin lives beside it. This file pins the other half: that
* the component this package PUBLISHES takes that shape and not the shared
* `DrillDownConfig`. The narrowing is worth nothing if the widget's prop still
* says `DrillDownConfig`, and that is a one-word regression in this package that
* 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` (the config's empty `paths`). Vitest strips types; the `expect` only
* keeps this a collected suite. What is deliberately NOT here: any runtime
* assertion that the two keys are dead. The behaviour pins for this block
* (`objectMetricDrillDownMembers-8071.test.tsx`) keep that restraint, and
* nothing at runtime changed.
*/

import { describe, it, expect } from 'vitest';
import type React from 'react';
import type { DrillDownConfig } from '@object-ui/types';
import type { ObjectMetricDrillDownConfig } from '@object-ui/types/data-display';
import { ObjectMetricWidget } from '../index';

type Assert<T extends true> = T;
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
type IsAny<T> = 0 extends 1 & T ? true : false;

type MetricDrillProp = NonNullable<React.ComponentProps<typeof ObjectMetricWidget>['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<Equal<IsAny<MetricDrillProp>, false>>;
// The published component takes the per-block shape, not the shared one.
type _PropIsTheMetricShape = Assert<Equal<MetricDrillProp, ObjectMetricDrillDownConfig>>;

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 = <ObjectMetricWidget {...base} drillDown={{ enabled: true, filter: { stage: 'won' } }} />;
// @ts-expect-error `drillDown.mode` is refused on `object-metric` (objectui#9002).
const withMode = <ObjectMetricWidget {...base} drillDown={{ enabled: true, mode: 'record' }} />;

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

// 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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 !==
Expand Down Expand Up @@ -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
*
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-dashboard/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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 extends true> = T;
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
type IsAny<T> = 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<Equal<IsAny<ObjectMetricDrillDownConfig>, 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);
});
});
46 changes: 46 additions & 0 deletions packages/types/src/data-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ export type {
PivotAggregation,
PivotTableSchema,
DrillDownConfig,
ObjectMetricDrillDownConfig,
TimelineEvent,
TimelineScale,
TimelineSchema,
Expand Down
Loading