Skip to content

Commit e668ac1

Browse files
committed
Refactor routing to use react-router-dom v7
Migrates the app to use react-router-dom v7 for routing, replacing manual path management. Adds MainLayout and WorkspaceLayout components for structured layouts, introduces route-based object and app dashboards, and implements hooks for object schema fetching. Updates Dashboard to delegate app selection to DashboardHome and object views to dedicated routes, improving code organization and navigation.
1 parent 9d8c8d5 commit e668ac1

File tree

15 files changed

+673
-257
lines changed

15 files changed

+673
-257
lines changed

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"react": "^18.3.1",
2121
"react-dom": "^18.3.1",
2222
"react-hook-form": "^7.54.2",
23+
"react-router-dom": "^7.12.0",
2324
"tailwind-merge": "^2.2.1"
2425
},
2526
"devDependencies": {

apps/web/src/App.tsx

Lines changed: 40 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,18 @@
1-
import { useState, useEffect, useRef } from 'react';
1+
import { Routes, Route, Navigate } from 'react-router-dom';
22
import AppList from './pages/AppList';
33
import Login from './pages/Login';
4-
import Dashboard from './pages/Dashboard';
4+
import AppDashboard from './pages/AppDashboard'; // New App Home
55
import Settings from './pages/Settings';
66
import Organization from './pages/Organization';
77
import { AuthProvider, useAuth } from './context/AuthContext';
8-
import { AppSidebar } from './components/app-sidebar';
9-
import { SidebarProvider, SidebarInset, SidebarTrigger, Separator, Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, Avatar, AvatarFallback, AvatarImage, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuGroup, DropdownMenuItem } from '@objectos/ui';
10-
import { LogOut, Settings as SettingsIcon, Building, Bell } from 'lucide-react';
8+
import { MainLayout } from './layouts/MainLayout';
9+
import { WorkspaceLayout } from './layouts/WorkspaceLayout';
10+
import { ObjectListRoute } from './pages/objects/ObjectListRoute';
11+
import { ObjectDetailRoute } from './pages/objects/ObjectDetailRoute';
12+
import * as paths from './routes';
1113

1214
function AppContent() {
13-
const { user, loading, signOut } = useAuth();
14-
const [currentPath, setCurrentPath] = useState(window.location.pathname);
15-
16-
const [currentAppMetadata, setCurrentAppMetadata] = useState<any>(null);
17-
const lastFetchedApp = useRef<string | null>(null);
18-
19-
// Fetch App Metadata when entering an app
20-
useEffect(() => {
21-
const parts = currentPath.split('/');
22-
if (parts[1] === 'app' && parts[2]) {
23-
const appName = parts[2];
24-
25-
// Avoid re-fetching if we already have this app loaded
26-
if (lastFetchedApp.current === appName) {
27-
return;
28-
}
29-
30-
lastFetchedApp.current = appName;
31-
32-
fetch(`/api/metadata/app/${appName}`)
33-
.then(res => {
34-
if (!res.ok) throw new Error('App not found');
35-
return res.json();
36-
})
37-
.then(data => {
38-
setCurrentAppMetadata(data);
39-
// Ensure ref matches confirmed loaded data ID/Name if needed, but keeping it simple
40-
})
41-
.catch(err => {
42-
console.error(err);
43-
setCurrentAppMetadata(null);
44-
// Reset ref on error so we can try again if user refreshes or navs away and back
45-
lastFetchedApp.current = null;
46-
});
47-
} else {
48-
if (lastFetchedApp.current) {
49-
setCurrentAppMetadata(null);
50-
lastFetchedApp.current = null;
51-
}
52-
}
53-
}, [currentPath]); // Remove currentAppMetadata from dependency
54-
55-
useEffect(() => {
56-
const handlePopState = () => setCurrentPath(window.location.pathname);
57-
window.addEventListener('popstate', handlePopState);
58-
59-
// Custom event for navigation
60-
const handlePushState = () => setCurrentPath(window.location.pathname);
61-
window.addEventListener('pushstate', handlePushState);
62-
63-
return () => {
64-
window.removeEventListener('popstate', handlePopState);
65-
window.removeEventListener('pushstate', handlePushState);
66-
};
67-
}, []);
15+
const { user, loading } = useAuth();
6816

6917
if (loading) {
7018
return (
@@ -76,116 +24,43 @@ function AppContent() {
7624

7725
// Auth Routing
7826
if (!user) {
79-
if (currentPath !== '/login') {
80-
window.history.pushState({}, '', '/login');
81-
return <Login />;
82-
}
83-
return <Login />;
84-
}
85-
86-
if (currentPath === '/login') {
87-
window.history.pushState({}, '', '/');
88-
setCurrentPath('/');
89-
}
90-
91-
// Main Layout
92-
if (currentPath === '/' || currentPath === '/apps') {
9327
return (
94-
<div className="flex flex-col min-h-screen w-full bg-background">
95-
<header className="flex h-16 shrink-0 items-center gap-4 border-b px-6 bg-card sticky top-0 z-50">
96-
<div className="flex items-center gap-2 font-bold text-lg">
97-
<span>ObjectOS</span>
98-
</div>
99-
<div className="ml-auto flex items-center gap-2">
100-
{/* User Menu */}
101-
<DropdownMenu>
102-
<DropdownMenuTrigger asChild>
103-
<button className="flex items-center gap-2 outline-none">
104-
<Avatar className="h-8 w-8 rounded-lg cursor-pointer hover:opacity-80 transition-opacity">
105-
<AvatarImage src={user?.image} alt={user?.name} />
106-
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
107-
</Avatar>
108-
</button>
109-
</DropdownMenuTrigger>
110-
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" side="bottom" align="end" sideOffset={4}>
111-
<DropdownMenuLabel className="p-0 font-normal">
112-
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
113-
<Avatar className="h-8 w-8 rounded-lg">
114-
<AvatarImage src={user?.image} alt={user?.name} />
115-
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
116-
</Avatar>
117-
<div className="grid flex-1 text-left text-sm leading-tight">
118-
<span className="truncate font-semibold">{user?.name}</span>
119-
<span className="truncate text-xs">{user?.email}</span>
120-
</div>
121-
</div>
122-
</DropdownMenuLabel>
123-
<DropdownMenuSeparator />
124-
<DropdownMenuGroup>
125-
<DropdownMenuItem>
126-
<Building className="mr-2 h-4 w-4" />
127-
Organization
128-
</DropdownMenuItem>
129-
<DropdownMenuItem>
130-
<SettingsIcon className="mr-2 h-4 w-4" />
131-
Settings
132-
</DropdownMenuItem>
133-
<DropdownMenuItem>
134-
<Bell className="mr-2 h-4 w-4" />
135-
Notifications
136-
</DropdownMenuItem>
137-
</DropdownMenuGroup>
138-
<DropdownMenuSeparator />
139-
<DropdownMenuItem onClick={signOut}>
140-
<LogOut className="mr-2 h-4 w-4" />
141-
Log out
142-
</DropdownMenuItem>
143-
</DropdownMenuContent>
144-
</DropdownMenu>
145-
</div>
146-
</header>
147-
<div className="flex flex-1 flex-col gap-4 p-8 overflow-y-auto">
148-
<div className="max-w-7xl mx-auto w-full">
149-
<h1 className="text-2xl font-bold mb-6">Apps</h1>
150-
<AppList />
151-
</div>
152-
</div>
153-
</div>
28+
<Routes>
29+
<Route path={paths.LOGIN} element={<Login />} />
30+
<Route path="*" element={<Navigate to={paths.LOGIN} replace />} />
31+
</Routes>
15432
);
15533
}
15634

15735
return (
158-
<SidebarProvider>
159-
<AppSidebar objects={{}} appMetadata={currentAppMetadata} />
160-
<SidebarInset>
161-
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-4 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
162-
<SidebarTrigger className="-ml-1" />
163-
<Separator orientation="vertical" className="mr-2 h-4" />
164-
<Breadcrumb>
165-
<BreadcrumbList>
166-
<BreadcrumbItem>
167-
<BreadcrumbPage>
168-
{(() => {
169-
if (currentPath === '/settings') return 'Settings';
170-
if (currentPath === '/organization') return 'Organization';
171-
172-
const parts = currentPath.split('/');
173-
if (parts[1] === 'app') {
174-
return `App: ${parts[2]}`;
175-
}
176-
return 'Dashboard';
177-
})()}
178-
</BreadcrumbPage>
179-
</BreadcrumbItem>
180-
</BreadcrumbList>
181-
</Breadcrumb>
182-
</header>
183-
<div className="flex flex-1 flex-col gap-4 p-4">
184-
{currentPath === '/settings' ? <Settings /> :
185-
currentPath === '/organization' ? <Organization /> : <Dashboard />}
186-
</div>
187-
</SidebarInset>
188-
</SidebarProvider>
36+
<Routes>
37+
<Route path={paths.LOGIN} element={<Navigate to="/" replace />} />
38+
39+
{/* Main App Selection Layout */}
40+
<Route element={<MainLayout />}>
41+
<Route path={paths.ROOT} element={<AppList />} />
42+
<Route path={paths.APPS} element={<AppList />} />
43+
</Route>
44+
45+
{/* Workspace/Dashboard Layout */}
46+
<Route element={<WorkspaceLayout />}>
47+
{/* The App Home Dashboard showing menu shortcuts */}
48+
<Route path={paths.APP_ROOT} element={<AppDashboard />} />
49+
50+
{/* Object Routes */}
51+
<Route path={paths.APP_OBJECT_LIST} element={<ObjectListRoute />} />
52+
<Route path={paths.APP_OBJECT_DETAIL} element={<ObjectDetailRoute />} />
53+
{/* Legacy/Compat routes support */}
54+
<Route path="/app/:appName/object/:objectName/:recordId" element={<ObjectDetailRoute />} />
55+
56+
{/* Global/Standard Routes */}
57+
<Route path={paths.SETTINGS} element={<Settings />} />
58+
<Route path={paths.ORGANIZATION} element={<Organization />} />
59+
</Route>
60+
61+
{/* Fallback */}
62+
<Route path="*" element={<Navigate to="/" replace />} />
63+
</Routes>
18964
);
19065
}
19166

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useRouter } from '../../hooks/useRouter';
2+
3+
interface ObjectNotFoundProps {
4+
objectName: string;
5+
}
6+
7+
export function ObjectNotFound({ objectName }: ObjectNotFoundProps) {
8+
const { navigate } = useRouter();
9+
10+
return (
11+
<div className="flex flex-col items-center justify-center h-[calc(100vh-200px)]">
12+
<div className="p-6 bg-red-50 rounded-full mb-4">
13+
<i className="ri-error-warning-line text-4xl text-red-500"></i>
14+
</div>
15+
<h2 className="text-2xl font-bold text-gray-900">Object Not Found</h2>
16+
<p className="text-muted-foreground mt-2 max-w-md text-center">
17+
The object "{objectName}" does not exist or you do not have permission to view it.
18+
</p>
19+
<button
20+
onClick={() => navigate('/')}
21+
className="mt-6 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
22+
>
23+
Return to Dashboard
24+
</button>
25+
</div>
26+
);
27+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { useState, useEffect } from 'react';
2+
import { getHeaders } from '../lib/api';
3+
4+
const schemaCache: Record<string, any> = {};
5+
6+
export function useObjectSchema(objectName: string) {
7+
const [schema, setSchema] = useState<any>(null);
8+
const [loading, setLoading] = useState(true);
9+
const [error, setError] = useState<Error | null>(null);
10+
11+
useEffect(() => {
12+
if (!objectName) return;
13+
14+
if (schemaCache[objectName]) {
15+
setSchema(schemaCache[objectName]);
16+
setLoading(false);
17+
return;
18+
}
19+
20+
setLoading(true);
21+
// We could fetch single object too: /api/metadata/object/:name
22+
// But current API might be bulk. Let's try single if available or filter from bulk (inefficient but works for now)
23+
// Optimization: Create /api/metadata/object/:name endpoint in backend or assume bulk cache in context.
24+
// For now, let's fetch list and find. (Or just assume the API supports name, which standard ObjectQL usually does)
25+
26+
fetch(`/api/metadata/object/${objectName}`, { headers: getHeaders() })
27+
.then(async res => {
28+
if (res.status === 404) return null; // Not found
29+
if (!res.ok) {
30+
// Fallback to bulk if single endpoint fails?
31+
// Let's try bulk list as fallback or primay if we know backend
32+
throw new Error('Failed to load schema');
33+
}
34+
return res.json();
35+
})
36+
.then(data => {
37+
if (data) {
38+
schemaCache[objectName] = data;
39+
setSchema(data);
40+
} else {
41+
// Try bulk fetch fallback if 404/error on single?
42+
// Assuming 404 meant endponit doesn't exist, not object.
43+
// But actually, if object doesn't exist, we want null.
44+
setError(new Error('Object not found'));
45+
}
46+
setLoading(false);
47+
})
48+
.catch(() => {
49+
// Fallback: Fetch all
50+
fetch('/api/metadata/object', { headers: getHeaders() })
51+
.then(res => res.json())
52+
.then(result => {
53+
const list = Array.isArray(result) ? result : (result.object || result.data || []);
54+
const found = list.find((o: any) => o.name === objectName);
55+
if (found) {
56+
schemaCache[objectName] = found;
57+
setSchema(found);
58+
setError(null);
59+
} else {
60+
setError(new Error('Object not found'));
61+
}
62+
})
63+
.catch(e => setError(e))
64+
.finally(() => setLoading(false));
65+
});
66+
67+
}, [objectName]);
68+
69+
return { schema, loading, error };
70+
}

apps/web/src/hooks/useRouter.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,12 @@
1-
import { useState, useEffect, useCallback } from 'react';
1+
import { useNavigate, useLocation } from 'react-router-dom';
22

33
export function useRouter() {
4-
const [path, setPath] = useState(window.location.pathname);
4+
const navigate = useNavigate();
5+
const location = useLocation();
56

6-
useEffect(() => {
7-
const onPopState = () => setPath(window.location.pathname);
8-
window.addEventListener('popstate', onPopState);
9-
return () => window.removeEventListener('popstate', onPopState);
10-
}, []);
11-
12-
const navigate = useCallback((newPath: string) => {
13-
window.history.pushState({}, '', newPath);
14-
setPath(newPath);
15-
// Dispatch popstate event to notify other listeners (like App.tsx if it listens)
16-
window.dispatchEvent(new Event('popstate'));
17-
}, []);
18-
19-
return { path, navigate };
7+
return {
8+
path: location.pathname,
9+
navigate: (path: string) => navigate(path),
10+
search: location.search
11+
};
2012
}

0 commit comments

Comments
 (0)