Skip to content

Commit a35507e

Browse files
authored
Merge pull request #898 from objectstack-ai/copilot/optimize-empty-state-menu-availability
2 parents f96e9a0 + 2166a10 commit a35507e

File tree

7 files changed

+475
-133
lines changed

7 files changed

+475
-133
lines changed

ROADMAP.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,15 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
728728
- [x] Settings button → `/system/` hub (was `/system/profile`)
729729
- [x] App switcher "Manage All Apps" link → `/system/apps`
730730

731+
**Empty State & System Route Accessibility (P1.12.1):**
732+
- [x] "Create App" button always shown in empty state (even when config loading fails)
733+
- [x] "System Settings" link always shown alongside "Create App" in empty state
734+
- [x] Top-level `/system/*` routes accessible without any app context (promoted to main routes)
735+
- [x] Top-level `/create-app` route accessible without any app context
736+
- [x] Sidebar fallback navigation with system menu items when no apps are configured
737+
- [x] System pages (`SystemHubPage`, `AppManagementPage`) handle missing `appName` gracefully
738+
- [x] Login/Register/Forgot password pages remain always accessible regardless of app state
739+
731740
**Routes:**
732741
- [x] `/system/` → SystemHubPage
733742
- [x] `/system/apps` → AppManagementPage

apps/console/src/App.tsx

