@object-ui/app-shell 4.0.10 → 4.0.12

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.12
4
+
5
+ ### Patch Changes
6
+
7
+ - e468592: fix
8
+ - @object-ui/types@4.0.12
9
+ - @object-ui/core@4.0.12
10
+ - @object-ui/i18n@4.0.12
11
+ - @object-ui/react@4.0.12
12
+ - @object-ui/components@4.0.12
13
+ - @object-ui/fields@4.0.12
14
+ - @object-ui/layout@4.0.12
15
+ - @object-ui/data-objectstack@4.0.12
16
+ - @object-ui/auth@4.0.12
17
+ - @object-ui/permissions@4.0.12
18
+ - @object-ui/plugin-calendar@4.0.12
19
+ - @object-ui/plugin-charts@4.0.12
20
+ - @object-ui/plugin-chatbot@4.0.12
21
+ - @object-ui/plugin-dashboard@4.0.12
22
+ - @object-ui/plugin-designer@4.0.12
23
+ - @object-ui/plugin-detail@4.0.12
24
+ - @object-ui/plugin-form@4.0.12
25
+ - @object-ui/plugin-grid@4.0.12
26
+ - @object-ui/plugin-kanban@4.0.12
27
+ - @object-ui/plugin-list@4.0.12
28
+ - @object-ui/plugin-report@4.0.12
29
+ - @object-ui/plugin-view@4.0.12
30
+ - @object-ui/collaboration@4.0.12
31
+
32
+ ## 4.0.11
33
+
34
+ ### Patch Changes
35
+
36
+ - 7ea1d93: dashboard
37
+ - Updated dependencies [1909bc3]
38
+ - @object-ui/i18n@4.0.11
39
+ - @object-ui/components@4.0.11
40
+ - @object-ui/fields@4.0.11
41
+ - @object-ui/plugin-calendar@4.0.11
42
+ - @object-ui/plugin-charts@4.0.11
43
+ - @object-ui/plugin-dashboard@4.0.11
44
+ - @object-ui/plugin-designer@4.0.11
45
+ - @object-ui/plugin-kanban@4.0.11
46
+ - @object-ui/plugin-list@4.0.11
47
+ - @object-ui/react@4.0.11
48
+ - @object-ui/layout@4.0.11
49
+ - @object-ui/plugin-chatbot@4.0.11
50
+ - @object-ui/plugin-detail@4.0.11
51
+ - @object-ui/plugin-form@4.0.11
52
+ - @object-ui/plugin-grid@4.0.11
53
+ - @object-ui/plugin-report@4.0.11
54
+ - @object-ui/plugin-view@4.0.11
55
+ - @object-ui/types@4.0.11
56
+ - @object-ui/core@4.0.11
57
+ - @object-ui/data-objectstack@4.0.11
58
+ - @object-ui/auth@4.0.11
59
+ - @object-ui/permissions@4.0.11
60
+ - @object-ui/collaboration@4.0.11
61
+
3
62
  ## 4.0.10
4
63
 
5
64
  ### Patch Changes
@@ -26,5 +26,5 @@ export function HomeLayout({ children }) {
26
26
  useEffect(() => {
27
27
  setContext('home');
28
28
  }, [setContext]);
29
- return (_jsxs("div", { className: "flex min-h-svh w-full flex-col bg-background", "data-testid": "home-layout", children: [_jsx("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4", children: _jsx(AppHeader, { variant: "home" }) }), _jsx("main", { className: "flex-1 min-w-0 overflow-auto", children: children }), showChatbot && (_jsx(Suspense, { fallback: null, children: _jsx(ConsoleFloatingChatbot, { appLabel: "Workspace", objects: [] }) }))] }));
29
+ return (_jsxs("div", { className: "flex min-h-svh w-full flex-col bg-background", "data-testid": "home-layout", children: [_jsx("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4", children: _jsx(AppHeader, { variant: "home" }) }), _jsx("main", { className: "flex-1 min-w-0 overflow-auto pb-20 sm:pb-0", children: children }), showChatbot && (_jsx(Suspense, { fallback: null, children: _jsx(ConsoleFloatingChatbot, { appLabel: "Workspace", objects: [] }) }))] }));
30
30
  }
@@ -48,7 +48,9 @@ export function HomePage() {
48
48
  }
49
49
  // Empty state - no apps configured
50
50
  if (activeApps.length === 0) {
51
- return (_jsx("div", { className: "flex flex-1 items-center justify-center p-6", children: _jsxs(Empty, { children: [_jsx(EmptyTitle, { children: "Welcome to ObjectUI" }), _jsx(EmptyDescription, { children: "Get started by creating your first application or configure your system settings." }), _jsxs("div", { className: "mt-6 flex flex-col sm:flex-row items-center gap-3", children: [_jsxs(Button, { onClick: () => navigate('/create-app'), "data-testid": "create-first-app-btn", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), "Create Your First App"] }), _jsxs(Button, { variant: "outline", onClick: () => navigate('/apps/setup'), "data-testid": "go-to-settings-btn", children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), "System Settings"] })] })] }) }));
51
+ return (_jsx("div", { className: "flex flex-1 items-center justify-center p-6", children: _jsxs(Empty, { children: [_jsx(EmptyTitle, { children: t('home.welcome', { defaultValue: 'Welcome to ObjectUI' }) }), _jsx(EmptyDescription, { children: t('home.welcomeDescription', {
52
+ defaultValue: 'Get started by creating your first application or configure your system settings.',
53
+ }) }), _jsxs("div", { className: "mt-6 flex flex-col sm:flex-row items-center gap-3", children: [_jsxs(Button, { onClick: () => navigate('/create-app'), "data-testid": "create-first-app-btn", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('home.createFirstApp', { defaultValue: 'Create Your First App' })] }), _jsxs(Button, { variant: "outline", onClick: () => navigate('/apps/setup'), "data-testid": "go-to-settings-btn", children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('home.systemSettings', { defaultValue: 'System Settings' })] })] })] }) }));
52
54
  }
