diff --git a/ROADMAP.md b/ROADMAP.md index 911260e20f..0f0d1ec3aa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -374,6 +374,21 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind - [x] Preview click → editor property panel linkage now works end-to-end (select, switch, deselect) - [x] Add 11 new tests (7 DashboardDesignInteraction integration + 4 DashboardEditor.propertyPanelLayout) +**Phase 8 — Inline Config Panel Refactor (ListView Parity):** +- [x] Replace `DesignDrawer` + `DashboardEditor` in `DashboardView` with inline `DashboardConfigPanel` / `WidgetConfigPanel` +- [x] Right-side panel shows `DashboardConfigPanel` when no widget selected (dashboard-level properties: columns, gap, refresh, theme) +- [x] Right-side panel switches to `WidgetConfigPanel` when a widget is selected (title, type, data binding, layout, appearance) +- [x] Config panels use standard `ConfigPanelRenderer` with save/discard/footer (matches ListView/PageDesigner pattern) +- [x] Add-widget toolbar moved to main area header (visible only in edit mode) +- [x] Main area remains WYSIWYG preview via `DashboardRenderer` with `designMode` click-to-select +- [x] Widget config flattening/unflattening (layout.w ↔ layoutW, layout.h ↔ layoutH) +- [x] Auto-save on config save via `useAdapter().update()` +- [x] Live preview updates via `onFieldChange` callback +- [x] Config draft stabilization via `configVersion` counter (matching ViewConfigPanel's `stableActiveView` pattern) — prevents `useConfigDraft` draft reset on live field changes +- [x] Widget delete via `headerExtra` delete button in WidgetConfigPanel header +- [x] `WidgetConfigPanel` — added `headerExtra` prop for custom header actions +- [x] Update 21 integration tests (10 DashboardDesignInteraction + 11 DashboardViewSelection) to verify inline config panel pattern, widget deletion, live preview sync + ### P1.11 Console — Schema-Driven View Config Panel Migration > 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. diff --git a/apps/console/src/__tests__/DashboardDesignInteraction.test.tsx b/apps/console/src/__tests__/DashboardDesignInteraction.test.tsx index 24f0888b2c..a6c3c53145 100644 --- a/apps/console/src/__tests__/DashboardDesignInteraction.test.tsx +++ b/apps/console/src/__tests__/DashboardDesignInteraction.test.tsx @@ -1,10 +1,11 @@ /** * DashboardView Design Interaction Tests * - * Verifies the fixes for: - * - Non-modal DesignDrawer allowing preview widget clicks - * - Property panel appearing above widget grid when a widget is selected - * - Click-to-select in preview area with highlight and property linkage + * Verifies the refactored design mode: + * - Inline config panel (DashboardConfigPanel / WidgetConfigPanel) on the right + * - Click-to-select in preview area syncs with config panel + * - Dashboard config panel shows when no widget selected + * - Widget config panel shows when a widget is selected */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -12,18 +13,25 @@ import { render, screen, fireEvent, act } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { DashboardView } from '../components/DashboardView'; -// Track calls passed to mocked components -const { editorCalls, rendererCalls } = vi.hoisted(() => ({ - editorCalls: { - selectedWidgetId: null as string | null, - onWidgetSelect: null as ((id: string | null) => void) | null, - lastSchema: null as unknown, - }, +// Track props passed to mocked components +const { rendererCalls, dashboardConfigCalls, widgetConfigCalls } = vi.hoisted(() => ({ rendererCalls: { designMode: false, selectedWidgetId: null as string | null, onWidgetClick: null as ((id: string | null) => void) | null, }, + dashboardConfigCalls: { + open: false, + onClose: null as (() => void) | null, + config: null as Record | null, + }, + widgetConfigCalls: { + open: false, + onClose: null as (() => void) | null, + config: null as Record | null, + onSave: null as ((config: Record) => void) | null, + onFieldChange: null as ((field: string, value: any) => void) | null, + }, })); // Mock MetadataProvider with a dashboard @@ -85,32 +93,30 @@ vi.mock('@object-ui/plugin-dashboard', () => ({ ); }, -})); - -// Mock DashboardEditor to capture selection and show property panel -vi.mock('@object-ui/plugin-designer', () => ({ - DashboardEditor: (props: any) => { - editorCalls.selectedWidgetId = props.selectedWidgetId; - editorCalls.onWidgetSelect = props.onWidgetSelect; - editorCalls.lastSchema = props.schema; - const widget = props.schema?.widgets?.find((w: any) => w.id === props.selectedWidgetId); + DashboardConfigPanel: (props: any) => { + dashboardConfigCalls.open = props.open; + dashboardConfigCalls.onClose = props.onClose; + dashboardConfigCalls.config = props.config; + if (!props.open) return null; return ( -
- {props.selectedWidgetId ?? 'none'} - {widget && ( -
- {widget.title} -
- )} - {props.schema?.widgets?.map((w: any) => ( - - ))} +
+ {props.config?.columns ?? 'none'} + +
+ ); + }, + WidgetConfigPanel: (props: any) => { + widgetConfigCalls.open = props.open; + widgetConfigCalls.onClose = props.onClose; + widgetConfigCalls.config = props.config; + widgetConfigCalls.onSave = props.onSave; + widgetConfigCalls.onFieldChange = props.onFieldChange; + if (!props.open) return null; + return ( +
+ {props.config?.title ?? 'none'} + {props.headerExtra &&
{props.headerExtra}
} +
); }, @@ -124,23 +130,19 @@ vi.mock('sonner', () => ({ }, })); -// Mock Radix Dialog portal to render inline for testing -vi.mock('@radix-ui/react-dialog', async () => { - const actual = await vi.importActual('@radix-ui/react-dialog'); - return { - ...(actual as Record), - Portal: ({ children }: { children: React.ReactNode }) => <>{children}, - }; -}); - beforeEach(() => { mockUpdate.mockClear(); - editorCalls.selectedWidgetId = null; - editorCalls.onWidgetSelect = null; - editorCalls.lastSchema = null; rendererCalls.designMode = false; rendererCalls.selectedWidgetId = null; rendererCalls.onWidgetClick = null; + dashboardConfigCalls.open = false; + dashboardConfigCalls.onClose = null; + dashboardConfigCalls.config = null; + widgetConfigCalls.open = false; + widgetConfigCalls.onClose = null; + widgetConfigCalls.config = null; + widgetConfigCalls.onSave = null; + widgetConfigCalls.onFieldChange = null; }); const renderDashboardView = async () => { @@ -151,143 +153,159 @@ const renderDashboardView = async () => { , ); - // Wait for the queueMicrotask loading state to resolve await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); return result; }; -const openDrawer = async () => { +const openConfigPanel = async () => { await act(async () => { fireEvent.click(screen.getByTestId('dashboard-edit-button')); }); - // Wait for lazy-loaded DashboardEditor to resolve - await act(async () => { - await new Promise((r) => setTimeout(r, 50)); - }); }; -describe('Dashboard Design Mode — Non-modal Drawer Interaction', () => { - it('should open drawer with non-modal behavior (no blocking overlay)', async () => { +describe('Dashboard Design Mode — Inline Config Panel', () => { + it('should show dashboard config panel when edit button is clicked (no widget selected)', async () => { await renderDashboardView(); + await openConfigPanel(); - await openDrawer(); - - // Drawer should be open - expect(screen.getByTestId('design-drawer')).toBeInTheDocument(); - // Design mode should be enabled expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('true'); - // Both renderer and editor should be visible simultaneously - expect(screen.getByTestId('dashboard-renderer')).toBeInTheDocument(); - expect(screen.getByTestId('dashboard-editor')).toBeInTheDocument(); + expect(screen.getByTestId('dashboard-config-panel')).toBeInTheDocument(); + expect(screen.queryByTestId('widget-config-panel')).not.toBeInTheDocument(); }); - it('should allow clicking preview widgets while drawer is open', async () => { + it('should show widget config panel when a widget is clicked in preview', async () => { await renderDashboardView(); - await openDrawer(); + await openConfigPanel(); - // Click widget in preview area — this verifies the drawer doesn't block clicks await act(async () => { fireEvent.click(screen.getByTestId('renderer-widget-w1')); }); - // Widget should be selected in both renderer and editor - expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w1'); - expect(screen.getByTestId('editor-selected')).toHaveTextContent('w1'); + expect(screen.getByTestId('widget-config-panel')).toBeInTheDocument(); + expect(screen.getByTestId('widget-config-title')).toHaveTextContent('Total Revenue'); + expect(screen.queryByTestId('dashboard-config-panel')).not.toBeInTheDocument(); }); - it('should show property panel in editor when preview widget is clicked', async () => { + it('should switch back to dashboard config when widget is deselected', async () => { await renderDashboardView(); - await openDrawer(); + await openConfigPanel(); - // Click widget in preview + // Select a widget await act(async () => { fireEvent.click(screen.getByTestId('renderer-widget-w1')); }); + expect(screen.getByTestId('widget-config-panel')).toBeInTheDocument(); - // Property panel should show the selected widget's properties - expect(screen.getByTestId('editor-property-panel')).toBeInTheDocument(); - expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Total Revenue'); + // Deselect by clicking null + await act(async () => { + rendererCalls.onWidgetClick?.(null); + }); + + expect(screen.getByTestId('dashboard-config-panel')).toBeInTheDocument(); + expect(screen.queryByTestId('widget-config-panel')).not.toBeInTheDocument(); }); - it('should show property panel when clicking editor widget list item', async () => { + it('should switch between different widgets', async () => { await renderDashboardView(); - await openDrawer(); + await openConfigPanel(); + + await act(async () => { + fireEvent.click(screen.getByTestId('renderer-widget-w1')); + }); + expect(screen.getByTestId('widget-config-title')).toHaveTextContent('Total Revenue'); - // Click widget in editor list await act(async () => { - fireEvent.click(screen.getByTestId('editor-widget-w2')); + fireEvent.click(screen.getByTestId('renderer-widget-w3')); }); + expect(screen.getByTestId('widget-config-title')).toHaveTextContent('Pipeline by Stage'); + }); + + it('should show add-widget toolbar in edit mode', async () => { + await renderDashboardView(); + expect(screen.queryByTestId('dashboard-widget-toolbar')).not.toBeInTheDocument(); + + await openConfigPanel(); + expect(screen.getByTestId('dashboard-widget-toolbar')).toBeInTheDocument(); + expect(screen.getByTestId('dashboard-add-metric')).toBeInTheDocument(); + }); - // Property panel should show for the clicked widget - expect(screen.getByTestId('editor-property-panel')).toBeInTheDocument(); - expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Revenue Trends'); - // Preview should also reflect the selection - expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w2'); + it('should not show DesignDrawer (no Sheet overlay)', async () => { + await renderDashboardView(); + await openConfigPanel(); + expect(screen.queryByTestId('design-drawer')).not.toBeInTheDocument(); }); - it('should switch selection between different widgets', async () => { + it('should close config panel and clear selection on close', async () => { await renderDashboardView(); - await openDrawer(); + await openConfigPanel(); - // Select w1 + // Select a widget await act(async () => { fireEvent.click(screen.getByTestId('renderer-widget-w1')); }); - expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Total Revenue'); - // Switch to w3 + // Close via widget config panel close button await act(async () => { - fireEvent.click(screen.getByTestId('renderer-widget-w3')); + fireEvent.click(screen.getByTestId('widget-config-close')); }); - expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Pipeline by Stage'); - expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w3'); + + expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('false'); + expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none'); }); - it('should deselect when clicking empty space in preview', async () => { + it('should show delete button in widget config panel header', async () => { await renderDashboardView(); - await openDrawer(); + await openConfigPanel(); - // Select a widget await act(async () => { fireEvent.click(screen.getByTestId('renderer-widget-w1')); }); - expect(screen.getByTestId('editor-property-panel')).toBeInTheDocument(); - // Deselect by calling onWidgetClick(null) (simulates background click) + expect(screen.getByTestId('widget-config-header-extra')).toBeInTheDocument(); + expect(screen.getByTestId('widget-delete-button')).toBeInTheDocument(); + }); + + it('should remove widget and switch to dashboard config when delete is clicked', async () => { + await renderDashboardView(); + await openConfigPanel(); + await act(async () => { - rendererCalls.onWidgetClick?.(null); + fireEvent.click(screen.getByTestId('renderer-widget-w1')); }); + expect(screen.getByTestId('widget-config-panel')).toBeInTheDocument(); - // Property panel should be hidden - expect(screen.queryByTestId('editor-property-panel')).not.toBeInTheDocument(); - expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none'); + // Click the delete button + await act(async () => { + fireEvent.click(screen.getByTestId('widget-delete-button')); + }); + + // Should switch back to dashboard config (widget deselected) + expect(screen.getByTestId('dashboard-config-panel')).toBeInTheDocument(); + expect(screen.queryByTestId('widget-config-panel')).not.toBeInTheDocument(); + // Deleted widget should be removed from the preview + expect(screen.queryByTestId('renderer-widget-w1')).not.toBeInTheDocument(); + // Backend should be called to persist the deletion + expect(mockUpdate).toHaveBeenCalled(); }); - it('should clear selection when drawer is closed', async () => { + it('should preserve live preview when field changes via onFieldChange', async () => { await renderDashboardView(); + await openConfigPanel(); - // Open drawer and select - await openDrawer(); await act(async () => { fireEvent.click(screen.getByTestId('renderer-widget-w1')); }); - expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w1'); - // Close the drawer - const closeButtons = screen.getAllByRole('button', { name: /close/i }); - const sheetCloseBtn = closeButtons.find((btn) => - btn.closest('[data-testid="design-drawer"]'), - ); - if (sheetCloseBtn) { - await act(async () => { - fireEvent.click(sheetCloseBtn); - }); - } + // Simulate a live field change via onFieldChange + await act(async () => { + widgetConfigCalls.onFieldChange?.('title', 'Live Title'); + }); - // Selection should be cleared - expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('false'); - expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none'); + // Preview should update live + expect(screen.getByTestId('renderer-widget-w1')).toHaveTextContent('Live Title'); + // Config panel should still show the widget (not reset or disappear) + expect(screen.getByTestId('widget-config-panel')).toBeInTheDocument(); }); }); diff --git a/apps/console/src/__tests__/DashboardViewSelection.test.tsx b/apps/console/src/__tests__/DashboardViewSelection.test.tsx index 081b00ab6f..704ef3219c 100644 --- a/apps/console/src/__tests__/DashboardViewSelection.test.tsx +++ b/apps/console/src/__tests__/DashboardViewSelection.test.tsx @@ -1,8 +1,8 @@ /** * DashboardView Selection Sync Tests * - * Integration tests verifying the full click-to-select flow: - * Preview widget click → editor panel shows properties → edit property → preview updates + * Integration tests verifying the full click-to-select flow with inline config panels: + * Preview widget click → config panel switches → edit property → preview updates */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -10,10 +10,27 @@ import { render, screen, fireEvent, act } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { DashboardView } from '../components/DashboardView'; -// Track the latest onWidgetSelect and selectedWidgetId passed to DashboardEditor -const { editorCalls, rendererCalls } = vi.hoisted(() => ({ - editorCalls: { selectedWidgetId: null as string | null, onWidgetSelect: null as ((id: string | null) => void) | null, lastOnChange: null as ((s: any) => void) | null }, - rendererCalls: { designMode: false, selectedWidgetId: null as string | null, onWidgetClick: null as ((id: string | null) => void) | null }, +// Track the latest props passed to mocked components +const { rendererCalls, dashboardConfigCalls, widgetConfigCalls } = vi.hoisted(() => ({ + rendererCalls: { + designMode: false, + selectedWidgetId: null as string | null, + onWidgetClick: null as ((id: string | null) => void) | null, + lastSchema: null as any, + }, + dashboardConfigCalls: { + open: false, + config: null as Record | null, + onSave: null as ((config: Record) => void) | null, + onFieldChange: null as ((field: string, value: any) => void) | null, + }, + widgetConfigCalls: { + open: false, + config: null as Record | null, + onSave: null as ((config: Record) => void) | null, + onFieldChange: null as ((field: string, value: any) => void) | null, + onClose: null as (() => void) | null, + }, })); // Mock MetadataProvider with a dashboard @@ -51,12 +68,13 @@ vi.mock('../context/AdapterProvider', () => ({ }), })); -// Mock DashboardRenderer to capture designMode, selectedWidgetId, and onWidgetClick +// Mock plugin-dashboard components to capture config panel interactions vi.mock('@object-ui/plugin-dashboard', () => ({ DashboardRenderer: (props: any) => { rendererCalls.designMode = props.designMode; rendererCalls.selectedWidgetId = props.selectedWidgetId; rendererCalls.onWidgetClick = props.onWidgetClick; + rendererCalls.lastSchema = props.schema; return (
{String(!!props.designMode)} @@ -74,49 +92,35 @@ vi.mock('@object-ui/plugin-dashboard', () => ({
); }, -})); - -// Mock DashboardEditor to capture selectedWidgetId and onWidgetSelect -vi.mock('@object-ui/plugin-designer', () => ({ - DashboardEditor: (props: any) => { - editorCalls.selectedWidgetId = props.selectedWidgetId; - editorCalls.onWidgetSelect = props.onWidgetSelect; - editorCalls.lastOnChange = props.onChange; - const widget = props.schema?.widgets?.find((w: any) => w.id === props.selectedWidgetId); + DashboardConfigPanel: (props: any) => { + dashboardConfigCalls.open = props.open; + dashboardConfigCalls.config = props.config; + dashboardConfigCalls.onSave = props.onSave; + dashboardConfigCalls.onFieldChange = props.onFieldChange; + if (!props.open) return null; return ( -
- {props.selectedWidgetId ?? 'none'} - {widget && ( -
- {widget.title} - -
- )} - {/* Clicking a widget in the editor list */} - {props.schema?.widgets?.map((w: any) => ( - - ))} +
+ {props.config?.title ?? 'none'} +
+ ); + }, + WidgetConfigPanel: (props: any) => { + widgetConfigCalls.open = props.open; + widgetConfigCalls.config = props.config; + widgetConfigCalls.onSave = props.onSave; + widgetConfigCalls.onFieldChange = props.onFieldChange; + widgetConfigCalls.onClose = props.onClose; + if (!props.open) return null; + return ( +
+ {props.config?.title ?? 'none'} + {props.headerExtra &&
{props.headerExtra}
} +
); }, @@ -130,23 +134,21 @@ vi.mock('sonner', () => ({ }, })); -// Mock Radix Dialog portal to render inline for testing -vi.mock('@radix-ui/react-dialog', async () => { - const actual = await vi.importActual('@radix-ui/react-dialog'); - return { - ...(actual as Record), - Portal: ({ children }: { children: React.ReactNode }) => <>{children}, - }; -}); - beforeEach(() => { mockUpdate.mockClear(); - editorCalls.selectedWidgetId = null; - editorCalls.onWidgetSelect = null; - editorCalls.lastOnChange = null; rendererCalls.designMode = false; rendererCalls.selectedWidgetId = null; rendererCalls.onWidgetClick = null; + rendererCalls.lastSchema = null; + dashboardConfigCalls.open = false; + dashboardConfigCalls.config = null; + dashboardConfigCalls.onSave = null; + dashboardConfigCalls.onFieldChange = null; + widgetConfigCalls.open = false; + widgetConfigCalls.config = null; + widgetConfigCalls.onSave = null; + widgetConfigCalls.onFieldChange = null; + widgetConfigCalls.onClose = null; }); const renderDashboardView = async () => { @@ -157,7 +159,6 @@ const renderDashboardView = async () => { , ); - // Wait for the queueMicrotask loading state to resolve await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); @@ -165,14 +166,14 @@ const renderDashboardView = async () => { }; describe('DashboardView — Selection Sync Integration', () => { - it('should not enable design mode when drawer is closed', async () => { + it('should not enable design mode when config panel is closed', async () => { await renderDashboardView(); expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('false'); expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none'); }); - it('should enable design mode when drawer is opened', async () => { + it('should enable design mode when edit button is clicked', async () => { await renderDashboardView(); await act(async () => { @@ -182,95 +183,89 @@ describe('DashboardView — Selection Sync Integration', () => { expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('true'); }); - it('should sync widget selection from preview to editor when clicking a widget', async () => { + it('should show dashboard config panel by default (no widget selected)', async () => { await renderDashboardView(); - // Open drawer await act(async () => { fireEvent.click(screen.getByTestId('dashboard-edit-button')); }); - // Click widget w1 in the preview area + expect(screen.getByTestId('dashboard-config-panel')).toBeInTheDocument(); + expect(screen.queryByTestId('widget-config-panel')).not.toBeInTheDocument(); + }); + + it('should switch to widget config panel when a widget is selected', async () => { + await renderDashboardView(); + + await act(async () => { + fireEvent.click(screen.getByTestId('dashboard-edit-button')); + }); await act(async () => { fireEvent.click(screen.getByTestId('renderer-widget-w1')); }); - // Both renderer and editor should show w1 as selected expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w1'); - expect(screen.getByTestId('editor-selected')).toHaveTextContent('w1'); - // Editor should show property panel for w1 - expect(screen.getByTestId('editor-property-panel')).toBeInTheDocument(); - expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Revenue'); + expect(screen.getByTestId('widget-config-panel')).toBeInTheDocument(); + expect(screen.getByTestId('widget-config-title')).toHaveTextContent('Revenue'); }); - it('should sync widget selection from editor to preview', async () => { + it('should sync widget selection from preview click', async () => { await renderDashboardView(); - // Open drawer await act(async () => { fireEvent.click(screen.getByTestId('dashboard-edit-button')); }); - - // Click widget w2 in the editor await act(async () => { - fireEvent.click(screen.getByTestId('editor-widget-w2')); + fireEvent.click(screen.getByTestId('renderer-widget-w2')); }); - // Both should show w2 selected expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w2'); - expect(screen.getByTestId('editor-selected')).toHaveTextContent('w2'); + expect(screen.getByTestId('widget-config-title')).toHaveTextContent('Sales Chart'); }); - it('should update preview when property is edited in the editor panel', async () => { + it('should update preview when widget config is saved', async () => { await renderDashboardView(); - // Open drawer await act(async () => { fireEvent.click(screen.getByTestId('dashboard-edit-button')); }); - - // Select widget w1 in preview await act(async () => { fireEvent.click(screen.getByTestId('renderer-widget-w1')); }); - // Edit the title in the property panel + // Save updated widget config await act(async () => { - fireEvent.click(screen.getByTestId('editor-change-title')); + fireEvent.click(screen.getByTestId('widget-config-save')); }); - // Preview should now show the updated title + // Preview should reflect the updated title expect(screen.getByTestId('renderer-widget-w1')).toHaveTextContent('Updated Revenue'); }); it('should deselect when clicking background (null selection)', async () => { await renderDashboardView(); - // Open drawer await act(async () => { fireEvent.click(screen.getByTestId('dashboard-edit-button')); }); - - // Select w1 await act(async () => { fireEvent.click(screen.getByTestId('renderer-widget-w1')); }); - expect(screen.getByTestId('editor-property-panel')).toBeInTheDocument(); + expect(screen.getByTestId('widget-config-panel')).toBeInTheDocument(); - // Simulate background click by calling onWidgetClick(null) + // Simulate background click await act(async () => { rendererCalls.onWidgetClick?.(null); }); expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none'); - expect(screen.getByTestId('editor-selected')).toHaveTextContent('none'); - expect(screen.queryByTestId('editor-property-panel')).not.toBeInTheDocument(); + expect(screen.getByTestId('dashboard-config-panel')).toBeInTheDocument(); + expect(screen.queryByTestId('widget-config-panel')).not.toBeInTheDocument(); }); - it('should clear selection when drawer is closed', async () => { + it('should clear selection when config panel is closed', async () => { await renderDashboardView(); - // Open drawer and select a widget await act(async () => { fireEvent.click(screen.getByTestId('dashboard-edit-button')); }); @@ -279,39 +274,30 @@ describe('DashboardView — Selection Sync Integration', () => { }); expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w1'); - // Close the drawer via the Sheet's close button (sr-only "Close" text) - const closeButtons = screen.getAllByRole('button', { name: /close/i }); - const sheetCloseBtn = closeButtons.find((btn) => - btn.closest('[data-testid="design-drawer"]'), - ); - if (sheetCloseBtn) { - await act(async () => { - fireEvent.click(sheetCloseBtn); - }); - } + // Close the config panel + await act(async () => { + widgetConfigCalls.onClose?.(); + }); - // After close, design mode should be off and selection cleared expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('false'); expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none'); }); - it('should auto-save property changes to backend', async () => { + it('should auto-save widget config changes to backend', async () => { await renderDashboardView(); - // Open drawer await act(async () => { fireEvent.click(screen.getByTestId('dashboard-edit-button')); }); - - // Select and edit await act(async () => { fireEvent.click(screen.getByTestId('renderer-widget-w1')); }); + + // Save updated config await act(async () => { - fireEvent.click(screen.getByTestId('editor-change-title')); + fireEvent.click(screen.getByTestId('widget-config-save')); }); - // Backend should be called with updated schema expect(mockUpdate).toHaveBeenCalledWith( 'sys_dashboard', 'sales', @@ -322,4 +308,56 @@ describe('DashboardView — Selection Sync Integration', () => { }), ); }); + + it('should delete widget and persist to backend', async () => { + await renderDashboardView(); + + await act(async () => { + fireEvent.click(screen.getByTestId('dashboard-edit-button')); + }); + await act(async () => { + fireEvent.click(screen.getByTestId('renderer-widget-w1')); + }); + + // Click delete + await act(async () => { + fireEvent.click(screen.getByTestId('widget-delete-button')); + }); + + // Widget removed from preview + expect(screen.queryByTestId('renderer-widget-w1')).not.toBeInTheDocument(); + // Remaining widget still present + expect(screen.getByTestId('renderer-widget-w2')).toBeInTheDocument(); + // Backend should be called without the deleted widget + expect(mockUpdate).toHaveBeenCalledWith( + 'sys_dashboard', + 'sales', + expect.objectContaining({ + widgets: expect.not.arrayContaining([ + expect.objectContaining({ id: 'w1' }), + ]), + }), + ); + }); + + it('should update preview live when onFieldChange fires without resetting config panel', async () => { + await renderDashboardView(); + + await act(async () => { + fireEvent.click(screen.getByTestId('dashboard-edit-button')); + }); + await act(async () => { + fireEvent.click(screen.getByTestId('renderer-widget-w1')); + }); + + // Simulate live field change + await act(async () => { + widgetConfigCalls.onFieldChange?.('title', 'Live Preview Title'); + }); + + // Preview should update + expect(screen.getByTestId('renderer-widget-w1')).toHaveTextContent('Live Preview Title'); + // Widget config panel should still be visible + expect(screen.getByTestId('widget-config-panel')).toBeInTheDocument(); + }); }); diff --git a/apps/console/src/components/DashboardView.tsx b/apps/console/src/components/DashboardView.tsx index 43a9febaa7..92e72506a3 100644 --- a/apps/console/src/components/DashboardView.tsx +++ b/apps/console/src/components/DashboardView.tsx @@ -1,56 +1,296 @@ /** * Dashboard View Component * Renders a dashboard based on the dashboardName parameter. - * Edit opens a right-side drawer with DashboardEditor for real-time preview. + * Edit mode shows an inline config panel (DashboardConfigPanel / WidgetConfigPanel) + * on the right side, following the same pattern as ListView. */ -import { useState, useEffect, useCallback, lazy, Suspense } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useParams } from 'react-router-dom'; -import { DashboardRenderer } from '@object-ui/plugin-dashboard'; -import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; -import { LayoutDashboard, Pencil } from 'lucide-react'; +import { + DashboardRenderer, + DashboardConfigPanel, + WidgetConfigPanel, +} from '@object-ui/plugin-dashboard'; +import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components'; +import { + LayoutDashboard, + Pencil, + TrendingUp, + BarChart3, + LineChart, + PieChart, + Table2, + LayoutGrid, + Plus, + Trash2, +} from 'lucide-react'; import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector'; import { SkeletonDashboard } from './skeletons'; import { useMetadata } from '../context/MetadataProvider'; import { resolveI18nLabel } from '../utils'; -import { DesignDrawer } from './DesignDrawer'; -import type { DashboardSchema } from '@object-ui/types'; +import { useAdapter } from '../context/AdapterProvider'; +import type { DashboardSchema, DashboardWidgetSchema } from '@object-ui/types'; -const DashboardEditor = lazy(() => - import('@object-ui/plugin-designer').then((m) => ({ default: m.DashboardEditor })), -); +// --------------------------------------------------------------------------- +// Widget type palette for the add-widget toolbar +// --------------------------------------------------------------------------- + +const WIDGET_TYPES = [ + { type: 'metric', label: 'KPI Metric', Icon: TrendingUp }, + { type: 'bar', label: 'Bar Chart', Icon: BarChart3 }, + { type: 'line', label: 'Line Chart', Icon: LineChart }, + { type: 'pie', label: 'Pie Chart', Icon: PieChart }, + { type: 'table', label: 'Table', Icon: Table2 }, + { type: 'grid', label: 'Grid', Icon: LayoutGrid }, +]; + +let widgetCounter = 0; +function createWidgetId(): string { + widgetCounter += 1; + return `widget_${Date.now()}_${widgetCounter}`; +} + +// --------------------------------------------------------------------------- +// Helpers: flatten / unflatten widget config for WidgetConfigPanel +// --------------------------------------------------------------------------- + +function flattenWidgetConfig(widget: DashboardWidgetSchema): Record { + return { + title: widget.title ?? '', + description: widget.description ?? '', + type: widget.type ?? 'metric', + object: widget.object ?? '', + categoryField: widget.categoryField ?? '', + valueField: widget.valueField ?? '', + aggregate: widget.aggregate ?? 'count', + layoutW: widget.layout?.w ?? 1, + layoutH: widget.layout?.h ?? 1, + colorVariant: widget.colorVariant ?? 'default', + actionUrl: widget.actionUrl ?? '', + }; +} + +function unflattenWidgetConfig( + config: Record, + base: DashboardWidgetSchema, +): Partial { + return { + title: config.title, + description: config.description, + type: config.type, + object: config.object, + categoryField: config.categoryField, + valueField: config.valueField, + aggregate: config.aggregate, + layout: { ...(base.layout || {}), w: config.layoutW, h: config.layoutH } as DashboardWidgetSchema['layout'], + colorVariant: config.colorVariant, + actionUrl: config.actionUrl, + }; +} + +function extractDashboardConfig(schema: DashboardSchema): Record { + return { + columns: schema.columns ?? 3, + gap: schema.gap ?? 4, + rowHeight: String((schema as any).rowHeight ?? '120'), + refreshInterval: String(schema.refreshInterval ?? '0'), + title: schema.title ?? '', + showDescription: (schema as any).showDescription ?? true, + theme: (schema as any).theme ?? 'auto', + }; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- export function DashboardView({ dataSource }: { dataSource?: any }) { const { dashboardName } = useParams<{ dashboardName: string }>(); const { showDebug, toggleDebug } = useMetadataInspector(); + const adapter = useAdapter(); const [isLoading, setIsLoading] = useState(true); - const [drawerOpen, setDrawerOpen] = useState(false); + const [configPanelOpen, setConfigPanelOpen] = useState(false); const [selectedWidgetId, setSelectedWidgetId] = useState(null); + // Version counter — incremented on save to refresh the stable config reference + const [configVersion, setConfigVersion] = useState(0); useEffect(() => { - // Reset loading on navigation; the actual DashboardRenderer handles data fetching setIsLoading(true); - // Use microtask to let React render the skeleton before the heavy dashboard queueMicrotask(() => setIsLoading(false)); }, [dashboardName]); - - // Find dashboard definition from API-driven metadata + const { dashboards } = useMetadata(); const dashboard = dashboards?.find((d: any) => d.name === dashboardName); // Local schema state for live preview — initialized from metadata const [editSchema, setEditSchema] = useState(null); - const handleOpenDrawer = useCallback(() => { + // ---- Save helper -------------------------------------------------------- + const saveSchema = useCallback( + async (schema: DashboardSchema) => { + try { + if (adapter) { + await adapter.update('sys_dashboard', dashboardName!, schema); + } + } catch (err) { + console.warn('[DashboardView] Auto-save failed:', err); + } + }, + [adapter, dashboardName], + ); + + // ---- Open / close config panel ------------------------------------------ + const handleOpenConfigPanel = useCallback(() => { setEditSchema(dashboard as DashboardSchema); - setDrawerOpen(true); + setConfigPanelOpen(true); + setConfigVersion((v) => v + 1); }, [dashboard]); - const handleCloseDrawer = useCallback((open: boolean) => { - setDrawerOpen(open); - if (!open) setSelectedWidgetId(null); + const handleCloseConfigPanel = useCallback(() => { + setConfigPanelOpen(false); + setSelectedWidgetId(null); }, []); + // ---- Widget management -------------------------------------------------- + const addWidget = useCallback( + (type: string) => { + if (!editSchema) return; + const id = createWidgetId(); + const newWidget: DashboardWidgetSchema = { + id, + title: '', + type, + layout: { + x: 0, + y: (editSchema.widgets?.length ?? 0), + w: editSchema.columns ?? 2, + h: 1, + }, + }; + const newSchema = { ...editSchema, widgets: [...(editSchema.widgets || []), newWidget] }; + setEditSchema(newSchema); + saveSchema(newSchema); + setSelectedWidgetId(id); + setConfigVersion((v) => v + 1); + }, + [editSchema, saveSchema], + ); + + const removeWidget = useCallback( + (widgetId: string) => { + if (!editSchema) return; + const newSchema = { + ...editSchema, + widgets: editSchema.widgets.filter((w) => w.id !== widgetId), + }; + setEditSchema(newSchema); + saveSchema(newSchema); + if (selectedWidgetId === widgetId) { + setSelectedWidgetId(null); + } + }, + [editSchema, selectedWidgetId, saveSchema], + ); + + // ---- Dashboard config panel handlers ------------------------------------ + // Stabilize config reference: only recompute after explicit actions (panel + // open, save, widget add). configVersion is incremented on those actions. + // This prevents useConfigDraft from resetting the draft on every live field + // change (same pattern as ViewConfigPanel's stableActiveView). + const dashboardConfig = useMemo( + () => extractDashboardConfig(editSchema || (dashboard as DashboardSchema)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [configVersion], + ); + + const handleDashboardConfigSave = useCallback( + (config: Record) => { + if (!editSchema) return; + const newSchema = { + ...editSchema, + columns: config.columns, + gap: config.gap, + rowHeight: config.rowHeight, + refreshInterval: Number(config.refreshInterval) || 0, + title: config.title, + showDescription: config.showDescription, + theme: config.theme, + } as DashboardSchema; + setEditSchema(newSchema); + saveSchema(newSchema); + setConfigVersion((v) => v + 1); + }, + [editSchema, saveSchema], + ); + + const handleDashboardFieldChange = useCallback( + (field: string, value: any) => { + if (!editSchema) return; + // Map config field keys to proper DashboardSchema updates for live preview + setEditSchema((prev) => { + if (!prev) return prev; + if (field === 'refreshInterval') { + return { ...prev, refreshInterval: Number(value) || 0 }; + } + return { ...prev, [field]: value }; + }); + }, + [editSchema], + ); + + // ---- Widget config panel handlers --------------------------------------- + const selectedWidget = editSchema?.widgets?.find((w) => w.id === selectedWidgetId); + + // Stabilize widget config: only recompute after explicit actions (widget + // switch, save, add). configVersion is incremented on save/add, and + // selectedWidgetId changes on widget switch — this prevents useConfigDraft + // from resetting the draft on every live field change. + const widgetConfig = useMemo( + () => (selectedWidget ? flattenWidgetConfig(selectedWidget) : {}), + // eslint-disable-next-line react-hooks/exhaustive-deps + [selectedWidgetId, configVersion], + ); + + const handleWidgetConfigSave = useCallback( + (config: Record) => { + if (!editSchema || !selectedWidgetId || !selectedWidget) return; + const updates = unflattenWidgetConfig(config, selectedWidget); + const newSchema = { + ...editSchema, + widgets: editSchema.widgets.map((w) => + w.id === selectedWidgetId ? { ...w, ...updates } : w, + ), + }; + setEditSchema(newSchema); + saveSchema(newSchema); + setConfigVersion((v) => v + 1); + }, + [editSchema, selectedWidgetId, selectedWidget, saveSchema], + ); + + const handleWidgetFieldChange = useCallback( + (field: string, value: any) => { + if (!selectedWidgetId) return; + setEditSchema((prev) => { + if (!prev) return prev; + const widget = prev.widgets?.find((w) => w.id === selectedWidgetId); + if (!widget) return prev; + const flat = flattenWidgetConfig(widget); + flat[field] = value; + const updates = unflattenWidgetConfig(flat, widget); + return { + ...prev, + widgets: prev.widgets.map((w) => + w.id === selectedWidgetId ? { ...w, ...updates } : w, + ), + }; + }); + }, + [selectedWidgetId], + ); + + // ---- Loading / not-found guards ----------------------------------------- if (isLoading) { return ; } @@ -72,11 +312,11 @@ export function DashboardView({ dataSource }: { dataSource?: any }) { ); } - // Use live-edited schema for preview when the drawer is open - const previewSchema = drawerOpen && editSchema ? editSchema : dashboard; + const previewSchema = configPanelOpen && editSchema ? editSchema : dashboard; return (
+ {/* ── Header ───────────────────────────────────────────────── */}

