@object-ui/plugin-charts 3.3.0 → 3.3.1
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/CHANGELOG.md +10 -0
- package/README.md +24 -0
- package/dist/{AdvancedChartImpl-JDjuxIZW.js → AdvancedChartImpl-DxaZtNlE.js} +17 -12
- package/dist/{BarChart-Bvt5Se8Q.js → BarChart-BQS4sYHd.js} +1 -1
- package/dist/{ChartImpl-CQj8Kris.js → ChartImpl-BaXisyXJ.js} +2 -2
- package/dist/index.js +2 -2
- package/dist/index.umd.cjs +2 -2
- package/dist/{jsx-runtime-C8d0IhUE.js → jsx-runtime-Caia9pQX.js} +1 -1
- package/package.json +32 -9
- package/.turbo/turbo-build.log +0 -26
- package/examples/chart-examples.ts +0 -54
- package/src/AdvancedChartImpl.tsx +0 -323
- package/src/ChartContainerImpl.tsx +0 -353
- package/src/ChartImpl.tsx +0 -91
- package/src/ChartRenderer.tsx +0 -112
- package/src/ObjectChart.stories.tsx +0 -104
- package/src/ObjectChart.tsx +0 -298
- package/src/__tests__/ObjectChart.aggregation.test.ts +0 -166
- package/src/__tests__/ObjectChart.dataFetch.test.tsx +0 -303
- package/src/__tests__/ObjectChart.labelResolution.test.ts +0 -329
- package/src/index.test.ts +0 -136
- package/src/index.tsx +0 -172
- package/src/types.ts +0 -68
- package/tsconfig.json +0 -17
- package/vite.config.ts +0 -62
- package/vitest.config.ts +0 -13
- package/vitest.setup.ts +0 -1
package/src/ChartImpl.tsx
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
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 { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
|
10
|
-
|
|
11
|
-
export interface ChartImplProps {
|
|
12
|
-
data?: Array<Record<string, any>>;
|
|
13
|
-
dataKey?: string;
|
|
14
|
-
xAxisKey?: string;
|
|
15
|
-
height?: number;
|
|
16
|
-
className?: string;
|
|
17
|
-
color?: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* ChartImpl - The heavy implementation that imports Recharts
|
|
22
|
-
* This component is lazy-loaded to avoid including Recharts in the initial bundle
|
|
23
|
-
*/
|
|
24
|
-
export default function ChartImpl({
|
|
25
|
-
data = [],
|
|
26
|
-
dataKey = 'value',
|
|
27
|
-
xAxisKey = 'name',
|
|
28
|
-
height = 400,
|
|
29
|
-
className = '',
|
|
30
|
-
// Default to standard primary color
|
|
31
|
-
color = 'hsl(var(--primary))',
|
|
32
|
-
}: ChartImplProps) {
|
|
33
|
-
return (
|
|
34
|
-
<div className={`p-2 sm:p-3 md:p-4 rounded-xl border border-border bg-card/40 backdrop-blur-sm shadow-lg shadow-background/5 ${className}`}>
|
|
35
|
-
<ResponsiveContainer width="100%" height={height}>
|
|
36
|
-
<BarChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 5 }}>
|
|
37
|
-
<defs>
|
|
38
|
-
<linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1">
|
|
39
|
-
<stop offset="0%" stopColor={color} stopOpacity={1} />
|
|
40
|
-
<stop offset="90%" stopColor={color} stopOpacity={0.6} />
|
|
41
|
-
<stop offset="100%" stopColor={color} stopOpacity={0.3} />
|
|
42
|
-
</linearGradient>
|
|
43
|
-
<filter id="glow" height="130%">
|
|
44
|
-
<feGaussianBlur in="SourceAlpha" stdDeviation="3" result="blur" />
|
|
45
|
-
<feOffset in="blur" dx="0" dy="0" result="offsetBlur" />
|
|
46
|
-
<feFlood floodColor={color} floodOpacity="0.5" result="offsetColor" />
|
|
47
|
-
<feComposite in="offsetColor" in2="offsetBlur" operator="in" result="offsetBlur" />
|
|
48
|
-
<feMerge>
|
|
49
|
-
<feMergeNode in="offsetBlur" />
|
|
50
|
-
<feMergeNode in="SourceGraphic" />
|
|
51
|
-
</feMerge>
|
|
52
|
-
</filter>
|
|
53
|
-
</defs>
|
|
54
|
-
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
|
55
|
-
<XAxis
|
|
56
|
-
dataKey={xAxisKey}
|
|
57
|
-
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12, fontFamily: 'monospace' }}
|
|
58
|
-
tickLine={false}
|
|
59
|
-
axisLine={{ stroke: 'hsl(var(--border))' }}
|
|
60
|
-
dy={10}
|
|
61
|
-
/>
|
|
62
|
-
<YAxis
|
|
63
|
-
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12, fontFamily: 'monospace' }}
|
|
64
|
-
tickLine={false}
|
|
65
|
-
axisLine={false}
|
|
66
|
-
/>
|
|
67
|
-
<Tooltip
|
|
68
|
-
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.2 }}
|
|
69
|
-
contentStyle={{
|
|
70
|
-
backgroundColor: 'hsl(var(--popover))',
|
|
71
|
-
borderColor: 'hsl(var(--border))',
|
|
72
|
-
color: 'hsl(var(--popover-foreground))',
|
|
73
|
-
borderRadius: '8px',
|
|
74
|
-
fontFamily: 'monospace',
|
|
75
|
-
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)'
|
|
76
|
-
}}
|
|
77
|
-
itemStyle={{ color: 'hsl(var(--primary))' }}
|
|
78
|
-
/>
|
|
79
|
-
<Legend wrapperStyle={{ paddingTop: '20px', fontFamily: 'monospace' }} />
|
|
80
|
-
<Bar
|
|
81
|
-
dataKey={dataKey}
|
|
82
|
-
fill="url(#barGradient)"
|
|
83
|
-
radius={[4, 4, 0, 0]}
|
|
84
|
-
filter="url(#glow)"
|
|
85
|
-
animationDuration={1500}
|
|
86
|
-
/>
|
|
87
|
-
</BarChart>
|
|
88
|
-
</ResponsiveContainer>
|
|
89
|
-
</div>
|
|
90
|
-
);
|
|
91
|
-
}
|
package/src/ChartRenderer.tsx
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
import React, { Suspense } from 'react';
|
|
3
|
-
import { Skeleton } from '@object-ui/components';
|
|
4
|
-
import type { ChartConfig } from './ChartContainerImpl';
|
|
5
|
-
|
|
6
|
-
// 🚀 Lazy load the implementation files
|
|
7
|
-
const LazyChart = React.lazy(() => import('./ChartImpl'));
|
|
8
|
-
const LazyAdvancedChart = React.lazy(() => import('./AdvancedChartImpl'));
|
|
9
|
-
|
|
10
|
-
export interface ChartBarRendererProps {
|
|
11
|
-
schema: {
|
|
12
|
-
type: string;
|
|
13
|
-
id?: string;
|
|
14
|
-
className?: string;
|
|
15
|
-
data?: Array<Record<string, any>>;
|
|
16
|
-
dataKey?: string;
|
|
17
|
-
xAxisKey?: string;
|
|
18
|
-
height?: number;
|
|
19
|
-
color?: string;
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* ChartBarRenderer - The public API for the bar chart component
|
|
25
|
-
*/
|
|
26
|
-
export const ChartBarRenderer: React.FC<ChartBarRendererProps> = ({ schema }) => {
|
|
27
|
-
return (
|
|
28
|
-
<Suspense fallback={<Skeleton className="w-full h-48 sm:h-64 md:h-80 lg:h-[400px]" />}>
|
|
29
|
-
<LazyChart
|
|
30
|
-
data={schema.data}
|
|
31
|
-
dataKey={schema.dataKey}
|
|
32
|
-
xAxisKey={schema.xAxisKey}
|
|
33
|
-
height={schema.height}
|
|
34
|
-
className={schema.className}
|
|
35
|
-
color={schema.color}
|
|
36
|
-
/>
|
|
37
|
-
</Suspense>
|
|
38
|
-
);
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
export interface ChartRendererProps {
|
|
42
|
-
schema: {
|
|
43
|
-
type: string;
|
|
44
|
-
id?: string;
|
|
45
|
-
className?: string;
|
|
46
|
-
chartType?: 'bar' | 'line' | 'area' | 'pie' | 'donut' | 'radar' | 'scatter' | 'combo';
|
|
47
|
-
data?: Array<Record<string, any>>;
|
|
48
|
-
config?: Record<string, any>;
|
|
49
|
-
xAxisKey?: string;
|
|
50
|
-
series?: Array<{ dataKey: string }>;
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* ChartRenderer - The public API for the advanced chart component
|
|
56
|
-
*/
|
|
57
|
-
export const ChartRenderer: React.FC<ChartRendererProps> = ({ schema }) => {
|
|
58
|
-
// ⚡️ Adapter: Normalize JSON schema to Recharts Props
|
|
59
|
-
const props = React.useMemo(() => {
|
|
60
|
-
// 1. Defaults
|
|
61
|
-
let series = schema.series;
|
|
62
|
-
let xAxisKey = schema.xAxisKey;
|
|
63
|
-
let config = schema.config;
|
|
64
|
-
|
|
65
|
-
// 2. Adapt Tremor/Simple format (categories -> series, index -> xAxisKey)
|
|
66
|
-
if (!xAxisKey) {
|
|
67
|
-
if ((schema as any).index) xAxisKey = (schema as any).index;
|
|
68
|
-
else if ((schema as any).category) xAxisKey = (schema as any).category; // Support Pie/Donut category
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (!series) {
|
|
72
|
-
if ((schema as any).categories) {
|
|
73
|
-
series = (schema as any).categories.map((cat: string) => ({ dataKey: cat }));
|
|
74
|
-
} else if ((schema as any).value) {
|
|
75
|
-
// Single value adapter (for Pie/Simple charts)
|
|
76
|
-
series = [{ dataKey: (schema as any).value }];
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// 3. Auto-generate config/colors if missing
|
|
81
|
-
if (!config && series) {
|
|
82
|
-
const colors = (schema as any).colors || ['hsl(var(--chart-1))', 'hsl(var(--chart-2))', 'hsl(var(--chart-3))'];
|
|
83
|
-
const newConfig: ChartConfig = {};
|
|
84
|
-
series.forEach((s: any, idx: number) => {
|
|
85
|
-
newConfig[s.dataKey] = { label: s.dataKey, color: colors[idx % colors.length] };
|
|
86
|
-
});
|
|
87
|
-
config = newConfig;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
chartType: schema.chartType,
|
|
92
|
-
data: Array.isArray(schema.data) ? schema.data : [],
|
|
93
|
-
config,
|
|
94
|
-
xAxisKey,
|
|
95
|
-
series,
|
|
96
|
-
className: schema.className
|
|
97
|
-
};
|
|
98
|
-
}, [schema]);
|
|
99
|
-
|
|
100
|
-
return (
|
|
101
|
-
<Suspense fallback={<Skeleton className="w-full h-48 sm:h-64 md:h-80 lg:h-[400px]" />}>
|
|
102
|
-
<LazyAdvancedChart
|
|
103
|
-
chartType={props.chartType}
|
|
104
|
-
data={props.data}
|
|
105
|
-
config={props.config}
|
|
106
|
-
xAxisKey={props.xAxisKey}
|
|
107
|
-
series={props.series}
|
|
108
|
-
className={props.className}
|
|
109
|
-
/>
|
|
110
|
-
</Suspense>
|
|
111
|
-
);
|
|
112
|
-
};
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { SchemaRenderer } from '@object-ui/react';
|
|
3
|
-
import type { BaseSchema } from '@object-ui/types';
|
|
4
|
-
|
|
5
|
-
const meta = {
|
|
6
|
-
title: 'Plugins/ObjectChart',
|
|
7
|
-
component: SchemaRenderer,
|
|
8
|
-
parameters: {
|
|
9
|
-
layout: 'padded',
|
|
10
|
-
},
|
|
11
|
-
tags: ['autodocs'],
|
|
12
|
-
argTypes: {
|
|
13
|
-
schema: { table: { disable: true } },
|
|
14
|
-
},
|
|
15
|
-
} satisfies Meta<any>;
|
|
16
|
-
|
|
17
|
-
export default meta;
|
|
18
|
-
type Story = StoryObj<typeof meta>;
|
|
19
|
-
|
|
20
|
-
const renderStory = (args: any) => <SchemaRenderer schema={args as unknown as BaseSchema} />;
|
|
21
|
-
|
|
22
|
-
export const Default: Story = {
|
|
23
|
-
render: renderStory,
|
|
24
|
-
args: {
|
|
25
|
-
type: 'chart',
|
|
26
|
-
chartType: 'bar',
|
|
27
|
-
data: [
|
|
28
|
-
{ month: 'Jan', revenue: 4200 },
|
|
29
|
-
{ month: 'Feb', revenue: 3800 },
|
|
30
|
-
{ month: 'Mar', revenue: 5100 },
|
|
31
|
-
{ month: 'Apr', revenue: 6300 },
|
|
32
|
-
{ month: 'May', revenue: 5800 },
|
|
33
|
-
{ month: 'Jun', revenue: 7200 },
|
|
34
|
-
],
|
|
35
|
-
xAxisKey: 'month',
|
|
36
|
-
series: [{ dataKey: 'revenue' }],
|
|
37
|
-
config: {
|
|
38
|
-
revenue: { label: 'Revenue', color: '#3b82f6' },
|
|
39
|
-
},
|
|
40
|
-
} as any,
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export const LineChart: Story = {
|
|
44
|
-
render: renderStory,
|
|
45
|
-
args: {
|
|
46
|
-
type: 'chart',
|
|
47
|
-
chartType: 'line',
|
|
48
|
-
data: [
|
|
49
|
-
{ month: 'Jan', users: 150, sessions: 520 },
|
|
50
|
-
{ month: 'Feb', users: 210, sessions: 680 },
|
|
51
|
-
{ month: 'Mar', users: 280, sessions: 910 },
|
|
52
|
-
{ month: 'Apr', users: 350, sessions: 1250 },
|
|
53
|
-
{ month: 'May', users: 430, sessions: 1600 },
|
|
54
|
-
{ month: 'Jun', users: 540, sessions: 2050 },
|
|
55
|
-
],
|
|
56
|
-
config: {
|
|
57
|
-
users: { label: 'Active Users', color: '#8b5cf6' },
|
|
58
|
-
sessions: { label: 'Sessions', color: '#ec4899' },
|
|
59
|
-
},
|
|
60
|
-
xAxisKey: 'month',
|
|
61
|
-
series: [{ dataKey: 'users' }, { dataKey: 'sessions' }],
|
|
62
|
-
} as any,
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
export const PieChart: Story = {
|
|
66
|
-
render: renderStory,
|
|
67
|
-
args: {
|
|
68
|
-
type: 'pie-chart',
|
|
69
|
-
data: [
|
|
70
|
-
{ name: 'Desktop', value: 55 },
|
|
71
|
-
{ name: 'Mobile', value: 35 },
|
|
72
|
-
{ name: 'Tablet', value: 10 },
|
|
73
|
-
],
|
|
74
|
-
xAxisKey: 'name',
|
|
75
|
-
series: [{ dataKey: 'value' }],
|
|
76
|
-
config: {
|
|
77
|
-
Desktop: { label: 'Desktop', color: 'hsl(var(--chart-1))' },
|
|
78
|
-
Mobile: { label: 'Mobile', color: 'hsl(var(--chart-2))' },
|
|
79
|
-
Tablet: { label: 'Tablet', color: 'hsl(var(--chart-3))' },
|
|
80
|
-
},
|
|
81
|
-
} as any,
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
export const AreaChart: Story = {
|
|
85
|
-
render: renderStory,
|
|
86
|
-
args: {
|
|
87
|
-
type: 'chart',
|
|
88
|
-
chartType: 'area',
|
|
89
|
-
data: [
|
|
90
|
-
{ date: 'Mon', traffic: 3200 },
|
|
91
|
-
{ date: 'Tue', traffic: 2800 },
|
|
92
|
-
{ date: 'Wed', traffic: 4100 },
|
|
93
|
-
{ date: 'Thu', traffic: 3600 },
|
|
94
|
-
{ date: 'Fri', traffic: 4800 },
|
|
95
|
-
{ date: 'Sat', traffic: 2900 },
|
|
96
|
-
{ date: 'Sun', traffic: 3400 },
|
|
97
|
-
],
|
|
98
|
-
config: {
|
|
99
|
-
traffic: { label: 'Page Views', color: '#06b6d4' },
|
|
100
|
-
},
|
|
101
|
-
xAxisKey: 'date',
|
|
102
|
-
series: [{ dataKey: 'traffic' }],
|
|
103
|
-
} as any,
|
|
104
|
-
};
|
package/src/ObjectChart.tsx
DELETED
|
@@ -1,298 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
import React, { useState, useEffect, useContext, useCallback } from 'react';
|
|
3
|
-
import { useDataScope, SchemaRendererContext } from '@object-ui/react';
|
|
4
|
-
import { ChartRenderer } from './ChartRenderer';
|
|
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
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Client-side aggregation for fetched records.
|
|
18
|
-
* Groups records by `groupBy` field and applies the aggregation function
|
|
19
|
-
* to the `field` values in each group.
|
|
20
|
-
*/
|
|
21
|
-
export function aggregateRecords(
|
|
22
|
-
records: any[],
|
|
23
|
-
aggregate: { field: string; function: string; groupBy: string }
|
|
24
|
-
): any[] {
|
|
25
|
-
const { field, function: aggFn, groupBy } = aggregate;
|
|
26
|
-
const groups: Record<string, any[]> = {};
|
|
27
|
-
|
|
28
|
-
for (const record of records) {
|
|
29
|
-
const key = String(record[groupBy] ?? 'Unknown');
|
|
30
|
-
if (!groups[key]) groups[key] = [];
|
|
31
|
-
groups[key].push(record);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return Object.entries(groups).map(([key, group]) => {
|
|
35
|
-
const values = group.map(r => Number(r[field]) || 0);
|
|
36
|
-
let result: number;
|
|
37
|
-
|
|
38
|
-
switch (aggFn) {
|
|
39
|
-
case 'count':
|
|
40
|
-
result = group.length;
|
|
41
|
-
break;
|
|
42
|
-
case 'avg':
|
|
43
|
-
result = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
|
|
44
|
-
break;
|
|
45
|
-
case 'min':
|
|
46
|
-
result = values.length > 0 ? Math.min(...values) : 0;
|
|
47
|
-
break;
|
|
48
|
-
case 'max':
|
|
49
|
-
result = values.length > 0 ? Math.max(...values) : 0;
|
|
50
|
-
break;
|
|
51
|
-
case 'sum':
|
|
52
|
-
default:
|
|
53
|
-
result = values.reduce((a, b) => a + b, 0);
|
|
54
|
-
break;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return { [groupBy]: key, [field]: result };
|
|
58
|
-
});
|
|
59
|
-
}
|
|
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
|
-
|
|
174
|
-
// Re-export extractRecords from @object-ui/core for backward compatibility
|
|
175
|
-
export { extractRecords } from '@object-ui/core';
|
|
176
|
-
|
|
177
|
-
export const ObjectChart = (props: any) => {
|
|
178
|
-
const { schema } = props;
|
|
179
|
-
const context = useContext(SchemaRendererContext);
|
|
180
|
-
const dataSource = props.dataSource || context?.dataSource;
|
|
181
|
-
const boundData = useDataScope(schema.bind);
|
|
182
|
-
|
|
183
|
-
const [fetchedData, setFetchedData] = useState<any[]>([]);
|
|
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]);
|
|
246
|
-
|
|
247
|
-
useEffect(() => {
|
|
248
|
-
const mounted = { current: true };
|
|
249
|
-
|
|
250
|
-
if (schema.objectName && !boundData && !schema.data) {
|
|
251
|
-
fetchData(dataSource, mounted);
|
|
252
|
-
}
|
|
253
|
-
return () => { mounted.current = false; };
|
|
254
|
-
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, schema.aggregate, fetchData]);
|
|
255
|
-
|
|
256
|
-
const rawData = boundData || schema.data || fetchedData;
|
|
257
|
-
const finalData = Array.isArray(rawData) ? rawData : [];
|
|
258
|
-
|
|
259
|
-
// Merge data if not provided in schema
|
|
260
|
-
const finalSchema = {
|
|
261
|
-
...schema,
|
|
262
|
-
data: finalData
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
if (loading && finalData.length === 0) {
|
|
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
|
-
);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
if (!dataSource && schema.objectName && finalData.length === 0) {
|
|
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>;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
return <ChartRenderer {...props} schema={finalSchema} />;
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
// Register it
|
|
288
|
-
ComponentRegistry.register('object-chart', ObjectChart, {
|
|
289
|
-
namespace: 'plugin-charts',
|
|
290
|
-
label: 'Object Chart',
|
|
291
|
-
category: 'view',
|
|
292
|
-
inputs: [
|
|
293
|
-
{ name: 'objectName', type: 'string', label: 'Object Name', required: true },
|
|
294
|
-
{ name: 'data', type: 'array', label: 'Data', description: 'Optional static data' },
|
|
295
|
-
{ name: 'filter', type: 'array', label: 'Filter' },
|
|
296
|
-
{ name: 'aggregate', type: 'object', label: 'Aggregate', description: 'Aggregation config: { field, function, groupBy }' },
|
|
297
|
-
]
|
|
298
|
-
});
|