Skip to content

Commit 6ec3016

Browse files
authored
Merge pull request #466 from objectstack-ai/copilot/continue-development-from-roadmap
2 parents 41fce27 + 9ddc657 commit 6ec3016

File tree

11 files changed

+926
-120
lines changed

11 files changed

+926
-120
lines changed

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ ObjectUI's current overall compliance stands at **82%** (down from 91% against v
3838
- ✅ 57+ Storybook stories with interactive demos
3939
- ✅ TypeScript 5.9+ strict mode (100%)
4040
- ✅ React 19 + Tailwind CSS + Shadcn UI
41-
- ✅ All 42 builds pass, all 3011 tests pass
41+
- ✅ All 42 builds pass, all 3185+ tests pass
4242
-@objectstack/client v2.0.7 integration validated (100% protocol coverage)
4343

4444
**Core Features (Complete):**

SPEC_COMPLIANCE_EVALUATION.md

Lines changed: 96 additions & 99 deletions
Large diffs are not rendered by default.

packages/plugin-dashboard/src/DashboardGridLayout.tsx

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as React from 'react';
22
import { ResponsiveGridLayout, useContainerWidth, type LayoutItem as RGLLayout, type Layout, type ResponsiveLayouts } from 'react-grid-layout';
33
import 'react-grid-layout/css/styles.css';
44
import { cn, Card, CardHeader, CardTitle, CardContent, Button } from '@object-ui/components';
5-
import { Edit, GripVertical, Save, X } from 'lucide-react';
5+
import { Edit, GripVertical, Save, X, RefreshCw } from 'lucide-react';
66
import { SchemaRenderer, useHasDndProvider, useDnd } from '@object-ui/react';
77
import type { DashboardSchema, DashboardWidgetSchema } from '@object-ui/types';
88

@@ -35,17 +35,38 @@ export interface DashboardGridLayoutProps {
3535
className?: string;
3636
onLayoutChange?: (layout: RGLLayout[]) => void;
3737
persistLayoutKey?: string;
38+
/** Callback invoked when dashboard refresh is triggered (manual or auto) */
39+
onRefresh?: () => void;
3840
}
3941

