Skip to content

Commit 4f42f7b

Browse files
authored
Merge pull request #798 from objectstack-ai/copilot/enable-widget-selection-preview
2 parents d9bdd90 + 2dbd1db commit 4f42f7b

File tree

7 files changed

+856
-11
lines changed

7 files changed

+856
-11
lines changed

ROADMAP.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,17 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
355355
- [x] Add `DashboardConfig` types to `@object-ui/types`
356356
- [x] Add Zod schema validation for `DashboardConfig`
357357

358+
**Phase 6 — Design Mode Preview Click-to-Select:**
359+
- [x] Add `designMode`, `selectedWidgetId`, `onWidgetClick` props to `DashboardRenderer` for preview-area widget selection
360+
- [x] Implement click-to-select with primary ring highlight (light/dark theme compatible, a11y focus-visible ring)
361+
- [x] Click empty space to deselect; Escape key to deselect
362+
- [x] Keyboard navigation: ArrowRight/ArrowDown to next widget, ArrowLeft/ArrowUp to previous, Enter/Space to select, Tab/Shift+Tab for focus
363+
- [x] Add `selectedWidgetId` and `onWidgetSelect` props to `DashboardEditor` for external controlled selection
364+
- [x] Sync selection between `DashboardRenderer` (preview) and `DashboardEditor` (drawer) via shared state in `DashboardView`
365+
- [x] Property changes in editor panel instantly reflected in preview (live preview path verified end-to-end)
366+
- [x] Auto-save property changes to backend via DesignDrawer
367+
- [x] Add Vitest tests (15 DashboardRenderer design mode + 9 DashboardEditor external selection + 8 DashboardView integration = 32 new tests)
368+
358369
### P1.11 Console — Schema-Driven View Config Panel Migration
359370

