Skip to content
16 changes: 16 additions & 0 deletions .changeset/30615-report-chart-table-drill-measure-scopes.md
Original file line number Diff line number Diff line change
@@ -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 (`<measure>__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.
77 changes: 65 additions & 12 deletions ui/packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand All @@ -1326,7 +1333,8 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: {
// drill handler). Only a `FilterOperatorError` is caught.
let merged: Record<string, unknown>;
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}`);
Expand Down Expand Up @@ -1682,15 +1690,15 @@ 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 (
<td
key={`${cc.col.id}-${cc.measure}`}
// A drillable cell is a 44 px touch target below `sm` (#29078).
className={cn('px-2 py-1 text-right tabular-nums whitespace-nowrap', clickable && 'cursor-pointer hover:bg-accent/40 max-sm:h-11 max-sm:min-w-11')}
data-testid={clickable ? 'dataset-drill-cell' : undefined}
onClick={clickable ? () => 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)}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1922,7 +1940,9 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: {
</tr>
</thead>
<tbody>
{orderedEntries.map(({ row, index }) => (
{orderedEntries.map(({ row, index }) => {
const rowTitle = drillDims.map((d) => formatDimensionValue(row[d], displayLocale)).filter(Boolean).join(' / ');
return (
<tr
key={index}
// Hover feedback on EVERY row, not only a drillable one — the
Expand All @@ -1931,25 +1951,39 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: {
// drillable row keeps its stronger accent fill: `cn` runs
// tailwind-merge, so the later `hover:bg-accent/40` replaces
// `hover:bg-muted/40` rather than stacking with it.
className={cn('border-t transition-colors hover:bg-muted/40', canDrill && 'cursor-pointer hover:bg-accent/40')}
data-testid={canDrill ? 'dataset-drill-row' : undefined}
onClick={canDrill ? () => 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.
<td key={c} className={cn('px-2 py-1 whitespace-nowrap tabular-nums', numericColumns.has(c) && 'text-right', canDrill && 'max-sm:h-11')}>
<td
key={c}
className={cn(
'px-2 py-1 whitespace-nowrap tabular-nums',
numericColumns.has(c) && 'text-right',
(lineDrills || cellDrills(undefined)) && 'max-sm:h-11',
cellDrill && 'cursor-pointer hover:bg-accent/40 max-sm:min-w-11',
)}
data-testid={cellDrill ? 'dataset-drill-cell' : undefined}
onClick={cellDrill ? () => 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)}
</td>
);
})}
</tr>
))}
);
})}
</tbody>
{/* The totals row lives in `tfoot`, OUTSIDE `orderedEntries`
entirely — not as a special case inside the sort. Three
Expand Down Expand Up @@ -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 `<measure>__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) => {
Expand Down Expand Up @@ -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 (
<div className={cn('relative h-full w-full min-h-[220px]')}>
{refreshBar}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
})),
});
Expand Down Expand Up @@ -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' }],
})),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const datasetOf = (rows: Array<Record<string, unknown>>, rawRows: Array<Record<s
],
object: 'crm_opportunity',
dimensionFields: { owner_name: 'owner_id' },
drillScopes: { deals: {} },
drillRawRows: rawRows,
})),
});
Expand Down Expand Up @@ -259,3 +260,64 @@ describe('DatasetWidget — a bucket drills to ITS OWN rows (objectui#4508)', ()
await waitFor(() => 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<string, Record<string, unknown>>) => ({
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<typeof invoices>) =>
render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false, resources: {} }}>
<DatasetWidget
widget={{ type: 'bar', dataset: 'invoices', dimensions: ['status'], values: ['invoice_count', 'paid_count'], filter: { region: 'emea' } }}
dataSource={src as any}
/>
</I18nProvider>,
);
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
});
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ const nullKeyedDataset = () => ({
],
object: 'crm_opportunity',
dimensionFields: { owner_name: 'owner_id' },
drillScopes: { deals: {} },
drillRawRows: [{ owner_name: 'user-ada' }, { owner_name: null }],
})),
});
Expand All @@ -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' }],
})),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ const valueKeyedChart = () =>
],
object: 'crm_opportunity',
dimensionFields: { competitor_name: 'competitor_name' },
drillScopes: { deals: {}, total: {} },
drillRawRows: [
{ competitor_name: 'orion' },
{ competitor_name: 'cabot' },
Expand All @@ -179,6 +180,7 @@ const serverResolvedChart = () =>
],
object: 'crm_opportunity',
dimensionFields: { competitor_name: 'competitor_name' },
drillScopes: { deals: {}, total: {} },
drillRawRows: [{ competitor_name: 'orion' }, { competitor_name: 'cabot' }],
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ const datasetOf = (rows: Array<Record<string, unknown>>) => ({
fields: FIELDS,
object: 'showcase_task',
dimensionFields: { status: 'status', priority: 'priority', area: 'area' },
drillScopes: { task_count: {} },
drillRawRows: rows,
})),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
});

Expand Down
Loading
Loading