Skip to content

Commit d799e49

Browse files
authored
Merge pull request #516 from objectstack-ai/copilot/implement-api-driven-metadata
2 parents 149ce56 + 50e422a commit d799e49

11 files changed

Lines changed: 376 additions & 85 deletions

apps/console/src/App.tsx

Lines changed: 53 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useSearchParams } from 'react-router-dom';
2-
import { useState, useEffect, lazy, Suspense, useMemo } from 'react';
2+
import { useState, useEffect, lazy, Suspense, useMemo, type ReactNode } from 'react';
33
import { ObjectForm } from '@object-ui/plugin-form';
44
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Empty, EmptyTitle } from '@object-ui/components';
55
import { toast } from 'sonner';
66
import { SchemaRendererProvider } from '@object-ui/react';
7-
import { ObjectStackAdapter } from './dataSource';
87
import type { ConnectionState } from './dataSource';
9-
import appConfig from '../objectstack.shared';
108
import { AuthGuard, useAuth, PreviewBanner } from '@object-ui/auth';
9+
import { MetadataProvider, useMetadata } from './context/MetadataProvider';
10+
import { AdapterProvider, useAdapter } from './context/AdapterProvider';
1111

1212
// Components (eagerly loaded — always needed)
1313
import { ConsoleLayout } from './components/ConsoleLayout';
@@ -46,17 +46,42 @@ import { useParams } from 'react-router-dom';
4646
import { ThemeProvider } from './components/theme-provider';
4747
import { ConsoleToaster } from './components/ConsoleToaster';
4848