360371
> Migrated the Console ViewConfigPanel from imperative implementation (~1655 lines) to Schema-Driven architecture using `ConfigPanelRenderer` + `useConfigDraft` + `ConfigPanelSchema`, reducing to ~170 lines declarative wrapper + schema factory.
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
/**
2+
* DashboardView Selection Sync Tests
3+
*
4+
* Integration tests verifying the full click-to-select flow:
5+
* Preview widget click → editor panel shows properties → edit property → preview updates
6+
*/
7+
8+
import { describe, it, expect, vi, beforeEach } from 'vitest';
9+
import { render, screen, fireEvent, act } from '@testing-library/react';
10+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
11+
import { DashboardView } from '../components/DashboardView';
12+
13+
// Track the latest onWidgetSelect and selectedWidgetId passed to DashboardEditor
14+
const { editorCalls, rendererCalls } = vi.hoisted(() => ({
15+
editorCalls: { selectedWidgetId: null as string | null, onWidgetSelect: null as ((id: string | null) => void) | null, lastOnChange: null as ((s: any) => void) | null },
16+
rendererCalls: { designMode: false, selectedWidgetId: null as string | null, onWidgetClick: null as ((id: string | null) => void) | null },
17+
}));
18+
19+
// Mock MetadataProvider with a dashboard
20+
vi.mock('../context/MetadataProvider', () => ({
21+
useMetadata: () => ({
22+
apps: [],
23+
objects: [],
24+
dashboards: [
25+
{
26+
name: 'sales',
27+
type: 'dashboard',
28+
title: 'Sales Dashboard',
29+
label: 'Sales Dashboard',
30+
columns: 2,
31+
widgets: [
32+
{ id: 'w1', title: 'Revenue', type: 'metric', object: 'orders', valueField: 'amount', aggregate: 'sum' },
33+
{ id: 'w2', title: 'Sales Chart', type: 'bar', object: 'orders', categoryField: 'month' },
34+
],
35+
},
36+
],
37+
reports: [],
38+
pages: [],
39+
loading: false,
40+
error: null,
41+
refresh: vi.fn(),
42+
}),
43+
}));
44+
45+
// Mock AdapterProvider
46+
const { mockUpdate } = vi.hoisted(() => ({ mockUpdate: vi.fn().mockResolvedValue({}) }));
47+
vi.mock('../context/AdapterProvider', () => ({
48+
useAdapter: () => ({
49+
update: mockUpdate,
50+
create: vi.fn().mockResolvedValue({}),
51+
}),
52+
}));
53+
54+
// Mock DashboardRenderer to capture designMode, selectedWidgetId, and onWidgetClick
55+
vi.mock('@object-ui/plugin-dashboard', () => ({
56+
DashboardRenderer: (props: any) => {
57+
rendererCalls.designMode = props.designMode;
58+
rendererCalls.selectedWidgetId = props.selectedWidgetId;
59+
rendererCalls.onWidgetClick = props.onWidgetClick;
60+
return (
61+
<div data-testid="dashboard-renderer">
62+
<span data-testid="renderer-design-mode">{String(!!props.designMode)}</span>
63+
<span data-testid="renderer-selected">{props.selectedWidgetId ?? 'none'}</span>
64+
{props.schema?.widgets?.map((w: any) => (
65+
<div
66+
key={w.id}
67+
data-testid={`renderer-widget-${w.id}`}
68+
data-widget-title={w.title}
69+
onClick={() => props.onWidgetClick?.(w.id)}
70+
>
71+
{w.title}
72+
</div>
73+
))}
74+
</div>
75+
);
76+
},
77+
}));
78+
79+
// Mock DashboardEditor to capture selectedWidgetId and onWidgetSelect
80+
vi.mock('@object-ui/plugin-designer', () => ({
81+
DashboardEditor: (props: any) => {
82+
editorCalls.selectedWidgetId = props.selectedWidgetId;
83+
editorCalls.onWidgetSelect = props.onWidgetSelect;
84+
editorCalls.lastOnChange = props.onChange;
85+
const widget = props.schema?.widgets?.find((w: any) => w.id === props.selectedWidgetId);
86+
return (
87+
<div data-testid="dashboard-editor">
88+
<span data-testid="editor-selected">{props.selectedWidgetId ?? 'none'}</span>
89+
{widget && (
90+
<div data-testid="editor-property-panel">
91+
<span data-testid="editor-widget-title">{widget.title}</span>
92+
<button
93+
data-testid="editor-change-title"
94+
onClick={() => {
95+
const updated = {
96+
...props.schema,
97+
widgets: props.schema.widgets.map((w: any) =>
98+
w.id === props.selectedWidgetId
99+
? { ...w, title: 'Updated Revenue' }
100+
: w,
101+
),
102+
};
103+
props.onChange(updated);
104+
}}
105+
>
106+
Change Title
107+
</button>
108+
</div>
109+
)}
110+
{/* Clicking a widget in the editor list */}
111+
{props.schema?.widgets?.map((w: any) => (
112+
<button
113+
key={w.id}
114+
data-testid={`editor-widget-${w.id}`}
115+
onClick={() => props.onWidgetSelect?.(w.id)}
116+
>
117+
{w.title}
118+
</button>
119+
))}
120+
</div>
121+
);
122+
},
123+
}));
124+
125+
// Mock sonner toast
126+
vi.mock('sonner', () => ({
127+
toast: {
128+
success: vi.fn(),
129+
error: vi.fn(),
130+
},
131+
}));
132+
133+
// Mock Radix Dialog portal to render inline for testing
134+
vi.mock('@radix-ui/react-dialog', async () => {
135+
const actual = await vi.importActual<typeof import('@radix-ui/react-dialog')>('@radix-ui/react-dialog');
136+
return {
137+
...actual,
138+
Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
139+
};
140+
});
141+
142+
beforeEach(() => {
143+
mockUpdate.mockClear();
144+
editorCalls.selectedWidgetId = null;
145+
editorCalls.onWidgetSelect = null;
146+
editorCalls.lastOnChange = null;
147+
rendererCalls.designMode = false;
148+
rendererCalls.selectedWidgetId = null;
149+
rendererCalls.onWidgetClick = null;
150+
});
151+
152+
const renderDashboardView = async () => {
153+
const result = render(
154+
<MemoryRouter initialEntries={['/dashboard/sales']}>
155+
<Routes>
156+
<Route path="/dashboard/:dashboardName" element={<DashboardView />} />
157+
</Routes>
158+
</MemoryRouter>,
159+
);
160+
// Wait for the queueMicrotask loading state to resolve
161+
await act(async () => {
162+
await new Promise((r) => setTimeout(r, 10));
163+
});
164+
return result;
165+
};
166+
167+
describe('DashboardView — Selection Sync Integration', () => {
168+
it('should not enable design mode when drawer is closed', async () => {
169+
await renderDashboardView();
170+
171+
expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('false');
172+
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none');
173+
});
174+
175+
it('should enable design mode when drawer is opened', async () => {
176+
await renderDashboardView();
177+
178+
await act(async () => {
179+
fireEvent.click(screen.getByTestId('dashboard-edit-button'));
180+
});
181+
182+
expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('true');
183+
});
184+
185+
it('should sync widget selection from preview to editor when clicking a widget', async () => {
186+
await renderDashboardView();
187+
188+
// Open drawer
189+
await act(async () => {
190+
fireEvent.click(screen.getByTestId('dashboard-edit-button'));
191+
});
192+
193+
// Click widget w1 in the preview area
194+
await act(async () => {
195+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
196+
});
197+
198+
// Both renderer and editor should show w1 as selected
199+
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w1');
200+
expect(screen.getByTestId('editor-selected')).toHaveTextContent('w1');
201+
// Editor should show property panel for w1
202+
expect(screen.getByTestId('editor-property-panel')).toBeInTheDocument();
203+
expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Revenue');
204+
});
205+
206+
it('should sync widget selection from editor to preview', async () => {
207+
await renderDashboardView();
208+
209+
// Open drawer
210+
await act(async () => {
211+
fireEvent.click(screen.getByTestId('dashboard-edit-button'));
212+
});
213+
214+
// Click widget w2 in the editor
215+
await act(async () => {
216+
fireEvent.click(screen.getByTestId('editor-widget-w2'));
217+
});
218+
219+
// Both should show w2 selected
220+
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w2');
221+
expect(screen.getByTestId('editor-selected')).toHaveTextContent('w2');
222+
});
223+
224+
it('should update preview when property is edited in the editor panel', async () => {
225+
await renderDashboardView();
226+
227+
// Open drawer
228+
await act(async () => {
229+
fireEvent.click(screen.getByTestId('dashboard-edit-button'));
230+
});
231+
232+
// Select widget w1 in preview
233+
await act(async () => {
234+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
235+
});
236+
237+
// Edit the title in the property panel
238+
await act(async () => {
239+
fireEvent.click(screen.getByTestId('editor-change-title'));
240+
});
241+
242+
// Preview should now show the updated title
243+
expect(screen.getByTestId('renderer-widget-w1')).toHaveTextContent('Updated Revenue');
244+
});
245+
246+
it('should deselect when clicking background (null selection)', async () => {
247+
await renderDashboardView();
248+
249+
// Open drawer
250+
await act(async () => {
251+
fireEvent.click(screen.getByTestId('dashboard-edit-button'));
252+
});
253+
254+
// Select w1
255+
await act(async () => {
256+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
257+
});
258+
expect(screen.getByTestId('editor-property-panel')).toBeInTheDocument();
259+
260+
// Simulate background click by calling onWidgetClick(null)
261+
await act(async () => {
262+
rendererCalls.onWidgetClick?.(null);
263+
});
264+
265+
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none');
266+
expect(screen.getByTestId('editor-selected')).toHaveTextContent('none');
267+
expect(screen.queryByTestId('editor-property-panel')).not.toBeInTheDocument();
268+
});
269+
270+
it('should clear selection when drawer is closed', async () => {
271+
await renderDashboardView();
272+
273+
// Open drawer and select a widget
274+
await act(async () => {
275+
fireEvent.click(screen.getByTestId('dashboard-edit-button'));
276+
});
277+
await act(async () => {
278+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
279+
});
280+
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w1');
281+
282+
// Close the drawer via the Sheet's close button (sr-only "Close" text)
283+
const closeButtons = screen.getAllByRole('button', { name: /close/i });
284+
const sheetCloseBtn = closeButtons.find((btn) =>
285+
btn.closest('[data-testid="design-drawer"]'),
286+
);
287+
if (sheetCloseBtn) {
288+
await act(async () => {
289+
fireEvent.click(sheetCloseBtn);
290+
});
291+
}
292+
293+
// After close, design mode should be off and selection cleared
294+
expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('false');
295+
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none');
296+
});
297+
298+
it('should auto-save property changes to backend', async () => {
299+
await renderDashboardView();
300+
301+
// Open drawer
302+
await act(async () => {
303+
fireEvent.click(screen.getByTestId('dashboard-edit-button'));
304+
});
305+
306+
// Select and edit
307+
await act(async () => {
308+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
309+
});
310+
await act(async () => {
311+
fireEvent.click(screen.getByTestId('editor-change-title'));
312+
});
313+
314+
// Backend should be called with updated schema
315+
expect(mockUpdate).toHaveBeenCalledWith(
316+
'sys_dashboard',
317+
'sales',
318+
expect.objectContaining({
319+
widgets: expect.arrayContaining([
320+
expect.objectContaining({ id: 'w1', title: 'Updated Revenue' }),
321+
]),
322+
}),
323+
);
324+
});
325+
});

