@object-ui/plugin-dashboard 3.0.3 → 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.
Files changed (54) hide show
  1. package/.turbo/turbo-build.log +40 -7
  2. package/dist/index.js +3848 -2635
  3. package/dist/index.umd.cjs +5 -5
  4. package/dist/src/DashboardConfigPanel.d.ts +28 -0
  5. package/dist/src/DashboardConfigPanel.d.ts.map +1 -0
  6. package/dist/src/DashboardConfigPanel.stories.d.ts +14 -0
  7. package/dist/src/DashboardConfigPanel.stories.d.ts.map +1 -0
  8. package/dist/src/DashboardGridLayout.d.ts.map +1 -1
  9. package/dist/src/DashboardRenderer.d.ts +14 -0
  10. package/dist/src/DashboardRenderer.d.ts.map +1 -1
  11. package/dist/src/DashboardWithConfig.d.ts +32 -0
  12. package/dist/src/DashboardWithConfig.d.ts.map +1 -0
  13. package/dist/src/MetricCard.d.ts +8 -2
  14. package/dist/src/MetricCard.d.ts.map +1 -1
  15. package/dist/src/MetricWidget.d.ts +12 -3
  16. package/dist/src/MetricWidget.d.ts.map +1 -1
  17. package/dist/src/ObjectDataTable.d.ts +39 -0
  18. package/dist/src/ObjectDataTable.d.ts.map +1 -0
  19. package/dist/src/ObjectPivotTable.d.ts +29 -0
  20. package/dist/src/ObjectPivotTable.d.ts.map +1 -0
  21. package/dist/src/PivotTable.d.ts +14 -0
  22. package/dist/src/PivotTable.d.ts.map +1 -0
  23. package/dist/src/WidgetConfigPanel.d.ts +43 -0
  24. package/dist/src/WidgetConfigPanel.d.ts.map +1 -0
  25. package/dist/src/index.d.ts +13 -1
  26. package/dist/src/index.d.ts.map +1 -1
  27. package/dist/src/utils.d.ts +14 -0
  28. package/dist/src/utils.d.ts.map +1 -0
  29. package/package.json +7 -7
  30. package/src/DashboardConfigPanel.stories.tsx +164 -0
  31. package/src/DashboardConfigPanel.tsx +158 -0
  32. package/src/DashboardGridLayout.tsx +101 -3
  33. package/src/DashboardRenderer.tsx +269 -28
  34. package/src/DashboardWithConfig.tsx +211 -0
  35. package/src/MetricCard.tsx +11 -4
  36. package/src/MetricWidget.tsx +18 -11
  37. package/src/ObjectDataTable.tsx +191 -0
  38. package/src/ObjectPivotTable.tsx +160 -0
  39. package/src/PivotTable.tsx +262 -0
  40. package/src/WidgetConfigPanel.tsx +540 -0
  41. package/src/__tests__/DashboardConfigPanel.test.tsx +206 -0
  42. package/src/__tests__/DashboardRenderer.designMode.test.tsx +386 -0
  43. package/src/__tests__/DashboardRenderer.header.test.tsx +114 -0
  44. package/src/__tests__/DashboardRenderer.mobile.test.tsx +214 -0
  45. package/src/__tests__/DashboardRenderer.widgetData.test.tsx +1022 -0
  46. package/src/__tests__/DashboardWithConfig.test.tsx +276 -0
  47. package/src/__tests__/MetricCard.test.tsx +23 -0
  48. package/src/__tests__/ObjectDataTable.test.tsx +122 -0
  49. package/src/__tests__/ObjectPivotTable.test.tsx +192 -0
  50. package/src/__tests__/PivotTable.test.tsx +162 -0
  51. package/src/__tests__/WidgetConfigPanel.test.tsx +492 -0
  52. package/src/__tests__/ensureWidgetIds.test.tsx +103 -0
  53. package/src/index.tsx +107 -1
  54. package/src/utils.ts +17 -0
@@ -4,17 +4,24 @@ import { cn } from '@object-ui/components';
4
4
  import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from 'lucide-react';
5
5
  import * as LucideIcons from 'lucide-react';
6
6
 