53
55
  return (_jsxs("div", { className: "bg-background", children: [_jsxs("div", { className: "px-4 sm:px-6 pt-6 pb-4", children: [_jsx("h1", { className: "text-2xl sm:text-3xl font-bold tracking-tight", children: t('home.title', { defaultValue: 'Home' }) }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: t('home.subtitle', { defaultValue: 'Your workspace dashboard' }) })] }), _jsxs("div", { className: "px-4 sm:px-6 py-4 space-y-8", children: [_jsx(QuickActions, {}), starredApps.length > 0 && (_jsx(StarredApps, { items: starredApps })), recentApps.length > 0 && (_jsx(RecentApps, { items: recentApps })), _jsxs("section", { children: [_jsx("h2", { className: "text-2xl font-semibold tracking-tight mb-4", children: t('home.allApps', { defaultValue: 'All Applications' }) }), _jsx("div", { className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4", children: activeApps.map((app) => (_jsx(AppCard, { app: app, onClick: () => navigate(`/apps/${app.name}`), isFavorite: favorites.some(f => f.id === `app:${app.name}`) }, app.name))) })] })] })] }));
54
56
  }
@@ -19,7 +19,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
19
19
  */
20
20
  import { useLocation, useParams, Link, useNavigate } from 'react-router-dom';
21
21
  import { SidebarTrigger, Button, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuGroup, Avatar, AvatarImage, AvatarFallback, } from '@object-ui/components';
22
- import { Search, HelpCircle, ChevronDown, Settings, LogOut, User as UserIcon, Boxes, } from 'lucide-react';
22
+ import { Search, HelpCircle, ChevronDown, Settings, LogOut, User as UserIcon, Boxes, Bell, } from 'lucide-react';
23
23
  import { useState, useEffect, useCallback, useRef } from 'react';
24
24
  import { useOffline } from '@object-ui/react';
25
25
  import { PresenceAvatars } from '@object-ui/collaboration';
@@ -58,11 +58,14 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
58
58
  const { currentAppName, recordTitle } = useNavigationContext();
59
59
  const [apiPresenceUsers, setApiPresenceUsers] = useState(null);
60
60
  const [apiActivities, setApiActivities] = useState(null);
61
+ /** M10.8: in-header notifications. Polled from sys_notification scoped to current user. */
62
+ const [notifications, setNotifications] = useState([]);
61
63
  // Once the server returns 404 for these collections we stop retrying for
62
64
  // the lifetime of the page — they're optional features and re-requesting
63
65
  // on every navigation creates console noise + wasted round trips.
64
66
  const presenceUnavailableRef = useRef(false);
65
67
  const activityUnavailableRef = useRef(false);
68
+ const notificationsUnavailableRef = useRef(false);
66
69
  const fetchPresenceAndActivities = useCallback(async () => {
67
70
  if (!dataSource || !isApp)
68
71
  return;
@@ -103,6 +106,71 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
103
106
  catch { /* fallback below */ }
104
107
  }, [dataSource, isApp]);
105
108
  useEffect(() => { fetchPresenceAndActivities(); }, [fetchPresenceAndActivities]);
109
+ /**
110
+ * M10.8: poll sys_notification for the signed-in user. Limited to
111
+ * the 20 most-recent entries; unread count drives the bell badge.
112
+ * Polls every 30s while the tab is foregrounded. Tolerates 404 so
113
+ * older deployments without sys_notification degrade silently.
114
+ */
115
+ useEffect(() => {
116
+ if (!dataSource || !isApp || !user?.id)
117
+ return;
118
+ if (notificationsUnavailableRef.current)
119
+ return;
120
+ let cancelled = false;
121
+ const isMissingResource = (err) => err?.httpStatus === 404 || err?.status === 404 || err?.code === 'object_not_found';
122
+ const fetchOnce = async () => {
123
+ try {
124
+ const res = await dataSource.find('sys_notification', {
125
+ $filter: { recipient_id: user.id },
126
+ $orderby: { created_at: 'desc' },
127
+ $top: 20,
128
+ });
129
+ if (cancelled)
130
+ return;
131
+ if (Array.isArray(res?.data))
132
+ setNotifications(res.data);
133
+ }
134
+ catch (err) {
135
+ if (isMissingResource(err))
136
+ notificationsUnavailableRef.current = true;
137
+ }
138
+ };
139
+ fetchOnce();
140
+ const handle = setInterval(() => {
141
+ if (typeof document !== 'undefined' && document.hidden)
142
+ return;
143
+ if (notificationsUnavailableRef.current) {
144
+ clearInterval(handle);
145
+ return;
146
+ }
147
+ fetchOnce();
148
+ }, 30000);
149
+ return () => { cancelled = true; clearInterval(handle); };
150
+ }, [dataSource, isApp, user?.id]);
151
+ const unreadCount = notifications.reduce((n, x) => n + (x.is_read ? 0 : 1), 0);
152
+ const markNotificationRead = useCallback(async (id) => {
153
+ setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n));
154
+ if (!dataSource)
155
+ return;
156
+ try {
157
+ await dataSource.update('sys_notification', id, {
158
+ is_read: true,
159
+ read_at: new Date().toISOString(),
160
+ });
161
+ }
162
+ catch { /* best-effort */ }
163
+ }, [dataSource]);
164
+ const markAllRead = useCallback(async () => {
165
+ const unread = notifications.filter(n => !n.is_read);
166
+ if (!unread.length)
167
+ return;
168
+ setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
169
+ if (!dataSource)
170
+ return;
171
+ const now = new Date().toISOString();
172
+ await Promise.all(unread.map(n => dataSource.update('sys_notification', n.id, { is_read: true, read_at: now }).catch(() => { })));
173
+ }, [dataSource, notifications]);
106
174
  const activeUsers = presenceUsers ?? apiPresenceUsers ?? EMPTY_PRESENCE_USERS;
107
175
  const activeActivities = activities ?? apiActivities ?? [];
108
176
  const orgList = organizations ?? [];
@@ -203,5 +271,15 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
203
271
  return (_jsxs("div", { className: "flex items-center justify-between w-full h-full", children: [_jsxs("div", { className: "flex items-center min-w-0 flex-1", children: [_jsx(Link, { to: "/home", className: "flex items-center justify-center h-7 w-7 shrink-0 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors", title: "ObjectStack", children: _jsx(Boxes, { className: "h-4 w-4" }) }), resolvedVariant === 'home' && (_jsx("span", { className: "hidden sm:inline ml-2 text-sm font-semibold tracking-tight", children: "ObjectStack" })), resolvedVariant === 'orgs' && (_jsxs(_Fragment, { children: [_jsx(PathSep, {}), _jsx("span", { className: "text-sm font-medium text-foreground/80 px-1.5", children: t('organizations.title', { defaultValue: 'Organizations' }) })] })), isApp && (_jsxs(_Fragment, { children: [_jsx(SidebarTrigger, { className: "md:hidden shrink-0 ml-1", "aria-label": t('common.toggleSidebar') || 'Toggle sidebar' }), activeAppName && onAppChange ? (_jsxs(_Fragment, { children: [_jsx(PathSep, {}), _jsx(AppSwitcher, { activeAppName: activeAppName, onAppChange: onAppChange })] })) : appName ? (_jsxs(_Fragment, { children: [_jsx(PathSep, {}), _jsx("span", { className: "text-sm font-medium text-foreground/80 px-1.5", children: appName })] })) : null, extraSegments.map((seg, i) => {
204
272
  const isLast = i === extraSegments.length - 1;
205
273
  return (_jsxs("span", { className: "hidden sm:flex items-center min-w-0", children: [_jsx(PathSep, {}), seg.siblings && seg.siblings.length > 1 ? (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { className: `flex items-center gap-1 rounded-md px-1.5 py-1 text-sm font-medium transition-colors outline-none hover:bg-accent hover:text-foreground ${!isLast ? 'text-foreground/60' : 'text-foreground/80'}`, children: [seg.label, _jsx(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground" })] }), _jsxs(DropdownMenuContent, { align: "start", sideOffset: 8, className: "w-56 max-h-72 overflow-y-auto", children: [_jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground font-normal", children: "Switch Object" }), _jsx(DropdownMenuSeparator, {}), seg.siblings.map((sibling) => (_jsx(DropdownMenuItem, { asChild: true, children: _jsx(Link, { to: sibling.href, className: "w-full", children: sibling.label }) }, sibling.href)))] })] })) : seg.href ? (_jsx(Link, { to: seg.href, className: `rounded-md px-1.5 py-1 text-sm font-medium transition-colors hover:bg-accent hover:text-foreground truncate max-w-[160px] ${isLast ? 'text-foreground/80' : 'text-foreground/60'}`, children: seg.label })) : (_jsx("span", { className: `px-1.5 py-1 text-sm font-medium truncate max-w-[160px] ${isLast ? 'text-foreground/80' : 'text-foreground/60'}`, children: seg.label }))] }, i));
206
- }), _jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: lastSegmentLabel })] }))] }), _jsxs("div", { className: "flex items-center gap-0.5 sm:gap-1 shrink-0 [&>*+*[data-topbar-group]]:ml-1 [&>[data-topbar-group]+[data-topbar-group]]:border-l [&>[data-topbar-group]+[data-topbar-group]]:border-border/60 [&>[data-topbar-group]+[data-topbar-group]]:pl-1 sm:[&>[data-topbar-group]+[data-topbar-group]]:pl-2 sm:[&>[data-topbar-group]+[data-topbar-group]]:ml-2", children: [!isOnline && (_jsxs("div", { className: "flex items-center gap-1 px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-medium", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-yellow-500 animate-pulse" }), "Offline"] })), isApp && connectionState && _jsx(ConnectionStatus, { state: connectionState }), isApp && activeUsers.length > 0 && (_jsx("div", { className: "hidden md:flex items-center shrink-0", title: "Users currently online", children: _jsx(PresenceAvatars, { users: activeUsers, size: "sm", maxVisible: 3, showStatus: true }) })), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 sm:gap-1 shrink-0", children: [_jsxs("button", { onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), className: "hidden lg:flex relative items-center gap-2 w-48 xl:w-64 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors", children: [_jsx(Search, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 text-left text-xs", children: t('console.search', { defaultValue: 'Search...' }) }), _jsxs("kbd", { className: "pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground", children: [_jsx("span", { className: "text-xs", children: "\u2318" }), "K"] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden h-8 w-8 shrink-0", onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), "aria-label": t('console.search', { defaultValue: 'Search...' }), children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(ActivityFeed, { activities: activeActivities }) }), _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 hidden md:flex shrink-0", asChild: true, "aria-label": t('sidebar.helpTooltip', { defaultValue: 'Help & Documentation' }), children: _jsx("a", { href: "https://docs.objectstack.ai", target: "_blank", rel: "noopener noreferrer", children: _jsx(HelpCircle, { className: "h-4 w-4" }) }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(ModeToggle, {}) }), _jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(LocaleSwitcher, {}) }), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0 rounded-full", children: _jsxs(Avatar, { className: "h-7 w-7 rounded-full", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-full bg-primary text-primary-foreground text-xs", children: getUserInitials(user) })] }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "min-w-64 rounded-lg", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-2 py-2", 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, {}), _jsxs(DropdownMenuGroup, { children: [hasOrgSection && (_jsxs(DropdownMenuItem, { onClick: () => navigate('/organizations'), className: "cursor-pointer", children: [_jsx(Boxes, { className: "mr-2 h-4 w-4" }), t('organizations.mine', { defaultValue: 'My Organizations' })] })), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup/system/profile'), children: [_jsx(UserIcon, { className: "mr-2 h-4 w-4" }), t('user.profile', { defaultValue: 'Profile' })] }), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup'), children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('sidebar.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' })] })] }))] })] })] })] })] }));
274
+ }), _jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: lastSegmentLabel })] }))] }), _jsxs("div", { className: "flex items-center gap-0.5 sm:gap-1 shrink-0 [&>*+*[data-topbar-group]]:ml-1 [&>[data-topbar-group]+[data-topbar-group]]:border-l [&>[data-topbar-group]+[data-topbar-group]]:border-border/60 [&>[data-topbar-group]+[data-topbar-group]]:pl-1 sm:[&>[data-topbar-group]+[data-topbar-group]]:pl-2 sm:[&>[data-topbar-group]+[data-topbar-group]]:ml-2", children: [!isOnline && (_jsxs("div", { className: "flex items-center gap-1 px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-medium", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-yellow-500 animate-pulse" }), "Offline"] })), isApp && connectionState && _jsx(ConnectionStatus, { state: connectionState }), isApp && activeUsers.length > 0 && (_jsx("div", { className: "hidden md:flex items-center shrink-0", title: "Users currently online", children: _jsx(PresenceAvatars, { users: activeUsers, size: "sm", maxVisible: 3, showStatus: true }) })), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 sm:gap-1 shrink-0", children: [_jsxs("button", { onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), className: "hidden lg:flex relative items-center gap-2 w-48 xl:w-64 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors", children: [_jsx(Search, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 text-left text-xs", children: t('console.search', { defaultValue: 'Search...' }) }), _jsxs("kbd", { className: "pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground", children: [_jsx("span", { className: "text-xs", children: "\u2318" }), "K"] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden h-8 w-8 shrink-0", onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), "aria-label": t('console.search', { defaultValue: 'Search...' }), children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(ActivityFeed, { activities: activeActivities }) }), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 relative shrink-0", "aria-label": t('sidebar.notifications', { defaultValue: 'Notifications' }), children: [_jsx(Bell, { className: "h-4 w-4" }), unreadCount > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 h-4 min-w-[16px] rounded-full bg-red-500 text-[10px] leading-4 text-white text-center px-1", children: unreadCount > 9 ? '9+' : unreadCount }))] }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-80 max-h-96 overflow-auto", children: [_jsxs(DropdownMenuLabel, { className: "flex items-center justify-between", children: [_jsx("span", { children: t('sidebar.notifications', { defaultValue: 'Notifications' }) }), unreadCount > 0 && (_jsx("button", { type: "button", onClick: (e) => { e.preventDefault(); markAllRead(); }, className: "text-xs text-muted-foreground hover:text-foreground", children: t('notifications.markAllRead', { defaultValue: 'Mark all read' }) }))] }), _jsx(DropdownMenuSeparator, {}), notifications.length === 0 ? (_jsx("div", { className: "px-3 py-6 text-sm text-muted-foreground text-center", children: t('notifications.empty', { defaultValue: 'No notifications' }) })) : (notifications.map((n) => (_jsxs(DropdownMenuItem, { className: `flex flex-col items-start gap-1 ${n.is_read ? '' : 'bg-accent/40'}`, onSelect: () => {
275
+ markNotificationRead(n.id);
276
+ if (n.source_object && n.source_id) {
277
+ // Navigate to the related record; relative to /apps/<currentApp> if any.
278
+ const app = currentAppName ?? params.appName;
279
+ const target = app
280
+ ? `/apps/${app}/${n.source_object}/${n.source_id}`
281
+ : `/objects/${n.source_object}/${n.source_id}`;
282
+ navigate(target);
283
+ }
284
+ }, children: [_jsx("div", { className: "text-sm font-medium leading-tight", children: n.title }), n.body && (_jsx("div", { className: "text-xs text-muted-foreground line-clamp-2", children: n.body })), n.created_at && (_jsx("div", { className: "text-[10px] text-muted-foreground", children: new Date(n.created_at).toLocaleString() }))] }, n.id))))] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 hidden md:flex shrink-0", asChild: true, "aria-label": t('sidebar.helpTooltip', { defaultValue: 'Help & Documentation' }), children: _jsx("a", { href: "https://docs.objectstack.ai", target: "_blank", rel: "noopener noreferrer", children: _jsx(HelpCircle, { className: "h-4 w-4" }) }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(ModeToggle, {}) }), _jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(LocaleSwitcher, {}) }), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0 rounded-full", children: _jsxs(Avatar, { className: "h-7 w-7 rounded-full", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-full bg-primary text-primary-foreground text-xs", children: getUserInitials(user) })] }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "min-w-64 rounded-lg", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-2 py-2", 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, {}), _jsxs(DropdownMenuGroup, { children: [hasOrgSection && (_jsxs(DropdownMenuItem, { onClick: () => navigate('/organizations'), className: "cursor-pointer", children: [_jsx(Boxes, { className: "mr-2 h-4 w-4" }), t('organizations.mine', { defaultValue: 'My Organizations' })] })), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup/system/profile'), children: [_jsx(UserIcon, { className: "mr-2 h-4 w-4" }), t('user.profile', { defaultValue: 'Profile' })] }), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup'), children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('sidebar.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' })] })] }))] })] })] })] })] }));
207
285
  }
