Skip to content

Commit 6bd658c

Browse files
authored
Merge pull request #810 from objectstack-ai/copilot/refactor-dashboard-attribute-panel
2 parents 7965ea9 + 06b5967 commit 6bd658c

File tree

5 files changed

+622
-277
lines changed

5 files changed

+622
-277
lines changed

ROADMAP.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,21 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
374374
- [x] Preview click → editor property panel linkage now works end-to-end (select, switch, deselect)
375375
- [x] Add 11 new tests (7 DashboardDesignInteraction integration + 4 DashboardEditor.propertyPanelLayout)
376376

377+
**Phase 8 — Inline Config Panel Refactor (ListView Parity):**
378+
- [x] Replace `DesignDrawer` + `DashboardEditor` in `DashboardView` with inline `DashboardConfigPanel` / `WidgetConfigPanel`
379+
- [x] Right-side panel shows `DashboardConfigPanel` when no widget selected (dashboard-level properties: columns, gap, refresh, theme)
380+
- [x] Right-side panel switches to `WidgetConfigPanel` when a widget is selected (title, type, data binding, layout, appearance)
381+
- [x] Config panels use standard `ConfigPanelRenderer` with save/discard/footer (matches ListView/PageDesigner pattern)
382+
- [x] Add-widget toolbar moved to main area header (visible only in edit mode)
383+
- [x] Main area remains WYSIWYG preview via `DashboardRenderer` with `designMode` click-to-select
384+
- [x] Widget config flattening/unflattening (layout.w ↔ layoutW, layout.h ↔ layoutH)
385+
- [x] Auto-save on config save via `useAdapter().update()`
386+
- [x] Live preview updates via `onFieldChange` callback
387+
- [x] Config draft stabilization via `configVersion` counter (matching ViewConfigPanel's `stableActiveView` pattern) — prevents `useConfigDraft` draft reset on live field changes
388+
- [x] Widget delete via `headerExtra` delete button in WidgetConfigPanel header
389+
- [x] `WidgetConfigPanel` — added `headerExtra` prop for custom header actions
390+
- [x] Update 21 integration tests (10 DashboardDesignInteraction + 11 DashboardViewSelection) to verify inline config panel pattern, widget deletion, live preview sync
391+
377392
### P1.11 Console — Schema-Driven View Config Panel Migration
378393

379394
> 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: 137 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,37 @@
11
/**
22
* DashboardView Design Interaction Tests
33
*
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
4+
* Verifies the refactored design mode:
5+
* - Inline config panel (DashboardConfigPanel / WidgetConfigPanel) on the right
6+
* - Click-to-select in preview area syncs with config panel
7+
* - Dashboard config panel shows when no widget selected
8+
* - Widget config panel shows when a widget is selected
89
*/
910

1011
import { describe, it, expect, vi, beforeEach } from 'vitest';
1112
import { render, screen, fireEvent, act } from '@testing-library/react';
1213
import { MemoryRouter, Route, Routes } from 'react-router-dom';
1314
import { DashboardView } from '../components/DashboardView';
1415

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-
},
16+
// Track props passed to mocked components
17+
const { rendererCalls, dashboardConfigCalls, widgetConfigCalls } = vi.hoisted(() => ({
2218
rendererCalls: {
2319
designMode: false,
2420
selectedWidgetId: null as string | null,
2521
onWidgetClick: null as ((id: string | null) => void) | null,
2622
},
23+
dashboardConfigCalls: {
24+
open: false,
25+
onClose: null as (() => void) | null,
26+
config: null as Record<string, any> | null,
27+
},
28+
widgetConfigCalls: {
29+
open: false,
30+
onClose: null as (() => void) | null,
31+
config: null as Record<string, any> | null,
32+
onSave: null as ((config: Record<string, any>) => void) | null,
33+
onFieldChange: null as ((field: string, value: any) => void) | null,
34+
},
2735
}));
2836

