Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/dashboard-drill-expansion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@object-ui/types": patch
"@object-ui/plugin-dashboard": patch
"@object-ui/plugin-charts": patch
---

feat(dashboard): expand drill-in — table/list row→record + scatter/treemap/sankey drill-through

Drill-in now covers the widgets that were missing it, and formalizes the two
interaction semantics mainstream BI/low-code platforms separate. `DrillDownConfig`
gains a `mode` discriminator: `'filter'` (drill-through: aggregate bucket → filtered
record list) and `'record'` (drill-to-record: a table/list row → that record's detail).

- Scatter, treemap and sankey charts now wire click → the existing filtered-record
drill drawer (radar excluded — no single clickable category point). The
Recharts-payload → drill-event mapping is extracted to pure, tested functions.
- Object-backed table/list widgets drill to the clicked record in a read-only detail
drawer (Sheet/Dialog), on by default (`drillDown:{enabled:false}` opts out). Field
labels and value formatting (incl. tenant-default currency) are shared with the
table cells so a value reads identically in both. An author-supplied `onRowClick`
still wins.
- The chart/KPI drill-through record lists now drill into a record too, completing the
segment → list → record chain.
31 changes: 30 additions & 1 deletion packages/plugin-charts/src/AdvancedChartImpl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
ChartLegendContent,
ChartConfig
} from './ChartContainerImpl';
import { mapScatterClick, mapTreemapClick, mapSankeyClick } from './chartDrillEvents';

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

// Scatter / treemap / sankey element clicks map to the same
// { category, series, value } drill event via pure mappers (see
// chartDrillEvents). Each returns null for non-drillable targets (e.g. a
// sankey link or root node), in which case we don't fire.
const handleScatterClick = React.useCallback((node: any) => {
if (!onChartClick) return;
const ev = mapScatterClick(node, xAxisKey, series);
if (ev) onChartClick(ev);
}, [onChartClick, xAxisKey, series]);

const handleTreemapClick = React.useCallback((node: any) => {
if (!onChartClick) return;
const ev = mapTreemapClick(node, series);
if (ev) onChartClick(ev);
}, [onChartClick, series]);

const handleSankeyClick = React.useCallback((payload: any) => {
if (!onChartClick) return;
const ev = mapSankeyClick(payload, series);
if (ev) onChartClick(ev);
}, [onChartClick, series]);

const scatterClickProps = onChartClick ? { onClick: handleScatterClick, cursor: 'pointer' } : {};
const treemapClickProps = onChartClick ? { onClick: handleTreemapClick, style: { cursor: 'pointer' as const } } : {};
const sankeyClickProps = onChartClick ? { onClick: handleSankeyClick, style: { cursor: 'pointer' as const } } : {};

