@object-ui/app-shell 4.0.9 → 4.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,64 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 4.0.11
4
+
5
+ ### Patch Changes
6
+
7
+ - 7ea1d93: dashboard
8
+ - Updated dependencies [1909bc3]
9
+ - @object-ui/i18n@4.0.11
10
+ - @object-ui/components@4.0.11
11
+ - @object-ui/fields@4.0.11
12
+ - @object-ui/plugin-calendar@4.0.11
13
+ - @object-ui/plugin-charts@4.0.11
14
+ - @object-ui/plugin-dashboard@4.0.11
15
+ - @object-ui/plugin-designer@4.0.11
16
+ - @object-ui/plugin-kanban@4.0.11
17
+ - @object-ui/plugin-list@4.0.11
18
+ - @object-ui/react@4.0.11
19
+ - @object-ui/layout@4.0.11
20
+ - @object-ui/plugin-chatbot@4.0.11
21
+ - @object-ui/plugin-detail@4.0.11
22
+ - @object-ui/plugin-form@4.0.11
23
+ - @object-ui/plugin-grid@4.0.11
24
+ - @object-ui/plugin-report@4.0.11
25
+ - @object-ui/plugin-view@4.0.11
26
+ - @object-ui/types@4.0.11
27
+ - @object-ui/core@4.0.11
28
+ - @object-ui/data-objectstack@4.0.11
29
+ - @object-ui/auth@4.0.11
30
+ - @object-ui/permissions@4.0.11
31
+ - @object-ui/collaboration@4.0.11
32
+
33
+ ## 4.0.10
34
+
35
+ ### Patch Changes
36
+
37
+ - 7cb0c37: metadata
38
+ - @object-ui/types@4.0.10
39
+ - @object-ui/core@4.0.10
40
+ - @object-ui/i18n@4.0.10
41
+ - @object-ui/react@4.0.10
42
+ - @object-ui/components@4.0.10
43
+ - @object-ui/fields@4.0.10
44
+ - @object-ui/layout@4.0.10
45
+ - @object-ui/data-objectstack@4.0.10
46
+ - @object-ui/auth@4.0.10
47
+ - @object-ui/permissions@4.0.10
48
+ - @object-ui/plugin-calendar@4.0.10
49
+ - @object-ui/plugin-charts@4.0.10
50
+ - @object-ui/plugin-chatbot@4.0.10
51
+ - @object-ui/plugin-dashboard@4.0.10
52
+ - @object-ui/plugin-designer@4.0.10
53
+ - @object-ui/plugin-detail@4.0.10
54
+ - @object-ui/plugin-form@4.0.10
55
+ - @object-ui/plugin-grid@4.0.10
56
+ - @object-ui/plugin-kanban@4.0.10
57
+ - @object-ui/plugin-list@4.0.10
58
+ - @object-ui/plugin-report@4.0.10
59
+ - @object-ui/plugin-view@4.0.10
60
+ - @object-ui/collaboration@4.0.10
61
+
3
62
  ## 4.0.9
4
63
 
5
64
  ### Patch Changes
@@ -118,7 +118,7 @@ export function AppSidebar({ activeAppName, onAppChange }) {
118
118
  }, [isMobile]);
119
119
  const { recentItems } = useRecentItems();
120
120
  const { favorites, removeFavorite } = useFavorites();
121
- const { apps: metadataApps } = useMetadata();
121
+ const { apps: metadataApps, objects: metadataObjects } = useMetadata();
122
122
  const apps = metadataApps || [];
123
123
  // Filter out inactive apps
124
124
  const activeApps = apps.filter((a) => a.active !== false);
@@ -166,6 +166,24 @@ export function AppSidebar({ activeAppName, onAppChange }) {
166
166
  : [perm, 'read'];
167
167
  return can(object, action);
168
168
  }), [can]);
169
+ // Runtime capability checker — gates nav entries with `requiresObject` /
170
+ // `requiresService` against the runtime's actual SchemaRegistry contents.
171
+ // Currently we only probe registered objects (sourced from the metadata
172
+ // provider, which fetches `GET /api/v1/meta/object` on mount). Service
173
+ // gates default to pass since there is no client-side service registry
174
+ // probe yet — callers should wire one when needed.
175
+ const registeredObjectNames = React.useMemo(() => new Set((metadataObjects || []).map((o) => o?.name).filter(Boolean)), [metadataObjects]);
176
+ const checkCap = React.useCallback((kind, name) => {
177
+ if (kind === 'object') {
178
+ // While metadata is still loading we have an empty set; show
179
+ // entries by default to avoid a "menu flicker" where everything
180
+ // disappears momentarily on first render.
181
+ if (registeredObjectNames.size === 0)
182
+ return true;
183
+ return registeredObjectNames.has(name);
184
+ }
185
+ return true;
186
+ }, [registeredObjectNames]);
169
187
  const basePath = activeApp ? `/apps/${activeAppName}` : '';
170
188
  // Fallback system navigation when no active app exists — routes into the Setup app.
171
189
  const systemFallbackNavigation = React.useMemo(() => [
@@ -181,7 +199,7 @@ export function AppSidebar({ activeAppName, onAppChange }) {
181
199
  const AreaIcon = getIcon(area.icon);
182
200
  const isActiveArea = area.id === activeAreaId;
183
201
  return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActiveArea, tooltip: area.label, onClick: () => setActiveAreaId(area.id), children: [_jsx(AreaIcon, { className: "h-4 w-4" }), _jsx("span", { children: area.label })] }) }, area.id));
184
- }) }) })] })), _jsx(SidebarGroup, { className: "py-0", children: _jsxs(SidebarGroupContent, { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 size-4 -translate-y-1/2 select-none opacity-70" }), _jsx(SidebarInput, { placeholder: "Search navigation...", value: navSearchQuery, onChange: (e) => setNavSearchQuery(e.target.value), className: "pl-8" })] }) }), _jsx(NavigationRenderer, { items: processedNavigation, basePath: basePath, evaluateVisibility: evalVis, checkPermission: checkPerm, searchQuery: navSearchQuery, enablePinning: true, onPinToggle: togglePin, enableReorder: true, onReorder: handleReorder, resolveObjectLabel: (objectName, fallback) => resolveNavObjectLabel({ name: objectName, label: fallback }), t: t }), recentItems.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5 cursor-pointer select-none", onClick: () => setRecentExpanded(prev => !prev), children: [_jsx(ChevronRight, { className: `h-3 w-3 transition-transform duration-150 ${recentExpanded ? 'rotate-90' : ''}` }), _jsx(Clock, { className: "h-3.5 w-3.5" }), "Recent"] }), recentExpanded && (_jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: recentItems.slice(0, 5).map(item => (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄' }), _jsx("span", { className: "truncate", children: item.label })] }) }) }, item.id))) }) }))] })), favorites.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Star, { className: "h-3.5 w-3.5" }), "Favorites"] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: favorites.slice(0, 8).map(item => (_jsxs(SidebarMenuItem, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : item.type === 'page' ? '📄' : '📋' }), _jsx("span", { className: "truncate", children: item.label })] }) }), _jsx(SidebarMenuAction, { showOnHover: true, onClick: (e) => { e.stopPropagation(); removeFavorite(item.id); }, "aria-label": `Remove ${item.label} from favorites`, children: _jsx(StarOff, { className: "h-3 w-3" }) })] }, item.id))) }) })] }))] })) : (_jsxs(SidebarGroup, { "data-testid": "system-fallback-nav", children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Settings, { className: "h-3.5 w-3.5" }), "System"] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: systemFallbackNavigation.map((item) => {
202
+ }) }) })] })), _jsx(SidebarGroup, { className: "py-0", children: _jsxs(SidebarGroupContent, { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 size-4 -translate-y-1/2 select-none opacity-70" }), _jsx(SidebarInput, { placeholder: "Search navigation...", value: navSearchQuery, onChange: (e) => setNavSearchQuery(e.target.value), className: "pl-8" })] }) }), _jsx(NavigationRenderer, { items: processedNavigation, basePath: basePath, evaluateVisibility: evalVis, checkPermission: checkPerm, checkCapability: checkCap, searchQuery: navSearchQuery, enablePinning: true, onPinToggle: togglePin, enableReorder: true, onReorder: handleReorder, resolveObjectLabel: (objectName, fallback) => resolveNavObjectLabel({ name: objectName, label: fallback }), t: t }), recentItems.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5 cursor-pointer select-none", onClick: () => setRecentExpanded(prev => !prev), children: [_jsx(ChevronRight, { className: `h-3 w-3 transition-transform duration-150 ${recentExpanded ? 'rotate-90' : ''}` }), _jsx(Clock, { className: "h-3.5 w-3.5" }), "Recent"] }), recentExpanded && (_jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: recentItems.slice(0, 5).map(item => (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄' }), _jsx("span", { className: "truncate", children: item.label })] }) }) }, item.id))) }) }))] })), favorites.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Star, { className: "h-3.5 w-3.5" }), "Favorites"] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: favorites.slice(0, 8).map(item => (_jsxs(SidebarMenuItem, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : item.type === 'page' ? '📄' : '📋' }), _jsx("span", { className: "truncate", children: item.label })] }) }), _jsx(SidebarMenuAction, { showOnHover: true, onClick: (e) => { e.stopPropagation(); removeFavorite(item.id); }, "aria-label": `Remove ${item.label} from favorites`, children: _jsx(StarOff, { className: "h-3 w-3" }) })] }, item.id))) }) })] }))] })) : (_jsxs(SidebarGroup, { "data-testid": "system-fallback-nav", children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Settings, { className: "h-3.5 w-3.5" }), "System"] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: systemFallbackNavigation.map((item) => {
185
203
  const NavIcon = getIcon(item.icon);
186
204
  return (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.url || '/system', children: [_jsx(NavIcon, { className: "h-4 w-4" }), _jsx("span", { children: item.label })] }) }) }, item.id));
