@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.
- package/.turbo/turbo-build.log +7 -7
- package/CHANGELOG.md +9 -0
- package/dist/{AdvancedChartImpl-BPJDgZhN.js → AdvancedChartImpl-D5NQFQLZ.js} +948 -920
- package/dist/{ChartImpl-C-IeuOgj.js → ChartImpl-WXTkPN08.js} +1 -1
- package/dist/{index-B49zCCfG.js → index-xUWSanB8.js} +249 -202
- package/dist/index.js +1 -1
- package/dist/index.umd.cjs +21 -21
- package/dist/src/AdvancedChartImpl.d.ts +3 -2
- package/dist/src/ChartRenderer.d.ts +1 -1
- package/dist/src/ObjectChart.d.ts +11 -0
- package/package.json +6 -6
- package/src/AdvancedChartImpl.tsx +44 -3
- package/src/ChartRenderer.tsx +2 -2
- package/src/ObjectChart.tsx +87 -21
- package/src/__tests__/ObjectChart.aggregation.test.ts +166 -0
- package/src/__tests__/ObjectChart.dataFetch.test.tsx +303 -0
|
@@ -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
|
|
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
|
|
29
|
-
"@object-ui/core": "3.0
|
|
30
|
-
"@object-ui/react": "3.0
|
|
31
|
-
"@object-ui/types": "3.0
|
|
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.
|
|
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}>
|
package/src/ChartRenderer.tsx
CHANGED
|
@@ -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,
|
package/src/ObjectChart.tsx
CHANGED
|
@@ -1,13 +1,61 @@
|
|
|
1
1
|
|
|
2
|
-
import React, { useState, useEffect } from 'react';
|
|
3
|
-
import { useDataScope,
|
|
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 =
|
|
10
|
-
const dataSource = props.dataSource || context
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
});
|