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
10 changes: 10 additions & 0 deletions .changeset/30080-dashboard-tile-drill.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 43 additions & 8 deletions ui/packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';

Expand All @@ -113,7 +114,7 @@ type Row = Record<string, unknown>;
// `@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<string, string>; drillRawRows?: Row[]; drillRanges?: Array<Record<string, DatasetDrillRange>>; totals?: DatasetResultTotals[] }
interface DatasetResult { rows: Row[]; fields?: DatasetResultField[]; object?: string; dimensionFields?: Record<string, string>; drillRawRows?: Row[]; drillRanges?: Array<Record<string, DatasetDrillRange>>; totals?: DatasetResultTotals[]; drillScopes?: Record<string, unknown> }
interface DatasetCapableSource {
queryDataset?: (dataset: string, selection: unknown) => Promise<DatasetResult>;
}
Expand Down Expand Up @@ -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<string, string>; drillRawRows?: Array<Record<string, unknown>>; drillRanges?: Array<Record<string, DatasetDrillRange>>; 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<string, string>; drillRawRows?: Array<Record<string, unknown>>; drillRanges?: Array<Record<string, DatasetDrillRange>>; 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<string, unknown>; title: string } | null>(null);
// ── The flat table's client-side sort (objectui#5827) ────────────────────
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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.
<div className={cn('flex h-full w-full flex-col items-start justify-center gap-1 p-2', heading && cn('min-w-0', METRIC_TILE_CONTAINER_CLASS), state.refreshing && 'relative')}>
<>
{/* 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. */}
<div
className={cn('flex h-full w-full flex-col items-start justify-center gap-1 p-2', heading && cn('min-w-0', METRIC_TILE_CONTAINER_CLASS), state.refreshing && 'relative', openTileDrill && 'cursor-pointer rounded-md transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring')}
{...tileDrillProps}
>
{refreshBar}
{/* objectui#12623 — with a heading, the title owns the first line, the
value never wraps, and the caption (the description, else the
Expand Down Expand Up @@ -1428,6 +1461,8 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: {
)}
{!heading && <span className="text-xs text-muted-foreground">{measureLabel}</span>}
</div>
{drillDrawer}
</>
);
}

Expand Down
71 changes: 71 additions & 0 deletions ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => ({
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<typeof tileSource>, filter: Record<string, unknown> | undefined = OPEN) =>
render(
<DatasetWidget
widget={{ type: 'metric', dataset: 'opportunity_metrics', values: ['total_amount'], ...(filter ? { filter } : {}) }}
dataSource={src}
/>,
);

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');
});
});
66 changes: 66 additions & 0 deletions ui/packages/plugin-dashboard/src/__tests__/metricDrill.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
70 changes: 70 additions & 0 deletions ui/packages/plugin-dashboard/src/metricDrill.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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<string, DatasetDrillRange>;
}

/**
* 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<string, unknown> | 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<string, unknown> | undefined {
const scopes = answer?.drillScopes;
if (!measure || !scopes || typeof scopes !== 'object' || Array.isArray(scopes)) return undefined;
const entry = (scopes as Record<string, unknown>)[measure];
return entry && typeof entry === 'object' && !Array.isArray(entry)
? (entry as Record<string, unknown>)
: undefined;
}
Loading