@object-ui/plugin-dashboard 0.1.1 → 0.5.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 (36) hide show
  1. package/.turbo/turbo-build.log +20 -0
  2. package/dist/index.css +1 -0
  3. package/dist/index.js +5797 -266
  4. package/dist/index.umd.cjs +5 -2
  5. package/dist/src/DashboardGridLayout.d.ts +11 -0
  6. package/dist/src/DashboardGridLayout.d.ts.map +1 -0
  7. package/dist/src/DashboardRenderer.d.ts +1 -1
  8. package/dist/src/DashboardRenderer.d.ts.map +1 -1
  9. package/dist/src/MetricCard.d.ts +16 -0
  10. package/dist/src/MetricCard.d.ts.map +1 -0
  11. package/dist/src/MetricWidget.d.ts +1 -1
  12. package/dist/src/MetricWidget.d.ts.map +1 -1
  13. package/dist/src/ReportBuilder.d.ts +11 -0
  14. package/dist/src/ReportBuilder.d.ts.map +1 -0
  15. package/dist/src/ReportRenderer.d.ts +15 -0
  16. package/dist/src/ReportRenderer.d.ts.map +1 -0
  17. package/dist/src/ReportViewer.d.ts +11 -0
  18. package/dist/src/ReportViewer.d.ts.map +1 -0
  19. package/dist/src/index.d.ts +19 -1
  20. package/dist/src/index.d.ts.map +1 -1
  21. package/package.json +10 -8
  22. package/src/DashboardGridLayout.tsx +210 -0
  23. package/src/DashboardRenderer.tsx +108 -20
  24. package/src/MetricCard.tsx +75 -0
  25. package/src/MetricWidget.tsx +13 -3
  26. package/src/ReportBuilder.tsx +625 -0
  27. package/src/ReportRenderer.tsx +89 -0
  28. package/src/ReportViewer.tsx +232 -0
  29. package/src/__tests__/DashboardGridLayout.test.tsx +199 -0
  30. package/src/__tests__/MetricCard.test.tsx +59 -0
  31. package/src/__tests__/ReportBuilder.test.tsx +115 -0
  32. package/src/__tests__/ReportViewer.test.tsx +107 -0
  33. package/src/index.tsx +122 -3
  34. package/vite.config.ts +19 -0
  35. package/vitest.config.ts +9 -0
  36. package/vitest.setup.tsx +18 -0
