Skip to content

Commit cb2fdb1

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(dashboard): expand drill-in — table/list row→record + scatter/treemap/sankey drill-through (#1859)
* feat(dashboard): drill-through for scatter/treemap/sankey + DrillDownConfig.mode Wire onClick handlers for the scatter, treemap and sankey chart families so they open the same filtered-record drill drawer the bar/line/pie/funnel charts already use. Add a `mode: 'filter' | 'record'` discriminator to DrillDownConfig to formalize drill-through (aggregate→list) vs drill-to-record (row→record), ahead of wiring table/list row drill. Also fixes a stale comment that claimed scatter/funnel had no click wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(dashboard): drill-to-record for table/list widgets Clicking a row in an object-backed table/list widget now opens that record in a detail drawer (Sheet) or dialog — the mainstream list-row → record interaction (Salesforce list views, Power BI 'see records'). Object-backed table/list widgets get this on by default; authors opt out with `drillDown:{enabled:false}`. - RecordDetailDrawer renders the clicked record read-only, reusing the table's field labels + cell formatting via a new shared `recordFields` module (so a value reads identically in the cell and the drawer). - ObjectDataTable refactored onto the shared helpers and wires onRowClick when drill is enabled; an author-supplied onRowClick still wins. - Completes the drill chain: the chart/KPI drill-through record lists now drill into a record too (rendered as a dialog so it stacks over the drawer). - vitest: exclude in-repo `.wt-*` git worktrees so sibling-branch test copies don't get globbed into runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(dashboard): lock chart-drill payload mappers + drill-type registry Extract the scatter/treemap/sankey Recharts-click → drill-event mapping into pure, unit-tested functions (chartDrillEvents) and add a registry guard for supportsDrillDown covering the newly drillable types. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(changeset): dashboard drill-in expansion Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bd8b054 commit cb2fdb1

16 files changed

Lines changed: 938 additions & 104 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@object-ui/types": patch
3+
"@object-ui/plugin-dashboard": patch
4+
"@object-ui/plugin-charts": patch
5+
---
6+
7+
feat(dashboard): expand drill-in — table/list row→record + scatter/treemap/sankey drill-through
8+
9+
Drill-in now covers the widgets that were missing it, and formalizes the two
10+
interaction semantics mainstream BI/low-code platforms separate. `DrillDownConfig`
11+
gains a `mode` discriminator: `'filter'` (drill-through: aggregate bucket → filtered
12+
record list) and `'record'` (drill-to-record: a table/list row → that record's detail).
13+
14+
- Scatter, treemap and sankey charts now wire click → the existing filtered-record
15+
drill drawer (radar excluded — no single clickable category point). The
16+
Recharts-payload → drill-event mapping is extracted to pure, tested functions.
17+
- Object-backed table/list widgets drill to the clicked record in a read-only detail
18+
drawer (Sheet/Dialog), on by default (`drillDown:{enabled:false}` opts out). Field
19+
labels and value formatting (incl. tenant-default currency) are shared with the
20+
table cells so a value reads identically in both. An author-supplied `onRowClick`
21+
still wins.
22+
- The chart/KPI drill-through record lists now drill into a record too, completing the
23+
segment → list → record chain.

‎packages/plugin-charts/src/AdvancedChartImpl.tsx‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
ChartLegendContent,
4444
ChartConfig
4545
} from './ChartContainerImpl';
46+
import { mapScatterClick, mapTreemapClick, mapSankeyClick } from './chartDrillEvents';
4647

4748
// Default color fallback for chart series
4849
const DEFAULT_CHART_COLOR = 'hsl(var(--primary))';
@@ -148,6 +149,32 @@ export default function AdvancedChartImpl({
148149
const cartesianClickProps = onChartClick ? { onClick: handleCartesianClick, style: { cursor: 'pointer' as const } } : {};
149150
const pieClickProps = onChartClick ? { onClick: handlePieClick, style: { cursor: 'pointer' as const } } : {};
150151

152+
// Scatter / treemap / sankey element clicks map to the same
153+
// { category, series, value } drill event via pure mappers (see
154+
// chartDrillEvents). Each returns null for non-drillable targets (e.g. a
155+
// sankey link or root node), in which case we don't fire.
156+
const handleScatterClick = React.useCallback((node: any) => {
157+
if (!onChartClick) return;
158+
const ev = mapScatterClick(node, xAxisKey, series);
159+
if (ev) onChartClick(ev);
160+
}, [onChartClick, xAxisKey, series]);
161+
162+
const handleTreemapClick = React.useCallback((node: any) => {
163+
if (!onChartClick) return;
164+
const ev = mapTreemapClick(node, series);
165+
if (ev) onChartClick(ev);
166+
}, [onChartClick, series]);
167+
168+
const handleSankeyClick = React.useCallback((payload: any) => {
169+
if (!onChartClick) return;
170+
const ev = mapSankeyClick(payload, series);
171+
if (ev) onChartClick(ev);
172+
}, [onChartClick, series]);
173+
174+
const scatterClickProps = onChartClick ? { onClick: handleScatterClick, cursor: 'pointer' } : {};
175+
const treemapClickProps = onChartClick ? { onClick: handleTreemapClick, style: { cursor: 'pointer' as const } } : {};
176+
const sankeyClickProps = onChartClick ? { onClick: handleSankeyClick, style: { cursor: 'pointer' as const } } : {};
177+
151178
React.useEffect(() => {
152179
const checkMobile = () => setIsMobile(window.innerWidth < 640);
153180
checkMobile();
@@ -371,7 +398,7 @@ export default function AdvancedChartImpl({
371398
};
372399
return (
373400
<ChartContainer config={config} className={className}>
374-
<Treemap data={tmData} dataKey="size" nameKey="name" isAnimationActive content={<TreemapCell />}>
401+
<Treemap data={tmData} dataKey="size" nameKey="name" isAnimationActive content={<TreemapCell />} {...treemapClickProps}>
375402
<Tooltip />
376403
</Treemap>
377404
</ChartContainer>
@@ -397,6 +424,7 @@ export default function AdvancedChartImpl({
397424
nodePadding={24}
398425
link={{ stroke: 'hsl(var(--muted-foreground))', strokeOpacity: 0.25 }}
399426
node={{ fill: 'hsl(var(--chart-1))' } as any}
427+
{...sankeyClickProps}
400428
>
401429
<Tooltip />
402430
</Sankey>
@@ -474,6 +502,7 @@ export default function AdvancedChartImpl({
474502
data={data}
475503
fill={color}
476504
fillOpacity={cmp?.fillOpacity}
505+
{...scatterClickProps}
477506
/>
478507
);
479508
})}

‎packages/plugin-charts/src/ObjectChart.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,9 @@ export const ObjectChart = (props: any) => {
606606
pagination: true,
607607
pageSize: drillDown?.maxRows,
608608
columns: drillDown?.columns?.map((c: string) => ({ accessorKey: c, header: c })),
609+
// Complete the drill chain: a row in the filtered list opens that record.
610+
// Rendered as a dialog so it stacks cleanly over this drill drawer.
611+
drillDown: { enabled: true, mode: 'record' as const, target: 'dialog' as const },
609612
};
610613
const body = (
611614
<div className="overflow-auto" data-testid="chart-drill-body">
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* Unit tests for the scatter / treemap / sankey drill-event mappers. These lock
6+
* the Recharts-payload → { category, series, value } contract that ObjectChart's
7+
* drill drawer depends on.
8+
*/
9+
import { describe, it, expect } from 'vitest';
10+
import { mapScatterClick, mapTreemapClick, mapSankeyClick } from '../chartDrillEvents';
11+
12+
const series = [{ dataKey: 'sales' }];
13+
14+
describe('mapScatterClick', () => {
15+
it('reads category (xAxisKey) and value from node.payload', () => {
16+
const node = { payload: { region: 'West', sales: 4200 } };
17+
expect(mapScatterClick(node, 'region', series)).toEqual({
18+
category: 'West',
19+
series: 'sales',
20+
value: 4200,
21+
});
22+
});
23+
24+
it('falls back to top-level node fields when no payload', () => {
25+
const node = { region: 'East', sales: 9 };
26+
expect(mapScatterClick(node, 'region', series)).toEqual({
27+
category: 'East',
28+
series: 'sales',
29+
value: 9,
30+
});
31+
});
32+
33+
it('coerces a non-string category and omits a non-numeric value', () => {
34+
const node = { payload: { region: 2025, sales: 'n/a' } };
35+
expect(mapScatterClick(node, 'region', series)).toEqual({
36+
category: '2025',
37+
series: 'sales',
38+
value: undefined,
39+
});
40+
});
41+
42+
it('returns null when there is no category', () => {
43+
expect(mapScatterClick({ payload: { sales: 1 } }, 'region', series)).toBeNull();
44+
expect(mapScatterClick(null, 'region', series)).toBeNull();
45+
});
46+
47+
it('defaults the series key to "value"', () => {
48+
expect(mapScatterClick({ x: 'A', value: 3 }, 'x', undefined)?.series).toBe('value');
49+
});
50+
});
51+
52+
describe('mapTreemapClick', () => {
53+
it('maps tile name + value', () => {
54+
expect(mapTreemapClick({ name: 'Tech', value: 1200 }, series)).toEqual({
55+
category: 'Tech',
56+
series: 'sales',
57+
value: 1200,
58+
});
59+
});
60+
61+
it('falls back to size, then payload.name', () => {
62+
expect(mapTreemapClick({ name: 'A', size: 7 }, series)?.value).toBe(7);
63+
expect(mapTreemapClick({ payload: { name: 'B' } }, series)?.category).toBe('B');
64+
});
65+
66+
it('returns null without a name', () => {
67+
expect(mapTreemapClick({ value: 1 }, series)).toBeNull();
68+
expect(mapTreemapClick(null, series)).toBeNull();
69+
});
70+
});
71+
72+
describe('mapSankeyClick', () => {
73+
it('drills on a category node', () => {
74+
expect(mapSankeyClick({ payload: { name: 'Web', depth: 1, value: 500 } }, series)).toEqual({
75+
category: 'Web',
76+
series: 'sales',
77+
value: 500,
78+
});
79+
});
80+
81+
it('ignores the synthetic root node (depth 0)', () => {
82+
expect(mapSankeyClick({ payload: { name: 'Total', depth: 0 } }, series)).toBeNull();
83+
});
84+
85+
it('ignores links (no name)', () => {
86+
expect(mapSankeyClick({ payload: { source: 0, target: 1, value: 5 } }, series)).toBeNull();
87+
expect(mapSankeyClick(null, series)).toBeNull();
88+
});
89+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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+
* Pure mappers from a Recharts element-click payload to the shared
9+
* `{ category, series, value }` drill event consumed by ObjectChart's drill
10+
* drawer. Kept separate from AdvancedChartImpl so the (Recharts-shape-sensitive)
11+
* payload extraction can be unit-tested without rendering a chart.
12+
*/
13+
14+
export interface ChartDrillEvent {
15+
category?: string;
16+
series?: string;
17+
value?: number;
18+
}
19+
20+
interface SeriesLike {
21+
dataKey?: string;
22+
}
23+
24+
function seriesKey(series: SeriesLike[] | undefined): string {
25+
return series?.[0]?.dataKey || 'value';
26+
}
27+
28+
/**
29+
* Scatter point click. Recharts spreads the data row onto the node and also
30+
* exposes it under `.payload`; we read the category (xAxisKey) and the
31+
* y-measure from whichever is present. Returns `null` when there's no category.
32+
*/
33+
export function mapScatterClick(
34+
node: any,
35+
xAxisKey: string,
36+
series: SeriesLike[] | undefined,
37+
): ChartDrillEvent | null {
38+
if (!node) return null;
39+
const row = node.payload ?? node;
40+
const cat = row?.[xAxisKey];
41+
if (cat == null) return null;
42+
const dk = seriesKey(series);
43+
return {
44+
category: String(cat),
45+
series: dk,
46+
value: typeof row?.[dk] === 'number' ? row[dk] : undefined,
47+
};
48+
}
49+
50+
/**
51+
* Treemap tile click. The node carries the category label as `name` and the
52+
* measure as `value`/`size`. Returns `null` when there's no name.
53+
*/
54+
export function mapTreemapClick(
55+
node: any,
56+
series: SeriesLike[] | undefined,
57+
): ChartDrillEvent | null {
58+
if (!node) return null;
59+
const name = node.name ?? node.payload?.name ?? node.root?.name;
60+
if (name == null) return null;
61+
const val = node.value ?? node.size ?? node.payload?.size;
62+
return {
63+
category: String(name),
64+
series: seriesKey(series),
65+
value: typeof val === 'number' ? val : undefined,
66+
};
67+
}
68+
69+
/**
70+
* Sankey click. Drill only on flow *nodes* (categories): links carry
71+
* source/target indices but no `name`, and the synthetic root node (depth 0)
72+
* aggregates everything. Returns `null` for those so they don't drill.
73+
*/
74+
export function mapSankeyClick(
75+
payload: any,
76+
series: SeriesLike[] | undefined,
77+
): ChartDrillEvent | null {
78+
if (!payload) return null;
79+
const node = payload.payload ?? payload;
80+
const name = node?.name;
81+
if (name == null || node?.depth === 0) return null;
82+
return {
83+
category: String(name),
84+
series: seriesKey(series),
85+
value: typeof node?.value === 'number' ? node.value : undefined,
86+
};
87+
}

‎packages/plugin-dashboard/src/DashboardRenderer.tsx‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,11 +150,13 @@ const UNSUPPORTED_CHART_TYPES = new Set([
150150
/**
151151
* Chart sub-types that have a meaningful drill-down interaction.
152152
* Mirrors WidgetConfigPanel.DRILL_DOWN_TYPES and the click-handler wiring
153-
* in plugin-charts/AdvancedChartImpl. Scatter and funnel are excluded
154-
* because no onChartClick is wired for them today.
153+
* in plugin-charts/AdvancedChartImpl. Radar is excluded because a polar
154+
* polygon has no single clickable category point (the interaction would be
155+
* ambiguous); every cartesian / part-to-whole / flow family is covered.
155156
*/
156157
const DRILLABLE_CHART_TYPES = new Set([
157158
'bar', 'horizontal-bar', 'line', 'area', 'pie', 'donut', 'funnel',
159+
'scatter', 'treemap', 'sankey',
158160
]);
159161

160162
/**
@@ -584,6 +586,14 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
584586
// Support data at widget level or nested inside options
585587
const widgetData = (widget as any).data || options.data;
586588

589+
// Default-on drill-to-record for object-backed tables: clicking a
590+
// row opens that record in a detail drawer. Static (data-array)
591+
// tables stay opt-in (no object to fetch the record's schema from).
592+
const isObjectTable = isObjectProvider(widgetData) || (!widgetData && !!widget.object);
593+
const tableDrill = isObjectTable
594+
? (options.drillDown ?? { enabled: true, mode: 'record' as const })
595+
: undefined;
596+
587597
// provider: 'object' — use ObjectDataTable for async data loading
588598
if (isObjectProvider(widgetData)) {
589599
const { data: _data, ...restOptions } = options;
@@ -595,6 +605,7 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
595605
filter: widgetData.filter || widget.filter,
596606
searchable: widget.searchable ?? false,
597607
pagination: widget.pagination ?? false,
608+
drillDown: tableDrill,
598609
className: "border-0"
599610
};
600611
}
@@ -608,6 +619,7 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
608619
filter: widget.filter,
609620
searchable: widget.searchable ?? false,
610621
pagination: widget.pagination ?? false,
622+
drillDown: tableDrill,
611623
className: "border-0"
612624
};
613625
}
@@ -686,6 +698,13 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
686698
if (widgetType === 'list') {
687699
const widgetData = (widget as any).data || options.data;
688700

701+
// Default-on drill-to-record for object-backed lists (same policy
702+
// as the table widget — a list row is a record).
703+
const isObjectList = isObjectProvider(widgetData) || (!widgetData && !!widget.object);
704+
const listDrill = isObjectList
705+
? (options.drillDown ?? { enabled: true, mode: 'record' as const })
706+
: undefined;
707+
689708
if (isObjectProvider(widgetData)) {
690709
const { data: _data, ...restOptions } = options;
691710
return {
@@ -696,6 +715,7 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
696715
filter: widgetData.filter || widget.filter,
697716
searchable: false,
698717
pagination: false,
718+
drillDown: listDrill,
699719
className: "border-0",
700720
};
701721
}
@@ -708,6 +728,7 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
708728
filter: widget.filter,
709729
searchable: false,
710730
pagination: false,
731+
drillDown: listDrill,
711732
className: "border-0",
712733
};
713734
}

0 commit comments

Comments
 (0)