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
9 changes: 9 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 86 additions & 18 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isSystemRoute uses location.pathname.includes('/system'), which can match unrelated paths (e.g. /apps/system/... where appName is system, or /apps/_new/systematic). Since this logic gates whether we bypass the empty-state screen when !activeApp, it should match a full path segment (or use useParams().appName and check pathname.startsWith(/apps/${appName}/system)) rather than a substring match.

Suggested change
const isSystemRoute = location.pathname.includes('/system');
const isSystemRoute =
/^\/system(\/|$)/.test(location.pathname) ||
/^\/apps\/[^/]+\/system(\/|$)/.test(location.pathname);

Copilot uses AI. Check for mistakes.

if (!activeApp && !isCreateAppRoute && !isSystemRoute) return (
<div className="h-screen flex items-center justify-center">
<Empty>
<EmptyTitle>No Apps Configured</EmptyTitle>
<EmptyDescription>No applications have been registered.</EmptyDescription>
<button
className="mt-4 inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
onClick={() => navigate('create-app')}
data-testid="create-first-app-btn"
>
Create Your First App
</button>
<EmptyDescription>
No applications have been registered. Create your first app or visit System Settings to configure your environment.
</EmptyDescription>
<div className="mt-4 flex flex-col sm:flex-row items-center gap-3">
<Button
onClick={() => navigate('/create-app')}
data-testid="create-first-app-btn"
>
Create Your First App
</Button>
<Button
variant="outline"
onClick={() => navigate('/system')}
data-testid="go-to-settings-btn"
>
System Settings
</Button>
</div>
</Empty>
</div>
);

