Skip to content

Commit b7e232e

Browse files
authored
Merge pull request #796 from objectstack-ai/copilot/add-visual-editor-drawer
2 parents 75aaa32 + 81f9baf commit b7e232e

5 files changed

Lines changed: 414 additions & 19 deletions

File tree

ROADMAP.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@
55
> **Spec Version:** @objectstack/spec v3.0.9
66
> **Client Version:** @objectstack/client v3.0.9
77
> **Target UX Benchmark:** 🎯 Airtable parity
8-
> **Current Priority:** AppShell Navigation · Designer Interaction · View Config Live Preview Sync · Dashboard Config Panel · Airtable UX Polish · **Flow Designer ✅** · **App Creation & Editing Flow ✅** · **System Settings & App Management ✅**
8+
> **Current Priority:** AppShell Navigation · Designer Interaction · View Config Live Preview Sync · Dashboard Config Panel · Airtable UX Polish · **Flow Designer ✅** · **App Creation & Editing Flow ✅** · **System Settings & App Management ✅** · **Right-Side Visual Editor Drawer ✅**
99
1010
---
1111

1212
## 📋 Executive Summary
1313

1414
ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind + Shadcn. It renders JSON metadata from the @objectstack/spec protocol into pixel-perfect, accessible, and interactive enterprise interfaces.
1515

16-
**Where We Are:** Foundation is **solid and shipping** — 35 packages, 99+ components, 5,700+ tests, 80 Storybook stories, 43/43 builds passing, ~85% protocol alignment. SpecBridge, Expression Engine, Action Engine, data binding, all view plugins (Grid/Kanban/Calendar/Gantt/Timeline/Map/Gallery), Record components, Report engine, Dashboard BI features, mobile UX, i18n (11 locales), WCAG AA accessibility, Designer Phase 1 (ViewDesigner drag-to-reorder ✅), Console through Phase 20 (L3), **AppShell Navigation Renderer** (P0.1), **Flow Designer** (P2.4), **Feed/Chatter UI** (P1.5), **App Creation & Editing Flow** (P1.11), **System Settings & App Management** (P1.12), and **Page/Dashboard Editor Console Integration** (P1.11) — all ✅ complete.
16+
**Where We Are:** Foundation is **solid and shipping** — 35 packages, 99+ components, 5,700+ tests, 80 Storybook stories, 43/43 builds passing, ~85% protocol alignment. SpecBridge, Expression Engine, Action Engine, data binding, all view plugins (Grid/Kanban/Calendar/Gantt/Timeline/Map/Gallery), Record components, Report engine, Dashboard BI features, mobile UX, i18n (11 locales), WCAG AA accessibility, Designer Phase 1 (ViewDesigner drag-to-reorder ✅), Console through Phase 20 (L3), **AppShell Navigation Renderer** (P0.1), **Flow Designer** (P2.4), **Feed/Chatter UI** (P1.5), **App Creation & Editing Flow** (P1.11), **System Settings & App Management** (P1.12), **Page/Dashboard Editor Console Integration** (P1.11), and **Right-Side Visual Editor Drawer** (P1.11) — all ✅ complete.
1717

1818
**What Remains:** The gap to **Airtable-level UX** is primarily in:
1919
1. ~~**AppShell** — No dynamic navigation renderer from spec JSON (last P0 blocker)~~ ✅ Complete
@@ -540,10 +540,12 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
540540
- [x] 13 console integration tests (routes, wizard callbacks, draft persistence, saveItem, CommandPalette)
541541
- [x] `PageDesignPage` — integrates `PageCanvasEditor` at `/design/page/:pageName` route with auto-save, JSON export/import
542542
- [x] `DashboardDesignPage` — integrates `DashboardEditor` at `/design/dashboard/:dashboardName` route with auto-save, JSON export/import
543-
- [x] "Edit" button on `PageView` and `DashboardView` — navigates to corresponding design routes
543+
- [x] "Edit" button on `PageView` and `DashboardView` — opens right-side `DesignDrawer` with real-time preview (no page navigation)
544+
- [x] `DesignDrawer` component — reusable right-side Sheet panel hosting editors with auto-save, Ctrl+S shortcut, and live schema sync
544545
- [x] Ctrl+S/Cmd+S keyboard shortcut to explicitly save in both design pages (with toast confirmation)
545546
- [x] Storybook stories for `PageCanvasEditor` and `DashboardEditor` (Designers/PageCanvasEditor, Designers/DashboardEditor)
546547
- [x] 12 console design page tests (PageDesignPage + DashboardDesignPage: routes, 404 handling, editor rendering, onChange, Ctrl+S save)
548+
- [x] 7 DesignDrawer tests (drawer open/close, editor rendering, real-time preview sync, auto-save, Ctrl+S, no navigation)
547549

