Skip to content

Commit 7a455fb

Browse files
authored
Merge pull request #853 from objectstack-ai/copilot/fix-chart-widget-data-loading
2 parents 8a6c4f0 + d91f2e2 commit 7a455fb

File tree

9 files changed

+376
-10
lines changed

9 files changed

+376
-10
lines changed

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
943943
- [x] **P1: Dashboard provider:'object' Crash & Blank Rendering Fixes** — Fixed 3 critical bugs causing all charts to be blank and tables to crash on provider:'object' dashboards: (1) DashboardRenderer `...options` spread was leaking provider config objects as `data` in data-table and pivot schemas — fixed by destructuring `data` out before spread, (2) DataTableRenderer and PivotTable now guard with `Array.isArray()` for graceful degradation when non-array data arrives, (3) ObjectChart now shows visible loading/warning messages instead of silently rendering blank when `dataSource` is missing. Also added provider:'object' support to DashboardGridLayout (charts, tables, pivots). 2 new regression tests.
944944
- [x] **P1: Dashboard Widget Data Blank — useDataScope/dataSource Injection Fix** — Fixed root cause of dashboard widgets showing blank data with no server requests: `useDataScope(undefined)` was returning the full context `dataSource` (service adapter) instead of `undefined` when no bind path was given, causing ObjectChart and all data components (ObjectKanban, ObjectGallery, ObjectTimeline, ObjectGrid) to treat the adapter as pre-bound data and skip async fetching. Fixed `useDataScope` to return `undefined` when no path is provided. Also improved ObjectChart fault tolerance: uses `useContext` directly instead of `useSchemaContext` (no throw without provider), validates `dataSource.find` is callable before invoking. 14 new tests (7 useDataScope + 7 ObjectChart data fetch/fault tolerance).
945945
- [x] **P1: URL-Driven Debug/Developer Panel** — Universal debug mode activated via `?__debug` URL parameter (amis devtools-style). `@object-ui/core`: exported `DebugFlags`, `DebugCollector` (perf/expr/event data collection, tree-shakeable), `parseDebugFlags()`, enhanced `isDebugEnabled()` (URL → globalThis → env resolution, SSR-safe). `@object-ui/react`: `useDebugMode` hook with URL detection, Ctrl+Shift+D shortcut, manual toggle; `SchemaRendererContext` extended with `debugFlags`; `SchemaRenderer` injects `data-debug-type`/`data-debug-id` attrs + reports render perf to `DebugCollector` when debug enabled. `@object-ui/components`: floating `DebugPanel` with 7 built-in tabs (Schema, Data, Perf, Expr, Events, Registry, Flags), plugin-extensible via `extraTabs`. Console `MetadataInspector` auto-opens when `?__debug` detected. Fine-grained sub-flags: `?__debug_schema`, `?__debug_perf`, `?__debug_data`, `?__debug_expr`, `?__debug_events`, `?__debug_registry`. 48 new tests.
946+
- [x] **P1: Chart Widget Server-Side Aggregation** — Fixed chart widgets (bar/line/area/pie/donut/scatter) downloading all raw data and aggregating client-side. Added optional `aggregate()` method to `DataSource` interface (`AggregateParams`, `AggregateResult` types) enabling server-side grouping/aggregation via analytics API (e.g. `GET /api/v1/analytics/{resource}?category=…&metric=…&agg=…`). `ObjectChart` now prefers `dataSource.aggregate()` when available, falling back to `dataSource.find()` + client-side aggregation for backward compatibility. Implemented `aggregate()` in `ValueDataSource` (in-memory), `ApiDataSource` (HTTP), and `ObjectStackAdapter` (analytics API with client-side fallback). Only detail widgets (grid/table/list) continue to fetch full data. 9 new tests.
946947

947948
### Ecosystem & Marketplace
948949
- Plugin marketplace website with search, ratings, and install count

packages/core/src/adapters/ApiDataSource.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import type {
1515
QueryResult,
1616
HttpRequest,
1717
HttpMethod,
18+
AggregateParams,
19+
AggregateResult,
1820
} from '@object-ui/types';
1921

2022
// ---------------------------------------------------------------------------
@@ -297,6 +299,31 @@ export class ApiDataSource<T = any> implements DataSource<T> {
297299
return null;
298300
}
299301

302+
async aggregate(_resource: string, params: AggregateParams): Promise<AggregateResult[]> {
303+
const queryParams: Record<string, unknown> = {
304+
field: params.field,
305+
function: params.function,
306+
groupBy: params.groupBy,
307+
};
308+
if (params.filter) {
309+
queryParams.filter = typeof params.filter === 'string'
310+
? params.filter
311+
: JSON.stringify(params.filter);
312+
}
313+
314+
const raw = await this.request<any>(this.readConfig, {
315+
pathSuffix: 'aggregate',
316+
method: 'GET',
317+
queryParams,
318+
});
319+
320+
// Normalize: the API might return an array or an object with data/results
321+
if (Array.isArray(raw)) return raw;
322+
if (raw?.data && Array.isArray(raw.data)) return raw.data;
323+
if (raw?.results && Array.isArray(raw.results)) return raw.results;
324+
return [];
325+
}
326+
300327
// -----------------------------------------------------------------------
301328
// Helpers
302329
// -----------------------------------------------------------------------

