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
40 changes: 38 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
> **Spec Version:** @objectstack/spec v3.0.9
> **Client Version:** @objectstack/client v3.0.9
> **Target UX Benchmark:** 🎯 Airtable parity
> **Current Priority:** AppShell Navigation · Designer Interaction · View Config Live Preview Sync · Dashboard Config Panel · Airtable UX Polish · **Flow Designer ✅** · **App Creation & Editing Flow ✅**
> **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 ✅**

---

## 📋 Executive Summary

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.

**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.
**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.

**What Remains:** The gap to **Airtable-level UX** is primarily in:
1. ~~**AppShell** — No dynamic navigation renderer from spec JSON (last P0 blocker)~~ ✅ Complete
Expand Down Expand Up @@ -495,6 +495,42 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
- [x] `createApp` i18n key added to all 10 locales
- [x] 11 console integration tests (routes, wizard callbacks, draft persistence, CommandPalette)

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

> Unified system settings hub, app management page, and permission management page.

**System Hub Page (`/system/`):**
- [x] Card-based overview linking to all system administration sections
- [x] Live statistics for each section (users, orgs, roles, permissions, audit logs, apps)
- [x] Navigation to Apps, Users, Organizations, Roles, Permissions, Audit Log, Profile

**App Management Page (`/system/apps`):**
- [x] Full app list with search/filter
- [x] Enable/disable toggle per app
- [x] Set default app
- [x] Delete app with confirmation
- [x] Bulk select with enable/disable operations
- [x] Navigate to Create App / Edit App pages
- [x] Navigate to app home

**Permission Management Page (`/system/permissions`):**
- [x] CRUD grid for `sys_permission` object
- [x] Search/filter permissions
- [x] Admin-only create/delete controls

**Sidebar & Navigation Updates:**
- [x] Settings button → `/system/` hub (was `/system/profile`)
- [x] App switcher "Manage All Apps" link → `/system/apps`

**Routes:**
- [x] `/system/` → SystemHubPage
- [x] `/system/apps` → AppManagementPage
- [x] `/system/permissions` → PermissionManagementPage

**Tests:**
- [x] 11 new tests (SystemHubPage, AppManagementPage, PermissionManagementPage)
- [x] Total: 20 system page tests passing

---

## 🧩 P2 — Polish & Advanced Features
Expand Down
6 changes: 6 additions & 0 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,12 @@ const RegisterPage = lazy(() => import('./pages/RegisterPage').then(m => ({ defa
const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage').then(m => ({ default: m.ForgotPasswordPage })));

// System Admin Pages (lazy — rarely accessed)
const SystemHubPage = lazy(() => import('./pages/system/SystemHubPage').then(m => ({ default: m.SystemHubPage })));
const AppManagementPage = lazy(() => import('./pages/system/AppManagementPage').then(m => ({ default: m.AppManagementPage })));
const UserManagementPage = lazy(() => import('./pages/system/UserManagementPage').then(m => ({ default: m.UserManagementPage })));
const OrgManagementPage = lazy(() => import('./pages/system/OrgManagementPage').then(m => ({ default: m.OrgManagementPage })));
const RoleManagementPage = lazy(() => import('./pages/system/RoleManagementPage').then(m => ({ default: m.RoleManagementPage })));
const PermissionManagementPage = lazy(() => import('./pages/system/PermissionManagementPage').then(m => ({ default: m.PermissionManagementPage })));
const AuditLogPage = lazy(() => import('./pages/system/AuditLogPage').then(m => ({ default: m.AuditLogPage })));
const ProfilePage = lazy(() => import('./pages/system/ProfilePage').then(m => ({ default: m.ProfilePage })));

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

{/* System Administration Routes */}
<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>
Expand Down
150 changes: 150 additions & 0 deletions apps/console/src/__tests__/SystemPages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,41 @@ vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));

const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
useParams: () => ({ appName: 'test-app' }),
};
});

const mockRefresh = vi.fn().mockResolvedValue(undefined);
vi.mock('../context/MetadataProvider', () => ({
useMetadata: () => ({
apps: [
{ name: 'crm', label: 'CRM', description: 'Customer management', active: true, isDefault: true },
{ name: 'hr', label: 'HR', description: 'Human resources', active: false, isDefault: false },
],
objects: [],
dashboards: [],
reports: [],
pages: [],
loading: false,
error: null,
refresh: mockRefresh,
}),
}));

// Import after mocks
import { UserManagementPage } from '../pages/system/UserManagementPage';
import { OrgManagementPage } from '../pages/system/OrgManagementPage';
import { RoleManagementPage } from '../pages/system/RoleManagementPage';
import { AuditLogPage } from '../pages/system/AuditLogPage';
import { SystemHubPage } from '../pages/system/SystemHubPage';
import { AppManagementPage } from '../pages/system/AppManagementPage';
import { PermissionManagementPage } from '../pages/system/PermissionManagementPage';

function wrap(ui: React.ReactElement) {
return render(<MemoryRouter>{ui}</MemoryRouter>);
Expand Down Expand Up @@ -143,3 +173,123 @@ describe('AuditLogPage', () => {
});
});
});

