@object-ui/plugin-charts 3.1.4 → 3.3.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 +11 -11
- package/CHANGELOG.md +27 -0
- package/dist/{AdvancedChartImpl-DmHTUUVD.js → AdvancedChartImpl-JDjuxIZW.js} +1201 -1198
- package/dist/{BarChart-XZkfLmcU.js → BarChart-Bvt5Se8Q.js} +3858 -3765
- package/dist/{ChartImpl-0VlpsMWG.js → ChartImpl-CQj8Kris.js} +5 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +206 -62
- package/dist/index.umd.cjs +19 -19
- package/dist/packages/plugin-charts/src/ObjectChart.d.ts +31 -0
- package/package.json +9 -8
- package/src/AdvancedChartImpl.tsx +17 -3
- package/src/ObjectChart.tsx +201 -48
- package/src/__tests__/ObjectChart.labelResolution.test.ts +329 -0
- package/vite.config.ts +1 -0
- package/dist/src/ObjectChart.d.ts +0 -12
- /package/dist/{src → packages/plugin-charts/src}/AdvancedChartImpl.d.ts +0 -0
- /package/dist/{src → packages/plugin-charts/src}/ChartContainerImpl.d.ts +0 -0
- /package/dist/{src → packages/plugin-charts/src}/ChartImpl.d.ts +0 -0
- /package/dist/{src → packages/plugin-charts/src}/ChartRenderer.d.ts +0 -0
- /package/dist/{src → packages/plugin-charts/src}/ObjectChart.stories.d.ts +0 -0
- /package/dist/{src → packages/plugin-charts/src}/index.d.ts +0 -0
- /package/dist/{src → packages/plugin-charts/src}/types.d.ts +0 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Humanize a snake_case or kebab-case string into Title Case.
|
|
3
|
+
* Local implementation to avoid a dependency on @object-ui/fields.
|
|
4
|
+
*/
|
|
5
|
+
export declare function humanizeLabel(value: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Client-side aggregation for fetched records.
|
|
8
|
+
* Groups records by `groupBy` field and applies the aggregation function
|
|
9
|
+
* to the `field` values in each group.
|
|
10
|
+
*/
|
|
11
|
+
export declare function aggregateRecords(records: any[], aggregate: {
|
|
12
|
+
field: string;
|
|
13
|
+
function: string;
|
|
14
|
+
groupBy: string;
|
|
15
|
+
}): any[];
|
|
16
|
+
/**
|
|
17
|
+
* Resolve groupBy field values to human-readable labels using field metadata.
|
|
18
|
+
*
|
|
19
|
+
* - **select/picklist** fields: maps value→label via `field.options`.
|
|
20
|
+
* - **lookup/master_detail** fields: batch-fetches referenced records
|
|
21
|
+
* via `dataSource.find()` and maps id→name.
|
|
22
|
+
* - **fallback**: applies `humanizeLabel()` to convert snake_case/kebab-case
|
|
23
|
+
* values into Title Case.
|
|
24
|
+
*
|
|
25
|
+
* The resolved data is a new array with the groupBy key replaced by its label.
|
|
26
|
+
* This function is pure data-layer logic — the rendering layer does not need
|
|
27
|
+
* to perform any value→label conversion.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveGroupByLabels(data: any[], groupByField: string, objectSchema: any, dataSource?: any): Promise<any[]>;
|
|
30
|
+
export { extractRecords } from '../../core/src';
|
|
31
|
+
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.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Chart components plugin for Object UI, powered by Recharts",
|
|
@@ -24,11 +24,12 @@
|
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"@object-ui/
|
|
30
|
-
"@object-ui/
|
|
31
|
-
"@object-ui/
|
|
27
|
+
"lucide-react": "^1.8.0",
|
|
28
|
+
"recharts": "^3.8.1",
|
|
29
|
+
"@object-ui/components": "3.3.0",
|
|
30
|
+
"@object-ui/core": "3.3.0",
|
|
31
|
+
"@object-ui/react": "3.3.0",
|
|
32
|
+
"@object-ui/types": "3.3.0"
|
|
32
33
|
},
|
|
33
34
|
"peerDependencies": {
|
|
34
35
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -38,8 +39,8 @@
|
|
|
38
39
|
"@types/react": "19.2.14",
|
|
39
40
|
"@types/react-dom": "19.2.3",
|
|
40
41
|
"@vitejs/plugin-react": "^6.0.1",
|
|
41
|
-
"typescript": "^
|
|
42
|
-
"vite": "^8.0.
|
|
42
|
+
"typescript": "^6.0.2",
|
|
43
|
+
"vite": "^8.0.8",
|
|
43
44
|
"vite-plugin-dts": "^4.5.4"
|
|
44
45
|
},
|
|
45
46
|
"scripts": {
|
|
@@ -110,7 +110,11 @@ export default function AdvancedChartImpl({
|
|
|
110
110
|
combo: BarChart,
|
|
111
111
|
}[chartType] || BarChart;
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
// Memoize whether any X-axis label is long enough to warrant angle rotation
|
|
114
|
+
const hasLongLabels = React.useMemo(
|
|
115
|
+
() => data.some((d: any) => String(d[xAxisKey] || '').length > 5),
|
|
116
|
+
[data, xAxisKey],
|
|
117
|
+
);
|
|
114
118
|
|
|
115
119
|
// Helper function to get color palette
|
|
116
120
|
const getPalette = () => [
|
|
@@ -245,7 +249,12 @@ export default function AdvancedChartImpl({
|
|
|
245
249
|
tickMargin={10}
|
|
246
250
|
axisLine={false}
|
|
247
251
|
interval={isMobile ? Math.ceil(data.length / 5) : 0}
|
|
248
|
-
tickFormatter={(value) =>
|
|
252
|
+
tickFormatter={(value) => {
|
|
253
|
+
if (!value || typeof value !== 'string') return value;
|
|
254
|
+
if (isMobile && value.length > 8) return value.slice(0, 8) + '…';
|
|
255
|
+
return value;
|
|
256
|
+
}}
|
|
257
|
+
{...(!isMobile && hasLongLabels && { angle: -35, textAnchor: 'end', height: 60 })}
|
|
249
258
|
/>
|
|
250
259
|
<YAxis yAxisId="left" tickLine={false} axisLine={false} />
|
|
251
260
|
<YAxis yAxisId="right" orientation="right" tickLine={false} axisLine={false} />
|
|
@@ -282,7 +291,12 @@ export default function AdvancedChartImpl({
|
|
|
282
291
|
tickMargin={10}
|
|
283
292
|
axisLine={false}
|
|
284
293
|
interval={isMobile ? Math.ceil(data.length / 5) : 0}
|
|
285
|
-
tickFormatter={(value) =>
|
|
294
|
+
tickFormatter={(value) => {
|
|
295
|
+
if (!value || typeof value !== 'string') return value;
|
|
296
|
+
if (isMobile && value.length > 8) return value.slice(0, 8) + '…';
|
|
297
|
+
return value;
|
|
298
|
+
}}
|
|
299
|
+
{...(!isMobile && hasLongLabels && { angle: -35, textAnchor: 'end', height: 60 })}
|
|
286
300
|
/>
|
|
287
301
|
<ChartTooltip content={<ChartTooltipContent />} />
|
|
288
302
|
<ChartLegend
|
package/src/ObjectChart.tsx
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
|
|
2
|
-
import React, { useState, useEffect, useContext } from 'react';
|
|
2
|
+
import React, { useState, useEffect, useContext, useCallback } from 'react';
|
|
3
3
|
import { useDataScope, SchemaRendererContext } from '@object-ui/react';
|
|
4
4
|
import { ChartRenderer } from './ChartRenderer';
|
|
5
5
|
import { ComponentRegistry, extractRecords } from '@object-ui/core';
|
|
6
|
+
import { AlertCircle } from 'lucide-react';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Humanize a snake_case or kebab-case string into Title Case.
|
|
10
|
+
* Local implementation to avoid a dependency on @object-ui/fields.
|
|
11
|
+
*/
|
|
12
|
+
export function humanizeLabel(value: string): string {
|
|
13
|
+
return value.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
14
|
+
}
|
|
6
15
|
|
|
7
16
|
/**
|
|
8
17
|
* Client-side aggregation for fetched records.
|
|
@@ -49,6 +58,119 @@ export function aggregateRecords(
|
|
|
49
58
|
});
|
|
50
59
|
}
|
|
51
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Resolve groupBy field values to human-readable labels using field metadata.
|
|
63
|
+
*
|
|
64
|
+
* - **select/picklist** fields: maps value→label via `field.options`.
|
|
65
|
+
* - **lookup/master_detail** fields: batch-fetches referenced records
|
|
66
|
+
* via `dataSource.find()` and maps id→name.
|
|
67
|
+
* - **fallback**: applies `humanizeLabel()` to convert snake_case/kebab-case
|
|
68
|
+
* values into Title Case.
|
|
69
|
+
*
|
|
70
|
+
* The resolved data is a new array with the groupBy key replaced by its label.
|
|
71
|
+
* This function is pure data-layer logic — the rendering layer does not need
|
|
72
|
+
* to perform any value→label conversion.
|
|
73
|
+
*/
|
|
74
|
+
export async function resolveGroupByLabels(
|
|
75
|
+
data: any[],
|
|
76
|
+
groupByField: string,
|
|
77
|
+
objectSchema: any,
|
|
78
|
+
dataSource?: any,
|
|
79
|
+
): Promise<any[]> {
|
|
80
|
+
if (!data.length || !groupByField) return data;
|
|
81
|
+
|
|
82
|
+
const fieldDef = objectSchema?.fields?.[groupByField];
|
|
83
|
+
if (!fieldDef) {
|
|
84
|
+
// No metadata available — apply humanizeLabel as fallback
|
|
85
|
+
return data.map(row => ({
|
|
86
|
+
...row,
|
|
87
|
+
[groupByField]: humanizeLabel(String(row[groupByField] ?? '')),
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const fieldType = fieldDef.type;
|
|
92
|
+
|
|
93
|
+
// --- select / picklist / dropdown fields ---
|
|
94
|
+
if (fieldType === 'select' || fieldType === 'picklist' || fieldType === 'dropdown') {
|
|
95
|
+
const options: Array<{ value: string; label: string } | string> = fieldDef.options || [];
|
|
96
|
+
if (options.length === 0) {
|
|
97
|
+
return data.map(row => ({
|
|
98
|
+
...row,
|
|
99
|
+
[groupByField]: humanizeLabel(String(row[groupByField] ?? '')),
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Build value→label map (options can be {value,label} objects or plain strings)
|
|
104
|
+
const labelMap: Record<string, string> = {};
|
|
105
|
+
for (const opt of options) {
|
|
106
|
+
if (typeof opt === 'string') {
|
|
107
|
+
labelMap[opt] = opt;
|
|
108
|
+
} else if (opt && typeof opt === 'object') {
|
|
109
|
+
labelMap[String(opt.value)] = opt.label || String(opt.value);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return data.map(row => {
|
|
114
|
+
const rawValue = String(row[groupByField] ?? '');
|
|
115
|
+
return {
|
|
116
|
+
...row,
|
|
117
|
+
[groupByField]: labelMap[rawValue] || humanizeLabel(rawValue),
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- lookup / master_detail fields ---
|
|
123
|
+
if (fieldType === 'lookup' || fieldType === 'master_detail') {
|
|
124
|
+
const referenceTo = fieldDef.reference_to || fieldDef.reference;
|
|
125
|
+
if (!referenceTo || !dataSource || typeof dataSource.find !== 'function') {
|
|
126
|
+
// Cannot resolve — return as-is
|
|
127
|
+
return data;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Collect unique IDs to fetch
|
|
131
|
+
const ids = [...new Set(data.map(row => row[groupByField]).filter(v => v != null))];
|
|
132
|
+
if (ids.length === 0) return data;
|
|
133
|
+
|
|
134
|
+
// Derive the ID field from metadata (fallback to 'id')
|
|
135
|
+
const idField: string = fieldDef.id_field || 'id';
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const results = await dataSource.find(referenceTo, {
|
|
139
|
+
$filter: { [idField]: { $in: ids } },
|
|
140
|
+
$top: ids.length,
|
|
141
|
+
});
|
|
142
|
+
const records = extractRecords(results);
|
|
143
|
+
|
|
144
|
+
// Build id→label map using display field from metadata with sensible fallbacks
|
|
145
|
+
const displayField: string =
|
|
146
|
+
fieldDef.reference_field || fieldDef.display_field || 'name';
|
|
147
|
+
const idToName: Record<string, string> = {};
|
|
148
|
+
for (const rec of records) {
|
|
149
|
+
const id = String(rec[idField] ?? rec.id ?? rec._id ?? '');
|
|
150
|
+
const name = rec[displayField] || rec.name || rec.label || rec.title || id;
|
|
151
|
+
if (id) idToName[id] = String(name);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return data.map(row => {
|
|
155
|
+
const rawValue = String(row[groupByField] ?? '');
|
|
156
|
+
return {
|
|
157
|
+
...row,
|
|
158
|
+
[groupByField]: idToName[rawValue] || rawValue,
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
} catch (e) {
|
|
162
|
+
console.warn('[ObjectChart] Failed to resolve lookup labels:', e);
|
|
163
|
+
return data;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- fallback for other field types ---
|
|
168
|
+
return data.map(row => ({
|
|
169
|
+
...row,
|
|
170
|
+
[groupByField]: humanizeLabel(String(row[groupByField] ?? '')),
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
|
|
52
174
|
// Re-export extractRecords from @object-ui/core for backward compatibility
|
|
53
175
|
export { extractRecords } from '@object-ui/core';
|
|
54
176
|
|
|
@@ -60,56 +182,76 @@ export const ObjectChart = (props: any) => {
|
|
|
60
182
|
|
|
61
183
|
const [fetchedData, setFetchedData] = useState<any[]>([]);
|
|
62
184
|
const [loading, setLoading] = useState(false);
|
|
185
|
+
const [error, setError] = useState<string | null>(null);
|
|
186
|
+
|
|
187
|
+
const fetchData = useCallback(async (ds: any, mounted: { current: boolean }) => {
|
|
188
|
+
if (!ds || !schema.objectName) return;
|
|
189
|
+
if (mounted.current) {
|
|
190
|
+
setLoading(true);
|
|
191
|
+
setError(null);
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
let data: any[];
|
|
195
|
+
|
|
196
|
+
// Prefer server-side aggregation when aggregate config is provided
|
|
197
|
+
// and dataSource supports the aggregate() method.
|
|
198
|
+
if (schema.aggregate && typeof ds.aggregate === 'function') {
|
|
199
|
+
const results = await ds.aggregate(schema.objectName, {
|
|
200
|
+
field: schema.aggregate.field,
|
|
201
|
+
function: schema.aggregate.function,
|
|
202
|
+
groupBy: schema.aggregate.groupBy,
|
|
203
|
+
filter: schema.filter,
|
|
204
|
+
});
|
|
205
|
+
data = Array.isArray(results) ? results : [];
|
|
206
|
+
} else if (typeof ds.find === 'function') {
|
|
207
|
+
// Fallback: fetch all records and aggregate client-side
|
|
208
|
+
const results = await ds.find(schema.objectName, {
|
|
209
|
+
$filter: schema.filter
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
data = extractRecords(results);
|
|
213
|
+
|
|
214
|
+
// Apply client-side aggregation when aggregate config is provided
|
|
215
|
+
if (schema.aggregate && data.length > 0) {
|
|
216
|
+
data = aggregateRecords(data, schema.aggregate);
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Resolve groupBy value→label using field metadata.
|
|
223
|
+
// The groupBy field is determined from aggregate config or xAxisKey.
|
|
224
|
+
const groupByField = schema.aggregate?.groupBy || schema.xAxisKey;
|
|
225
|
+
if (groupByField && typeof ds.getObjectSchema === 'function') {
|
|
226
|
+
try {
|
|
227
|
+
const objectSchema = await ds.getObjectSchema(schema.objectName);
|
|
228
|
+
data = await resolveGroupByLabels(data, groupByField, objectSchema, ds);
|
|
229
|
+
} catch {
|
|
230
|
+
// Schema fetch failed — continue with raw values
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (mounted.current) {
|
|
235
|
+
setFetchedData(data);
|
|
236
|
+
}
|
|
237
|
+
} catch (e) {
|
|
238
|
+
console.error('[ObjectChart] Fetch error:', e);
|
|
239
|
+
if (mounted.current) {
|
|
240
|
+
setError(e instanceof Error ? e.message : 'Failed to load chart data');
|
|
241
|
+
}
|
|
242
|
+
} finally {
|
|
243
|
+
if (mounted.current) setLoading(false);
|
|
244
|
+
}
|
|
245
|
+
}, [schema.objectName, schema.aggregate, schema.filter, schema.xAxisKey]);
|
|
63
246
|
|
|
64
247
|
useEffect(() => {
|
|
65
|
-
|
|
66
|
-
const fetchData = async () => {
|
|
67
|
-
if (!dataSource || !schema.objectName) return;
|
|
68
|
-
if (isMounted) setLoading(true);
|
|
69
|
-
try {
|
|
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;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (isMounted) {
|
|
99
|
-
setFetchedData(data);
|
|
100
|
-
}
|
|
101
|
-
} catch (e) {
|
|
102
|
-
console.error('[ObjectChart] Fetch error:', e);
|
|
103
|
-
} finally {
|
|
104
|
-
if (isMounted) setLoading(false);
|
|
105
|
-
}
|
|
106
|
-
};
|
|
248
|
+
const mounted = { current: true };
|
|
107
249
|
|
|
108
250
|
if (schema.objectName && !boundData && !schema.data) {
|
|
109
|
-
fetchData();
|
|
251
|
+
fetchData(dataSource, mounted);
|
|
110
252
|
}
|
|
111
|
-
return () => {
|
|
112
|
-
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, schema.aggregate]);
|
|
253
|
+
return () => { mounted.current = false; };
|
|
254
|
+
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, schema.aggregate, fetchData]);
|
|
113
255
|
|
|
114
256
|
const rawData = boundData || schema.data || fetchedData;
|
|
115
257
|
const finalData = Array.isArray(rawData) ? rawData : [];
|
|
@@ -121,11 +263,22 @@ export const ObjectChart = (props: any) => {
|
|
|
121
263
|
};
|
|
122
264
|
|
|
123
265
|
if (loading && finalData.length === 0) {
|
|
124
|
-
return <div className={"flex items-center justify-center text-muted-foreground text-sm p-4 " + (schema.className || '')}>Loading chart data…</div>;
|
|
266
|
+
return <div className={"flex items-center justify-center text-muted-foreground text-sm p-4 " + (schema.className || '')} data-testid="chart-loading">Loading chart data…</div>;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Error state — show the error prominently so issues are not hidden
|
|
270
|
+
if (error) {
|
|
271
|
+
return (
|
|
272
|
+
<div className={"flex flex-col items-center justify-center gap-2 p-4 " + (schema.className || '')} data-testid="chart-error" role="alert">
|
|
273
|
+
<AlertCircle className="h-6 w-6 text-destructive opacity-60" />
|
|
274
|
+
<p className="text-xs text-destructive font-medium">Failed to load chart data</p>
|
|
275
|
+
<p className="text-xs text-muted-foreground max-w-xs text-center">{error}</p>
|
|
276
|
+
</div>
|
|
277
|
+
);
|
|
125
278
|
}
|
|
126
279
|
|
|
127
280
|
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
|
|
281
|
+
return <div className={"flex items-center justify-center text-muted-foreground text-sm p-4 " + (schema.className || '')} data-testid="chart-no-datasource">No data source available for “{schema.objectName}”</div>;
|
|
129
282
|
}
|
|
130
283
|
|
|
131
284
|
return <ChartRenderer {...props} schema={finalSchema} />;
|