49+
/**
50+
* ConnectedShell
51+
*
52+
* Creates the ObjectStackAdapter (via AdapterProvider), waits for connection,
53+
* then wraps children in MetadataProvider for API-driven metadata.
54+
*/
55+
function ConnectedShell({ children }: { children: ReactNode }) {
56+
return (
57+
<AdapterProvider>
58+
<ConnectedShellInner>{children}</ConnectedShellInner>
59+
</AdapterProvider>
60+
);
61+
}
62+
63+
function ConnectedShellInner({ children }: { children: ReactNode }) {
64+
const adapter = useAdapter();
65+
if (!adapter) return <LoadingScreen />;
66+
67+
return (
68+
<MetadataProvider adapter={adapter}>
69+
{children}
70+
</MetadataProvider>
71+
);
72+
}
73+
4974
export function AppContent() {
50-
const [dataSource, setDataSource] = useState<ObjectStackAdapter | null>(null);
5175
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
5276
const { user } = useAuth();
77+
const dataSource = useAdapter();
5378

5479
// App Selection
5580
const navigate = useNavigate();
5681
const location = useLocation();
5782
const [, setSearchParams] = useSearchParams();
5883
const { appName } = useParams();
59-
const apps = appConfig.apps || [];
84+
const { apps, objects: allObjects, loading: metadataLoading } = useMetadata();
6085

6186
// Determine active app based on URL
6287
const activeApps = apps.filter((a: any) => a.active !== false);
@@ -70,48 +95,19 @@ export function AppContent() {
7095
// Branding is now applied by AppShell via ConsoleLayout
7196

7297
useEffect(() => {
73-
let cancelled = false;
74-
75-
async function initializeDataSource() {
76-
try {
77-
const adapter = new ObjectStackAdapter({
78-
baseUrl: '',
79-
autoReconnect: true,
80-
maxReconnectAttempts: 5,
81-
reconnectDelay: 1000,
82-
cache: { maxSize: 50, ttl: 300_000 },
83-
});
84-
85-
// Monitor connection state
86-
adapter.onConnectionStateChange((event) => {
87-
if (cancelled) return;
88-
setConnectionState(event.state);
89-
if (event.error) {
90-
console.error('[Console] Connection error:', event.error);
91-
}
92-
});
93-
94-
await adapter.connect();
95-
96-
if (!cancelled) {
97-
setDataSource(adapter);
98-
}
99-
} catch (err) {
100-
if (!cancelled) {
101-
console.error('[Console] Failed to initialize:', err);
102-
setConnectionState('error');
103-
}
98+
if (!dataSource) return;
99+
const unsub = dataSource.onConnectionStateChange((event) => {
100+
setConnectionState(event.state);
101+
if (event.error) {
102+
console.error('[Console] Connection error:', event.error);
104103
}
105-
}
106-
107-
initializeDataSource();
104+
});
105+
// Sync current state
106+
setConnectionState(dataSource.getConnectionState());
107+
return unsub;
108+
}, [dataSource]);
108109

109-
return () => {
110-
cancelled = true;
111-
};
112-
}, []);
113-
114-
const allObjects = appConfig.objects || [];
110+
// allObjects already derived from useMetadata() above
115111

116112
// Find current object for Dialog
117113
// Path is now relative to /apps/:appName/
@@ -140,7 +136,7 @@ export function AppContent() {
140136
objName = '';
141137
}
142138
const basePath = `/apps/${activeApp.name}`;
143-
const objects = appConfig.objects || [];
139+
const objects = allObjects;
144140
if (objName) {
145141
const obj = objects.find((o: any) => o.name === objName);
146142
if (obj) {
@@ -199,7 +195,7 @@ export function AppContent() {
199195
[user, activeApp, editingRecord]
200196
);
201197

202-
if (!dataSource) return <LoadingScreen />;
198+
if (!dataSource || metadataLoading) return <LoadingScreen />;
203199
if (!activeApp) return (
204200
<div className="h-screen flex items-center justify-center">
205201
<Empty>
@@ -372,10 +368,11 @@ function findFirstRoute(items: any[]): string {
372368

373369
// Redirect root to default app
374370
function RootRedirect() {
375-
const apps = appConfig.apps || [];
371+
const { apps, loading } = useMetadata();
376372
const activeApps = apps.filter((a: any) => a.active !== false);
377373
const defaultApp = activeApps.find((a: any) => a.isDefault === true) || activeApps[0];
378374

375+
if (loading) return <LoadingScreen />;
379376
if (defaultApp) {
380377
return <Navigate to={`/apps/${defaultApp.name}`} replace />;
381378
}
@@ -396,10 +393,16 @@ export function App() {
396393
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
397394
<Route path="/apps/:appName/*" element={
398395
<AuthGuard fallback={<Navigate to="/login" />} loadingFallback={<LoadingScreen />}>
399-
<AppContent />
396+
<ConnectedShell>
397+
<AppContent />
398+
</ConnectedShell>
400399
</AuthGuard>
401400
} />
402-
<Route path="/" element={<RootRedirect />} />
401+
<Route path="/" element={
402+
<ConnectedShell>
403+
<RootRedirect />
404+
</ConnectedShell>
405+
} />
403406
</Routes>
404407
</Suspense>
405408
</BrowserRouter>

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ const mocks = vi.hoisted(() => {
5959
async update(_: string, id: string, data: any) { return { ...data, id }; }
6060
async delete() { return true; }
6161
async connect() { return true; }
62-
onConnectionStateChange() {}
62+
onConnectionStateChange() { return () => {}; }
63+
getConnectionState() { return 'connected'; }
6364
discovery = {};
6465
}
6566

@@ -102,6 +103,31 @@ vi.mock('../dataSource', () => ({
102103
ObjectStackDataSource: mocks.MockDataSource,
103104
}));
104105

106+
// Mock AdapterProvider to provide a mock adapter directly
107+
vi.mock('../context/AdapterProvider', () => ({
108+
AdapterProvider: ({ children }: any) => <>{children}</>,
109+
useAdapter: () => new mocks.MockDataSource(),
110+
}));
111+
112+
// Mock MetadataProvider to use the real objectstack.shared config as metadata
113+
vi.mock('../context/MetadataProvider', async () => {
114+
const config = await import('../../objectstack.shared');
115+
const appConfig = (config.default as any).default || config.default;
116+
return {
117+
MetadataProvider: ({ children }: any) => <>{children}</>,
118+
useMetadata: () => ({
119+
apps: appConfig.apps || [],
120+
objects: appConfig.objects || [],
121+
dashboards: appConfig.dashboards || [],
122+
reports: (appConfig as any).reports || [],
123+
pages: appConfig.pages || [],
124+
loading: false,
125+
error: null,
126+
refresh: vi.fn(),
127+
}),
128+
};
129+
});
130+
105131
// --- 2. Import AppContent ---
106132
import { AppContent } from '../App';
107133

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

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { MemoryRouter, Routes, Route } from 'react-router-dom';
66

77
// --- Mocks ---
88

9-
// Mock ObjectStack Config
9+
// Mock ObjectStack Config (still used by MSW mock setup)
1010
vi.mock('../../objectstack.shared', () => ({
1111
default: {
1212
apps: [
@@ -33,6 +33,39 @@ vi.mock('../../objectstack.shared', () => ({
3333
}
3434
}));
3535

36+
// Mock MetadataProvider to return static metadata (replaces objectstack.shared in components)
37+
vi.mock('../context/MetadataProvider', () => ({
38+
MetadataProvider: ({ children }: any) => <>{children}</>,
39+
useMetadata: () => ({
40+
apps: [
41+
{
42+
name: 'sales',
43+
label: 'Sales App',
44+
active: true,
45+
icon: 'briefcase',
46+
navigation: [
47+
{ id: 'nav_opp', label: 'Opportunities', type: 'object', objectName: 'opportunity' }
48+
]
49+
},
50+
{
51+
name: 'admin',
52+
label: 'Admin',
53+
active: true,
54+
navigation: []
55+
}
56+
],
57+
objects: [
58+
{ name: 'opportunity', label: 'Opportunity', fields: {} }
59+
],
60+
dashboards: [],
61+
reports: [],
62+
pages: [],
63+
loading: false,
64+
error: null,
65+
refresh: vi.fn(),
66+
}),
67+
}));
68+
3669
// Mock Client and DataSource
3770
vi.mock('@objectstack/client', () => {
3871
return {
@@ -42,6 +75,18 @@ vi.mock('@objectstack/client', () => {
4275
};
4376
});
4477

78+
const MockAdapterInstance = {
79+
find: vi.fn().mockResolvedValue([]),
80+
findOne: vi.fn(),
81+
create: vi.fn(),
82+
update: vi.fn(),
83+
delete: vi.fn(),
84+
connect: vi.fn().mockResolvedValue(true),
85+
onConnectionStateChange: vi.fn().mockReturnValue(() => {}),
86+
getConnectionState: vi.fn().mockReturnValue('connected'),
87+
discovery: {},
88+
};
89+
4590
vi.mock('../dataSource', () => {
4691
const MockAdapter = class {
4792
find = vi.fn().mockResolvedValue([]);
@@ -50,7 +95,8 @@ vi.mock('../dataSource', () => {
5095
update = vi.fn();
5196
delete = vi.fn();
5297
connect = vi.fn().mockResolvedValue(true);
53-
onConnectionStateChange = vi.fn();
98+
onConnectionStateChange = vi.fn().mockReturnValue(() => {});
99+
getConnectionState = vi.fn().mockReturnValue('connected');
54100
discovery = {};
55101
};
56102
return {
@@ -59,6 +105,12 @@ vi.mock('../dataSource', () => {
59105
};
60106
});
61107

108+
// Mock AdapterProvider to provide mock adapter directly
109+
vi.mock('../context/AdapterProvider', () => ({
110+
AdapterProvider: ({ children }: any) => <>{children}</>,
111+
useAdapter: () => MockAdapterInstance,
112+
}));
113+
62114
// Mock Child Components (Integration level)
63115
// We want to verify routing, so we mock the "Page" components but keep Layout structure mostly
64116
vi.mock('../components/ObjectView', () => ({
@@ -136,17 +188,12 @@ describe('Console App Integration', () => {
136188
it('initializes and renders default app layout', async () => {
137189
renderApp();
138190

139-
// 1. Should show loading initially
140-
expect(screen.getByText(/Initializing/i)).toBeInTheDocument();
141-
142-
// 2. Should eventually show Main Layout (header/sidebar)
191+
// With mocked adapter and metadata, the app renders immediately
192+
// Check for App Name in sidebar/header config
143193
await waitFor(() => {
144-
expect(screen.queryByText(/Initializing/i)).not.toBeInTheDocument();
194+
const appLabels = screen.getAllByText('Sales App');
195+
expect(appLabels.length).toBeGreaterThan(0);
145196
}, { timeout: 10000 });
146-
147-
// Check for App Name in sidebar/header config
148-
const appLabels = screen.getAllByText('Sales App');
149-
expect(appLabels.length).toBeGreaterThan(0);
150197

151198
// Check for Navigation Items
152199
expect(screen.getByText('Opportunities')).toBeInTheDocument();

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ import { render, screen } from '@testing-library/react';
44
import { MemoryRouter, Route, Routes } from 'react-router-dom';
55
import { PageView } from '../components/PageView';
66

7-
// Mock appConfig
8-
vi.mock('../../objectstack.shared', () => ({
9-
default: {
7+
// Mock MetadataProvider to return static page metadata
8+
vi.mock('../context/MetadataProvider', () => ({
9+
useMetadata: () => ({
10+
apps: [],
11+
objects: [],
12+
dashboards: [],
13+
reports: [],
1014
pages: [
1115
{
1216
name: 'help_page',
13-
// This simulates the fix: ensuring 'app' type renders correctly config-side
14-
// But the real component registry needs to support it (which we improved in packages/components)
1517
type: 'app',
1618
label: 'Help Guide',
1719
regions: [
@@ -30,8 +32,11 @@ vi.mock('../../objectstack.shared', () => ({
3032
{ type: 'text', value: 'Standard Page Content' }
3133
]
3234
}
33-
]
34-
}
35+
],
36+
loading: false,
37+
error: null,
38+
refresh: vi.fn(),
39+
}),
3540
}));
3641

3742
// Mock SchemaRenderer to avoid complex component tree rendering

apps/console/src/components/AppSidebar.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import {
4848
Star,
4949
StarOff,
5050
} from 'lucide-react';
51-
import appConfig from '../../objectstack.shared';
51+
import { useMetadata } from '../context/MetadataProvider';
5252
import { useExpressionContext, evaluateVisibility } from '../context/ExpressionProvider';
5353
import { useAuth, getUserInitials } from '@object-ui/auth';
5454
import { usePermissions } from '@object-ui/permissions';
@@ -185,7 +185,8 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
185185
const { recentItems } = useRecentItems();
186186
const { favorites, removeFavorite } = useFavorites();
187187

188-
const apps = appConfig.apps || [];
188+
const { apps: metadataApps } = useMetadata();
189+
const apps = metadataApps || [];
189190
// Filter out inactive apps
190191
const activeApps = apps.filter((a: any) => a.active !== false);
191192
const activeApp = activeApps.find((a: any) => a.name === activeAppName) || activeApps[0];

0 commit comments

Comments
 (0)