Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
256 changes: 137 additions & 119 deletions apps/console/src/__tests__/DashboardDesignInteraction.test.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,37 @@
/**
* 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';
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<string, any> | null,
},
widgetConfigCalls: {
open: false,
onClose: null as (() => void) | null,
config: null as Record<string, any> | null,
onSave: null as ((config: Record<string, any>) => void) | null,
onFieldChange: null as ((field: string, value: any) => void) | null,
},
}));

// Mock MetadataProvider with a dashboard
Expand Down Expand Up @@ -85,32 +93,30 @@ vi.mock('@object-ui/plugin-dashboard', () => ({
</div>
);
},
}));

// 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 (
<div data-testid="dashboard-editor">
<span data-testid="editor-selected">{props.selectedWidgetId ?? 'none'}</span>
{widget && (
<div data-testid="editor-property-panel">
<span data-testid="editor-widget-title">{widget.title}</span>
</div>
)}
{props.schema?.widgets?.map((w: any) => (
<button
key={w.id}
data-testid={`editor-widget-${w.id}`}
onClick={() => props.onWidgetSelect?.(w.id)}
>
{w.title}
</button>
))}
<div data-testid="dashboard-config-panel">
<span data-testid="dashboard-config-columns">{props.config?.columns ?? 'none'}</span>
<button data-testid="dashboard-config-close" onClick={props.onClose}>Close</button>
</div>
);
},
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 (
<div data-testid="widget-config-panel">
<span data-testid="widget-config-title">{props.config?.title ?? 'none'}</span>
{props.headerExtra && <div data-testid="widget-config-header-extra">{props.headerExtra}</div>}
<button data-testid="widget-config-close" onClick={props.onClose}>Close</button>
</div>
);
},
Expand All @@ -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<string, unknown>),
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 () => {
Expand All @@ -151,143 +153,159 @@ const renderDashboardView = async () => {
</Routes>
</MemoryRouter>,
);
// 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();
});
});
Loading