@@ -0,0 +1,210 @@
1
+ import * as React from 'react';
2
+ import { Responsive, WidthProvider, Layout as RGLLayout } from 'react-grid-layout';
3
+ import 'react-grid-layout/css/styles.css';
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';
7
+ import type { DashboardSchema, DashboardWidgetSchema } from '@object-ui/types';
8
+
9
+ const ResponsiveGridLayout = WidthProvider(Responsive);
10
+
11
+ const CHART_COLORS = [
12
+ 'hsl(var(--chart-1))',
13
+ 'hsl(var(--chart-2))',
14
+ 'hsl(var(--chart-3))',
15
+ 'hsl(var(--chart-4))',
16
+ 'hsl(var(--chart-5))',
17
+ ];
18
+
19
+ export interface DashboardGridLayoutProps {
20
+ schema: DashboardSchema;
21
+ className?: string;
22
+ onLayoutChange?: (layout: RGLLayout[]) => void;
23
+ persistLayoutKey?: string;
24
+ }
25
+
26
+ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
27
+ schema,
28
+ className,
29
+ onLayoutChange,
30
+ persistLayoutKey = 'dashboard-layout',
31
+ }) => {
32
+ const [editMode, setEditMode] = React.useState(false);
33
+ const [layouts, setLayouts] = React.useState<{ lg: RGLLayout[] }>(() => {
34
+ // Try to load saved layout
35
+ if (typeof window !== 'undefined' && persistLayoutKey) {
36
+ const saved = localStorage.getItem(persistLayoutKey);
37
+ if (saved) {
38
+ try {
39
+ return JSON.parse(saved);
40
+ } catch (e) {
41
+ console.error('Failed to parse saved layout:', e);
42
+ }
43
+ }
44
+ }
45
+
46
+ // Default layout from schema
47
+ return {
48
+ lg: schema.widgets?.map((widget: DashboardWidgetSchema, index: number) => ({
49
+ i: widget.id || `widget-${index}`,
50
+ x: widget.layout?.x || (index % 4) * 3,
51
+ y: widget.layout?.y || Math.floor(index / 4) * 4,
52
+ w: widget.layout?.w || 3,
53
+ h: widget.layout?.h || 4,
54
+ })) || [],
55
+ };
56
+ });
57
+
58
+ const handleLayoutChange = React.useCallback(
59
+ (layout: RGLLayout[], allLayouts: { lg: RGLLayout[] }) => {
60
+ setLayouts(allLayouts);
61
+ onLayoutChange?.(layout);
62
+ },
63
+ [onLayoutChange]
64
+ );
65
+
66
+ const handleSaveLayout = React.useCallback(() => {
67
+ if (typeof window !== 'undefined' && persistLayoutKey) {
68
+ localStorage.setItem(persistLayoutKey, JSON.stringify(layouts));
69
+ }
70
+ setEditMode(false);
71
+ }, [layouts, persistLayoutKey]);
72
+
73
+ const handleResetLayout = React.useCallback(() => {
74
+ const defaultLayouts = {
75
+ lg: schema.widgets?.map((widget: DashboardWidgetSchema, index: number) => ({
76
+ i: widget.id || `widget-${index}`,
77
+ x: widget.layout?.x || (index % 4) * 3,
78
+ y: widget.layout?.y || Math.floor(index / 4) * 4,
79
+ w: widget.layout?.w || 3,
80
+ h: widget.layout?.h || 4,
81
+ })) || [],
82
+ };
83
+ setLayouts(defaultLayouts);
84
+ if (typeof window !== 'undefined' && persistLayoutKey) {
85
+ localStorage.removeItem(persistLayoutKey);
86
+ }
87
+ }, [schema.widgets, persistLayoutKey]);
88
+
89
+ const getComponentSchema = React.useCallback((widget: DashboardWidgetSchema) => {
90
+ if (widget.component) return widget.component;
91
+
92
+ const widgetType = (widget as any).type;
93
+ if (widgetType === 'bar' || widgetType === 'line' || widgetType === 'area' || widgetType === 'pie' || widgetType === 'donut') {
94
+ const dataItems = Array.isArray((widget as any).data) ? (widget as any).data : (widget as any).data?.items || [];
95
+ const options = (widget as any).options || {};
96
+ const xAxisKey = options.xField || 'name';
97
+ const yField = options.yField || 'value';
98
+
99
+ return {
100
+ type: 'chart',
101
+ chartType: widgetType,
102
+ data: dataItems,
103
+ xAxisKey: xAxisKey,
104
+ series: [{ dataKey: yField }],
105
+ colors: CHART_COLORS,
106
+ className: "h-full"
107
+ };
108
+ }
109
+
110
+ if (widgetType === 'table') {
111
+ return {
112
+ type: 'data-table',
113
+ ...(widget as any).options,
114
+ data: (widget as any).data?.items || [],
115
+ searchable: false,
116
+ pagination: false,
117
+ className: "border-0"
118
+ };
119
+ }
120
+
121
+ return widget;
122
+ }, []);
123
+
124
+ return (
125
+ <div className={cn("w-full", className)} data-testid="grid-layout">
126
+ <div className="mb-4 flex items-center justify-between">
127
+ <h2 className="text-2xl font-bold">{schema.title || 'Dashboard'}</h2>
128
+ <div className="flex gap-2">
129
+ {editMode ? (
130
+ <>
131
+ <Button onClick={handleSaveLayout} size="sm" variant="default">
132
+ <Save className="h-4 w-4 mr-2" />
133
+ Save Layout
134
+ </Button>
135
+ <Button onClick={handleResetLayout} size="sm" variant="outline">
136
+ <X className="h-4 w-4 mr-2" />
137
+ Reset
138
+ </Button>
139
+ <Button onClick={() => setEditMode(false)} size="sm" variant="ghost">
140
+ Cancel
141
+ </Button>
142
+ </>
143
+ ) : (
144
+ <Button onClick={() => setEditMode(true)} size="sm" variant="outline">
145
+ <Edit className="h-4 w-4 mr-2" />
146
+ Edit Layout
147
+ </Button>
148
+ )}
149
+ </div>
150
+ </div>
151
+
152
+ <ResponsiveGridLayout
153
+ className="layout"
154
+ layouts={layouts}
155
+ breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
156
+ cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
157
+ rowHeight={60}
158
+ isDraggable={editMode}
159
+ isResizable={editMode}
160
+ onLayoutChange={handleLayoutChange}
161
+ draggableHandle=".drag-handle"
162
+ >
163
+ {schema.widgets?.map((widget, index) => {
164
+ const widgetId = widget.id || `widget-${index}`;
165
+ const componentSchema = getComponentSchema(widget);
166
+ const isSelfContained = (widget as any).type === 'metric';
167
+
168
+ return (
169
+ <div key={widgetId} className="h-full">
170
+ {isSelfContained ? (
171
+ <div className="h-full w-full relative">
172
+ {editMode && (
173
+ <div className="drag-handle absolute top-2 right-2 z-10 cursor-move p-1 bg-background/80 rounded border border-border">
174
+ <GripVertical className="h-4 w-4" />
175
+ </div>
176
+ )}
177
+ <SchemaRenderer schema={componentSchema} className="h-full w-full" />
178
+ </div>
179
+ ) : (
180
+ <Card className={cn(
181
+ "h-full overflow-hidden border-border/50 shadow-sm transition-all",
182
+ "bg-card/50 backdrop-blur-sm",
183
+ editMode && "ring-2 ring-primary/20"
184
+ )}>
185
+ {widget.title && (
186
+ <CardHeader className="pb-2 border-b border-border/40 bg-muted/20 flex flex-row items-center justify-between">
187
+ <CardTitle className="text-base font-medium tracking-tight truncate" title={widget.title}>
188
+ {widget.title}
189
+ </CardTitle>
190
+ {editMode && (
191
+ <div className="drag-handle cursor-move p-1 hover:bg-muted/40 rounded">
192
+ <GripVertical className="h-4 w-4" />
193
+ </div>
194
+ )}
195
+ </CardHeader>
196
+ )}
197
+ <CardContent className="p-0 h-full">
198
+ <div className={cn("h-full w-full overflow-auto", !widget.title ? "p-4" : "p-4")}>
199
+ <SchemaRenderer schema={componentSchema} />
200
+ </div>
201
+ </CardContent>
202
+ </Card>
203
+ )}
204
+ </div>
205
+ );
206
+ })}
207
+ </ResponsiveGridLayout>
208
+ </div>
209
+ );
210
+ };
@@ -6,43 +6,131 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
 
