From 2fc2a28cb218b075a9c0ad34c353dd03c459bc45 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 14:17:29 +0000 Subject: [PATCH 1/2] wip(plugin-dashboard): KPI tile click shell for the records behind the number A dataset-bound metric tile opens the list its number counts through the shared drill drawer, keyboard included, and offers no click whenever the list cannot be proven to match the number. The tile's number also carries the dataset's own scope and the measure's own filter, which only the server knows, so the scope is read from a server sidecar that is not on the wire yet: until it lands, every tile keeps today's no-click markup. Co-Authored-By: Claude --- .../plugin-dashboard/src/DatasetWidget.tsx | 38 ++++++++-- .../src/__tests__/DatasetWidget.test.tsx | 22 ++++++ .../src/__tests__/metricDrill.test.ts | 59 +++++++++++++++ .../plugin-dashboard/src/metricDrill.ts | 72 +++++++++++++++++++ 4 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 ui/packages/plugin-dashboard/src/__tests__/metricDrill.test.ts create mode 100644 ui/packages/plugin-dashboard/src/metricDrill.ts diff --git a/ui/packages/plugin-dashboard/src/DatasetWidget.tsx b/ui/packages/plugin-dashboard/src/DatasetWidget.tsx index 29f3b016b6c..6b6fcf158c8 100644 --- a/ui/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/ui/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -33,7 +33,7 @@ * objectui bumps its `@objectstack/spec` dependency (cross-repo spec skew). */ -import { useEffect, useMemo, useState, type ReactElement } from 'react'; +import { useEffect, useMemo, useState, type ReactElement, type KeyboardEvent as ReactKeyboardEvent } from 'react'; // `useDatasetDimensionMeta` is the analytics label net's SHARED React glue // (objectui#4389): the locale-free metadata read this widget and the dataset // report block both take, lifted out of the two longhand copies PR #4388 left @@ -105,6 +105,7 @@ import { metricTileValueTitle, } from './metricTile'; import { DrillDownDrawer } from './DrillDownDrawer'; +import { metricDrillFilter, readMeasureDrillScope } from './metricDrill'; import { specDefaultWidgetType } from './widgetDispatch'; import { DASHBOARD_WIDGET_MULTI_MEASURE_TYPES } from '@objectstack/spec/ui'; @@ -898,7 +899,7 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { // `signature` is the query an `ok` answer was read for, and `refreshing` marks // a re-read of that same query in flight (objectui#10815, see the effect). - const [state, setState] = useState<{ status: 'idle' | 'loading' | 'ok' | 'error'; rows: Row[]; fields?: DatasetResultField[]; object?: string; dimensionFields?: Record; drillRawRows?: Array>; drillRanges?: Array>; totals?: DatasetResultTotals[]; error?: string; forbidden?: boolean; signature?: string; refreshing?: boolean }>({ status: 'idle', rows: [] }); + const [state, setState] = useState<{ status: 'idle' | 'loading' | 'ok' | 'error'; rows: Row[]; fields?: DatasetResultField[]; object?: string; dimensionFields?: Record; drillRawRows?: Array>; drillRanges?: Array>; totals?: DatasetResultTotals[]; measureDrillScope?: Record | null; error?: string; forbidden?: boolean; signature?: string; refreshing?: boolean }>({ status: 'idle', rows: [] }); // Drill-through (ADR-0021 D2): the clicked bucket's record-list filter + title. const [drill, setDrill] = useState<{ filter: Record; title: string } | null>(null); // ── The flat table's client-side sort (objectui#5827) ──────────────────── @@ -967,7 +968,7 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { }) .then((res) => { if (cancelled) return; - setState({ status: 'ok', rows: Array.isArray(res?.rows) ? res.rows : [], fields: Array.isArray(res?.fields) ? res.fields : [], object: res?.object, dimensionFields: res?.dimensionFields, drillRawRows: Array.isArray(res?.drillRawRows) ? res.drillRawRows : undefined, drillRanges: Array.isArray(res?.drillRanges) ? res.drillRanges : undefined, totals: Array.isArray(res?.totals) ? res.totals : undefined, signature }); + setState({ status: 'ok', rows: Array.isArray(res?.rows) ? res.rows : [], fields: Array.isArray(res?.fields) ? res.fields : [], object: res?.object, dimensionFields: res?.dimensionFields, drillRawRows: Array.isArray(res?.drillRawRows) ? res.drillRawRows : undefined, drillRanges: Array.isArray(res?.drillRanges) ? res.drillRanges : undefined, totals: Array.isArray(res?.totals) ? res.totals : undefined, measureDrillScope: readMeasureDrillScope(res, values[0]), signature }); if (typeof res?.object === 'string' && res.object) setDatasetObject(res.object); }) .catch((e) => { @@ -1392,12 +1393,41 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { // the title's tooltip (objectstack#27430). const caption = heading?.description || measureLabel; const shownValue = formatMeasure(value, f?.format, f?.currency, f?.percentScale, displayLocale); + // ── The records behind the number (ADR-0021 D2) ──────────────────────── + // The tile opens the list its number counts, through the same drawer the + // chart and table drills open. `null` means the list could not be proven + // to match the number (see `./metricDrill`), and the tile then offers no + // click at all: its markup stays exactly the no-click markup. + const tileDrillFilter = metricDrillFilter({ + object: drillObject, + measureScope: state.measureDrillScope, + runtimeFilter, + ranges: drillRanges?.[0], + }); + const openTileDrill = tileDrillFilter + ? () => setDrill({ filter: tileDrillFilter, title: String(widget?.title ?? '') }) + : undefined; + const tileDrillProps = openTileDrill + ? { + role: 'button' as const, + tabIndex: 0, + 'data-testid': 'dataset-metric-drill', + onClick: openTileDrill, + onKeyDown: (e: ReactKeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openTileDrill(); } + }, + } + : {}; return ( // Positioned only while a re-read is in flight, for the refresh bar: the // idle tile's markup is pinned byte-for-byte (`DatasetWidget.colorVariant`, // `.subCaption`, `.unrenderedMeasures-8894` tests), and it moves by nothing. -
+
{refreshBar} + {drillDrawer} {/* objectui#12623 — with a heading, the title owns the first line, the value never wraps, and the caption (the description, else the measure label) is a line under it (the shared rule in diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx index 0cb2f5c3884..e25d3b807d5 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx @@ -1061,3 +1061,25 @@ describe('DatasetWidget — dimension metadata probe (objectui#5280)', () => { expect(screen.queryByText('in_progress')).not.toBeInTheDocument(); }); }); + +// ── The KPI tile's click (ADR-0021 D2) ─────────────────────────────────────── +// A tile drills only when the answer states its measure's own scope; an answer +// that names the object and nothing more leaves the tile with no click. +describe('DatasetWidget metric tile drill', () => { + it('offers no click when the answer does not state the measure scope', async () => { + const src = makeSource(async () => ({ + rows: [{ total_amount: 814400 }], + fields: [{ name: 'total_amount', type: 'number', label: 'Total Amount', aggregate: 'sum' }], + object: 'crm_opportunity', + })); + render( + , + ); + await screen.findByText('814400'); + expect(screen.queryByTestId('dataset-metric-drill')).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/ui/packages/plugin-dashboard/src/__tests__/metricDrill.test.ts b/ui/packages/plugin-dashboard/src/__tests__/metricDrill.test.ts new file mode 100644 index 00000000000..20435fce7e0 --- /dev/null +++ b/ui/packages/plugin-dashboard/src/__tests__/metricDrill.test.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { metricDrillFilter, readMeasureDrillScope } from '../metricDrill'; + +describe('metricDrillFilter — the list a KPI tile opens (ADR-0021 D2)', () => { + it('offers no click when the answer names no object', () => { + expect(metricDrillFilter({ object: undefined, measureScope: {}, runtimeFilter: undefined })).toBeNull(); + }); + + it('offers no click when the answer did not state the measure scope', () => { + expect(metricDrillFilter({ object: 'crm_opportunity', measureScope: undefined, runtimeFilter: { stage: 'closed_won' } })).toBeNull(); + }); + + it('offers no click when the server says the scope is not a list filter', () => { + expect(metricDrillFilter({ object: 'crm_opportunity', measureScope: null, runtimeFilter: undefined })).toBeNull(); + }); + + it('opens the widget filter alone when the measure has no scope of its own', () => { + expect( + metricDrillFilter({ object: 'crm_opportunity', measureScope: {}, runtimeFilter: { stage: { $nin: ['closed_won', 'closed_lost'] } } }), + ).toEqual({ stage: { $nin: ['closed_won', 'closed_lost'] } }); + }); + + it('opens every record when neither side filters', () => { + expect(metricDrillFilter({ object: 'crm_opportunity', measureScope: {}, runtimeFilter: undefined })).toEqual({}); + }); + + it('keeps the measure scope AND the widget filter, never only one of them', () => { + const filter = metricDrillFilter({ object: 'invoice', measureScope: { status: 'paid' }, runtimeFilter: { region: 'emea' } }); + expect(filter).toEqual({ $and: [{ region: 'emea' }, { status: 'paid' }] }); + }); + + it('keeps the measure scope when the widget declares no filter', () => { + expect(metricDrillFilter({ object: 'invoice', measureScope: { status: 'paid' }, runtimeFilter: undefined })).toEqual({ status: 'paid' }); + }); + + it('bounds the list by the answer date window', () => { + const filter = metricDrillFilter({ + object: 'crm_opportunity', + measureScope: {}, + runtimeFilter: { stage: 'closed_won' }, + ranges: { close_date: { field: 'close_date', gte: '2026-10-01', lt: '2027-01-01' } }, + }); + expect(JSON.stringify(filter)).toContain('2026-10-01'); + expect(JSON.stringify(filter)).toContain('closed_won'); + }); + + it('offers no click when the widget filter cannot become a list filter', () => { + // A scalar on a list operator is refused by the shared drill sink. + expect(metricDrillFilter({ object: 'invoice', measureScope: { status: 'paid' }, runtimeFilter: [['stage', 'in', 'won']] })).toBeNull(); + }); +}); + +describe('readMeasureDrillScope — before the server sidecar exists', () => { + it('reads every answer as "did not say"', () => { + expect(readMeasureDrillScope({ rows: [{ total_amount: 1 }], object: 'crm_opportunity' }, 'total_amount')).toBeUndefined(); + }); +}); diff --git a/ui/packages/plugin-dashboard/src/metricDrill.ts b/ui/packages/plugin-dashboard/src/metricDrill.ts new file mode 100644 index 00000000000..744d621750e --- /dev/null +++ b/ui/packages/plugin-dashboard/src/metricDrill.ts @@ -0,0 +1,72 @@ +/** + * 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. + */ + +/** + * The record list behind a dataset-bound KPI tile (ADR-0021 D2). + * + * A tile's number is the dataset's own scope AND the measure's own filter AND + * the widget's filter (the dashboard's filters are already merged into it). + * The first two live in the dataset definition, which this package never + * reads: only the server knows them, so the tile drills only when the answer + * hands over that measure's scope as a list filter. Without it, a tile whose + * measure carries a filter would open more records than its number counts. + * + * Invariant: a tile offers a click only when its list provably matches its + * number. Every "cannot tell" answer below is `null`, never a looser filter. + */ + +import { buildDatasetDrillFilter, composeDrillFilter, FilterOperatorError } from '@object-ui/core'; +import type { DatasetDrillRange } from '@object-ui/core'; + +export interface MetricDrillInput { + /** The dataset's base object, from the answer (`object`). */ + object: string | undefined; + /** + * The tile measure's own scope as a list filter: the dataset's filter AND + * the measure's filter, already lowered by the server. + * - `undefined`: the answer did not say (no click). + * - `null`: the server says the scope is not a list filter (no click). + * - an object (possibly empty): that scope. + */ + measureScope: Record | null | undefined; + /** The widget's resolved filter (placeholders already resolved). */ + runtimeFilter: unknown; + /** The answer's date windows for the tile's single row (`drillRanges[0]`). */ + ranges?: Record; +} + +/** + * The list filter a tile's click opens, or `null` when the tile must offer no + * click. Computed before rendering, so a refused filter shows no affordance + * instead of a click that does nothing. + */ +export function metricDrillFilter(input: MetricDrillInput): Record | null { + const { object, measureScope, runtimeFilter, ranges } = input; + if (!object || measureScope == null) return null; + try { + const hasScope = Object.keys(measureScope).length > 0; + const scoped = hasScope ? composeDrillFilter(runtimeFilter, measureScope) : runtimeFilter; + return buildDatasetDrillFilter(undefined, [], {}, scoped, ranges); + } catch (error) { + if (error instanceof FilterOperatorError) return null; + throw error; + } +} + +/** + * Read the tile measure's scope off a dataset answer. + * + * The server sidecar this reads is not on the wire yet; until it is, every + * answer reads as "did not say" and no tile offers a click. + */ +export function readMeasureDrillScope( + _answer: unknown, + _measure: string, +): Record | null | undefined { + return undefined; +} From 8f485138fa37c476f6f4240f4a0429bebce150aa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 19:00:05 +0000 Subject: [PATCH 2/2] feat(plugin-dashboard): a dataset KPI tile opens the records behind its number The metric tile reads its measure's drillScopes entry off the dataset answer and opens the shared drill drawer with the widget filter AND the date window AND that scope, so the list matches the number. A measure with no entry offers no click. The drawer is the tile's sibling, so a click or a key typed inside it never reaches the tile's handlers. Co-Authored-By: Claude --- .changeset/30080-dashboard-tile-drill.md | 10 +++ .../plugin-dashboard/src/DatasetWidget.tsx | 23 ++++--- .../src/__tests__/DatasetWidget.test.tsx | 61 +++++++++++++++++-- .../src/__tests__/metricDrill.test.ts | 21 ++++--- .../plugin-dashboard/src/metricDrill.ts | 38 ++++++------ 5 files changed, 111 insertions(+), 42 deletions(-) create mode 100644 .changeset/30080-dashboard-tile-drill.md diff --git a/.changeset/30080-dashboard-tile-drill.md b/.changeset/30080-dashboard-tile-drill.md new file mode 100644 index 00000000000..69688d9046e --- /dev/null +++ b/.changeset/30080-dashboard-tile-drill.md @@ -0,0 +1,10 @@ +--- +'@object-ui/plugin-dashboard': patch +--- + +feat(plugin-dashboard): a dataset-bound KPI tile opens the records behind its number + +Clause-②: no +Metadata-protocol: none + +A dashboard metric tile bound to a dataset now opens the same drill drawer its charts and tables already open, by click or by Enter/Space. The list is the widget's filter (the dashboard's filters included) AND the answer's date window AND the measure's `drillScopes` entry, which carries the dataset's own filter and the measure's own filter, so the records listed are the records the number counts. A tile whose measure has no `drillScopes` entry (a derived measure, or a scope that names a relationship path) offers no click, as does a tile whose filter the list cannot express; its markup is unchanged. Nothing to author: the click follows from the dataset answer. diff --git a/ui/packages/plugin-dashboard/src/DatasetWidget.tsx b/ui/packages/plugin-dashboard/src/DatasetWidget.tsx index 6b6fcf158c8..0fee836dee7 100644 --- a/ui/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/ui/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -114,7 +114,7 @@ type Row = Record; // `@objectstack/spec/api`'s `DatasetTotals`, which since 17.5.0 is the REQUEST side // (`{ groupings }`, what to compute) — objectui#11073. interface DatasetResultTotals { dimensions: string[]; rows: Row[] } -interface DatasetResult { rows: Row[]; fields?: DatasetResultField[]; object?: string; dimensionFields?: Record; drillRawRows?: Row[]; drillRanges?: Array>; totals?: DatasetResultTotals[] } +interface DatasetResult { rows: Row[]; fields?: DatasetResultField[]; object?: string; dimensionFields?: Record; drillRawRows?: Row[]; drillRanges?: Array>; totals?: DatasetResultTotals[]; drillScopes?: Record } interface DatasetCapableSource { queryDataset?: (dataset: string, selection: unknown) => Promise; } @@ -899,7 +899,7 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { // `signature` is the query an `ok` answer was read for, and `refreshing` marks // a re-read of that same query in flight (objectui#10815, see the effect). - const [state, setState] = useState<{ status: 'idle' | 'loading' | 'ok' | 'error'; rows: Row[]; fields?: DatasetResultField[]; object?: string; dimensionFields?: Record; drillRawRows?: Array>; drillRanges?: Array>; totals?: DatasetResultTotals[]; measureDrillScope?: Record | null; error?: string; forbidden?: boolean; signature?: string; refreshing?: boolean }>({ status: 'idle', rows: [] }); + const [state, setState] = useState<{ status: 'idle' | 'loading' | 'ok' | 'error'; rows: Row[]; fields?: DatasetResultField[]; object?: string; dimensionFields?: Record; drillRawRows?: Array>; drillRanges?: Array>; totals?: DatasetResultTotals[]; drillScopes?: unknown; error?: string; forbidden?: boolean; signature?: string; refreshing?: boolean }>({ status: 'idle', rows: [] }); // Drill-through (ADR-0021 D2): the clicked bucket's record-list filter + title. const [drill, setDrill] = useState<{ filter: Record; title: string } | null>(null); // ── The flat table's client-side sort (objectui#5827) ──────────────────── @@ -968,7 +968,7 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { }) .then((res) => { if (cancelled) return; - setState({ status: 'ok', rows: Array.isArray(res?.rows) ? res.rows : [], fields: Array.isArray(res?.fields) ? res.fields : [], object: res?.object, dimensionFields: res?.dimensionFields, drillRawRows: Array.isArray(res?.drillRawRows) ? res.drillRawRows : undefined, drillRanges: Array.isArray(res?.drillRanges) ? res.drillRanges : undefined, totals: Array.isArray(res?.totals) ? res.totals : undefined, measureDrillScope: readMeasureDrillScope(res, values[0]), signature }); + setState({ status: 'ok', rows: Array.isArray(res?.rows) ? res.rows : [], fields: Array.isArray(res?.fields) ? res.fields : [], object: res?.object, dimensionFields: res?.dimensionFields, drillRawRows: Array.isArray(res?.drillRawRows) ? res.drillRawRows : undefined, drillRanges: Array.isArray(res?.drillRanges) ? res.drillRanges : undefined, totals: Array.isArray(res?.totals) ? res.totals : undefined, drillScopes: res?.drillScopes, signature }); if (typeof res?.object === 'string' && res.object) setDatasetObject(res.object); }) .catch((e) => { @@ -1400,12 +1400,12 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { // click at all: its markup stays exactly the no-click markup. const tileDrillFilter = metricDrillFilter({ object: drillObject, - measureScope: state.measureDrillScope, + measureScope: readMeasureDrillScope(state, tileMeasure), runtimeFilter, ranges: drillRanges?.[0], }); const openTileDrill = tileDrillFilter - ? () => setDrill({ filter: tileDrillFilter, title: String(widget?.title ?? '') }) + ? () => setDrill({ filter: tileDrillFilter, title: heading?.title || measureLabel }) : undefined; const tileDrillProps = openTileDrill ? { @@ -1418,16 +1418,19 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { }, } : {}; + // The drawer is a SIBLING of the tile, never its child: React bubbles a + // portal's events through the component tree, so a click or a space typed + // inside the drawer would otherwise reach the tile's own handlers. return ( - // Positioned only while a re-read is in flight, for the refresh bar: the - // idle tile's markup is pinned byte-for-byte (`DatasetWidget.colorVariant`, - // `.subCaption`, `.unrenderedMeasures-8894` tests), and it moves by nothing. + <> + {/* Positioned only while a re-read is in flight, for the refresh bar: the + idle tile's markup is pinned byte-for-byte (`DatasetWidget.colorVariant`, + `.subCaption`, `.unrenderedMeasures-8894` tests), and it moves by nothing. */}
{refreshBar} - {drillDrawer} {/* objectui#12623 — with a heading, the title owns the first line, the value never wraps, and the caption (the description, else the measure label) is a line under it (the shared rule in @@ -1458,6 +1461,8 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { )} {!heading && {measureLabel}}
+ {drillDrawer} + ); } diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx index e25d3b807d5..1f89f0175d2 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx @@ -1063,23 +1063,72 @@ describe('DatasetWidget — dimension metadata probe (objectui#5280)', () => { }); // ── The KPI tile's click (ADR-0021 D2) ─────────────────────────────────────── -// A tile drills only when the answer states its measure's own scope; an answer -// that names the object and nothing more leaves the tile with no click. +// A tile drills only when the answer carries a `drillScopes` entry for its +// measure, and the list it opens is the widget filter AND that entry. describe('DatasetWidget metric tile drill', () => { - it('offers no click when the answer does not state the measure scope', async () => { - const src = makeSource(async () => ({ + const OPEN = { stage: { $nin: ['closed_won', 'closed_lost'] } }; + const tileSource = (answer: Record) => ({ + queryDataset: vi.fn(async () => ({ rows: [{ total_amount: 814400 }], fields: [{ name: 'total_amount', type: 'number', label: 'Total Amount', aggregate: 'sum' }], object: 'crm_opportunity', - })); + ...answer, + })), + // Parameters declared: the assertions read `find.mock.calls[0][1]`. + find: vi.fn(async (_object: string, _params: unknown) => ({ data: [] })), + getObjectSchema: vi.fn(async () => ({ fields: {} })), + }); + const renderTile = (src: ReturnType, filter: Record | undefined = OPEN) => render( , ); + + it('offers no click when the answer carries no drillScopes', async () => { + renderTile(tileSource({})); await screen.findByText('814400'); expect(screen.queryByTestId('dataset-metric-drill')).not.toBeInTheDocument(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); + + it('offers no click when the tile measure has no entry (a derived measure)', async () => { + renderTile(tileSource({ drillScopes: { opp_count: {} } })); + await screen.findByText('814400'); + expect(screen.queryByTestId('dataset-metric-drill')).not.toBeInTheDocument(); + }); + + it('opens the widget filter AND the measure scope on click', async () => { + const src = tileSource({ drillScopes: { total_amount: { amount: { $null: false } } } }); + renderTile(src); + fireEvent.click(await screen.findByTestId('dataset-metric-drill')); + await waitFor(() => expect(src.find).toHaveBeenCalled()); + expect(src.find.mock.calls[0][0]).toBe('crm_opportunity'); + expect((src.find.mock.calls[0][1] as any).$filter).toEqual({ + $and: [{ stage: { $nin: ['closed_won', 'closed_lost'] } }, { amount: { $null: false } }], + }); + }); + + it('opens the widget filter alone when the entry is empty', async () => { + const src = tileSource({ drillScopes: { total_amount: {} } }); + renderTile(src); + fireEvent.keyDown(await screen.findByTestId('dataset-metric-drill'), { key: 'Enter' }); + await waitFor(() => expect(src.find).toHaveBeenCalled()); + expect((src.find.mock.calls[0][1] as any).$filter).toEqual(OPEN); + }); + + it('bounds the list by the answer date window', async () => { + const src = tileSource({ + drillScopes: { total_amount: {} }, + drillRanges: [{ close_date: { field: 'close_date', gte: '2026-10-01', lt: '2027-01-01' } }], + }); + renderTile(src, { stage: 'closed_won' }); + fireEvent.click(await screen.findByTestId('dataset-metric-drill')); + await waitFor(() => expect(src.find).toHaveBeenCalled()); + const sent = JSON.stringify((src.find.mock.calls[0][1] as any).$filter); + expect(sent).toContain('closed_won'); + expect(sent).toContain('2026-10-01'); + expect(sent).toContain('2027-01-01'); + }); }); diff --git a/ui/packages/plugin-dashboard/src/__tests__/metricDrill.test.ts b/ui/packages/plugin-dashboard/src/__tests__/metricDrill.test.ts index 20435fce7e0..37e4fa736e3 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/metricDrill.test.ts +++ b/ui/packages/plugin-dashboard/src/__tests__/metricDrill.test.ts @@ -12,10 +12,6 @@ describe('metricDrillFilter — the list a KPI tile opens (ADR-0021 D2)', () => expect(metricDrillFilter({ object: 'crm_opportunity', measureScope: undefined, runtimeFilter: { stage: 'closed_won' } })).toBeNull(); }); - it('offers no click when the server says the scope is not a list filter', () => { - expect(metricDrillFilter({ object: 'crm_opportunity', measureScope: null, runtimeFilter: undefined })).toBeNull(); - }); - it('opens the widget filter alone when the measure has no scope of its own', () => { expect( metricDrillFilter({ object: 'crm_opportunity', measureScope: {}, runtimeFilter: { stage: { $nin: ['closed_won', 'closed_lost'] } } }), @@ -52,8 +48,19 @@ describe('metricDrillFilter — the list a KPI tile opens (ADR-0021 D2)', () => }); }); -describe('readMeasureDrillScope — before the server sidecar exists', () => { - it('reads every answer as "did not say"', () => { - expect(readMeasureDrillScope({ rows: [{ total_amount: 1 }], object: 'crm_opportunity' }, 'total_amount')).toBeUndefined(); +describe('readMeasureDrillScope — the answer drillScopes entry', () => { + it('reads the tile measure entry', () => { + expect(readMeasureDrillScope({ drillScopes: { paid_count: { status: 'paid' } } }, 'paid_count')).toEqual({ status: 'paid' }); + }); + + it('reads an empty entry as a scope with no extra condition', () => { + expect(readMeasureDrillScope({ drillScopes: { total_amount: {} } }, 'total_amount')).toEqual({}); + }); + + it('reads a missing entry, a missing map or no measure as "no scope"', () => { + expect(readMeasureDrillScope({ drillScopes: { opp_count: {} } }, 'paid_rate')).toBeUndefined(); + expect(readMeasureDrillScope({}, 'total_amount')).toBeUndefined(); + expect(readMeasureDrillScope(undefined, 'total_amount')).toBeUndefined(); + expect(readMeasureDrillScope({ drillScopes: { total_amount: {} } }, undefined)).toBeUndefined(); }); }); diff --git a/ui/packages/plugin-dashboard/src/metricDrill.ts b/ui/packages/plugin-dashboard/src/metricDrill.ts index 744d621750e..bca877be117 100644 --- a/ui/packages/plugin-dashboard/src/metricDrill.ts +++ b/ui/packages/plugin-dashboard/src/metricDrill.ts @@ -12,9 +12,10 @@ * A tile's number is the dataset's own scope AND the measure's own filter AND * the widget's filter (the dashboard's filters are already merged into it). * The first two live in the dataset definition, which this package never - * reads: only the server knows them, so the tile drills only when the answer - * hands over that measure's scope as a list filter. Without it, a tile whose - * measure carries a filter would open more records than its number counts. + * reads: the server hands them over per measure as the answer's `drillScopes` + * entry, already lowered to a list filter on the answer's `object`. A measure + * with no entry (a derived measure, or a scope naming a relationship path) has + * no list that selects its records. * * Invariant: a tile offers a click only when its list provably matches its * number. Every "cannot tell" answer below is `null`, never a looser filter. @@ -27,13 +28,10 @@ export interface MetricDrillInput { /** The dataset's base object, from the answer (`object`). */ object: string | undefined; /** - * The tile measure's own scope as a list filter: the dataset's filter AND - * the measure's filter, already lowered by the server. - * - `undefined`: the answer did not say (no click). - * - `null`: the server says the scope is not a list filter (no click). - * - an object (possibly empty): that scope. + * The tile measure's `drillScopes` entry: `undefined` when the answer has + * none (no click), else the list filter its records need (`{}` = none). */ - measureScope: Record | null | undefined; + measureScope: Record | undefined; /** The widget's resolved filter (placeholders already resolved). */ runtimeFilter: unknown; /** The answer's date windows for the tile's single row (`drillRanges[0]`). */ @@ -47,7 +45,7 @@ export interface MetricDrillInput { */ export function metricDrillFilter(input: MetricDrillInput): Record | null { const { object, measureScope, runtimeFilter, ranges } = input; - if (!object || measureScope == null) return null; + if (!object || measureScope === undefined) return null; try { const hasScope = Object.keys(measureScope).length > 0; const scoped = hasScope ? composeDrillFilter(runtimeFilter, measureScope) : runtimeFilter; @@ -58,15 +56,15 @@ export function metricDrillFilter(input: MetricDrillInput): Record | null | undefined { - return undefined; + answer: { drillScopes?: unknown } | null | undefined, + measure: string | undefined, +): Record | undefined { + const scopes = answer?.drillScopes; + if (!measure || !scopes || typeof scopes !== 'object' || Array.isArray(scopes)) return undefined; + const entry = (scopes as Record)[measure]; + return entry && typeof entry === 'object' && !Array.isArray(entry) + ? (entry as Record) + : undefined; }