// When on create-app without an active app, render a minimal layout with just the wizard
if (!activeApp && isCreateAppRoute) {
if (!activeApp && (isCreateAppRoute || isSystemRoute)) {
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="create-app" element={<CreateAppPage />} />
<Route path="system" element={<SystemHubPage />} />
<Route path="system/apps" element={<AppManagementPage />} />
<Route path="system/users" element={<UserManagementPage />} />
<Route path="system/organizations" element={<OrgManagementPage />} />
<Route path="system/roles" element={<RoleManagementPage />} />
<Route path="system/permissions" element={<PermissionManagementPage />} />
<Route path="system/audit-log" element={<AuditLogPage />} />
<Route path="system/profile" element={<ProfilePage />} />
</Routes>
Comment on lines 293 to 307
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When !activeApp and the route is create-app or a system route, this returns a minimal <Routes> block without ConsoleLayout/AppSidebar. That means the newly added no-app fallback navigation in AppSidebar will never be rendered in the no-app scenarios this PR targets. Consider rendering these routes inside the normal shell (or a dedicated system shell that includes the sidebar) so users still have consistent navigation.

Copilot uses AI. Check for mistakes.
</Suspense>
);
Expand Down Expand Up @@ -455,22 +476,51 @@ function RootRedirect() {
<Empty>
<EmptyTitle>{error ? 'Failed to Load Configuration' : 'No Apps Configured'}</EmptyTitle>
<EmptyDescription>
{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.'}
</EmptyDescription>
{!error && (
<button
className="mt-4 inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
onClick={() => navigate('/apps/_new/create-app')}
<div className="mt-4 flex flex-col sm:flex-row items-center gap-3">
<Button
onClick={() => navigate('/create-app')}
data-testid="create-first-app-btn"
>
Create Your First App
</button>
)}
</Button>
<Button
variant="outline"
onClick={() => navigate('/system')}
data-testid="go-to-settings-btn"
>
System Settings
</Button>
</div>
</Empty>
</div>
);
}

/**
* SystemRoutes — Top-level system admin routes accessible without any app context.
* Provides a minimal layout with system navigation sidebar.
*/
function SystemRoutes() {
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/" element={<SystemHubPage />} />
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In SystemRoutes(), the child route for the hub uses path="/", which is treated as an absolute path in React Router. When this component is mounted under the parent route /system/*, /system may not match this child route and can render a blank page. Use an index route (or path="") for the default /system page instead.

Suggested change
<Route path="/" element={<SystemHubPage />} />
<Route index element={<SystemHubPage />} />

Copilot uses AI. Check for mistakes.
<Route path="apps" element={<AppManagementPage />} />
<Route path="users" element={<UserManagementPage />} />
<Route path="organizations" element={<OrgManagementPage />} />
<Route path="roles" element={<RoleManagementPage />} />
<Route path="permissions" element={<PermissionManagementPage />} />
<Route path="audit-log" element={<AuditLogPage />} />
<Route path="profile" element={<ProfilePage />} />
</Routes>
</Suspense>
);
}

export function App() {
return (
<ThemeProvider defaultTheme="system" storageKey="object-ui-theme">
Expand All @@ -483,6 +533,24 @@ export function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
{/* Top-level system routes — accessible without any app */}
<Route path="/system/*" element={
<AuthGuard fallback={<Navigate to="/login" />} loadingFallback={<LoadingScreen />}>
<ConnectedShell>
<SystemRoutes />
</ConnectedShell>
</AuthGuard>
} />
{/* Top-level create-app — accessible without any app */}
<Route path="/create-app" element={
<AuthGuard fallback={<Navigate to="/login" />} loadingFallback={<LoadingScreen />}>
<ConnectedShell>
<Suspense fallback={<LoadingScreen />}>
<CreateAppPage />
</Suspense>
</ConnectedShell>
</AuthGuard>
} />
Comment on lines +536 to +553
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New top-level /system/* and /create-app routes are introduced here, but the added test file only mounts AppContent under /apps/:appName/*, so it doesn’t exercise these top-level routes. Add a test that mounts the route config used by App() (or refactor route definitions for testability) to verify /system and /create-app work without an app context and remain guarded by auth as intended.

Copilot uses AI. Check for mistakes.
<Route path="/apps/:appName/*" element={
<AuthGuard fallback={<Navigate to="/login" />} loadingFallback={<LoadingScreen />}>
<ConnectedShell>
Expand Down
209 changes: 209 additions & 0 deletions apps/console/src/__tests__/EmptyStateSystemRoutes.test.tsx
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +5 to +11
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header comment lists Login/Register/Forgot Password accessibility as a requirement, but this test file doesn’t include assertions for those routes (and it also doesn’t cover the new top-level /system/* routes added in App()). Either add the missing tests or trim the stated requirements so the comment accurately reflects what’s being validated.

Suggested change
* 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
* and the availability of core system navigation and create-app entry points.
*
* This suite focuses on the empty state UI and key system navigation links
* that should remain available even when no applications are configured.

Copilot uses AI. Check for mistakes.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
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';

// --- 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: () => <div data-testid="object-view">Object View</div>,
}));

vi.mock('@object-ui/components', async (importOriginal) => {
const actual = await importOriginal<any>();
return {
...actual,
TooltipProvider: ({ children }: any) => <div>{children}</div>,
Dialog: ({ children, open }: any) => open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children }: any) => <div>{children}</div>,
};
});

vi.mock('lucide-react', async (importOriginal) => {
const actual = await importOriginal<any>();
return {
...actual,
Database: () => <span data-testid="icon-database" />,
Settings: () => <span data-testid="icon-settings" />,
Plus: () => <span />,
Search: () => <span />,
ChevronsUpDown: () => <span />,
LogOut: () => <span />,
ChevronRight: () => <span />,
Clock: () => <span />,
Star: () => <span />,
StarOff: () => <span />,
Pencil: () => <span />,
};
});

// System pages mocks
vi.mock('../pages/system/SystemHubPage', () => ({
SystemHubPage: () => <div data-testid="system-hub-page">System Hub</div>,
}));

vi.mock('../pages/system/AppManagementPage', () => ({
AppManagementPage: () => <div data-testid="app-management-page">App Management</div>,
}));

vi.mock('../pages/system/UserManagementPage', () => ({
UserManagementPage: () => <div data-testid="user-management-page">User Management</div>,
}));

vi.mock('../pages/system/OrgManagementPage', () => ({
OrgManagementPage: () => <div data-testid="org-management-page">Org Management</div>,
}));

vi.mock('../pages/system/RoleManagementPage', () => ({
RoleManagementPage: () => <div data-testid="role-management-page">Role Management</div>,
}));

vi.mock('../pages/system/PermissionManagementPage', () => ({
PermissionManagementPage: () => <div data-testid="permission-management-page">Permission Management</div>,
}));

vi.mock('../pages/system/AuditLogPage', () => ({
AuditLogPage: () => <div data-testid="audit-log-page">Audit Log</div>,
}));

vi.mock('../pages/system/ProfilePage', () => ({
ProfilePage: () => <div data-testid="profile-page">Profile</div>,
}));

vi.mock('../pages/CreateAppPage', () => ({
CreateAppPage: () => <div data-testid="create-app-page">Create App Page</div>,
}));

describe('Empty State — No Apps Configured', () => {
beforeEach(() => {
vi.clearAllMocks();
});

const renderApp = (initialRoute = '/apps/_new/') => {
return render(
<MemoryRouter initialEntries={[initialRoute]}>
<Routes>
<Route path="/apps/:appName/*" element={<AppContent />} />
</Routes>
</MemoryRouter>,
);
};

it('shows "Create Your First App" button in empty state', async () => {
renderApp();
await waitFor(() => {
expect(screen.getByTestId('create-first-app-btn')).toBeInTheDocument();
}, { timeout: 5000 });
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: 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: 5000 });
});
});

describe('System Routes Within App Context (No Active App)', () => {
beforeEach(() => {
vi.clearAllMocks();
});

const renderApp = (initialRoute: string) => {
return render(
<MemoryRouter initialEntries={[initialRoute]}>
<Routes>
<Route path="/apps/:appName/*" element={<AppContent />} />
</Routes>
</MemoryRouter>,
);
};

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: 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: 5000 });
});
});
Loading