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 29f3b016b6c..0fee836dee7 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'; @@ -113,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; } @@ -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[]; 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) ──────────────────── @@ -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, drillScopes: res?.drillScopes, signature }); if (typeof res?.object === 'string' && res.object) setDatasetObject(res.object); }) .catch((e) => { @@ -1392,11 +1393,43 @@ 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: readMeasureDrillScope(state, tileMeasure), + runtimeFilter, + ranges: drillRanges?.[0], + }); + const openTileDrill = tileDrillFilter + ? () => setDrill({ filter: tileDrillFilter, title: heading?.title || measureLabel }) + : 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(); } + }, + } + : {}; + // 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} {/* objectui#12623 — with a heading, the title owns the first line, the value never wraps, and the caption (the description, else the @@ -1428,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 0cb2f5c3884..1f89f0175d2 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx @@ -1061,3 +1061,74 @@ 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 carries a `drillScopes` entry for its +// measure, and the list it opens is the widget filter AND that entry. +describe('DatasetWidget metric tile drill', () => { + 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 new file mode 100644 index 00000000000..37e4fa736e3 --- /dev/null +++ b/ui/packages/plugin-dashboard/src/__tests__/metricDrill.test.ts @@ -0,0 +1,66 @@ +// 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('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 — 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 new file mode 100644 index 00000000000..bca877be117 --- /dev/null +++ b/ui/packages/plugin-dashboard/src/metricDrill.ts @@ -0,0 +1,70 @@ +/** + * 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: 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. + */ + +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 `drillScopes` entry: `undefined` when the answer has + * none (no click), else the list filter its records need (`{}` = none). + */ + 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]`). */ + 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 === undefined) 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 one measure's `drillScopes` entry off a dataset answer. */ +export function readMeasureDrillScope( + 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; +}