describe('SystemHubPage', () => {
it('should render System Settings heading and all hub cards', async () => {
mockFind.mockResolvedValue({ data: [] });
wrap(<SystemHubPage />);
expect(screen.getByText('System Settings')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('hub-card-applications')).toBeInTheDocument();
expect(screen.getByTestId('hub-card-users')).toBeInTheDocument();
expect(screen.getByTestId('hub-card-organizations')).toBeInTheDocument();
expect(screen.getByTestId('hub-card-roles')).toBeInTheDocument();
expect(screen.getByTestId('hub-card-permissions')).toBeInTheDocument();
expect(screen.getByTestId('hub-card-audit-log')).toBeInTheDocument();
expect(screen.getByTestId('hub-card-profile')).toBeInTheDocument();
});
});

it('should fetch counts from dataSource on mount', async () => {
mockFind.mockResolvedValue({ data: [{ id: '1' }] });
wrap(<SystemHubPage />);
await waitFor(() => {
expect(mockFind).toHaveBeenCalledWith('sys_user');
expect(mockFind).toHaveBeenCalledWith('sys_org');
expect(mockFind).toHaveBeenCalledWith('sys_role');
expect(mockFind).toHaveBeenCalledWith('sys_permission');
expect(mockFind).toHaveBeenCalledWith('sys_audit_log');
});
});

it('should navigate to section when card is clicked', async () => {
mockFind.mockResolvedValue({ data: [] });
wrap(<SystemHubPage />);
await waitFor(() => {
expect(screen.getByTestId('hub-card-users')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('hub-card-users'));
expect(mockNavigate).toHaveBeenCalledWith('/apps/test-app/system/users');
});
});

describe('AppManagementPage', () => {
it('should render app list from metadata', () => {
wrap(<AppManagementPage />);
expect(screen.getByText('Applications')).toBeInTheDocument();
expect(screen.getByTestId('app-card-crm')).toBeInTheDocument();
expect(screen.getByTestId('app-card-hr')).toBeInTheDocument();
});

it('should filter apps by search query', () => {
wrap(<AppManagementPage />);
fireEvent.change(screen.getByTestId('app-search-input'), { target: { value: 'CRM' } });
expect(screen.getByTestId('app-card-crm')).toBeInTheDocument();
expect(screen.queryByTestId('app-card-hr')).not.toBeInTheDocument();
});

it('should show empty state when no matching apps', () => {
wrap(<AppManagementPage />);
fireEvent.change(screen.getByTestId('app-search-input'), { target: { value: 'nonexistent' } });
expect(screen.getByTestId('no-apps-message')).toBeInTheDocument();
});

it('should navigate to create-app on New App button click', () => {
wrap(<AppManagementPage />);
fireEvent.click(screen.getByTestId('create-app-btn'));
expect(mockNavigate).toHaveBeenCalledWith('/apps/test-app/create-app');
});
});

describe('PermissionManagementPage', () => {
it('should call dataSource.find("sys_permission") on mount', async () => {
mockFind.mockResolvedValueOnce({
data: [{ id: '1', name: 'manage_users', resource: 'user', action: 'manage', description: 'Full user access' }],
});
wrap(<PermissionManagementPage />);
await waitFor(() => {
expect(mockFind).toHaveBeenCalledWith('sys_permission');
});
expect(screen.getByText('manage_users')).toBeInTheDocument();
});

it('should show empty state when no permissions', async () => {
mockFind.mockResolvedValueOnce({ data: [] });
wrap(<PermissionManagementPage />);
await waitFor(() => {
expect(screen.getByText('No permissions found.')).toBeInTheDocument();
});
});

it('should call create when Add Permission is clicked', async () => {
mockFind.mockResolvedValue({ data: [] });
mockCreate.mockResolvedValueOnce({ id: 'new-perm' });
wrap(<PermissionManagementPage />);
await waitFor(() => {
expect(screen.getByText('No permissions found.')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Add Permission'));
await waitFor(() => {
expect(mockCreate).toHaveBeenCalledWith('sys_permission', expect.objectContaining({ name: 'New Permission' }));
});
});

it('should filter permissions by search query', async () => {
mockFind.mockResolvedValue({
data: [
{ id: '1', name: 'manage_users', resource: 'user', action: 'manage', description: '' },
{ id: '2', name: 'read_reports', resource: 'report', action: 'read', description: '' },
],
});
wrap(<PermissionManagementPage />);
await waitFor(() => {
expect(screen.getByText('manage_users')).toBeInTheDocument();
expect(screen.getByText('read_reports')).toBeInTheDocument();
});
fireEvent.change(screen.getByTestId('permission-search-input'), { target: { value: 'report' } });
await waitFor(() => {
expect(screen.queryByText('manage_users')).not.toBeInTheDocument();
expect(screen.getByText('read_reports')).toBeInTheDocument();
});
});
});
8 changes: 7 additions & 1 deletion apps/console/src/components/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,12 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
</div>
<div className="font-medium text-muted-foreground">Edit App</div>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 p-2" onClick={() => navigate(`/apps/${activeAppName}/system/apps`)} data-testid="manage-all-apps-btn">
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<Settings className="size-4" />
</div>
<div className="font-medium text-muted-foreground">Manage All Apps</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
Expand Down Expand Up @@ -480,7 +486,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => navigate(`/apps/${activeAppName}/system/profile`)}
onClick={() => navigate(`/apps/${activeAppName}/system`)}
>
<Settings className="mr-2 h-4 w-4" />
Settings
Expand Down
Loading