@object-ui/plugin-dashboard 2.0.0 → 3.0.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 +5 -5
- package/CHANGELOG.md +20 -0
- package/dist/index.js +2122 -2042
- package/dist/index.umd.cjs +4 -4
- package/dist/src/DashboardGridLayout.d.ts +2 -0
- package/dist/src/DashboardGridLayout.d.ts.map +1 -1
- package/dist/src/DashboardRenderer.d.ts +6 -3
- package/dist/src/DashboardRenderer.d.ts.map +1 -1
- package/dist/src/DashboardRenderer.stories.d.ts +24 -0
- package/dist/src/DashboardRenderer.stories.d.ts.map +1 -0
- package/dist/src/index.d.ts +1 -5
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +10 -10
- package/src/DashboardGridLayout.tsx +59 -6
- package/src/DashboardRenderer.stories.tsx +173 -0
- package/src/DashboardRenderer.tsx +161 -99
- package/src/__tests__/DashboardRenderer.autoRefresh.test.tsx +124 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/plugin-dashboard",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Dashboard plugin for Object UI",
|
|
@@ -15,16 +15,16 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"clsx": "^2.1.
|
|
18
|
+
"clsx": "^2.1.1",
|
|
19
19
|
"lucide-react": "^0.563.0",
|
|
20
|
-
"react": "
|
|
21
|
-
"react-dom": "
|
|
20
|
+
"react": "19.2.4",
|
|
21
|
+
"react-dom": "19.2.4",
|
|
22
22
|
"react-grid-layout": "^2.2.2",
|
|
23
|
-
"tailwind-merge": "^2.
|
|
24
|
-
"@object-ui/components": "
|
|
25
|
-
"@object-ui/core": "
|
|
26
|
-
"@object-ui/react": "
|
|
27
|
-
"@object-ui/types": "
|
|
23
|
+
"tailwind-merge": "^2.6.1",
|
|
24
|
+
"@object-ui/components": "3.0.0",
|
|
25
|
+
"@object-ui/core": "3.0.0",
|
|
26
|
+
"@object-ui/react": "3.0.0",
|
|
27
|
+
"@object-ui/types": "3.0.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"react": "^18.0.0",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/react-grid-layout": "^2.1.0",
|
|
35
|
-
"@vitejs/plugin-react": "^5.1.
|
|
35
|
+
"@vitejs/plugin-react": "^5.1.4",
|
|
36
36
|
"typescript": "^5.9.3",
|
|
37
37
|
"vite": "^7.3.1",
|
|
38
38
|
"vite-plugin-dts": "^4.5.4"
|
|
@@ -2,10 +2,26 @@ import * as React from 'react';
|
|
|
2
2
|
import { ResponsiveGridLayout, useContainerWidth, type LayoutItem as RGLLayout, type Layout, type ResponsiveLayouts } from 'react-grid-layout';
|
|
3
3
|
import 'react-grid-layout/css/styles.css';
|
|
4
4
|
import { cn, Card, CardHeader, CardTitle, CardContent, Button } from '@object-ui/components';
|
|
5
|
-
import { Edit, GripVertical, Save, X } from 'lucide-react';
|
|
6
|
-
import { SchemaRenderer } from '@object-ui/react';
|
|
5
|
+
import { Edit, GripVertical, Save, X, RefreshCw } from 'lucide-react';
|
|
6
|
+
import { SchemaRenderer, useHasDndProvider, useDnd } from '@object-ui/react';
|
|
7
7
|
import type { DashboardSchema, DashboardWidgetSchema } from '@object-ui/types';
|
|
8
8
|
|
|
9
|
+
/** Bridges editMode transitions to the ObjectUI DnD system when a DndProvider is present. */
|
|
10
|
+
function DndEditModeBridge({ editMode }: { editMode: boolean }) {
|
|
11
|
+
const dnd = useDnd();
|
|
12
|
+
|
|
13
|
+
React.useEffect(() => {
|
|
14
|
+
if (editMode) {
|
|
15
|
+
dnd.startDrag({ id: 'dashboard-layout', type: 'dashboard-widget', data: {} });
|
|
16
|
+
return () => { dnd.endDrag(); };
|
|
17
|
+
} else {
|
|
18
|
+
dnd.endDrag('dashboard');
|
|
19
|
+
}
|
|
20
|
+
}, [editMode, dnd]);
|
|
21
|
+
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
const CHART_COLORS = [
|
|
10
26
|
'hsl(var(--chart-1))',
|
|
11
27
|
'hsl(var(--chart-2))',
|
|
@@ -19,6 +35,8 @@ export interface DashboardGridLayoutProps {
|
|
|
19
35
|
className?: string;
|
|
20
36
|
onLayoutChange?: (layout: RGLLayout[]) => void;
|
|
21
37
|
persistLayoutKey?: string;
|
|
38
|
+
/** Callback invoked when dashboard refresh is triggered (manual or auto) */
|
|
39
|
+
onRefresh?: () => void;
|
|
22
40
|
}
|
|
23
41
|
|
|
24
42
|
export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
|
|
@@ -26,9 +44,29 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
|
|
|
26
44
|
className,
|
|
27
45
|
onLayoutChange,
|
|
28
46
|
persistLayoutKey = 'dashboard-layout',
|
|
47
|
+
onRefresh,
|
|
29
48
|
}) => {
|
|
30
49
|
const { width, containerRef, mounted } = useContainerWidth();
|
|
31
50
|
const [editMode, setEditMode] = React.useState(false);
|
|
51
|
+
const [refreshing, setRefreshing] = React.useState(false);
|
|
52
|
+
const hasDndProvider = useHasDndProvider();
|
|
53
|
+
const intervalRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
|
|
54
|
+
|
|
55
|
+
const handleRefresh = React.useCallback(() => {
|
|
56
|
+
if (!onRefresh) return;
|
|
57
|
+
setRefreshing(true);
|
|
58
|
+
onRefresh();
|
|
59
|
+
setTimeout(() => setRefreshing(false), 600);
|
|
60
|
+
}, [onRefresh]);
|
|
61
|
+
|
|
62
|
+
// Auto-refresh interval
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
if (!schema.refreshInterval || schema.refreshInterval <= 0 || !onRefresh) return;
|
|
65
|
+
intervalRef.current = setInterval(handleRefresh, schema.refreshInterval * 1000);
|
|
66
|
+
return () => {
|
|
67
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
68
|
+
};
|
|
69
|
+
}, [schema.refreshInterval, onRefresh, handleRefresh]);
|
|
32
70
|
const [layouts, setLayouts] = React.useState<{ lg: RGLLayout[] }>(() => {
|
|
33
71
|
// Try to load saved layout
|
|
34
72
|
if (typeof window !== 'undefined' && persistLayoutKey) {
|
|
@@ -122,6 +160,7 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
|
|
|
122
160
|
|
|
123
161
|
return (
|
|
124
162
|
<div ref={containerRef} className={cn("w-full", className)} data-testid="grid-layout">
|
|
163
|
+
{hasDndProvider && <DndEditModeBridge editMode={editMode} />}
|
|
125
164
|
<div className="mb-4 flex items-center justify-between">
|
|
126
165
|
<h2 className="text-2xl font-bold">{schema.title || 'Dashboard'}</h2>
|
|
127
166
|
<div className="flex gap-2">
|
|
@@ -140,10 +179,24 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
|
|
|
140
179
|
</Button>
|
|
141
180
|
</>
|
|
142
181
|
) : (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
182
|
+
<>
|
|
183
|
+
{onRefresh && (
|
|
184
|
+
<Button
|
|
185
|
+
onClick={handleRefresh}
|
|
186
|
+
size="sm"
|
|
187
|
+
variant="outline"
|
|
188
|
+
disabled={refreshing}
|
|
189
|
+
aria-label="Refresh dashboard"
|
|
190
|
+
>
|
|
191
|
+
<RefreshCw className={cn("h-4 w-4 mr-2", refreshing && "animate-spin")} />
|
|
192
|
+
{refreshing ? 'Refreshing…' : 'Refresh All'}
|
|
193
|
+
</Button>
|
|
194
|
+
)}
|
|
195
|
+
<Button onClick={() => setEditMode(true)} size="sm" variant="outline">
|
|
196
|
+
<Edit className="h-4 w-4 mr-2" />
|
|
197
|
+
Edit Layout
|
|
198
|
+
</Button>
|
|
199
|
+
</>
|
|
147
200
|
)}
|
|
148
201
|
</div>
|
|
149
202
|
</div>
|
|
@@ -0,0 +1,173 @@
|
|
|
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/DashboardRenderer',
|
|
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: 'dashboard',
|
|
26
|
+
columns: 3,
|
|
27
|
+
gap: 4,
|
|
28
|
+
widgets: [
|
|
29
|
+
{
|
|
30
|
+
id: 'metric-1',
|
|
31
|
+
component: {
|
|
32
|
+
type: 'metric',
|
|
33
|
+
label: 'Total Revenue',
|
|
34
|
+
value: '$128,430',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'metric-2',
|
|
39
|
+
component: {
|
|
40
|
+
type: 'metric',
|
|
41
|
+
label: 'Active Users',
|
|
42
|
+
value: '3,842',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'metric-3',
|
|
47
|
+
component: {
|
|
48
|
+
type: 'metric',
|
|
49
|
+
label: 'Conversion Rate',
|
|
50
|
+
value: '4.2%',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
} as any,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const WithMetricCards: Story = {
|
|
58
|
+
render: renderStory,
|
|
59
|
+
args: {
|
|
60
|
+
type: 'dashboard',
|
|
61
|
+
columns: 3,
|
|
62
|
+
gap: 4,
|
|
63
|
+
widgets: [
|
|
64
|
+
{
|
|
65
|
+
id: 'mc-1',
|
|
66
|
+
component: {
|
|
67
|
+
type: 'metric-card',
|
|
68
|
+
title: 'Monthly Revenue',
|
|
69
|
+
value: '$52,489',
|
|
70
|
+
icon: 'DollarSign',
|
|
71
|
+
trend: 'up',
|
|
72
|
+
trendValue: '+14.2%',
|
|
73
|
+
description: 'vs last month',
|
|
74
|
+
},
|
|
75
|
+
layout: { x: 0, y: 0, w: 1, h: 1 },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'mc-2',
|
|
79
|
+
component: {
|
|
80
|
+
type: 'metric-card',
|
|
81
|
+
title: 'New Signups',
|
|
82
|
+
value: '1,205',
|
|
83
|
+
icon: 'Users',
|
|
84
|
+
trend: 'up',
|
|
85
|
+
trendValue: '+8.1%',
|
|
86
|
+
description: 'vs last month',
|
|
87
|
+
},
|
|
88
|
+
layout: { x: 1, y: 0, w: 1, h: 1 },
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 'mc-3',
|
|
92
|
+
component: {
|
|
93
|
+
type: 'metric-card',
|
|
94
|
+
title: 'Churn Rate',
|
|
95
|
+
value: '1.8%',
|
|
96
|
+
icon: 'TrendingDown',
|
|
97
|
+
trend: 'down',
|
|
98
|
+
trendValue: '-0.3%',
|
|
99
|
+
description: 'vs last month',
|
|
100
|
+
},
|
|
101
|
+
layout: { x: 2, y: 0, w: 1, h: 1 },
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
} as any,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const WithChartsAndMetrics: Story = {
|
|
108
|
+
render: renderStory,
|
|
109
|
+
args: {
|
|
110
|
+
type: 'dashboard',
|
|
111
|
+
columns: 3,
|
|
112
|
+
gap: 4,
|
|
113
|
+
widgets: [
|
|
114
|
+
{
|
|
115
|
+
id: 'd-m1',
|
|
116
|
+
component: {
|
|
117
|
+
type: 'metric-card',
|
|
118
|
+
title: 'Total Orders',
|
|
119
|
+
value: '1,284',
|
|
120
|
+
icon: 'ShoppingCart',
|
|
121
|
+
trend: 'up',
|
|
122
|
+
trendValue: '+11%',
|
|
123
|
+
},
|
|
124
|
+
layout: { x: 0, y: 0, w: 1, h: 1 },
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: 'd-m2',
|
|
128
|
+
component: {
|
|
129
|
+
type: 'metric-card',
|
|
130
|
+
title: 'Avg Order Value',
|
|
131
|
+
value: '$86.50',
|
|
132
|
+
icon: 'DollarSign',
|
|
133
|
+
trend: 'up',
|
|
134
|
+
trendValue: '+3.2%',
|
|
135
|
+
},
|
|
136
|
+
layout: { x: 1, y: 0, w: 1, h: 1 },
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: 'd-m3',
|
|
140
|
+
component: {
|
|
141
|
+
type: 'metric-card',
|
|
142
|
+
title: 'Return Rate',
|
|
143
|
+
value: '2.1%',
|
|
144
|
+
icon: 'TrendingDown',
|
|
145
|
+
trend: 'down',
|
|
146
|
+
trendValue: '-0.8%',
|
|
147
|
+
},
|
|
148
|
+
layout: { x: 2, y: 0, w: 1, h: 1 },
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: 'd-chart',
|
|
152
|
+
title: 'Weekly Sales',
|
|
153
|
+
component: {
|
|
154
|
+
type: 'chart',
|
|
155
|
+
chartType: 'bar',
|
|
156
|
+
data: [
|
|
157
|
+
{ day: 'Mon', sales: 120 },
|
|
158
|
+
{ day: 'Tue', sales: 180 },
|
|
159
|
+
{ day: 'Wed', sales: 150 },
|
|
160
|
+
{ day: 'Thu', sales: 210 },
|
|
161
|
+
{ day: 'Fri', sales: 190 },
|
|
162
|
+
],
|
|
163
|
+
xAxisKey: 'day',
|
|
164
|
+
series: [{ dataKey: 'sales' }],
|
|
165
|
+
config: {
|
|
166
|
+
sales: { label: 'Sales', color: '#3b82f6' },
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
layout: { x: 0, y: 1, w: 3, h: 2 },
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
} as any,
|
|
173
|
+
};
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
import type { DashboardSchema, DashboardWidgetSchema } from '@object-ui/types';
|
|
10
10
|
import { SchemaRenderer } from '@object-ui/react';
|
|
11
|
-
import { cn, Card, CardHeader, CardTitle, CardContent } from '@object-ui/components';
|
|
12
|
-
import { forwardRef } from 'react';
|
|
11
|
+
import { cn, Card, CardHeader, CardTitle, CardContent, Button } from '@object-ui/components';
|
|
12
|
+
import { forwardRef, useState, useEffect, useCallback, useRef } from 'react';
|
|
13
|
+
import { RefreshCw } from 'lucide-react';
|
|
13
14
|
|
|
14
15
|
// Color palette for charts
|
|
15
16
|
const CHART_COLORS = [
|
|
@@ -20,117 +21,178 @@ const CHART_COLORS = [
|
|
|
20
21
|
'hsl(var(--chart-5))',
|
|
21
22
|
];
|
|
22
23
|
|
|
23
|
-
export
|
|
24
|
-
|
|
24
|
+
export interface DashboardRendererProps {
|
|
25
|
+
schema: DashboardSchema;
|
|
26
|
+
className?: string;
|
|
27
|
+
/** Callback invoked when dashboard refresh is triggered (manual or auto) */
|
|
28
|
+
onRefresh?: () => void;
|
|
29
|
+
[key: string]: any;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererProps>(
|
|
33
|
+
({ schema, className, dataSource, onRefresh, ...props }, ref) => {
|
|
25
34
|
const columns = schema.columns || 4; // Default to 4 columns for better density
|
|
26
35
|
const gap = schema.gap || 4;
|
|
36
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
37
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
38
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
27
39
|
|
|
28
|
-
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (widgetType === 'table') {
|
|
68
|
-
// Map to ObjectGrid
|
|
69
|
-
return {
|
|
70
|
-
type: 'data-table',
|
|
71
|
-
...(widget as any).options,
|
|
72
|
-
data: (widget as any).data?.items || [],
|
|
73
|
-
searchable: false, // Simple table for dashboard
|
|
74
|
-
pagination: false,
|
|
75
|
-
className: "border-0"
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// For generic widgets (like 'metric'), we simply spread the options into the schema
|
|
80
|
-
// so they appear as top-level props for the component.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const checkMobile = () => setIsMobile(window.innerWidth < 640);
|
|
42
|
+
checkMobile();
|
|
43
|
+
window.addEventListener('resize', checkMobile);
|
|
44
|
+
return () => window.removeEventListener('resize', checkMobile);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
const handleRefresh = useCallback(() => {
|
|
48
|
+
if (!onRefresh) return;
|
|
49
|
+
setRefreshing(true);
|
|
50
|
+
onRefresh();
|
|
51
|
+
// Reset refreshing indicator after a short delay
|
|
52
|
+
setTimeout(() => setRefreshing(false), 600);
|
|
53
|
+
}, [onRefresh]);
|
|
54
|
+
|
|
55
|
+
// Auto-refresh interval
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!schema.refreshInterval || schema.refreshInterval <= 0 || !onRefresh) return;
|
|
58
|
+
intervalRef.current = setInterval(handleRefresh, schema.refreshInterval * 1000);
|
|
59
|
+
return () => {
|
|
60
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
61
|
+
};
|
|
62
|
+
}, [schema.refreshInterval, onRefresh, handleRefresh]);
|
|
63
|
+
|
|
64
|
+
const renderWidget = (widget: DashboardWidgetSchema) => {
|
|
65
|
+
const getComponentSchema = () => {
|
|
66
|
+
if (widget.component) return widget.component;
|
|
67
|
+
|
|
68
|
+
// Handle Shorthand Registry Mappings
|
|
69
|
+
const widgetType = (widget as any).type;
|
|
70
|
+
if (widgetType === 'bar' || widgetType === 'line' || widgetType === 'area' || widgetType === 'pie' || widgetType === 'donut') {
|
|
71
|
+
const dataItems = Array.isArray((widget as any).data) ? (widget as any).data : (widget as any).data?.items || [];
|
|
72
|
+
const options = (widget as any).options || {};
|
|
73
|
+
const xAxisKey = options.xField || 'name';
|
|
74
|
+
const yField = options.yField || 'value';
|
|
75
|
+
|
|
81
76
|
return {
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
type: 'chart',
|
|
78
|
+
chartType: widgetType,
|
|
79
|
+
data: dataItems,
|
|
80
|
+
xAxisKey: xAxisKey,
|
|
81
|
+
series: [{ dataKey: yField }],
|
|
82
|
+
colors: CHART_COLORS,
|
|
83
|
+
className: "h-[200px] sm:h-[250px] md:h-[300px]"
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (widgetType === 'table') {
|
|
88
|
+
return {
|
|
89
|
+
type: 'data-table',
|
|
90
|
+
...(widget as any).options,
|
|
91
|
+
data: (widget as any).data?.items || [],
|
|
92
|
+
searchable: false,
|
|
93
|
+
pagination: false,
|
|
94
|
+
className: "border-0"
|
|
84
95
|
};
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const componentSchema = getComponentSchema();
|
|
88
|
-
|
|
89
|
-
// Check if the widget is self-contained (like a Metric Card) to avoid double borders
|
|
90
|
-
const isSelfContained = (widget as any).type === 'metric';
|
|
91
|
-
|
|
92
|
-
if (isSelfContained) {
|
|
93
|
-
return (
|
|
94
|
-
<div
|
|
95
|
-
key={widget.id || widget.title}
|
|
96
|
-
className="h-full w-full"
|
|
97
|
-
style={widget.layout ? {
|
|
98
|
-
gridColumn: `span ${widget.layout.w}`,
|
|
99
|
-
gridRow: `span ${widget.layout.h}`
|
|
100
|
-
}: undefined}
|
|
101
|
-
>
|
|
102
|
-
<SchemaRenderer schema={componentSchema} className="h-full w-full" />
|
|
103
|
-
</div>
|
|
104
|
-
);
|
|
105
96
|
}
|
|
106
97
|
|
|
98
|
+
return {
|
|
99
|
+
...widget,
|
|
100
|
+
...((widget as any).options || {})
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const componentSchema = getComponentSchema();
|
|
105
|
+
const isSelfContained = (widget as any).type === 'metric';
|
|
106
|
+
|
|
107
|
+
if (isSelfContained) {
|
|
107
108
|
return (
|
|
108
|
-
<
|
|
109
|
+
<div
|
|
109
110
|
key={widget.id || widget.title}
|
|
110
|
-
className={cn(
|
|
111
|
-
|
|
112
|
-
"bg-card/50 backdrop-blur-sm"
|
|
113
|
-
)}
|
|
114
|
-
style={widget.layout ? {
|
|
111
|
+
className={cn("h-full w-full", isMobile && "w-[85vw] shrink-0 snap-center")}
|
|
112
|
+
style={!isMobile && widget.layout ? {
|
|
115
113
|
gridColumn: `span ${widget.layout.w}`,
|
|
116
114
|
gridRow: `span ${widget.layout.h}`
|
|
117
115
|
}: undefined}
|
|
118
116
|
>
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
<CardTitle className="text-base font-medium tracking-tight truncate" title={widget.title}>
|
|
122
|
-
{widget.title}
|
|
123
|
-
</CardTitle>
|
|
124
|
-
</CardHeader>
|
|
125
|
-
)}
|
|
126
|
-
<CardContent className="p-0">
|
|
127
|
-
<div className={cn("h-full w-full", !widget.title ? "p-4" : "p-4")}>
|
|
128
|
-
<SchemaRenderer schema={componentSchema} />
|
|
129
|
-
</div>
|
|
130
|
-
</CardContent>
|
|
131
|
-
</Card>
|
|
117
|
+
<SchemaRenderer schema={componentSchema} className="h-full w-full" />
|
|
118
|
+
</div>
|
|
132
119
|
);
|
|
133
|
-
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<Card
|
|
124
|
+
key={widget.id || widget.title}
|
|
125
|
+
className={cn(
|
|
126
|
+
"overflow-hidden border-border/50 shadow-sm transition-all hover:shadow-md",
|
|
127
|
+
"bg-card/50 backdrop-blur-sm",
|
|
128
|
+
isMobile && "w-[85vw] shrink-0 snap-center"
|
|
129
|
+
)}
|
|
130
|
+
style={!isMobile && widget.layout ? {
|
|
131
|
+
gridColumn: `span ${widget.layout.w}`,
|
|
132
|
+
gridRow: `span ${widget.layout.h}`
|
|
133
|
+
}: undefined}
|
|
134
|
+
>
|
|
135
|
+
{widget.title && (
|
|
136
|
+
<CardHeader className="pb-2 border-b border-border/40 bg-muted/20 px-3 sm:px-6">
|
|
137
|
+
<CardTitle className="text-sm sm:text-base font-medium tracking-tight truncate" title={widget.title}>
|
|
138
|
+
{widget.title}
|
|
139
|
+
</CardTitle>
|
|
140
|
+
</CardHeader>
|
|
141
|
+
)}
|
|
142
|
+
<CardContent className="p-0">
|
|
143
|
+
<div className={cn("h-full w-full", "p-3 sm:p-4 md:p-6")}>
|
|
144
|
+
<SchemaRenderer schema={componentSchema} />
|
|
145
|
+
</div>
|
|
146
|
+
</CardContent>
|
|
147
|
+
</Card>
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const refreshButton = onRefresh && (
|
|
152
|
+
<div className={cn(isMobile ? "flex justify-end mb-2" : "col-span-full flex justify-end mb-2")}>
|
|
153
|
+
<Button
|
|
154
|
+
variant="outline"
|
|
155
|
+
size="sm"
|
|
156
|
+
onClick={handleRefresh}
|
|
157
|
+
disabled={refreshing}
|
|
158
|
+
aria-label="Refresh dashboard"
|
|
159
|
+
>
|
|
160
|
+
<RefreshCw className={cn("h-4 w-4 mr-2", refreshing && "animate-spin")} />
|
|
161
|
+
{refreshing ? 'Refreshing…' : 'Refresh All'}
|
|
162
|
+
</Button>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (isMobile) {
|
|
167
|
+
return (
|
|
168
|
+
<div ref={ref} className={cn("flex flex-col", className)} {...props}>
|
|
169
|
+
{refreshButton}
|
|
170
|
+
<div
|
|
171
|
+
className="flex overflow-x-auto snap-x snap-mandatory gap-3 pb-4 [-webkit-overflow-scrolling:touch]"
|
|
172
|
+
style={{ scrollPaddingLeft: '0.75rem' }}
|
|
173
|
+
>
|
|
174
|
+
{schema.widgets?.map((widget: DashboardWidgetSchema) => renderWidget(widget))}
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div
|
|
182
|
+
ref={ref}
|
|
183
|
+
className={cn(
|
|
184
|
+
"grid auto-rows-min",
|
|
185
|
+
"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
|
|
186
|
+
className
|
|
187
|
+
)}
|
|
188
|
+
style={{
|
|
189
|
+
...(columns > 4 && { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }),
|
|
190
|
+
gap: `${gap * 0.25}rem`
|
|
191
|
+
}}
|
|
192
|
+
{...props}
|
|
193
|
+
>
|
|
194
|
+
{refreshButton}
|
|
195
|
+
{schema.widgets?.map((widget: DashboardWidgetSchema) => renderWidget(widget))}
|
|
134
196
|
</div>
|
|
135
197
|
);
|
|
136
198
|
}
|