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
10 changes: 10 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,16 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
- [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

**Phase 9 — Design Mode Widget Selection Click-Through Fix:**
- [x] Fix: Widget content (charts, tables via `SchemaRenderer`) intercepted click events, preventing selection in edit mode
- [x] Defense layer 1: `pointer-events-none` on `SchemaRenderer` content wrappers disables chart/table hover and tooltip interactivity in design mode
- [x] Defense layer 2: Transparent click-capture overlay (`absolute inset-0 z-10`) renders on top of widget content in design mode — guarantees click reaches widget handler even if SVG children override `pointer-events`
- [x] Self-contained (metric) widgets: both `pointer-events-none` on SchemaRenderer + overlay inside `relative` wrapper
- [x] Card-based (chart/table) widgets: `pointer-events-none` on `CardContent` inner wrapper + overlay inside `relative` Card
- [x] No impact on non-design mode — widgets remain fully interactive when not editing
- [x] Updated SchemaRenderer mock to forward `className` and include interactive child button for more realistic testing
- [x] Add 9 new Vitest tests: pointer-events-none presence/absence, overlay presence/absence, relative positioning, click-to-select on Card-based widgets

### 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
9 changes: 6 additions & 3 deletions packages/plugin-dashboard/src/DashboardRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,14 +187,15 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
return (
<div
key={widgetKey}
className={cn("h-full w-full", selectionClasses)}
className={cn("h-full w-full", designMode && "relative", selectionClasses)}
style={!isMobile && widget.layout ? {
gridColumn: `span ${widget.layout.w}`,
gridRow: `span ${widget.layout.h}`
}: undefined}
{...designModeProps}
>
<SchemaRenderer schema={componentSchema} className="h-full w-full" />
<SchemaRenderer schema={componentSchema} className={cn("h-full w-full", designMode && "pointer-events-none")} />
{designMode && <div className="absolute inset-0 z-10" aria-hidden="true" data-testid="widget-click-overlay" />}
</div>
);
}
Expand All @@ -206,6 +207,7 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
"overflow-hidden border-border/50 shadow-sm transition-all hover:shadow-md",
"bg-card/50 backdrop-blur-sm",
forceMobileFullWidth && "w-full",
designMode && "relative",
selectionClasses
)}
style={!isMobile && widget.layout ? {
Expand All @@ -222,10 +224,11 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
</CardHeader>
)}
<CardContent className="p-0">
<div className={cn("h-full w-full", "p-3 sm:p-4 md:p-6")}>
<div className={cn("h-full w-full", "p-3 sm:p-4 md:p-6", designMode && "pointer-events-none")}>
<SchemaRenderer schema={componentSchema} />
</div>
</CardContent>
{designMode && <div className="absolute inset-0 z-10" aria-hidden="true" data-testid="widget-click-overlay" />}
</Card>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { DashboardRenderer } from '../DashboardRenderer';
import type { DashboardSchema } from '@object-ui/types';

// Mock SchemaRenderer to avoid pulling in the full renderer tree
// Mock SchemaRenderer to avoid pulling in the full renderer tree.
// Forwards className and includes an interactive child to simulate real chart content.
vi.mock('@object-ui/react', () => ({
SchemaRenderer: ({ schema }: { schema: any }) => (
<div data-testid="schema-renderer">{schema?.type ?? 'unknown'}</div>
SchemaRenderer: ({ schema, className }: { schema: any; className?: string }) => (
<div data-testid="schema-renderer" className={className}>
<button data-testid={`interactive-child-${schema?.type ?? 'unknown'}`}>
{schema?.type ?? 'unknown'}
</button>
</div>
),
}));

Expand Down Expand Up @@ -229,6 +234,144 @@ describe('DashboardRenderer design mode', () => {
});
});

