@object-ui/plugin-dashboard 0.1.1 → 2.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 +20 -0
- package/CHANGELOG.md +14 -0
- package/dist/index.css +1 -0
- package/dist/index.js +4563 -264
- package/dist/index.umd.cjs +5 -2
- package/dist/src/DashboardGridLayout.d.ts +11 -0
- package/dist/src/DashboardGridLayout.d.ts.map +1 -0
- package/dist/src/DashboardRenderer.d.ts +1 -1
- package/dist/src/DashboardRenderer.d.ts.map +1 -1
- package/dist/src/MetricCard.d.ts +16 -0
- package/dist/src/MetricCard.d.ts.map +1 -0
- package/dist/src/MetricWidget.d.ts +1 -1
- package/dist/src/MetricWidget.d.ts.map +1 -1
- package/dist/src/index.d.ts +13 -1
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +10 -8
- package/src/DashboardGridLayout.tsx +211 -0
- package/src/DashboardRenderer.tsx +111 -23
- package/src/MetricCard.tsx +75 -0
- package/src/MetricWidget.tsx +13 -3
- package/src/__tests__/DashboardGridLayout.test.tsx +199 -0
- package/src/__tests__/MetricCard.test.tsx +59 -0
- package/src/index.tsx +64 -3
- package/vite.config.ts +19 -0
- package/vitest.config.ts +9 -0
- package/vitest.setup.tsx +18 -0
|
@@ -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
|
-
({ schema, className, ...props }, ref) => {
|
|
17
|
-
const columns = schema.columns ||
|
|
24
|
+
({ schema, className, dataSource, ...props }, ref) => {
|
|
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
|
-
<div
|
|
25
|
-
ref={ref}
|
|
26
|
-
className={cn("grid", className)}
|
|
29
|
+
<div
|
|
30
|
+
ref={ref}
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
+
};
|
package/src/MetricWidget.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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>
|
|
@@ -0,0 +1,199 @@
|
|
|
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 } from 'vitest';
|
|
10
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
11
|
+
import { DashboardGridLayout } from '../DashboardGridLayout';
|
|
12
|
+
import type { DashboardSchema } from '@object-ui/types';
|
|
13
|
+
|
|
14
|
+
// Mock localStorage
|
|
15
|
+
const localStorageMock = (() => {
|
|
16
|
+
let store: Record<string, string> = {};
|
|
17
|
+
return {
|
|
18
|
+
getItem: (key: string) => store[key] || null,
|
|
19
|
+
setItem: (key: string, value: string) => { store[key] = value; },
|
|
20
|
+
clear: () => { store = {}; },
|
|
21
|
+
removeItem: (key: string) => { delete store[key]; },
|
|
22
|
+
};
|
|
23
|
+
})();
|
|
24
|
+
|
|
25
|
+
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
|
26
|
+
|
|
27
|
+
// Mock react-grid-layout
|
|
28
|
+
vi.mock('react-grid-layout', () => ({
|
|
29
|
+
Responsive: ({ children }: any) => <div data-testid="grid-layout">{children}</div>,
|
|
30
|
+
WidthProvider: (Component: any) => Component,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
describe('DashboardGridLayout', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
localStorageMock.clear();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const mockSchema: DashboardSchema = {
|
|
39
|
+
type: 'dashboard',
|
|
40
|
+
name: 'test_dashboard',
|
|
41
|
+
title: 'Test Dashboard',
|
|
42
|
+
widgets: [
|
|
43
|
+
{
|
|
44
|
+
id: 'widget-1',
|
|
45
|
+
type: 'metric-card',
|
|
46
|
+
title: 'Total Sales',
|
|
47
|
+
layout: { x: 0, y: 0, w: 3, h: 2 },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'widget-2',
|
|
51
|
+
type: 'bar',
|
|
52
|
+
title: 'Revenue by Month',
|
|
53
|
+
layout: { x: 3, y: 0, w: 6, h: 4 },
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
it('should render without crashing', () => {
|
|
59
|
+
const { container } = render(<DashboardGridLayout schema={mockSchema} />);
|
|
60
|
+
expect(container).toBeTruthy();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should render dashboard title', () => {
|
|
64
|
+
render(<DashboardGridLayout schema={mockSchema} />);
|
|
65
|
+
expect(screen.getByText('Test Dashboard')).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should render all widgets', () => {
|
|
69
|
+
render(<DashboardGridLayout schema={mockSchema} />);
|
|
70
|
+
|
|
71
|
+
expect(screen.getAllByText('Total Sales').length).toBeGreaterThan(0);
|
|
72
|
+
expect(screen.getByText('Revenue by Month')).toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should render edit mode button', () => {
|
|
76
|
+
render(<DashboardGridLayout schema={mockSchema} />);
|
|
77
|
+
|
|
78
|
+
const editButton = screen.getByRole('button', { name: /edit/i });
|
|
79
|
+
expect(editButton).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should toggle edit mode when edit button is clicked', () => {
|
|
83
|
+
render(<DashboardGridLayout schema={mockSchema} />);
|
|
84
|
+
|
|
85
|
+
const editButton = screen.getByRole('button', { name: /edit/i });
|
|
86
|
+
fireEvent.click(editButton);
|
|
87
|
+
|
|
88
|
+
// In edit mode, should show Save and Cancel buttons
|
|
89
|
+
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
|
|
90
|
+
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should save layout to localStorage when save button is clicked', () => {
|
|
94
|
+
render(<DashboardGridLayout schema={mockSchema} persistLayoutKey="test-layout" />);
|
|
95
|
+
|
|
96
|
+
const editButton = screen.getByRole('button', { name: /edit/i });
|
|
97
|
+
fireEvent.click(editButton);
|
|
98
|
+
|
|
99
|
+
const saveButton = screen.getByRole('button', { name: /save/i });
|
|
100
|
+
fireEvent.click(saveButton);
|
|
101
|
+
|
|
102
|
+
// Check that layout was saved to localStorage
|
|
103
|
+
const saved = localStorageMock.getItem('test-layout');
|
|
104
|
+
expect(saved).toBeTruthy();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should restore layout from localStorage', () => {
|
|
108
|
+
const savedLayout = {
|
|
109
|
+
lg: [
|
|
110
|
+
{ i: 'widget-1', x: 0, y: 0, w: 3, h: 2 },
|
|
111
|
+
{ i: 'widget-2', x: 3, y: 0, w: 6, h: 4 },
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
localStorageMock.setItem('test-layout', JSON.stringify(savedLayout));
|
|
116
|
+
|
|
117
|
+
render(<DashboardGridLayout schema={mockSchema} persistLayoutKey="test-layout" />);
|
|
118
|
+
|
|
119
|
+
// Component should render with saved layout
|
|
120
|
+
expect(screen.getByText('Test Dashboard')).toBeInTheDocument();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should call onLayoutChange when layout changes', () => {
|
|
124
|
+
const onLayoutChange = vi.fn();
|
|
125
|
+
render(<DashboardGridLayout schema={mockSchema} onLayoutChange={onLayoutChange} />);
|
|
126
|
+
|
|
127
|
+
// Trigger layout change (this would normally happen through drag/drop)
|
|
128
|
+
// In our mock, we can't easily trigger this, but we verify the callback exists
|
|
129
|
+
expect(onLayoutChange).toBeDefined();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should cancel edit mode when cancel button is clicked', () => {
|
|
133
|
+
render(<DashboardGridLayout schema={mockSchema} />);
|
|
134
|
+
|
|
135
|
+
const editButton = screen.getByRole('button', { name: /edit/i });
|
|
136
|
+
fireEvent.click(editButton);
|
|
137
|
+
|
|
138
|
+
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
|
139
|
+
fireEvent.click(cancelButton);
|
|
140
|
+
|
|
141
|
+
// Should exit edit mode
|
|
142
|
+
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
|
|
143
|
+
expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should reset layout when reset button is clicked', () => {
|
|
147
|
+
render(<DashboardGridLayout schema={mockSchema} />);
|
|
148
|
+
|
|
149
|
+
const editButton = screen.getByRole('button', { name: /edit/i });
|
|
150
|
+
fireEvent.click(editButton);
|
|
151
|
+
|
|
152
|
+
// Look for reset button (might be in a dropdown or menu)
|
|
153
|
+
const buttons = screen.getAllByRole('button');
|
|
154
|
+
const resetButton = buttons.find(btn => btn.textContent?.includes('Reset'));
|
|
155
|
+
|
|
156
|
+
if (resetButton) {
|
|
157
|
+
fireEvent.click(resetButton);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should render grid layout container', () => {
|
|
162
|
+
render(<DashboardGridLayout schema={mockSchema} />);
|
|
163
|
+
|
|
164
|
+
const gridLayout = screen.getByTestId('grid-layout');
|
|
165
|
+
expect(gridLayout).toBeInTheDocument();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should handle empty widgets array', () => {
|
|
169
|
+
const emptySchema: DashboardSchema = {
|
|
170
|
+
type: 'dashboard',
|
|
171
|
+
name: 'empty_dashboard',
|
|
172
|
+
title: 'Empty Dashboard',
|
|
173
|
+
widgets: [],
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const { container } = render(<DashboardGridLayout schema={emptySchema} />);
|
|
177
|
+
expect(container).toBeTruthy();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should apply custom className', () => {
|
|
181
|
+
const { container } = render(
|
|
182
|
+
<DashboardGridLayout schema={mockSchema} className="custom-class" />
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const dashboardContainer = container.querySelector('.custom-class');
|
|
186
|
+
expect(dashboardContainer).toBeTruthy();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should render drag handles in edit mode', () => {
|
|
190
|
+
const { container } = render(<DashboardGridLayout schema={mockSchema} />);
|
|
191
|
+
|
|
192
|
+
const editButton = screen.getByRole('button', { name: /edit/i });
|
|
193
|
+
fireEvent.click(editButton);
|
|
194
|
+
|
|
195
|
+
// In edit mode, widgets should have drag handles (GripVertical icons)
|
|
196
|
+
const gripIcons = container.querySelectorAll('svg');
|
|
197
|
+
expect(gripIcons.length).toBeGreaterThan(0);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
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 } from 'vitest';
|
|
10
|
+
import { render, screen } from '@testing-library/react';
|
|
11
|
+
import '@testing-library/jest-dom';
|
|
12
|
+
import { MetricCard } from '../MetricCard';
|
|
13
|
+
|
|
14
|
+
describe('MetricCard', () => {
|
|
15
|
+
it('should render metric card with title and value', () => {
|
|
16
|
+
render(<MetricCard title="Total Users" value="1,234" />);
|
|
17
|
+
|
|
18
|
+
expect(screen.getByText('Total Users')).toBeInTheDocument();
|
|
19
|
+
expect(screen.getByText('1,234')).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should render trend indicator when trend is provided', () => {
|
|
23
|
+
render(
|
|
24
|
+
<MetricCard
|
|
25
|
+
title="Revenue"
|
|
26
|
+
value="$45,231"
|
|
27
|
+
trend="up"
|
|
28
|
+
trendValue="+12%"
|
|
29
|
+
description="vs last month"
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(screen.getByText('Revenue')).toBeInTheDocument();
|
|
34
|
+
expect(screen.getByText('$45,231')).toBeInTheDocument();
|
|
35
|
+
expect(screen.getByText('+12%')).toBeInTheDocument();
|
|
36
|
+
expect(screen.getByText('vs last month')).toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should render description without trend', () => {
|
|
40
|
+
render(
|
|
41
|
+
<MetricCard
|
|
42
|
+
title="Active Sessions"
|
|
43
|
+
value="432"
|
|
44
|
+
description="current users online"
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(screen.getByText('Active Sessions')).toBeInTheDocument();
|
|
49
|
+
expect(screen.getByText('432')).toBeInTheDocument();
|
|
50
|
+
expect(screen.getByText('current users online')).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should handle numeric values', () => {
|
|
54
|
+
render(<MetricCard title="Count" value={1234} />);
|
|
55
|
+
|
|
56
|
+
expect(screen.getByText('Count')).toBeInTheDocument();
|
|
57
|
+
expect(screen.getByText('1234')).toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/index.tsx
CHANGED
|
@@ -8,15 +8,18 @@
|
|
|
8
8
|
|
|
9
9
|
import { ComponentRegistry } from '@object-ui/core';
|
|
10
10
|
import { DashboardRenderer } from './DashboardRenderer';
|
|
11
|
+
import { DashboardGridLayout } from './DashboardGridLayout';
|
|
11
12
|
import { MetricWidget } from './MetricWidget';
|
|
13
|
+
import { MetricCard } from './MetricCard';
|
|
12
14
|
|
|
13
|
-
export { DashboardRenderer, MetricWidget };
|
|
15
|
+
export { DashboardRenderer, DashboardGridLayout, MetricWidget, MetricCard };
|
|
14
16
|
|
|
15
17
|
// Register dashboard component
|
|
16
18
|
ComponentRegistry.register(
|
|
17
19
|
'dashboard',
|
|
18
20
|
DashboardRenderer,
|
|
19
21
|
{
|
|
22
|
+
namespace: 'view',
|
|
20
23
|
label: 'Dashboard',
|
|
21
24
|
category: 'Complex',
|
|
22
25
|
icon: 'layout-dashboard',
|
|
@@ -32,12 +35,13 @@ ComponentRegistry.register(
|
|
|
32
35
|
}
|
|
33
36
|
);
|
|
34
37
|
|
|
35
|
-
// Register metric
|
|
38
|
+
// Register metric widget (legacy)
|
|
36
39
|
ComponentRegistry.register(
|
|
37
40
|
'metric',
|
|
38
41
|
MetricWidget,
|
|
39
42
|
{
|
|
40
|
-
|
|
43
|
+
namespace: 'plugin-dashboard',
|
|
44
|
+
label: 'Metric Widget',
|
|
41
45
|
category: 'Dashboard',
|
|
42
46
|
inputs: [
|
|
43
47
|
{ name: 'label', type: 'string', label: 'Label' },
|
|
@@ -45,3 +49,60 @@ ComponentRegistry.register(
|
|
|
45
49
|
]
|
|
46
50
|
}
|
|
47
51
|
);
|
|
52
|
+
|
|
53
|
+
// Register metric card (new standalone component)
|
|
54
|
+
ComponentRegistry.register(
|
|
55
|
+
'metric-card',
|
|
56
|
+
MetricCard,
|
|
57
|
+
{
|
|
58
|
+
namespace: 'plugin-dashboard',
|
|
59
|
+
label: 'Metric Card',
|
|
60
|
+
category: 'Dashboard',
|
|
61
|
+
inputs: [
|
|
62
|
+
{ name: 'title', type: 'string', label: 'Title' },
|
|
63
|
+
{ name: 'value', type: 'string', label: 'Value', required: true },
|
|
64
|
+
{ name: 'icon', type: 'string', label: 'Icon (Lucide name)' },
|
|
65
|
+
{ name: 'trend', type: 'enum', label: 'Trend', enum: [
|
|
66
|
+
{ label: 'Up', value: 'up' },
|
|
67
|
+
{ label: 'Down', value: 'down' },
|
|
68
|
+
{ label: 'Neutral', value: 'neutral' }
|
|
69
|
+
]},
|
|
70
|
+
{ name: 'trendValue', type: 'string', label: 'Trend Value (e.g., +12%)' },
|
|
71
|
+
{ name: 'description', type: 'string', label: 'Description' },
|
|
72
|
+
],
|
|
73
|
+
defaultProps: {
|
|
74
|
+
title: 'Metric',
|
|
75
|
+
value: '0'
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Register dashboard grid layout component
|
|
81
|
+
ComponentRegistry.register(
|
|
82
|
+
'dashboard-grid',
|
|
83
|
+
DashboardGridLayout,
|
|
84
|
+
{
|
|
85
|
+
namespace: 'plugin-dashboard',
|
|
86
|
+
label: 'Dashboard Grid (Editable)',
|
|
87
|
+
category: 'Complex',
|
|
88
|
+
icon: 'layout-grid',
|
|
89
|
+
inputs: [
|
|
90
|
+
{ name: 'title', type: 'string', label: 'Title' },
|
|
91
|
+
{ name: 'persistLayoutKey', type: 'string', label: 'Layout Storage Key', defaultValue: 'dashboard-layout' },
|
|
92
|
+
{ name: 'className', type: 'string', label: 'CSS Class' }
|
|
93
|
+
],
|
|
94
|
+
defaultProps: {
|
|
95
|
+
title: 'Dashboard',
|
|
96
|
+
widgets: [],
|
|
97
|
+
persistLayoutKey: 'dashboard-layout',
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Standard Export Protocol - for manual integration
|
|
103
|
+
export const dashboardComponents = {
|
|
104
|
+
DashboardRenderer,
|
|
105
|
+
DashboardGridLayout,
|
|
106
|
+
MetricWidget,
|
|
107
|
+
MetricCard,
|
|
108
|
+
};
|
package/vite.config.ts
CHANGED
|
@@ -9,8 +9,21 @@ export default defineConfig({
|
|
|
9
9
|
dts({
|
|
10
10
|
insertTypesEntry: true,
|
|
11
11
|
include: ['src'],
|
|
12
|
+
exclude: ['**/*.test.ts', '**/*.test.tsx', 'node_modules'],
|
|
13
|
+
skipDiagnostics: true,
|
|
12
14
|
}),
|
|
13
15
|
],
|
|
16
|
+
resolve: {
|
|
17
|
+
alias: {
|
|
18
|
+
'@': resolve(__dirname, './src'),
|
|
19
|
+
'@object-ui/core': resolve(__dirname, '../core/src'),
|
|
20
|
+
'@object-ui/types': resolve(__dirname, '../types/src'),
|
|
21
|
+
'@object-ui/react': resolve(__dirname, '../react/src'),
|
|
22
|
+
'@object-ui/components': resolve(__dirname, '../components/src'),
|
|
23
|
+
'@object-ui/fields': resolve(__dirname, '../fields/src'),
|
|
24
|
+
'@object-ui/plugin-grid': resolve(__dirname, '../plugin-grid/src'),
|
|
25
|
+
},
|
|
26
|
+
},
|
|
14
27
|
build: {
|
|
15
28
|
lib: {
|
|
16
29
|
entry: resolve(__dirname, 'src/index.tsx'),
|
|
@@ -41,4 +54,10 @@ export default defineConfig({
|
|
|
41
54
|
},
|
|
42
55
|
},
|
|
43
56
|
},
|
|
57
|
+
test: {
|
|
58
|
+
globals: true,
|
|
59
|
+
environment: 'happy-dom',
|
|
60
|
+
setupFiles: ['./vitest.setup.tsx'],
|
|
61
|
+
passWithNoTests: true,
|
|
62
|
+
},
|
|
44
63
|
});
|
package/vitest.config.ts
ADDED