apps/console/src/components/DashboardView.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
2525
const { showDebug, toggleDebug } = useMetadataInspector();
2626
const [isLoading, setIsLoading] = useState(true);
2727
const [drawerOpen, setDrawerOpen] = useState(false);
28+
const [selectedWidgetId, setSelectedWidgetId] = useState<string | null>(null);
2829

2930
useEffect(() => {
3031
// Reset loading on navigation; the actual DashboardRenderer handles data fetching
@@ -47,6 +48,7 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
4748

4849
const handleCloseDrawer = useCallback((open: boolean) => {
4950
setDrawerOpen(open);
51+
if (!open) setSelectedWidgetId(null);
5052
}, []);
5153

5254
if (isLoading) {
@@ -98,7 +100,13 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
98100

99101
<div className="flex-1 overflow-hidden flex flex-col sm:flex-row relative">
100102
<div className="flex-1 overflow-auto p-0 sm:p-6">
101-
<DashboardRenderer schema={previewSchema} dataSource={dataSource} />
103+
<DashboardRenderer
104+
schema={previewSchema}
105+
dataSource={dataSource}
106+
designMode={drawerOpen}
107+
selectedWidgetId={selectedWidgetId}
108+
onWidgetClick={setSelectedWidgetId}
109+
/>
102110
</div>
103111

104112
<MetadataPanel
@@ -118,7 +126,12 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
118126
>
119127
{(schema, onChange) => (
120128
<Suspense fallback={<div className="p-4 text-muted-foreground">Loading editor…</div>}>
121-
<DashboardEditor schema={schema} onChange={onChange} />
129+
<DashboardEditor
130+
schema={schema}
131+
onChange={onChange}
132+
selectedWidgetId={selectedWidgetId}
133+
onWidgetSelect={setSelectedWidgetId}
134+
/>
122135
</Suspense>
123136
)}
124137
</DesignDrawer>

0 commit comments

Comments
 (0)