@object-ui/plugin-charts 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,12 @@
1
1
  import { ChartConfig } from './ChartContainerImpl';
2
2
  export interface AdvancedChartImplProps {
3
- chartType?: 'bar' | 'line' | 'area' | 'pie' | 'donut' | 'radar' | 'scatter';
3
+ chartType?: 'bar' | 'line' | 'area' | 'pie' | 'donut' | 'radar' | 'scatter' | 'combo';
4
4
  data?: Array<Record<string, any>>;
5
5
  config?: ChartConfig;
6
6
  xAxisKey?: string;
7
7
  series?: Array<{
8
8
  dataKey: string;
9
+ chartType?: 'bar' | 'line' | 'area';
9
10
  }>;
10
11
  className?: string;
11
12
  }
@@ -13,4 +14,4 @@ export interface AdvancedChartImplProps {
13
14
  * AdvancedChartImpl - The heavy implementation that imports Recharts with full features
14
15
  * This component is lazy-loaded to avoid including Recharts in the initial bundle
15
16
  */
16
- export default function AdvancedChartImpl({ chartType, data, config, xAxisKey, series, className, }: AdvancedChartImplProps): import("react/jsx-runtime").JSX.Element;
17
+ export default function AdvancedChartImpl({ chartType, data: rawData, config, xAxisKey, series, className, }: AdvancedChartImplProps): import("react/jsx-runtime").JSX.Element;
@@ -20,7 +20,7 @@ export interface ChartRendererProps {
20
20
  type: string;
21
21
  id?: string;
22
22
  className?: string;
23
- chartType?: 'bar' | 'line' | 'area';
23
+ chartType?: 'bar' | 'line' | 'area' | 'pie' | 'donut' | 'radar' | 'scatter' | 'combo';
24
24
  data?: Array<Record<string, any>>;
25
25
  config?: Record<string, any>;
26
26
  xAxisKey?: string;
@@ -1 +1,12 @@
1
+ /**
2
+ * Client-side aggregation for fetched records.
3
+ * Groups records by `groupBy` field and applies the aggregation function
4
+ * to the `field` values in each group.
5
+ */
6
+ export declare function aggregateRecords(records: any[], aggregate: {
7
+ field: string;
8
+ function: string;
9
+ groupBy: string;
10
+ }): any[];
11
+ export { extractRecords } from '../../core/src';
1
12
  export declare const ObjectChart: (props: any) => import("react/jsx-runtime").JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/plugin-charts",
3
- "version": "3.0.2",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Chart components plugin for Object UI, powered by Recharts",
@@ -25,17 +25,17 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "recharts": "^3.7.0",
28
- "@object-ui/components": "3.0.2",
29
- "@object-ui/core": "3.0.2",
30
- "@object-ui/react": "3.0.2",
31
- "@object-ui/types": "3.0.2"
28
+ "@object-ui/components": "3.1.0",
29
+ "@object-ui/core": "3.1.0",
30
+ "@object-ui/react": "3.1.0",
31
+ "@object-ui/types": "3.1.0"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "react": "^18.0.0 || ^19.0.0",
35
35
  "react-dom": "^18.0.0 || ^19.0.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@types/react": "19.2.13",
38
+ "@types/react": "19.2.14",
39
39
  "@types/react-dom": "19.2.3",
40
40
  "@vitejs/plugin-react": "^5.1.4",
41
41
  "typescript": "^5.9.3",
@@ -70,11 +70,11 @@ const TW_COLORS: Record<string, string> = {
70
70
  const resolveColor = (color: string) => TW_COLORS[color] || color;
71
71
 
72
72
  export interface AdvancedChartImplProps {
73
- chartType?: 'bar' | 'line' | 'area' | 'pie' | 'donut' | 'radar' | 'scatter';
73
+ chartType?: 'bar' | 'line' | 'area' | 'pie' | 'donut' | 'radar' | 'scatter' | 'combo';
74
74
  data?: Array<Record<string, any>>;
75
75
  config?: ChartConfig;
76
76
  xAxisKey?: string;
77
- series?: Array<{ dataKey: string }>;
77
+ series?: Array<{ dataKey: string; chartType?: 'bar' | 'line' | 'area' }>;
78
78
  className?: string;
79
79
  }
80
80
 
@@ -84,12 +84,13 @@ export interface AdvancedChartImplProps {
84
84
  */
85
85
  export default function AdvancedChartImpl({
86
86
  chartType = 'bar',
87
- data = [],
87
+ data: rawData = [],
88
88
  config = {},
89
89
  xAxisKey = 'name',
90
90
  series = [],
91
91
  className = '',
92
92
  }: AdvancedChartImplProps) {
93
+ const data = Array.isArray(rawData) ? rawData : [];
93
94
  const [isMobile, setIsMobile] = React.useState(false);
94
95
 
95
96
  React.useEffect(() => {
@@ -106,6 +107,7 @@ export default function AdvancedChartImpl({
106
107
  donut: PieChart,
107
108
  radar: RadarChart,
108
109
  scatter: ScatterChart,
110
+ combo: BarChart,
109
111
  }[chartType] || BarChart;
110
112
 
111
113
  console.log('📈 Rendering Chart:', { chartType, dataLength: data.length, config, series, xAxisKey });
@@ -231,6 +233,45 @@ export default function AdvancedChartImpl({
231
233
  );
232
234
  }
233
235
 
236
+ // Combo chart (mixed bar + line on same chart)
237
+ if (chartType === 'combo') {
238
+ return (
239
+ <ChartContainer config={config} className={className}>
240
+ <BarChart data={data}>
241
+ <CartesianGrid vertical={false} />
242
+ <XAxis
243
+ dataKey={xAxisKey}
244
+ tickLine={false}
245
+ tickMargin={10}
246
+ axisLine={false}
247
+ interval={isMobile ? Math.ceil(data.length / 5) : 0}
248
+ tickFormatter={(value) => (value && typeof value === 'string') ? value.slice(0, 3) : value}
249
+ />
250
+ <YAxis yAxisId="left" tickLine={false} axisLine={false} />
251
+ <YAxis yAxisId="right" orientation="right" tickLine={false} axisLine={false} />
252
+ <ChartTooltip content={<ChartTooltipContent />} />
253
+ <ChartLegend
254
+ content={<ChartLegendContent />}
255
+ {...(isMobile && { verticalAlign: "bottom", wrapperStyle: { fontSize: '11px', paddingTop: '8px' } })}
256
+ />
257
+ {series.map((s: any, index: number) => {
258
+ const color = resolveColor(config[s.dataKey]?.color || DEFAULT_CHART_COLOR);
259
+ const seriesType = s.chartType || (index === 0 ? 'bar' : 'line');
260
+ const yAxisId = seriesType === 'bar' ? 'left' : 'right';
261
+
262
+ if (seriesType === 'line') {
263
+ return <Line key={s.dataKey} yAxisId={yAxisId} type="monotone" dataKey={s.dataKey} stroke={color} strokeWidth={2} dot={false} />;
264
+ }
265
+ if (seriesType === 'area') {
266
+ return <Area key={s.dataKey} yAxisId={yAxisId} type="monotone" dataKey={s.dataKey} fill={color} stroke={color} fillOpacity={0.4} />;
267
+ }
268
+ return <Bar key={s.dataKey} yAxisId={yAxisId} dataKey={s.dataKey} fill={color} radius={4} />;
269
+ })}
270
+ </BarChart>
271
+ </ChartContainer>
272
+ );
273
+ }
274
+
234
275
  return (
235
276
  <ChartContainer config={config} className={className}>
236
277
  <ChartComponent data={data}>
@@ -43,7 +43,7 @@ export interface ChartRendererProps {
43
43
  type: string;
44
44
  id?: string;
45
45
  className?: string;
46
- chartType?: 'bar' | 'line' | 'area';
46
+ chartType?: 'bar' | 'line' | 'area' | 'pie' | 'donut' | 'radar' | 'scatter' | 'combo';
47
47
  data?: Array<Record<string, any>>;
48
48
  config?: Record<string, any>;
49
49
  xAxisKey?: string;
@@ -89,7 +89,7 @@ export const ChartRenderer: React.FC<ChartRendererProps> = ({ schema }) => {
89
89
 
90
90
  return {
91
91
  chartType: schema.chartType,
92
- data: schema.data,
92
+ data: Array.isArray(schema.data) ? schema.data : [],
93
93
  config,
94
94
  xAxisKey,
95
95
  series,
@@ -1,13 +1,61 @@
1
1
 
2
- import React, { useState, useEffect } from 'react';
3
- import { useDataScope, useSchemaContext } from '@object-ui/react';
2
+ import React, { useState, useEffect, useContext } from 'react';
3
+ import { useDataScope, SchemaRendererContext } from '@object-ui/react';
4
4
  import { ChartRenderer } from './ChartRenderer';
5
- import { ComponentRegistry } from '@object-ui/core';
5
+ import { ComponentRegistry, extractRecords } from '@object-ui/core';
6
+
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
+
52
+ // Re-export extractRecords from @object-ui/core for backward compatibility
53
+ export { extractRecords } from '@object-ui/core';
6
54
 
7
55
  export const ObjectChart = (props: any) => {
8
56
  const { schema } = props;
9
- const context = useSchemaContext();
10
- const dataSource = props.dataSource || context.dataSource;
57
+ const context = useContext(SchemaRendererContext);
58
+ const dataSource = props.dataSource || context?.dataSource;
11
59
  const boundData = useDataScope(schema.bind);
12
60
 
13
61
  const [fetchedData, setFetchedData] = useState<any[]>([]);
@@ -19,18 +67,32 @@ export const ObjectChart = (props: any) => {
19
67
  if (!dataSource || !schema.objectName) return;
20
68
  if (isMounted) setLoading(true);
21
69
  try {
22
- // Apply filtering?
23
- const results = await dataSource.find(schema.objectName, {
24
- $filter: schema.filter
25
- });
26
-
27
- let data: any[] = [];
28
- if (Array.isArray(results)) {
29
- data = results;
30
- } else if (results && typeof results === 'object') {
31
- if (Array.isArray((results as any).records)) {
32
- data = (results as any).records;
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);
33
93
  }
94
+ } else {
95
+ return;
34
96
  }
35
97
 
36
98
  if (isMounted) {
@@ -47,9 +109,10 @@ export const ObjectChart = (props: any) => {
47
109
  fetchData();
48
110
  }
49
111
  return () => { isMounted = false; };
50
- }, [schema.objectName, dataSource, boundData, schema.data, schema.filter]);
112
+ }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, schema.aggregate]);
51
113
 
52
- const finalData = boundData || schema.data || fetchedData || [];
114
+ const rawData = boundData || schema.data || fetchedData;
115
+ const finalData = Array.isArray(rawData) ? rawData : [];
53
116
 
54
117
  // Merge data if not provided in schema
55
118
  const finalSchema = {
@@ -58,9 +121,11 @@ export const ObjectChart = (props: any) => {
58
121
  };
59
122
 
60
123
  if (loading && finalData.length === 0) {
61
- // Return skeleton or loading state?
62
- // ChartRenderer has suspense/skeleton handling but needs to be triggered.
63
- // We pass empty data but it might render empty chart.
124
+ return <div className={"flex items-center justify-center text-muted-foreground text-sm p-4 " + (schema.className || '')}>Loading chart data…</div>;
125
+ }
126
+
127
+ if (!dataSource && schema.objectName && finalData.length === 0) {
128
+ return <div className={"flex items-center justify-center text-muted-foreground text-sm p-4 " + (schema.className || '')}>No data source available for "{schema.objectName}"</div>;
64
129
  }
65
130
 
66
131
  return <ChartRenderer {...props} schema={finalSchema} />;
@@ -75,5 +140,6 @@ ComponentRegistry.register('object-chart', ObjectChart, {
75
140
  { name: 'objectName', type: 'string', label: 'Object Name', required: true },
76
141
  { name: 'data', type: 'array', label: 'Data', description: 'Optional static data' },
77
142
  { name: 'filter', type: 'array', label: 'Filter' },
143
+ { name: 'aggregate', type: 'object', label: 'Aggregate', description: 'Aggregation config: { field, function, groupBy }' },
78
144
  ]
79
145
  });
@@ -0,0 +1,166 @@
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, extractRecords } 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
+ });
127
+
128
+ describe('extractRecords', () => {
129
+ const sampleData = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
130
+
131
+ it('should return the array directly when results is an array', () => {
132
+ expect(extractRecords(sampleData)).toEqual(sampleData);
133
+ });
134
+
135
+ it('should extract from results.records', () => {
136
+ expect(extractRecords({ records: sampleData })).toEqual(sampleData);
137
+ });
138
+
139
+ it('should extract from results.data', () => {
140
+ expect(extractRecords({ data: sampleData })).toEqual(sampleData);
141
+ });
142
+
143
+ it('should extract from results.value', () => {
144
+ expect(extractRecords({ value: sampleData })).toEqual(sampleData);
145
+ });
146
+
147
+ it('should return empty array for null/undefined', () => {
148
+ expect(extractRecords(null)).toEqual([]);
149
+ expect(extractRecords(undefined)).toEqual([]);
150
+ });
151
+
152
+ it('should return empty array for non-array/non-object', () => {
153
+ expect(extractRecords('string')).toEqual([]);
154
+ expect(extractRecords(42)).toEqual([]);
155
+ });
156
+
157
+ it('should return empty array for object without recognized keys', () => {
158
+ expect(extractRecords({ total: 100 })).toEqual([]);
159
+ });
160
+
161
+ it('should prefer records over data and value', () => {
162
+ const records = [{ id: 1 }];
163
+ const data = [{ id: 2 }];
164
+ expect(extractRecords({ records, data })).toEqual(records);
165
+ });
166
+ });