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
9 changes: 8 additions & 1 deletion apps/console/objectstack.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,11 @@ export const sharedConfig = {
]
};

export default defineStack(sharedConfig as Parameters<typeof defineStack>[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<typeof defineStack>[0]);
export default {
...validated,
objects: mergeViewsIntoObjects(validated.objects || [], allConfigs),
};
Comment on lines +148 to +152
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change re-merges listViews after defineStack() (objects: mergeViewsIntoObjects(validated.objects || [], allConfigs)), but sharedConfig.objects already ran mergeViewsIntoObjects() before validation. Since defineStack() strips listViews, the pre-merge is now redundant work and makes the pipeline harder to follow. Consider constructing sharedConfig.objects without listViews and applying mergeViewsIntoObjects() only once after validation.

Copilot uses AI. Check for mistakes.
2 changes: 1 addition & 1 deletion apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findFirstRoute() now includes viewName, but it still assumes item.objectName is present for type: 'object'. If the first visible object nav item is missing objectName, this will return undefined (or undefined/view/...) and break the app’s root redirect. Consider skipping object items without objectName and continuing the search.

Suggested change
if (item.type === 'object') return item.viewName ? `${item.objectName}/view/${item.viewName}` : `${item.objectName}`;
if (item.type === 'object') {
// Skip object items that do not have a valid objectName
if (!item.objectName) continue;
return item.viewName ? `${item.objectName}/view/${item.viewName}` : `${item.objectName}`;
}

Copilot uses AI. Check for mistakes.
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
Expand Down
5 changes: 4 additions & 1 deletion apps/console/src/components/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Comment on lines +528 to +529
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Console mobile bottom nav constructs object hrefs with ${baseUrl}/${item.objectName} without guarding for missing objectName. If a type: 'object' navigation item lacks objectName, this produces /apps/<app>/undefined (and now may append /view/...). Consider leaving href as '#' unless objectName is defined, consistent with other target types.

Suggested change
href = `${baseUrl}/${item.objectName}`;
if (item.viewName) href += `/view/${item.viewName}`;
if (item.objectName) {
href = `${baseUrl}/${item.objectName}`;
if (item.viewName) href += `/view/${item.viewName}`;
}

Copilot uses AI. Check for mistakes.
}
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 (
Expand Down
5 changes: 4 additions & 1 deletion apps/console/src/components/SearchResultsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SearchResultsPage builds object hrefs using ${baseUrl}/${item.objectName} without checking that objectName exists. Navigation configs produced by the designer can include type: 'object' items without objectName, which would cause these results to show up with /apps/<app>/undefined (and possibly /view/... appended). Consider only creating an href when objectName is present (otherwise keep '#' so it gets filtered out).

Suggested change
if (item.type === 'object') {
if (item.type === 'object' && item.objectName) {

Copilot uses AI. Check for mistakes.
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}`;
Expand Down
43 changes: 42 additions & 1 deletion objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, any>> = {};
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<string, any>)) {
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: {
Expand All @@ -49,6 +79,11 @@ const mergedApp = defineStack({
],
},
objects: baseObjects,
views: [
...(crm.views || []),
...(todo.views || []),
...(kitchenSink.views || []),
],
apps: [
...(crm.apps || []),
...(todo.apps || []),
Expand All @@ -69,14 +104,20 @@ 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.
export default {
plugins: [
new ObjectQLPlugin(),
new DriverPlugin(new InMemoryDriver()),
new AppPlugin(mergedApp),
new AppPlugin(mergedAppWithViews),
new HonoServerPlugin({ port: 3000 }),
new ConsolePlugin(),
],
Expand Down
5 changes: 4 additions & 1 deletion packages/layout/src/AppSchemaRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Comment on lines 195 to +199
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s test coverage for href generation in packages/layout/src/__tests__/AppSchemaRenderer.test.tsx, but no test asserting the new viewName/view/<name> behavior for the mobile bottom nav. Adding a focused test would help prevent regressions since href logic is duplicated across multiple components.

Copilot uses AI. Check for mistakes.
if (item.viewName) href += `/view/${item.viewName}`;
Comment on lines +199 to +200
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MobileBottomNav constructs object hrefs with ${basePath}/${item.objectName} without guarding for a missing objectName. This can yield routes like /apps/foo/undefined and will now also append /view/${item.viewName} onto that. Since other code paths allow NavigationItem objects without objectName (e.g. designer-created hidden items), it would be safer to keep href = '#' unless objectName is present.

Suggested change
href = `${basePath}/${item.objectName}`;
if (item.viewName) href += `/view/${item.viewName}`;
if (item.objectName) {
href = `${basePath}/${item.objectName}`;
if (item.viewName) href += `/view/${item.viewName}`;
} else {
href = '#';
}

Copilot uses AI. Check for mistakes.
}
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}` : '#';
Expand Down
6 changes: 4 additions & 2 deletions packages/layout/src/NavigationRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Comment on lines +188 to +189
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveHref() builds an object href even when item.objectName is missing (it becomes ${basePath}/), and with viewName present it can produce a double-slash path like ${basePath}//view/<name>. There are existing configs/tests that create NavigationItem objects without objectName (e.g. designer hidden items), so this can cause navigation to unexpectedly route to the app root. Consider returning '#' (non-navigable) when objectName is absent, consistent with the dashboard/page/report cases.

Suggested change
const objectPath = `${basePath}/${item.objectName ?? ''}`;
return { href: item.viewName ? `${objectPath}/view/${item.viewName}` : objectPath, external: false };
if (!item.objectName) {
return { href: '#', external: false };
}
const objectPath = `${basePath}/${item.objectName}`;
return {
href: item.viewName ? `${objectPath}/view/${item.viewName}` : objectPath,
external: false,
};

Copilot uses AI. Check for mistakes.
}
case 'dashboard':
return { href: item.dashboardName ? `${basePath}/dashboard/${item.dashboardName}` : '#', external: false };
case 'page':
Expand Down
14 changes: 14 additions & 0 deletions packages/layout/src/__tests__/NavigationRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions packages/types/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions packages/types/src/zod/app.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const NavigationItemSchema: z.ZodType<any> = 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)'),
Expand Down