9
- import { ComponentRegistry } from '@object-ui/core';
10
9
  import type { DashboardSchema, DashboardWidgetSchema } from '@object-ui/types';
11
10
  import { SchemaRenderer } from '@object-ui/react';
12
- import { cn } from '@object-ui/components';
11
+ import { cn, Card, CardHeader, CardTitle, CardContent } from '@object-ui/components';
13
12
  import { forwardRef } from 'react';
14
13
 
14
+ // Color palette for charts
15
+ const CHART_COLORS = [
16
+ 'hsl(var(--chart-1))',
17
+ 'hsl(var(--chart-2))',
18
+ 'hsl(var(--chart-3))',
19
+ 'hsl(var(--chart-4))',
20
+ 'hsl(var(--chart-5))',
21
+ ];
22
+
15
23
  export const DashboardRenderer = forwardRef<HTMLDivElement, { schema: DashboardSchema; className?: string; [key: string]: any }>(
16
24
  ({ schema, className, ...props }, ref) => {
17
- const columns = schema.columns || 3;
25
+ const columns = schema.columns || 4; // Default to 4 columns for better density
18
26
  const gap = schema.gap || 4;
19
27
 
20
- // Use style to convert gap number to pixels or use tailwind classes if possible
21
- // Here using inline style for grid gap which maps to 0.25rem * 4 * gap = gap rem
22
-
23
28
  return (
24
29
  <div
25
30
  ref={ref}
26
- className={cn("grid", className)}
31
+ className={cn("grid auto-rows-min", className)}
27
32
  style={{
28
33
  gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
29
34
  gap: `${gap * 0.25}rem`
30
35
  }}
31
36
  {...props}
32
37
  >
33
- {schema.widgets?.map((widget: DashboardWidgetSchema) => (
34
- <div
35
- key={widget.id}
36
- className={cn("border rounded-lg p-4 bg-card text-card-foreground shadow-sm")}
37
- style={widget.layout ? {
38
- gridColumn: `span ${widget.layout.w}`,
39
- gridRow: `span ${widget.layout.h}`
40
- }: undefined}
41
- >
42
- {widget.title && <h3 className="font-semibold mb-2">{widget.title}</h3>}
43
- <SchemaRenderer schema={widget.component} />
44
- </div>
45
- ))}
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.
81
+ return {
82
+ ...widget,
83
+ ...((widget as any).options || {})
84
+ };
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
+ }
106
+
107
+ return (
108
+ <Card
109
+ 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 ? {
115
+ gridColumn: `span ${widget.layout.w}`,
116
+ gridRow: `span ${widget.layout.h}`
117
+ }: undefined}
118
+ >
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>
132
+ );
133
+ })}
46
134
  </div>
