From fba9c26f131b79bd670d6d59e2e6a763efe72afe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:07:46 +0000 Subject: [PATCH 1/5] Initial plan From c9a6022b16daea77a0c2224047bde82029a99723 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:19:00 +0000 Subject: [PATCH 2/5] refactor: replace DesignDrawer+DashboardEditor with inline config panels in DashboardView Replace the right-side drawer that contained the full DashboardEditor (widget list, toolbar, preview) with inline DashboardConfigPanel / WidgetConfigPanel, following the same pattern as ListView. - No widget selected: shows DashboardConfigPanel (dashboard-level properties) - Widget selected: shows WidgetConfigPanel (widget configuration) - Add-widget toolbar moved to the main area header - Config panels use standard ConfigPanelRenderer with save/discard footer - Main area remains WYSIWYG preview via DashboardRenderer Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../DashboardDesignInteraction.test.tsx | 215 +++++------- .../__tests__/DashboardViewSelection.test.tsx | 209 ++++++------ apps/console/src/components/DashboardView.tsx | 307 +++++++++++++++--- 3 files changed, 448 insertions(+), 283 deletions(-) diff --git a/apps/console/src/__tests__/DashboardDesignInteraction.test.tsx b/apps/console/src/__tests__/DashboardDesignInteraction.test.tsx index 24f0888b2c..8191922d73 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,29 @@ 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'} +
); }, @@ -124,23 +129,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,142 +152,104 @@ 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'); - }); - - it('should show property panel when clicking editor widget list item', async () => { - await renderDashboardView(); - await openDrawer(); - - // Click widget in editor list + // Deselect by clicking null await act(async () => { - fireEvent.click(screen.getByTestId('editor-widget-w2')); + rendererCalls.onWidgetClick?.(null); }); - // 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'); + expect(screen.getByTestId('dashboard-config-panel')).toBeInTheDocument(); + expect(screen.queryByTestId('widget-config-panel')).not.toBeInTheDocument(); }); - it('should switch selection between different widgets', async () => { + it('should switch between different widgets', async () => { await renderDashboardView(); - await openDrawer(); + await openConfigPanel(); - // Select w1 await act(async () => { fireEvent.click(screen.getByTestId('renderer-widget-w1')); }); - expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Total Revenue'); + expect(screen.getByTestId('widget-config-title')).toHaveTextContent('Total Revenue'); - // Switch to w3 await act(async () => { fireEvent.click(screen.getByTestId('renderer-widget-w3')); }); - expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Pipeline by Stage'); - expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w3'); + expect(screen.getByTestId('widget-config-title')).toHaveTextContent('Pipeline by Stage'); }); - it('should deselect when clicking empty space in preview', async () => { + it('should show add-widget toolbar in edit mode', async () => { await renderDashboardView(); - await openDrawer(); - - // Select a widget - await act(async () => { - fireEvent.click(screen.getByTestId('renderer-widget-w1')); - }); - expect(screen.getByTestId('editor-property-panel')).toBeInTheDocument(); + expect(screen.queryByTestId('dashboard-widget-toolbar')).not.toBeInTheDocument(); - // Deselect by calling onWidgetClick(null) (simulates background click) - await act(async () => { - rendererCalls.onWidgetClick?.(null); - }); + await openConfigPanel(); + expect(screen.getByTestId('dashboard-widget-toolbar')).toBeInTheDocument(); + expect(screen.getByTestId('dashboard-add-metric')).toBeInTheDocument(); + }); - // Property panel should be hidden - expect(screen.queryByTestId('editor-property-panel')).not.toBeInTheDocument(); - expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none'); + it('should not show DesignDrawer (no Sheet overlay)', async () => { + await renderDashboardView(); + await openConfigPanel(); + expect(screen.queryByTestId('design-drawer')).not.toBeInTheDocument(); }); - it('should clear selection when drawer is closed', async () => { + it('should close config panel and clear selection on close', async () => { await renderDashboardView(); + await openConfigPanel(); - // Open drawer and select - await openDrawer(); + // Select a widget 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); - }); - } + // Close via widget config panel close button + await act(async () => { + fireEvent.click(screen.getByTestId('widget-config-close')); + }); - // Selection should be cleared expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('false'); expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none'); }); diff --git a/apps/console/src/__tests__/DashboardViewSelection.test.tsx b/apps/console/src/__tests__/DashboardViewSelection.test.tsx index 081b00ab6f..fa554ee416 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,34 @@ 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'} +
); }, @@ -130,23 +133,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 +158,6 @@ const renderDashboardView = async () => { , ); - // Wait for the queueMicrotask loading state to resolve await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); @@ -165,14 +165,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 +182,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 +273,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', diff --git a/apps/console/src/components/DashboardView.tsx b/apps/console/src/components/DashboardView.tsx index 43a9febaa7..860aa9a826 100644 --- a/apps/console/src/components/DashboardView.tsx +++ b/apps/console/src/components/DashboardView.tsx @@ -1,56 +1,255 @@ /** * 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 { + DashboardRenderer, + DashboardConfigPanel, + WidgetConfigPanel, +} from '@object-ui/plugin-dashboard'; import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; -import { LayoutDashboard, Pencil } from 'lucide-react'; +import { + LayoutDashboard, + Pencil, + TrendingUp, + BarChart3, + LineChart, + PieChart, + Table2, + LayoutGrid, + Plus, +} 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); 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); }, [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); + }, + [editSchema, saveSchema], + ); + + // ---- Dashboard config panel handlers ------------------------------------ + const dashboardConfig = useMemo( + () => extractDashboardConfig(editSchema || (dashboard as DashboardSchema)), + [editSchema, dashboard], + ); + + 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); + }, + [editSchema, saveSchema], + ); + + const handleDashboardFieldChange = useCallback( + (field: string, value: any) => { + setEditSchema((prev) => (prev ? { ...prev, [field]: value } : prev)); + }, + [], + ); + + // ---- Widget config panel handlers --------------------------------------- + const selectedWidget = editSchema?.widgets?.find((w) => w.id === selectedWidgetId); + + const widgetConfig = useMemo( + () => (selectedWidget ? flattenWidgetConfig(selectedWidget) : {}), + [selectedWidget], + ); + + 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); + }, + [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 +271,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 +284,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 ? ( + + ) : ( + + )} +
- - - {(schema, onChange) => ( - Loading editor…
}> - - - )} -
); } From 4de6992d4c3393b5dcfa624c9ebd31a60016fe8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:23:22 +0000 Subject: [PATCH 3/5] fix: guard against undefined layout in unflattenWidgetConfig, update ROADMAP Addresses code review feedback: - Guard base.layout with fallback to empty object in unflattenWidgetConfig - Add Phase 8 to ROADMAP documenting the inline config panel refactor Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 12 ++++++++++++ apps/console/src/components/DashboardView.tsx | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 911260e20f..d4a3bfbd19 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -374,6 +374,18 @@ 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] Update 16 integration tests (7 DashboardDesignInteraction + 9 DashboardViewSelection) to verify inline config panel pattern + ### 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/components/DashboardView.tsx b/apps/console/src/components/DashboardView.tsx index 860aa9a826..34ce282d5f 100644 --- a/apps/console/src/components/DashboardView.tsx +++ b/apps/console/src/components/DashboardView.tsx @@ -82,7 +82,7 @@ function unflattenWidgetConfig( categoryField: config.categoryField, valueField: config.valueField, aggregate: config.aggregate, - layout: { ...base.layout, w: config.layoutW, h: config.layoutH } as DashboardWidgetSchema['layout'], + layout: { ...(base.layout || {}), w: config.layoutW, h: config.layoutH } as DashboardWidgetSchema['layout'], colorVariant: config.colorVariant, actionUrl: config.actionUrl, }; From 2e0c98899932a91ad696364c7372488df6de0b15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:46:03 +0000 Subject: [PATCH 4/5] fix: stabilize config draft, add widget delete, fix live preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical fixes for complete ListView-parity: 1. Stabilize config references using configVersion counter pattern (matching ViewConfigPanel's stableActiveView approach) — prevents useConfigDraft from resetting the draft on every field change, so Save/Discard footer works correctly. 2. Add widget delete button via WidgetConfigPanel's new headerExtra prop — renders a Trash2 icon in the config panel header. 3. Fix dashboard config live preview — properly convert refreshInterval to number when updating editSchema. 4. Add headerExtra prop to WidgetConfigPanel component. 5. Add tests for widget deletion, live field change preview, and config panel header actions. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../DashboardDesignInteraction.test.tsx | 55 ++++++++++++++++ .../__tests__/DashboardViewSelection.test.tsx | 53 ++++++++++++++++ apps/console/src/components/DashboardView.tsx | 63 ++++++++++++++++--- .../src/WidgetConfigPanel.tsx | 4 ++ 4 files changed, 167 insertions(+), 8 deletions(-) diff --git a/apps/console/src/__tests__/DashboardDesignInteraction.test.tsx b/apps/console/src/__tests__/DashboardDesignInteraction.test.tsx index 8191922d73..a6c3c53145 100644 --- a/apps/console/src/__tests__/DashboardDesignInteraction.test.tsx +++ b/apps/console/src/__tests__/DashboardDesignInteraction.test.tsx @@ -115,6 +115,7 @@ vi.mock('@object-ui/plugin-dashboard', () => ({ return (
{props.config?.title ?? 'none'} + {props.headerExtra &&
{props.headerExtra}
}
); @@ -253,4 +254,58 @@ describe('Dashboard Design Mode — Inline Config Panel', () => { expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('false'); expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none'); }); + + it('should show delete button in widget config panel header', async () => { + await renderDashboardView(); + await openConfigPanel(); + + await act(async () => { + fireEvent.click(screen.getByTestId('renderer-widget-w1')); + }); + + 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 () => { + fireEvent.click(screen.getByTestId('renderer-widget-w1')); + }); + expect(screen.getByTestId('widget-config-panel')).toBeInTheDocument(); + + // 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 preserve live preview when field changes via onFieldChange', async () => { + await renderDashboardView(); + await openConfigPanel(); + + await act(async () => { + fireEvent.click(screen.getByTestId('renderer-widget-w1')); + }); + + // Simulate a live field change via onFieldChange + await act(async () => { + widgetConfigCalls.onFieldChange?.('title', 'Live Title'); + }); + + // 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 fa554ee416..704ef3219c 100644 --- a/apps/console/src/__tests__/DashboardViewSelection.test.tsx +++ b/apps/console/src/__tests__/DashboardViewSelection.test.tsx @@ -114,6 +114,7 @@ vi.mock('@object-ui/plugin-dashboard', () => ({ return (
{props.config?.title ?? 'none'} + {props.headerExtra &&
{props.headerExtra}
} + } /> ) : ( ) => 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} /> ); } From 06b59673a4e6d920645891ca05182109300d2769 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:49:27 +0000 Subject: [PATCH 5/5] fix: restore dashboard config save properties, improve stabilization comments - Restore rowHeight/showDescription/theme in handleDashboardConfigSave (prevents data loss on save) - Simplify dashboardConfig memo dep to [configVersion] alone - Improve comments explaining the configVersion stabilization pattern - Update ROADMAP with widget delete and config stabilization entries Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 5 ++++- apps/console/src/components/DashboardView.tsx | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d4a3bfbd19..0f0d1ec3aa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -384,7 +384,10 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind - [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] Update 16 integration tests (7 DashboardDesignInteraction + 9 DashboardViewSelection) to verify inline config panel pattern +- [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 diff --git a/apps/console/src/components/DashboardView.tsx b/apps/console/src/components/DashboardView.tsx index a6e34f1b60..92e72506a3 100644 --- a/apps/console/src/components/DashboardView.tsx +++ b/apps/console/src/components/DashboardView.tsx @@ -194,13 +194,14 @@ export function DashboardView({ dataSource }: { dataSource?: any }) { ); // ---- Dashboard config panel handlers ------------------------------------ - // Stabilize config reference: only recompute when panel opens or after save. - // This prevents useConfigDraft from resetting the draft on every parent re-render - // (same pattern as ViewConfigPanel's stableActiveView). + // 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 - [dashboardName, configVersion], + [configVersion], ); const handleDashboardConfigSave = useCallback( @@ -210,8 +211,11 @@ export function DashboardView({ dataSource }: { dataSource?: any }) { ...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); @@ -238,8 +242,10 @@ export function DashboardView({ dataSource }: { dataSource?: any }) { // ---- Widget config panel handlers --------------------------------------- const selectedWidget = editSchema?.widgets?.find((w) => w.id === selectedWidgetId); - // Stabilize widget config: only recompute when selecting a different widget - // or after save — prevents useConfigDraft from resetting the draft on live preview updates. + // 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