@object-ui/plugin-dashboard 0.5.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.
@@ -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, ...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
  }
@@ -0,0 +1,124 @@
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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10
+ import { render, screen, fireEvent, act } from '@testing-library/react';
11
+ import { DashboardRenderer } from '../DashboardRenderer';
12
+ import type { DashboardSchema } from '@object-ui/types';
13
+
14
+ describe('DashboardRenderer auto-refresh', () => {
15
+ beforeEach(() => {
16
+ vi.useFakeTimers();
17
+ });
18
+
19
+ afterEach(() => {
20
+ vi.useRealTimers();
21
+ });
22
+
23
+ const mockSchema: DashboardSchema = {
24
+ type: 'dashboard',
25
+ name: 'test_dashboard',
26
+ title: 'Test Dashboard',
27
+ widgets: [],
28
+ };
29
+
30
+ it('should not render refresh button when onRefresh is not provided', () => {
31
+ render(<DashboardRenderer schema={mockSchema} />);
32
+ expect(screen.queryByLabelText('Refresh dashboard')).not.toBeInTheDocument();
33
+ });
34
+
35
+ it('should render refresh button when onRefresh is provided', () => {
36
+ const onRefresh = vi.fn();
37
+ render(<DashboardRenderer schema={mockSchema} onRefresh={onRefresh} />);
38
+ expect(screen.getByLabelText('Refresh dashboard')).toBeInTheDocument();
39
+ });
40
+
41
+ it('should call onRefresh when refresh button is clicked', () => {
42
+ const onRefresh = vi.fn();
43
+ render(<DashboardRenderer schema={mockSchema} onRefresh={onRefresh} />);
44
+
45
+ const button = screen.getByLabelText('Refresh dashboard');
46
+ fireEvent.click(button);
47
+
48
+ expect(onRefresh).toHaveBeenCalledTimes(1);
49
+ });
50
+
51
+ it('should auto-refresh at the configured interval', () => {
52
+ const onRefresh = vi.fn();
53
+ const schemaWithRefresh: DashboardSchema = {
54
+ ...mockSchema,
55
+ refreshInterval: 30, // 30 seconds
56
+ };
57
+
58
+ render(<DashboardRenderer schema={schemaWithRefresh} onRefresh={onRefresh} />);
59
+
60
+ expect(onRefresh).not.toHaveBeenCalled();
61
+
62
+ // Advance past one interval
63
+ act(() => {
64
+ vi.advanceTimersByTime(30_000);
65
+ });
66
+ expect(onRefresh).toHaveBeenCalledTimes(1);
67
+
68
+ // Advance past another interval
69
+ act(() => {
70
+ vi.advanceTimersByTime(30_000);
71
+ });
72
+ expect(onRefresh).toHaveBeenCalledTimes(2);
73
+ });
74
+
75
+ it('should not auto-refresh when refreshInterval is 0', () => {
76
+ const onRefresh = vi.fn();
77
+ const schemaWithZeroInterval: DashboardSchema = {
78
+ ...mockSchema,
79
+ refreshInterval: 0,
80
+ };
81
+
82
+ render(<DashboardRenderer schema={schemaWithZeroInterval} onRefresh={onRefresh} />);
83
+
84
+ act(() => {
85
+ vi.advanceTimersByTime(60_000);
86
+ });
87
+
88
+ expect(onRefresh).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it('should not auto-refresh when onRefresh is not provided', () => {
92
+ const schemaWithRefresh: DashboardSchema = {
93
+ ...mockSchema,
94
+ refreshInterval: 10,
95
+ };
96
+
97
+ // Should not throw
98
+ render(<DashboardRenderer schema={schemaWithRefresh} />);
99
+
100
+ act(() => {
101
+ vi.advanceTimersByTime(30_000);
102
+ });
103
+ });
104
+
105
+ it('should clean up interval on unmount', () => {
106
+ const onRefresh = vi.fn();
107
+ const schemaWithRefresh: DashboardSchema = {
108
+ ...mockSchema,
109
+ refreshInterval: 5,
110
+ };
111
+
112
+ const { unmount } = render(
113
+ <DashboardRenderer schema={schemaWithRefresh} onRefresh={onRefresh} />
114
+ );
115
+
116
+ unmount();
117
+
118
+ act(() => {
119
+ vi.advanceTimersByTime(30_000);
120
+ });
121
+
122
+ expect(onRefresh).not.toHaveBeenCalled();
123
+ });
124
+ });
package/src/index.tsx CHANGED
@@ -11,18 +11,15 @@ import { DashboardRenderer } from './DashboardRenderer';
11
11
  import { DashboardGridLayout } from './DashboardGridLayout';
12
12
  import { MetricWidget } from './MetricWidget';
13
13
  import { MetricCard } from './MetricCard';
14
- import { ReportRenderer } from './ReportRenderer';
15
- import { ReportViewer } from './ReportViewer';
16
- import { ReportBuilder } from './ReportBuilder';
17
14
 
18
- export { DashboardRenderer, DashboardGridLayout, MetricWidget, MetricCard, ReportRenderer, ReportViewer, ReportBuilder };
15
+ export { DashboardRenderer, DashboardGridLayout, MetricWidget, MetricCard };
19
16
 
20
17
  // Register dashboard component
21
18
  ComponentRegistry.register(
22
19
  'dashboard',
23
20
  DashboardRenderer,
24
21
  {
25
- namespace: 'plugin-dashboard',
22
+ namespace: 'view',
26
23
  label: 'Dashboard',
27
24
  category: 'Complex',
28
25
  icon: 'layout-dashboard',
@@ -80,57 +77,6 @@ ComponentRegistry.register(
80
77
  }
81
78
  );
82
79
 
83
- // Register report component (legacy)
84
- ComponentRegistry.register(
85
- 'report',
86
- ReportRenderer,
87
- {
88
- namespace: 'plugin-dashboard',
89
- label: 'Report',
90
- category: 'Dashboard',
91
- inputs: [
92
- { name: 'title', type: 'string', label: 'Title' },
93
- { name: 'description', type: 'string', label: 'Description' },
94
- { name: 'chart', type: 'code', label: 'Chart Configuration' },
95
- ]
96
- }
97
- );
98
-
99
- // Register report viewer component
100
- ComponentRegistry.register(
101
- 'report-viewer',
102
- ReportViewer,
103
- {
104
- namespace: 'plugin-dashboard',
105
- label: 'Report Viewer',
106
- category: 'Reports',
107
- inputs: [
108
- { name: 'report', type: 'code', label: 'Report Configuration', required: true },
109
- { name: 'data', type: 'code', label: 'Report Data' },
110
- { name: 'showToolbar', type: 'boolean', label: 'Show Toolbar', defaultValue: true },
111
- { name: 'allowExport', type: 'boolean', label: 'Allow Export', defaultValue: true },
112
- { name: 'allowPrint', type: 'boolean', label: 'Allow Print', defaultValue: true },
113
- ]
114
- }
115
- );
116
-
117
- // Register report builder component
118
- ComponentRegistry.register(
119
- 'report-builder',
120
- ReportBuilder,
121
- {
122
- namespace: 'plugin-dashboard',
123
- label: 'Report Builder',
124
- category: 'Reports',
125
- inputs: [
126
- { name: 'report', type: 'code', label: 'Initial Report Config' },
127
- { name: 'dataSources', type: 'code', label: 'Available Data Sources' },
128
- { name: 'availableFields', type: 'code', label: 'Available Fields' },
129
- { name: 'showPreview', type: 'boolean', label: 'Show Preview', defaultValue: true },
130
- ]
131
- }
132
- );
133
-
134
80
  // Register dashboard grid layout component
135
81
  ComponentRegistry.register(
136
82
  'dashboard-grid',
@@ -159,8 +105,4 @@ export const dashboardComponents = {
159
105
  DashboardGridLayout,
160
106
  MetricWidget,
161
107
  MetricCard,
162
- ReportRenderer,
163
- ReportViewer,
164
- ReportBuilder,
165
108
  };
166
-
@@ -1,11 +0,0 @@
1
- import { default as React } from 'react';
2
- import { ReportBuilderSchema } from '../../types/src';
3
- export interface ReportBuilderProps {
4
- schema: ReportBuilderSchema;
5
- }
6
- /**
7
- * ReportBuilder - Interactive report builder component
8
- * Allows users to configure report fields, filters, grouping, sections, and export settings
9
- */
10
- export declare const ReportBuilder: React.FC<ReportBuilderProps>;
11
- //# sourceMappingURL=ReportBuilder.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ReportBuilder.d.ts","sourceRoot":"","sources":["../../src/ReportBuilder.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAmB,MAAM,OAAO,CAAC;AAExC,OAAO,KAAK,EAAE,mBAAmB,EAAyE,MAAM,kBAAkB,CAAC;AAInI,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,mBAAmB,CAAC;CAC7B;AAED;;;GAGG;AACH,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CA0lBtD,CAAC"}
@@ -1,15 +0,0 @@
1
- import { default as React } from 'react';
2
- export interface ReportRendererProps {
3
- schema: {
4
- type: string;
5
- id?: string;
6
- title?: string;
7
- description?: string;
8
- chart?: any;
9
- data?: any[];
10
- columns?: any[];
11
- className?: string;
12
- };
13
- }
14
- export declare const ReportRenderer: React.FC<ReportRendererProps>;
15
- //# sourceMappingURL=ReportRenderer.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ReportRenderer.d.ts","sourceRoot":"","sources":["../../src/ReportRenderer.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAI1B,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE;QACN,IAAI,EAAE,MAAM,CAAC;QACb,EAAE,CAAC,EAAE,MAAM,CAAC;QACZ,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,KAAK,CAAC,EAAE,GAAG,CAAC;QACZ,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,EAAE,GAAG,EAAE,CAAC;QAChB,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,CAAC;CACH;AAED,eAAO,MAAM,cAAc,EAAE,KAAK,CAAC,EAAE,CAAC,mBAAmB,CAuExD,CAAC"}
@@ -1,11 +0,0 @@
1
- import { default as React } from 'react';
2
- import { ReportViewerSchema } from '../../types/src';
3
- export interface ReportViewerProps {
4
- schema: ReportViewerSchema;
5
- }
6
- /**
7
- * ReportViewer - Display a generated report with optional toolbar
8
- * Supports rendering report sections, charts, tables, and export functionality
9
- */
10
- export declare const ReportViewer: React.FC<ReportViewerProps>;
11
- //# sourceMappingURL=ReportViewer.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ReportViewer.d.ts","sourceRoot":"","sources":["../../src/ReportViewer.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,MAAM,OAAO,CAAC;AAI1B,OAAO,KAAK,EAAE,kBAAkB,EAAiB,MAAM,kBAAkB,CAAC;AAG1E,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,kBAAkB,CAAC;CAC5B;AAED;;;GAGG;AACH,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC,iBAAiB,CAgNpD,CAAC"}