47
135
  );
48
136
  }
@@ -0,0 +1,75 @@
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 from 'react';
10
+ import { Card, CardContent, CardHeader, CardTitle } from '@object-ui/components';
11
+ import { cn } from '@object-ui/components';
12
+ import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from 'lucide-react';
13
+ import * as LucideIcons from 'lucide-react';
14
+
15
+ export interface MetricCardProps {
16
+ title?: string;
17
+ value: string | number;
18
+ icon?: string;
19
+ trend?: 'up' | 'down' | 'neutral';
20
+ trendValue?: string;
21
+ description?: string;
22
+ className?: string;
23
+ }
24
+
25
+ /**
26
+ * MetricCard - Standalone metric card component for dashboard KPIs
27
+ * Displays a metric value with optional icon, trend indicator, and description
28
+ */
29
+ export const MetricCard: React.FC<MetricCardProps> = ({
30
+ title,
31
+ value,
32
+ icon,
33
+ trend,
34
+ trendValue,
35
+ description,
36
+ className,
37
+ ...props
38
+ }) => {
39
+ // Resolve icon from lucide-react
40
+ const IconComponent = icon && (LucideIcons as any)[icon];
41
+
42
+ return (
43
+ <Card className={cn("h-full", className)} {...props}>
44
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
45
+ <CardTitle className="text-sm font-medium">
46
+ {title}
47
+ </CardTitle>
48
+ {IconComponent && (
49
+ <IconComponent className="h-4 w-4 text-muted-foreground" />
50
+ )}
51
+ </CardHeader>
52
+ <CardContent>
53
+ <div className="text-2xl font-bold">{value}</div>
54
+ {(trend || trendValue || description) && (
55
+ <p className="text-xs text-muted-foreground flex items-center mt-1">
56
+ {trend && trendValue && (
57
+ <span className={cn(
58
+ "flex items-center mr-2",
59
+ trend === 'up' && "text-green-500",
60
+ trend === 'down' && "text-red-500",
61
+ trend === 'neutral' && "text-yellow-500"
62
+ )}>
63
+ {trend === 'up' && <ArrowUpIcon className="h-3 w-3 mr-1" />}
64
+ {trend === 'down' && <ArrowDownIcon className="h-3 w-3 mr-1" />}
65
+ {trend === 'neutral' && <MinusIcon className="h-3 w-3 mr-1" />}
66
+ {trendValue}
67
+ </span>
68
+ )}
69
+ {description}
70
+ </p>
71
+ )}
72
+ </CardContent>
73
+ </Card>
74
+ );
75
+ };
@@ -1,7 +1,8 @@
1
- import React from 'react';
1
+ import React, { useMemo } from 'react';
2
2
  import { Card, CardContent, CardHeader, CardTitle } from '@object-ui/components';
3
3
  import { cn } from '@object-ui/components';
4
4
  import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from 'lucide-react';
5
+ import * as LucideIcons from 'lucide-react';
5
6
 
6
7
  export interface MetricWidgetProps {
7
8
  label: string;
@@ -11,7 +12,7 @@ export interface MetricWidgetProps {
11
12
  label?: string;
12
13
  direction?: 'up' | 'down' | 'neutral';
13
14
  };
14
- icon?: React.ReactNode;
15
+ icon?: React.ReactNode | string;
15
16
  className?: string;
16
17
  description?: string;
17
18
  }
@@ -25,13 +26,22 @@ export const MetricWidget = ({
25
26
  description,
26
27
  ...props
27
28
  }: MetricWidgetProps) => {
29
+ // Resolve icon if it's a string
30
+ const resolvedIcon = useMemo(() => {
31
+ if (typeof icon === 'string') {
32
+ const IconComponent = (LucideIcons as any)[icon];
33
+ return IconComponent ? <IconComponent className="h-4 w-4 text-muted-foreground" /> : null;
34
+ }
35
+ return icon;
36
+ }, [icon]);
37
+
28
38
  return (
29
39
  <Card className={cn("h-full", className)} {...props}>
30
40
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
31
41
  <CardTitle className="text-sm font-medium">
32
42
  {label}
33
43
  </CardTitle>
34
- {icon && <div className="h-4 w-4 text-muted-foreground">{icon}</div>}
44
+ {resolvedIcon && <div className="h-4 w-4 text-muted-foreground">{resolvedIcon}</div>}
35
45
  </CardHeader>
36
46
  <CardContent>
37
47
  <div className="text-2xl font-bold">{value}</div>