Lines changed: 86 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useSearchParams } from 'react-router-dom';
22
import { useState, useEffect, useCallback, lazy, Suspense, useMemo, type ReactNode } from 'react';
33
import { ModalForm } from '@object-ui/plugin-form';
4-
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
4+
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
55
import { toast } from 'sonner';
66
import { SchemaRendererProvider, useActionRunner, useGlobalUndo } from '@object-ui/react';
77
import type { ConnectionState } from './dataSource';
@@ -261,28 +261,49 @@ export function AppContent() {
261261
// Allow create-app route even when no active app exists
262262
const isCreateAppRoute = location.pathname.endsWith('/create-app');
263263

264-
if (!activeApp && !isCreateAppRoute) return (
264+
// Check if we're on a system route (accessible without an active app)
265+
const isSystemRoute = location.pathname.includes('/system');
266+
267+
if (!activeApp && !isCreateAppRoute && !isSystemRoute) return (
265268
<div className="h-screen flex items-center justify-center">
266269
<Empty>
267270
<EmptyTitle>No Apps Configured</EmptyTitle>
268-
<EmptyDescription>No applications have been registered.</EmptyDescription>
269-
<button
270-
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"
271-
onClick={() => navigate('create-app')}
272-
data-testid="create-first-app-btn"
273-
>
274-
Create Your First App
275-
</button>
271+
<EmptyDescription>
272+
No applications have been registered. Create your first app or visit System Settings to configure your environment.
273+
</EmptyDescription>
274+
<div className="mt-4 flex flex-col sm:flex-row items-center gap-3">
275+
<Button
276+
onClick={() => navigate('/create-app')}
277+
data-testid="create-first-app-btn"
278+
>
279+
Create Your First App
280+
</Button>
281+
<Button
282+
variant="outline"
283+
onClick={() => navigate('/system')}
284+
data-testid="go-to-settings-btn"
285+
>
286+
System Settings
287+
</Button>
288+
</div>
276289
</Empty>
277290
</div>
278291
);
279292

280293
// When on create-app without an active app, render a minimal layout with just the wizard
281-
if (!activeApp && isCreateAppRoute) {
294+
if (!activeApp && (isCreateAppRoute || isSystemRoute)) {
282295
return (
283296
<Suspense fallback={<LoadingScreen />}>
284297
<Routes>
285298
<Route path="create-app" element={<CreateAppPage />} />
299+
<Route path="system" element={<SystemHubPage />} />
300+
<Route path="system/apps" element={<AppManagementPage />} />
301+
<Route path="system/users" element={<UserManagementPage />} />
302+
<Route path="system/organizations" element={<OrgManagementPage />} />
303+
<Route path="system/roles" element={<RoleManagementPage />} />
304+
<Route path="system/permissions" element={<PermissionManagementPage />} />
305+
<Route path="system/audit-log" element={<AuditLogPage />} />
306+
<Route path="system/profile" element={<ProfilePage />} />
286307
</Routes>
287308
</Suspense>
288309
);
@@ -455,22 +476,51 @@ function RootRedirect() {
455476
<Empty>
456477
<EmptyTitle>{error ? 'Failed to Load Configuration' : 'No Apps Configured'}</EmptyTitle>
457478
<EmptyDescription>
458-
{error ? error.message : 'No applications have been registered. Check your ObjectStack configuration.'}
479+
{error
480+
? 'There was an error loading the configuration. You can still create an app or access System Settings.'
481+
: 'No applications have been registered. Create your first app or configure your system.'}
459482
</EmptyDescription>
460-
{!error && (
461-
<button
462-
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"
463-
onClick={() => navigate('/apps/_new/create-app')}
483+
<div className="mt-4 flex flex-col sm:flex-row items-center gap-3">
484+
<Button
485+
onClick={() => navigate('/create-app')}
464486
data-testid="create-first-app-btn"
465487
>
466488
Create Your First App
467-
</button>
468-
)}
489+
</Button>
490+
<Button
491+
variant="outline"
492+
onClick={() => navigate('/system')}
493+
data-testid="go-to-settings-btn"
494+
>
495+
System Settings
496+
</Button>
497+
</div>
469498
</Empty>
470499
</div>
471500
);
472501
}
473502

503+
/**
504+
* SystemRoutes — Top-level system admin routes accessible without any app context.
505+
* Provides a minimal layout with system navigation sidebar.
506+
*/
507+
function SystemRoutes() {
508+
return (
509+
<Suspense fallback={<LoadingScreen />}>
510+
<Routes>
511+
<Route path="/" element={<SystemHubPage />} />
512+
<Route path="apps" element={<AppManagementPage />} />
513+
<Route path="users" element={<UserManagementPage />} />
514+
<Route path="organizations" element={<OrgManagementPage />} />
515+
<Route path="roles" element={<RoleManagementPage />} />
516+
<Route path="permissions" element={<PermissionManagementPage />} />
517+
<Route path="audit-log" element={<AuditLogPage />} />
518+
<Route path="profile" element={<ProfilePage />} />
519+
</Routes>
520+
</Suspense>
521+
);
522+
}
523+
474524
export function App() {
475525
return (
476526
<ThemeProvider defaultTheme="system" storageKey="object-ui-theme">
@@ -483,6 +533,24 @@ export function App() {
483533
<Route path="/login" element={<LoginPage />} />
484534
<Route path="/register" element={<RegisterPage />} />
485535
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
536+
{/* Top-level system routes — accessible without any app */}
537+
<Route path="/system/*" element={
538+
<AuthGuard fallback={<Navigate to="/login" />} loadingFallback={<LoadingScreen />}>
539+
<ConnectedShell>
540+
<SystemRoutes />
541+
</ConnectedShell>
542+
</AuthGuard>
543+
} />
544+
{/* Top-level create-app — accessible without any app */}
545+
<Route path="/create-app" element={
546+
<AuthGuard fallback={<Navigate to="/login" />} loadingFallback={<LoadingScreen />}>
547+
<ConnectedShell>
548+
<Suspense fallback={<LoadingScreen />}>
549+
<CreateAppPage />
550+
</Suspense>
551+
</ConnectedShell>
552+
</AuthGuard>
553+
} />
486554
<Route path="/apps/:appName/*" element={
487555
<AuthGuard fallback={<Navigate to="/login" />} loadingFallback={<LoadingScreen />}>
488556
<ConnectedShell>
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/**
2+
* Empty State & System Routes Tests
3+
*
4+
* Validates the empty state behavior when no apps are configured
5+
* and the availability of system routes and create-app entry points.
6+
*
7+
* Requirements:
8+
* - "Create App" button always visible in empty state (even on error)
9+
* - "System Settings" link always visible in empty state
10+
* - System routes accessible without app context
11+
* - Login/Register/Forgot password always accessible
12+
*/
13+
14+
import { describe, it, expect, vi, beforeEach } from 'vitest';
15+
import { render, screen, waitFor } from '@testing-library/react';
16+
import '@testing-library/jest-dom';
17+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
18+
import { AppContent } from '../App';
19+
20+
// --- Mocks ---
21+
22+
// Mock MetadataProvider with NO apps (empty state)
23+
vi.mock('../context/MetadataProvider', () => ({
24+
MetadataProvider: ({ children }: any) => <>{children}</>,
25+
useMetadata: () => ({
26+
apps: [],
27+
objects: [],
28+
dashboards: [],
29+
reports: [],
30+
pages: [],
31+
loading: false,
32+
error: null,
33+
refresh: vi.fn(),
34+
}),
35+
}));
36+
37+
// Mock AdapterProvider
38+
const MockAdapterInstance = {
39+
find: vi.fn().mockResolvedValue([]),
40+
findOne: vi.fn(),
41+
create: vi.fn(),
42+
update: vi.fn(),
43+
delete: vi.fn(),
44+
connect: vi.fn().mockResolvedValue(true),
45+
onConnectionStateChange: vi.fn().mockReturnValue(() => {}),
46+
getConnectionState: vi.fn().mockReturnValue('connected'),
47+
discovery: {},
48+
};
49+
50+
vi.mock('../context/AdapterProvider', () => ({
51+
AdapterProvider: ({ children }: any) => <>{children}</>,
52+
useAdapter: () => MockAdapterInstance,
53+
}));
54+
55+
vi.mock('../dataSource', () => {
56+
const MockAdapter = class {
57+
find = vi.fn().mockResolvedValue([]);
58+
findOne = vi.fn();
59+
create = vi.fn();
60+
update = vi.fn();
61+
delete = vi.fn();
62+
connect = vi.fn().mockResolvedValue(true);
63+
onConnectionStateChange = vi.fn().mockReturnValue(() => {});
64+
getConnectionState = vi.fn().mockReturnValue('connected');
65+
discovery = {};
66+
};
67+
return {
68+
ObjectStackAdapter: MockAdapter,
69+
ObjectStackDataSource: MockAdapter,
70+
};
71+
});
72+
73+
// Mock child components to simplify testing
74+
vi.mock('../components/ObjectView', () => ({
75+
ObjectView: () => <div data-testid="object-view">Object View</div>,
76+
}));
77+
78+
vi.mock('@object-ui/components', async (importOriginal) => {
79+
const actual = await importOriginal<any>();
80+
return {
81+
...actual,
82+
TooltipProvider: ({ children }: any) => <div>{children}</div>,
83+
Dialog: ({ children, open }: any) => open ? <div data-testid="dialog">{children}</div> : null,
84+
DialogContent: ({ children }: any) => <div>{children}</div>,
85+
};
86+
});
87+
88+
vi.mock('lucide-react', async (importOriginal) => {
89+
const actual = await importOriginal<any>();
90+
return {
91+
...actual,
92+
Database: () => <span data-testid="icon-database" />,
93+
Settings: () => <span data-testid="icon-settings" />,
94+
Plus: () => <span />,
95+
Search: () => <span />,
96+
ChevronsUpDown: () => <span />,
97+
LogOut: () => <span />,
98+
ChevronRight: () => <span />,
99+
Clock: () => <span />,
100+
Star: () => <span />,
101+
StarOff: () => <span />,
102+
Pencil: () => <span />,
103+
};
104+
});
105+
106+
// System pages mocks
107+
vi.mock('../pages/system/SystemHubPage', () => ({
108+
SystemHubPage: () => <div data-testid="system-hub-page">System Hub</div>,
109+
}));
110+
111+
vi.mock('../pages/system/AppManagementPage', () => ({
112+
AppManagementPage: () => <div data-testid="app-management-page">App Management</div>,
113+
}));
114+
115+
vi.mock('../pages/system/UserManagementPage', () => ({
116+
UserManagementPage: () => <div data-testid="user-management-page">User Management</div>,
117+
}));
118+
119+
vi.mock('../pages/system/OrgManagementPage', () => ({
120+
OrgManagementPage: () => <div data-testid="org-management-page">Org Management</div>,
121+
}));
122+
123+
vi.mock('../pages/system/RoleManagementPage', () => ({
124+
RoleManagementPage: () => <div data-testid="role-management-page">Role Management</div>,
125+
}));
126+
127+
vi.mock('../pages/system/PermissionManagementPage', () => ({
128+
PermissionManagementPage: () => <div data-testid="permission-management-page">Permission Management</div>,
129+
}));
130+
131+
vi.mock('../pages/system/AuditLogPage', () => ({
132+
AuditLogPage: () => <div data-testid="audit-log-page">Audit Log</div>,
133+
}));
134+
135+
vi.mock('../pages/system/ProfilePage', () => ({
136+
ProfilePage: () => <div data-testid="profile-page">Profile</div>,
137+
}));
138+
139+
vi.mock('../pages/CreateAppPage', () => ({
140+
CreateAppPage: () => <div data-testid="create-app-page">Create App Page</div>,
141+
}));
142+
143+
describe('Empty State — No Apps Configured', () => {
144+
beforeEach(() => {
145+
vi.clearAllMocks();
146+
});
147+
148+
const renderApp = (initialRoute = '/apps/_new/') => {
149+
return render(
150+
<MemoryRouter initialEntries={[initialRoute]}>
151+
<Routes>
152+
<Route path="/apps/:appName/*" element={<AppContent />} />
153+
</Routes>
154+
</MemoryRouter>,
155+
);
156+
};
157+
158+
it('shows "Create Your First App" button in empty state', async () => {
159+
renderApp();
160+
await waitFor(() => {
161+
expect(screen.getByTestId('create-first-app-btn')).toBeInTheDocument();
162+
}, { timeout: 5000 });
163+
expect(screen.getByText('No Apps Configured')).toBeInTheDocument();
164+
});
165+
166+
it('shows "System Settings" button in empty state', async () => {
167+
renderApp();
168+
await waitFor(() => {
169+
expect(screen.getByTestId('go-to-settings-btn')).toBeInTheDocument();
170+
}, { timeout: 5000 });
171+
});
172+
173+
it('shows descriptive text about creating apps or visiting settings', async () => {
174+
renderApp();
175+
await waitFor(() => {
176+
expect(screen.getByText(/Create your first app or visit System Settings/i)).toBeInTheDocument();
177+
}, { timeout: 5000 });
178+
});
179+
});
180+
181+
describe('System Routes Within App Context (No Active App)', () => {
182+
beforeEach(() => {
183+
vi.clearAllMocks();
184+
});
185+
186+
const renderApp = (initialRoute: string) => {
187+
return render(
188+
<MemoryRouter initialEntries={[initialRoute]}>
189+
<Routes>
190+
<Route path="/apps/:appName/*" element={<AppContent />} />
191+
</Routes>
192+
</MemoryRouter>,
193+
);
194+
};
195+
196+
it('renders system hub page at /apps/_new/system when no active app', async () => {
197+
renderApp('/apps/_new/system');
198+
await waitFor(() => {
199+
expect(screen.getByTestId('system-hub-page')).toBeInTheDocument();
200+
}, { timeout: 5000 });
201+
});
202+
203+
it('renders create app page at /apps/_new/create-app when no active app', async () => {
204+
renderApp('/apps/_new/create-app');
205+
await waitFor(() => {
206+
expect(screen.getByTestId('create-app-page')).toBeInTheDocument();
207+
}, { timeout: 5000 });
208+
});
209+
});

0 commit comments

Comments
 (0)