describe('Content pointer-events in design mode', () => {
it('should apply pointer-events-none to widget content in design mode', () => {
render(
<DashboardRenderer
schema={DASHBOARD_WITH_WIDGETS}
designMode
selectedWidgetId={null}
onWidgetClick={vi.fn()}
/>,
);

// Card widget (bar chart) — content wrapper should have pointer-events-none
const barWidget = screen.getByTestId('dashboard-preview-widget-w2');
const contentWrapper = barWidget.querySelector('.pointer-events-none');
expect(contentWrapper).toBeInTheDocument();
});

it('should apply pointer-events-none to self-contained (metric) widget content', () => {
render(
<DashboardRenderer
schema={DASHBOARD_WITH_WIDGETS}
designMode
selectedWidgetId={null}
onWidgetClick={vi.fn()}
/>,
);

// Metric widget — SchemaRenderer receives pointer-events-none className
const metricWidget = screen.getByTestId('dashboard-preview-widget-w1');
const contentWrapper = metricWidget.querySelector('.pointer-events-none');
expect(contentWrapper).toBeInTheDocument();
});

it('should NOT apply pointer-events-none when not in design mode', () => {
const { container } = render(<DashboardRenderer schema={DASHBOARD_WITH_WIDGETS} />);

// No element should have pointer-events-none class
expect(container.querySelector('.pointer-events-none')).not.toBeInTheDocument();
});

it('should still call onWidgetClick when clicking on Card-based widget content area', () => {
const onWidgetClick = vi.fn();
render(
<DashboardRenderer
schema={DASHBOARD_WITH_WIDGETS}
designMode
selectedWidgetId={null}
onWidgetClick={onWidgetClick}
/>,
);

// Click on the bar chart widget (Card-based)
fireEvent.click(screen.getByTestId('dashboard-preview-widget-w2'));
expect(onWidgetClick).toHaveBeenCalledWith('w2');
});

it('should still call onWidgetClick when clicking on table widget', () => {
const onWidgetClick = vi.fn();
render(
<DashboardRenderer
schema={DASHBOARD_WITH_WIDGETS}
designMode
selectedWidgetId={null}
onWidgetClick={onWidgetClick}
/>,
);

// Click on the table widget (Card-based)
fireEvent.click(screen.getByTestId('dashboard-preview-widget-w3'));
expect(onWidgetClick).toHaveBeenCalledWith('w3');
});
});

describe('Click-capture overlay in design mode', () => {
it('should render a click-capture overlay on Card-based widgets in design mode', () => {
render(
<DashboardRenderer
schema={DASHBOARD_WITH_WIDGETS}
designMode
selectedWidgetId={null}
onWidgetClick={vi.fn()}
/>,
);

// Card widget (bar chart) — should have an absolute overlay div
const barWidget = screen.getByTestId('dashboard-preview-widget-w2');
const overlay = barWidget.querySelector('[data-testid="widget-click-overlay"]');
expect(overlay).toBeInTheDocument();
expect(overlay?.className).toContain('absolute');
expect(overlay?.className).toContain('inset-0');
expect(overlay?.className).toContain('z-10');
});

it('should render a click-capture overlay on self-contained widgets in design mode', () => {
render(
<DashboardRenderer
schema={DASHBOARD_WITH_WIDGETS}
designMode
selectedWidgetId={null}
onWidgetClick={vi.fn()}
/>,
);

// Metric widget — should have an absolute overlay div
const metricWidget = screen.getByTestId('dashboard-preview-widget-w1');
const overlay = metricWidget.querySelector('[data-testid="widget-click-overlay"]');
expect(overlay).toBeInTheDocument();
expect(overlay?.className).toContain('absolute');
expect(overlay?.className).toContain('inset-0');
expect(overlay?.className).toContain('z-10');
});

it('should NOT render overlays when not in design mode', () => {
const { container } = render(<DashboardRenderer schema={DASHBOARD_WITH_WIDGETS} />);

expect(container.querySelector('[data-testid="widget-click-overlay"]')).not.toBeInTheDocument();
});

it('should apply relative positioning to widget container in design mode', () => {
render(
<DashboardRenderer
schema={DASHBOARD_WITH_WIDGETS}
designMode
selectedWidgetId={null}
onWidgetClick={vi.fn()}
/>,
);

// Card widget should have relative for overlay positioning
const barWidget = screen.getByTestId('dashboard-preview-widget-w2');
expect(barWidget.className).toContain('relative');

// Metric widget should have relative for overlay positioning
const metricWidget = screen.getByTestId('dashboard-preview-widget-w1');
expect(metricWidget.className).toContain('relative');
});
});

describe('Non-design mode behavior', () => {
it('should not add design mode attributes when designMode is off', () => {
const { container } = render(<DashboardRenderer schema={DASHBOARD_WITH_WIDGETS} />);
Expand Down