Skip to content

Commit 002f6ee

Browse files
authored
Merge pull request #767 from objectstack-ai/copilot/add-app-management-page
2 parents fb4998c + 74f89aa commit 002f6ee

File tree

7 files changed

+834
-3
lines changed

7 files changed

+834
-3
lines changed

ROADMAP.md

Lines changed: 38 additions & 2 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 ✅**
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 ✅**
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, 78 Storybook stories, 42/42 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), and **App Creation & Editing Flow** (P1.11) — all ✅ complete.
16+
**Where We Are:** Foundation is **solid and shipping** — 35 packages, 99+ components, 5,700+ tests, 78 Storybook stories, 42/42 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.
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
@@ -504,6 +504,42 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
504504
- [x] `createApp` i18n key added to all 10 locales
505505
- [x] 13 console integration tests (routes, wizard callbacks, draft persistence, saveItem, CommandPalette)
506506

507+
### P1.12 System Settings & App Management Center
508+
509+
> Unified system settings hub, app management page, and permission management page.
510+
511+
**System Hub Page (`/system/`):**
512+
- [x] Card-based overview linking to all system administration sections
513+
- [x] Live statistics for each section (users, orgs, roles, permissions, audit logs, apps)
514+
- [x] Navigation to Apps, Users, Organizations, Roles, Permissions, Audit Log, Profile
515+
516+
**App Management Page (`/system/apps`):**
517+
- [x] Full app list with search/filter
518+
- [x] Enable/disable toggle per app
519+
- [x] Set default app
520+
- [x] Delete app with confirmation
521+
- [x] Bulk select with enable/disable operations
522+
- [x] Navigate to Create App / Edit App pages
523+
- [x] Navigate to app home
524+
525+
**Permission Management Page (`/system/permissions`):**
526+
- [x] CRUD grid for `sys_permission` object
527+
- [x] Search/filter permissions
528+
- [x] Admin-only create/delete controls
529+
530+
**Sidebar & Navigation Updates:**
531+
- [x] Settings button → `/system/` hub (was `/system/profile`)
532+
- [x] App switcher "Manage All Apps" link → `/system/apps`
533+
534+
**Routes:**
535+
- [x] `/system/` → SystemHubPage
536+
- [x] `/system/apps` → AppManagementPage
537+
- [x] `/system/permissions` → PermissionManagementPage
538+
539+
**Tests:**
540+
- [x] 11 new tests (SystemHubPage, AppManagementPage, PermissionManagementPage)
541+
- [x] Total: 20 system page tests passing
542+
507543
---
508544

509545
## 🧩 P2 — Polish & Advanced Features

