Skip to content

Commit 4ac213d

Browse files
committed
feat: Add multi-app support with CRM and Todo app integration in console
1 parent 6e60ebe commit 4ac213d

7 files changed

Lines changed: 156 additions & 39 deletions

File tree

apps/console/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"preview": "vite preview"
1515
},
1616
"dependencies": {
17+
"@example/app-crm": "workspace:*",
1718
"@example/app-todo": "workspace:*",
1819
"@objectstack/client": "workspace:*",
1920
"@objectstack/client-react": "workspace:*",

apps/console/src/App.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Toaster } from "@/components/ui/toaster"
99
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
1010
import { Database, Layers, Sparkles, Zap } from 'lucide-react';
1111
import { getApiBaseUrl, config } from './lib/config';
12+
import { appPackages, type AppPackage } from './mocks/browser';
1213

1314
function DashboardWelcome() {
1415
return (
@@ -121,6 +122,7 @@ export default function App() {
121122
const [selectedObject, setSelectedObject] = useState<string | null>(null);
122123
const [editingRecord, setEditingRecord] = useState<any>(null);
123124
const [showForm, setShowForm] = useState(false);
125+
const [selectedApp, setSelectedApp] = useState<AppPackage>(appPackages[0]);
124126

125127
useEffect(() => {
126128
// Use the configured API base URL based on runtime mode (MSW or Server)
@@ -152,15 +154,25 @@ export default function App() {
152154
setEditingRecord(null);
153155
}
154156

157+
function handleSelectApp(app: AppPackage) {
158+
setSelectedApp(app);
159+
setSelectedObject(null);
160+
setShowForm(false);
161+
setEditingRecord(null);
162+
}
163+
155164
return (
156165
<SidebarProvider>
157166
<AppSidebar
158167
client={client}
159168
selectedObject={selectedObject}
160-
onSelectObject={(name) => setSelectedObject(name || null)}
169+
onSelectObject={(name) => setSelectedObject(name || null)}
170+
apps={appPackages}
171+
selectedApp={selectedApp}
172+
onSelectApp={handleSelectApp}
161173
/>
162174
<main className="flex flex-1 flex-col bg-background">
163-
<SiteHeader selectedObject={selectedObject} />
175+
<SiteHeader selectedObject={selectedObject} appLabel={selectedApp?.label} />
164176
<div className="flex flex-1 flex-col">
165177
{selectedObject ? (
166178
<div className="flex flex-1 flex-col gap-4 p-4">

apps/console/src/components/app-sidebar.tsx

Lines changed: 71 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ import {
77
LogOut,
88
Sparkles,
99
Search,
10+
Check,
11+
Briefcase,
12+
CheckSquare,
1013
} from "lucide-react"
1114
import { useState, useEffect } from "react"
1215
import { ObjectStackClient } from '@objectstack/client';
16+
import type { AppPackage } from "@/mocks/browser";
1317

1418
import {
1519
Sidebar,
@@ -38,13 +42,21 @@ import {
3842
DropdownMenuTrigger,
3943
} from "@/components/ui/dropdown-menu"
4044

45+
const APP_ICONS: Record<string, React.ElementType> = {
46+
'check-square': CheckSquare,
47+
'briefcase': Briefcase,
48+
};
49+
4150
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
4251
client: ObjectStackClient | null;
4352
selectedObject: string | null;
4453
onSelectObject: (name: string) => void;
54+
apps: AppPackage[];
55+
selectedApp: AppPackage | null;
56+
onSelectApp: (app: AppPackage) => void;
4557
}
4658

47-
export function AppSidebar({ client, selectedObject, onSelectObject, ...props }: AppSidebarProps) {
59+
export function AppSidebar({ client, selectedObject, onSelectObject, apps, selectedApp, onSelectApp, ...props }: AppSidebarProps) {
4860
const [objects, setObjects] = useState<any[]>([]);
4961
const [loading, setLoading] = useState(false);
5062
const [searchQuery, setSearchQuery] = useState("");
@@ -73,24 +85,72 @@ export function AppSidebar({ client, selectedObject, onSelectObject, ...props }:
7385
loadObjects();
7486
}, [client]);
7587

76-
const filteredObjects = objects.filter(obj =>
88+
// Filter objects by selected app's registered objects
89+
const appObjectNames = selectedApp
90+
? (selectedApp.config.objects || []).map((o: any) => o.name)
91+
: [];
92+
93+
const appObjects = selectedApp
94+
? objects.filter(obj => appObjectNames.includes(obj.name))
95+
: objects;
96+
97+
const filteredObjects = appObjects.filter(obj =>
7798
!searchQuery ||
7899
obj.label?.toLowerCase().includes(searchQuery.toLowerCase()) ||
79100
obj.name?.toLowerCase().includes(searchQuery.toLowerCase())
80101
);
81102

103+
const AppIcon = selectedApp?.icon ? (APP_ICONS[selectedApp.icon] || Sparkles) : Sparkles;
104+
82105
return (
83106
<Sidebar {...props}>
84107
<SidebarHeader className="border-b">
85-
<div className="flex items-center gap-2 px-2 py-1.5">
86-
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
87-
<Sparkles className="h-4 w-4" />
88-
</div>
89-
<div className="flex flex-col gap-0.5 leading-none">
90-
<span className="font-semibold text-sm">ObjectStack</span>
91-
<span className="text-xs text-muted-foreground">Platform Console</span>
92-
</div>
93-
</div>
108+
{/* App Switcher */}
109+
<DropdownMenu>
110+
<DropdownMenuTrigger asChild>
111+
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-sidebar-accent transition-colors">
112+
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
113+
<AppIcon className="h-4 w-4" />
114+
</div>
115+
<div className="flex flex-1 flex-col gap-0.5 leading-none overflow-hidden">
116+
<span className="truncate font-semibold text-sm">
117+
{selectedApp ? selectedApp.label : 'ObjectStack'}
118+
</span>
119+
<span className="truncate text-xs text-muted-foreground">
120+
{selectedApp ? selectedApp.description : 'Select an app'}
121+
</span>
122+
</div>
123+
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" />
124+
</button>
125+
</DropdownMenuTrigger>
126+
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width] min-w-64" align="start" sideOffset={4}>
127+
<DropdownMenuLabel>Applications</DropdownMenuLabel>
128+
<DropdownMenuSeparator />
129+
{apps.map((app) => {
130+
const Icon = app.icon ? (APP_ICONS[app.icon] || Package) : Package;
131+
return (
132+
<DropdownMenuItem
133+
key={app.id}
134+
onClick={() => onSelectApp(app)}
135+
className="gap-2 py-2"
136+
>
137+
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-primary/10 text-primary">
138+
<Icon className="h-3.5 w-3.5" />
139+
</div>
140+
<div className="flex flex-1 flex-col leading-tight">
141+
<span className="text-sm font-medium">{app.label}</span>
142+
{app.description && (
143+
<span className="text-xs text-muted-foreground">{app.description}</span>
144+
)}
145+
</div>
146+
{selectedApp?.id === app.id && (
147+
<Check className="h-4 w-4 text-primary" />
148+
)}
149+
</DropdownMenuItem>
150+
);
151+
})}
152+
</DropdownMenuContent>
153+
</DropdownMenu>
94154
</SidebarHeader>
95155

96156
<SidebarContent>

apps/console/src/components/site-header.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import { ThemeToggle } from "@/components/theme-toggle"
1212

1313
interface SiteHeaderProps {
1414
selectedObject: string | null;
15+
appLabel?: string;
1516
}
1617

17-
export function SiteHeader({ selectedObject }: SiteHeaderProps) {
18+
export function SiteHeader({ selectedObject, appLabel }: SiteHeaderProps) {
1819
return (
1920
<header className="flex h-16 shrink-0 items-center justify-between gap-2 border-b px-4">
2021
<div className="flex items-center gap-2">
@@ -24,7 +25,7 @@ export function SiteHeader({ selectedObject }: SiteHeaderProps) {
2425
<BreadcrumbList>
2526
<BreadcrumbItem className="hidden md:block">
2627
<BreadcrumbLink href="#">
27-
ObjectStack
28+
{appLabel || 'ObjectStack'}
2829
</BreadcrumbLink>
2930
</BreadcrumbItem>
3031
<BreadcrumbSeparator className="hidden md:block" />

apps/console/src/mocks/browser.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,59 @@
33
*
44
* This creates a complete ObjectStack environment in the browser using the In-Memory Driver
55
* and the MSW Plugin which automatically exposes the API.
6+
* Supports multiple app packages (app-todo, app-crm, etc.)
67
*/
78
import { ObjectKernel } from '@objectstack/runtime';
89
import todoConfig from '@example/app-todo/objectstack.config';
10+
import crmConfig from '@example/app-crm/objectstack.config';
911
import { createKernel } from './createKernel';
1012

1113
let kernel: ObjectKernel | null = null;
1214

15+
/** All available app packages */
16+
export interface AppPackage {
17+
id: string;
18+
name: string;
19+
label: string;
20+
description?: string;
21+
icon?: string;
22+
config: any;
23+
}
24+
25+
function resolveConfig(raw: any) {
26+
return (raw as any).default || raw;
27+
}
28+
29+
export const appPackages: AppPackage[] = [
30+
{
31+
id: 'com.example.todo',
32+
name: 'todo_app',
33+
label: 'Todo App',
34+
description: 'A simple Todo example',
35+
icon: 'check-square',
36+
config: resolveConfig(todoConfig),
37+
},
38+
{
39+
id: 'com.example.crm',
40+
name: 'crm_app',
41+
label: 'Enterprise CRM',
42+
description: 'Comprehensive enterprise CRM',
43+
icon: 'briefcase',
44+
config: resolveConfig(crmConfig),
45+
},
46+
];
47+
1348
export async function startMockServer() {
1449
if (kernel) return;
1550

1651
console.log('[MSW] Starting ObjectStack Runtime (Browser Mode)...');
52+
console.log('[MSW] Loading apps:', appPackages.map(a => a.label));
53+
54+
const appConfigs = appPackages.map(a => a.config);
1755

18-
// Handle CommonJS/ESM interop for config loading
19-
const appConfig = (todoConfig as any).default || todoConfig;
20-
console.log('[MSW] Loaded Config:', appConfig);
21-
22-
// Use shared factory
56+
// Use shared factory with multi-app support
2357
kernel = await createKernel({
24-
appConfig,
58+
appConfigs,
2559
enableBrowser: true
2660
});
2761

apps/console/src/mocks/createKernel.ts

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@ import { InMemoryDriver } from '@objectstack/driver-memory';
44
import { MSWPlugin } from '@objectstack/plugin-msw';
55

66
export interface KernelOptions {
7-
appConfig: any;
7+
appConfigs?: any[]; // Multiple app configs
8+
appConfig?: any; // Legacy single app config (backward compat)
89
enableBrowser?: boolean; // Default true (for browser usage), set false for tests
910
}
1011

1112
export async function createKernel(options: KernelOptions) {
12-
const { appConfig, enableBrowser = true } = options;
13+
const { enableBrowser = true } = options;
14+
15+
// Support both single and multi-app modes
16+
const allConfigs = options.appConfigs
17+
|| (options.appConfig ? [options.appConfig] : []);
1318

1419
console.log('[KernelFactory] Creating ObjectStack Kernel...');
15-
console.log('[KernelFactory] App Config:', appConfig);
20+
console.log('[KernelFactory] App Configs:', allConfigs.length);
1621

1722
const driver = new InMemoryDriver();
1823
const kernel = new ObjectKernel();
@@ -23,8 +28,11 @@ export async function createKernel(options: KernelOptions) {
2328
// Register the driver
2429
await kernel.use(new DriverPlugin(driver, 'memory'));
2530

26-
// Load app config as a plugin (which handles Seeding)
27-
await kernel.use(new AppPlugin(appConfig));
31+
// Load all app configs as plugins (handles object registration & seeding)
32+
for (const appConfig of allConfigs) {
33+
console.log('[KernelFactory] Loading app:', appConfig.manifest?.id || appConfig.name || 'unknown');
34+
await kernel.use(new AppPlugin(appConfig));
35+
}
2836

2937
// MSW Plugin
3038
await kernel.use(new MSWPlugin({
@@ -222,29 +230,27 @@ export async function createKernel(options: KernelOptions) {
222230
// FORCE SYNC SEED: Guarantees data availability for both Browser and Tests
223231
const ql = (kernel as any).context?.getService('objectql');
224232
if (ql) {
225-
// Initial check
226-
let tasks = await ql.find('todo_task');
227-
228-
// If AppPlugin's async seeding hasn't finished or failed, do it manually now.
229-
if (!tasks || tasks.length === 0) {
230-
console.warn('[KernelFactory] Seeding check failed. Executing IMMEDIATE Manual Seeding...');
231-
233+
// Seed data for all app configs
234+
for (const appConfig of allConfigs) {
232235
const manifestData = appConfig.data || (appConfig.manifest && appConfig.manifest.data);
233236
if (manifestData && Array.isArray(manifestData)) {
234237
for (const dataset of manifestData) {
235-
if (dataset.records) {
238+
if (!dataset.records || !dataset.object) continue;
239+
240+
// Check if data already seeded
241+
let existing = await ql.find(dataset.object);
242+
if (existing && (existing as any).value) existing = (existing as any).value;
243+
244+
if (!existing || existing.length === 0) {
236245
console.log(`[KernelFactory] Manual Seeding ${dataset.records.length} records for ${dataset.object}`);
237246
for (const record of dataset.records) {
238247
await ql.insert(dataset.object, record);
239248
}
249+
} else {
250+
console.log(`[KernelFactory] Data verified present for ${dataset.object}: ${existing.length} records.`);
240251
}
241252
}
242253
}
243-
// Verify
244-
tasks = await ql.find('todo_task');
245-
console.log(`[KernelFactory] Manual Seeding Complete. Count in DB: ${tasks?.length}`);
246-
} else {
247-
console.log(`[KernelFactory] Data verified present: ${tasks?.length} records.`);
248254
}
249255
}
250256

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)