@@ -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,22 +199,40 @@ 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
- }) }) })] })) }), _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) => {
188
- const NavIcon = getIcon(item.icon);
189
- const baseUrl = activeApp ? `/apps/${activeAppName}` : '';
190
- let href = item.url || '#';
191
- if (item.type === 'object') {
192
- href = `${baseUrl}/${item.objectName}`;
193
- if (item.viewName)
194
- href += `/view/${item.viewName}`;
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: (() => {
206
+ // Flatten group items so apps that organise navigation into groups
207
+ // (e.g. Setup Overview / Administration / …) still surface real
208
+ // leaf links in the mobile bottom nav instead of rendering nothing.
209
+ const source = activeApp ? resolvedNavigation : systemFallbackNavigation;
210
+ const leaves = [];
211
+ for (const item of source) {
212
+ if (item.type === 'group') {
213
+ for (const child of (item.children || item.items || [])) {
214
+ if (child && child.type !== 'group')
215
+ leaves.push(child);
216
+ }
217
+ }
218
+ else {
219
+ leaves.push(item);
220
+ }
195
221
  }
196
- else if (item.type === 'dashboard')
197
- href = item.dashboardName ? `${baseUrl}/dashboard/${item.dashboardName}` : '#';
198
- else if (item.type === 'page')
199
- href = item.pageName ? `${baseUrl}/page/${item.pageName}` : '#';
200
- return (_jsxs(Link, { to: href, className: "flex flex-col items-center gap-0.5 px-2 py-1.5 text-muted-foreground hover:text-foreground transition-colors min-w-[44px] min-h-[44px] justify-center", children: [_jsx(NavIcon, { className: "h-5 w-5" }), _jsx("span", { className: "text-[10px] truncate max-w-[60px]", children: resolveI18nLabel(item.label, t) })] }, item.id));
201
- }) }))] }));
222
+ return leaves.slice(0, 5).map((item) => {
223
+ const NavIcon = getIcon(item.icon);
224
+ const baseUrl = activeApp ? `/apps/${activeAppName}` : '';
225
+ let href = item.url || '#';
226
+ if (item.type === 'object') {
227
+ href = `${baseUrl}/${item.objectName}`;
228
+ if (item.viewName)
229
+ href += `/view/${item.viewName}`;
230
+ }
231
+ else if (item.type === 'dashboard')
232
+ href = item.dashboardName ? `${baseUrl}/dashboard/${item.dashboardName}` : '#';
233
+ else if (item.type === 'page')
234
+ href = item.pageName ? `${baseUrl}/page/${item.pageName}` : '#';
235
+ return (_jsxs(Link, { to: href, className: "flex flex-col items-center gap-0.5 px-2 py-1.5 text-muted-foreground hover:text-foreground transition-colors min-w-[44px] min-h-[44px] justify-center", children: [_jsx(NavIcon, { className: "h-5 w-5" }), _jsx("span", { className: "text-[10px] truncate max-w-[60px]", children: resolveI18nLabel(item.label, t) })] }, item.id));
236
+ });
237
+ })() }))] }));
202
238
  }