packages/core/src/adapters/ValueDataSource.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import type {
1313
DataSource,
1414
QueryParams,
1515
QueryResult,
16+
AggregateParams,
17+
AggregateResult,
1618
} from '@object-ui/types';
1719

1820
// ---------------------------------------------------------------------------
@@ -383,6 +385,43 @@ export class ValueDataSource<T = any> implements DataSource<T> {
383385
return null;
384386
}
385387

388+
async aggregate(_resource: string, params: AggregateParams): Promise<AggregateResult[]> {
389+
const { field, function: aggFn, groupBy } = params;
390+
const groups: Record<string, any[]> = {};
391+
392+
for (const record of this.items as any[]) {
393+
const key = String(record[groupBy] ?? 'Unknown');
394+
if (!groups[key]) groups[key] = [];
395+
groups[key].push(record);
396+
}
397+
398+
return Object.entries(groups).map(([key, group]) => {
399+
const values = group.map(r => Number(r[field]) || 0);
400+
let result: number;
401+
402+
switch (aggFn) {
403+
case 'count':
404+
result = group.length;
405+
break;
406+
case 'avg':
407+
result = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
408+
break;
409+
case 'min':
410+
result = values.length > 0 ? Math.min(...values) : 0;
411+
break;
412+
case 'max':
413+
result = values.length > 0 ? Math.max(...values) : 0;
414+
break;
415+
case 'sum':
416+
default:
417+
result = values.reduce((a, b) => a + b, 0);
418+
break;
419+
}
420+
421+
return { [groupBy]: key, [field]: result };
422+
});
423+
}
424+
386425
// -----------------------------------------------------------------------
387426
// Extra utilities
388427
// -----------------------------------------------------------------------

packages/core/src/adapters/__tests__/ValueDataSource.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,60 @@ describe('ValueDataSource — isolation', () => {
413413
expect(ds.count).toBe(2);
414414
});
415415
});
416+
417+
// ---------------------------------------------------------------------------
418+
// aggregate
419+
// ---------------------------------------------------------------------------
420+
421+
describe('ValueDataSource — aggregate', () => {
422+
const aggData = [
423+
{ _id: '1', category: 'A', amount: 10 },
424+
{ _id: '2', category: 'A', amount: 20 },
425+
{ _id: '3', category: 'B', amount: 30 },
426+
{ _id: '4', category: 'B', amount: 40 },
427+
{ _id: '5', category: 'B', amount: 50 },
428+
];
429+
430+
function createAggDS() {
431+
return new ValueDataSource({ items: aggData });
432+
}
433+
434+
it('should compute sum aggregation', async () => {
435+
const ds = createAggDS();
436+
const result = await ds.aggregate('items', { field: 'amount', function: 'sum', groupBy: 'category' });
437+
expect(result).toHaveLength(2);
438+
const groupA = result.find((r: any) => r.category === 'A');
439+
const groupB = result.find((r: any) => r.category === 'B');
440+
expect(groupA?.amount).toBe(30);
441+
expect(groupB?.amount).toBe(120);
442+
});
443+
444+
it('should compute count aggregation', async () => {
445+
const ds = createAggDS();
446+
const result = await ds.aggregate('items', { field: 'amount', function: 'count', groupBy: 'category' });
447+
expect(result).toHaveLength(2);
448+
expect(result.find((r: any) => r.category === 'A')?.amount).toBe(2);
449+
expect(result.find((r: any) => r.category === 'B')?.amount).toBe(3);
450+
});
451+
452+
it('should compute avg aggregation', async () => {
453+
const ds = createAggDS();
454+
const result = await ds.aggregate('items', { field: 'amount', function: 'avg', groupBy: 'category' });
455+
expect(result.find((r: any) => r.category === 'A')?.amount).toBe(15);
456+
expect(result.find((r: any) => r.category === 'B')?.amount).toBe(40);
457+
});
458+
459+
it('should compute min aggregation', async () => {
460+
const ds = createAggDS();
461+
const result = await ds.aggregate('items', { field: 'amount', function: 'min', groupBy: 'category' });
462+
expect(result.find((r: any) => r.category === 'A')?.amount).toBe(10);
463+
expect(result.find((r: any) => r.category === 'B')?.amount).toBe(30);
464+
});
465+
466+
it('should compute max aggregation', async () => {
467+
const ds = createAggDS();
468+
const result = await ds.aggregate('items', { field: 'amount', function: 'max', groupBy: 'category' });
469+
expect(result.find((r: any) => r.category === 'A')?.amount).toBe(20);
470+
expect(result.find((r: any) => r.category === 'B')?.amount).toBe(50);
471+
});
472+
});

