@object-ui/app-shell 4.0.11 → 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 +29 -0
- package/dist/console/home/HomeLayout.js +1 -1
- package/dist/console/home/HomePage.js +3 -1
- package/dist/layout/AppHeader.js +80 -2
- package/dist/layout/AppSidebar.js +32 -14
- package/dist/layout/UnifiedSidebar.js +30 -13
- package/dist/views/ObjectView.js +5 -0
- package/dist/views/RecordDetailView.js +213 -38
- package/package.json +27 -27
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
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
|
+
|
|
3
32
|
## 4.0.11
|
|
4
33
|
|
|
5
34
|
### 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:
|
|
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
|
}
|
package/dist/layout/AppHeader.js
CHANGED
|
@@ -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
|
|
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
|
}
|
|
@@ -202,19 +202,37 @@ export function AppSidebar({ activeAppName, onAppChange }) {
|
|
|
202
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) => {
|
|
203
203
|
const NavIcon = getIcon(item.icon);
|
|
204
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));
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
+
}
|
|
213
221
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
href = item.
|
|
218
|
-
|
|
219
|
-
|
|
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
|
+
})() }))] }));
|
|
220
238
|
}
|
|
@@ -180,18 +180,35 @@ export function UnifiedSidebar({ activeAppName }) {
|
|
|
180
180
|
const NavIcon = getIcon(item.icon);
|
|
181
181
|
const isActive = location.pathname === item.url;
|
|
182
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));
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
}
|
|
190
198
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
+
})() }))] }));
|
|
197
214
|
}
|
package/dist/views/ObjectView.js
CHANGED
|
@@ -1504,6 +1504,11 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
|
|
|
1504
1504
|
})),
|
|
1505
1505
|
size: 'sm',
|
|
1506
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,
|
|
1507
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]) => ({
|
|
1508
1513
|
name,
|
|
1509
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
243
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
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
|
-
|
|
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 || {})
|
|
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.
|
|
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.
|
|
28
|
+
"lucide-react": "^1.16.0",
|
|
29
29
|
"sonner": "^2.0.7",
|
|
30
|
-
"@object-ui/auth": "4.0.
|
|
31
|
-
"@object-ui/collaboration": "4.0.
|
|
32
|
-
"@object-ui/components": "4.0.
|
|
33
|
-
"@object-ui/core": "4.0.
|
|
34
|
-
"@object-ui/data-objectstack": "4.0.
|
|
35
|
-
"@object-ui/fields": "4.0.
|
|
36
|
-
"@object-ui/i18n": "4.0.
|
|
37
|
-
"@object-ui/layout": "4.0.
|
|
38
|
-
"@object-ui/permissions": "4.0.
|
|
39
|
-
"@object-ui/react": "4.0.
|
|
40
|
-
"@object-ui/types": "4.0.
|
|
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.
|
|
47
|
-
"@object-ui/plugin-charts": "4.0.
|
|
48
|
-
"@object-ui/plugin-chatbot": "4.0.
|
|
49
|
-
"@object-ui/plugin-dashboard": "4.0.
|
|
50
|
-
"@object-ui/plugin-designer": "4.0.
|
|
51
|
-
"@object-ui/plugin-detail": "4.0.
|
|
52
|
-
"@object-ui/plugin-form": "4.0.
|
|
53
|
-
"@object-ui/plugin-grid": "4.0.
|
|
54
|
-
"@object-ui/plugin-kanban": "4.0.
|
|
55
|
-
"@object-ui/plugin-list": "4.0.
|
|
56
|
-
"@object-ui/plugin-report": "4.0.
|
|
57
|
-
"@object-ui/plugin-view": "4.0.
|
|
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.
|
|
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.
|
|
68
|
+
"vite": "^8.0.13"
|
|
69
69
|
},
|
|
70
70
|
"keywords": [
|
|
71
71
|
"objectui",
|