Skip to content

Commit 7965ea9

Browse files
authored
Merge pull request #807 from objectstack-ai/copilot/fix-dashboard-design-panel
2 parents 29f8fc0 + 8855eca commit 7965ea9

File tree

6 files changed

+412
-16
lines changed

6 files changed

+412
-16
lines changed

ROADMAP.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,14 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
366366
- [x] Auto-save property changes to backend via DesignDrawer
367367
- [x] Add Vitest tests (15 DashboardRenderer design mode + 9 DashboardEditor external selection + 8 DashboardView integration = 32 new tests)
368368

369+
**Phase 7 — Non-Modal Drawer & Property Panel UX Fix:**
370+
- [x] `SheetContent` — added `hideOverlay` prop to conditionally skip the full-screen backdrop overlay
371+
- [x] `DesignDrawer``modal={false}` + `hideOverlay` so preview widgets are clickable while drawer is open
372+
- [x] `DashboardEditor` — property panel renders above widget grid (stacked `flex-col` layout) for immediate visibility in narrow drawer
373+
- [x] `DashboardEditor` — property panel uses full width (removed fixed `w-72`) for better readability in drawer context
374+
- [x] Preview click → editor property panel linkage now works end-to-end (select, switch, deselect)
375+
- [x] Add 11 new tests (7 DashboardDesignInteraction integration + 4 DashboardEditor.propertyPanelLayout)
376+
369377
### P1.11 Console — Schema-Driven View Config Panel Migration
370378