2937
// Mock MetadataProvider with a dashboard
@@ -85,32 +93,30 @@ vi.mock('@object-ui/plugin-dashboard', () => ({
8593
</div>
8694
);
8795
},
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);
96+
DashboardConfigPanel: (props: any) => {
97+
dashboardConfigCalls.open = props.open;
98+
dashboardConfigCalls.onClose = props.onClose;
99+
dashboardConfigCalls.config = props.config;
100+
if (!props.open) return null;
97101
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-
))}
102+
<div data-testid="dashboard-config-panel">
103+
<span data-testid="dashboard-config-columns">{props.config?.columns ?? 'none'}</span>
104+
<button data-testid="dashboard-config-close" onClick={props.onClose}>Close</button>
105+
</div>
106+
);
107+
},
108+
WidgetConfigPanel: (props: any) => {
109+
widgetConfigCalls.open = props.open;
110+
widgetConfigCalls.onClose = props.onClose;
111+
widgetConfigCalls.config = props.config;
112+
widgetConfigCalls.onSave = props.onSave;
113+
widgetConfigCalls.onFieldChange = props.onFieldChange;
114+
if (!props.open) return null;
115+
return (
116+
<div data-testid="widget-config-panel">
117+
<span data-testid="widget-config-title">{props.config?.title ?? 'none'}</span>
118+
{props.headerExtra && <div data-testid="widget-config-header-extra">{props.headerExtra}</div>}
119+
<button data-testid="widget-config-close" onClick={props.onClose}>Close</button>
114120
</div>
115121
);
116122
},
@@ -124,23 +130,19 @@ vi.mock('sonner', () => ({
124130
},
125131
}));
126132

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-
136133
beforeEach(() => {
137134
mockUpdate.mockClear();
138-
editorCalls.selectedWidgetId = null;
139-
editorCalls.onWidgetSelect = null;
140-
editorCalls.lastSchema = null;
141135
rendererCalls.designMode = false;
142136
rendererCalls.selectedWidgetId = null;
143137
rendererCalls.onWidgetClick = null;
138+
dashboardConfigCalls.open = false;
139+
dashboardConfigCalls.onClose = null;
140+
dashboardConfigCalls.config = null;
141+
widgetConfigCalls.open = false;
142+
widgetConfigCalls.onClose = null;
143+
widgetConfigCalls.config = null;
144+
widgetConfigCalls.onSave = null;
145+
widgetConfigCalls.onFieldChange = null;
144146
});
145147

146148
const renderDashboardView = async () => {
@@ -151,143 +153,159 @@ const renderDashboardView = async () => {
151153
</Routes>
152154
</MemoryRouter>,
153155
);
154-
// Wait for the queueMicrotask loading state to resolve
155156
await act(async () => {
156157
await new Promise((r) => setTimeout(r, 10));
157158
});
158159
return result;
159160
};
160161