187
205
  }) }) })] })) }), _jsx(SidebarFooter, { children: _jsx(SidebarMenu, { children: _jsx(SidebarMenuItem, { children: _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(SidebarMenuButton, { size: "lg", className: "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] }), _jsx(ChevronsUpDown, { className: "ml-auto size-4" })] }) }), _jsxs(DropdownMenuContent, { className: "w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg", side: isMobile ? "bottom" : "right", align: "end", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-1 py-1.5 text-left text-sm", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] })] }) }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup'), children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('user.settings', { defaultValue: 'Settings' })] }) }), isAuthEnabled && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "text-destructive focus:text-destructive", onClick: () => signOut(), children: [_jsx(LogOut, { className: "mr-2 h-4 w-4" }), t('user.logout', { defaultValue: 'Log out' })] })] }))] })] }) }) }) })] }), isMobile && (_jsx("div", { className: "fixed bottom-0 left-0 right-0 z-50 flex items-center justify-around border-t bg-background/95 backdrop-blur-sm px-2 py-1 sm:hidden safe-area-bottom", children: (activeApp ? resolvedNavigation : systemFallbackNavigation).filter((n) => n.type !== 'group').slice(0, 5).map((item) => {
@@ -114,7 +114,7 @@ export function UnifiedSidebar({ activeAppName }) {
114
114
  }, [isMobile]);
115
115
  const { recentItems } = useRecentItems();
116
116
  const { favorites, removeFavorite } = useFavorites();
117
- const { apps: metadataApps } = useMetadata();
117
+ const { apps: metadataApps, objects: metadataObjects } = useMetadata();
118
118
  const apps = metadataApps || [];
119
119
  const activeApps = apps.filter((a) => a.active !== false);
120
120
  const activeApp = activeApps.find((a) => a.name === (activeAppName || currentAppName)) || activeApps[0];
@@ -160,12 +160,23 @@ export function UnifiedSidebar({ activeAppName }) {
160
160
  : [perm, 'read'];
161
161
  return can(object, action);
162
162
  }), [can]);
163
+ // Runtime capability gate: hide nav items targeting objects/services
164
+ // not registered in this runtime (e.g. cloud-only `sys_app`).
165
+ const registeredObjectNames = React.useMemo(() => new Set((metadataObjects || []).map((o) => o?.name).filter(Boolean)), [metadataObjects]);
166
+ const checkCap = React.useCallback((kind, name) => {
167
+ if (kind === 'object') {
168
+ if (registeredObjectNames.size === 0)
169
+ return true;
170
+ return registeredObjectNames.has(name);
171
+ }
172
+ return true;
173
+ }, [registeredObjectNames]);
163
174
  const basePath = context === 'app' && activeApp ? `/apps/${activeApp.name}` : '';
164
175
  return (_jsxs(_Fragment, { children: [_jsxs(Sidebar, { collapsible: "icon", className: "!top-14 !h-[calc(100svh-3.5rem)]", children: [_jsx(SidebarContent, { className: "pt-2", children: _jsx("div", { className: "transition-opacity duration-200 ease-in-out", children: context === 'app' && activeApp ? (_jsxs(_Fragment, { children: [areas.length > 1 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Layers, { className: "h-3.5 w-3.5" }), t('sidebar.area', { defaultValue: 'Area' })] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: areas.map((area) => {
165
176
  const AreaIcon = getIcon(area.icon);
166
177
  const isActiveArea = area.id === activeAreaId;
167
178
  return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActiveArea, tooltip: area.label, onClick: () => setActiveAreaId(area.id), children: [_jsx(AreaIcon, { className: "h-4 w-4" }), _jsx("span", { children: area.label })] }) }, area.id));
168
- }) }) })] })), _jsx(NavigationRenderer, { items: processedNavigation, basePath: basePath, evaluateVisibility: evalVis, checkPermission: checkPerm, enablePinning: !isMobile, onPinToggle: togglePin, enableReorder: !isMobile, onReorder: handleReorder, resolveObjectLabel: (objectName, fallback) => resolveNavObjectLabel({ name: objectName, label: fallback }), resolveDashboardLabel: (dashboardName, fallback) => resolveNavDashboardLabel({ name: dashboardName, label: fallback }), resolveGroupLabel: activeApp ? (groupId, fallback) => resolveNavGroupLabel(activeApp.name, groupId, fallback) : undefined, t: t }), recentItems.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5 cursor-pointer select-none", onClick: () => setRecentExpanded(prev => !prev), children: [_jsx(ChevronRight, { className: `h-3 w-3 transition-transform duration-150 ${recentExpanded ? 'rotate-90' : ''}` }), _jsx(Clock, { className: "h-3.5 w-3.5" }), t('sidebar.recent', { defaultValue: 'Recent' })] }), recentExpanded && (_jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: recentItems.slice(0, 5).map(item => (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄' }), _jsx("span", { className: "truncate", children: item.label })] }) }) }, item.id))) }) }))] })), favorites.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Star, { className: "h-3.5 w-3.5" }), t('sidebar.favorites', { defaultValue: 'Favorites' })] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: favorites.slice(0, 8).map(item => (_jsxs(SidebarMenuItem, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : item.type === 'page' ? '📄' : '📋' }), _jsx("span", { className: "truncate", children: item.label })] }) }), _jsx(SidebarMenuAction, { showOnHover: true, onClick: (e) => { e.stopPropagation(); removeFavorite(item.id); }, "aria-label": t('sidebar.removeFromFavorites', { defaultValue: 'Remove {{name}} from favorites', name: item.label }), children: _jsx(StarOff, { className: "h-3 w-3" }) })] }, item.id))) }) })] }))] })) : (_jsxs(_Fragment, { children: [_jsx(SidebarGroup, { children: _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: homeNavigation.map((item) => {
179
+ }) }) })] })), _jsx(NavigationRenderer, { items: processedNavigation, basePath: basePath, evaluateVisibility: evalVis, checkPermission: checkPerm, checkCapability: checkCap, enablePinning: !isMobile, onPinToggle: togglePin, enableReorder: !isMobile, onReorder: handleReorder, resolveObjectLabel: (objectName, fallback) => resolveNavObjectLabel({ name: objectName, label: fallback }), resolveDashboardLabel: (dashboardName, fallback) => resolveNavDashboardLabel({ name: dashboardName, label: fallback }), resolveGroupLabel: activeApp ? (groupId, fallback) => resolveNavGroupLabel(activeApp.name, groupId, fallback) : undefined, t: t }), recentItems.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5 cursor-pointer select-none", onClick: () => setRecentExpanded(prev => !prev), children: [_jsx(ChevronRight, { className: `h-3 w-3 transition-transform duration-150 ${recentExpanded ? 'rotate-90' : ''}` }), _jsx(Clock, { className: "h-3.5 w-3.5" }), t('sidebar.recent', { defaultValue: 'Recent' })] }), recentExpanded && (_jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: recentItems.slice(0, 5).map(item => (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄' }), _jsx("span", { className: "truncate", children: item.label })] }) }) }, item.id))) }) }))] })), favorites.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Star, { className: "h-3.5 w-3.5" }), t('sidebar.favorites', { defaultValue: 'Favorites' })] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: favorites.slice(0, 8).map(item => (_jsxs(SidebarMenuItem, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : item.type === 'page' ? '📄' : '📋' }), _jsx("span", { className: "truncate", children: item.label })] }) }), _jsx(SidebarMenuAction, { showOnHover: true, onClick: (e) => { e.stopPropagation(); removeFavorite(item.id); }, "aria-label": t('sidebar.removeFromFavorites', { defaultValue: 'Remove {{name}} from favorites', name: item.label }), children: _jsx(StarOff, { className: "h-3 w-3" }) })] }, item.id))) }) })] }))] })) : (_jsxs(_Fragment, { children: [_jsx(SidebarGroup, { children: _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: homeNavigation.map((item) => {
169
180
  const NavIcon = getIcon(item.icon);
