@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.
@@ -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.1.4",
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
- "recharts": "^3.8.0",
28
- "@object-ui/components": "3.1.4",
29
- "@object-ui/core": "3.1.4",
30
- "@object-ui/react": "3.1.4",
31
- "@object-ui/types": "3.1.4"
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": "^5.9.3",
42
- "vite": "^8.0.1",
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
- console.log('📈 Rendering Chart:', { chartType, dataLength: data.length, config, series, xAxisKey });
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) => (value && typeof value === 'string') ? value.slice(0, 3) : 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) => (value && typeof value === 'string') ? value.slice(0, 3) : 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
@@ -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
- let isMounted = true;
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 () => { isMounted = false; };
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 "{schema.objectName}"</div>;
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 &ldquo;{schema.objectName}&rdquo;</div>;
129
282
  }
130
283
 
131
284
  return <ChartRenderer {...props} schema={finalSchema} />;