@@ -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,27 +160,55 @@ 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));
172
- }) }) }) }), favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').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.starred', { defaultValue: 'Starred' })] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').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 === '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))) }) })] }))] })) }) }), _jsx(SidebarFooter, { className: "border-t p-1", children: _jsx(SidebarTrigger, { className: "w-full justify-start pl-2 group-data-[state=collapsed]:justify-center group-data-[state=collapsed]:pl-0" }) })] }), isMobile && context === 'app' && (_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: processedNavigation.filter((n) => n.type !== 'group').slice(0, 5).map((item) => {
173
- const NavIcon = getIcon(item.icon);
174
- let href = item.url || '#';
175
- if (item.type === 'object') {
176
- href = `${basePath}/${item.objectName}`;
177
- if (item.viewName)
178
- href += `/view/${item.viewName}`;
183
+ }) }) }) }), favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').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.starred', { defaultValue: 'Starred' })] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').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 === '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))) }) })] }))] })) }) }), _jsx(SidebarFooter, { className: "border-t p-1", children: _jsx(SidebarTrigger, { className: "w-full justify-start pl-2 group-data-[state=collapsed]:justify-center group-data-[state=collapsed]:pl-0" }) })] }), isMobile && context === 'app' && (_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: (() => {
184
+ // Flatten group items so apps that organise navigation into groups
185
+ // (e.g. Setup Overview / Administration / …) still surface real
186
+ // leaf links in the mobile bottom nav instead of rendering nothing.
187
+ const leaves = [];
188
+ for (const item of processedNavigation) {
189
+ if (item.type === 'group') {
190
+ for (const child of (item.children || [])) {
191
+ if (child && child.type !== 'group')
192
+ leaves.push(child);
193
+ }
194
+ }
195
+ else {
196
+ leaves.push(item);
197
+ }
179
198
  }
180
- else if (item.type === 'dashboard')
181
- href = item.dashboardName ? `${basePath}/dashboard/${item.dashboardName}` : '#';
182
- else if (item.type === 'page')
183
- href = item.pageName ? `${basePath}/page/${item.pageName}` : '#';
184
- return (_jsxs(Link, { to: href, className: "flex flex-col items-center gap-0.5 px-2 py-1.5 text-muted-foreground hover:text-foreground transition-colors min-w-[44px] min-h-[44px] justify-center", children: [_jsx(NavIcon, { className: "h-5 w-5" }), _jsx("span", { className: "text-[10px] truncate max-w-[60px]", children: resolveI18nLabel(item.label, t) })] }, item.id));
185
- }) }))] }));
199
+ return leaves.slice(0, 5).map((item) => {
200
+ const NavIcon = getIcon(item.icon);
201
+ let href = item.url || '#';
202
+ if (item.type === 'object') {
203
+ href = `${basePath}/${item.objectName}`;
204
+ if (item.viewName)
205
+ href += `/view/${item.viewName}`;
206
+ }
207
+ else if (item.type === 'dashboard')
208
+ href = item.dashboardName ? `${basePath}/dashboard/${item.dashboardName}` : '#';
209
+ else if (item.type === 'page')
210
+ href = item.pageName ? `${basePath}/page/${item.pageName}` : '#';
211
+ return (_jsxs(Link, { to: href, className: "flex flex-col items-center gap-0.5 px-2 py-1.5 text-muted-foreground hover:text-foreground transition-colors min-w-[44px] min-h-[44px] justify-center", children: [_jsx(NavIcon, { className: "h-5 w-5" }), _jsx("span", { className: "text-[10px] truncate max-w-[60px]", children: resolveI18nLabel(item.label, t) })] }, item.id));
212
+ });
213
+ })() }))] }));
186
214
  }
