diff --git a/ROADMAP.md b/ROADMAP.md index 2d2c24af45..7a21851188 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -366,6 +366,14 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind - [x] Auto-save property changes to backend via DesignDrawer - [x] Add Vitest tests (15 DashboardRenderer design mode + 9 DashboardEditor external selection + 8 DashboardView integration = 32 new tests) +**Phase 7 — Non-Modal Drawer & Property Panel UX Fix:** +- [x] `SheetContent` — added `hideOverlay` prop to conditionally skip the full-screen backdrop overlay +- [x] `DesignDrawer` — `modal={false}` + `hideOverlay` so preview widgets are clickable while drawer is open +- [x] `DashboardEditor` — property panel renders above widget grid (stacked `flex-col` layout) for immediate visibility in narrow drawer +- [x] `DashboardEditor` — property panel uses full width (removed fixed `w-72`) for better readability in drawer context +- [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) + ### 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 new file mode 100644 index 0000000000..24f0888b2c --- /dev/null +++ b/apps/console/src/__tests__/DashboardDesignInteraction.test.tsx @@ -0,0 +1,293 @@ +/** + * 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 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +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, + }, + rendererCalls: { + designMode: false, + selectedWidgetId: null as string | null, + onWidgetClick: null as ((id: string | null) => void) | null, + }, +})); + +// Mock MetadataProvider with a dashboard +vi.mock('../context/MetadataProvider', () => ({ + useMetadata: () => ({ + apps: [], + objects: [], + dashboards: [ + { + name: 'crm-dashboard', + type: 'dashboard', + title: 'CRM Overview', + label: 'CRM Overview', + columns: 2, + widgets: [ + { id: 'w1', title: 'Total Revenue', type: 'metric', object: 'orders', valueField: 'amount', aggregate: 'sum' }, + { id: 'w2', title: 'Revenue Trends', type: 'line', object: 'orders', categoryField: 'month' }, + { id: 'w3', title: 'Pipeline by Stage', type: 'bar', object: 'opportunities' }, + ], + }, + ], + reports: [], + pages: [], + loading: false, + error: null, + refresh: vi.fn(), + }), +})); + +// Mock AdapterProvider +const { mockUpdate } = vi.hoisted(() => ({ mockUpdate: vi.fn().mockResolvedValue({}) })); +vi.mock('../context/AdapterProvider', () => ({ + useAdapter: () => ({ + update: mockUpdate, + create: vi.fn().mockResolvedValue({}), + }), +})); + +// Mock DashboardRenderer to capture design mode props +vi.mock('@object-ui/plugin-dashboard', () => ({ + DashboardRenderer: (props: any) => { + rendererCalls.designMode = props.designMode; + rendererCalls.selectedWidgetId = props.selectedWidgetId; + rendererCalls.onWidgetClick = props.onWidgetClick; + return ( +
+ {String(!!props.designMode)} + {props.selectedWidgetId ?? 'none'} + {props.schema?.widgets?.map((w: any) => ( +
props.onWidgetClick?.(w.id)} + > + {w.title} +
+ ))} +
+ ); + }, +})); + +// 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); + return ( +
+ {props.selectedWidgetId ?? 'none'} + {widget && ( +
+ {widget.title} +
+ )} + {props.schema?.widgets?.map((w: any) => ( + + ))} +
+ ); + }, +})); + +// Mock sonner toast +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +// 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; +}); + +const renderDashboardView = async () => { + const result = render( + + + } /> + + , + ); + // Wait for the queueMicrotask loading state to resolve + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + return result; +}; + +const openDrawer = 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 () => { + await renderDashboardView(); + + 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(); + }); + + it('should allow clicking preview widgets while drawer is open', async () => { + await renderDashboardView(); + await openDrawer(); + + // 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'); + }); + + it('should show property panel in editor when preview widget is clicked', async () => { + await renderDashboardView(); + await openDrawer(); + + // Click widget in preview + await act(async () => { + fireEvent.click(screen.getByTestId('renderer-widget-w1')); + }); + + // 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 + await act(async () => { + fireEvent.click(screen.getByTestId('editor-widget-w2')); + }); + + // 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 switch selection between different widgets', async () => { + await renderDashboardView(); + await openDrawer(); + + // Select w1 + await act(async () => { + fireEvent.click(screen.getByTestId('renderer-widget-w1')); + }); + expect(screen.getByTestId('editor-widget-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'); + }); + + it('should deselect when clicking empty space in preview', 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(); + + // Deselect by calling onWidgetClick(null) (simulates background click) + await act(async () => { + rendererCalls.onWidgetClick?.(null); + }); + + // Property panel should be hidden + expect(screen.queryByTestId('editor-property-panel')).not.toBeInTheDocument(); + expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none'); + }); + + it('should clear selection when drawer is closed', async () => { + await renderDashboardView(); + + // 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); + }); + } + + // 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/components/DesignDrawer.tsx b/apps/console/src/components/DesignDrawer.tsx index 6d2a09356a..219c1c36b8 100644 --- a/apps/console/src/components/DesignDrawer.tsx +++ b/apps/console/src/components/DesignDrawer.tsx @@ -93,9 +93,10 @@ export function DesignDrawer({ }, [open, saveSchema, title]); return ( - + diff --git a/packages/components/src/ui/sheet.tsx b/packages/components/src/ui/sheet.tsx index 5c21aa1a7f..43655e26c2 100644 --- a/packages/components/src/ui/sheet.tsx +++ b/packages/components/src/ui/sheet.tsx @@ -59,14 +59,17 @@ const sheetVariants = cva( interface SheetContentProps extends React.ComponentPropsWithoutRef, - VariantProps {} + VariantProps { + /** When true, the backdrop overlay is not rendered (useful for non-modal drawers) */ + hideOverlay?: boolean; +} const SheetContent = React.forwardRef< React.ElementRef, SheetContentProps ->(({ side = "right", className, children, ...props }, ref) => ( +>(({ side = "right", className, children, hideOverlay, ...props }, ref) => ( - + {!hideOverlay && }

