diff --git a/apps/console/objectstack.shared.ts b/apps/console/objectstack.shared.ts index 5eb736366..6c8ecb43c 100644 --- a/apps/console/objectstack.shared.ts +++ b/apps/console/objectstack.shared.ts @@ -142,4 +142,11 @@ export const sharedConfig = { ] }; -export default defineStack(sharedConfig as Parameters[0]); +// defineStack() validates the config but strips non-standard properties like +// listViews from objects. Re-merge listViews after validation so the runtime +// protocol serves objects with their view definitions (calendar, kanban, etc.). +const validated = defineStack(sharedConfig as Parameters[0]); +export default { + ...validated, + objects: mergeViewsIntoObjects(validated.objects || [], allConfigs), +}; diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index cf4843a73..3d6a0e06d 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -427,7 +427,7 @@ export function AppContent() { function findFirstRoute(items: any[]): string { if (!items || items.length === 0) return ''; for (const item of items) { - if (item.type === 'object') return `${item.objectName}`; + if (item.type === 'object') return item.viewName ? `${item.objectName}/view/${item.viewName}` : `${item.objectName}`; if (item.type === 'page') return item.pageName ? `page/${item.pageName}` : ''; if (item.type === 'dashboard') return item.dashboardName ? `dashboard/${item.dashboardName}` : ''; if (item.type === 'url') continue; // Skip external URLs diff --git a/apps/console/src/components/AppSidebar.tsx b/apps/console/src/components/AppSidebar.tsx index f25a5183a..6a1dc9d8b 100644 --- a/apps/console/src/components/AppSidebar.tsx +++ b/apps/console/src/components/AppSidebar.tsx @@ -524,7 +524,10 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri const NavIcon = getIcon(item.icon); const baseUrl = `/apps/${activeAppName}`; let href = '#'; - if (item.type === 'object') href = `${baseUrl}/${item.objectName}`; + if (item.type === 'object') { + href = `${baseUrl}/${item.objectName}`; + if (item.viewName) href += `/view/${item.viewName}`; + } else if (item.type === 'dashboard') href = item.dashboardName ? `${baseUrl}/dashboard/${item.dashboardName}` : '#'; else if (item.type === 'page') href = item.pageName ? `${baseUrl}/page/${item.pageName}` : '#'; return ( diff --git a/apps/console/src/components/SearchResultsPage.tsx b/apps/console/src/components/SearchResultsPage.tsx index 9f088ff2f..4d3502667 100644 --- a/apps/console/src/components/SearchResultsPage.tsx +++ b/apps/console/src/components/SearchResultsPage.tsx @@ -77,7 +77,10 @@ export function SearchResultsPage() { const navItems = flattenNavigation(activeApp.navigation || []); return navItems.map((item: any) => { let href = '#'; - if (item.type === 'object') href = `${baseUrl}/${item.objectName}`; + if (item.type === 'object') { + href = `${baseUrl}/${item.objectName}`; + if (item.viewName) href += `/view/${item.viewName}`; + } else if (item.type === 'dashboard') href = `${baseUrl}/dashboard/${item.dashboardName}`; else if (item.type === 'page') href = `${baseUrl}/page/${item.pageName}`; else if (item.type === 'report') href = `${baseUrl}/report/${item.reportName}`; diff --git a/objectstack.config.ts b/objectstack.config.ts index 6329a5df2..c2cdf1bcb 100644 --- a/objectstack.config.ts +++ b/objectstack.config.ts @@ -34,6 +34,36 @@ const baseObjects = [ ...(kitchenSink.objects || []), ]; +// Collect all example configs for view merging +const allConfigs = [crm, todo, kitchenSink]; + +// --------------------------------------------------------------------------- +// Merge stack-level views into object definitions. +// defineStack() strips non-standard properties like listViews from objects. +// Re-merge listViews after validation so the runtime protocol serves objects +// with their view definitions (calendar, kanban, etc.). +// --------------------------------------------------------------------------- +function mergeViewsIntoObjects(objects: any[], configs: any[]): any[] { + const viewsByObject: Record> = {}; + for (const config of configs) { + if (!Array.isArray(config.views)) continue; + for (const view of config.views) { + if (!view.listViews) continue; + for (const [viewName, listView] of Object.entries(view.listViews as Record)) { + const objectName = listView?.data?.object; + if (!objectName) continue; + if (!viewsByObject[objectName]) viewsByObject[objectName] = {}; + viewsByObject[objectName][viewName] = listView; + } + } + } + return objects.map((obj: any) => { + const views = viewsByObject[obj.name]; + if (!views) return obj; + return { ...obj, listViews: { ...(obj.listViews || {}), ...views } }; + }); +} + // Merge all example configs into a single app bundle for AppPlugin const mergedApp = defineStack({ manifest: { @@ -49,6 +79,11 @@ const mergedApp = defineStack({ ], }, objects: baseObjects, + views: [ + ...(crm.views || []), + ...(todo.views || []), + ...(kitchenSink.views || []), + ], apps: [ ...(crm.apps || []), ...(todo.apps || []), @@ -69,6 +104,12 @@ const mergedApp = defineStack({ ], } as any); +// Re-merge listViews that defineStack stripped from objects +const mergedAppWithViews = { + ...mergedApp, + objects: mergeViewsIntoObjects(mergedApp.objects || [], allConfigs), +}; + // Export only plugins — no top-level objects/manifest/apps. // The CLI auto-creates an AppPlugin from the config if it detects objects/manifest/apps, // which would conflict with our explicit AppPlugin and skip seed data loading. @@ -76,7 +117,7 @@ export default { plugins: [ new ObjectQLPlugin(), new DriverPlugin(new InMemoryDriver()), - new AppPlugin(mergedApp), + new AppPlugin(mergedAppWithViews), new HonoServerPlugin({ port: 3000 }), new ConsolePlugin(), ], diff --git a/packages/layout/src/AppSchemaRenderer.tsx b/packages/layout/src/AppSchemaRenderer.tsx index e138278e5..e346e9b5b 100644 --- a/packages/layout/src/AppSchemaRenderer.tsx +++ b/packages/layout/src/AppSchemaRenderer.tsx @@ -195,7 +195,10 @@ function MobileBottomNav({ {leaves.map((item) => { const NavIcon = resolveIcon(item.icon); let href = '#'; - if (item.type === 'object') href = `${basePath}/${item.objectName}`; + if (item.type === 'object') { + href = `${basePath}/${item.objectName}`; + if (item.viewName) href += `/view/${item.viewName}`; + } else if (item.type === 'dashboard') href = item.dashboardName ? `${basePath}/dashboard/${item.dashboardName}` : '#'; else if (item.type === 'page') href = item.pageName ? `${basePath}/page/${item.pageName}` : '#'; else if (item.type === 'report') href = item.reportName ? `${basePath}/report/${item.reportName}` : '#'; diff --git a/packages/layout/src/NavigationRenderer.tsx b/packages/layout/src/NavigationRenderer.tsx index 999d214be..407cdc16b 100644 --- a/packages/layout/src/NavigationRenderer.tsx +++ b/packages/layout/src/NavigationRenderer.tsx @@ -184,8 +184,10 @@ const defaultPermission: PermissionChecker = () => true; function resolveHref(item: NavigationItem, basePath: string): { href: string; external: boolean } { switch (item.type) { - case 'object': - return { href: `${basePath}/${item.objectName ?? ''}`, external: false }; + case 'object': { + const objectPath = `${basePath}/${item.objectName ?? ''}`; + return { href: item.viewName ? `${objectPath}/view/${item.viewName}` : objectPath, external: false }; + } case 'dashboard': return { href: item.dashboardName ? `${basePath}/dashboard/${item.dashboardName}` : '#', external: false }; case 'page': diff --git a/packages/layout/src/__tests__/NavigationRenderer.test.tsx b/packages/layout/src/__tests__/NavigationRenderer.test.tsx index 5f93c303b..9e847162f 100644 --- a/packages/layout/src/__tests__/NavigationRenderer.test.tsx +++ b/packages/layout/src/__tests__/NavigationRenderer.test.tsx @@ -110,6 +110,20 @@ describe('NavigationRenderer', () => { expect(link?.getAttribute('href')).toBe('/apps/test/account'); }); + it('renders object navigation item with viewName in href', () => { + const calendarItem: NavigationItem = { + id: 'nav-calendar', + type: 'object', + label: 'Calendar', + icon: 'Calendar', + objectName: 'event', + viewName: 'calendar', + }; + renderNav([calendarItem]); + const link = screen.getByText('Calendar').closest('a'); + expect(link?.getAttribute('href')).toBe('/apps/test/event/view/calendar'); + }); + it('renders dashboard navigation item', () => { renderNav([dashboardItem]); expect(screen.getByText('Sales Dashboard')).toBeTruthy(); diff --git a/packages/types/src/app.ts b/packages/types/src/app.ts index 6f8d210a0..306b6e4a0 100644 --- a/packages/types/src/app.ts +++ b/packages/types/src/app.ts @@ -66,6 +66,9 @@ export interface NavigationItem { /** Target object name (for type: 'object') */ objectName?: string; + /** Target view name (for type: 'object') — opens a specific named list view e.g. 'calendar', 'pipeline' */ + viewName?: string; + /** Target dashboard name (for type: 'dashboard') */ dashboardName?: string; diff --git a/packages/types/src/zod/app.zod.ts b/packages/types/src/zod/app.zod.ts index dbc94a51f..0c85c774e 100644 --- a/packages/types/src/zod/app.zod.ts +++ b/packages/types/src/zod/app.zod.ts @@ -41,6 +41,7 @@ export const NavigationItemSchema: z.ZodType = z.lazy(() => z.object({ // Type-specific target fields objectName: z.string().optional().describe('Target object name (type: object)'), + viewName: z.string().optional().describe('Target view name (type: object) — named list view e.g. calendar, pipeline'), dashboardName: z.string().optional().describe('Target dashboard name (type: dashboard)'), pageName: z.string().optional().describe('Target page name (type: page)'), reportName: z.string().optional().describe('Target report name (type: report)'),