@@ -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,
@@ -400,6 +400,20 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
400
400
  // the physical `sys_view` table (whose columns no longer
401
401
  // accommodate the spec shape: arrays, nested objects, etc.).
402
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 };
415
+ }
416
+ }
403
417
  if (typeof dataSource?.createView === 'function') {
404
418
  const created = await dataSource.createView(objectName, spec);
405
419
  createdId = created?.name || config?.name;
@@ -1490,6 +1504,11 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1490
1504
  })),
1491
1505
  size: 'sm',
1492
1506
  variant: 'outline',
1507
+ // On mobile, collapse all schema-driven toolbar actions
1508
+ // into a single overflow menu so the icon-only New /
1509
+ // Import buttons stay visible without pushing the page
1510
+ // title off-screen.
1511
+ mobileMaxVisible: 0,
1493
1512
  } }))] }) }), showImport && (_jsx(Suspense, { fallback: null, children: _jsx(ImportWizard, { open: showImport, onOpenChange: setShowImport, objectName: objectDef.name, objectLabel: objectLabel(objectDef), fields: Object.entries(objectDef.fields || {}).map(([name, def]) => ({
1494
1513
  name,
1495
1514
  label: def?.label || name,
@@ -22,6 +22,14 @@ import { ActionParamDialog } from './ActionParamDialog';
22
22
  import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
23
23
  import { getRecordDisplayName } from '../utils';
24
24
  const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
25
+ /**
26
+ * Audit field names auto-injected by the framework's `applySystemFields`.
27
+ * Surfaced as a dedicated, collapsed "System Information" section on the
28
+ * record detail page so they don't clutter the primary content but remain
29
+ * discoverable. The inline-edit drawer keeps filtering them out via
30
+ * `DEFAULT_SYSTEM_FIELDS` in `@object-ui/plugin-detail/RecordDetailDrawer`.
31
+ */
32
+ const AUDIT_FIELD_NAMES = new Set(['created_at', 'created_by', 'updated_at', 'updated_by']);
25
33
  export function RecordDetailView({ dataSource, objects, onEdit }) {
26
34
  const { appName, objectName, recordId } = useParams();
27
35
  const { showDebug } = useMetadataInspector();
@@ -226,9 +234,10 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
226
234
  });
227
235
  return () => { cancelled = true; };
228
236
  }, [dataSource, pureRecordId, childRelations]);
229
- const currentUser = user
230
- ? { id: user.id, name: user.name, avatar: user.image }
231
- : FALLBACK_USER;
237
+ // Memoize so the object identity is stable across renders — otherwise
238
+ // any effect that depends on it (e.g. the feed loader below) would
239
+ // re-fire every render and create an infinite request loop.
240
+ const currentUser = useMemo(() => (user ? { id: user.id, name: user.name, avatar: user.image } : FALLBACK_USER), [user?.id, user?.name, user?.image]);
232
241
  // Fetch presence and comments from API
233
242
  useEffect(() => {
234
243
  if (!dataSource || !objectName || !pureRecordId)
@@ -239,28 +248,141 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
239
248
  .then((res) => { if (res.data?.length)
240
249
  setRecordViewers(res.data); })
241
250
  .catch(() => { });
242
- // Fetch persisted comments and map to FeedItem[]
243
- dataSource.find('sys_comment', { $filter: { threadId }, $orderby: { createdAt: 'asc' } })
251
+ // M10.10: Fetch persisted comments from sys_comment. Field names
252
+ // are snake_case to match the platform-objects schema
253
+ // (`packages/platform-objects/src/audit/sys-comment.object.ts`):
254
+ // thread_id, author_id, author_name, author_avatar_url, body,
255
+ // reactions (JSON string), parent_id, created_at, updated_at.
256
+ //
257
+ // Reactions are stored as a JSON object of `{ emoji: string[] }`
258
+ // (one array of user-ids per emoji). The aggregator below counts
259
+ // entries and flags the currently-signed-in user.
260
+ const parseReactions = (raw) => {
261
+ if (!raw)
262
+ return undefined;
263
+ let parsed;
264
+ if (typeof raw === 'string') {
265
+ try {
266
+ parsed = JSON.parse(raw);
267
+ }
268
+ catch {
269
+ return undefined;
270
+ }
271
+ }
272
+ else if (typeof raw === 'object') {
273
+ parsed = raw;
274
+ }
275
+ if (!parsed)
276
+ return undefined;
277
+ return Object.entries(parsed).map(([emoji, userIds]) => ({
278
+ emoji,
279
+ count: Array.isArray(userIds) ? userIds.length : 0,
280
+ reacted: Array.isArray(userIds) && userIds.includes(currentUser.id),
281
+ }));
282
+ };
283
+ dataSource.find('sys_comment', { $filter: { thread_id: threadId }, $orderby: { created_at: 'asc' } })
284
+ .then((res) => {
285
+ if (!res?.data?.length)
286
+ return;
287
+ const mapped = res.data.map((c) => ({
288
+ id: c.id,
289
+ type: 'comment',
290
+ actor: c.author_name ?? 'Unknown',
291
+ actorAvatarUrl: c.author_avatar_url ?? undefined,
292
+ body: c.body ?? '',
293
+ createdAt: c.created_at,
294
+ updatedAt: c.updated_at,
295
+ parentId: c.parent_id ?? undefined,
296
+ reactions: parseReactions(c.reactions),
297
+ }));
298
+ setFeedItems(prev => {
299
+ const byId = new Map();
300
+ for (const item of [...prev, ...mapped])
301
+ byId.set(String(item.id), item);
302
+ return Array.from(byId.values()).sort((a, b) => {
303
+ const ta = a.createdAt ? Date.parse(a.createdAt) : 0;
304
+ const tb = b.createdAt ? Date.parse(b.createdAt) : 0;
305
+ return ta - tb;
306
+ });
307
+ });
308
+ })
309
+ .catch(() => { });
310
+ // M10.11: Fetch sys_activity rows for this record and merge into the
311
+ // timeline. plugin-audit's writers populate sys_activity on every
312
+ // create/update/delete of objects that opt-in via enable.activities,
313
+ // so this surface — once wired here — gives us a Salesforce-style
314
+ // "what happened on this record" feed without any per-app glue.
315
+ //
316
+ // We map sys_activity.type to FeedItemType so the existing icon /
317
+ // colour map in RecordActivityTimeline keeps working:
318
+ // created/updated/deleted/system → 'field_change'
319
+ // assigned/shared → 'field_change'
320
+ // completed → 'task'
321
+ // commented/mentioned → 'comment' (but skipped — we
322
+ // already load these from
323
+ // sys_comment to get reactions
324
+ // and threading)
325
+ //
326
+ // sys_activity is system-owned so a 404 ("table not provisioned",
327
+ // older schemas without activities) is silently tolerated.
328
+ const activityTypeToFeed = {
329
+ created: 'field_change',
330
+ updated: 'field_change',
331
+ deleted: 'field_change',
332
+ assigned: 'field_change',
333
+ shared: 'field_change',
334
+ system: 'system',
335
+ completed: 'task',
336
+ commented: undefined,
337
+ mentioned: undefined,
338
+ login: undefined,
339
+ logout: undefined,
340
+ };
341
+ dataSource.find('sys_activity', {
342
+ $filter: { object_name: objectName, record_id: pureRecordId },
343
+ $orderby: { timestamp: 'asc' },
344
+ $top: 200,
345
+ })
244
346
  .then((res) => {
245
- if (res.data?.length) {
246
- setFeedItems(res.data.map((c) => ({
247
- id: c.id,
248
- type: 'comment',
249
- actor: c.author?.name ?? 'Unknown',
250
- actorAvatarUrl: c.author?.avatar,
251
- body: c.content,
252
- createdAt: c.createdAt,
253
- updatedAt: c.updatedAt,
254
- parentId: c.parentId,
255
- reactions: c.reactions
256
- ? Object.entries(c.reactions).map(([emoji, userIds]) => ({
257
- emoji,
258
- count: userIds.length,
259
- reacted: userIds.includes(currentUser.id),
260
- }))
261
- : undefined,
262
- })));
347
+ if (!res?.data?.length)
348
+ return;
349
+ const mapped = [];
350
+ for (const row of res.data) {
351
+ const t = activityTypeToFeed[row.type];
352
+ if (!t)
353
+ continue;
354
+ // Prefer the explicit `timestamp` column, but tolerate older
355
+ // rows where the driver leaked the literal "NOW()" — fall
356
+ // back to created_at (always a real ISO date).
357
+ let when = row.timestamp;
358
+ if (!when || when === 'NOW()' || Number.isNaN(Date.parse(when))) {
359
+ when = row.created_at;
360
+ }
361
+ mapped.push({
362
+ id: row.id,
363
+ type: t,
364
+ actor: row.actor_name ?? 'System',
365
+ actorAvatarUrl: row.actor_avatar_url ?? undefined,
366
+ body: row.summary ?? '',
367
+ createdAt: when,
368
+ });
263
369
  }
370
+ if (!mapped.length)
371
+ return;
372
+ setFeedItems(prev => {
373
+ // Merge by id (timeline events are append-only); sort by
374
+ // createdAt ascending so the activity panel reads as a
375
+ // chronological narrative.
376
+ const byId = new Map();
377
+ for (const item of [...prev, ...mapped]) {
378
+ byId.set(String(item.id), item);
379
+ }
380
+ return Array.from(byId.values()).sort((a, b) => {
381
+ const ta = a.createdAt ? Date.parse(a.createdAt) : 0;
382
+ const tb = b.createdAt ? Date.parse(b.createdAt) : 0;
383
+ return ta - tb;
384
+ });
385
+ });
264
386
  })
265
387
  .catch(() => { });
266
388
  }, [dataSource, objectName, pureRecordId, currentUser]);
@@ -274,16 +396,18 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
274
396
  createdAt: new Date().toISOString(),
275
397
  };
276
398
  setFeedItems(prev => [...prev, newItem]);
277
- // Persist to backend
399
+ // Persist to backend (M10.10: snake_case fields per sys_comment schema)
278
400
  if (dataSource) {
279
401
  const threadId = `${objectName}:${pureRecordId}`;
280
402
  dataSource.create('sys_comment', {
281
403
  id: newItem.id,
282
- threadId,
283
- author: currentUser,
284
- content: text,
285
- mentions: [],
286
- createdAt: newItem.createdAt,
404
+ thread_id: threadId,
405
+ author_id: currentUser.id,
406
+ author_name: currentUser.name,
407
+ author_avatar_url: 'avatar' in currentUser ? currentUser.avatar : undefined,
408
+ body: text,
409
+ mentions: '[]',
410
+ created_at: newItem.createdAt,
287
411
  }).catch(() => { });
288
412
  }
289
413
  }, [currentUser, dataSource, objectName, pureRecordId]);
@@ -308,12 +432,14 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
308
432
  const threadId = `${objectName}:${pureRecordId}`;
309
433
  dataSource.create('sys_comment', {
310
434
  id: newItem.id,
311
- threadId,
312
- author: currentUser,
313
- content: text,
314
- mentions: [],
315
- createdAt: newItem.createdAt,
316
- parentId,
435
+ thread_id: threadId,
436
+ author_id: currentUser.id,
437
+ author_name: currentUser.name,
438
+ author_avatar_url: 'avatar' in currentUser ? currentUser.avatar : undefined,
439
+ body: text,
440
+ mentions: '[]',
441
+ created_at: newItem.createdAt,
442
+ parent_id: parentId,
317
443
  }).catch(() => { });
318
444
  }
319
445
  }, [currentUser, dataSource, objectName, pureRecordId]);