4042
export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
4143
schema,
4244
className,
4345
onLayoutChange,
4446
persistLayoutKey = 'dashboard-layout',
47+
onRefresh,
4548
}) => {
4649
const { width, containerRef, mounted } = useContainerWidth();
4750
const [editMode, setEditMode] = React.useState(false);
51+
const [refreshing, setRefreshing] = React.useState(false);
4852
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]);
4970
const [layouts, setLayouts] = React.useState<{ lg: RGLLayout[] }>(() => {
5071
// Try to load saved layout
5172
if (typeof window !== 'undefined' && persistLayoutKey) {
@@ -158,10 +179,24 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
158179
</Button>
159180
</>
160181
) : (
161-
<Button onClick={() => setEditMode(true)} size="sm" variant="outline">
162-
<Edit className="h-4 w-4 mr-2" />
163-
Edit Layout
164-
</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+
</>
165200
)}
166201
</div>
167202
</div>

packages/plugin-dashboard/src/DashboardRenderer.tsx

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88

99
import type { DashboardSchema, DashboardWidgetSchema } from '@object-ui/types';
1010
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';
1314

1415
// Color palette for charts
1516
const CHART_COLORS = [
@@ -20,10 +21,37 @@ const CHART_COLORS = [
2021
'hsl(var(--chart-5))',
2122
];
2223

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) => {
2534
const columns = schema.columns || 4; // Default to 4 columns for better density
2635
const gap = schema.gap || 4;
36+
const [refreshing, setRefreshing] = useState(false);
37+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
38+
39+
const handleRefresh = useCallback(() => {
40+
if (!onRefresh) return;
41+
setRefreshing(true);
42+
onRefresh();
43+
// Reset refreshing indicator after a short delay
44+
setTimeout(() => setRefreshing(false), 600);
45+
}, [onRefresh]);
46+
47+
// Auto-refresh interval
48+
useEffect(() => {
49+
if (!schema.refreshInterval || schema.refreshInterval <= 0 || !onRefresh) return;
50+
intervalRef.current = setInterval(handleRefresh, schema.refreshInterval * 1000);
51+
return () => {
52+
if (intervalRef.current) clearInterval(intervalRef.current);
53+
};
54+
}, [schema.refreshInterval, onRefresh, handleRefresh]);
2755

2856
return (
2957
<div
@@ -35,6 +63,20 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, { schema: DashboardS
3563
}}
3664
{...props}
3765
>
66+
{onRefresh && (
67+
<div className="col-span-full flex justify-end mb-2">
68+
<Button
69+
variant="outline"
70+
size="sm"
71+
onClick={handleRefresh}
72+
disabled={refreshing}
73+
aria-label="Refresh dashboard"
74+
>
75+
<RefreshCw className={cn("h-4 w-4 mr-2", refreshing && "animate-spin")} />
76+
{refreshing ? 'Refreshing…' : 'Refresh All'}
77+
</Button>
78+
</div>
79+
)}
3880
{schema.widgets?.map((widget: DashboardWidgetSchema) => {
3981
// Logic to determine what to render
4082
// Supports both Component Schema (widget.component) and Shorthand (widget.type)
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

packages/react/src/SchemaRenderer.tsx

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9-
import React, { forwardRef, useContext, useMemo } from 'react';
9+
import React, { forwardRef, useContext, useMemo, Component } from 'react';
1010
import { SchemaNode, ComponentRegistry, ExpressionEvaluator } from '@object-ui/core';
1111
import { SchemaRendererContext } from './context/SchemaRendererContext';
1212
import { resolveI18nLabel } from './utils/i18n';
@@ -34,6 +34,51 @@ function resolveAriaProps(schema: Record<string, any>): Record<string, string |
3434
return aria;
3535
}
3636

37+
/**
38+
* Per-component Error Boundary for SchemaRenderer.
39+
* Catches render errors in individual components, preventing one broken
40+
* component from crashing the entire page.
41+
*/
42+
interface SchemaErrorBoundaryState {
43+
hasError: boolean;
44+
error: Error | null;
45+
}
46+
47+
export class SchemaErrorBoundary extends Component<
48+
{ componentType?: string; children: React.ReactNode },
49+
SchemaErrorBoundaryState
50+
> {
51+
state: SchemaErrorBoundaryState = { hasError: false, error: null };
52+
53+
static getDerivedStateFromError(error: Error): SchemaErrorBoundaryState {
54+
return { hasError: true, error };
55+
}
56+
57+
handleRetry = () => {
58+
this.setState({ hasError: false, error: null });
59+
};
60+
61+
render() {
62+
if (this.state.hasError && this.state.error) {
63+
return (
64+
<div className="p-4 border border-orange-400 rounded bg-orange-50 text-orange-700 my-2" role="alert">
65+
<p className="font-medium">
66+
Component{this.props.componentType ? ` "${this.props.componentType}"` : ''} failed to render
67+
</p>
68+
<p className="text-sm mt-1">{this.state.error.message}</p>
69+
<button
70+
onClick={this.handleRetry}
71+
className="mt-2 text-sm underline hover:no-underline"
72+
>
73+
Retry
74+
</button>
75+
</div>
76+
);
77+
}
78+
return this.props.children;
79+
}
80+
}
81+
3782
export const SchemaRenderer = forwardRef<any, { schema: SchemaNode } & Record<string, any>>(({ schema, ...props }, _ref) => {
3883
const context = useContext(SchemaRendererContext);
3984
const dataSource = context?.dataSource || {};
@@ -105,15 +150,19 @@ export const SchemaRenderer = forwardRef<any, { schema: SchemaNode } & Record<st
105150
// Extract AriaPropsSchema properties for accessibility
106151
const ariaProps = resolveAriaProps(evaluatedSchema);
107152

108-
return React.createElement(Component, {
109-
schema: evaluatedSchema,
110-
...componentProps, // Spread non-metadata schema properties as props
111-
...(evaluatedSchema.props || {}), // Override with explicit props if provided
112-
...ariaProps, // Inject ARIA attributes from AriaPropsSchema
113-
className: evaluatedSchema.className,
114-
'data-obj-id': evaluatedSchema.id,
115-
'data-obj-type': evaluatedSchema.type,
116-
...props
117-
});
153+
return (
154+
<SchemaErrorBoundary componentType={evaluatedSchema.type}>
155+
{React.createElement(Component, {
156+
schema: evaluatedSchema,
157+
...componentProps, // Spread non-metadata schema properties as props
158+
...(evaluatedSchema.props || {}), // Override with explicit props if provided
159+
...ariaProps, // Inject ARIA attributes from AriaPropsSchema
160+
className: evaluatedSchema.className,
161+
'data-obj-id': evaluatedSchema.id,
162+
'data-obj-type': evaluatedSchema.type,
163+
...props
164+
})}
165+
</SchemaErrorBoundary>
166+
);
118167
});
119168
SchemaRenderer.displayName = 'SchemaRenderer';

0 commit comments

Comments
 (0)