@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/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
- }
@@ -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
- };
@@ -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 &ldquo;{schema.objectName}&rdquo;</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
- });