{resolveI18nLabel(dashboard.label) || dashboard.name}

@@ -85,9 +325,27 @@ export function DashboardView({ dataSource }: { dataSource?: any }) { )}
+ {/* Add-widget toolbar — visible only in edit mode */} + {configPanelOpen && ( +
+ {WIDGET_TYPES.map(({ type, label, Icon }) => ( + + ))} +
+ )}
+ {/* ── Main area + Config Panel ─────────────────────────────── */}
-
+
+ {/* Right-side config panel — switches between dashboard / widget config */} + {selectedWidget ? ( + removeWidget(selectedWidgetId!)} + className="h-7 w-7 p-0 text-destructive hover:text-destructive" + data-testid="widget-delete-button" + title="Delete widget" + > + + + } + /> + ) : ( + + )} +
- - - {(schema, onChange) => ( - Loading editor…
}> - - - )} -
); } diff --git a/packages/plugin-dashboard/src/WidgetConfigPanel.tsx b/packages/plugin-dashboard/src/WidgetConfigPanel.tsx index 648d8b9ce7..262467d201 100644 --- a/packages/plugin-dashboard/src/WidgetConfigPanel.tsx +++ b/packages/plugin-dashboard/src/WidgetConfigPanel.tsx @@ -177,6 +177,8 @@ export interface WidgetConfigPanelProps { onSave: (config: Record) => void; /** Optional live-update callback */ onFieldChange?: (field: string, value: any) => void; + /** Extra content rendered in the header row (e.g. delete button) */ + headerExtra?: React.ReactNode; } // --------------------------------------------------------------------------- @@ -197,6 +199,7 @@ export function WidgetConfigPanel({ config, onSave, onFieldChange, + headerExtra, }: WidgetConfigPanelProps) { const { draft, isDirty, updateField, discard } = useConfigDraft(config, { onUpdate: onFieldChange, @@ -212,6 +215,7 @@ export function WidgetConfigPanel({ onFieldChange={updateField} onSave={() => onSave(draft)} onDiscard={discard} + headerExtra={headerExtra} /> ); }