@@ -342,10 +468,31 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
342
468
  reactions.push({ emoji, count: 1, reacted: true });
343
469
  }
344
470
  const updated = { ...item, reactions };
345
- // Persist reaction toggle to backend
471
+ // Persist reactions to backend as JSON. The schema stores
472
+ // `reactions` as a textarea JSON string of `{ emoji: userIds[] }`,
473
+ // so we rebuild the canonical shape from the optimistic local
474
+ // state before writing back. A failed update silently keeps the
475
+ // optimistic UI change (best-effort, surfaced by RUM if needed).
346
476
  if (dataSource) {
477
+ const userId = currentUser.id;
478
+ const remoteShape = {};
479
+ for (const r of reactions) {
480
+ // We don't have the original user-id list locally, so we
481
+ // approximate by emitting the signed-in user when they are
482
+ // the (only known) reactor. This is an over-simplification
483
+ // for single-user pilot installs and will be replaced by a
484
+ // proper backend reaction endpoint in M11.
485
+ const ids = [];
486
+ if (r.reacted)
487
+ ids.push(userId);
488
+ // Pad with a synthetic marker so count is preserved across
489
+ // refreshes from other clients (best-effort).
490
+ while (ids.length < r.count)
491
+ ids.push('__other__');
492
+ remoteShape[r.emoji] = ids;
493
+ }
347
494
  dataSource.update('sys_comment', String(itemId), {
348
- $toggleReaction: { emoji, userId: currentUser.id },
495
+ reactions: JSON.stringify(remoteShape),
349
496
  }).catch(() => { });
350
497
  }