packages/data-objectstack/src/index.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,69 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
690690
}
691691
}
692692

693+
/**
694+
* Perform server-side aggregation via the ObjectStack analytics API.
695+
* Uses `this.client.analytics.query()` from @objectstack/client to leverage
696+
* the SDK's built-in auth, headers, and fetch configuration.
697+
* Falls back to client-side aggregation via find() if the analytics endpoint
698+
* is not available.
699+
*/
700+
async aggregate(resource: string, params: { field: string; function: string; groupBy: string; filter?: any }): Promise<any[]> {
701+
await this.connect();
702+
703+
try {
704+
const payload: Record<string, unknown> = {
705+
object: resource,
706+
measures: [{ field: params.field, function: params.function }],
707+
dimensions: [params.groupBy],
708+
};
709+
if (params.filter) {
710+
payload.filters = params.filter;
711+
}
712+
713+
const data = await this.client.analytics.query(payload);
714+
if (Array.isArray(data)) return data;
715+
if (data?.data && Array.isArray(data.data)) return data.data;
716+
if (data?.results && Array.isArray(data.results)) return data.results;
717+
return [];
718+
} catch {
719+
// If the analytics endpoint is not available, fall back to
720+
// find() + client-side aggregation
721+
const result = await this.find(resource as any);
722+
const records = result.data || [];
723+
if (records.length === 0) return [];
724+
725+
return this.aggregateClientSide(records, params);
726+
}
727+
}
728+
729+
/** Client-side aggregation fallback */
730+
private aggregateClientSide(records: any[], params: { field: string; function: string; groupBy: string }): any[] {
731+
const { field, function: aggFn, groupBy } = params;
732+
const groups: Record<string, any[]> = {};
733+
734+
for (const record of records) {
735+
const key = String(record[groupBy] ?? 'Unknown');
736+
if (!groups[key]) groups[key] = [];
737+
groups[key].push(record);
738+
}
739+
740+
return Object.entries(groups).map(([key, group]) => {
741+
const values = group.map(r => Number(r[field]) || 0);
742+
let result: number;
743+
744+
switch (aggFn) {
745+
case 'count': result = group.length; break;
746+
case 'avg': result = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; break;
747+
case 'min': result = values.length > 0 ? Math.min(...values) : 0; break;
748+
case 'max': result = values.length > 0 ? Math.max(...values) : 0; break;
749+
case 'sum': default: result = values.reduce((a, b) => a + b, 0); break;
750+
}
751+
752+
return { [groupBy]: key, [field]: result };
753+
});
754+
}
755+
693756
/**
694757
* Get multiple metadata items from ObjectStack.
695758
* Uses v3.0.0 metadata API pattern: getItems for batch retrieval.

packages/plugin-charts/src/ObjectChart.tsx

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,18 +64,35 @@ export const ObjectChart = (props: any) => {
6464
useEffect(() => {
6565
let isMounted = true;
6666
const fetchData = async () => {
67-
if (!dataSource || typeof dataSource.find !== 'function' || !schema.objectName) return;
67+
if (!dataSource || !schema.objectName) return;
6868
if (isMounted) setLoading(true);
6969
try {
70-
const results = await dataSource.find(schema.objectName, {
71-
$filter: schema.filter
72-
});
73-
74-
let data: any[] = extractRecords(results);
75-
76-
// Apply client-side aggregation when aggregate config is provided
77-
if (schema.aggregate && data.length > 0) {
78-
data = aggregateRecords(data, schema.aggregate);
70+
let data: any[];
71+
72+
// Prefer server-side aggregation when aggregate config is provided
73+
// and dataSource supports the aggregate() method.
74+
if (schema.aggregate && typeof dataSource.aggregate === 'function') {
75+
const results = await dataSource.aggregate(schema.objectName, {
76+
field: schema.aggregate.field,
77+
function: schema.aggregate.function,
78+
groupBy: schema.aggregate.groupBy,
79+
filter: schema.filter,
80+
});
81+
data = Array.isArray(results) ? results : [];
82+
} else if (typeof dataSource.find === 'function') {
83+
// Fallback: fetch all records and aggregate client-side
84+
const results = await dataSource.find(schema.objectName, {
85+
$filter: schema.filter
86+
});
87+
88+
data = extractRecords(results);
89+
90+
// Apply client-side aggregation when aggregate config is provided
91+
if (schema.aggregate && data.length > 0) {
92+
data = aggregateRecords(data, schema.aggregate);
93+
}
94+
} else {
95+
return;
7996
}
8097

8198
if (isMounted) {

0 commit comments

Comments
 (0)