Skip to content

Commit f0b5a1f

Browse files
authored
Merge pull request #788 from objectstack-ai/copilot/integrate-page-dashboard-editors
feat: integrate Page/Dashboard editors into Console with design routes
2 parents 9b95ca5 + 7d57d69 commit f0b5a1f

File tree

11 files changed

+698
-11
lines changed

11 files changed

+698
-11
lines changed

ROADMAP.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
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, 78 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), and **System Settings & App Management** (P1.12) — 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), and **Page/Dashboard Editor Console Integration** (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
@@ -537,7 +537,13 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
537537
- [x] MSW PUT handler for `/meta/:type/:name` — dev/mock mode metadata persistence
538538
- [x] Draft persistence to localStorage with auto-clear on success
539539
- [x] `createApp` i18n key added to all 10 locales
540-
- [x] 14 console integration tests (routes, wizard callbacks, draft persistence, saveItem, merge preservation, CommandPalette)
540+
- [x] 13 console integration tests (routes, wizard callbacks, draft persistence, saveItem, CommandPalette)
541+
- [x] `PageDesignPage` — integrates `PageCanvasEditor` at `/design/page/:pageName` route with auto-save, JSON export/import
542+
- [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
544+
- [x] Ctrl+S/Cmd+S keyboard shortcut to explicitly save in both design pages (with toast confirmation)
545+
- [x] Storybook stories for `PageCanvasEditor` and `DashboardEditor` (Designers/PageCanvasEditor, Designers/DashboardEditor)
546+
- [x] 12 console design page tests (PageDesignPage + DashboardDesignPage: routes, 404 handling, editor rendering, onChange, Ctrl+S save)
541547

542548
### P1.12 System Settings & App Management Center
543549

apps/console/src/App.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ const SearchResultsPage = lazy(() => import('./components/SearchResultsPage').th
3434
const CreateAppPage = lazy(() => import('./pages/CreateAppPage').then(m => ({ default: m.CreateAppPage })));
3535
const EditAppPage = lazy(() => import('./pages/EditAppPage').then(m => ({ default: m.EditAppPage })));
3636

37+
// Design Pages (lazy — only needed when editing pages/dashboards)
38+
const PageDesignPage = lazy(() => import('./pages/PageDesignPage').then(m => ({ default: m.PageDesignPage })));
39+
const DashboardDesignPage = lazy(() => import('./pages/DashboardDesignPage').then(m => ({ default: m.DashboardDesignPage })));
40+
3741
// Auth Pages (lazy — only needed before login)
3842
const LoginPage = lazy(() => import('./pages/LoginPage').then(m => ({ default: m.LoginPage })));
3943
const RegisterPage = lazy(() => import('./pages/RegisterPage').then(m => ({ default: m.RegisterPage })));
@@ -158,7 +162,7 @@ export function AppContent() {
158162
const cleanParts = pathParts.filter(p => p);
159163
// [apps, crm, contact]
160164
let objectNameFromPath = cleanParts[2];
161-
if (objectNameFromPath === 'view' || objectNameFromPath === 'record' || objectNameFromPath === 'page' || objectNameFromPath === 'dashboard') {
165+
if (objectNameFromPath === 'view' || objectNameFromPath === 'record' || objectNameFromPath === 'page' || objectNameFromPath === 'dashboard' || objectNameFromPath === 'design') {
162166
objectNameFromPath = ''; // Not an object root
163167
}
164168

@@ -189,7 +193,7 @@ export function AppContent() {
189193
if (!activeApp) return;
190194
const parts = location.pathname.split('/').filter(Boolean);
191195
let objName = parts[2];
192-
if (objName === 'view' || objName === 'record' || objName === 'page' || objName === 'dashboard') {
196+
if (objName === 'view' || objName === 'record' || objName === 'page' || objName === 'dashboard' || objName === 'design') {
193197
objName = '';
194198
}
195199
const basePath = `/apps/${activeApp.name}`;
@@ -357,6 +361,12 @@ export function AppContent() {
357361
<Route path="page/:pageName" element={
358362
<PageView />
359363
} />
364+
<Route path="design/page/:pageName" element={
365+
<PageDesignPage />
366+
} />
367+
<Route path="design/dashboard/:dashboardName" element={
368+
<DashboardDesignPage />
369+
} />
360370
<Route path="search" element={
361371
<SearchResultsPage />
362372
} />
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* DashboardDesignPage Tests
3+
*
4+
* Tests the Dashboard Design route integration (/design/dashboard/:dashboardName)
5+
*/
6+
7+
import { describe, it, expect, vi } from 'vitest';
8+
import { render, screen, fireEvent, act } from '@testing-library/react';
9+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
10+
import { DashboardDesignPage } from '../pages/DashboardDesignPage';
11+
12+
// Mock MetadataProvider
13+
vi.mock('../context/MetadataProvider', () => ({
14+
useMetadata: () => ({
15+
apps: [],
16+
objects: [],
17+
dashboards: [
18+
{
19+
name: 'sales-dashboard',
20+
type: 'dashboard',
21+
title: 'Sales Dashboard',
22+
label: 'Sales Dashboard',
23+
columns: 3,
24+
widgets: [
25+
{ id: 'w1', title: 'Revenue', type: 'metric', layout: { x: 0, y: 0, w: 1, h: 1 } },
26+
],
27+
},
28+
],
29+
reports: [],
30+
pages: [],
31+
loading: false,
32+
error: null,
33+
refresh: vi.fn(),
34+
}),
35+
}));
36+
37+
// Mock AdapterProvider
38+
const { mockUpdate } = vi.hoisted(() => ({ mockUpdate: vi.fn().mockResolvedValue({}) }));
39+
vi.mock('../context/AdapterProvider', () => ({
40+
useAdapter: () => ({
41+
update: mockUpdate,
42+
create: vi.fn().mockResolvedValue({}),
43+
}),
44+
}));
45+
46+
// Mock plugin-designer to avoid complex component tree
47+
vi.mock('@object-ui/plugin-designer', () => ({
48+
DashboardEditor: ({ schema, onChange, readOnly }: any) => (
49+
<div data-testid="dashboard-editor">
50+
<span>DashboardEditor: {schema.title || schema.name}</span>
51+
<button data-testid="trigger-change" onClick={() => onChange({ ...schema, title: 'Updated Dashboard' })}>
52+
Change
53+
</button>
54+
{readOnly && <span>read-only</span>}
55+
</div>
56+
),
57+
}));
58+
59+
// Mock sonner toast
60+
vi.mock('sonner', () => ({
61+
toast: {
62+
success: vi.fn(),
63+
error: vi.fn(),
64+
},
65+
}));
66+
67+
const renderWithRouter = (dashboardName: string) =>
68+
render(
69+
<MemoryRouter initialEntries={[`/design/dashboard/${dashboardName}`]}>
70+
<Routes>
71+
<Route path="/design/dashboard/:dashboardName" element={<DashboardDesignPage />} />
72+
</Routes>
73+
</MemoryRouter>,
74+
);
75+
76+
describe('DashboardDesignPage', () => {
77+
it('should render the dashboard editor for a known dashboard', () => {
78+
renderWithRouter('sales-dashboard');
79+
80+
expect(screen.getByTestId('dashboard-design-page')).toBeInTheDocument();
81+
expect(screen.getByText(/Edit Dashboard/)).toBeInTheDocument();
82+
expect(screen.getByTestId('dashboard-editor')).toBeInTheDocument();
83+
});
84+
85+
it('should show 404 for unknown dashboard', () => {
86+
renderWithRouter('unknown-dashboard');
87+
88+
expect(screen.getByText(/Dashboard.*not found/i)).toBeInTheDocument();
89+
});
90+
91+
it('should render back button', () => {
92+
renderWithRouter('sales-dashboard');
93+
94+
expect(screen.getByTestId('dashboard-design-back')).toBeInTheDocument();
95+
});
96+
97+
it('should pass schema to the editor', () => {
98+
renderWithRouter('sales-dashboard');
99+
100+
expect(screen.getByText(/DashboardEditor: Sales Dashboard/)).toBeInTheDocument();
101+
});
102+
103+
it('should call onChange when editor triggers a change', () => {
104+
renderWithRouter('sales-dashboard');
105+
106+
fireEvent.click(screen.getByTestId('trigger-change'));
107+
// After change, editor should reflect updated schema
108+
expect(screen.getByText(/DashboardEditor: Updated Dashboard/)).toBeInTheDocument();
109+
});
110+
111+
it('should save via Ctrl+S keyboard shortcut', async () => {
112+
renderWithRouter('sales-dashboard');
113+
114+
await act(async () => {
115+
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
116+
});
117+
118+
// Should call dataSource.update with the dashboard schema
119+
expect(mockUpdate).toHaveBeenCalledWith('sys_dashboard', 'sales-dashboard', expect.objectContaining({ type: 'dashboard' }));
120+
});
121+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* PageDesignPage Tests
3+
*
4+
* Tests the Page Design route integration (/design/page/:pageName)
5+
*/
6+
7+
import { describe, it, expect, vi } from 'vitest';
8+
import { render, screen, fireEvent, act } from '@testing-library/react';
9+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
10+
import { PageDesignPage } from '../pages/PageDesignPage';
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: 'grid', id: 'grid-1', title: 'Orders Grid' },
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 plugin-designer to avoid complex component tree
45+
vi.mock('@object-ui/plugin-designer', () => ({
46+
PageCanvasEditor: ({ schema, onChange, readOnly }: any) => (
47+
<div data-testid="page-canvas-editor">
48+
<span>PageCanvasEditor: {schema.title || schema.name}</span>
49+
<button data-testid="trigger-change" onClick={() => onChange({ ...schema, title: 'Updated' })}>
50+
Change
51+
</button>
52+
{readOnly && <span>read-only</span>}
53+
</div>
54+
),
55+
}));
56+
57+
// Mock sonner toast
58+
vi.mock('sonner', () => ({
59+
toast: {
60+
success: vi.fn(),
61+
error: vi.fn(),
62+
},
63+
}));
64+
65+
const renderWithRouter = (pageName: string) =>
66+
render(
67+
<MemoryRouter initialEntries={[`/design/page/${pageName}`]}>
68+
<Routes>
69+
<Route path="/design/page/:pageName" element={<PageDesignPage />} />
70+
</Routes>
71+
</MemoryRouter>,
72+
);
73+
74+
describe('PageDesignPage', () => {
75+
it('should render the page canvas editor for a known page', () => {
76+
renderWithRouter('test-page');
77+
78+
expect(screen.getByTestId('page-design-page')).toBeInTheDocument();
79+
expect(screen.getByText(/Edit Page/)).toBeInTheDocument();
80+
expect(screen.getByTestId('page-canvas-editor')).toBeInTheDocument();
81+
});
82+
83+
it('should show 404 for unknown page', () => {
84+
renderWithRouter('unknown-page');
85+
86+
expect(screen.getByText(/Page.*not found/i)).toBeInTheDocument();
87+
});
88+
89+
it('should render back button', () => {
90+
renderWithRouter('test-page');
91+
92+
expect(screen.getByTestId('page-design-back')).toBeInTheDocument();
93+
});
94+
95+
it('should pass schema to the editor', () => {
96+
renderWithRouter('test-page');
97+
98+
expect(screen.getByText(/PageCanvasEditor: Test Page/)).toBeInTheDocument();
99+
});
100+
101+
it('should call onChange when editor triggers a change', () => {
102+
renderWithRouter('test-page');
103+
104+
fireEvent.click(screen.getByTestId('trigger-change'));
105+
// After change, editor should reflect updated schema
106+
expect(screen.getByText(/PageCanvasEditor: Updated/)).toBeInTheDocument();
107+
});
108+
109+
it('should save via Ctrl+S keyboard shortcut', async () => {
110+
renderWithRouter('test-page');
111+
112+
await act(async () => {
113+
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
114+
});
115+
116+
// Should call dataSource.update with the page schema
117+
expect(mockUpdate).toHaveBeenCalledWith('sys_page', 'test-page', expect.objectContaining({ type: 'page' }));
118+
});
119+
});

apps/console/src/__tests__/console-load-performance.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import * as path from 'node:path';
3333

3434
const PERF = {
3535
/** Max number of JS chunks a production build may emit */
36-
MAX_CHUNK_COUNT: 20,
36+
MAX_CHUNK_COUNT: 22,
3737
/** Main entry gzip budget reference (KB) */
3838
MAIN_ENTRY_GZIP_KB: 50,
3939
/** Maximum React component nesting depth */

apps/console/src/components/DashboardView.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,18 @@
44
*/
55

66
import { useState, useEffect } from 'react';
7-
import { useParams } from 'react-router-dom';
7+
import { useParams, useNavigate } from 'react-router-dom';
88
import { DashboardRenderer } from '@object-ui/plugin-dashboard';
99
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
10-
import { LayoutDashboard } from 'lucide-react';
10+
import { LayoutDashboard, Pencil } from 'lucide-react';
1111
import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector';
1212
import { SkeletonDashboard } from './skeletons';
1313
import { useMetadata } from '../context/MetadataProvider';
1414
import { resolveI18nLabel } from '../utils';
1515

1616
export function DashboardView({ dataSource }: { dataSource?: any }) {
1717
const { dashboardName } = useParams<{ dashboardName: string }>();
18+
const navigate = useNavigate();
1819
const { showDebug, toggleDebug } = useMetadataInspector();
1920
const [isLoading, setIsLoading] = useState(true);
2021

@@ -59,7 +60,16 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
5960
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{resolveI18nLabel(dashboard.description)}</p>
6061
)}
6162
</div>
62-
<div className="shrink-0">
63+
<div className="shrink-0 flex items-center gap-1.5">
64+
<button
65+
type="button"
66+
onClick={() => navigate(`../design/dashboard/${dashboardName}`)}
67+
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"
68+
data-testid="dashboard-edit-button"
69+
>
70+
<Pencil className="h-3.5 w-3.5" />
71+
Edit
72+
</button>
6373
<MetadataToggle open={showDebug} onToggle={toggleDebug} />
6474
</div>
6575
</div>

apps/console/src/components/PageView.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@
33
* Renders a custom page based on the pageName parameter
44
*/
55

6-
import { useParams, useSearchParams } from 'react-router-dom';
6+
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
77
import { SchemaRenderer } from '@object-ui/react';
88
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
9-
import { FileText } from 'lucide-react';
9+
import { FileText, Pencil } from 'lucide-react';
1010
import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector';
1111
import { useMetadata } from '../context/MetadataProvider';
1212

1313
export function PageView() {
1414
const { pageName } = useParams<{ pageName: string }>();
1515
const [searchParams] = useSearchParams();
16+
const navigate = useNavigate();
1617
const { showDebug, toggleDebug } = useMetadataInspector();
1718

1819
// Find page definition from API-driven metadata
@@ -42,7 +43,16 @@ export function PageView() {
4243
return (
4344
<div className="flex flex-row h-full w-full overflow-hidden relative">
4445
<div className="flex-1 overflow-auto h-full relative group">
45-
<div className={`absolute top-1.5 sm:top-2 right-1.5 sm:right-2 z-50 transition-opacity ${showDebug ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
46+
<div className={`absolute top-1.5 sm:top-2 right-1.5 sm:right-2 z-50 flex items-center gap-1.5 transition-opacity ${showDebug ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
47+
<button
48+
type="button"
49+
onClick={() => navigate(`../design/page/${pageName}`)}
50+
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"
51+
data-testid="page-edit-button"
52+
>
53+
<Pencil className="h-3.5 w-3.5" />
54+
Edit
55+
</button>
4656
<MetadataToggle open={showDebug} onToggle={toggleDebug} />
4757
</div>
4858
<SchemaRenderer

0 commit comments

Comments
 (0)