161-
const openDrawer = async () => {
162+
const openConfigPanel = async () => {
162163
await act(async () => {
163164
fireEvent.click(screen.getByTestId('dashboard-edit-button'));
164165
});
165-
// Wait for lazy-loaded DashboardEditor to resolve
166-
await act(async () => {
167-
await new Promise((r) => setTimeout(r, 50));
168-
});
169166
};
170167

171-
describe('Dashboard Design Mode — Non-modal Drawer Interaction', () => {
172-
it('should open drawer with non-modal behavior (no blocking overlay)', async () => {
168+
describe('Dashboard Design Mode — Inline Config Panel', () => {
169+
it('should show dashboard config panel when edit button is clicked (no widget selected)', async () => {
173170
await renderDashboardView();
171+
await openConfigPanel();
174172

175-
await openDrawer();
176-
177-
// Drawer should be open
178-
expect(screen.getByTestId('design-drawer')).toBeInTheDocument();
179-
// Design mode should be enabled
180173
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();
174+
expect(screen.getByTestId('dashboard-config-panel')).toBeInTheDocument();
175+
expect(screen.queryByTestId('widget-config-panel')).not.toBeInTheDocument();
184176
});
185177

186-
it('should allow clicking preview widgets while drawer is open', async () => {
178+
it('should show widget config panel when a widget is clicked in preview', async () => {
187179
await renderDashboardView();
188-
await openDrawer();
180+
await openConfigPanel();
189181

190-
// Click widget in preview area — this verifies the drawer doesn't block clicks
191182
await act(async () => {
192183
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
193184
});
194185

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');
186+
expect(screen.getByTestId('widget-config-panel')).toBeInTheDocument();
187+
expect(screen.getByTestId('widget-config-title')).toHaveTextContent('Total Revenue');
188+
expect(screen.queryByTestId('dashboard-config-panel')).not.toBeInTheDocument();
198189
});
199190

200-
it('should show property panel in editor when preview widget is clicked', async () => {
191+
it('should switch back to dashboard config when widget is deselected', async () => {
201192
await renderDashboardView();
202-
await openDrawer();
193+
await openConfigPanel();
203194

204-
// Click widget in preview
195+
// Select a widget
205196
await act(async () => {
206197
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
207198
});
199+
expect(screen.getByTestId('widget-config-panel')).toBeInTheDocument();
208200

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');
201+
// Deselect by clicking null
202+
await act(async () => {
203+
rendererCalls.onWidgetClick?.(null);
204+
});
205+
206+
expect(screen.getByTestId('dashboard-config-panel')).toBeInTheDocument();
207+
expect(screen.queryByTestId('widget-config-panel')).not.toBeInTheDocument();
212208
});
213209

214-
it('should show property panel when clicking editor widget list item', async () => {
210+
it('should switch between different widgets', async () => {
215211
await renderDashboardView();
216-
await openDrawer();
212+
await openConfigPanel();
213+
214+
await act(async () => {
215+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
216+
});
217+
expect(screen.getByTestId('widget-config-title')).toHaveTextContent('Total Revenue');
217218

218-
// Click widget in editor list
219219
await act(async () => {
220-
fireEvent.click(screen.getByTestId('editor-widget-w2'));
220+
fireEvent.click(screen.getByTestId('renderer-widget-w3'));
221221
});
222+
expect(screen.getByTestId('widget-config-title')).toHaveTextContent('Pipeline by Stage');
223+
});
224+
225+
it('should show add-widget toolbar in edit mode', async () => {
226+
await renderDashboardView();
227+
expect(screen.queryByTestId('dashboard-widget-toolbar')).not.toBeInTheDocument();
228+
229+
await openConfigPanel();
230+
expect(screen.getByTestId('dashboard-widget-toolbar')).toBeInTheDocument();
231+
expect(screen.getByTestId('dashboard-add-metric')).toBeInTheDocument();
232+
});
222233

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');
234+
it('should not show DesignDrawer (no Sheet overlay)', async () => {
235+
await renderDashboardView();
236+
await openConfigPanel();
237+
expect(screen.queryByTestId('design-drawer')).not.toBeInTheDocument();
228238
});
229239

230-
it('should switch selection between different widgets', async () => {
240+
it('should close config panel and clear selection on close', async () => {
231241
await renderDashboardView();
232-
await openDrawer();
242+
await openConfigPanel();
233243

234-
// Select w1
244+
// Select a widget
235245
await act(async () => {
236246
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
237247
});
238-
expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Total Revenue');
239248

240-
// Switch to w3
249+
// Close via widget config panel close button
241250
await act(async () => {
242-
fireEvent.click(screen.getByTestId('renderer-widget-w3'));
251+
fireEvent.click(screen.getByTestId('widget-config-close'));
243252
});
244-
expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Pipeline by Stage');
245-
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w3');
253+
254+
expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('false');
255+
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none');
246256
});
247257

248-
it('should deselect when clicking empty space in preview', async () => {
258+
it('should show delete button in widget config panel header', async () => {
249259
await renderDashboardView();
250-
await openDrawer();
260+
await openConfigPanel();
251261

252-
// Select a widget
253262
await act(async () => {
254263
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
255264
});
256-
expect(screen.getByTestId('editor-property-panel')).toBeInTheDocument();
257265

258-
// Deselect by calling onWidgetClick(null) (simulates background click)
266+
expect(screen.getByTestId('widget-config-header-extra')).toBeInTheDocument();
267+
expect(screen.getByTestId('widget-delete-button')).toBeInTheDocument();
268+
});
269+
270+
it('should remove widget and switch to dashboard config when delete is clicked', async () => {
271+
await renderDashboardView();
272+
await openConfigPanel();
273+
259274
await act(async () => {
260-
rendererCalls.onWidgetClick?.(null);
275+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
261276
});
277+
expect(screen.getByTestId('widget-config-panel')).toBeInTheDocument();
262278

263-
// Property panel should be hidden
264-
expect(screen.queryByTestId('editor-property-panel')).not.toBeInTheDocument();
265-
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none');
279+
// Click the delete button
280+
await act(async () => {
281+
fireEvent.click(screen.getByTestId('widget-delete-button'));
282+
});
283+
284+
// Should switch back to dashboard config (widget deselected)
285+
expect(screen.getByTestId('dashboard-config-panel')).toBeInTheDocument();
286+
expect(screen.queryByTestId('widget-config-panel')).not.toBeInTheDocument();
287+
// Deleted widget should be removed from the preview
288+
expect(screen.queryByTestId('renderer-widget-w1')).not.toBeInTheDocument();
289+
// Backend should be called to persist the deletion
290+
expect(mockUpdate).toHaveBeenCalled();
266291
});
267292

268-
it('should clear selection when drawer is closed', async () => {
293+
it('should preserve live preview when field changes via onFieldChange', async () => {
269294
await renderDashboardView();
295+
await openConfigPanel();
270296

271-
// Open drawer and select
272-
await openDrawer();
273297
await act(async () => {
274298
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
275299
});
276-
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w1');
277300

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-
}
301+
// Simulate a live field change via onFieldChange
302+
await act(async () => {
303+
widgetConfigCalls.onFieldChange?.('title', 'Live Title');
304+
});
288305

289-
// Selection should be cleared
290-
expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('false');
291-
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none');
306+
// Preview should update live
307+
expect(screen.getByTestId('renderer-widget-w1')).toHaveTextContent('Live Title');
308+
// Config panel should still show the widget (not reset or disappear)
309+
expect(screen.getByTestId('widget-config-panel')).toBeInTheDocument();
292310
});
293311
});

0 commit comments

Comments
 (0)