371379
> 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: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
/**
2+
* DashboardView Design Interaction Tests
3+
*
4+
* Verifies the fixes for:
5+
* - Non-modal DesignDrawer allowing preview widget clicks
6+
* - Property panel appearing above widget grid when a widget is selected
7+
* - Click-to-select in preview area with highlight and property linkage
8+
*/
9+
10+
import { describe, it, expect, vi, beforeEach } from 'vitest';
11+
import { render, screen, fireEvent, act } from '@testing-library/react';
12+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
13+
import { DashboardView } from '../components/DashboardView';
14+
15+
// Track calls passed to mocked components
16+
const { editorCalls, rendererCalls } = vi.hoisted(() => ({
17+
editorCalls: {
18+
selectedWidgetId: null as string | null,
19+
onWidgetSelect: null as ((id: string | null) => void) | null,
20+
lastSchema: null as unknown,
21+
},
22+
rendererCalls: {
23+
designMode: false,
24+
selectedWidgetId: null as string | null,
25+
onWidgetClick: null as ((id: string | null) => void) | null,
26+
},
27+
}));
28+
29+
// Mock MetadataProvider with a dashboard
30+
vi.mock('../context/MetadataProvider', () => ({
31+
useMetadata: () => ({
32+
apps: [],
33+
objects: [],
34+
dashboards: [
35+
{
36+
name: 'crm-dashboard',
37+
type: 'dashboard',
38+
title: 'CRM Overview',
39+
label: 'CRM Overview',
40+
columns: 2,
41+
widgets: [
42+
{ id: 'w1', title: 'Total Revenue', type: 'metric', object: 'orders', valueField: 'amount', aggregate: 'sum' },
43+
{ id: 'w2', title: 'Revenue Trends', type: 'line', object: 'orders', categoryField: 'month' },
44+
{ id: 'w3', title: 'Pipeline by Stage', type: 'bar', object: 'opportunities' },
45+
],
46+
},
47+
],
48+
reports: [],
49+
pages: [],
50+
loading: false,
51+
error: null,
52+
refresh: vi.fn(),
53+
}),
54+
}));
55+
56+
// Mock AdapterProvider
57+
const { mockUpdate } = vi.hoisted(() => ({ mockUpdate: vi.fn().mockResolvedValue({}) }));
58+
vi.mock('../context/AdapterProvider', () => ({
59+
useAdapter: () => ({
60+
update: mockUpdate,
61+
create: vi.fn().mockResolvedValue({}),
62+
}),
63+
}));
64+
65+
// Mock DashboardRenderer to capture design mode props
66+
vi.mock('@object-ui/plugin-dashboard', () => ({
67+
DashboardRenderer: (props: any) => {
68+
rendererCalls.designMode = props.designMode;
69+
rendererCalls.selectedWidgetId = props.selectedWidgetId;
70+
rendererCalls.onWidgetClick = props.onWidgetClick;
71+
return (
72+
<div data-testid="dashboard-renderer">
73+
<span data-testid="renderer-design-mode">{String(!!props.designMode)}</span>
74+
<span data-testid="renderer-selected">{props.selectedWidgetId ?? 'none'}</span>
75+
{props.schema?.widgets?.map((w: any) => (
76+
<div
77+
key={w.id}
78+
data-testid={`renderer-widget-${w.id}`}
79+
data-widget-title={w.title}
80+
onClick={() => props.onWidgetClick?.(w.id)}
81+
>
82+
{w.title}
83+
</div>
84+
))}
85+
</div>
86+
);
87+
},
88+
}));
89+
90+
// Mock DashboardEditor to capture selection and show property panel
91+
vi.mock('@object-ui/plugin-designer', () => ({
92+
DashboardEditor: (props: any) => {
93+
editorCalls.selectedWidgetId = props.selectedWidgetId;
94+
editorCalls.onWidgetSelect = props.onWidgetSelect;
95+
editorCalls.lastSchema = props.schema;
96+
const widget = props.schema?.widgets?.find((w: any) => w.id === props.selectedWidgetId);
97+
return (
98+
<div data-testid="dashboard-editor">
99+
<span data-testid="editor-selected">{props.selectedWidgetId ?? 'none'}</span>
100+
{widget && (
101+
<div data-testid="editor-property-panel">
102+
<span data-testid="editor-widget-title">{widget.title}</span>
103+
</div>
104+
)}
105+
{props.schema?.widgets?.map((w: any) => (
106+
<button
107+
key={w.id}
108+
data-testid={`editor-widget-${w.id}`}
109+
onClick={() => props.onWidgetSelect?.(w.id)}
110+
>
111+
{w.title}
112+
</button>
113+
))}
114+
</div>
115+
);
116+
},
117+
}));
118+
119+
// Mock sonner toast
120+
vi.mock('sonner', () => ({
121+
toast: {
122+
success: vi.fn(),
123+
error: vi.fn(),
124+
},
125+
}));
126+
127+
// Mock Radix Dialog portal to render inline for testing
128+
vi.mock('@radix-ui/react-dialog', async () => {
129+
const actual = await vi.importActual('@radix-ui/react-dialog');
130+
return {
131+
...(actual as Record<string, unknown>),
132+
Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
133+
};
134+
});
135+
136+
beforeEach(() => {
137+
mockUpdate.mockClear();
138+
editorCalls.selectedWidgetId = null;
139+
editorCalls.onWidgetSelect = null;
140+
editorCalls.lastSchema = null;
141+
rendererCalls.designMode = false;
142+
rendererCalls.selectedWidgetId = null;
143+
rendererCalls.onWidgetClick = null;
144+
});
145+
146+
const renderDashboardView = async () => {
147+
const result = render(
148+
<MemoryRouter initialEntries={['/dashboard/crm-dashboard']}>
149+
<Routes>
150+
<Route path="/dashboard/:dashboardName" element={<DashboardView />} />
151+
</Routes>
152+
</MemoryRouter>,
153+
);
154+
// Wait for the queueMicrotask loading state to resolve
155+
await act(async () => {
156+
await new Promise((r) => setTimeout(r, 10));
157+
});
158+
return result;
159+
};
160+
161+
const openDrawer = async () => {
162+
await act(async () => {
163+
fireEvent.click(screen.getByTestId('dashboard-edit-button'));
164+
});
165+
// Wait for lazy-loaded DashboardEditor to resolve
166+
await act(async () => {
167+
await new Promise((r) => setTimeout(r, 50));
168+
});
169+
};
170+
171+
describe('Dashboard Design Mode — Non-modal Drawer Interaction', () => {
172+
it('should open drawer with non-modal behavior (no blocking overlay)', async () => {
173+
await renderDashboardView();
174+
175+
await openDrawer();
176+
177+
// Drawer should be open
178+
expect(screen.getByTestId('design-drawer')).toBeInTheDocument();
179+
// Design mode should be enabled
180+
expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('true');
181+
// Both renderer and editor should be visible simultaneously
182+
expect(screen.getByTestId('dashboard-renderer')).toBeInTheDocument();
183+
expect(screen.getByTestId('dashboard-editor')).toBeInTheDocument();
184+
});
185+
186+
it('should allow clicking preview widgets while drawer is open', async () => {
187+
await renderDashboardView();
188+
await openDrawer();
189+
190+
// Click widget in preview area — this verifies the drawer doesn't block clicks
191+
await act(async () => {
192+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
193+
});
194+
195+
// Widget should be selected in both renderer and editor
196+
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w1');
197+
expect(screen.getByTestId('editor-selected')).toHaveTextContent('w1');
198+
});
199+
200+
it('should show property panel in editor when preview widget is clicked', async () => {
201+
await renderDashboardView();
202+
await openDrawer();
203+
204+
// Click widget in preview
205+
await act(async () => {
206+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
207+
});
208+
209+
// Property panel should show the selected widget's properties
210+
expect(screen.getByTestId('editor-property-panel')).toBeInTheDocument();
211+
expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Total Revenue');
212+
});
213+
214+
it('should show property panel when clicking editor widget list item', async () => {
215+
await renderDashboardView();
216+
await openDrawer();
217+
218+
// Click widget in editor list
219+
await act(async () => {
220+
fireEvent.click(screen.getByTestId('editor-widget-w2'));
221+
});
222+
223+
// Property panel should show for the clicked widget
224+
expect(screen.getByTestId('editor-property-panel')).toBeInTheDocument();
225+
expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Revenue Trends');
226+
// Preview should also reflect the selection
227+
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w2');
228+
});
229+
230+
it('should switch selection between different widgets', async () => {
231+
await renderDashboardView();
232+
await openDrawer();
233+
234+
// Select w1
235+
await act(async () => {
236+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
237+
});
238+
expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Total Revenue');
239+
240+
// Switch to w3
241+
await act(async () => {
242+
fireEvent.click(screen.getByTestId('renderer-widget-w3'));
243+
});
244+
expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Pipeline by Stage');
245+
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w3');
246+
});
247+
248+
it('should deselect when clicking empty space in preview', async () => {
249+
await renderDashboardView();
250+
await openDrawer();
251+
252+
// Select a widget
253+
await act(async () => {
254+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
255+
});
256+
expect(screen.getByTestId('editor-property-panel')).toBeInTheDocument();
257+
258+
// Deselect by calling onWidgetClick(null) (simulates background click)
259+
await act(async () => {
260+
rendererCalls.onWidgetClick?.(null);
261+
});
262+
263+
// Property panel should be hidden
264+
expect(screen.queryByTestId('editor-property-panel')).not.toBeInTheDocument();
265+
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none');
266+
});
267+
268+
it('should clear selection when drawer is closed', async () => {
269+
await renderDashboardView();
270+
271+
// Open drawer and select
272+
await openDrawer();
273+
await act(async () => {
274+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
275+
});
276+
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w1');
277+
278+
// Close the drawer
279+
const closeButtons = screen.getAllByRole('button', { name: /close/i });
280+
const sheetCloseBtn = closeButtons.find((btn) =>
281+
btn.closest('[data-testid="design-drawer"]'),
282+
);
283+
if (sheetCloseBtn) {
284+
await act(async () => {
285+
fireEvent.click(sheetCloseBtn);
286+
});
287+
}
288+
289+
// Selection should be cleared
290+
expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('false');
291+
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none');
292+
});
293+
});