{t('appDesigner.widgetProperties')}

@@ -572,7 +572,7 @@ export function DashboardEditor({ ref={containerRef} tabIndex={0} data-testid="dashboard-editor" - className={cn('flex flex-col gap-4 outline-none sm:flex-row', className)} + className={cn('flex flex-col gap-4 outline-none', className)} > {/* Hidden file input for import */} + {/* Property panel — shown above widget list when a widget is selected */} + {selectedWidget && !previewMode && ( + setSelectedWidgetId(null)} + /> + )} + {/* Main area */}
{/* Toolbar */} @@ -695,16 +705,6 @@ export function DashboardEditor({
)}
- - {/* Property panel */} - {selectedWidget && !previewMode && ( - setSelectedWidgetId(null)} - /> - )} ); } diff --git a/packages/plugin-designer/src/__tests__/DashboardEditor.propertyPanelLayout.test.tsx b/packages/plugin-designer/src/__tests__/DashboardEditor.propertyPanelLayout.test.tsx new file mode 100644 index 0000000000..bab7ce4e3d --- /dev/null +++ b/packages/plugin-designer/src/__tests__/DashboardEditor.propertyPanelLayout.test.tsx @@ -0,0 +1,91 @@ +/** + * DashboardEditor Property Panel Layout Tests + * + * Verifies that the property panel renders above the widget grid + * so it's immediately visible in the narrow DesignDrawer context. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { DashboardEditor } from '../DashboardEditor'; +import type { DashboardSchema } from '@object-ui/types'; + +const DASHBOARD_WITH_WIDGETS: DashboardSchema = { + type: 'dashboard', + title: 'Test Dashboard', + columns: 2, + widgets: [ + { id: 'w1', title: 'Revenue', type: 'metric', object: 'orders', valueField: 'amount', aggregate: 'sum' }, + { id: 'w2', title: 'Sales Chart', type: 'bar', object: 'orders', categoryField: 'month' }, + ], +}; + +describe('DashboardEditor property panel layout', () => { + it('should render property panel before widget grid in DOM order when widget is selected', () => { + const { container } = render( + , + ); + + const editor = screen.getByTestId('dashboard-editor'); + const propertyPanel = screen.getByTestId('widget-property-panel'); + const widgetCard = screen.getByTestId('dashboard-widget-w1'); + + // Property panel should appear before the widget cards in DOM order + // compareDocumentPosition bit 4 (DOCUMENT_POSITION_FOLLOWING) means widgetCard comes after propertyPanel + const position = propertyPanel.compareDocumentPosition(widgetCard); + expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('should render property panel as a direct child of the editor container', () => { + render( + , + ); + + const editor = screen.getByTestId('dashboard-editor'); + const propertyPanel = screen.getByTestId('widget-property-panel'); + + // Property panel should be a direct child of the editor (not nested inside main area) + expect(propertyPanel.parentElement).toBe(editor); + }); + + it('should use vertical (flex-col) layout for the editor', () => { + render( + , + ); + + const editor = screen.getByTestId('dashboard-editor'); + // Should use flex-col layout, NOT sm:flex-row + expect(editor.className).toContain('flex-col'); + expect(editor.className).not.toContain('flex-row'); + }); + + it('should not have fixed width on property panel (uses full width in stacked layout)', () => { + render( + , + ); + + const propertyPanel = screen.getByTestId('widget-property-panel'); + // Should NOT have the old w-72 fixed width class + expect(propertyPanel.className).not.toContain('w-72'); + }); +});