Skip to content

Commit b188e5f

Browse files
authored
Merge pull request #830 from objectstack-ai/copilot/fix-dashboard-widget-issue
2 parents 2afa038 + 0747f70 commit b188e5f

File tree

5 files changed

+383
-4
lines changed

5 files changed

+383
-4
lines changed

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -845,7 +845,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
845845
- [x] **P1: Order ↔ Product Junction**`order_items` object with line items (quantity, price, discount, item_type) + 12 seed records
846846
- [x] **P1: Opportunity ↔ Contact Junction**`opportunity_contacts` object with role-based relationships + 7 seed records
847847
- [x] **P1: Contact ↔ Event Attendees**`participants` field populated on all event seed data
848-
- [x] **P2: Dashboard Dynamic Data** — "Revenue by Account" widget using `provider: 'object'` aggregation
848+
- [x] **P2: Dashboard Dynamic Data** — "Revenue by Account" widget using `provider: 'object'` aggregation. DashboardRenderer now delegates `provider: 'object'` widgets to ObjectChart (`type: 'object-chart'`) for async data loading + client-side aggregation (sum/count/avg/min/max)
849849
- [x] **P2: App Branding**`logo`, `favicon`, `backgroundColor` fields on CRM app
850850
- [x] **P3: Pages** — Settings page (utility) and Getting Started page (onboarding)
851851
- [x] **P2: Spec Compliance Audit** — Fixed `variant: 'danger'``'destructive'` (4 actions), `columns: string``number` (33 form sections), added `type: 'dashboard'` to dashboard

packages/plugin-charts/src/ObjectChart.tsx

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,51 @@ import { useDataScope, useSchemaContext } from '@object-ui/react';
44
import { ChartRenderer } from './ChartRenderer';
55
import { ComponentRegistry } from '@object-ui/core';
66

7+
/**
8+
* Client-side aggregation for fetched records.
9+
* Groups records by `groupBy` field and applies the aggregation function
10+
* to the `field` values in each group.
11+
*/
12+
export function aggregateRecords(
13+
records: any[],
14+
aggregate: { field: string; function: string; groupBy: string }
15+
): any[] {
16+
const { field, function: aggFn, groupBy } = aggregate;
17+
const groups: Record<string, any[]> = {};
18+
19+
for (const record of records) {
20+
const key = String(record[groupBy] ?? 'Unknown');
21+
if (!groups[key]) groups[key] = [];
22+
groups[key].push(record);
23+
}
24+
25+
return Object.entries(groups).map(([key, group]) => {
26+
const values = group.map(r => Number(r[field]) || 0);
27+
let result: number;
28+
29+
switch (aggFn) {
30+
case 'count':
31+
result = group.length;
32+
break;
33+
case 'avg':
34+
result = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
35+
break;
36+
case 'min':
37+
result = values.length > 0 ? Math.min(...values) : 0;
38+
break;
39+
case 'max':
40+
result = values.length > 0 ? Math.max(...values) : 0;
41+
break;
42+
case 'sum':
43+
default:
44+
result = values.reduce((a, b) => a + b, 0);
45+
break;
46+
}
47+
48+
return { [groupBy]: key, [field]: result };
49+
});
50+
}
51+
752
export const ObjectChart = (props: any) => {
853
const { schema } = props;
954
const context = useSchemaContext();
@@ -19,7 +64,6 @@ export const ObjectChart = (props: any) => {
1964
if (!dataSource || !schema.objectName) return;
2065
if (isMounted) setLoading(true);
2166
try {
22-
// Apply filtering?
2367
const results = await dataSource.find(schema.objectName, {
2468
$filter: schema.filter
2569
});
@@ -33,6 +77,11 @@ export const ObjectChart = (props: any) => {
3377
}
3478
}
3579

80+
// Apply client-side aggregation when aggregate config is provided
81+
if (schema.aggregate && data.length > 0) {
82+
data = aggregateRecords(data, schema.aggregate);
83+
}
84+
3685
if (isMounted) {
3786
setFetchedData(data);
3887
}
@@ -47,7 +96,7 @@ export const ObjectChart = (props: any) => {
4796
fetchData();
4897
}
4998
return () => { isMounted = false; };
50-
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter]);
99+
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, schema.aggregate]);
51100

