diff --git a/.changeset/30615-report-chart-table-drill-measure-scopes.md b/.changeset/30615-report-chart-table-drill-measure-scopes.md new file mode 100644 index 00000000000..16aa130d83b --- /dev/null +++ b/.changeset/30615-report-chart-table-drill-measure-scopes.md @@ -0,0 +1,16 @@ +--- +'@object-ui/plugin-report': patch +'@object-ui/plugin-dashboard': patch +--- + +fix(plugin-report,plugin-dashboard): a report, chart, table or cross-tab drill on a filtered measure lists the records the number counted + +Clause-②: no +Metadata-protocol: none + +A drill used to list the clicked bucket AND the report or widget filter only. The number also carries the dataset's own `filter` and the measure's own `filter`, so a drill on a filtered measure listed a superset: on the app-showcase Revenue Pulse dashboard, the Paid Invoices cell of the Sent row reads 0 and its drill listed all 4 sent invoices. The drill now also ANDs in the answer's `drillScopes` entry for the measure it stands for (the server's per-measure list filter; `{}` adds nothing, so a drill on an unfiltered measure is unchanged). + +- **What a click stands for.** A chart mark stands for its series' measure (a pivoted chart for its one measure), a cross-tab or matrix cell for its column's measure, and a table row for every measure it holds. +- **A table row whose measures select different records drills cell by cell.** When the row's measures all share one scope (one measure, or several plain ones), the row drills as before. Otherwise each measure cell with a scope is its own drill target (test id `dataset-drill-cell`) and the row is not. +- **No list that does not match the number.** A measure with no `drillScopes` entry (a derived measure, or a scope naming a relationship path) offers no drill, and neither does any measure of an answer that carries no `drillScopes`. A comparison (`__compare`) series opens nothing. A chart none of whose drawn measures has an entry gets no click handler. +- **Unchanged:** a report answer with no `object` (a server older than ADR-0021 D2) still hands the host its group-key fallback drill. diff --git a/ui/packages/plugin-dashboard/src/DatasetWidget.tsx b/ui/packages/plugin-dashboard/src/DatasetWidget.tsx index 0fee836dee7..2154c99804a 100644 --- a/ui/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/ui/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -106,6 +106,7 @@ import { } from './metricTile'; import { DrillDownDrawer } from './DrillDownDrawer'; import { metricDrillFilter, readMeasureDrillScope } from './metricDrill'; +import { sharedMeasureDrillScope, scopeDrillFilter } from './measureDrill'; import { specDefaultWidgetType } from './widgetDispatch'; import { DASHBOARD_WIDGET_MULTI_MEASURE_TYPES } from '@objectstack/spec/ui'; @@ -1314,8 +1315,14 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { // objectstack-ai/objectstack#1752: a date-only widget has no equality drill dim but still drills by the // server's per-row date RANGE, so the presence of ranges makes it drillable too. const canDrill = !!drillObject && (drillDims.length > 0 || !!drillRanges?.length); - const openDrill = (index: number, title: string) => { + // objectstack-ai/objectstack#30615 — a drill stands for the MEASURES the + // clicked mark or cell shows: a chart mark its series' measure, a cross-tab + // cell its column's, a table row every measure it holds. It opens only when + // the answer's `drillScopes` states one record list for them (`./measureDrill`). + const openDrill = (index: number, title: string, measures: readonly string[]) => { if (!drillObject) return; + const scope = sharedMeasureDrillScope(state, measures); + if (!scope) return; // objectui#10789 — composing the widget's filter with the clicked bucket // lowers both through the throwing converter form. A widget filter the // dataset query carried to the server can still be one this layer refuses @@ -1326,7 +1333,8 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { // drill handler). Only a `FilterOperatorError` is caught. let merged: Record; try { - merged = buildDrillFilter(drillRawRows?.[index], drillDims, dimensionFields ?? {}, runtimeFilter, drillRanges?.[index]); + // The clicked bucket AND the widget filter AND the measure's scope. + merged = buildDrillFilter(drillRawRows?.[index], drillDims, dimensionFields ?? {}, scopeDrillFilter(runtimeFilter, scope), drillRanges?.[index]); } catch (error) { if (!(error instanceof FilterOperatorError)) throw error; console.warn(`[DatasetWidget] drill-down refused — the drilled list cannot be scoped: ${error.message}`); @@ -1682,7 +1690,7 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { {cellCols.map((cc) => { const index = pivot.cellIndex.get(pivotCellKey(rh.id, cc.col.id)); const fr = index != null ? state.rows[index] : undefined; - const clickable = canDrill && index != null; + const clickable = canDrill && index != null && readMeasureDrillScope(state, cc.measure) !== undefined; const title = [...rh.labels, cc.col.label].filter(Boolean).join(' / '); return ( openDrill(index as number, title) : undefined} + onClick={clickable ? () => openDrill(index as number, title, [cc.measure]) : undefined} > {fr ? fmtMeasure(fr[cc.measure], cc.measure) : '—'} {compareStack(fr, cc.measure)} @@ -1739,6 +1747,16 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { // table (and a 1-dimension pivot) → a flat grouped table. const columns = [...dimensions, ...measureColumns]; + // objectstack-ai/objectstack#30615 — a row stands for every measure it + // holds, so the ROW drills only when they share one scope. Otherwise each + // measure cell with a scope of its own drills its own records, and a + // measure with none (a derived one) is not clickable. `cellDrills()` with + // no measure answers whether ANY cell of a row drills. + const lineDrills = canDrill && sharedMeasureDrillScope(state, values) !== undefined; + const cellDrills = (measure: string | undefined): boolean => + canDrill && !lineDrills && (measure === undefined + ? values.some((m) => readMeasureDrillScope(state, m) !== undefined) + : readMeasureDrillScope(state, measure) !== undefined); // ── Which columns are NUMBERS (objectui#5827) ───────────────────────── // Right-alignment is a claim about the values, so it is decided from them. @@ -1922,7 +1940,9 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { - {orderedEntries.map(({ row, index }) => ( + {orderedEntries.map(({ row, index }) => { + const rowTitle = drillDims.map((d) => formatDimensionValue(row[d], displayLocale)).filter(Boolean).join(' / '); + return ( openDrill(index, drillDims.map((d) => formatDimensionValue(row[d], displayLocale)).filter(Boolean).join(' / ')) : undefined} + className={cn('border-t transition-colors hover:bg-muted/40', lineDrills && 'cursor-pointer hover:bg-accent/40')} + data-testid={lineDrills ? 'dataset-drill-row' : undefined} + onClick={lineDrills ? () => openDrill(index, rowTitle, values) : undefined} > {columns.map((c) => { // A comparison column formats as the measure it compares. const measure = compareOf(c) ?? (values.includes(c) ? c : undefined); + // objectstack-ai/objectstack#30615 — when the row's measures + // are no one list, each measure cell drills its OWN records. + const cellDrill = values.includes(c) && cellDrills(c); return ( // A drillable row is a 44 px touch target below `sm` (#29078); // a `td`'s height is its floor, so the row grows to it. - + openDrill(index, rowTitle, [c]) : undefined} + > {measure ? formatMeasure(row[c], measureField(measure)?.format, measureCellCurrency(measureField(measure), state.rows[index]), measureField(measure)?.percentScale, displayLocale) : formatDimensionValue(row[c], displayLocale)} @@ -1949,7 +1982,8 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { ); })} - ))} + ); + })} {/* The totals row lives in `tfoot`, OUTSIDE `orderedEntries` entirely — not as a special case inside the sort. Three @@ -2191,6 +2225,19 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { // underneath any explicit author map. const chartPresentation = chartConfigPresentation(widget?.chartConfig, categoryColors); + // objectstack-ai/objectstack#30615 — the measure a clicked mark stands for. + // A pivoted chart carries one measure, its series being the second + // dimension's groups. Otherwise the series key IS the measure: a key that + // names no declared measure (a `__compare` series, the earlier + // window, which `drillScopes` gives no entry) stands for no record list. A + // mark with no series key belongs to the one measure a single-series chart + // draws. + const chartDrillMeasure = (seriesKey: unknown): string | undefined => { + if (pivotedSeries) return values[0]; + if (typeof seriesKey === 'string' && seriesKey !== '') return values.includes(seriesKey) ? seriesKey : undefined; + return renderedMeasures.length === 1 ? renderedMeasures[0] : undefined; + }; + // Map a clicked chart segment back to its dataset row, then drill through to // the underlying records — same governed path the table/pivot rows use. const handleChartDrill = (ev: ChartSegmentClickEvent) => { @@ -2219,13 +2266,19 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { const pivoted = dimensions.length >= 2 && values.length === 1; const seriesTitle = ev?.seriesLabel ?? ev?.series; const title = [ev?.category, pivoted ? seriesTitle : undefined].filter(Boolean).map(String).join(' / '); - openDrill(idx, title); + const measure = chartDrillMeasure(ev?.series); + if (measure === undefined) return; + openDrill(idx, title, [measure]); }; // The `chart` schema renders through either ChartRenderer (reads `onChartClick`) // or ObjectChart (reads `onSegmentClick` and suppresses its own object-drill) — // pass both so the segment click drills regardless of which is registered. - const chartDrill = canDrill ? handleChartDrill : undefined; + // …and offered only when at least one drawn measure has a record list. + const chartDrillable = canDrill && (pivotedSeries ? values.slice(0, 1) : renderedMeasures).some( + (m) => readMeasureDrillScope(state, m) !== undefined, + ); + const chartDrill = chartDrillable ? handleChartDrill : undefined; return (
{refreshBar} diff --git a/ui/packages/plugin-dashboard/src/__tests__/DashboardPhoneTouchTargets-29078.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DashboardPhoneTouchTargets-29078.test.tsx index 710cc729e36..5e33d5767fe 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DashboardPhoneTouchTargets-29078.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DashboardPhoneTouchTargets-29078.test.tsx @@ -135,6 +135,7 @@ describe('#29078 — the dataset table and pivot are 44 px touch targets on a ph fields, object: 'crm_account', dimensionFields: { industry: 'industry' }, + drillScopes: { account_count: {}, task_count: {} }, drillRawRows: [{ industry: 'fin' }, { industry: 'tech' }], })), }); @@ -180,6 +181,7 @@ describe('#29078 — the dataset table and pivot are 44 px touch targets on a ph rows: [{ status: 'Open', priority: 'High', task_count: 2 }], object: 'task', dimensionFields: { status: 'status', priority: 'priority' }, + drillScopes: { account_count: {}, task_count: {} }, drillRawRows: [{ status: 'open', priority: 'high' }], })), }; diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.chartBucketIdentity.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.chartBucketIdentity.test.tsx index 1db3a204382..310556e7926 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.chartBucketIdentity.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.chartBucketIdentity.test.tsx @@ -87,6 +87,7 @@ const datasetOf = (rows: Array>, rawRows: Array expect(lastFilter()).toEqual({ owner_id: { $null: true } })); }); }); + +// ── objectstack-ai/objectstack#30615 — a chart mark lists ITS measure's records ── +// The app-showcase `revenue-pulse` shape: `invoice_count` (scope `{}`) beside +// `paid_count` (`filter: { status: 'paid' }`), one series per measure. +describe('DatasetWidget chart drill — measure drill scopes (#30615)', () => { + const invoices = (drillScopes?: Record>) => ({ + queryDataset: vi.fn(async () => ({ + rows: [{ status: 'Sent', invoice_count: 4, paid_count: 0 }], + fields: [ + { name: 'status', type: 'text', label: 'Status' }, + { name: 'invoice_count', type: 'number', label: 'Invoices' }, + { name: 'paid_count', type: 'number', label: 'Paid Invoices' }, + ], + object: 'showcase_invoice', + dimensionFields: { status: 'status' }, + drillRawRows: [{ status: 'sent' }], + ...(drillScopes ? { drillScopes } : {}), + })), + }); + const renderBars = (src: ReturnType) => + render( + + + , + ); + const SCOPES = { invoice_count: {}, paid_count: { status: 'paid' } }; + + it('a filtered measure bar lists bucket AND widget filter AND the measure scope', async () => { + installMetaRouter(); + renderBars(invoices(SCOPES)); + await waitFor(() => expect(chartRows()).toHaveLength(1)); + capturedChartProps.onSegmentClick({ category: 'Sent', series: 'paid_count' }); + await waitFor(() => expect(drillFilters.length).toBeGreaterThan(0)); + expect(lastFilter()).toEqual({ $and: [{ $and: [{ region: 'emea' }, { status: 'paid' }] }, { status: 'sent' }] }); + }); + + it('an unfiltered measure bar (`{}`) lists bucket AND widget filter only', async () => { + installMetaRouter(); + renderBars(invoices(SCOPES)); + await waitFor(() => expect(chartRows()).toHaveLength(1)); + capturedChartProps.onSegmentClick({ category: 'Sent', series: 'invoice_count' }); + await waitFor(() => expect(drillFilters.length).toBeGreaterThan(0)); + expect(lastFilter()).toEqual({ $and: [{ region: 'emea' }, { status: 'sent' }] }); + }); + + it('a series with no entry opens nothing, and an answer with no drillScopes offers no chart drill', async () => { + installMetaRouter(); + renderBars(invoices({ invoice_count: {} })); + await waitFor(() => expect(chartRows()).toHaveLength(1)); + capturedChartProps.onSegmentClick({ category: 'Sent', series: 'paid_count' }); + expect(drillFilters).toHaveLength(0); + cleanup(); + renderBars(invoices()); + await waitFor(() => expect(chartRows()).toHaveLength(1)); + expect(capturedChartProps.onSegmentClick).toBeUndefined(); + expect(capturedChartProps.onChartClick).toBeUndefined(); + }); +}); diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.declaredNullLabel-25330.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.declaredNullLabel-25330.test.tsx index 94cb7010e92..a1a9842dd85 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.declaredNullLabel-25330.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.declaredNullLabel-25330.test.tsx @@ -82,6 +82,7 @@ const auditDataset = (userNullLabel?: string, actionNullLabel?: string) => ({ ], object: 'sys_audit_log', dimensionFields: pivot ? { user_id: 'user_id', action: 'action' } : { user_id: 'user_id' }, + drillScopes: { event_count: {} }, drillRawRows: pivot ? [{ user_id: 'usr_dev', action: 'login' }, { user_id: null, action: 'create' }] : [{ user_id: 'usr_dev' }, { user_id: null }], diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.drillTitleLabel.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.drillTitleLabel.test.tsx index aba82033008..b44d47399b0 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.drillTitleLabel.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.drillTitleLabel.test.tsx @@ -102,6 +102,7 @@ const datasetOf = ( // Both dimensions drill, so the filter names the clicked GROUP as well as // the clicked bucket — which is what makes a wrong series visible. dimensionFields: { status: 'status_id', priority: 'priority_id' }, + drillScopes: { est_hours: {} }, drillRawRows: rawRows, })), }); @@ -307,6 +308,7 @@ describe('DatasetWidget — the drill title reads the series LABEL (objectui#468 ], object: 'proj_task', dimensionFields: { status: 'status_id' }, + drillScopes: { est_hours: {} }, drillRawRows: [{ status: 'st-backlog' }], })), } as any} diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.localSelectI18n.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.localSelectI18n.test.tsx index e6a097cb2f6..3e2cb10f18f 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.localSelectI18n.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.localSelectI18n.test.tsx @@ -173,6 +173,7 @@ const serverResolvedTable = () => ], object: 'crm_opportunity', dimensionFields: { sales_channel: 'sales_channel', competitor_name: 'competitor_name' }, + drillScopes: { deals: {} }, drillRawRows: [ { sales_channel: 'domestic', competitor_name: 'orion' }, { sales_channel: 'export', competitor_name: 'cabot' }, diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.nullCategoryI18n.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.nullCategoryI18n.test.tsx index f43011af6b8..c6d4646440c 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.nullCategoryI18n.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.nullCategoryI18n.test.tsx @@ -108,6 +108,7 @@ const nullKeyedDataset = () => ({ ], object: 'crm_opportunity', dimensionFields: { owner_name: 'owner_id' }, + drillScopes: { deals: {} }, drillRawRows: [{ owner_name: 'user-ada' }, { owner_name: null }], })), }); @@ -125,6 +126,7 @@ const fullyKeyedDataset = () => ({ ], object: 'crm_opportunity', dimensionFields: { owner_name: 'owner_id' }, + drillScopes: { deals: {} }, drillRawRows: [{ owner_name: 'user-ada' }, { owner_name: 'user-grace' }], })), }); diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.optionLabelI18n.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.optionLabelI18n.test.tsx index 70270be48f8..97a2292ada6 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.optionLabelI18n.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.optionLabelI18n.test.tsx @@ -159,6 +159,7 @@ const valueKeyedChart = () => ], object: 'crm_opportunity', dimensionFields: { competitor_name: 'competitor_name' }, + drillScopes: { deals: {}, total: {} }, drillRawRows: [ { competitor_name: 'orion' }, { competitor_name: 'cabot' }, @@ -179,6 +180,7 @@ const serverResolvedChart = () => ], object: 'crm_opportunity', dimensionFields: { competitor_name: 'competitor_name' }, + drillScopes: { deals: {}, total: {} }, drillRawRows: [{ competitor_name: 'orion' }, { competitor_name: 'cabot' }], }); diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.selectOptionOrder-11809.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.selectOptionOrder-11809.test.tsx index 1ef310341cd..8619bbb3b72 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.selectOptionOrder-11809.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.selectOptionOrder-11809.test.tsx @@ -127,6 +127,7 @@ const datasetOf = (rows: Array>) => ({ fields: FIELDS, object: 'showcase_task', dimensionFields: { status: 'status', priority: 'priority', area: 'area' }, + drillScopes: { task_count: {} }, drillRawRows: rows, })), }); diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.tablePolish.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.tablePolish.test.tsx index 31aaf22d8c4..82c253ecac4 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.tablePolish.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.tablePolish.test.tsx @@ -111,6 +111,7 @@ const drillableSource = () => industrySource({ object: 'crm_account', dimensionFields: { industry: 'industry' }, + drillScopes: { annual_revenue_sum: {}, account_count: {}, top_account: {} }, drillRawRows: [{ industry: null }, { industry: 'fin' }, { industry: 'tech' }], }); diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.tableTotalsRow.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.tableTotalsRow.test.tsx index bf3ceb40101..c42c9355d4f 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.tableTotalsRow.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.tableTotalsRow.test.tsx @@ -94,6 +94,7 @@ const drillableSource = () => industrySource({ object: 'crm_account', dimensionFields: { industry: 'industry' }, + drillScopes: { annual_revenue_sum: {}, avg_deal: {}, distinct_owners: {}, win_rate: {} }, drillRawRows: [{ industry: null }, { industry: 'fin' }, { industry: 'tech' }], }); diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx index 1f89f0175d2..3fae4df2cf2 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx @@ -414,6 +414,7 @@ describe('DatasetWidget', () => { fields: [{ name: 'status', type: 'string', label: 'Status' }, { name: 'task_count', type: 'number', label: 'Tasks' }], object: 'showcase_task', dimensionFields: { status: 'status' }, + drillScopes: { task_count: {} }, drillRawRows: [{ status: 'open' }], })) }; render(); @@ -426,6 +427,7 @@ describe('DatasetWidget', () => { fields: [{ name: 'close_date', type: 'date', label: 'Close Date' }, { name: 'task_count', type: 'number', label: 'Tasks' }], object: 'showcase_task', // No dimensionFields — a date bucket isn't equality-drillable; only a range sidecar. + drillScopes: { task_count: {} }, drillRanges: [{ close_date: { field: 'close_date', gte: '2026-04-01', lt: '2026-07-01' } }], })) }; render(); @@ -722,6 +724,7 @@ describe('DatasetWidget', () => { ], object: 'showcase_deal', dimensionFields: { region: 'region', quarter: 'quarter' }, + drillScopes: { amount: {} }, drillRawRows: [ { region: 'new', quarter: 'york_q1' }, { region: 'new_york', quarter: 'q1' }, @@ -771,6 +774,7 @@ describe('DatasetWidget', () => { dimensionFields: { region: 'region', quarter: 'quarter' }, // Deliberately distinguishable raw values: the drill filter names which // flat index the clicked cell resolved to. + drillScopes: { amount: {} }, drillRawRows: [ { region: 'was_null', quarter: 'q1' }, { region: 'literal_emptyset', quarter: 'q1' }, @@ -808,6 +812,7 @@ describe('DatasetWidget', () => { fields: [{ name: 'status', type: 'string', label: 'Status' }, { name: 'priority', type: 'string', label: 'Priority' }, { name: 'task_count', type: 'number', label: 'Tasks' }], object: 'showcase_task', dimensionFields: { status: 'status', priority: 'priority' }, + drillScopes: { task_count: {} }, drillRawRows: [{ status: 'open', priority: 'high' }], })) }; render(); @@ -1132,3 +1137,96 @@ describe('DatasetWidget metric tile drill', () => { expect(sent).toContain('2027-01-01'); }); }); + +// ── objectstack-ai/objectstack#30615 — a table/cross-tab drill lists the records the number counted ── +// The app-showcase `revenue-pulse` `table_rate_by_status` shape: `invoice_count` +// (scope `{}`), `paid_count` (`filter: { status: 'paid' }`) and `paid_rate` +// (derived: no entry), grouped by `status`. +describe('DatasetWidget table drill — measure drill scopes (#30615)', () => { + const invoiceSource = (answer: Record) => ({ + queryDataset: vi.fn(async () => ({ + rows: [{ status: 'Sent', region: 'EMEA', invoice_count: 4, paid_count: 0, paid_rate: 0 }], + fields: [ + { name: 'status', type: 'string', label: 'Status' }, + { name: 'region', type: 'string', label: 'Region' }, + { name: 'invoice_count', type: 'number', label: 'Invoices' }, + { name: 'paid_count', type: 'number', label: 'Paid Invoices' }, + { name: 'paid_rate', type: 'number', label: 'Paid Rate' }, + ], + object: 'showcase_invoice', + dimensionFields: { status: 'status', region: 'region' }, + drillRawRows: [{ status: 'sent', region: 'emea' }], + ...answer, + })), + find: vi.fn(async (_object: string, _params: unknown) => ({ data: [] })), + getObjectSchema: vi.fn(async () => ({ fields: {} })), + }); + const SCOPES = { invoice_count: {}, paid_count: { status: 'paid' } }; + const renderTable = (src: ReturnType, values: string[]) => + render( + , + ); + const sentFilter = (src: ReturnType) => (src.find.mock.calls[0][1] as any).$filter; + + it('a filtered measure cell lists bucket AND widget filter AND the measure scope', async () => { + const src = invoiceSource({ drillScopes: SCOPES }); + renderTable(src, ['invoice_count', 'paid_count', 'paid_rate']); + await waitFor(() => expect(screen.getAllByTestId('dataset-drill-cell')).toHaveLength(2)); + // A plain count beside a filtered count: the row is no one list. + expect(screen.queryByTestId('dataset-drill-row')).not.toBeInTheDocument(); + fireEvent.click(screen.getAllByTestId('dataset-drill-cell')[1]); + await waitFor(() => expect(src.find).toHaveBeenCalled()); + expect(src.find.mock.calls[0][0]).toBe('showcase_invoice'); + expect(sentFilter(src)).toEqual({ $and: [{ $and: [{ region: 'emea' }, { status: 'paid' }] }, { status: 'sent' }] }); + }); + + it('an unfiltered measure cell (`{}`) lists bucket AND widget filter only', async () => { + const src = invoiceSource({ drillScopes: SCOPES }); + renderTable(src, ['invoice_count', 'paid_count', 'paid_rate']); + await waitFor(() => expect(screen.getAllByTestId('dataset-drill-cell')).toHaveLength(2)); + fireEvent.click(screen.getAllByTestId('dataset-drill-cell')[0]); + await waitFor(() => expect(src.find).toHaveBeenCalled()); + expect(sentFilter(src)).toEqual({ $and: [{ region: 'emea' }, { status: 'sent' }] }); + }); + + it('a measure with no entry (derived) offers no drill', async () => { + const src = invoiceSource({ drillScopes: SCOPES }); + renderTable(src, ['paid_rate']); + await screen.findByText('Sent'); + expect(screen.queryByTestId('dataset-drill-row')).not.toBeInTheDocument(); + expect(screen.queryByTestId('dataset-drill-cell')).not.toBeInTheDocument(); + }); + + it('an answer with no drillScopes offers no drill', async () => { + const src = invoiceSource({}); + renderTable(src, ['invoice_count']); + await screen.findByText('Sent'); + expect(screen.queryByTestId('dataset-drill-row')).not.toBeInTheDocument(); + expect(screen.queryByTestId('dataset-drill-cell')).not.toBeInTheDocument(); + }); + + it('a row whose measures share one scope drills as a row, carrying it', async () => { + const src = invoiceSource({ drillScopes: SCOPES }); + renderTable(src, ['paid_count']); + fireEvent.click(await screen.findByTestId('dataset-drill-row')); + await waitFor(() => expect(src.find).toHaveBeenCalled()); + expect(sentFilter(src)).toEqual({ $and: [{ $and: [{ region: 'emea' }, { status: 'paid' }] }, { status: 'sent' }] }); + }); + + it('cross-tab: a cell drills with its own measure scope; a measure with no entry does not drill', async () => { + const src = invoiceSource({ drillScopes: { paid_count: { status: 'paid' } } }); + render( + , + ); + await waitFor(() => expect(screen.getAllByTestId('dataset-drill-cell')).toHaveLength(1)); + fireEvent.click(screen.getByTestId('dataset-drill-cell')); + await waitFor(() => expect(src.find).toHaveBeenCalled()); + expect(sentFilter(src)).toEqual({ $and: [{ status: 'paid' }, { $and: [{ status: 'sent' }, { region: 'emea' }] }] }); + }); +}); diff --git a/ui/packages/plugin-dashboard/src/__tests__/drillRefusal-10789.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/drillRefusal-10789.test.tsx index 8ded1d584fc..30687ce1720 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/drillRefusal-10789.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/drillRefusal-10789.test.tsx @@ -140,6 +140,7 @@ function renderDatasetWidget(filter: unknown) { ], object: 'proj_task', dimensionFields: { status: 'status_id' }, + drillScopes: { est_hours: {} }, drillRawRows: [{ status: 'open' }], })), }; diff --git a/ui/packages/plugin-dashboard/src/measureDrill.ts b/ui/packages/plugin-dashboard/src/measureDrill.ts new file mode 100644 index 00000000000..db75569cbe8 --- /dev/null +++ b/ui/packages/plugin-dashboard/src/measureDrill.ts @@ -0,0 +1,79 @@ +/** + * 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 MEASURE half of a chart / table / cross-tab drill (ADR-0021 D2, + * objectstack-ai/objectstack#30615) — the sibling of `./metricDrill`, which + * does the same for a KPI tile. + * + * A dataset number is computed under the dataset's own `filter`, the measure's + * own `filter` and the widget filter at once. Only the last is the widget's; + * the server states the other two per measure as the answer's `drillScopes` + * (`{}` = nothing beyond the bucket and the widget filter). A measure with no + * entry (a derived measure, or a scope that names a relationship path) has no + * record list that matches its number and offers no drill, and so does every + * measure of an answer that carries no `drillScopes` at all. + * + * A drill therefore stands for a MEASURE, not only for a bucket: a chart mark + * for its series' measure, a cross-tab cell for its column's, a table row for + * every measure it holds, which is one list only when they all agree on one + * scope ({@link sharedMeasureDrillScope}). + * + * ⛔ Every "cannot tell" answers `undefined`, never a looser scope. + */ + +import { composeDrillFilter } from '@object-ui/core'; +import { readMeasureDrillScope } from './metricDrill'; + +/** Key-order-free spelling of a JSON condition, so two equal scopes compare equal. */ +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (value !== null && typeof value === 'object') { + const obj = value as Record; + return `{${Object.keys(obj) + .sort() + .map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`) + .join(',')}}`; + } + return JSON.stringify(value) ?? 'undefined'; +} + +/** + * The one scope every measure in `measures` shares on `answer`, or `undefined` + * when any of them has no entry, or two of them select different records (a + * row holding a plain count beside a filtered count stands for two lists). + */ +export function sharedMeasureDrillScope( + answer: { drillScopes?: unknown } | null | undefined, + measures: readonly string[], +): Record | undefined { + if (measures.length === 0) return undefined; + const first = readMeasureDrillScope(answer, measures[0]); + if (!first) return undefined; + const key = canonical(first); + for (const m of measures.slice(1)) { + const scope = readMeasureDrillScope(answer, m); + if (!scope || canonical(scope) !== key) return undefined; + } + return first; +} + +/** + * A scope is one measure's list filter (`{}` = no leg beyond the bucket and + * the widget filter). + * + * The widget filter a drill composes with the clicked bucket: `runtimeFilter` + * AND the measure's scope. A `{}` scope hands `runtimeFilter` back UNTOUCHED, + * so a drill on an unfiltered measure is byte-identical to the bucket-and- + * widget-filter drill it always was. Throws the sink's `FilterOperatorError` + * on a refused filter, which `openDrill` already reports. + */ +export function scopeDrillFilter(runtimeFilter: unknown, scope: Record): unknown { + if (Object.keys(scope).length === 0) return runtimeFilter; + return composeDrillFilter(runtimeFilter, scope); +} diff --git a/ui/packages/plugin-report/src/DatasetReportRenderer.tsx b/ui/packages/plugin-report/src/DatasetReportRenderer.tsx index 0b68fe40a31..c4e5a4f796b 100644 --- a/ui/packages/plugin-report/src/DatasetReportRenderer.tsx +++ b/ui/packages/plugin-report/src/DatasetReportRenderer.tsx @@ -123,6 +123,7 @@ import { import { builtinAggregateLabels, useSafeFieldLabel, useSafeTranslate, useDisplayLocale, useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import { useDataInvalidation, classifyLoadError } from '@object-ui/react'; import { mergeFilters } from './mergeFilters'; +import { measureDrillScope, sharedMeasureDrillScope, scopeDrillFilter, type MeasureDrillScope } from './measureDrillScope'; import { useDatasetDimensionMeta, type DatasetDimensionMeta } from './useDatasetDimensionLabels'; import { ReportExportControl, @@ -167,6 +168,8 @@ interface DatasetCapableSource { dimensionFields?: Record; drillRawRows?: Row[]; drillRanges?: Array>; + /** Per measure, the list filter that selects its records (see `measureDrillScope.ts`). */ + drillScopes?: Record>; totals?: DatasetResultTotals[]; }>; } @@ -622,6 +625,7 @@ function useDatasetRows( dimensionFields?: Record; drillRawRows?: Row[]; drillRanges?: Array>; + drillScopes?: unknown; totals?: DatasetResultTotals[]; error?: string; /** The query was refused because this viewer may not read the data (objectui#12330). */ @@ -720,6 +724,7 @@ function useDatasetRows( dimensionFields: res?.dimensionFields, drillRawRows: Array.isArray(res?.drillRawRows) ? res.drillRawRows : undefined, drillRanges: Array.isArray(res?.drillRanges) ? res.drillRanges : undefined, + drillScopes: res?.drillScopes, totals: Array.isArray(res?.totals) ? res.totals : undefined, }); } @@ -770,10 +775,18 @@ function useDatasetRows( * the rule `toFilterNodeSafely` states. */ function drillFilterOrRefusal( - ...args: Parameters + scope: MeasureDrillScope, + rawRow: Record | undefined, + drillDims: string[], + dimensionFields: Record, + runtimeFilter: Record | undefined, + rawRanges: Record | undefined, ): Record | null { try { - return buildDatasetDrillFilter(...args); + // objectstack-ai/objectstack#30615 — the clicked measure's scope joins the + // report scope BEFORE the bucket: bucket AND runtimeFilter AND scope. A + // `{}` scope leaves `runtimeFilter` untouched (`scopeDrillFilter`). + return buildDatasetDrillFilter(rawRow, drillDims, dimensionFields, scopeDrillFilter(runtimeFilter, scope), rawRanges); } catch (error) { if (!(error instanceof FilterOperatorError)) throw error; console.warn(`[DatasetReportRenderer] drill-down refused — the drilled list cannot be scoped: ${error.message}`); @@ -930,13 +943,34 @@ function DatasetReportTable({ // Drilling needs at least one dimension to scope by. const canDrill = !!onDrill && rows.length > 0; + // objectstack-ai/objectstack#30615 — which measures a click can stand for. + // An answer that names its `object` (an ADR-0021 D2 server) states each + // measure's record list in `drillScopes`; a measure it gives no entry for + // offers no drill, and an answer with no `drillScopes` at all gives none. + // A line holds every measure, so the LINE drills only when they all share + // one scope; otherwise each measure CELL that has an entry drills on its own + // and the line itself does not. An answer with no `object` (an older server) + // keeps the host's own fallback drill, unchanged. + const scopeFor = (measures: readonly string[]): MeasureDrillScope | undefined => + state.object ? sharedMeasureDrillScope(state.drillScopes, measures) : {}; + const lineDrills = canDrill && scopeFor(values) !== undefined; + const cellDrills = (measure: string) => + canDrill && !lineDrills && !!state.object && measureDrillScope(state.drillScopes, measure) !== undefined; // Dims the server can map to object fields → raw-value (drill-correct) filter. const drillDimsAll = state.dimensionFields ? rows.filter((d) => d in state.dimensionFields!) : []; // `dims` is the grouping the clicked line stands for: every `rows` dimension // for a data row, the prefix for a subtotal row, whose drill scopes by the // prefix values alone (read off the group's first row, which carries them) // and by the span of its `members`' ranges (`spanDrillRanges`). - const drill = (row: Row, index: number, dims: readonly string[] = rows, members?: readonly number[]) => { + const drill = ( + row: Row, + index: number, + dims: readonly string[] = rows, + members?: readonly number[], + measures: readonly string[] = values, + ) => { + const scope = scopeFor(measures); + if (scope === undefined) return; const groupKey: Record = {}; for (const dim of dims) groupKey[dim] = row[dim]; const drillDims = drillDimsAll.filter((d) => dims.includes(d)); @@ -953,10 +987,14 @@ function DatasetReportTable({ const hasRange = !!rowRanges && Object.keys(rowRanges).length > 0; let objectFilter: Record | undefined; if (state.object && (drillDims.length > 0 || hasRange)) { - const built = drillFilterOrRefusal(state.drillRawRows?.[index], drillDims, state.dimensionFields ?? {}, runtimeFilter, rowRanges); + const built = drillFilterOrRefusal(scope, state.drillRawRows?.[index], drillDims, state.dimensionFields ?? {}, runtimeFilter, rowRanges); // Refused: no drill at all — see `drillFilterOrRefusal`. if (built === null) return; objectFilter = built; + } else if (Object.keys(scope).length > 0) { + // No exact bucket filter to carry the scope, and the host's groupKey + // fallback rebuilds the list from the bucket ALONE: no drill. + return; } onDrill!({ dataset, groupKey, runtimeFilter, object: state.object, objectFilter }); }; @@ -1087,18 +1125,23 @@ function DatasetReportTable({ return ( drill(state.rows[i], i, prefix, line.members) : undefined} + onClick={lineDrills ? () => drill(state.rows[i], i, prefix, line.members) : undefined} > {line.level > 0 && } {subtotalText} {values.map((measure) => ( - + drill(state.rows[i], i, prefix, line.members, [measure]) : undefined} + > {fmt(measure, total?.[measure])} ))} @@ -1108,20 +1151,30 @@ function DatasetReportTable({ return ( drill(state.rows[i], i) : undefined} + onClick={lineDrills ? () => drill(state.rows[i], i) : undefined} > - {columns.map((c) => ( - - {values.includes(c) ? fmt(c, displayRows[i][c], state.rows[i]) : detailDimension(c, i)} - - ))} + {columns.map((c) => { + const cellDrill = values.includes(c) && cellDrills(c); + return ( + drill(state.rows[i], i, rows, undefined, [c]) : undefined} + > + {values.includes(c) ? fmt(c, displayRows[i][c], state.rows[i]) : detailDimension(c, i)} + + ); + })} ); })} @@ -1993,17 +2046,27 @@ function DatasetMatrixTable({ const drillDims = state.dimensionFields ? [...rows, ...columnsAcross].filter((d) => d in state.dimensionFields!) : []; - const drillCell = (rowKey: Row, colKey: Row, index: number) => { + // objectstack-ai/objectstack#30615 — a cell stands for ONE measure, and + // drills only when the answer states that measure's record list (an answer + // with no `object`, an older server, keeps the host's fallback drill). + const cellScope = (measure: string): MeasureDrillScope | undefined => + state.object ? measureDrillScope(state.drillScopes, measure) : {}; + const drillCell = (rowKey: Row, colKey: Row, index: number, measure: string) => { + const scope = cellScope(measure); + if (scope === undefined) return; // objectstack-ai/objectstack#1752: include the date-bucket range sidecar so an "X by time" cell scopes // to the clicked time bucket, not every bucket in that row/column. const cellRanges = state.drillRanges?.[index]; const hasRange = !!cellRanges && Object.keys(cellRanges).length > 0; let objectFilter: Record | undefined; if (state.object && (drillDims.length > 0 || hasRange)) { - const built = drillFilterOrRefusal(state.drillRawRows?.[index], drillDims, state.dimensionFields ?? {}, runtimeFilter, cellRanges); + const built = drillFilterOrRefusal(scope, state.drillRawRows?.[index], drillDims, state.dimensionFields ?? {}, runtimeFilter, cellRanges); // Refused: no drill at all — see `drillFilterOrRefusal`. if (built === null) return; objectFilter = built; + } else if (Object.keys(scope).length > 0) { + // The host's groupKey fallback cannot carry the scope: no drill. + return; } onDrill!({ dataset, groupKey: { ...rowKey, ...colKey }, runtimeFilter, object: state.object, objectFilter }); }; @@ -2112,13 +2175,13 @@ function DatasetMatrixTable({ {cellCols.map((cc) => { const entry = pivot.cells.get(pivotCellKey(rh.id, cc.col.id)); const value = entry?.row[cc.measure]; - const clickable = canDrill && entry != null; + const clickable = canDrill && entry != null && cellScope(cc.measure) !== undefined; return ( drillCell(rh.key, cc.col.key, entry!.index) : undefined} + onClick={clickable ? () => drillCell(rh.key, cc.col.key, entry!.index, cc.measure) : undefined} > {formatMeasure(value, measureField(cc.measure)?.format, measureField(cc.measure)?.currency, measureField(cc.measure)?.percentScale, displayLocale)} diff --git a/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.drillRefusal-10789.test.tsx b/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.drillRefusal-10789.test.tsx index 2720a63e6b8..264acc07cc1 100644 --- a/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.drillRefusal-10789.test.tsx +++ b/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.drillRefusal-10789.test.tsx @@ -45,6 +45,7 @@ function makeSource(rows: Array>, rawRows: Array Array.from(row.querySelectorAll('td')).map((td) => td.textContent); @@ -204,6 +205,7 @@ describe('DatasetReportRenderer matrix — both axes and the totals (objectui#43 ], object: 'crm_opportunity', dimensionFields: { sales_channel: 'sales_channel', competitor_name: 'competitor_name' }, + drillScopes: { deals: {} }, drillRawRows: [ { sales_channel: 'domestic', competitor_name: 'orion' }, { sales_channel: 'export', competitor_name: 'cabot' }, diff --git a/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.selectOptionOrder-12243.test.tsx b/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.selectOptionOrder-12243.test.tsx index e96f1fa0c00..174ffb44f47 100644 --- a/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.selectOptionOrder-12243.test.tsx +++ b/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.selectOptionOrder-12243.test.tsx @@ -186,6 +186,7 @@ const STAGE_RESULT = { ], object: 'crm_opportunity', dimensionFields: { stage: 'stage' }, + drillScopes: { total_amount: {}, avg_probability: {} }, drillRawRows: [ { stage: null }, { stage: 'closed_won' }, diff --git a/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.summarySubtotals-27975.test.tsx b/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.summarySubtotals-27975.test.tsx index d6d37fa028a..fa3244b08ba 100644 --- a/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.summarySubtotals-27975.test.tsx +++ b/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.summarySubtotals-27975.test.tsx @@ -29,6 +29,7 @@ type Result = { dimensionFields?: Record; drillRawRows?: Rows; drillRanges?: Array>; + drillScopes?: Record>; totals?: Array<{ dimensions: string[]; rows: Rows }>; }; @@ -276,6 +277,7 @@ describe('summary report subtotals (objectstack-ai/objectstack#27975)', () => { const DRILLABLE: Result = { ...TWO_LEVEL, object: 'opportunity', + drillScopes: { amount: {} }, dimensionFields: { owner: 'owner_id', stage: 'stage' }, drillRawRows: [ { owner: 'usr_amy', stage: 'negotiation' }, @@ -316,6 +318,7 @@ describe('summary report subtotals (objectstack-ai/objectstack#27975)', () => { { owner: 'Amy Chen', month: '2026-03', amount: 2 }, ], object: 'opportunity', + drillScopes: { amount: {} }, dimensionFields: { owner: 'owner_id' }, drillRawRows: [{ owner: 'usr_amy' }, { owner: 'usr_amy' }], drillRanges: [ @@ -351,6 +354,7 @@ describe('summary report subtotals (objectstack-ai/objectstack#27975)', () => { { owner: 'Amy Chen', month: null, amount: 2 }, ], object: 'opportunity', + drillScopes: { amount: {} }, dimensionFields: { owner: 'owner_id' }, drillRawRows: [{ owner: 'usr_amy' }, { owner: 'usr_amy' }], drillRanges: [{ month: { field: 'close_date', gte: '2026-01-01', lt: '2026-02-01' } }, {}], diff --git a/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx b/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx index 4795d37ecc9..2e7ab8b9e9a 100644 --- a/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx +++ b/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx @@ -30,6 +30,7 @@ type MockResult = { dimensionFields?: Record; drillRawRows?: MockRows; drillRanges?: Array>; + drillScopes?: Record>; totals?: Array<{ dimensions: string[]; rows: MockRows }>; }; @@ -48,6 +49,7 @@ function makeSource(byDataset: Record) { ...(entry?.dimensionFields ? { dimensionFields: entry.dimensionFields } : {}), ...(entry?.drillRawRows ? { drillRawRows: entry.drillRawRows } : {}), ...(entry?.drillRanges ? { drillRanges: entry.drillRanges } : {}), + ...(entry?.drillScopes ? { drillScopes: entry.drillScopes } : {}), ...(entry?.totals ? { totals: entry.totals } : {}), }; }), @@ -626,6 +628,7 @@ describe('DatasetReportRenderer', () => { task_metrics: { rows: [{ status: 'In Progress', est_hours: 30 }], object: 'task', + drillScopes: { est_hours: {} }, dimensionFields: { status: 'status' }, // the visible row carries the DISPLAY label; the raw row carries the stored value drillRawRows: [{ status: 'in_progress' }], @@ -660,6 +663,7 @@ describe('DatasetReportRenderer', () => { pipe_by_quarter: { rows: [{ close_date: '2026-Q2', revenue: 1000 }], object: 'opportunity', + drillScopes: { revenue: {} }, // A date bucket isn't equality-drillable → no dimensionFields; a range sidecar instead. drillRanges: [{ close_date: { field: 'close_date', gte: '2026-04-01', lt: '2026-07-01' } }], }, @@ -687,6 +691,7 @@ describe('DatasetReportRenderer', () => { deal_metrics: { rows: [{ account: 'Acme Corp', amount: 1000 }], object: 'deal', + drillScopes: { amount: {} }, dimensionFields: { account: 'account_id' }, drillRawRows: [{ account: 'acc_123' }], }, @@ -712,6 +717,7 @@ describe('DatasetReportRenderer', () => { task_metrics: { rows: [{ status: 'In Progress', priority: 'High', est_hours: 10 }], object: 'task', + drillScopes: { est_hours: {} }, dimensionFields: { status: 'status', priority: 'priority' }, drillRawRows: [{ status: 'in_progress', priority: 'p1' }], }, @@ -1052,6 +1058,7 @@ describe('DatasetReportRenderer — matrix bucket encoding (objectstack#5665)', { region: 'New York', quarter: 'Q1', amount: 222 }, ], object: 'opportunity', + drillScopes: { amount: {} }, dimensionFields: { region: 'billing_state', quarter: 'close_quarter' }, drillRawRows: [ { region: 'NEW', quarter: 'YORK-Q1' }, @@ -1180,3 +1187,114 @@ describe('DatasetReportRenderer — dimension metadata probe (objectui#5225)', ( expect(screen.queryByText('in_progress')).not.toBeInTheDocument(); }); }); + +/* ──────────────────────────────────────────────────────────────────────────── + * objectstack-ai/objectstack#30615 — a drill lists the records the NUMBER counted + * + * The number is computed under the dataset's and the measure's own filter as + * well as the report scope; the answer states the first two per measure as + * `drillScopes`. The fixture is the app-showcase `revenue-pulse` shape: + * `invoice_count` (a plain count, scope `{}`), `paid_count` (`filter: { status: + * 'paid' }`) and `paid_rate` (derived, no entry), grouped by `status`. + * ──────────────────────────────────────────────────────────────────────────── */ +describe('DatasetReportRenderer — measure drill scopes (#30615)', () => { + afterEach(() => cleanup()); + + const invoiceAnswer = (drillScopes?: Record>) => ({ + showcase_invoice_metrics: { + rows: [{ status: 'Open', invoice_count: 3, paid_count: 0, paid_rate: 0 }], + object: 'showcase_invoice', + dimensionFields: { status: 'status' }, + drillRawRows: [{ status: 'open' }], + ...(drillScopes ? { drillScopes } : {}), + }, + }); + const SCOPES = { invoice_count: {}, paid_count: { status: 'paid' } }; + + const renderSummary = (src: ReturnType, values: string[], onDrill = vi.fn()) => { + render( + , + ); + return onDrill; + }; + + it('a filtered measure cell drills bucket AND report scope AND the measure scope', async () => { + const onDrill = renderSummary(makeSource(invoiceAnswer(SCOPES)), ['invoice_count', 'paid_count', 'paid_rate']); + await waitFor(() => expect(screen.getAllByTestId('dataset-drill-cell')).toHaveLength(2)); + // Two measures that select different records: the line is no one list. + expect(screen.queryByTestId('dataset-drill-row')).not.toBeInTheDocument(); + fireEvent.click(screen.getAllByTestId('dataset-drill-cell')[1]); + expect(onDrill).toHaveBeenCalledTimes(1); + expect(onDrill.mock.calls[0][0]).toMatchObject({ + object: 'showcase_invoice', + groupKey: { status: 'Open' }, + // The measure scope joins the report scope first, then the bucket. + objectFilter: { $and: [{ $and: [{ region: 'emea' }, { status: 'paid' }] }, { status: 'open' }] }, + }); + }); + + it('an unfiltered measure cell (`{}`) drills bucket AND report scope only', async () => { + const onDrill = renderSummary(makeSource(invoiceAnswer(SCOPES)), ['invoice_count', 'paid_count', 'paid_rate']); + await waitFor(() => expect(screen.getAllByTestId('dataset-drill-cell')).toHaveLength(2)); + fireEvent.click(screen.getAllByTestId('dataset-drill-cell')[0]); + expect(onDrill.mock.calls[0][0].objectFilter).toEqual({ $and: [{ region: 'emea' }, { status: 'open' }] }); + }); + + it('a measure with no entry (derived) offers no drill', async () => { + const onDrill = renderSummary(makeSource(invoiceAnswer(SCOPES)), ['paid_rate']); + await waitFor(() => expect(screen.getByText('Open')).toBeInTheDocument()); + expect(screen.queryByTestId('dataset-drill-row')).not.toBeInTheDocument(); + expect(screen.queryByTestId('dataset-drill-cell')).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('Open')); + expect(onDrill).not.toHaveBeenCalled(); + }); + + it('an answer with an object but no drillScopes offers no drill', async () => { + const onDrill = renderSummary(makeSource(invoiceAnswer()), ['invoice_count']); + await waitFor(() => expect(screen.getByText('Open')).toBeInTheDocument()); + expect(screen.queryByTestId('dataset-drill-row')).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('Open')); + expect(onDrill).not.toHaveBeenCalled(); + }); + + it('a line whose measures share one scope drills as a line, carrying it', async () => { + const onDrill = renderSummary(makeSource(invoiceAnswer(SCOPES)), ['paid_count']); + await waitFor(() => expect(screen.getByTestId('dataset-drill-row')).toBeInTheDocument()); + expect(screen.queryByTestId('dataset-drill-cell')).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('dataset-drill-row')); + expect(onDrill.mock.calls[0][0].objectFilter).toEqual({ + $and: [{ $and: [{ region: 'emea' }, { status: 'paid' }] }, { status: 'open' }], + }); + }); + + it('matrix: a cell drills with its own measure scope; a measure with no entry does not drill', async () => { + const src = makeSource({ + showcase_invoice_metrics: { + rows: [{ status: 'Open', region: 'EMEA', paid_count: 0, paid_rate: 0 }], + object: 'showcase_invoice', + dimensionFields: { status: 'status', region: 'region' }, + drillRawRows: [{ status: 'open', region: 'emea' }], + drillScopes: { paid_count: { status: 'paid' } }, + }, + }); + const onDrill = vi.fn(); + render( + , + ); + await waitFor(() => expect(screen.getByTestId('dataset-matrix')).toBeInTheDocument()); + expect(screen.getAllByTestId('dataset-drill-cell')).toHaveLength(1); + fireEvent.click(screen.getByTestId('dataset-drill-cell')); + expect(onDrill.mock.calls[0][0].objectFilter).toEqual({ + $and: [{ status: 'paid' }, { $and: [{ status: 'open' }, { region: 'emea' }] }], + }); + }); +}); diff --git a/ui/packages/plugin-report/src/measureDrillScope.ts b/ui/packages/plugin-report/src/measureDrillScope.ts new file mode 100644 index 00000000000..3ada35c7e70 --- /dev/null +++ b/ui/packages/plugin-report/src/measureDrillScope.ts @@ -0,0 +1,96 @@ +/** + * 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 MEASURE half of a report drill (ADR-0021 D2, objectstack-ai/objectstack#30615). + * + * A dataset number is computed under the dataset's own `filter`, the measure's + * own `filter` and the report's `runtimeFilter` at once. This renderer never + * reads the dataset definition, so the first two reach it only as the answer's + * `drillScopes`: per selected measure, the list filter on the answer's + * `object` that selects the records that measure aggregates (`{}` = nothing + * beyond the bucket and the report scope). A measure with NO entry — a derived + * measure, or a scope that names a relationship path — has no record list that + * matches its number, and so offers no drill: "A tile whose dataset filter + * cannot be expressed as a list filter offers no click, rather than a list + * that does not match the number." (the ruling this card carries, #30080 + * comment 6631202970). + * + * A drill therefore stands for a MEASURE, not only for a bucket. A table row + * holds every selected measure, so it stands for all of them at once, which is + * a list only when they all agree on one scope ({@link sharedMeasureDrillScope}). + * + * ⛔ Every "cannot tell" here answers `undefined`, never a looser scope: a + * malformed map or entry is read as no entry. + */ + +import { composeDrillFilter } from '@object-ui/core'; + +/** One measure's list filter (`{}` = no leg beyond the bucket and the scope). */ +export type MeasureDrillScope = Record; + +const isConditionObject = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +/** + * The answer's `drillScopes` entry for `measure`, or `undefined` when the + * answer has none: no map at all, no entry, or an entry that is not a + * condition object. + */ +export function measureDrillScope(drillScopes: unknown, measure: string): MeasureDrillScope | undefined { + if (!isConditionObject(drillScopes)) return undefined; + if (!Object.prototype.hasOwnProperty.call(drillScopes, measure)) return undefined; + const entry = drillScopes[measure]; + return isConditionObject(entry) ? entry : undefined; +} + +/** Key-order-free spelling of a JSON condition, so two equal scopes compare equal. */ +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (isConditionObject(value)) { + return `{${Object.keys(value) + .sort() + .map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`) + .join(',')}}`; + } + return JSON.stringify(value) ?? 'undefined'; +} + +/** + * The one scope every measure in `measures` shares, or `undefined` when any + * of them has no entry, or two of them select different records (a row holding + * a plain count beside a filtered count stands for two record lists, so the + * row as a whole is no list: each measure cell drills on its own instead). + */ +export function sharedMeasureDrillScope( + drillScopes: unknown, + measures: readonly string[], +): MeasureDrillScope | undefined { + if (measures.length === 0) return undefined; + const first = measureDrillScope(drillScopes, measures[0]); + if (!first) return undefined; + const key = canonical(first); + for (const m of measures.slice(1)) { + const scope = measureDrillScope(drillScopes, m); + if (!scope || canonical(scope) !== key) return undefined; + } + return first; +} + +/** + * The report scope a drill composes with the clicked bucket: `runtimeFilter` + * AND the measure's scope. A `{}` scope hands `runtimeFilter` back UNTOUCHED, + * so a drill on an unfiltered measure stays byte-identical to the + * bucket-and-scope drill it always was. Composed through `composeDrillFilter`, + * never spread, and it throws that seam's `FilterOperatorError` on a refused + * filter, which the caller already reports. + */ +export function scopeDrillFilter(runtimeFilter: unknown, scope: MeasureDrillScope): unknown { + if (Object.keys(scope).length === 0) return runtimeFilter; + return composeDrillFilter(runtimeFilter, scope); +}