351
498
  return updated;
@@ -397,7 +544,9 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
397
544
  // section, DetailSection flattens it (no Card chrome, no
398
545
  // redundant "Details" heading).
399
546
  showBorder: false,
400
- fields: Object.keys(objectDef.fields || {}).map(key => {
547
+ fields: Object.keys(objectDef.fields || {})
548
+ .filter(key => !AUDIT_FIELD_NAMES.has(key))
549
+ .map(key => {
401
550
  const fieldDef = objectDef.fields[key];
402
551
  const refTarget = fieldDef.reference_to || fieldDef.reference;
403
552
  return {
@@ -412,6 +561,32 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
412
561
  }),
413
562
  },
414
563
  ];
564
+ // Append a dedicated, collapsed "System Information" section listing
565
+ // audit fields (created/updated at/by) when the schema declares them
566
+ // and no author-defined section has already surfaced them. The framework
567
+ // auto-injects these as `system: true, readonly: true` via
568
+ // `applySystemFields`; rendering them here gives users visibility into
569
+ // record provenance without polluting the primary content area.
570
+ const fieldsAlreadyShown = new Set(sections.flatMap((s) => (s.fields || []).map((f) => f.name)));
571
+ const auditFieldsToShow = Array.from(AUDIT_FIELD_NAMES).filter(name => objectDef.fields?.[name] && !fieldsAlreadyShown.has(name));
572
+ if (auditFieldsToShow.length > 0) {
573
+ sections.push({
574
+ title: sectionLabel(objectDef.name, 'system_info', 'System Information'),
575
+ collapsible: true,
576
+ defaultCollapsed: true,
577
+ fields: auditFieldsToShow.map(key => {
578
+ const fieldDef = objectDef.fields[key];
579
+ const refTarget = fieldDef.reference_to || fieldDef.reference;
580
+ return {
581
+ name: key,
582
+ label: fieldDef.label || key,
583
+ type: fieldDef.type || 'text',
584
+ readonly: true,
585
+ ...(refTarget && { reference_to: refTarget }),
586
+ };
587
+ }),
588
+ });
589
+ }
415
590
  // Filter actions for record_header location and deduplicate by name
416
591
  const recordHeaderActions = (() => {
417
592
  const seen = new Set();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/app-shell",
3
- "version": "4.0.10",
3
+ "version": "4.0.12",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Minimal application shell for ObjectUI - framework-agnostic rendering engine",
@@ -25,39 +25,39 @@
25
25
  "./styles.css": "./src/styles.css"
26
26
  },
27
27
  "dependencies": {
28
- "lucide-react": "^1.14.0",
28
+ "lucide-react": "^1.16.0",
29
29
  "sonner": "^2.0.7",
30
- "@object-ui/auth": "4.0.10",
31
- "@object-ui/collaboration": "4.0.10",
32
- "@object-ui/components": "4.0.10",
33
- "@object-ui/core": "4.0.10",
34
- "@object-ui/data-objectstack": "4.0.10",
35
- "@object-ui/fields": "4.0.10",
36
- "@object-ui/i18n": "4.0.10",
37
- "@object-ui/layout": "4.0.10",
38
- "@object-ui/permissions": "4.0.10",
39
- "@object-ui/react": "4.0.10",
40
- "@object-ui/types": "4.0.10"
30
+ "@object-ui/auth": "4.0.12",
31
+ "@object-ui/collaboration": "4.0.12",
32
+ "@object-ui/components": "4.0.12",
33
+ "@object-ui/core": "4.0.12",
34
+ "@object-ui/data-objectstack": "4.0.12",
35
+ "@object-ui/fields": "4.0.12",
36
+ "@object-ui/i18n": "4.0.12",
37
+ "@object-ui/layout": "4.0.12",
38
+ "@object-ui/permissions": "4.0.12",
39
+ "@object-ui/react": "4.0.12",
40
+ "@object-ui/types": "4.0.12"
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.10",
47
- "@object-ui/plugin-charts": "4.0.10",
48
- "@object-ui/plugin-chatbot": "4.0.10",
49
- "@object-ui/plugin-dashboard": "4.0.10",
50
- "@object-ui/plugin-designer": "4.0.10",
51
- "@object-ui/plugin-detail": "4.0.10",
52
- "@object-ui/plugin-form": "4.0.10",
53
- "@object-ui/plugin-grid": "4.0.10",
54
- "@object-ui/plugin-kanban": "4.0.10",
55
- "@object-ui/plugin-list": "4.0.10",
56
- "@object-ui/plugin-report": "4.0.10",
57
- "@object-ui/plugin-view": "4.0.10"
46
+ "@object-ui/plugin-calendar": "4.0.12",
47
+ "@object-ui/plugin-charts": "4.0.12",
48
+ "@object-ui/plugin-chatbot": "4.0.12",
49
+ "@object-ui/plugin-dashboard": "4.0.12",
50
+ "@object-ui/plugin-designer": "4.0.12",
51
+ "@object-ui/plugin-detail": "4.0.12",
52
+ "@object-ui/plugin-form": "4.0.12",
53
+ "@object-ui/plugin-grid": "4.0.12",
54
+ "@object-ui/plugin-kanban": "4.0.12",
55
+ "@object-ui/plugin-list": "4.0.12",
56
+ "@object-ui/plugin-report": "4.0.12",
57
+ "@object-ui/plugin-view": "4.0.12"
58
58
  },
59
59
  "devDependencies": {
60
- "@types/node": "^25.7.0",
60
+ "@types/node": "^25.9.0",
61
61
  "@types/react": "19.2.14",
62
62
  "@types/react-dom": "19.2.3",
63
63
  "react": "19.2.6",
@@ -65,7 +65,7 @@
65
65
  "react-router-dom": "^7.15.0",
66
66
  "sonner": "^2.0.7",
67
67
  "typescript": "^6.0.3",
68
- "vite": "^8.0.12"
68
+ "vite": "^8.0.13"
69
69
  },
70
70
  "keywords": [
71
71
  "objectui",