52101
const finalData = boundData || schema.data || fetchedData || [];
53102

@@ -75,5 +124,6 @@ ComponentRegistry.register('object-chart', ObjectChart, {
75124
{ name: 'objectName', type: 'string', label: 'Object Name', required: true },
76125
{ name: 'data', type: 'array', label: 'Data', description: 'Optional static data' },
77126
{ name: 'filter', type: 'array', label: 'Filter' },
127+
{ name: 'aggregate', type: 'object', label: 'Aggregate', description: 'Aggregation config: { field, function, groupBy }' },
78128
]
79129
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { aggregateRecords } from '../ObjectChart';
11+
12+
describe('aggregateRecords', () => {
13+
const records = [
14+
{ account: 'Acme Corp', amount: 100 },
15+
{ account: 'Acme Corp', amount: 200 },
16+
{ account: 'Globex', amount: 150 },
17+
{ account: 'Globex', amount: 50 },
18+
{ account: 'Initech', amount: 300 },
19+
];
20+
21+
it('should aggregate using sum', () => {
22+
const result = aggregateRecords(records, {
23+
field: 'amount',
24+
function: 'sum',
25+
groupBy: 'account',
26+
});
27+
28+
expect(result).toHaveLength(3);
29+
expect(result.find(r => r.account === 'Acme Corp')?.amount).toBe(300);
30+
expect(result.find(r => r.account === 'Globex')?.amount).toBe(200);
31+
expect(result.find(r => r.account === 'Initech')?.amount).toBe(300);
32+
});
33+
34+
it('should aggregate using count', () => {
35+
const result = aggregateRecords(records, {
36+
field: 'amount',
37+
function: 'count',
38+
groupBy: 'account',
39+
});
40+
41+
expect(result).toHaveLength(3);
42+
expect(result.find(r => r.account === 'Acme Corp')?.amount).toBe(2);
43+
expect(result.find(r => r.account === 'Globex')?.amount).toBe(2);
44+
expect(result.find(r => r.account === 'Initech')?.amount).toBe(1);
45+
});
46+
47+
it('should aggregate using avg', () => {
48+
const result = aggregateRecords(records, {
49+
field: 'amount',
50+
function: 'avg',
51+
groupBy: 'account',
52+
});
53+
54+
expect(result).toHaveLength(3);
55+
expect(result.find(r => r.account === 'Acme Corp')?.amount).toBe(150);
56+
expect(result.find(r => r.account === 'Globex')?.amount).toBe(100);
57+
expect(result.find(r => r.account === 'Initech')?.amount).toBe(300);
58+
});
59+
60+
it('should aggregate using min', () => {
61+
const result = aggregateRecords(records, {
62+
field: 'amount',
63+
function: 'min',
64+
groupBy: 'account',
65+
});
66+
67+
expect(result).toHaveLength(3);
68+
expect(result.find(r => r.account === 'Acme Corp')?.amount).toBe(100);
69+
expect(result.find(r => r.account === 'Globex')?.amount).toBe(50);
70+
});
71+
72+
it('should aggregate using max', () => {
73+
const result = aggregateRecords(records, {
74+
field: 'amount',
75+
function: 'max',
76+
groupBy: 'account',
77+
});
78+
79+
expect(result).toHaveLength(3);
80+
expect(result.find(r => r.account === 'Acme Corp')?.amount).toBe(200);
81+
expect(result.find(r => r.account === 'Globex')?.amount).toBe(150);
82+
});
83+
84+
it('should handle records with missing groupBy field', () => {
85+
const input = [
86+
{ account: 'Acme', amount: 100 },
87+
{ amount: 200 }, // missing account
88+
];
89+
90+
const result = aggregateRecords(input, {
91+
field: 'amount',
92+
function: 'sum',
93+
groupBy: 'account',
94+
});
95+
96+
expect(result).toHaveLength(2);
97+
expect(result.find(r => r.account === 'Acme')?.amount).toBe(100);
98+
expect(result.find(r => r.account === 'Unknown')?.amount).toBe(200);
99+
});
100+
101+
it('should handle empty records', () => {
102+
const result = aggregateRecords([], {
103+
field: 'amount',
104+
function: 'sum',
105+
groupBy: 'account',
106+
});
107+
108+
expect(result).toEqual([]);
109+
});
110+
111+
it('should handle non-numeric values gracefully', () => {
112+
const input = [
113+
{ account: 'Acme', amount: 'not-a-number' },
114+
{ account: 'Acme', amount: 100 },
115+
];
116+
117+
const result = aggregateRecords(input, {
118+
field: 'amount',
119+
function: 'sum',
120+
groupBy: 'account',
121+
});
122+
123+
expect(result).toHaveLength(1);
124+
expect(result[0].amount).toBe(100); // non-numeric value coerced to 0, sum is 0 + 100
125+
});
126+
});

packages/plugin-dashboard/src/DashboardRenderer.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ const CHART_COLORS = [
2121
'hsl(var(--chart-5))',
2222
];
2323

24+
/** Returns true when the widget data config uses provider: 'object' (async data source). */
25+
function isObjectProvider(widgetData: unknown): widgetData is { provider: 'object'; object?: string; aggregate?: any } {
26+
return (
27+
widgetData != null &&
28+
typeof widgetData === 'object' &&
29+
!Array.isArray(widgetData) &&
30+
(widgetData as any).provider === 'object'
31+
);
32+
}
33+
2434
export interface DashboardRendererProps {
2535
schema: DashboardSchema;
2636
className?: string;
@@ -114,9 +124,24 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
114124
if (widgetType === 'bar' || widgetType === 'line' || widgetType === 'area' || widgetType === 'pie' || widgetType === 'donut') {
115125
// Support data at widget level or nested inside options
116126
const widgetData = (widget as any).data || options.data;
117-
const dataItems = Array.isArray(widgetData) ? widgetData : widgetData?.items || [];
118127
const xAxisKey = options.xField || 'name';
119128
const yField = options.yField || 'value';
129+
130+
// provider: 'object' — delegate to ObjectChart for async data loading
131+
if (isObjectProvider(widgetData)) {
132+
return {
133+
type: 'object-chart',
134+
chartType: widgetType,
135+
objectName: widgetData.object || widget.object,
136+
aggregate: widgetData.aggregate,
137+
xAxisKey: xAxisKey,
138+
series: [{ dataKey: yField }],
139+
colors: CHART_COLORS,
140+
className: "h-[200px] sm:h-[250px] md:h-[300px]"
141+
};
142+
}
143+
144+
const dataItems = Array.isArray(widgetData) ? widgetData : widgetData?.items || [];
120145

121146
return {
122147
type: 'chart',
@@ -132,6 +157,20 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
132157
if (widgetType === 'table') {
133158
// Support data at widget level or nested inside options
134159
const widgetData = (widget as any).data || options.data;
160+
161+
// provider: 'object' — pass through object config for async data loading
162+
if (isObjectProvider(widgetData)) {
163+
return {
164+
type: 'data-table',
165+
...options,
166+
objectName: widgetData.object || widget.object,
167+
dataProvider: widgetData,
168+
searchable: false,
169+
pagination: false,
170+
className: "border-0"
171+
};
172+
}
173+
135174
return {
136175
type: 'data-table',
137176
...options,
@@ -144,6 +183,17 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
144183

145184
if (widgetType === 'pivot') {
146185
const widgetData = (widget as any).data || options.data;
186+
187+
// provider: 'object' — pass through object config for async data loading
188+
if (isObjectProvider(widgetData)) {
189+
return {
190+
type: 'pivot',
191+
...options,
192+
objectName: widgetData.object || widget.object,
193+
dataProvider: widgetData,
194+
};
195+
}
196+
147197
return {
148198
type: 'pivot',
149199
...options,

0 commit comments

Comments
 (0)