7
+ /** Resolve an I18nLabel (string or {key, defaultValue}) to a plain string. */
8
+ function resolveLabel(label: string | { key?: string; defaultValue?: string } | undefined): string | undefined {
9
+ if (label === undefined || label === null) return undefined;
10
+ if (typeof label === 'string') return label;
11
+ return label.defaultValue || label.key;
12
+ }
13
+
7
14
  export interface MetricWidgetProps {
8
- label: string;
15
+ label: string | { key?: string; defaultValue?: string };
9
16
  value: string | number;
10
17
  trend?: {
11
18
  value: number;
12
- label?: string;
19
+ label?: string | { key?: string; defaultValue?: string };
13
20
  direction?: 'up' | 'down' | 'neutral';
14
21
  };
15
22
  icon?: React.ReactNode | string;
16
23
  className?: string;
17
- description?: string;
24
+ description?: string | { key?: string; defaultValue?: string };
18
25
  }
19
26
 
20
27
  export const MetricWidget = ({
@@ -36,20 +43,20 @@ export const MetricWidget = ({
36
43
  }, [icon]);
37
44
 
38
45
  return (
39
- <Card className={cn("h-full", className)} {...props}>
46
+ <Card className={cn("h-full overflow-hidden", className)} {...props}>
40
47
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
41
- <CardTitle className="text-sm font-medium">
42
- {label}
48
+ <CardTitle className="text-sm font-medium truncate">
49
+ {resolveLabel(label)}
43
50
  </CardTitle>
44
- {resolvedIcon && <div className="h-4 w-4 text-muted-foreground">{resolvedIcon}</div>}
51
+ {resolvedIcon && <div className="h-4 w-4 text-muted-foreground shrink-0">{resolvedIcon}</div>}
45
52
  </CardHeader>
46
53
  <CardContent>
47
- <div className="text-2xl font-bold">{value}</div>
54
+ <div className="text-2xl font-bold truncate">{value}</div>
48
55
  {(trend || description) && (
49
- <p className="text-xs text-muted-foreground flex items-center mt-1">
56
+ <p className="text-xs text-muted-foreground flex items-center mt-1 truncate">
50
57
  {trend && (
51
58
  <span className={cn(
52
- "flex items-center mr-2",
59
+ "flex items-center mr-2 shrink-0",
53
60
  trend.direction === 'up' && "text-green-500",
54
61
  trend.direction === 'down' && "text-red-500",
55
62
  trend.direction === 'neutral' && "text-yellow-500"
@@ -60,7 +67,7 @@ export const MetricWidget = ({
60
67
  {trend.value}%
61
68
  </span>
62
69
  )}
63
- {description || trend?.label}
70
+ <span className="truncate">{resolveLabel(description) || resolveLabel(trend?.label)}</span>
64
71
  </p>
65
72
  )}
66
73
  </CardContent>
@@ -0,0 +1,191 @@
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 React, { useState, useEffect, useContext, useMemo } from 'react';
10
+ import { useDataScope, SchemaRendererContext, SchemaRenderer } from '@object-ui/react';
11
+ import { extractRecords } from '@object-ui/core';
12
+ import { Skeleton, cn } from '@object-ui/components';
13
+
14
+ export interface ObjectDataTableProps {
15
+ schema: {
16
+ type: string;
17
+ objectName?: string;
18
+ dataProvider?: { provider: string; object?: string };
19
+ bind?: string;
20
+ filter?: any;
21
+ data?: any[];
22
+ columns?: any[];
23
+ searchable?: boolean;
24
+ pagination?: boolean;
25
+ className?: string;
26
+ [key: string]: any;
27
+ };
28
+ dataSource?: any;
29
+ className?: string;
30
+ }
31
+
32
+ /**
33
+ * ObjectDataTable — Async-aware wrapper for data-table.
34
+ *
35
+ * When `objectName` is provided and a `dataSource` is available via context
36
+ * or props, fetches records automatically and passes them to the registered
37
+ * `data-table` component via SchemaRenderer.
38
+ *
39
+ * Also auto-derives columns from fetched data keys when no explicit columns
40
+ * are configured.
41
+ *
42
+ * Lifecycle states:
43
+ * - **Loading** → skeleton placeholder
44
+ * - **Error** → error message
45
+ * - **Empty** → friendly "No data available" message
46
+ * - **Data** → data-table with fetched rows
47
+ */
48
+ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ schema, dataSource: propDataSource, className }) => {
49
+ const context = useContext(SchemaRendererContext);
50
+ const dataSource = propDataSource || context?.dataSource;
51
+ const boundData = useDataScope(schema.bind);
52
+
53
+ const [fetchedData, setFetchedData] = useState<any[]>([]);
54
+ const [loading, setLoading] = useState(false);
55
+ const [error, setError] = useState<string | null>(null);
56
+
57
+ useEffect(() => {
58
+ let isMounted = true;
59
+
60
+ const fetchData = async () => {
61
+ if (!dataSource || !schema.objectName) return;
62
+ if (isMounted) {
63
+ setLoading(true);
64
+ setError(null);
65
+ }
66
+ try {
67
+ let data: any[];
68
+
69
+ if (typeof dataSource.find === 'function') {
70
+ const results = await dataSource.find(schema.objectName, {
71
+ $filter: schema.filter,
72
+ });
73
+ data = extractRecords(results);
74
+ } else {
75
+ return;
76
+ }
77
+
78
+ if (isMounted) {
79
+ setFetchedData(data);
80
+ }
81
+ } catch (e) {
82
+ console.error('[ObjectDataTable] Fetch error:', e);
83
+ if (isMounted) {
84
+ setError(e instanceof Error ? e.message : 'Failed to load data');
85
+ }
86
+ } finally {
87
+ if (isMounted) setLoading(false);
88
+ }
89
+ };
90
+
91
+ if (schema.objectName && !boundData && (!schema.data || schema.data.length === 0)) {
92
+ fetchData();
93
+ }
94
+
95
+ return () => { isMounted = false; };
96
+ }, [schema.objectName, dataSource, boundData, schema.data, schema.filter]);
97
+
98
+ // Resolve data: bound data > static schema data > fetched data
99
+ const rawData = boundData || schema.data || fetchedData;
100
+ const finalData = Array.isArray(rawData) ? rawData : [];
101
+
102
+ // Auto-derive columns from data keys when none are provided
103
+ const derivedColumns = useMemo(() => {
104
+ if (schema.columns && schema.columns.length > 0) return schema.columns;
105
+ if (finalData.length === 0) return [];
106
+ // Exclude internal/private fields (prefixed with '_') from auto-derived columns
107
+ const keys = Object.keys(finalData[0]).filter(k => !k.startsWith('_'));
108
+ // Convert camelCase keys to human-readable headers (e.g. firstName → First Name)
109
+ return keys.map(k => ({
110
+ header: k.charAt(0).toUpperCase() + k.slice(1).replace(/([A-Z])/g, ' $1'),
111
+ accessorKey: k,
112
+ }));
113
+ }, [schema.columns, finalData]);
114
+
115
+ // Loading skeleton
116
+ if (loading && finalData.length === 0) {
117
+ return (
118
+ <div className={cn('overflow-auto', className)} data-testid="table-loading">
119
+ <div className="space-y-2 p-2">
120
+ <div className="flex gap-2">
121
+ <Skeleton className="h-6 w-1/4" />
122
+ <Skeleton className="h-6 w-1/4" />
123
+ <Skeleton className="h-6 w-1/4" />
124
+ <Skeleton className="h-6 w-1/4" />
125
+ </div>
126
+ {[1, 2, 3, 4].map((i) => (
127
+ <div key={i} className="flex gap-2">
128
+ <Skeleton className="h-5 w-1/4" />
129
+ <Skeleton className="h-5 w-1/4" />
130
+ <Skeleton className="h-5 w-1/4" />
131
+ <Skeleton className="h-5 w-1/4" />
132
+ </div>
133
+ ))}
134
+ </div>
135
+ </div>
136
+ );
137
+ }
138
+
139
+ // Error state
140
+ if (error) {
141
+ return (
142
+ <div className={cn('overflow-auto', className)} data-testid="table-error">
143
+ <div className="flex flex-col items-center justify-center py-8 text-destructive" data-testid="table-error-message">
144
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 mb-2 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
145
+ <circle cx="12" cy="12" r="10" />
146
+ <line x1="12" y1="8" x2="12" y2="12" />
147
+ <line x1="12" y1="16" x2="12.01" y2="16" />
148
+ </svg>
149
+ <p className="text-xs">{error}</p>
150
+ </div>
151
+ </div>
152
+ );
153
+ }
154
+
155
+ // No data source available but objectName configured
156
+ if (!dataSource && schema.objectName && finalData.length === 0) {
157
+ return (
158
+ <div className={cn('overflow-auto', className)}>
159
+ <div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
160
+ <p className="text-xs">No data source available for &ldquo;{schema.objectName}&rdquo;</p>
161
+ </div>
162
+ </div>
163
+ );
164
+ }
165
+
166
+ // Empty state
167
+ if (finalData.length === 0) {
168
+ return (
169
+ <div className={cn('overflow-auto', className)} data-testid="table-empty-state">
170
+ <div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
171
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 mb-2 opacity-40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
172
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
173
+ <line x1="3" y1="9" x2="21" y2="9" />
174
+ <line x1="9" y1="21" x2="9" y2="9" />
175
+ </svg>
176
+ <p className="text-xs">No data available</p>
177
+ </div>
178
+ </div>
179
+ );
180
+ }
181
+
182
+ // Delegate to data-table via SchemaRenderer
183
+ const tableSchema = {
184
+ ...schema,
185
+ type: 'data-table',
186
+ data: finalData,
187
+ columns: derivedColumns,
188
+ };
189
+
190
+ return <SchemaRenderer schema={tableSchema} className={className} />;
191
+ };
@@ -0,0 +1,160 @@
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 React, { useState, useEffect, useContext } from 'react';
10
+ import { useDataScope, SchemaRendererContext } from '@object-ui/react';
11
+ import { extractRecords } from '@object-ui/core';
12
+ import { Skeleton, cn } from '@object-ui/components';
13
+ import { PivotTable } from './PivotTable';
14
+ import type { PivotTableSchema } from '@object-ui/types';
15
+
16
+ export interface ObjectPivotTableProps {
17
+ schema: PivotTableSchema & {
18
+ objectName?: string;
19
+ dataProvider?: { provider: string; object?: string };
20
+ bind?: string;
21
+ filter?: any;
22
+ };
23
+ dataSource?: any;
24
+ className?: string;
25
+ }
26
+
27
+ /**
28
+ * ObjectPivotTable — Async-aware wrapper around PivotTable.
29
+ *
30
+ * When `objectName` is provided and a `dataSource` is available via context
31
+ * or props, fetches records automatically and passes them to PivotTable.
32
+ *
33
+ * Lifecycle states:
34
+ * - **Loading** → skeleton placeholder
35
+ * - **Error** → error message
36
+ * - **Empty** → friendly "No data available" (delegated to PivotTable)
37
+ * - **Data** → PivotTable with fetched rows
38
+ */
39
+ export const ObjectPivotTable: React.FC<ObjectPivotTableProps> = ({ schema, dataSource: propDataSource, className }) => {
40
+ const context = useContext(SchemaRendererContext);
41
+ const dataSource = propDataSource || context?.dataSource;
42
+ const boundData = useDataScope(schema.bind);
43
+
44
+ const [fetchedData, setFetchedData] = useState<any[]>([]);
45
+ const [loading, setLoading] = useState(false);
46
+ const [error, setError] = useState<string | null>(null);
47
+
48
+ useEffect(() => {
49
+ let isMounted = true;
50
+
51
+ const fetchData = async () => {
52
+ if (!dataSource || !schema.objectName) return;
53
+ if (isMounted) {
54
+ setLoading(true);
55
+ setError(null);
56
+ }
57
+ try {
58
+ let data: any[];
59
+
60
+ if (typeof dataSource.find === 'function') {
61
+ const results = await dataSource.find(schema.objectName, {
62
+ $filter: schema.filter,
63
+ });
64
+ data = extractRecords(results);
65
+ } else {
66
+ return;
67
+ }
68
+
69
+ if (isMounted) {
70
+ setFetchedData(data);
71
+ }
72
+ } catch (e) {
73
+ console.error('[ObjectPivotTable] Fetch error:', e);
74
+ if (isMounted) {
75
+ setError(e instanceof Error ? e.message : 'Failed to load data');
76
+ }
77
+ } finally {
78
+ if (isMounted) setLoading(false);
79
+ }
80
+ };
81
+
82
+ if (schema.objectName && !boundData && (!schema.data || schema.data.length === 0)) {
83
+ fetchData();
84
+ }
85
+
86
+ return () => { isMounted = false; };
87
+ }, [schema.objectName, dataSource, boundData, schema.data, schema.filter]);
88
+
89
+ // Resolve data: bound data > static schema data > fetched data
90
+ const rawData = boundData || schema.data || fetchedData;
91
+ const finalData = Array.isArray(rawData) ? rawData : [];
92
+
93
+ // Loading skeleton
94
+ if (loading && finalData.length === 0) {
95
+ return (
96
+ <div className={cn('overflow-auto', className)} data-testid="pivot-loading">
97
+ {schema.title && (
98
+ <h3 className="text-sm font-semibold mb-2">{schema.title}</h3>
99
+ )}
100
+ <div className="space-y-2 p-2">
101
+ <div className="flex gap-2">
102
+ <Skeleton className="h-6 w-24" />
103
+ <Skeleton className="h-6 w-20" />
104
+ <Skeleton className="h-6 w-20" />
105
+ <Skeleton className="h-6 w-20" />
106
+ </div>
107
+ {[1, 2, 3].map((i) => (
108
+ <div key={i} className="flex gap-2">
109
+ <Skeleton className="h-5 w-24" />
110
+ <Skeleton className="h-5 w-20" />
111
+ <Skeleton className="h-5 w-20" />
112
+ <Skeleton className="h-5 w-20" />
113
+ </div>
114
+ ))}
115
+ </div>
116
+ </div>
117
+ );
118
+ }
119
+
120
+ // Error state
121
+ if (error) {
122
+ return (
123
+ <div className={cn('overflow-auto', className)} data-testid="pivot-error">
124
+ {schema.title && (
125
+ <h3 className="text-sm font-semibold mb-2">{schema.title}</h3>
126
+ )}
127
+ <div className="flex flex-col items-center justify-center py-8 text-destructive" data-testid="pivot-error-message">
128
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 mb-2 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
129
+ <circle cx="12" cy="12" r="10" />
130
+ <line x1="12" y1="8" x2="12" y2="12" />
131
+ <line x1="12" y1="16" x2="12.01" y2="16" />
132
+ </svg>
133
+ <p className="text-xs">{error}</p>
134
+ </div>
135
+ </div>
136
+ );
137
+ }
138
+
139
+ // No data source available but objectName configured
140
+ if (!dataSource && schema.objectName && finalData.length === 0) {
141
+ return (
142
+ <div className={cn('overflow-auto', className)}>
143
+ {schema.title && (
144
+ <h3 className="text-sm font-semibold mb-2">{schema.title}</h3>
145
+ )}
146
+ <div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
147
+ <p className="text-xs">No data source available for &ldquo;{schema.objectName}&rdquo;</p>
148
+ </div>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ // Delegate to PivotTable with resolved data
154
+ const finalSchema: PivotTableSchema = {
155
+ ...schema,
156
+ data: finalData,
157
+ };
158
+
159
+ return <PivotTable schema={finalSchema} className={className} />;
160
+ };
@@ -0,0 +1,262 @@
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 React, { useMemo } from 'react';
10
+ import type { PivotTableSchema, PivotAggregation } from '@object-ui/types';
11
+ import { cn } from '@object-ui/components';
12
+
13
+ export interface PivotTableProps {
14
+ schema: PivotTableSchema;
15
+ className?: string;
16
+ }
17
+
18
+ /** Apply a simple format string to a number. Supports prefix/suffix like "$,.2f". */
19
+ function formatValue(value: number, format?: string): string {
20
+ if (!format) return String(value);
21
+
22
+ let prefix = '';
23
+ let suffix = '';
24
+ let useGrouping = false;
25
+ let decimals: number | undefined;
26
+
27
+ let fmt = format;
28
+
29
+ // Extract leading non-format characters as prefix (e.g. "$")
30
+ const prefixMatch = fmt.match(/^([^0-9.,#]*)/);
31
+ if (prefixMatch && prefixMatch[1]) {
32
+ // comma inside the prefix-ish area means grouping, not a literal prefix
33
+ const raw = prefixMatch[1];
34
+ prefix = raw.replace(',', '');
35
+ if (raw.includes(',')) useGrouping = true;
36
+ fmt = fmt.slice(prefixMatch[1].length);
37
+ }
38
+
39
+ // Grouping indicator anywhere remaining
40
+ if (fmt.includes(',')) {
41
+ useGrouping = true;
42
+ fmt = fmt.replace(/,/g, '');
43
+ }
44
+
45
+ // Decimal specifier e.g. ".2f"
46
+ const decMatch = fmt.match(/\.(\d+)f?/);
47
+ if (decMatch) {
48
+ decimals = Number(decMatch[1]);
49
+ fmt = fmt.slice(decMatch[0].length);
50
+ }
51
+
52
+ // Remaining characters become suffix
53
+ suffix = fmt.replace(/[0-9#.f]/g, '');
54
+
55
+ const formatted = decimals !== undefined ? value.toFixed(decimals) : String(value);
56
+
57
+ if (useGrouping) {
58
+ const [intPart, decPart] = formatted.split('.');
59
+ const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
60
+ return prefix + (decPart !== undefined ? `${grouped}.${decPart}` : grouped) + suffix;
61
+ }
62
+
63
+ return prefix + formatted + suffix;
64
+ }
65
+
66
+ /** Aggregate an array of numbers with the given function. */
67
+ function aggregate(values: number[], fn: PivotAggregation): number {
68
+ if (values.length === 0) return 0;
69
+ switch (fn) {
70
+ case 'sum':
71
+ return values.reduce((a, b) => a + b, 0);
72
+ case 'count':
73
+ return values.length;
74
+ case 'avg':
75
+ return values.reduce((a, b) => a + b, 0) / values.length;
76
+ case 'min':
77
+ return Math.min(...values);
78
+ case 'max':
79
+ return Math.max(...values);
80
+ default:
81
+ return values.reduce((a, b) => a + b, 0);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * PivotTable – Cross-tabulation / Pivot Table component.
87
+ *
88
+ * Renders a matrix where rows correspond to `rowField`, columns to
89
+ * `columnField`, and cells show the aggregated `valueField`.
90
+ */
91
+ export const PivotTable: React.FC<PivotTableProps> = ({ schema, className }) => {
92
+ const {
93
+ title,
94
+ rowField,
95
+ columnField,
96
+ valueField,
97
+ aggregation = 'sum',
98
+ data: rawData = [],
99
+ showRowTotals = false,
100
+ showColumnTotals = false,
101
+ format,
102
+ columnColors,
103
+ } = schema;
104
+
105
+ // Ensure data is always an array – provider config objects must not reach iteration
106
+ const data = Array.isArray(rawData) ? rawData : [];
107
+
108
+ const { rowKeys, colKeys, matrix, rowTotals, colTotals, grandTotal } = useMemo(() => {
109
+ // Collect unique row/column values preserving insertion order
110
+ const rowSet = new Map<string, true>();
111
+ const colSet = new Map<string, true>();
112
+ // Bucket raw values: bucket[row][col] = number[]
113
+ const bucket: Record<string, Record<string, number[]>> = {};
114
+
115
+ for (const item of data) {
116
+ const r = String(item[rowField] ?? '');
117
+ const c = String(item[columnField] ?? '');
118
+ const v = Number(item[valueField]) || 0;
119
+
120
+ rowSet.set(r, true);
121
+ colSet.set(c, true);
122
+
123
+ if (!bucket[r]) bucket[r] = {};
124
+ if (!bucket[r][c]) bucket[r][c] = [];
125
+ bucket[r][c].push(v);
126
+ }
127
+
128
+ const rKeys = Array.from(rowSet.keys());
129
+ const cKeys = Array.from(colSet.keys());
130
+
131
+ // Build aggregated matrix
132
+ const mat: Record<string, Record<string, number>> = {};
133
+ const rTotals: Record<string, number> = {};
134
+ const cTotals: Record<string, number> = {};
135
+
136
+ for (const r of rKeys) {
137
+ mat[r] = {};
138
+ const rowValues: number[] = [];
139
+ for (const c of cKeys) {
140
+ const cellValues = bucket[r]?.[c] ?? [];
141
+ const cellAgg = aggregate(cellValues, aggregation);
142
+ mat[r][c] = cellAgg;
143
+ rowValues.push(...cellValues);
144
+
145
+ // Accumulate column bucket values for column totals
146
+ if (!cTotals[c] && cTotals[c] !== 0) {
147
+ // Will compute after
148
+ }
149
+ }
150
+ rTotals[r] = aggregate(rowValues, aggregation);
151
+ }
152
+
153
+ // Column totals
154
+ for (const c of cKeys) {
155
+ const colValues: number[] = [];
156
+ for (const r of rKeys) {
157
+ const cellValues = bucket[r]?.[c] ?? [];
158
+ colValues.push(...cellValues);
159
+ }
160
+ cTotals[c] = aggregate(colValues, aggregation);
161
+ }
162
+
163
+ // Grand total
164
+ const allValues: number[] = [];
165
+ for (const item of data) {
166
+ allValues.push(Number(item[valueField]) || 0);
167
+ }
168
+ const gt = aggregate(allValues, aggregation);
169
+
170
+ return { rowKeys: rKeys, colKeys: cKeys, matrix: mat, rowTotals: rTotals, colTotals: cTotals, grandTotal: gt };
171
+ }, [data, rowField, columnField, valueField, aggregation]);
172
+
173
+ const fmt = (v: number) => formatValue(v, format);
174
+
175
+ if (data.length === 0) {
176
+ return (
177
+ <div className={cn('overflow-auto', className)}>
178
+ {title && (
179
+ <h3 className="text-sm font-semibold mb-2">{title}</h3>
180
+ )}
181
+ <div className="flex flex-col items-center justify-center py-8 text-muted-foreground" data-testid="pivot-empty-state">
182
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 mb-2 opacity-40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
183
+ <rect x="3" y="3" width="7" height="7" />
184
+ <rect x="14" y="3" width="7" height="7" />
185
+ <rect x="3" y="14" width="7" height="7" />
186
+ <rect x="14" y="14" width="7" height="7" />
187
+ </svg>
188
+ <p className="text-xs">No data available</p>
189
+ </div>
190
+ </div>
191
+ );
192
+ }
193
+
194
+ return (
195
+ <div className={cn('overflow-auto', className)}>
196
+ {title && (
197
+ <h3 className="text-sm font-semibold mb-2">{title}</h3>
198
+ )}
199
+ <table className="w-full text-sm border-collapse" role="table">
200
+ <thead>
201
+ <tr className="border-b border-border">
202
+ <th className="text-left p-2 font-medium text-muted-foreground">{rowField}</th>
203
+ {colKeys.map((col) => (
204
+ <th
205
+ key={col}
206
+ className={cn(
207
+ 'text-right p-2 font-medium',
208
+ columnColors?.[col] ?? 'text-muted-foreground',
209
+ )}
210
+ >
211
+ {col}
212
+ </th>
213
+ ))}
214
+ {showRowTotals && (
215
+ <th className="text-right p-2 font-semibold text-muted-foreground bg-muted/20">Total</th>
216
+ )}
217
+ </tr>
218
+ </thead>
219
+ <tbody>
220
+ {rowKeys.map((row) => (
221
+ <tr key={row} className="border-b border-border/50 hover:bg-muted/30">
222
+ <td className="p-2 font-medium">{row}</td>
223
+ {colKeys.map((col) => (
224
+ <td
225
+ key={col}
226
+ className={cn(
227
+ 'text-right p-2 tabular-nums',
228
+ columnColors?.[col],
229
+ )}
230
+ >
231
+ {fmt(matrix[row]?.[col] ?? 0)}
232
+ </td>
233
+ ))}
234
+ {showRowTotals && (
235
+ <td className="text-right p-2 font-semibold tabular-nums bg-muted/20">
236
+ {fmt(rowTotals[row] ?? 0)}
237
+ </td>
238
+ )}
239
+ </tr>
240
+ ))}
241
+ </tbody>
242
+ {showColumnTotals && (
243
+ <tfoot>
244
+ <tr className="border-t-2 border-border font-semibold bg-muted/40">
245
+ <td className="p-2">Total</td>
246
+ {colKeys.map((col) => (
247
+ <td key={col} className="text-right p-2 tabular-nums">
248
+ {fmt(colTotals[col] ?? 0)}
249
+ </td>
250
+ ))}
251
+ {showRowTotals && (
252
+ <td className="text-right p-2 tabular-nums font-bold">
253
+ {fmt(grandTotal)}
254
+ </td>
255
+ )}
256
+ </tr>
257
+ </tfoot>
258
+ )}
259
+ </table>
260
+ </div>
261
+ );
262
+ };