apps/console/src/App.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,12 @@ const RegisterPage = lazy(() => import('./pages/RegisterPage').then(m => ({ defa
4040
const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage').then(m => ({ default: m.ForgotPasswordPage })));
4141

4242
// System Admin Pages (lazy — rarely accessed)
43+
const SystemHubPage = lazy(() => import('./pages/system/SystemHubPage').then(m => ({ default: m.SystemHubPage })));
44+
const AppManagementPage = lazy(() => import('./pages/system/AppManagementPage').then(m => ({ default: m.AppManagementPage })));
4345
const UserManagementPage = lazy(() => import('./pages/system/UserManagementPage').then(m => ({ default: m.UserManagementPage })));
4446
const OrgManagementPage = lazy(() => import('./pages/system/OrgManagementPage').then(m => ({ default: m.OrgManagementPage })));
4547
const RoleManagementPage = lazy(() => import('./pages/system/RoleManagementPage').then(m => ({ default: m.RoleManagementPage })));
48+
const PermissionManagementPage = lazy(() => import('./pages/system/PermissionManagementPage').then(m => ({ default: m.PermissionManagementPage })));
4649
const AuditLogPage = lazy(() => import('./pages/system/AuditLogPage').then(m => ({ default: m.AuditLogPage })));
4750
const ProfilePage = lazy(() => import('./pages/system/ProfilePage').then(m => ({ default: m.ProfilePage })));
4851

@@ -363,9 +366,12 @@ export function AppContent() {
363366
<Route path="edit-app/:editAppName" element={<EditAppPage />} />
364367

365368
{/* System Administration Routes */}
369+
<Route path="system" element={<SystemHubPage />} />
370+
<Route path="system/apps" element={<AppManagementPage />} />
366371
<Route path="system/users" element={<UserManagementPage />} />
367372
<Route path="system/organizations" element={<OrgManagementPage />} />
368373
<Route path="system/roles" element={<RoleManagementPage />} />
374+
<Route path="system/permissions" element={<PermissionManagementPage />} />
369375
<Route path="system/audit-log" element={<AuditLogPage />} />
370376
<Route path="system/profile" element={<ProfilePage />} />
371377
</Routes>

apps/console/src/__tests__/SystemPages.test.tsx

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,41 @@ vi.mock('sonner', () => ({
3333
toast: { success: vi.fn(), error: vi.fn() },
3434
}));
3535

36+
const mockNavigate = vi.fn();
37+
vi.mock('react-router-dom', async () => {
38+
const actual = await vi.importActual('react-router-dom');
39+
return {
40+
...actual,
41+
useNavigate: () => mockNavigate,
42+
useParams: () => ({ appName: 'test-app' }),
43+
};
44+
});
45+
46+
const mockRefresh = vi.fn().mockResolvedValue(undefined);
47+
vi.mock('../context/MetadataProvider', () => ({
48+
useMetadata: () => ({
49+
apps: [
50+
{ name: 'crm', label: 'CRM', description: 'Customer management', active: true, isDefault: true },
51+
{ name: 'hr', label: 'HR', description: 'Human resources', active: false, isDefault: false },
52+
],
53+
objects: [],
54+
dashboards: [],
55+
reports: [],
56+
pages: [],
57+
loading: false,
58+
error: null,
59+
refresh: mockRefresh,
60+
}),
61+
}));
62+
3663
// Import after mocks
3764
import { UserManagementPage } from '../pages/system/UserManagementPage';
3865
import { OrgManagementPage } from '../pages/system/OrgManagementPage';
3966
import { RoleManagementPage } from '../pages/system/RoleManagementPage';
4067
import { AuditLogPage } from '../pages/system/AuditLogPage';
68+
import { SystemHubPage } from '../pages/system/SystemHubPage';
69+
import { AppManagementPage } from '../pages/system/AppManagementPage';
70+
import { PermissionManagementPage } from '../pages/system/PermissionManagementPage';
4171

4272
function wrap(ui: React.ReactElement) {
4373
return render(<MemoryRouter>{ui}</MemoryRouter>);
@@ -143,3 +173,123 @@ describe('AuditLogPage', () => {
143173
});
144174
});
145175
});
176+
177+
describe('SystemHubPage', () => {
178+
it('should render System Settings heading and all hub cards', async () => {
179+
mockFind.mockResolvedValue({ data: [] });
180+
wrap(<SystemHubPage />);
181+
expect(screen.getByText('System Settings')).toBeInTheDocument();
182+
await waitFor(() => {
183+
expect(screen.getByTestId('hub-card-applications')).toBeInTheDocument();
184+
expect(screen.getByTestId('hub-card-users')).toBeInTheDocument();
185+
expect(screen.getByTestId('hub-card-organizations')).toBeInTheDocument();
186+
expect(screen.getByTestId('hub-card-roles')).toBeInTheDocument();
187+
expect(screen.getByTestId('hub-card-permissions')).toBeInTheDocument();
188+
expect(screen.getByTestId('hub-card-audit-log')).toBeInTheDocument();
189+
expect(screen.getByTestId('hub-card-profile')).toBeInTheDocument();
190+
});
191+
});
192+
193+
it('should fetch counts from dataSource on mount', async () => {
194+
mockFind.mockResolvedValue({ data: [{ id: '1' }] });
195+
wrap(<SystemHubPage />);
196+
await waitFor(() => {
197+
expect(mockFind).toHaveBeenCalledWith('sys_user');
198+
expect(mockFind).toHaveBeenCalledWith('sys_org');
199+
expect(mockFind).toHaveBeenCalledWith('sys_role');
200+
expect(mockFind).toHaveBeenCalledWith('sys_permission');
201+
expect(mockFind).toHaveBeenCalledWith('sys_audit_log');
202+
});
203+
});
204+
205+
it('should navigate to section when card is clicked', async () => {
206+
mockFind.mockResolvedValue({ data: [] });
207+
wrap(<SystemHubPage />);
208+
await waitFor(() => {
209+
expect(screen.getByTestId('hub-card-users')).toBeInTheDocument();
210+
});
211+
fireEvent.click(screen.getByTestId('hub-card-users'));
212+
expect(mockNavigate).toHaveBeenCalledWith('/apps/test-app/system/users');
213+
});
214+
});
215+
216+
describe('AppManagementPage', () => {
217+
it('should render app list from metadata', () => {
218+
wrap(<AppManagementPage />);
219+
expect(screen.getByText('Applications')).toBeInTheDocument();
220+
expect(screen.getByTestId('app-card-crm')).toBeInTheDocument();
221+
expect(screen.getByTestId('app-card-hr')).toBeInTheDocument();
222+
});
223+
224+
it('should filter apps by search query', () => {
225+
wrap(<AppManagementPage />);
226+
fireEvent.change(screen.getByTestId('app-search-input'), { target: { value: 'CRM' } });
227+
expect(screen.getByTestId('app-card-crm')).toBeInTheDocument();
228+
expect(screen.queryByTestId('app-card-hr')).not.toBeInTheDocument();
229+
});
230+
231+
it('should show empty state when no matching apps', () => {
232+
wrap(<AppManagementPage />);
233+
fireEvent.change(screen.getByTestId('app-search-input'), { target: { value: 'nonexistent' } });
234+
expect(screen.getByTestId('no-apps-message')).toBeInTheDocument();
235+
});
236+
237+
it('should navigate to create-app on New App button click', () => {
238+
wrap(<AppManagementPage />);
239+
fireEvent.click(screen.getByTestId('create-app-btn'));
240+
expect(mockNavigate).toHaveBeenCalledWith('/apps/test-app/create-app');
241+
});
242+
});
243+
244+
describe('PermissionManagementPage', () => {
245+
it('should call dataSource.find("sys_permission") on mount', async () => {
246+
mockFind.mockResolvedValueOnce({
247+
data: [{ id: '1', name: 'manage_users', resource: 'user', action: 'manage', description: 'Full user access' }],
248+
});
249+
wrap(<PermissionManagementPage />);
250+
await waitFor(() => {
251+
expect(mockFind).toHaveBeenCalledWith('sys_permission');
252+
});
253+
expect(screen.getByText('manage_users')).toBeInTheDocument();
254+
});
255+
256+
it('should show empty state when no permissions', async () => {
257+
mockFind.mockResolvedValueOnce({ data: [] });
258+
wrap(<PermissionManagementPage />);
259+
await waitFor(() => {
260+
expect(screen.getByText('No permissions found.')).toBeInTheDocument();
261+
});
262+
});
263+
264+
it('should call create when Add Permission is clicked', async () => {
265+
mockFind.mockResolvedValue({ data: [] });
266+
mockCreate.mockResolvedValueOnce({ id: 'new-perm' });
267+
wrap(<PermissionManagementPage />);
268+
await waitFor(() => {
269+
expect(screen.getByText('No permissions found.')).toBeInTheDocument();
270+
});
271+
fireEvent.click(screen.getByText('Add Permission'));
272+
await waitFor(() => {
273+
expect(mockCreate).toHaveBeenCalledWith('sys_permission', expect.objectContaining({ name: 'New Permission' }));
274+
});
275+
});
276+
277+
it('should filter permissions by search query', async () => {
278+
mockFind.mockResolvedValue({
279+
data: [
280+
{ id: '1', name: 'manage_users', resource: 'user', action: 'manage', description: '' },
281+
{ id: '2', name: 'read_reports', resource: 'report', action: 'read', description: '' },
282+
],
283+
});
284+
wrap(<PermissionManagementPage />);
285+
await waitFor(() => {
286+
expect(screen.getByText('manage_users')).toBeInTheDocument();
287+
expect(screen.getByText('read_reports')).toBeInTheDocument();
288+
});
289+
fireEvent.change(screen.getByTestId('permission-search-input'), { target: { value: 'report' } });
290+
await waitFor(() => {
291+
expect(screen.queryByText('manage_users')).not.toBeInTheDocument();
292+
expect(screen.getByText('read_reports')).toBeInTheDocument();
293+
});
294+
});
295+
});

apps/console/src/components/AppSidebar.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,12 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
312312
</div>
313313
<div className="font-medium text-muted-foreground">Edit App</div>
314314
</DropdownMenuItem>
315+
<DropdownMenuItem className="gap-2 p-2" onClick={() => navigate(`/apps/${activeAppName}/system/apps`)} data-testid="manage-all-apps-btn">
316+
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
317+
<Settings className="size-4" />
318+
</div>
319+
<div className="font-medium text-muted-foreground">Manage All Apps</div>
320+
</DropdownMenuItem>
315321
</DropdownMenuContent>
316322
</DropdownMenu>
317323
</SidebarMenuItem>
@@ -480,7 +486,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
480486
<DropdownMenuSeparator />
481487
<DropdownMenuGroup>
482488
<DropdownMenuItem
483-
onClick={() => navigate(`/apps/${activeAppName}/system/profile`)}
489+
onClick={() => navigate(`/apps/${activeAppName}/system`)}
484490
>
485491
<Settings className="mr-2 h-4 w-4" />
486492
Settings

0 commit comments

Comments
 (0)