170
181
  const isActive = location.pathname === item.url;
171
182
  return (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, isActive: isActive, children: _jsxs(Link, { to: item.url || '/home', children: [_jsx(NavIcon, { className: "h-4 w-4" }), _jsx("span", { children: item.label })] }) }) }, item.id));
@@ -65,15 +65,27 @@ const REQUIRED_FIELDS_BY_TYPE = {
65
65
  filter: (f) => f.type === 'date',
66
66
  preferred: PRIMARY_DATE_PREFERRED,
67
67
  },
68
+ {
69
+ key: 'titleField',
70
+ i18nKey: 'console.objectView.titleField',
71
+ helpI18nKey: 'console.objectView.titleFieldHelp',
72
+ filter: (f) => f.type === 'text',
73
+ },
68
74
  ],
69
75
  timeline: [
70
76
  {
71
- key: 'dateField',
72
- i18nKey: 'console.objectView.dateField',
77
+ key: 'startDateField',
78
+ i18nKey: 'console.objectView.startDateField',
73
79
  helpI18nKey: 'console.objectView.timelineDateFieldHelp',
74
80
  filter: (f) => f.type === 'date',
75
81
  preferred: PRIMARY_DATE_PREFERRED,
76
82
  },
83
+ {
84
+ key: 'titleField',
85
+ i18nKey: 'console.objectView.titleField',
86
+ helpI18nKey: 'console.objectView.titleFieldHelp',
87
+ filter: (f) => f.type === 'text',
88
+ },
77
89
  ],
78
90
  gantt: [
79
91
  {
@@ -90,10 +102,16 @@ const REQUIRED_FIELDS_BY_TYPE = {
90
102
  filter: (f) => f.type === 'date',
91
103
  preferred: END_DATE_PREFERRED,
92
104
  },
105
+ {
106
+ key: 'titleField',
107
+ i18nKey: 'console.objectView.titleField',
108
+ helpI18nKey: 'console.objectView.titleFieldHelp',
109
+ filter: (f) => f.type === 'text',
110
+ },
93
111
  ],
94
112
  gallery: [
95
113
  {
96
- key: 'imageField',
114
+ key: 'coverField',
97
115
  i18nKey: 'console.objectView.imageField',
98
116
  helpI18nKey: 'console.objectView.imageFieldHelp',
99
117
  filter: (f) => isImageLikeField(f),
@@ -27,7 +27,7 @@ const WIDGET_TYPES = [
27
27
  { type: 'line', label: 'Line Chart', Icon: LineChart },
28
28
  { type: 'pie', label: 'Pie Chart', Icon: PieChart },
29
29
  { type: 'table', label: 'Table', Icon: Table2 },
30
- { type: 'grid', label: 'Grid', Icon: LayoutGrid },
30
+ { type: 'pivot', label: 'Pivot', Icon: LayoutGrid },
31
31
  ];
32
32
  let widgetCounter = 0;
33
33
  function createWidgetId() {
@@ -74,12 +74,22 @@ const TOP_LEVEL_WIDGET_KEYS = new Set([
74
74
  'layoutW',
75
75
  'layoutH',
76
76
  'id',
77
+ // Spec-declared top-level fields that the renderer reads directly off the
78
+ // widget (not from options). Keeping them here ensures the unflatten path
79
+ // does not silently drop them into options where the renderer ignores them.
80
+ 'searchable',
81
+ 'pagination',
82
+ // Drill-down virtual flat keys are handled explicitly below — listing them
83
+ // here keeps the generic options-collector from duplicating them.
84
+ 'drillDownEnabled',
85
+ 'drillDownTarget',
77
86
  ]);
78
87
  function flattenWidgetConfig(widget) {
79
88
  // Spread options first so explicit top-level widget fields take precedence
80
89
  // on collision. This surfaces type-specific options (pivot/table/chart axes,
81
90
  // list itemTemplate, etc.) so they appear pre-filled in the config panel.
82
91
  const options = (widget.options ?? {});
92
+ const drillDown = (options.drillDown ?? {});
83
93
  return {
84
94
  ...options,
85
95
  title: widget.title ?? '',
@@ -93,6 +103,11 @@ function flattenWidgetConfig(widget) {
93
103
  layoutH: widget.layout?.h ?? 1,
94
104
  colorVariant: widget.colorVariant ?? options.colorVariant ?? 'default',
95
105
  actionUrl: widget.actionUrl ?? options.actionUrl ?? '',
106
+ searchable: widget.searchable ?? options.searchable ?? false,
107
+ pagination: widget.pagination ?? options.pagination ?? false,
108
+ // Surface drill-down nested shape as flat switches for the panel
109
+ drillDownEnabled: drillDown.enabled ?? false,
110
+ drillDownTarget: drillDown.target ?? 'drawer',
96
111
  };
97
112
  }
98
113
  function unflattenWidgetConfig(config, base) {
@@ -107,6 +122,17 @@ function unflattenWidgetConfig(config, base) {
107
122
  continue;
108
123
  newOptions[key] = value;
109
124
  }
125
+ // Re-nest drill-down flat keys back under options.drillDown so the renderer
126
+ // (which reads `options.drillDown`) picks them up.
127
+ const drillDownEnabled = config.drillDownEnabled;
128
+ const drillDownTarget = config.drillDownTarget;
129
+ if (drillDownEnabled !== undefined || drillDownTarget !== undefined) {
130
+ newOptions.drillDown = {
131
+ ...(baseOptions.drillDown || {}),
132
+ ...(drillDownEnabled !== undefined ? { enabled: !!drillDownEnabled } : {}),
133
+ ...(drillDownTarget !== undefined ? { target: drillDownTarget } : {}),
134
+ };
135
+ }
110
136
  return {
111
137
  title: config.title,
112
138
  description: config.description,
@@ -118,6 +144,8 @@ function unflattenWidgetConfig(config, base) {
118
144
  layout: { ...(base.layout || {}), w: config.layoutW, h: config.layoutH },
119
145
  colorVariant: config.colorVariant,
120
146
  actionUrl: config.actionUrl,
147
+ ...(config.searchable !== undefined ? { searchable: !!config.searchable } : {}),
148
+ ...(config.pagination !== undefined ? { pagination: !!config.pagination } : {}),
121
149
  ...(Object.keys(newOptions).length > 0 ? { options: newOptions } : {}),
122
150
  };
123
151
  }
@@ -129,6 +157,7 @@ function extractDashboardConfig(schema) {
129
157
  rowHeight: String(s.rowHeight ?? '120'),
130
158
  refreshInterval: String(s.refreshInterval ?? '0'),
131
159
  title: s.title ?? '',
160
+ description: s.description ?? '',
132
161
  showDescription: s.showDescription ?? true,
133
162
  theme: s.theme ?? 'auto',
134
163
  };
@@ -231,7 +260,12 @@ export function DashboardView({ dataSource }) {
231
260
  refresh().catch(() => { });
232
261
  }
233
262
  catch (err) {
263
+ // Surface the failure — previously this was a silent console.warn,
264
+ // which produced "I saw it work, then refresh wiped it" reports
265
+ // because the optimistic state update masked a server-side reject.
266
+ const message = err instanceof Error ? err.message : String(err);
234
267
  console.warn('[DashboardView] Auto-save failed:', err);
268
+ toast.error(`Failed to save dashboard: ${message}`);
235
269
  }
236
270
  }, [adapter, dashboardName, refresh]);
237
271
  // ---- Open / close config panel ------------------------------------------
@@ -279,6 +313,18 @@ export function DashboardView({ dataSource }) {
279
313
  setSelectedWidgetId(null);
280
314
  }
281
315
  }, [editSchema, selectedWidgetId, saveSchema]);
316
+ // Reorder widgets via drag-and-drop from DashboardRenderer's design mode.
317
+ const handleWidgetsReorder = useCallback((nextWidgets) => {
318
+ const baseSchema = editSchema || dashboard;
319
+ if (!baseSchema)
320
+ return;
321
+ const newSchema = {
322
+ ...baseSchema,
323
+ widgets: nextWidgets,
324
+ };
325
+ setEditSchema(newSchema);
326
+ saveSchema(newSchema);
327
+ }, [editSchema, dashboard, saveSchema]);
282
328
  // ---- Dashboard config panel handlers ------------------------------------
283
329
  // Stabilize config reference: only recompute after explicit actions (panel
284
330
  // open, save, widget add). configVersion is incremented on those actions.
@@ -301,6 +347,7 @@ export function DashboardView({ dataSource }) {
301
347
  rowHeight: toNum(config.rowHeight, editSchema.rowHeight),
302
348
  refreshInterval: toNum(config.refreshInterval, 0) ?? 0,
303
349
  title: config.title,
350
+ description: config.description,
304
351
  showDescription: config.showDescription,
305
352
  theme: config.theme,
306
353
  };
@@ -391,6 +438,34 @@ export function DashboardView({ dataSource }) {
391
438
  label: f.label || key,
392
439
  }));
