From a07d323cdabe8a0a01e70cb272790c3bcc43f332 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:24:06 +0000 Subject: [PATCH 1/5] Initial plan From 58a4892e32eebc433c46535742f99b6578ed57dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:32:06 +0000 Subject: [PATCH 2/5] feat: improve empty state UX and add top-level system routes - Always show Create App button in empty state (even on error) - Add System Settings link to empty state pages - Add top-level /system/* and /create-app routes for access without app context - Modify AppSidebar to show system navigation when no apps configured - Update SystemHubPage and AppManagementPage to handle missing appName Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/console/src/App.tsx | 104 +++++-- apps/console/src/components/AppSidebar.tsx | 280 +++++++++++------- .../src/pages/system/AppManagementPage.tsx | 2 +- .../src/pages/system/SystemHubPage.tsx | 2 +- 4 files changed, 256 insertions(+), 132 deletions(-) diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 3d6a0e06..610ab8f1 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -1,7 +1,7 @@ import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { useState, useEffect, useCallback, lazy, Suspense, useMemo, type ReactNode } from 'react'; import { ModalForm } from '@object-ui/plugin-form'; -import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; +import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components'; import { toast } from 'sonner'; import { SchemaRendererProvider, useActionRunner, useGlobalUndo } from '@object-ui/react'; import type { ConnectionState } from './dataSource'; @@ -261,28 +261,49 @@ export function AppContent() { // Allow create-app route even when no active app exists const isCreateAppRoute = location.pathname.endsWith('/create-app'); - if (!activeApp && !isCreateAppRoute) return ( + // Check if we're on a system route (accessible without an active app) + const isSystemRoute = location.pathname.includes('/system'); + + if (!activeApp && !isCreateAppRoute && !isSystemRoute) return (
No Apps Configured - No applications have been registered. - + + No applications have been registered. Create your first app or visit System Settings to configure your environment. + +
+ + +
); // When on create-app without an active app, render a minimal layout with just the wizard - if (!activeApp && isCreateAppRoute) { + if (!activeApp && (isCreateAppRoute || isSystemRoute)) { return ( }> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> ); @@ -455,22 +476,51 @@ function RootRedirect() { {error ? 'Failed to Load Configuration' : 'No Apps Configured'} - {error ? error.message : 'No applications have been registered. Check your ObjectStack configuration.'} + {error + ? 'There was an error loading the configuration. You can still create an app or access System Settings.' + : 'No applications have been registered. Create your first app or configure your system.'} - {!error && ( - - )} + + + ); } +/** + * SystemRoutes — Top-level system admin routes accessible without any app context. + * Provides a minimal layout with system navigation sidebar. + */ +function SystemRoutes() { + return ( + }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} + export function App() { return ( @@ -483,6 +533,24 @@ export function App() { } /> } /> } /> + {/* Top-level system routes — accessible without any app */} + } loadingFallback={}> + + + + + } /> + {/* Top-level create-app — accessible without any app */} + } loadingFallback={}> + + }> + + + + + } /> } loadingFallback={}> diff --git a/apps/console/src/components/AppSidebar.tsx b/apps/console/src/components/AppSidebar.tsx index 6a1dc9d8..4281873c 100644 --- a/apps/console/src/components/AppSidebar.tsx +++ b/apps/console/src/components/AppSidebar.tsx @@ -250,7 +250,17 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri [can], ); - const basePath = `/apps/${activeAppName}`; + const basePath = activeApp ? `/apps/${activeAppName}` : ''; + + // Fallback system navigation when no active app exists + const systemFallbackNavigation: NavigationItem[] = React.useMemo(() => [ + { id: 'sys-settings', label: 'System Settings', type: 'url' as const, url: '/system', icon: 'settings' }, + { id: 'sys-apps', label: 'Applications', type: 'url' as const, url: '/system/apps', icon: 'layout-grid' }, + { id: 'sys-users', label: 'Users', type: 'url' as const, url: '/system/users', icon: 'users' }, + { id: 'sys-orgs', label: 'Organizations', type: 'url' as const, url: '/system/organizations', icon: 'building-2' }, + { id: 'sys-roles', label: 'Roles', type: 'url' as const, url: '/system/roles', icon: 'shield' }, + { id: 'sys-create-app', label: 'Create App', type: 'url' as const, url: '/create-app', icon: 'plus' }, + ], []); return ( <> @@ -258,6 +268,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri + {activeApp ? ( + ) : ( + /* No-app fallback header */ + navigate('/system')} + data-testid="system-sidebar-header" + > +
+ +
+
+ System Console + No apps configured +
+
+ )}
- {/* Area Switcher — shown when app defines areas */} - {areas.length > 1 && ( - + {activeApp ? ( + <> + {/* Area Switcher — shown when app defines areas */} + {areas.length > 1 && ( + + + + Area + + + + {areas.map((area: any) => { + const AreaIcon = getIcon(area.icon); + const isActiveArea = area.id === activeAreaId; + return ( + + setActiveAreaId(area.id)} + > + + {area.label} + + + ); + })} + + + + )} + + {/* Navigation Search */} + + + + setNavSearchQuery(e.target.value)} + className="pl-8" + /> + + + + {/* Navigation tree — delegated to NavigationRenderer (@dnd-kit reorder + pin) */} + + + {/* Record Favorites */} + {favorites.length > 0 && ( + + + + Favorites + + + + {favorites.slice(0, 8).map(item => ( + + + + + {item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : item.type === 'page' ? '📄' : '📋'} + + {item.label} + + + { e.stopPropagation(); removeFavorite(item.id); }} + aria-label={`Remove ${item.label} from favorites`} + > + + + + ))} + + + + )} + + {/* Recent Items (default collapsed) */} + {recentItems.length > 0 && ( + + setRecentExpanded(prev => !prev)} + > + + + Recent + + {recentExpanded && ( + + + {recentItems.slice(0, 5).map(item => ( + + + + + {item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄'} + + {item.label} + + + + ))} + + + )} + + )} + + ) : ( + /* Fallback system navigation when no apps are configured */ + - - Area + + System - {areas.map((area: any) => { - const AreaIcon = getIcon(area.icon); - const isActiveArea = area.id === activeAreaId; + {systemFallbackNavigation.map((item) => { + const NavIcon = getIcon(item.icon); return ( - - setActiveAreaId(area.id)} - > - - {area.label} + + + + + {resolveI18nLabel(item.label, t)} + ); @@ -360,97 +507,6 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri )} - - {/* Navigation Search */} - - - - setNavSearchQuery(e.target.value)} - className="pl-8" - /> - - - - {/* Navigation tree — delegated to NavigationRenderer (@dnd-kit reorder + pin) */} - - - {/* Record Favorites */} - {favorites.length > 0 && ( - - - - Favorites - - - - {favorites.slice(0, 8).map(item => ( - - - - - {item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : item.type === 'page' ? '📄' : '📋'} - - {item.label} - - - { e.stopPropagation(); removeFavorite(item.id); }} - aria-label={`Remove ${item.label} from favorites`} - > - - - - ))} - - - - )} - - {/* Recent Items (default collapsed) */} - {recentItems.length > 0 && ( - - setRecentExpanded(prev => !prev)} - > - - - Recent - - {recentExpanded && ( - - - {recentItems.slice(0, 5).map(item => ( - - - - - {item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄'} - - {item.label} - - - - ))} - - - )} - - )} @@ -498,7 +554,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri navigate(`/apps/${activeAppName}/system`)} + onClick={() => navigate(activeApp ? `/apps/${activeAppName}/system` : '/system')} > Settings @@ -520,10 +576,10 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri {isMobile && (
- {(resolvedNavigation).filter((n: any) => n.type !== 'group').slice(0, 5).map((item: any) => { + {(activeApp ? resolvedNavigation : systemFallbackNavigation).filter((n: any) => n.type !== 'group').slice(0, 5).map((item: any) => { const NavIcon = getIcon(item.icon); - const baseUrl = `/apps/${activeAppName}`; - let href = '#'; + const baseUrl = activeApp ? `/apps/${activeAppName}` : ''; + let href = item.url || '#'; if (item.type === 'object') { href = `${baseUrl}/${item.objectName}`; if (item.viewName) href += `/view/${item.viewName}`; diff --git a/apps/console/src/pages/system/AppManagementPage.tsx b/apps/console/src/pages/system/AppManagementPage.tsx index 466bf4b1..2e86bd3c 100644 --- a/apps/console/src/pages/system/AppManagementPage.tsx +++ b/apps/console/src/pages/system/AppManagementPage.tsx @@ -32,7 +32,7 @@ import { resolveI18nLabel } from '../../utils'; export function AppManagementPage() { const navigate = useNavigate(); const { appName } = useParams(); - const basePath = `/apps/${appName}`; + const basePath = appName ? `/apps/${appName}` : ''; const { apps, refresh } = useMetadata(); const [searchQuery, setSearchQuery] = useState(''); diff --git a/apps/console/src/pages/system/SystemHubPage.tsx b/apps/console/src/pages/system/SystemHubPage.tsx index 646f4f09..8be655c2 100644 --- a/apps/console/src/pages/system/SystemHubPage.tsx +++ b/apps/console/src/pages/system/SystemHubPage.tsx @@ -41,7 +41,7 @@ interface HubCard { export function SystemHubPage() { const navigate = useNavigate(); const { appName } = useParams(); - const basePath = `/apps/${appName}`; + const basePath = appName ? `/apps/${appName}` : ''; const dataSource = useAdapter(); const { apps } = useMetadata(); From 313be35ddcbd9acc0be4a547ebf1524ab0ad9622 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:38:22 +0000 Subject: [PATCH 3/5] test: add empty state and system routes tests Validates: create-app button always visible, system settings link present, system routes accessible when no active app exists. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../__tests__/EmptyStateSystemRoutes.test.tsx | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 apps/console/src/__tests__/EmptyStateSystemRoutes.test.tsx diff --git a/apps/console/src/__tests__/EmptyStateSystemRoutes.test.tsx b/apps/console/src/__tests__/EmptyStateSystemRoutes.test.tsx new file mode 100644 index 00000000..932fa477 --- /dev/null +++ b/apps/console/src/__tests__/EmptyStateSystemRoutes.test.tsx @@ -0,0 +1,209 @@ +/** + * Empty State & System Routes Tests + * + * Validates the empty state behavior when no apps are configured + * and the availability of system routes and create-app entry points. + * + * Requirements: + * - "Create App" button always visible in empty state (even on error) + * - "System Settings" link always visible in empty state + * - System routes accessible without app context + * - Login/Register/Forgot password always accessible + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { AppContent } from '../App'; + +// --- Mocks --- + +// Mock MetadataProvider with NO apps (empty state) +vi.mock('../context/MetadataProvider', () => ({ + MetadataProvider: ({ children }: any) => <>{children}, + useMetadata: () => ({ + apps: [], + objects: [], + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: vi.fn(), + }), +})); + +// Mock AdapterProvider +const MockAdapterInstance = { + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + connect: vi.fn().mockResolvedValue(true), + onConnectionStateChange: vi.fn().mockReturnValue(() => {}), + getConnectionState: vi.fn().mockReturnValue('connected'), + discovery: {}, +}; + +vi.mock('../context/AdapterProvider', () => ({ + AdapterProvider: ({ children }: any) => <>{children}, + useAdapter: () => MockAdapterInstance, +})); + +vi.mock('../dataSource', () => { + const MockAdapter = class { + find = vi.fn().mockResolvedValue([]); + findOne = vi.fn(); + create = vi.fn(); + update = vi.fn(); + delete = vi.fn(); + connect = vi.fn().mockResolvedValue(true); + onConnectionStateChange = vi.fn().mockReturnValue(() => {}); + getConnectionState = vi.fn().mockReturnValue('connected'); + discovery = {}; + }; + return { + ObjectStackAdapter: MockAdapter, + ObjectStackDataSource: MockAdapter, + }; +}); + +// Mock child components to simplify testing +vi.mock('../components/ObjectView', () => ({ + ObjectView: () =>
Object View
, +})); + +vi.mock('@object-ui/components', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + TooltipProvider: ({ children }: any) =>
{children}
, + Dialog: ({ children, open }: any) => open ?
{children}
: null, + DialogContent: ({ children }: any) =>
{children}
, + }; +}); + +vi.mock('lucide-react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Database: () => , + Settings: () => , + Plus: () => , + Search: () => , + ChevronsUpDown: () => , + LogOut: () => , + ChevronRight: () => , + Clock: () => , + Star: () => , + StarOff: () => , + Pencil: () => , + }; +}); + +// System pages mocks +vi.mock('../pages/system/SystemHubPage', () => ({ + SystemHubPage: () =>
System Hub
, +})); + +vi.mock('../pages/system/AppManagementPage', () => ({ + AppManagementPage: () =>
App Management
, +})); + +vi.mock('../pages/system/UserManagementPage', () => ({ + UserManagementPage: () =>
User Management
, +})); + +vi.mock('../pages/system/OrgManagementPage', () => ({ + OrgManagementPage: () =>
Org Management
, +})); + +vi.mock('../pages/system/RoleManagementPage', () => ({ + RoleManagementPage: () =>
Role Management
, +})); + +vi.mock('../pages/system/PermissionManagementPage', () => ({ + PermissionManagementPage: () =>
Permission Management
, +})); + +vi.mock('../pages/system/AuditLogPage', () => ({ + AuditLogPage: () =>
Audit Log
, +})); + +vi.mock('../pages/system/ProfilePage', () => ({ + ProfilePage: () =>
Profile
, +})); + +vi.mock('../pages/CreateAppPage', () => ({ + CreateAppPage: () =>
Create App Page
, +})); + +describe('Empty State — No Apps Configured', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderApp = (initialRoute = '/apps/_new/') => { + return render( + + + } /> + + , + ); + }; + + it('shows "Create Your First App" button in empty state', async () => { + renderApp(); + await waitFor(() => { + expect(screen.getByTestId('create-first-app-btn')).toBeInTheDocument(); + }, { timeout: 10000 }); + expect(screen.getByText('No Apps Configured')).toBeInTheDocument(); + }); + + it('shows "System Settings" button in empty state', async () => { + renderApp(); + await waitFor(() => { + expect(screen.getByTestId('go-to-settings-btn')).toBeInTheDocument(); + }, { timeout: 10000 }); + }); + + it('shows descriptive text about creating apps or visiting settings', async () => { + renderApp(); + await waitFor(() => { + expect(screen.getByText(/Create your first app or visit System Settings/i)).toBeInTheDocument(); + }, { timeout: 10000 }); + }); +}); + +describe('System Routes Within App Context (No Active App)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderApp = (initialRoute: string) => { + return render( + + + } /> + + , + ); + }; + + it('renders system hub page at /apps/_new/system when no active app', async () => { + renderApp('/apps/_new/system'); + await waitFor(() => { + expect(screen.getByTestId('system-hub-page')).toBeInTheDocument(); + }, { timeout: 10000 }); + }); + + it('renders create app page at /apps/_new/create-app when no active app', async () => { + renderApp('/apps/_new/create-app'); + await waitFor(() => { + expect(screen.getByTestId('create-app-page')).toBeInTheDocument(); + }, { timeout: 10000 }); + }); +}); From e704b1195bde818520faf4763c686863d7c10cae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:40:09 +0000 Subject: [PATCH 4/5] refactor: address code review feedback - Simplify tooltip and label rendering for fallback nav items - Reduce test timeouts from 10000ms to 5000ms - Update ROADMAP.md with P1.12.1 empty state improvements Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 9 +++++++++ .../src/__tests__/EmptyStateSystemRoutes.test.tsx | 10 +++++----- apps/console/src/components/AppSidebar.tsx | 4 ++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index aecb625b..e8d0c59d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -728,6 +728,15 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind - [x] Settings button → `/system/` hub (was `/system/profile`) - [x] App switcher "Manage All Apps" link → `/system/apps` +**Empty State & System Route Accessibility (P1.12.1):** +- [x] "Create App" button always shown in empty state (even when config loading fails) +- [x] "System Settings" link always shown alongside "Create App" in empty state +- [x] Top-level `/system/*` routes accessible without any app context (promoted to main routes) +- [x] Top-level `/create-app` route accessible without any app context +- [x] Sidebar fallback navigation with system menu items when no apps are configured +- [x] System pages (`SystemHubPage`, `AppManagementPage`) handle missing `appName` gracefully +- [x] Login/Register/Forgot password pages remain always accessible regardless of app state + **Routes:** - [x] `/system/` → SystemHubPage - [x] `/system/apps` → AppManagementPage diff --git a/apps/console/src/__tests__/EmptyStateSystemRoutes.test.tsx b/apps/console/src/__tests__/EmptyStateSystemRoutes.test.tsx index 932fa477..472978ee 100644 --- a/apps/console/src/__tests__/EmptyStateSystemRoutes.test.tsx +++ b/apps/console/src/__tests__/EmptyStateSystemRoutes.test.tsx @@ -159,7 +159,7 @@ describe('Empty State — No Apps Configured', () => { renderApp(); await waitFor(() => { expect(screen.getByTestId('create-first-app-btn')).toBeInTheDocument(); - }, { timeout: 10000 }); + }, { timeout: 5000 }); expect(screen.getByText('No Apps Configured')).toBeInTheDocument(); }); @@ -167,14 +167,14 @@ describe('Empty State — No Apps Configured', () => { renderApp(); await waitFor(() => { expect(screen.getByTestId('go-to-settings-btn')).toBeInTheDocument(); - }, { timeout: 10000 }); + }, { timeout: 5000 }); }); it('shows descriptive text about creating apps or visiting settings', async () => { renderApp(); await waitFor(() => { expect(screen.getByText(/Create your first app or visit System Settings/i)).toBeInTheDocument(); - }, { timeout: 10000 }); + }, { timeout: 5000 }); }); }); @@ -197,13 +197,13 @@ describe('System Routes Within App Context (No Active App)', () => { renderApp('/apps/_new/system'); await waitFor(() => { expect(screen.getByTestId('system-hub-page')).toBeInTheDocument(); - }, { timeout: 10000 }); + }, { timeout: 5000 }); }); it('renders create app page at /apps/_new/create-app when no active app', async () => { renderApp('/apps/_new/create-app'); await waitFor(() => { expect(screen.getByTestId('create-app-page')).toBeInTheDocument(); - }, { timeout: 10000 }); + }, { timeout: 5000 }); }); }); diff --git a/apps/console/src/components/AppSidebar.tsx b/apps/console/src/components/AppSidebar.tsx index 4281873c..bed4999f 100644 --- a/apps/console/src/components/AppSidebar.tsx +++ b/apps/console/src/components/AppSidebar.tsx @@ -494,10 +494,10 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri const NavIcon = getIcon(item.icon); return ( - + - {resolveI18nLabel(item.label, t)} + {item.label as string} From 2166a100402fff01c864927de411e031df6f796c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:56:55 +0000 Subject: [PATCH 5/5] fix: resolve TS6133 build errors for unused variables - Prefix unused `record` param with underscore in ObjectView.tsx - Remove unused `fireEvent` import from EmptyStateSystemRoutes.test.tsx Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/console/src/__tests__/EmptyStateSystemRoutes.test.tsx | 2 +- packages/plugin-view/src/ObjectView.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/console/src/__tests__/EmptyStateSystemRoutes.test.tsx b/apps/console/src/__tests__/EmptyStateSystemRoutes.test.tsx index 472978ee..e6eb0ce8 100644 --- a/apps/console/src/__tests__/EmptyStateSystemRoutes.test.tsx +++ b/apps/console/src/__tests__/EmptyStateSystemRoutes.test.tsx @@ -12,7 +12,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { AppContent } from '../App'; diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 0a2b7731..4953cae4 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -984,7 +984,7 @@ export const ObjectView: React.FC = ({ : layout; // Build the record detail content for NavigationOverlay (split/popover modes) - const renderOverlayDetail = (record: Record) => ( + const renderOverlayDetail = (_record: Record) => (