Skip to content

Commit 14c67d4

Browse files
feat(plugin-dashboard): a dataset KPI tile opens the records behind its number (#30634)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuWWoy5F2AiXpoRcpmxamz3c)_ Fixes #30080 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:看板上绑定数据集的数字卡片(KPI)现在可以点开(鼠标或回车/空格),打开和图表、表格同一个下钻抽屉,列出这个数字背后的记录。 - 为什么改:经理看到「在途商机总额」后想知道是哪些商机,之前只能去列表手动重设条件,还不一定对得上。裁决 A 要求卡片直接打开对应记录,对不上的不给点击。 - 风险与回滚:只改 plugin-dashboard 的数字卡片分支;列表筛选 = 卡片筛选 ∧ 日期窗口 ∧ 服务器给的度量范围(drillScopes,来自已合并的 #30526)。度量没有范围条目(派生度量等)的卡片保持原样、不可点击。回滚即 revert 本 PR。 - 先看:ui/packages/plugin-dashboard/src/metricDrill.ts(筛选怎么拼、什么时候不给点击);DatasetWidget.tsx 的 metric 分支(点击和抽屉放在卡片外侧);__tests__/DatasetWidget.test.tsx 末尾的 metric tile drill 组。 - 两分钟验证:打开示例 CRM 的销售漏斗看板,点「在途商机总额」,抽屉里 4 条商机的金额合计应正好是卡片上的 710,000(种子数据)。 - 需要你做的:无,席位落地。 ## What changes - `ui/packages/plugin-dashboard/src/metricDrill.ts` (new): `metricDrillFilter` composes the widget filter (dashboard filters already merged in), the answer's date window (`drillRanges[0]`) and the tile measure's `drillScopes` entry through the shared drill sinks (`composeDrillFilter`, `buildDatasetDrillFilter`). It answers `null`, meaning no click, when the answer names no object, when the measure has no `drillScopes` entry, or when the sink refuses the filter (`FilterOperatorError`). `readMeasureDrillScope` reads one measure's entry off the answer. Neither is exported from the package entry. - `DatasetWidget.tsx`, metric branch only: when the filter is not `null` the tile gets `role="button"`, `tabIndex=0`, Enter/Space, the test id `dataset-metric-drill` and a hover/focus ring, and opens the existing `DrillDownDrawer` titled by the tile's heading. With no filter, the markup is the previous markup. The drawer renders as the tile's sibling, never its child, so events from inside the portal never reach the tile's handlers. The package-local `DatasetResult` type gains `drillScopes`. - One root changeset (`@object-ui/plugin-dashboard`: patch). ## Why Ruling A (comment 6631202970): a dashboard tile opens the matching records through the report drill path, and a tile whose filter cannot be expressed as a list filter offers no click. A tile's number also carries the dataset's own filter and the measure's own filter, which only the server knows; #30526 (PR #30596) added the per-measure `drillScopes` sidecar this reads, so the list is the set of records the number counts. ## Tests - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-dashboard/` on 8f48513: Test Files 203 passed (203), Tests 1948 passed, 6 skipped (VERDICT command-exit 0). - New pins: `__tests__/metricDrill.test.ts` (composition, every no-click arm, the entry reader) and a top-level `describe('DatasetWidget metric tile drill')` in the shared `DatasetWidget.test.tsx` harness: no `drillScopes`, no entry for the tile measure, scope AND widget filter on click (asserts the `find` object and `$filter`), an empty entry by Enter key, and the date window bound. - `pnpm --filter @object-ui/plugin-dashboard run type-check` (`tsc --noEmit` and `tsc -p tsconfig.test.json`): VERDICT command-exit 0. <details><summary>Ablation (one, on the scope leg)</summary> `node scripts/ablation-replace.mjs` replaced `const scoped = hasScope ? composeDrillFilter(runtimeFilter, measureScope) : runtimeFilter;` with a form that drops the measure scope (anchor 1 -> 0, blob bca877b -> 4a067a960ce7). The two files' run then failed 3 tests: `keeps the measure scope AND the widget filter, never only one of them`, `keeps the measure scope when the widget declares no filter`, `opens the widget filter AND the measure scope on click` (Tests 3 failed, 72 passed). Restored with `git checkout HEAD --`; hash-object after equals the HEAD blob bca877b. ui vitest resolves `@object-ui/*` to source, so no dist leg. </details> ## Acceptance notes - The chart, table and report drills still compose only the bucket and the widget or report filter; reading `drillScopes` there is #30615, not this PR. - No derived measure is bound to a tile on `pipeline_dashboard`, so the derived-measure no-click arm was measured by the widget pin only, not live. ## Verification <details><summary>Live: example CRM pipeline_dashboard, zh-CN, 1440 wide</summary> Before (main fa9c0aa, report 6660360476): the three metric tiles had no pointer, no drill test id, and a click opened nothing. After (8f48513, `objectstack dev --fresh` example CRM + Console vite dev server), each answer carried a `drillScopes` entry for the tile measure (`total_amount` and `avg_amount`: `amount` not null). Three tiles carry `dataset-metric-drill`; each click opened the drawer, and the listed records match the number: - 在途商机总额 710,000: 4 records (stages proposal, qualification, prospecting), amount sum 710,000. - 本季度赢单 235,000: 3 records, all closed_won, amount sum 235,000. - 平均成交额(同比) 81,000: 5 records, all closed_won, amount sum 405,000 = 5 x 81,000. </details> <details><summary>Gates on 8f48513</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 35; each ran with its exit code captured before any pipe, all 35 exit 0, plus `pnpm --dir ui run check:spec-symbols` exit 0. `--ran` reconciliation: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. Families CI runs are read from this PR's check-runs. </details> Branch: `claude/project-thread-pezzx8` is the card thread's platform-assigned branch (claim 6666212190), not the `claude/issue-30080-*` convention. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgWjsoG4R5yUYFw3T6s --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgWjsoG4R5yUYFw3T6s)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b90a6f7 commit 14c67d4

5 files changed

Lines changed: 260 additions & 8 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/plugin-dashboard': patch
3+
---
4+
5+
feat(plugin-dashboard): a dataset-bound KPI tile opens the records behind its number
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.

‎ui/packages/plugin-dashboard/src/DatasetWidget.tsx‎

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
* objectui bumps its `@objectstack/spec` dependency (cross-repo spec skew).
3434
*/
3535

36-
import { useEffect, useMemo, useState, type ReactElement } from 'react';
36+
import { useEffect, useMemo, useState, type ReactElement, type KeyboardEvent as ReactKeyboardEvent } from 'react';
3737
// `useDatasetDimensionMeta` is the analytics label net's SHARED React glue
3838
// (objectui#4389): the locale-free metadata read this widget and the dataset
3939
// report block both take, lifted out of the two longhand copies PR #4388 left
@@ -105,6 +105,7 @@ import {
105105
metricTileValueTitle,
106106
} from './metricTile';
107107
import { DrillDownDrawer } from './DrillDownDrawer';
108+
import { metricDrillFilter, readMeasureDrillScope } from './metricDrill';
108109
import { specDefaultWidgetType } from './widgetDispatch';
109110
import { DASHBOARD_WIDGET_MULTI_MEASURE_TYPES } from '@objectstack/spec/ui';
110111

@@ -113,7 +114,7 @@ type Row = Record<string, unknown>;
113114
// `@objectstack/spec/api`'s `DatasetTotals`, which since 17.5.0 is the REQUEST side
114115
// (`{ groupings }`, what to compute) — objectui#11073.
115116
interface DatasetResultTotals { dimensions: string[]; rows: Row[] }
116-
interface DatasetResult { rows: Row[]; fields?: DatasetResultField[]; object?: string; dimensionFields?: Record<string, string>; drillRawRows?: Row[]; drillRanges?: Array<Record<string, DatasetDrillRange>>; totals?: DatasetResultTotals[] }
117+
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> }
117118
interface DatasetCapableSource {
118119
queryDataset?: (dataset: string, selection: unknown) => Promise<DatasetResult>;
119120
}
@@ -898,7 +899,7 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: {
898899

899900
// `signature` is the query an `ok` answer was read for, and `refreshing` marks
900901
// a re-read of that same query in flight (objectui#10815, see the effect).
901-
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: [] });
902+
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: [] });
902903
// Drill-through (ADR-0021 D2): the clicked bucket's record-list filter + title.
903904
const [drill, setDrill] = useState<{ filter: Record<string, unknown>; title: string } | null>(null);
904905
// ── The flat table's client-side sort (objectui#5827) ────────────────────
@@ -967,7 +968,7 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: {
967968
})
968969
.then((res) => {
969970
if (cancelled) return;
970-
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 });
971+
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 });
971972
if (typeof res?.object === 'string' && res.object) setDatasetObject(res.object);
972973
})
973974
.catch((e) => {
@@ -1392,11 +1393,43 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: {
13921393
// the title's tooltip (objectstack#27430).
13931394
const caption = heading?.description || measureLabel;
13941395
const shownValue = formatMeasure(value, f?.format, f?.currency, f?.percentScale, displayLocale);
1396+
// ── The records behind the number (ADR-0021 D2) ────────────────────────
1397+
// The tile opens the list its number counts, through the same drawer the
1398+
// chart and table drills open. `null` means the list could not be proven
1399+
// to match the number (see `./metricDrill`), and the tile then offers no
1400+
// click at all: its markup stays exactly the no-click markup.
1401+
const tileDrillFilter = metricDrillFilter({
1402+
object: drillObject,
1403+
measureScope: readMeasureDrillScope(state, tileMeasure),
1404+
runtimeFilter,
1405+
ranges: drillRanges?.[0],
1406+
});
1407+
const openTileDrill = tileDrillFilter
1408+
? () => setDrill({ filter: tileDrillFilter, title: heading?.title || measureLabel })
1409+
: undefined;
1410+
const tileDrillProps = openTileDrill
1411+
? {
1412+
role: 'button' as const,
1413+
tabIndex: 0,
1414+
'data-testid': 'dataset-metric-drill',
1415+
onClick: openTileDrill,
1416+
onKeyDown: (e: ReactKeyboardEvent) => {
1417+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openTileDrill(); }
1418+
},
1419+
}
1420+
: {};
1421+
// The drawer is a SIBLING of the tile, never its child: React bubbles a
1422+
// portal's events through the component tree, so a click or a space typed
1423+
// inside the drawer would otherwise reach the tile's own handlers.
13951424
return (
1396-
// Positioned only while a re-read is in flight, for the refresh bar: the
1397-
// idle tile's markup is pinned byte-for-byte (`DatasetWidget.colorVariant`,
1398-
// `.subCaption`, `.unrenderedMeasures-8894` tests), and it moves by nothing.
1399-
<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')}>
1425+
<>
1426+
{/* Positioned only while a re-read is in flight, for the refresh bar: the
1427+
idle tile's markup is pinned byte-for-byte (`DatasetWidget.colorVariant`,
1428+
`.subCaption`, `.unrenderedMeasures-8894` tests), and it moves by nothing. */}
1429+
<div
1430+
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')}
1431+
{...tileDrillProps}
1432+
>
14001433
{refreshBar}
14011434
{/* objectui#12623 — with a heading, the title owns the first line, the
14021435
value never wraps, and the caption (the description, else the
@@ -1428,6 +1461,8 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: {
14281461
)}
14291462
{!heading && <span className="text-xs text-muted-foreground">{measureLabel}</span>}
14301463
</div>
1464+
{drillDrawer}
1465+
</>
14311466
);
14321467
}
14331468

‎ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,3 +1061,74 @@ describe('DatasetWidget — dimension metadata probe (objectui#5280)', () => {
10611061
expect(screen.queryByText('in_progress')).not.toBeInTheDocument();
10621062
});
10631063
});
1064+
1065+
// ── The KPI tile's click (ADR-0021 D2) ───────────────────────────────────────
1066+
// A tile drills only when the answer carries a `drillScopes` entry for its
1067+
// measure, and the list it opens is the widget filter AND that entry.
1068+
describe('DatasetWidget metric tile drill', () => {
1069+
const OPEN = { stage: { $nin: ['closed_won', 'closed_lost'] } };
1070+
const tileSource = (answer: Record<string, unknown>) => ({
1071+
queryDataset: vi.fn(async () => ({
1072+
rows: [{ total_amount: 814400 }],
1073+
fields: [{ name: 'total_amount', type: 'number', label: 'Total Amount', aggregate: 'sum' }],
1074+
object: 'crm_opportunity',
1075+
...answer,
1076+
})),
1077+
// Parameters declared: the assertions read `find.mock.calls[0][1]`.
1078+
find: vi.fn(async (_object: string, _params: unknown) => ({ data: [] })),
1079+
getObjectSchema: vi.fn(async () => ({ fields: {} })),
1080+
});
1081+
const renderTile = (src: ReturnType<typeof tileSource>, filter: Record<string, unknown> | undefined = OPEN) =>
1082+
render(
1083+
<DatasetWidget
1084+
widget={{ type: 'metric', dataset: 'opportunity_metrics', values: ['total_amount'], ...(filter ? { filter } : {}) }}
1085+
dataSource={src}
1086+
/>,
1087+
);
1088+
1089+
it('offers no click when the answer carries no drillScopes', async () => {
1090+
renderTile(tileSource({}));
1091+
await screen.findByText('814400');
1092+
expect(screen.queryByTestId('dataset-metric-drill')).not.toBeInTheDocument();
1093+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
1094+
});
1095+
1096+
it('offers no click when the tile measure has no entry (a derived measure)', async () => {
1097+
renderTile(tileSource({ drillScopes: { opp_count: {} } }));
1098+
await screen.findByText('814400');
1099+
expect(screen.queryByTestId('dataset-metric-drill')).not.toBeInTheDocument();
1100+
});
1101+
1102+
it('opens the widget filter AND the measure scope on click', async () => {
1103+
const src = tileSource({ drillScopes: { total_amount: { amount: { $null: false } } } });
1104+
renderTile(src);
1105+
fireEvent.click(await screen.findByTestId('dataset-metric-drill'));
1106+
await waitFor(() => expect(src.find).toHaveBeenCalled());
1107+
expect(src.find.mock.calls[0][0]).toBe('crm_opportunity');
1108+
expect((src.find.mock.calls[0][1] as any).$filter).toEqual({
1109+
$and: [{ stage: { $nin: ['closed_won', 'closed_lost'] } }, { amount: { $null: false } }],
1110+
});
1111+
});
1112+
1113+
it('opens the widget filter alone when the entry is empty', async () => {
1114+
const src = tileSource({ drillScopes: { total_amount: {} } });
1115+
renderTile(src);
1116+
fireEvent.keyDown(await screen.findByTestId('dataset-metric-drill'), { key: 'Enter' });
1117+
await waitFor(() => expect(src.find).toHaveBeenCalled());
1118+
expect((src.find.mock.calls[0][1] as any).$filter).toEqual(OPEN);
1119+
});
1120+
1121+
it('bounds the list by the answer date window', async () => {
1122+
const src = tileSource({
1123+
drillScopes: { total_amount: {} },
1124+
drillRanges: [{ close_date: { field: 'close_date', gte: '2026-10-01', lt: '2027-01-01' } }],
1125+
});
1126+
renderTile(src, { stage: 'closed_won' });
1127+
fireEvent.click(await screen.findByTestId('dataset-metric-drill'));
1128+
await waitFor(() => expect(src.find).toHaveBeenCalled());
1129+
const sent = JSON.stringify((src.find.mock.calls[0][1] as any).$filter);
1130+
expect(sent).toContain('closed_won');
1131+
expect(sent).toContain('2026-10-01');
1132+
expect(sent).toContain('2027-01-01');
1133+
});
1134+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { metricDrillFilter, readMeasureDrillScope } from '../metricDrill';
5+
6+
describe('metricDrillFilter — the list a KPI tile opens (ADR-0021 D2)', () => {
7+
it('offers no click when the answer names no object', () => {
8+
expect(metricDrillFilter({ object: undefined, measureScope: {}, runtimeFilter: undefined })).toBeNull();
9+
});
10+
11+
it('offers no click when the answer did not state the measure scope', () => {
12+
expect(metricDrillFilter({ object: 'crm_opportunity', measureScope: undefined, runtimeFilter: { stage: 'closed_won' } })).toBeNull();
13+
});
14+
15+
it('opens the widget filter alone when the measure has no scope of its own', () => {
16+
expect(
17+
metricDrillFilter({ object: 'crm_opportunity', measureScope: {}, runtimeFilter: { stage: { $nin: ['closed_won', 'closed_lost'] } } }),
18+
).toEqual({ stage: { $nin: ['closed_won', 'closed_lost'] } });
19+
});
20+
21+
it('opens every record when neither side filters', () => {
22+
expect(metricDrillFilter({ object: 'crm_opportunity', measureScope: {}, runtimeFilter: undefined })).toEqual({});
23+
});
24+
25+
it('keeps the measure scope AND the widget filter, never only one of them', () => {
26+
const filter = metricDrillFilter({ object: 'invoice', measureScope: { status: 'paid' }, runtimeFilter: { region: 'emea' } });
27+
expect(filter).toEqual({ $and: [{ region: 'emea' }, { status: 'paid' }] });
28+
});
29+
30+
it('keeps the measure scope when the widget declares no filter', () => {
31+
expect(metricDrillFilter({ object: 'invoice', measureScope: { status: 'paid' }, runtimeFilter: undefined })).toEqual({ status: 'paid' });
32+
});
33+
34+
it('bounds the list by the answer date window', () => {
35+
const filter = metricDrillFilter({
36+
object: 'crm_opportunity',
37+
measureScope: {},
38+
runtimeFilter: { stage: 'closed_won' },
39+
ranges: { close_date: { field: 'close_date', gte: '2026-10-01', lt: '2027-01-01' } },
40+
});
41+
expect(JSON.stringify(filter)).toContain('2026-10-01');
42+
expect(JSON.stringify(filter)).toContain('closed_won');
43+
});
44+
45+
it('offers no click when the widget filter cannot become a list filter', () => {
46+
// A scalar on a list operator is refused by the shared drill sink.
47+
expect(metricDrillFilter({ object: 'invoice', measureScope: { status: 'paid' }, runtimeFilter: [['stage', 'in', 'won']] })).toBeNull();
48+
});
49+
});
50+
51+
describe('readMeasureDrillScope — the answer drillScopes entry', () => {
52+
it('reads the tile measure entry', () => {
53+
expect(readMeasureDrillScope({ drillScopes: { paid_count: { status: 'paid' } } }, 'paid_count')).toEqual({ status: 'paid' });
54+
});
55+
56+
it('reads an empty entry as a scope with no extra condition', () => {
57+
expect(readMeasureDrillScope({ drillScopes: { total_amount: {} } }, 'total_amount')).toEqual({});
58+
});
59+
60+
it('reads a missing entry, a missing map or no measure as "no scope"', () => {
61+
expect(readMeasureDrillScope({ drillScopes: { opp_count: {} } }, 'paid_rate')).toBeUndefined();
62+
expect(readMeasureDrillScope({}, 'total_amount')).toBeUndefined();
63+
expect(readMeasureDrillScope(undefined, 'total_amount')).toBeUndefined();
64+
expect(readMeasureDrillScope({ drillScopes: { total_amount: {} } }, undefined)).toBeUndefined();
65+
});
66+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The record list behind a dataset-bound KPI tile (ADR-0021 D2).
11+
*
12+
* A tile's number is the dataset's own scope AND the measure's own filter AND
13+
* the widget's filter (the dashboard's filters are already merged into it).
14+
* The first two live in the dataset definition, which this package never
15+
* reads: the server hands them over per measure as the answer's `drillScopes`
16+
* entry, already lowered to a list filter on the answer's `object`. A measure
17+
* with no entry (a derived measure, or a scope naming a relationship path) has
18+
* no list that selects its records.
19+
*
20+
* Invariant: a tile offers a click only when its list provably matches its
21+
* number. Every "cannot tell" answer below is `null`, never a looser filter.
22+
*/
23+
24+
import { buildDatasetDrillFilter, composeDrillFilter, FilterOperatorError } from '@object-ui/core';
25+
import type { DatasetDrillRange } from '@object-ui/core';
26+
27+
export interface MetricDrillInput {
28+
/** The dataset's base object, from the answer (`object`). */
29+
object: string | undefined;
30+
/**
31+
* The tile measure's `drillScopes` entry: `undefined` when the answer has
32+
* none (no click), else the list filter its records need (`{}` = none).
33+
*/
34+
measureScope: Record<string, unknown> | undefined;
35+
/** The widget's resolved filter (placeholders already resolved). */
36+
runtimeFilter: unknown;
37+
/** The answer's date windows for the tile's single row (`drillRanges[0]`). */
38+
ranges?: Record<string, DatasetDrillRange>;
39+
}
40+
41+
/**
42+
* The list filter a tile's click opens, or `null` when the tile must offer no
43+
* click. Computed before rendering, so a refused filter shows no affordance
44+
* instead of a click that does nothing.
45+
*/
46+
export function metricDrillFilter(input: MetricDrillInput): Record<string, unknown> | null {
47+
const { object, measureScope, runtimeFilter, ranges } = input;
48+
if (!object || measureScope === undefined) return null;
49+
try {
50+
const hasScope = Object.keys(measureScope).length > 0;
51+
const scoped = hasScope ? composeDrillFilter(runtimeFilter, measureScope) : runtimeFilter;
52+
return buildDatasetDrillFilter(undefined, [], {}, scoped, ranges);
53+
} catch (error) {
54+
if (error instanceof FilterOperatorError) return null;
55+
throw error;
56+
}
57+
}
58+
59+
/** Read one measure's `drillScopes` entry off a dataset answer. */
60+
export function readMeasureDrillScope(
61+
answer: { drillScopes?: unknown } | null | undefined,
62+
measure: string | undefined,
63+
): Record<string, unknown> | undefined {
64+
const scopes = answer?.drillScopes;
65+
if (!measure || !scopes || typeof scopes !== 'object' || Array.isArray(scopes)) return undefined;
66+
const entry = (scopes as Record<string, unknown>)[measure];
67+
return entry && typeof entry === 'object' && !Array.isArray(entry)
68+
? (entry as Record<string, unknown>)
69+
: undefined;
70+
}

0 commit comments

Comments
 (0)