393
440
  }, [selectedWidget?.object, metadataObjects]);
441
+ // ---- Runtime capability gate (must run before guards to respect Rules of Hooks)
442
+ // Hide widgets whose `requiresObject` is not registered (mirrors
443
+ // NavigationItem.requiresObject for nav entries). Defaults to widget.object
444
+ // when not set, so any object-bound widget disappears gracefully when its
445
+ // backing object isn't in this runtime (e.g. cloud-only
446
+ // `sys_package_installation` on system_overview).
447
+ const registeredObjectNamesForFilter = useMemo(() => new Set((metadataObjects || []).map((o) => o?.name).filter(Boolean)), [metadataObjects]);
448
+ const previewSchemaSrc = editSchema || dashboard;
449
+ const previewSchema = useMemo(() => {
450
+ if (!previewSchemaSrc)
451
+ return previewSchemaSrc;
452
+ // Defer pruning until metadata has actually loaded — otherwise the
453
+ // empty Set would hide every object-bound widget on first render.
454
+ if (registeredObjectNamesForFilter.size === 0)
455
+ return previewSchemaSrc;
456
+ const widgets = previewSchemaSrc.widgets;
457
+ if (!Array.isArray(widgets) || widgets.length === 0)
458
+ return previewSchemaSrc;
459
+ const filtered = widgets.filter((w) => {
460
+ const required = w?.requiresObject ?? w?.object;
461
+ if (!required)
462
+ return true;
463
+ return registeredObjectNamesForFilter.has(required);
464
+ });
465
+ if (filtered.length === widgets.length)
466
+ return previewSchemaSrc;
467
+ return { ...previewSchemaSrc, widgets: filtered };
468
+ }, [previewSchemaSrc, registeredObjectNamesForFilter]);
394
469
  // ---- Loading / not-found guards -----------------------------------------