548550
### P1.12 System Settings & App Management Center
549551

@@ -794,6 +796,6 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
794796

795797
---
796798

797-
**Roadmap Status:** 🎯 Active — AppShell · Designer Interaction · View Config Live Preview Sync (P1.8.1) · Dashboard Config Panel · Schema-Driven View Config Panel ✅ · Airtable UX Parity
799+
**Roadmap Status:** 🎯 Active — AppShell · Designer Interaction · View Config Live Preview Sync (P1.8.1) · Dashboard Config Panel · Schema-Driven View Config Panel ✅ · Right-Side Visual Editor Drawer ✅ · Airtable UX Parity
798800
**Next Review:** March 15, 2026
799801
**Contact:** hello@objectui.org | https://github.com/objectstack-ai/objectui
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* DesignDrawer Tests
3+
*
4+
* Tests the right-side drawer visual editing panel with real-time preview.
5+
*/
6+
7+
import { describe, it, expect, vi, beforeEach } from 'vitest';
8+
import { render, screen, fireEvent, act } from '@testing-library/react';
9+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
10+
import { PageView } from '../components/PageView';
11+
12+
// Mock MetadataProvider
13+
vi.mock('../context/MetadataProvider', () => ({
14+
useMetadata: () => ({
15+
apps: [],
16+
objects: [],
17+
dashboards: [],
18+
reports: [],
19+
pages: [
20+
{
21+
name: 'test-page',
22+
type: 'page',
23+
title: 'Test Page',
24+
children: [
25+
{ type: 'text', value: 'Original Content' },
26+
],
27+
},
28+
],
29+
loading: false,
30+
error: null,
31+
refresh: vi.fn(),
32+
}),
33+
}));
34+
35+
// Mock AdapterProvider
36+
const { mockUpdate } = vi.hoisted(() => ({ mockUpdate: vi.fn().mockResolvedValue({}) }));
37+
vi.mock('../context/AdapterProvider', () => ({
38+
useAdapter: () => ({
39+
update: mockUpdate,
40+
create: vi.fn().mockResolvedValue({}),
41+
}),
42+
}));
43+
44+
// Mock SchemaRenderer to show current schema for preview verification
45+
vi.mock('@object-ui/react', () => ({
46+
SchemaRenderer: ({ schema }: { schema: any }) => (
47+
<div data-testid="schema-renderer">
48+
Rendered {schema.type}: {schema.name || 'unnamed'}
49+
{schema.children?.[0]?.value}
50+
</div>
51+
),
52+
}));
53+
54+
// Mock plugin-designer
55+
vi.mock('@object-ui/plugin-designer', () => ({
56+
PageCanvasEditor: ({ schema, onChange }: any) => (
57+
<div data-testid="page-canvas-editor">
58+
<span>Editor: {schema.title || schema.name}</span>
59+
<button
60+
data-testid="editor-change"
61+
onClick={() =>
62+
onChange({
63+
...schema,
64+
title: 'Updated Title',
65+
children: [{ type: 'text', value: 'Updated Content' }],
66+
})
67+
}
68+
>
69+
Make Change
70+
</button>
71+
</div>
72+
),
73+
}));
74+
75+
// Mock sonner toast
76+
vi.mock('sonner', () => ({
77+
toast: {
78+
success: vi.fn(),
79+
error: vi.fn(),
80+
},
81+
}));
82+
83+
// Mock Radix Dialog portal to render inline for testing
84+
vi.mock('@radix-ui/react-dialog', async () => {
85+
const actual = await vi.importActual<typeof import('@radix-ui/react-dialog')>('@radix-ui/react-dialog');
86+
return {
87+
...actual,
88+
Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
89+
};
90+
});
91+
92+
beforeEach(() => {
93+
mockUpdate.mockClear();
94+
});
95+
96+
const renderPageView = () =>
97+
render(
98+
<MemoryRouter initialEntries={['/page/test-page']}>
99+
<Routes>
100+
<Route path="/page/:pageName" element={<PageView />} />
101+
</Routes>
102+
</MemoryRouter>,
103+
);
104+
105+
describe('DesignDrawer — Right-Side Editor Panel', () => {
106+
it('should render the edit button', () => {
107+
renderPageView();
108+
expect(screen.getByTestId('page-edit-button')).toBeInTheDocument();
109+
});
110+
111+
it('should open design drawer when edit button is clicked', async () => {
112+
renderPageView();
113+
114+
await act(async () => {
115+
fireEvent.click(screen.getByTestId('page-edit-button'));
116+
});
117+
118+
// Drawer should be visible
119+
expect(screen.getByTestId('design-drawer')).toBeInTheDocument();
120+
});
121+
122+
it('should display the editor inside the drawer', async () => {
123+
renderPageView();
124+
125+
await act(async () => {
126+
fireEvent.click(screen.getByTestId('page-edit-button'));
127+
});
128+
129+
// Editor should be rendered inside the drawer
130+
expect(screen.getByTestId('page-canvas-editor')).toBeInTheDocument();
131+
expect(screen.getByText(/Editor: Test Page/)).toBeInTheDocument();
132+
});
133+
134+
it('should update preview in real-time when editor makes changes', async () => {
135+
renderPageView();
136+
137+
// Open the drawer
138+
await act(async () => {
139+
fireEvent.click(screen.getByTestId('page-edit-button'));
140+
});
141+
142+
// Verify original content is shown
143+
expect(screen.getByTestId('schema-renderer')).toHaveTextContent('Original Content');
144+
145+
// Make a change in the editor
146+
await act(async () => {
147+
fireEvent.click(screen.getByTestId('editor-change'));
148+
});
149+
150+
// Preview should reflect the updated content
151+
expect(screen.getByTestId('schema-renderer')).toHaveTextContent('Updated Content');
152+
});
153+
154+
it('should auto-save changes via dataSource.update', async () => {
155+
renderPageView();
156+
157+
await act(async () => {
158+
fireEvent.click(screen.getByTestId('page-edit-button'));
159+
});
160+
161+
await act(async () => {
162+
fireEvent.click(screen.getByTestId('editor-change'));
163+
});
164+
165+
// Should call dataSource.update with the updated schema
166+
expect(mockUpdate).toHaveBeenCalledWith(
167+
'sys_page',
168+
'test-page',
169+
expect.objectContaining({ title: 'Updated Title' }),
170+
);
171+
});
172+
173+
it('should save via Ctrl+S keyboard shortcut when drawer is open', async () => {
174+
renderPageView();
175+
176+
await act(async () => {
177+
fireEvent.click(screen.getByTestId('page-edit-button'));
178+
});
179+
180+
await act(async () => {
181+
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
182+
});
183+
184+
expect(mockUpdate).toHaveBeenCalledWith(
185+
'sys_page',
186+
'test-page',
187+
expect.objectContaining({ type: 'page' }),
188+
);
189+
});
190+
191+
it('should not navigate away when edit button is clicked', () => {
192+
const { container } = renderPageView();
193+
194+
fireEvent.click(screen.getByTestId('page-edit-button'));
195+
196+
// Main renderer should still be visible (no navigation)
197+
expect(screen.getByTestId('schema-renderer')).toBeInTheDocument();
198+
});
199+
});

