@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/plugin-dashboard",
3
- "version": "2.0.0",
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.0",
18
+ "clsx": "^2.1.1",
19
19
  "lucide-react": "^0.563.0",
20
- "react": "^19.2.4",
21
- "react-dom": "^19.2.4",
20
+ "react": "19.2.4",
21
+ "react-dom": "19.2.4",
22
22
  "react-grid-layout": "^2.2.2",
23
- "tailwind-merge": "^2.2.1",
24
- "@object-ui/components": "2.0.0",
25
- "@object-ui/core": "2.0.0",
26
- "@object-ui/react": "2.0.0",
27
- "@object-ui/types": "2.0.0"
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.3",
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
- <Button onClick={() => setEditMode(true)} size="sm" variant="outline">
144
- <Edit className="h-4 w-4 mr-2" />
145
- Edit Layout
146
- </Button>
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 const DashboardRenderer = forwardRef<HTMLDivElement, { schema: DashboardSchema; className?: string; [key: string]: any }>(
24
- ({ schema, className, dataSource, ...props }, ref) => {
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
- return (
29
- <div
30
- ref={ref}
31
- className={cn("grid auto-rows-min", className)}
32
- style={{
33
- gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
34
- gap: `${gap * 0.25}rem`
35
- }}
36
- {...props}
37
- >
38
- {schema.widgets?.map((widget: DashboardWidgetSchema) => {
39
- // Logic to determine what to render
40
- // Supports both Component Schema (widget.component) and Shorthand (widget.type)
41
-
42
- const getComponentSchema = () => {
43
- if (widget.component) return widget.component;
44
-
45
- // Handle Shorthand Registry Mappings
46
- const widgetType = (widget as any).type;
47
- if (widgetType === 'bar' || widgetType === 'line' || widgetType === 'area' || widgetType === 'pie' || widgetType === 'donut') {
48
- // Extract data from 'data.items' or 'data' array
49
- const dataItems = Array.isArray((widget as any).data) ? (widget as any).data : (widget as any).data?.items || [];
50
-
51
- // Map xField/yField to ChartRenderer expectations
52
- const options = (widget as any).options || {};
53
- const xAxisKey = options.xField || 'name';
54
- const yField = options.yField || 'value';
55
-
56
- return {
57
- type: 'chart',
58
- chartType: widgetType,
59
- data: dataItems,
60
- xAxisKey: xAxisKey,
61
- series: [{ dataKey: yField }],
62
- colors: CHART_COLORS,
63
- className: "h-[300px]" // Enforce height
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
- ...widget,
83
- ...((widget as any).options || {})
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
- <Card
109
+ <div
109
110
  key={widget.id || widget.title}
110
- className={cn(
111
- "overflow-hidden border-border/50 shadow-sm transition-all hover:shadow-md",
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
- {widget.title && (
120
- <CardHeader className="pb-2 border-b border-border/40 bg-muted/20">
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
  }