395
470
  if (isLoading) {
396
471
  return _jsx(SkeletonDashboard, {});
@@ -398,14 +473,29 @@ export function DashboardView({ dataSource }) {
398
473
  if (!dashboard) {
399
474
  return (_jsx("div", { className: "h-full flex items-center justify-center p-8", children: _jsxs(Empty, { children: [_jsx("div", { className: "mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted", children: _jsx(LayoutDashboard, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('empty.dashboardNotFound') }), _jsx(EmptyDescription, { children: t('empty.dashboardNotFoundDescription', { name: dashboardName }) })] }) }));
400
475
  }
401
- const previewSchema = editSchema || dashboard;
402
- return (_jsxs("div", { className: "flex flex-col h-full overflow-hidden bg-background", children: [_jsxs("div", { className: "flex flex-col sm:flex-row justify-between sm:items-center gap-3 sm:gap-4 p-4 sm:p-6 border-b shrink-0", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h1", { className: "text-lg sm:text-xl md:text-2xl font-bold tracking-tight truncate", children: dashboardLabel({ name: dashboard.name, label: resolveI18nLabel(dashboard.label, t) }) || dashboard.name }), (() => {
476
+ return (_jsxs("div", { className: "flex flex-col h-full overflow-hidden bg-background", children: [_jsxs("div", { className: "flex flex-col sm:flex-row justify-between sm:items-center gap-3 sm:gap-4 p-4 sm:p-6 border-b shrink-0", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [(() => {
477
+ // Per @objectstack/spec, DashboardSchema.title is "the dashboard
478
+ // title displayed in the header". We prefer it when present so
479
+ // edits made through the config panel (which writes `title`) are
480
+ // visible after save/reload. Falls back to `label` (the metadata
481
+ // display name) and finally to the raw `name`. We also follow
482
+ // `previewSchema` instead of the cached `dashboard` so the H1
483
+ // updates live while the user is typing in the config panel.
484
+ const headerSrc = previewSchema || dashboard;
485
+ const resolvedTitle = resolveI18nLabel(headerSrc.title, t);
486
+ const resolvedLabel = resolveI18nLabel(dashboard.label, t);
487
+ const fallbackLabel = dashboardLabel({ name: dashboard.name, label: resolvedLabel });
488
+ const display = resolvedTitle || fallbackLabel || dashboard.name;
489
+ return (_jsx("h1", { className: "text-lg sm:text-xl md:text-2xl font-bold tracking-tight truncate", children: display }));
490
+ })(), (() => {
491
+ const headerSrc = previewSchema || dashboard;
492
+ const rawDesc = headerSrc.description ?? dashboard.description;
403
493
  const desc = dashboardDescription({
404
494
  name: dashboard.name,
405
- description: resolveI18nLabel(dashboard.description, t),
495
+ description: resolveI18nLabel(rawDesc, t),
406
496
  });
407
497
  return desc ? (_jsx("p", { className: "text-sm text-muted-foreground mt-1 line-clamp-2", children: desc })) : null;
408
- })()] }), _jsxs("div", { className: "shrink-0 flex items-center gap-1.5", children: [configPanelOpen && (_jsx("div", { className: "flex items-center gap-1 mr-2", role: "toolbar", "aria-label": "Add widgets", "data-testid": "dashboard-widget-toolbar", children: WIDGET_TYPES.map(({ type, label, Icon }) => (_jsxs("button", { type: "button", "data-testid": `dashboard-add-${type}`, onClick: () => addWidget(type), className: "inline-flex items-center gap-1 rounded-md border border-input bg-background px-2 py-1.5 text-[11px] font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", title: `Add ${label}`, "aria-label": `Add ${label} widget`, children: [_jsx(Plus, { className: "h-3 w-3" }), _jsx(Icon, { className: "h-3 w-3" })] }, type))) })), _jsxs("button", { type: "button", onClick: handleOpenConfigPanel, className: "inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground shadow-sm hover:bg-accent hover:text-accent-foreground", "data-testid": "dashboard-edit-button", children: [_jsx(Pencil, { className: "h-3.5 w-3.5" }), t('common.edit', { defaultValue: 'Edit' })] })] })] }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-col sm:flex-row relative", children: [_jsx("div", { className: "flex-1 min-w-0 overflow-auto p-2 sm:p-4 md:p-6", children: _jsx(DashboardRenderer, { schema: previewSchema, dataSource: dataSource, designMode: configPanelOpen, selectedWidgetId: selectedWidgetId, onWidgetClick: setSelectedWidgetId, modalHandler: modalHandler, scriptHandlers: scriptHandlers }) }), selectedWidget ? (_jsx(WidgetConfigPanel, { open: configPanelOpen, onClose: handleCloseConfigPanel, config: widgetConfig, onSave: handleWidgetConfigSave, onFieldChange: handleWidgetFieldChange, availableObjects: availableObjects, availableFields: availableFields, headerExtra: _jsx(Button, { size: "sm", variant: "ghost", onClick: () => removeWidget(selectedWidgetId), className: "h-7 w-7 p-0 text-destructive hover:text-destructive", "data-testid": "widget-delete-button", title: "Delete widget", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }) }, selectedWidgetId)) : (_jsx(DashboardConfigPanel, { open: configPanelOpen, onClose: handleCloseConfigPanel, config: dashboardConfig, onSave: handleDashboardConfigSave, onFieldChange: handleDashboardFieldChange })), _jsx(MetadataPanel, { open: showDebug, sections: [{ title: 'Dashboard Configuration', data: previewSchema }] })] }), modalState && modalState.schema?.objectName ? (_jsx(ModalForm, { schema: {
498
+ })()] }), _jsxs("div", { className: "shrink-0 flex items-center gap-1.5", children: [configPanelOpen && (_jsx("div", { className: "flex items-center gap-1 mr-2", role: "toolbar", "aria-label": "Add widgets", "data-testid": "dashboard-widget-toolbar", children: WIDGET_TYPES.map(({ type, label, Icon }) => (_jsxs("button", { type: "button", "data-testid": `dashboard-add-${type}`, onClick: () => addWidget(type), className: "inline-flex items-center gap-1 rounded-md border border-input bg-background px-2 py-1.5 text-[11px] font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", title: `Add ${label}`, "aria-label": `Add ${label} widget`, children: [_jsx(Plus, { className: "h-3 w-3" }), _jsx(Icon, { className: "h-3 w-3" })] }, type))) })), _jsxs("button", { type: "button", onClick: handleOpenConfigPanel, className: "inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground shadow-sm hover:bg-accent hover:text-accent-foreground", "data-testid": "dashboard-edit-button", children: [_jsx(Pencil, { className: "h-3.5 w-3.5" }), t('common.edit', { defaultValue: 'Edit' })] })] })] }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-col sm:flex-row relative", children: [_jsx("div", { className: "flex-1 min-w-0 overflow-auto p-2 sm:p-4 md:p-6", children: _jsx(DashboardRenderer, { schema: previewSchema, dataSource: dataSource, designMode: configPanelOpen, selectedWidgetId: selectedWidgetId, onWidgetClick: setSelectedWidgetId, onWidgetsReorder: handleWidgetsReorder, modalHandler: modalHandler, scriptHandlers: scriptHandlers, hideHeaderText: true }) }), selectedWidget ? (_jsx(WidgetConfigPanel, { open: configPanelOpen, onClose: handleCloseConfigPanel, config: widgetConfig, onSave: handleWidgetConfigSave, onFieldChange: handleWidgetFieldChange, availableObjects: availableObjects, availableFields: availableFields, headerExtra: _jsx(Button, { size: "sm", variant: "ghost", onClick: () => removeWidget(selectedWidgetId), className: "h-7 w-7 p-0 text-destructive hover:text-destructive", "data-testid": "widget-delete-button", title: "Delete widget", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }) }, selectedWidgetId)) : (_jsx(DashboardConfigPanel, { open: configPanelOpen, onClose: handleCloseConfigPanel, config: dashboardConfig, onSave: handleDashboardConfigSave, onFieldChange: handleDashboardFieldChange })), _jsx(MetadataPanel, { open: showDebug, sections: [{ title: 'Dashboard Configuration', data: previewSchema }] })] }), modalState && modalState.schema?.objectName ? (_jsx(ModalForm, { schema: {
409
499
  type: 'object-form',
410
500
  formType: 'modal',
411
501
  objectName: modalState.schema.objectName,
@@ -47,6 +47,112 @@ const VIEW_TYPE_ICONS = {
47
47
  chart: BarChart3,
48
48
  };
49
49
  const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
50
+ /**
51
+ * Translate a NamedListView spec object (shape: `type`, `label`, `columns`,
52
+ * `filter`, `sort`, `kanban`/`chart`/`gantt`/... sub-blocks, `bulkActions`,
53
+ * `rowActions`, `pagination`, ...) into the `sys_view` storage shape that the
54
+ * server's `sys_view` object schema actually exposes (snake_case scalars +
55
+ * `*_json` text columns for complex shapes).
56
+ *
57
+ * The server rejects any other column name (`bulkActions`, `objectName`,
58
+ * `isPinned`, ...) with `table sys_view has no column named …`, and knex
59
+ * flattens raw arrays into positional bindings which mangles the SQL — so
60
+ * every write path that targets `sys_view` MUST go through this helper.
61
+ */
62
+ function toSysViewPayload(config, objectName, opts = {}) {
63
+ const VIEW_TYPE_KEYS = [
64
+ 'kanban', 'calendar', 'timeline', 'gantt',
65
+ 'gallery', 'map', 'chart', 'grid',
66
+ ];
67
+ // Fold view-type-specific sub-blocks plus any non-storage NamedListView
68
+ // fields into a single `config_json` blob so the round-trip preserves
69
+ // everything the renderer might need (bulkActions, rowActions, pagination,
70
+ // navigation, emptyState, exportOptions, rowHeight, isPinned, isDefault,
71
+ // visibility, sortOrder, …).
72
+ const subConfig = {};
73
+ for (const k of VIEW_TYPE_KEYS) {
74
+ if (config[k] && typeof config[k] === 'object') {
75
+ Object.assign(subConfig, config[k]);
76
+ }
77
+ }
78
+ const EXTRA_CONFIG_KEYS = [
79
+ 'bulkActions', 'rowActions', 'pagination', 'navigation',
80
+ 'emptyState', 'exportOptions', 'rowHeight',
81
+ 'isPinned', 'isDefault', 'visibility', 'sortOrder',
82
+ 'showSort',
83
+ ];
84
+ for (const k of EXTRA_CONFIG_KEYS) {
85
+ if (config[k] !== undefined)
86
+ subConfig[k] = config[k];
87
+ }
88
+ const incomingColumns = Array.isArray(config.columns) && config.columns.length > 0
89
+ ? config.columns
90
+ : (opts.defaultColumns ?? []);
91
+ const viewType = config.type || 'grid';
92
+ const baseLabel = config.label || config.name || 'Untitled View';
93
+ const slug = baseLabel
94
+ .toLowerCase()
95
+ .replace(/[^a-z0-9]+/g, '_')
96
+ .replace(/^_+|_+$/g, '')
97
+ .slice(0, 60) || 'view';
98
+ return {
99
+ name: `${slug}_${Date.now().toString(36)}`,
100
+ label: baseLabel,
101
+ object_name: objectName,
102
+ view_type: viewType,
103
+ columns_json: JSON.stringify(incomingColumns),
104
+ filters_json: config.filter ? JSON.stringify(config.filter) : null,
105
+ sort_json: config.sort ? JSON.stringify(config.sort) : null,
106
+ config_json: Object.keys(subConfig).length > 0
107
+ ? JSON.stringify(subConfig)
108
+ : null,
109
+ page_size: config.pageSize ?? 25,
110
+ show_search: config.showSearch !== false,
111
+ show_filters: config.showFilters !== false,
112
+ managed_by: 'user',
113
+ };
114
+ }
115
+ /**
116
+ * Inverse of {@link toSysViewPayload}. Decodes a raw `sys_view` record
117
+ * (snake_case fields + `*_json` text columns) back into the NamedListView
118
+ * spec shape (`type`, `columns`, `filter`, `sort`, plus everything stashed
119
+ * inside `config_json`) so the rest of the UI — ViewTabBar, ListView, the
120
+ * grid renderer — can consume it without caring about the storage layer.
121
+ *
122
+ * Robust to fixtures/mocks that already store the spec shape directly
123
+ * (older tests, the in-memory dev adapter): if `*_json` is missing we fall
124
+ * back to `sv.columns` / `sv.filter` / `sv.sort` as-is.
125
+ */
126
+ function fromSysViewRecord(sv) {
127
+ const parse = (raw) => {
128
+ if (raw == null)
129
+ return undefined;
130
+ if (typeof raw !== 'string')
131
+ return raw;
132
+ try {
133
+ return JSON.parse(raw);
134
+ }
135
+ catch {
136
+ return undefined;
137
+ }
138
+ };
139
+ const columns = parse(sv.columns_json) ?? sv.columns;
140
+ const filter = parse(sv.filters_json) ?? sv.filter;
141
+ const sort = parse(sv.sort_json) ?? sv.sort;
142
+ const extra = parse(sv.config_json) ?? {};
143
+ return {
144
+ ...sv,
145
+ ...extra,
146
+ type: sv.view_type ?? sv.type ?? 'grid',
147
+ objectName: sv.object_name ?? sv.objectName,
148
+ columns,
149
+ filter,
150
+ sort,
151
+ showSearch: sv.show_search ?? sv.showSearch,
152
+ showFilters: sv.show_filters ?? sv.showFilters,
153
+ pageSize: sv.page_size ?? sv.pageSize,
154
+ };
155
+ }
50
156
  /**
51
157
  * DrawerDetailContent — extracted component for NavigationOverlay content.
52
158
  * Needs to be a proper component (not a render prop) so it can use hooks
@@ -289,46 +395,35 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
289
395
  const incomingColumns = Array.isArray(config.columns) && config.columns.length > 0
290
396
  ? config.columns
291
397
  : defaultColumns;
292
- // Translate NamedListView spec shape (type, label, kanban, chart,
293
- // gantt, etc., columns, filter, sort) into the sys_view storage
294
- // shape (view_type, label, object_name, *_json columns).
295
- // Per spec: front-end follows the protocol, the persistence
296
- // boundary owns the mapping to physical columns.
297
- const VIEW_TYPE_KEYS = [
298
- 'kanban', 'calendar', 'timeline', 'gantt',
299
- 'gallery', 'map', 'chart', 'grid',
300
- ];
301
- const subConfig = {};
302
- for (const k of VIEW_TYPE_KEYS) {
303
- if (config[k] && typeof config[k] === 'object') {
304
- Object.assign(subConfig, config[k]);
398
+ // ADR-0005 overlay path write the full spec under a unique
399
+ // `name` via the metadata customization API instead of into
400
+ // the physical `sys_view` table (whose columns no longer
401
+ // accommodate the spec shape: arrays, nested objects, etc.).
402
+ const spec = { ...config, columns: incomingColumns };
403
+ // Per @objectstack/spec, certain view types nest their card/field
404
+ // list inside their type-specific subconfig (e.g. kanban.columns,
405
+ // gallery.visibleFields). The CreateViewDialog only collects
406
+ // required *picker* fields; we mirror the resolved column list
407
+ // into the subconfig here so the spec validator accepts the row.
408
+ if (config.type === 'kanban') {
409
+ spec.kanban = { ...(spec.kanban || {}), columns: incomingColumns };
410
+ }
411
+ else if (config.type === 'gallery') {
412
+ const existing = spec.gallery || {};
413
+ if (!Array.isArray(existing.visibleFields) || existing.visibleFields.length === 0) {
414
+ spec.gallery = { ...existing, visibleFields: incomingColumns };
305
415
  }
306
416
  }
307
- const viewType = config.type || 'grid';
308
- const baseLabel = config.label || config.name || 'Untitled View';
309
- const slug = baseLabel
310
- .toLowerCase()
311
- .replace(/[^a-z0-9]+/g, '_')
312
- .replace(/^_+|_+$/g, '')
313
- .slice(0, 60) || 'view';
314
- const payload = {
315
- name: `${slug}_${Date.now().toString(36)}`,
316
- label: baseLabel,
317
- object_name: objectName,
318
- view_type: viewType,
319
- columns_json: JSON.stringify(incomingColumns),
320
- filters_json: config.filter ? JSON.stringify(config.filter) : null,
321
- sort_json: config.sort ? JSON.stringify(config.sort) : null,
322
- config_json: Object.keys(subConfig).length > 0
323
- ? JSON.stringify(subConfig)
324
- : null,
325
- page_size: config.pageSize ?? 25,
326
- show_search: config.showSearch !== false,
327
- show_filters: config.showFilters !== false,
328
- managed_by: 'user',
329
- };
330
- const created = await dataSource.create('sys_view', payload);
331
- createdId = created?.id ?? created?._id;
417
+ if (typeof dataSource?.createView === 'function') {
418
+ const created = await dataSource.createView(objectName, spec);
419
+ createdId = created?.name || config?.name;
420
+ }
421
+ else if (dataSource?.create) {
422
+ // Legacy fallback for adapters that don't expose createView.
423
+ const payload = toSysViewPayload(spec, objectName, { defaultColumns });
424
+ const created = await dataSource.create('sys_view', payload);
425
+ createdId = created?.id ?? created?._id;
426
+ }
332
427
  }
333
428
  setShowViewConfigPanel(false);
334
429
  setViewConfigPanelMode('edit');
@@ -378,13 +473,47 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
378
473
  const [savedViews, setSavedViews] = useState([]);
379
474
  useEffect(() => {
380
475
  let cancelled = false;
381
- if (!dataSource?.find || !objectName) {
476
+ if (!objectName) {
477
+ setSavedViews([]);
478
+ return;
479
+ }
480
+ // ADR-0005: use the metadata overlay API instead of writing to
481
+ // the physical `sys_view` table (which has incompatible columns).
482
+ // Falls back to the legacy `find('sys_view', ...)` path only when
483
+ // the adapter doesn't expose `listViews` (e.g. test mocks).
484
+ if (typeof dataSource?.listViews === 'function') {
485
+ dataSource.listViews(objectName)
486
+ .then((rows) => {
487
+ if (cancelled)
488
+ return;
489
+ // Normalize: ensure each view has an `id` for ViewTabBar
490
+ // (which is name-keyed downstream). Stamp `objectName`
491
+ // so the defensive filter in handlers still works.
492
+ const normalized = (rows || []).map((sv) => ({
493
+ ...sv,
494
+ // Overlay rows are keyed by `name`. Prefer that as the
495
+ // tab id so a duplicate's `id` field (which may have
496
+ // been copied verbatim from the source artifact) does
497
+ // not collide with the source's view id during dedup.
498
+ id: sv.name || sv.id,
499
+ objectName: sv.objectName || sv.object || objectName,
500
+ }));
501
+ setSavedViews(normalized);
502
+ })
503
+ .catch((err) => {
504
+ console.error('[ObjectView] Failed to load overlay views:', err);
505
+ if (!cancelled)
506
+ setSavedViews([]);
507
+ });
508
+ return () => { cancelled = true; };
509
+ }
510
+ if (!dataSource?.find) {
382
511
  setSavedViews([]);
383
512
  return;
384
513
  }
385
514
  dataSource
386
515
  .find('sys_view', {
387
- $filter: ['objectName', '=', objectName],
516
+ $filter: ['object_name', '=', objectName],
388
517
  $orderby: [{ field: 'created_at', order: 'asc' }],
389
518
  $top: 200,
390
519
  })
@@ -400,8 +529,12 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
400
529
  // Defensive client-side filter: only keep rows that look like
401
530
  // sys_view records for *this* object. Adapters that don't
402
531
  // honour $filter (or test mocks that ignore it) won't pollute
403
- // the view list with arbitrary records.
404
- const filtered = rows.filter(r => r && r.objectName === objectName);
532
+ // the view list with arbitrary records. Match either casing —
533
+ // the storage column is `object_name`, but older mock fixtures
534
+ // and tests may still emit `objectName`.
535
+ const filtered = rows
536
+ .filter(r => r && (r.object_name === objectName || r.objectName === objectName))
537
+ .map(fromSysViewRecord);
405
538
  setSavedViews(filtered);
406
539
  })
407
540
  .catch((err) => {
@@ -666,21 +799,25 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
666
799
  return savedViews.some((sv) => (sv.id || sv._id) === vid);
667
800
  }, [savedViews]);
668
801
  const handleRenameView = useCallback(async (vid, newName) => {
669
- if (!dataSource?.update)
670
- return;
671
802
  if (!isSavedView(vid)) {
672
803
  toast.error(t('console.objectView.cannotEditMetaView') || 'Built-in views cannot be renamed.');
673
804
  return;
674
805
  }
675
806
  try {
676
- await dataSource.update('sys_view', vid, { label: newName });
807
+ // ADR-0005 overlay path — `vid` is the view's `name` field
808
+ if (typeof dataSource?.updateView === 'function') {
809
+ await dataSource.updateView(objectName, vid, { label: newName });
810
+ }
811
+ else if (dataSource?.update) {
812
+ await dataSource.update('sys_view', vid, { label: newName });
813
+ }
677
814
  setRefreshKey(k => k + 1);
678
815
  }
679
816
  catch (err) {
680
817
  console.error('[ViewTabBar] Failed to rename view:', err);
681
818
  toast.error(t('objectViewActions.renameFailed'));
682
819
  }
683
- }, [dataSource, isSavedView, t]);
820
+ }, [dataSource, objectName, isSavedView, t]);
684
821
  // Promise-based confirm/param dialogs — declared early so destructive
685
822
  // handlers (delete, etc.) can `await confirmHandler(...)` for a proper
686
823
  // Airtable-style confirmation flow.
@@ -697,7 +834,7 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
697
834
  });
698
835
  }, []);
699
836
  const handleDeleteView = useCallback(async (vid) => {
700
- if (!dataSource?.delete)
837
+ if (!dataSource)
701
838
  return;
702
839
  if (!isSavedView(vid)) {
703
840
  toast.error(t('console.objectView.cannotDeleteMetaView') || 'Built-in views cannot be deleted.');
@@ -714,7 +851,12 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
714
851
  if (!confirmed)
715
852
  return;
716
853
  try {
717
- await dataSource.delete('sys_view', vid);
854
+ if (typeof dataSource?.deleteView === 'function') {
855
+ await dataSource.deleteView(objectName, vid);
856
+ }
857
+ else if (dataSource?.delete) {
858
+ await dataSource.delete('sys_view', vid);
859
+ }
718
860
  // If we deleted the active view, fall back to the first remaining view.
719
861
  if (vid === activeViewId) {
720
862
  const fallback = views.find((v) => v.id !== vid);
@@ -729,30 +871,50 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
729
871
  }
730
872
  }, [dataSource, isSavedView, activeViewId, views, viewId, navigate, t, confirmHandler]);
731
873
  const handleDuplicateView = useCallback(async (vid) => {
732
- if (!dataSource?.create)
874
+ if (!dataSource)
733
875
  return;
734
876
  const source = views.find((v) => v.id === vid);
735
877
  if (!source)
736
878
  return;
737
879
  try {
738
- const { id: _omit, created_at, updated_at, ...rest } = source;
739
- const newId = `view_${Date.now()}`;
740
- const payload = {
880
+ // ADR-0005 overlay path store the full NamedListView spec
881
+ // shape directly under a unique `name`. No legacy sys_view
882
+ // column-flattening required.
883
+ const baseName = String(source.name || vid).toLowerCase()
884
+ .replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '') || 'view';
885
+ const newName = `${baseName}_copy_${Date.now().toString(36)}`;
886
+ // Drop `id` so the overlay row is keyed purely by `name`; copying
887
+ // the source's `id` would collide with the source view in the
888
+ // tab-bar dedup logic and silently shadow it.
889
+ const { id: _omitId, ...rest } = source;
890
+ const spec = {
741
891
  ...rest,
742
- objectName,
743
- id: newId,
892
+ name: newName,
744
893
  label: `${source.label || vid} (Copy)`,
745
894
  isDefault: false,
746
895
  isPinned: false,
896
+ data: source.data || { provider: 'object', object: objectName },
897
+ object: source.object || objectName,
747
898
  };
748
- await dataSource.create('sys_view', payload);
899
+ let newId;
900
+ if (typeof dataSource?.createView === 'function') {
901
+ const created = await dataSource.createView(objectName, spec);
902
+ newId = created?.name || newName;
903
+ }
904
+ else if (dataSource?.create) {
905
+ const payload = toSysViewPayload(spec, objectName);
906
+ const created = await dataSource.create('sys_view', payload);
907
+ newId = created?.id ?? created?._id;
908
+ }
749
909
  setRefreshKey(k => k + 1);
750
910
  // Auto-activate the duplicate (Airtable parity).
751
- if (viewId) {
752
- navigate(`../${newId}`, { relative: 'path' });
753
- }
754
- else {
755
- navigate(`view/${newId}`);
911
+ if (newId) {
912
+ if (viewId) {
913
+ navigate(`../${newId}`, { relative: 'path' });
914
+ }
915
+ else {
916
+ navigate(`view/${newId}`);
917
+ }
756
918
  }
757
919
  }
758
920
  catch (err) {
@@ -761,22 +923,27 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
761
923
  }
762
924
  }, [dataSource, views, objectName, navigate, viewId]);
763
925
  const handlePinView = useCallback(async (vid, pinned) => {
764
- if (!dataSource?.update)
926
+ if (!dataSource)
765
927
  return;
766
928
  if (!isSavedView(vid)) {
767
929
  toast.error(t('console.objectView.cannotEditMetaView') || 'Built-in views cannot be pinned.');
768
930
  return;
769
931
  }
770
932
  try {
771
- await dataSource.update('sys_view', vid, { isPinned: pinned });
933
+ if (typeof dataSource?.updateView === 'function') {
934
+ await dataSource.updateView(objectName, vid, { isPinned: pinned });
935
+ }
936
+ else if (dataSource?.update) {
937
+ await dataSource.update('sys_view', vid, { isPinned: pinned });
938
+ }
772
939
  setRefreshKey(k => k + 1);
773
940
  }
774
941
  catch (err) {
775
942
  console.error('[ViewTabBar] Failed to pin view:', err);
776
943
  }
777
- }, [dataSource, isSavedView, t]);
944
+ }, [dataSource, objectName, isSavedView, t]);
778
945
  const handleSetDefaultView = useCallback(async (vid) => {
779
- if (!dataSource?.update)
946
+ if (!dataSource)
780
947
  return;
781
948
  if (!isSavedView(vid)) {
782
949
  toast.error(t('console.objectView.cannotEditMetaView')
@@ -785,10 +952,18 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
785
952
  }
786
953
  try {
787
954
  // Clear `isDefault` on all other saved views, then set this one.
955
+ const hasOverlay = typeof dataSource?.updateView === 'function';
788
956
  const updates = savedViews
789
957
  .filter((sv) => (sv.id || sv._id) !== vid && sv.isDefault)
790
- .map((sv) => dataSource.update('sys_view', sv.id || sv._id, { isDefault: false }));
791
- updates.push(dataSource.update('sys_view', vid, { isDefault: true }));
958
+ .map((sv) => {
959
+ const id = sv.id || sv._id;
960
+ return hasOverlay
961
+ ? dataSource.updateView(objectName, id, { isDefault: false })
962
+ : dataSource.update('sys_view', id, { isDefault: false });
963
+ });
964
+ updates.push(hasOverlay
965
+ ? dataSource.updateView(objectName, vid, { isDefault: true })
966
+ : dataSource.update('sys_view', vid, { isDefault: true }));
792
967
  await Promise.all(updates);
793
968
  setRefreshKey(k => k + 1);
794
969
  }
@@ -796,7 +971,7 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
796
971
  console.error('[ViewTabBar] Failed to set default view:', err);
797
972
  toast.error('Failed to set default view');
798
973
  }
799
- }, [dataSource, savedViews, isSavedView, t]);
974
+ }, [dataSource, objectName, savedViews, isSavedView, t]);
800
975
  const handleReorderViews = useCallback(async (orderedIds) => {
801
976
  // Persist order for ALL views (incl. metadata) in localStorage so the
802
977
  // UI immediately reflects the new ordering, including reorderings
@@ -809,11 +984,15 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
809
984
  catch { /* ignore */ }
810
985
  // Best-effort: also persist `sortOrder` on each saved view so other
811
986
  // sessions / users can pick up the order from the backend.
812
- if (dataSource?.update) {
987
+ if (dataSource) {
988
+ const hasOverlay = typeof dataSource?.updateView === 'function';
813
989
  const savedIdSet = new Set(savedViews.map((sv) => sv.id || sv._id));
814
990
  const updates = orderedIds
815
991
  .filter(id => savedIdSet.has(id))
816
- .map((id, idx) => dataSource.update('sys_view', id, { sortOrder: idx }));
992
+ .map((id, idx) => hasOverlay
993
+ ? dataSource.updateView(objectName, id, { sortOrder: idx })
994
+ : dataSource.update?.('sys_view', id, { sortOrder: idx }))
995
+ .filter(Boolean);
817
996
  try {
818
997
  await Promise.all(updates);
819
998
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/app-shell",
3
- "version": "4.0.9",
3
+ "version": "4.0.11",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Minimal application shell for ObjectUI - framework-agnostic rendering engine",
@@ -27,34 +27,34 @@
27
27
  "dependencies": {
28
28
  "lucide-react": "^1.14.0",
29
29
  "sonner": "^2.0.7",
30
- "@object-ui/auth": "4.0.9",
31
- "@object-ui/collaboration": "4.0.9",
32
- "@object-ui/core": "4.0.9",
33
- "@object-ui/data-objectstack": "4.0.9",
34
- "@object-ui/fields": "4.0.9",
35
- "@object-ui/i18n": "4.0.9",
36
- "@object-ui/layout": "4.0.9",
37
- "@object-ui/permissions": "4.0.9",
38
- "@object-ui/react": "4.0.9",
39
- "@object-ui/types": "4.0.9",
40
- "@object-ui/components": "4.0.9"
30
+ "@object-ui/auth": "4.0.11",
31
+ "@object-ui/collaboration": "4.0.11",
32
+ "@object-ui/components": "4.0.11",
33
+ "@object-ui/core": "4.0.11",
34
+ "@object-ui/data-objectstack": "4.0.11",
35
+ "@object-ui/fields": "4.0.11",
36
+ "@object-ui/i18n": "4.0.11",
37
+ "@object-ui/layout": "4.0.11",
38
+ "@object-ui/permissions": "4.0.11",
39
+ "@object-ui/react": "4.0.11",
40
+ "@object-ui/types": "4.0.11"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "react": "^18.0.0 || ^19.0.0",
44
44
  "react-dom": "^18.0.0 || ^19.0.0",
45
45
  "react-router-dom": "^6.0.0 || ^7.0.0",
46
- "@object-ui/plugin-calendar": "4.0.9",
47
- "@object-ui/plugin-charts": "4.0.9",
48
- "@object-ui/plugin-chatbot": "4.0.9",
49
- "@object-ui/plugin-dashboard": "4.0.9",
50
- "@object-ui/plugin-designer": "4.0.9",
51
- "@object-ui/plugin-detail": "4.0.9",
52
- "@object-ui/plugin-form": "4.0.9",
53
- "@object-ui/plugin-grid": "4.0.9",
54
- "@object-ui/plugin-kanban": "4.0.9",
55
- "@object-ui/plugin-list": "4.0.9",
56
- "@object-ui/plugin-report": "4.0.9",
57
- "@object-ui/plugin-view": "4.0.9"
46
+ "@object-ui/plugin-calendar": "4.0.11",
47
+ "@object-ui/plugin-charts": "4.0.11",
48
+ "@object-ui/plugin-chatbot": "4.0.11",
49
+ "@object-ui/plugin-dashboard": "4.0.11",
50
+ "@object-ui/plugin-designer": "4.0.11",
51
+ "@object-ui/plugin-detail": "4.0.11",
52
+ "@object-ui/plugin-form": "4.0.11",
53
+ "@object-ui/plugin-grid": "4.0.11",
54
+ "@object-ui/plugin-kanban": "4.0.11",
55
+ "@object-ui/plugin-list": "4.0.11",
56
+ "@object-ui/plugin-report": "4.0.11",
57
+ "@object-ui/plugin-view": "4.0.11"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^25.7.0",