apps/console/src/components/DashboardView.tsx

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,30 @@
11
/**
22
* Dashboard View Component
3-
* Renders a dashboard based on the dashboardName parameter
3+
* Renders a dashboard based on the dashboardName parameter.
4+
* Edit opens a right-side drawer with DashboardEditor for real-time preview.
45
*/
56

6-
import { useState, useEffect } from 'react';
7-
import { useParams, useNavigate } from 'react-router-dom';
7+
import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
8+
import { useParams } from 'react-router-dom';
89
import { DashboardRenderer } from '@object-ui/plugin-dashboard';
910
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
1011
import { LayoutDashboard, Pencil } from 'lucide-react';
1112
import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector';
1213
import { SkeletonDashboard } from './skeletons';
1314
import { useMetadata } from '../context/MetadataProvider';
1415
import { resolveI18nLabel } from '../utils';
16+
import { DesignDrawer } from './DesignDrawer';
17+
import type { DashboardSchema } from '@object-ui/types';
18+
19+
const DashboardEditor = lazy(() =>
20+
import('@object-ui/plugin-designer').then((m) => ({ default: m.DashboardEditor })),
21+
);
1522

1623
export function DashboardView({ dataSource }: { dataSource?: any }) {
1724
const { dashboardName } = useParams<{ dashboardName: string }>();
18-
const navigate = useNavigate();
1925
const { showDebug, toggleDebug } = useMetadataInspector();
2026
const [isLoading, setIsLoading] = useState(true);
27+
const [drawerOpen, setDrawerOpen] = useState(false);
2128

2229
useEffect(() => {
2330
// Reset loading on navigation; the actual DashboardRenderer handles data fetching
@@ -30,6 +37,18 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
3037
const { dashboards } = useMetadata();
3138
const dashboard = dashboards?.find((d: any) => d.name === dashboardName);
3239

40+
// Local schema state for live preview — initialized from metadata
41+
const [editSchema, setEditSchema] = useState<DashboardSchema | null>(null);
42+
43+
const handleOpenDrawer = useCallback(() => {
44+
setEditSchema(dashboard as DashboardSchema);
45+
setDrawerOpen(true);
46+
}, [dashboard]);
47+
48+
const handleCloseDrawer = useCallback((open: boolean) => {
49+
setDrawerOpen(open);
50+
}, []);
51+
3352
if (isLoading) {
3453
return <SkeletonDashboard />;
3554
}
@@ -51,6 +70,9 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
5170
);
5271
}
5372

73+
// Use live-edited schema for preview when the drawer is open
74+
const previewSchema = drawerOpen && editSchema ? editSchema : dashboard;
75+
5476
return (
5577
<div className="flex flex-col h-full overflow-hidden bg-background">
5678
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-3 sm:gap-4 p-4 sm:p-6 border-b shrink-0">
@@ -63,7 +85,7 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
6385
<div className="shrink-0 flex items-center gap-1.5">
6486
<button
6587
type="button"
66-
onClick={() => navigate(`../design/dashboard/${dashboardName}`)}
88+
onClick={handleOpenDrawer}
6789
className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground shadow-sm hover:bg-accent hover:text-accent-foreground"
6890
data-testid="dashboard-edit-button"
6991
>
@@ -76,14 +98,30 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
7698

7799
<div className="flex-1 overflow-hidden flex flex-col sm:flex-row relative">
78100
<div className="flex-1 overflow-auto p-0 sm:p-6">
79-
<DashboardRenderer schema={dashboard} dataSource={dataSource} />
101+
<DashboardRenderer schema={previewSchema} dataSource={dataSource} />
80102
</div>
81103

82104
<MetadataPanel
83105
open={showDebug}
84-
sections={[{ title: 'Dashboard Configuration', data: dashboard }]}
106+
sections={[{ title: 'Dashboard Configuration', data: previewSchema }]}
85107
/>
86108
</div>
109+
110+
<DesignDrawer
111+
open={drawerOpen}
112+
onOpenChange={handleCloseDrawer}
113+
title={`Edit Dashboard: ${resolveI18nLabel(dashboard.label) || dashboard.name}`}
114+
schema={editSchema || dashboard}
115+
onSchemaChange={setEditSchema}
116+
collection="sys_dashboard"
117+
recordName={dashboardName!}
118+
>
119+
{(schema, onChange) => (
120+
<Suspense fallback={<div className="p-4 text-muted-foreground">Loading editor…</div>}>
121+
<DashboardEditor schema={schema} onChange={onChange} />
122+
</Suspense>
123+
)}
124+
</DesignDrawer>
87125
</div>
88126
);
89127
}

0 commit comments

Comments
 (0)