React.useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 640);
checkMobile();
Expand Down Expand Up @@ -371,7 +398,7 @@ export default function AdvancedChartImpl({
};
return (
<ChartContainer config={config} className={className}>
<Treemap data={tmData} dataKey="size" nameKey="name" isAnimationActive content={<TreemapCell />}>
<Treemap data={tmData} dataKey="size" nameKey="name" isAnimationActive content={<TreemapCell />} {...treemapClickProps}>
<Tooltip />
</Treemap>
</ChartContainer>
Expand All @@ -397,6 +424,7 @@ export default function AdvancedChartImpl({
nodePadding={24}
link={{ stroke: 'hsl(var(--muted-foreground))', strokeOpacity: 0.25 }}
node={{ fill: 'hsl(var(--chart-1))' } as any}
{...sankeyClickProps}
>
<Tooltip />
</Sankey>
Expand Down Expand Up @@ -474,6 +502,7 @@ export default function AdvancedChartImpl({
data={data}
fill={color}
fillOpacity={cmp?.fillOpacity}
{...scatterClickProps}
/>
);
})}
Expand Down
3 changes: 3 additions & 0 deletions packages/plugin-charts/src/ObjectChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,9 @@ export const ObjectChart = (props: any) => {
pagination: true,
pageSize: drillDown?.maxRows,
columns: drillDown?.columns?.map((c: string) => ({ accessorKey: c, header: c })),
// Complete the drill chain: a row in the filtered list opens that record.
// Rendered as a dialog so it stacks cleanly over this drill drawer.
drillDown: { enabled: true, mode: 'record' as const, target: 'dialog' as const },
};
const body = (
<div className="overflow-auto" data-testid="chart-drill-body">
Expand Down
89 changes: 89 additions & 0 deletions packages/plugin-charts/src/__tests__/chartDrillEvents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* Unit tests for the scatter / treemap / sankey drill-event mappers. These lock
* the Recharts-payload → { category, series, value } contract that ObjectChart's
* drill drawer depends on.
*/
import { describe, it, expect } from 'vitest';
import { mapScatterClick, mapTreemapClick, mapSankeyClick } from '../chartDrillEvents';

const series = [{ dataKey: 'sales' }];

describe('mapScatterClick', () => {
it('reads category (xAxisKey) and value from node.payload', () => {
const node = { payload: { region: 'West', sales: 4200 } };
expect(mapScatterClick(node, 'region', series)).toEqual({
category: 'West',
series: 'sales',
value: 4200,
});
});

it('falls back to top-level node fields when no payload', () => {
const node = { region: 'East', sales: 9 };
expect(mapScatterClick(node, 'region', series)).toEqual({
category: 'East',
series: 'sales',
value: 9,
});
});

it('coerces a non-string category and omits a non-numeric value', () => {
const node = { payload: { region: 2025, sales: 'n/a' } };
expect(mapScatterClick(node, 'region', series)).toEqual({
category: '2025',
series: 'sales',
value: undefined,
});
});

it('returns null when there is no category', () => {
expect(mapScatterClick({ payload: { sales: 1 } }, 'region', series)).toBeNull();
expect(mapScatterClick(null, 'region', series)).toBeNull();
});

it('defaults the series key to "value"', () => {
expect(mapScatterClick({ x: 'A', value: 3 }, 'x', undefined)?.series).toBe('value');
});
});

describe('mapTreemapClick', () => {
it('maps tile name + value', () => {
expect(mapTreemapClick({ name: 'Tech', value: 1200 }, series)).toEqual({
category: 'Tech',
series: 'sales',
value: 1200,
});
});

it('falls back to size, then payload.name', () => {
expect(mapTreemapClick({ name: 'A', size: 7 }, series)?.value).toBe(7);
expect(mapTreemapClick({ payload: { name: 'B' } }, series)?.category).toBe('B');
});

it('returns null without a name', () => {
expect(mapTreemapClick({ value: 1 }, series)).toBeNull();
expect(mapTreemapClick(null, series)).toBeNull();
});
});

describe('mapSankeyClick', () => {
it('drills on a category node', () => {
expect(mapSankeyClick({ payload: { name: 'Web', depth: 1, value: 500 } }, series)).toEqual({
category: 'Web',
series: 'sales',
value: 500,
});
});

it('ignores the synthetic root node (depth 0)', () => {
expect(mapSankeyClick({ payload: { name: 'Total', depth: 0 } }, series)).toBeNull();
});

it('ignores links (no name)', () => {
expect(mapSankeyClick({ payload: { source: 0, target: 1, value: 5 } }, series)).toBeNull();
expect(mapSankeyClick(null, series)).toBeNull();
});
});
87 changes: 87 additions & 0 deletions packages/plugin-charts/src/chartDrillEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* 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.
*
* Pure mappers from a Recharts element-click payload to the shared
* `{ category, series, value }` drill event consumed by ObjectChart's drill
* drawer. Kept separate from AdvancedChartImpl so the (Recharts-shape-sensitive)
* payload extraction can be unit-tested without rendering a chart.
*/

export interface ChartDrillEvent {
category?: string;
series?: string;
value?: number;
}

interface SeriesLike {
dataKey?: string;
}

function seriesKey(series: SeriesLike[] | undefined): string {
return series?.[0]?.dataKey || 'value';
}

/**
* Scatter point click. Recharts spreads the data row onto the node and also
* exposes it under `.payload`; we read the category (xAxisKey) and the
* y-measure from whichever is present. Returns `null` when there's no category.
*/
export function mapScatterClick(
node: any,
xAxisKey: string,
series: SeriesLike[] | undefined,
): ChartDrillEvent | null {
if (!node) return null;
const row = node.payload ?? node;
const cat = row?.[xAxisKey];
if (cat == null) return null;
const dk = seriesKey(series);
return {
category: String(cat),
series: dk,
value: typeof row?.[dk] === 'number' ? row[dk] : undefined,
};
}

/**
* Treemap tile click. The node carries the category label as `name` and the
* measure as `value`/`size`. Returns `null` when there's no name.
*/
export function mapTreemapClick(
node: any,
series: SeriesLike[] | undefined,
): ChartDrillEvent | null {
if (!node) return null;
const name = node.name ?? node.payload?.name ?? node.root?.name;
if (name == null) return null;
const val = node.value ?? node.size ?? node.payload?.size;
return {
category: String(name),
series: seriesKey(series),
value: typeof val === 'number' ? val : undefined,
};
}

/**
* Sankey click. Drill only on flow *nodes* (categories): links carry
* source/target indices but no `name`, and the synthetic root node (depth 0)
* aggregates everything. Returns `null` for those so they don't drill.
*/
export function mapSankeyClick(
payload: any,
series: SeriesLike[] | undefined,
): ChartDrillEvent | null {
if (!payload) return null;
const node = payload.payload ?? payload;
const name = node?.name;
if (name == null || node?.depth === 0) return null;
return {
category: String(name),
series: seriesKey(series),
value: typeof node?.value === 'number' ? node.value : undefined,
};
}
25 changes: 23 additions & 2 deletions packages/plugin-dashboard/src/DashboardRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,13 @@ const UNSUPPORTED_CHART_TYPES = new Set([
/**
* Chart sub-types that have a meaningful drill-down interaction.
* Mirrors WidgetConfigPanel.DRILL_DOWN_TYPES and the click-handler wiring
* in plugin-charts/AdvancedChartImpl. Scatter and funnel are excluded
* because no onChartClick is wired for them today.
* in plugin-charts/AdvancedChartImpl. Radar is excluded because a polar
* polygon has no single clickable category point (the interaction would be
* ambiguous); every cartesian / part-to-whole / flow family is covered.
*/
const DRILLABLE_CHART_TYPES = new Set([
'bar', 'horizontal-bar', 'line', 'area', 'pie', 'donut', 'funnel',
'scatter', 'treemap', 'sankey',
]);

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

// Default-on drill-to-record for object-backed tables: clicking a
// row opens that record in a detail drawer. Static (data-array)
// tables stay opt-in (no object to fetch the record's schema from).
const isObjectTable = isObjectProvider(widgetData) || (!widgetData && !!widget.object);
const tableDrill = isObjectTable
? (options.drillDown ?? { enabled: true, mode: 'record' as const })
: undefined;

// provider: 'object' — use ObjectDataTable for async data loading
if (isObjectProvider(widgetData)) {
const { data: _data, ...restOptions } = options;
Expand All @@ -595,6 +605,7 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
filter: widgetData.filter || widget.filter,
searchable: widget.searchable ?? false,
pagination: widget.pagination ?? false,
drillDown: tableDrill,
className: "border-0"
};
}
Expand All @@ -608,6 +619,7 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
filter: widget.filter,
searchable: widget.searchable ?? false,
pagination: widget.pagination ?? false,
drillDown: tableDrill,
className: "border-0"
};
}
Expand Down Expand Up @@ -686,6 +698,13 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
if (widgetType === 'list') {
const widgetData = (widget as any).data || options.data;

// Default-on drill-to-record for object-backed lists (same policy
// as the table widget — a list row is a record).
const isObjectList = isObjectProvider(widgetData) || (!widgetData && !!widget.object);
const listDrill = isObjectList
? (options.drillDown ?? { enabled: true, mode: 'record' as const })
: undefined;

if (isObjectProvider(widgetData)) {
const { data: _data, ...restOptions } = options;
return {
Expand All @@ -696,6 +715,7 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
filter: widgetData.filter || widget.filter,
searchable: false,
pagination: false,
drillDown: listDrill,
className: "border-0",
};
}
Expand All @@ -708,6 +728,7 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
filter: widget.filter,
searchable: false,
pagination: false,
drillDown: listDrill,
className: "border-0",
};
}
Expand Down
Loading
Loading