apps/console/src/components/DesignDrawer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,10 @@ export function DesignDrawer({
9393
}, [open, saveSchema, title]);
9494

9595
return (
96-
<Sheet open={open} onOpenChange={onOpenChange}>
96+
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
9797
<SheetContent
9898
side="right"
99+
hideOverlay
99100
className="w-full sm:w-[540px] sm:max-w-[540px] lg:w-[640px] lg:max-w-[640px] p-0 flex flex-col"
100101
data-testid="design-drawer"
101102
>

packages/components/src/ui/sheet.tsx

Lines changed: 6 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/plugin-designer/src/DashboardEditor.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ function WidgetPropertyPanel({
212212
return (
213213
<div
214214
data-testid="widget-property-panel"
215-
className="w-72 shrink-0 space-y-4 rounded-lg border border-gray-200 bg-white p-4"
215+
className="shrink-0 space-y-4 rounded-lg border border-gray-200 bg-white p-4"
216216
>
217217
<div className="flex items-center justify-between">
218218
<h4 className="text-sm font-semibold text-gray-800">{t('appDesigner.widgetProperties')}</h4>
@@ -572,7 +572,7 @@ export function DashboardEditor({
572572
ref={containerRef}
573573
tabIndex={0}
574574
data-testid="dashboard-editor"
575-
className={cn('flex flex-col gap-4 outline-none sm:flex-row', className)}
575+
className={cn('flex flex-col gap-4 outline-none', className)}
576576
>
577577
{/* Hidden file input for import */}
578578
<input
@@ -584,6 +584,16 @@ export function DashboardEditor({
584584
onChange={handleImportFile}
585585
/>
586586

587+
{/* Property panel — shown above widget list when a widget is selected */}
588+
{selectedWidget && !previewMode && (
589+
<WidgetPropertyPanel
590+
widget={selectedWidget}
591+
readOnly={readOnly}
592+
onChange={updateWidget}
593+
onClose={() => setSelectedWidgetId(null)}
594+
/>
595+
)}
596+
587597
{/* Main area */}
588598
<div className="flex-1 space-y-4">
589599
{/* Toolbar */}
@@ -695,16 +705,6 @@ export function DashboardEditor({
695705
</div>
696706
)}
697707
</div>
698-
699-
{/* Property panel */}
700-
{selectedWidget && !previewMode && (
701-
<WidgetPropertyPanel
702-
widget={selectedWidget}
703-
readOnly={readOnly}
704-
onChange={updateWidget}
705-
onClose={() => setSelectedWidgetId(null)}
706-
/>
707-
)}
708708
</div>
709709
);
710710
}

0 commit comments

Comments
 (0)