@object-ui/app-shell 4.0.12 → 4.2.1

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,62 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 4.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 47c27c7: fix
8
+ - @object-ui/types@4.2.1
9
+ - @object-ui/core@4.2.1
10
+ - @object-ui/i18n@4.2.1
11
+ - @object-ui/react@4.2.1
12
+ - @object-ui/components@4.2.1
13
+ - @object-ui/fields@4.2.1
14
+ - @object-ui/layout@4.2.1
15
+ - @object-ui/data-objectstack@4.2.1
16
+ - @object-ui/auth@4.2.1
17
+ - @object-ui/permissions@4.2.1
18
+ - @object-ui/collaboration@4.2.1
19
+
20
+ ## 4.2.0
21
+
22
+ ### Patch Changes
23
+
24
+ - 786de60: ReportView no longer caps the report content at `max-w-5xl` (1024px). Reports now use the full available content width, matching DashboardView behavior. Matrix and grid reports in particular benefit from the additional horizontal real estate.
25
+ - Updated dependencies [eb738bd]
26
+ - Updated dependencies [650392e]
27
+ - Updated dependencies [84b4bf1]
28
+ - @object-ui/i18n@4.2.0
29
+ - @object-ui/components@4.2.0
30
+ - @object-ui/fields@4.2.0
31
+ - @object-ui/react@4.2.0
32
+ - @object-ui/layout@4.2.0
33
+ - @object-ui/types@4.2.0
34
+ - @object-ui/core@4.2.0
35
+ - @object-ui/data-objectstack@4.2.0
36
+ - @object-ui/auth@4.2.0
37
+ - @object-ui/permissions@4.2.0
38
+ - @object-ui/collaboration@4.2.0
39
+
40
+ ## 4.1.0
41
+
42
+ ### Patch Changes
43
+
44
+ - b4ce9e2: Fix summary reports: render chart + KPIs, correct empty-table on server-aggregated data.
45
+ - `plugin-report`: `SpecReportGrid` now renders a KPI strip (per aggregating column) and a chart section above the grid for `summary` reports. KPI section auto-hides when no aggregating columns. New `buildChartData()` adapter buckets aggregated `ReportRow[]` to chart-ready data, auto-sorts pie/funnel descending, and falls back to row count when the chart `yAxis` points at a non-numeric column. When the data is server-aggregated, the grid switches columns to `[groupings, ${field}__${agg}]` so cells aren't empty against a raw-row column schema.
46
+ - `plugin-charts`: register `'column'` as an alias of `'bar'` in `ChartRenderer` / `AdvancedChartImpl` (Recharts only has `BarChart`).
47
+ - `app-shell`: `ReportView` now routes any object-backed report (matrix/joined/summary/tabular/columns/groupingsAcross) through the spec `ReportRenderer`; fully-legacy `fields`+`data` schemas still use `ReportViewer`.
48
+ - @object-ui/types@4.1.0
49
+ - @object-ui/core@4.1.0
50
+ - @object-ui/i18n@4.1.0
51
+ - @object-ui/react@4.1.0
52
+ - @object-ui/components@4.1.0
53
+ - @object-ui/fields@4.1.0
54
+ - @object-ui/layout@4.1.0
55
+ - @object-ui/data-objectstack@4.1.0
56
+ - @object-ui/auth@4.1.0
57
+ - @object-ui/permissions@4.1.0
58
+ - @object-ui/collaboration@4.1.0
59
+
3
60
  ## 4.0.12
4
61
 
5
62
  ### Patch Changes
@@ -0,0 +1,16 @@
1
+ export interface ManagedByBannerProps {
2
+ /** The `managedBy` flag from the object schema. */
3
+ managedBy?: string;
4
+ /** Optional override for the human-readable system name. */
5
+ label?: string;
6
+ /** Optional documentation link rendered as "Learn more →". */
7
+ docHref?: string;
8
+ /**
9
+ * Optional human-readable name for the source record / parent workflow
10
+ * referenced in `system`-bucket banners (e.g. "Opportunity"). When
11
+ * provided the banner reads "Use the Opportunity record's Submit for
12
+ * Approval action…" instead of the generic phrasing.
13
+ */
14
+ sourceRecordLabel?: string;
15
+ }
16
+ export declare function ManagedByBanner({ managedBy, label, docHref, sourceRecordLabel }: ManagedByBannerProps): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { ShieldAlert, Info, Lock, Archive } from 'lucide-react';
3
+ const VARIANTS = {
4
+ config: {
5
+ icon: Info,
6
+ tone: 'border-sky-300/60 bg-sky-50 text-sky-900 dark:border-sky-500/40 dark:bg-sky-950/40 dark:text-sky-100',
7
+ title: 'Administrator configuration',
8
+ body: () => "These rows define how the platform behaves at runtime — they're authored here, but the runtime data they produce lives in a separate table. Changes take effect once a row is marked Active.",
9
+ },
10
+ system: {
11
+ icon: Lock,
12
+ tone: 'border-slate-300/60 bg-slate-50 text-slate-900 dark:border-slate-500/40 dark:bg-slate-950/40 dark:text-slate-100',
13
+ title: 'Managed by the platform',
14
+ body: (_display, src) => `Rows here are created and updated automatically by the platform engine. To start a new one, use the action button on the ${src ?? 'source record'} (e.g. "Submit for Approval", "Share", "Invite"). The list below is the audit / monitoring surface — actions like Approve, Recall, or Resend live on the row.`,
15
+ },
16
+ 'append-only': {
17
+ icon: Archive,
18
+ tone: 'border-zinc-300/60 bg-zinc-50 text-zinc-900 dark:border-zinc-500/40 dark:bg-zinc-950/40 dark:text-zinc-100',
19
+ title: 'Read-only historical record',
20
+ body: () => "This is an immutable audit log. Rows cannot be created, edited, or deleted from the UI — they're written by the platform when events occur. Use Export to download for compliance review.",
21
+ },
22
+ 'better-auth': {
23
+ icon: ShieldAlert,
24
+ tone: 'border-amber-300/60 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-100',
25
+ title: 'Managed by better-auth',
26
+ body: (display) => `This object's schema is owned by ${display}. Direct edits here bypass password hashing, session validation, two-factor checks, and audit hooks — and may corrupt account state. Use the dedicated identity workflows instead (Invite User, Reset Password, Revoke Session, Rotate Key, …).`,
27
+ },
28
+ };
29
+ export function ManagedByBanner({ managedBy, label, docHref, sourceRecordLabel }) {
30
+ if (!managedBy || managedBy === 'platform')
31
+ return null;
32
+ const variant = VARIANTS[managedBy];
33
+ if (!variant)
34
+ return null;
35
+ const display = label ?? managedBy;
36
+ const Icon = variant.icon;
37
+ return (_jsxs("div", { role: "note", "data-testid": "managed-by-banner", "data-bucket": managedBy, className: `flex items-start gap-3 border-b px-4 py-2.5 text-sm ${variant.tone}`, children: [_jsx(Icon, { className: "mt-0.5 h-4 w-4 flex-none" }), _jsxs("div", { className: "flex-1", children: [_jsxs("strong", { className: "font-semibold", children: [variant.title, "."] }), ' ', variant.body(display, sourceRecordLabel), docHref && (_jsxs(_Fragment, { children: [' ', _jsx("a", { href: docHref, target: "_blank", rel: "noreferrer", className: "underline underline-offset-2 hover:opacity-80", children: "Learn more \u2192" })] }))] })] }));
38
+ }
@@ -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, Bell, } from 'lucide-react';
22
+ import { Search, HelpCircle, ChevronDown, Settings, LogOut, User as UserIcon, Boxes, Bell, CheckSquare, } 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';
@@ -66,6 +66,9 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
66
66
  const presenceUnavailableRef = useRef(false);
67
67
  const activityUnavailableRef = useRef(false);
68
68
  const notificationsUnavailableRef = useRef(false);
69
+ /** M11.C15: pending approvals count for the topbar shortcut. */
70
+ const [pendingApprovalsCount, setPendingApprovalsCount] = useState(0);
71
+ const approvalsUnavailableRef = useRef(false);
69
72
  const fetchPresenceAndActivities = useCallback(async () => {
70
73
  if (!dataSource || !isApp)
71
74
  return;
@@ -107,10 +110,22 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
107
110
  }, [dataSource, isApp]);
108
111
  useEffect(() => { fetchPresenceAndActivities(); }, [fetchPresenceAndActivities]);
109
112
  /**
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.
113
+ * M10.8 + M11.B3: poll sys_notification for the signed-in user.
114
+ *
115
+ * - Limited to the 20 most-recent entries; unread count drives the bell badge.
116
+ * - Adaptive interval: 10s while the tab is foregrounded (was 30s) so the
117
+ * bell reflects mentions / assignments within seconds without requiring a
118
+ * server-push transport.
119
+ * - Immediate refetch on `visibilitychange` when the user returns to the
120
+ * tab, so a backgrounded tab catches up the moment it regains focus.
121
+ * - On transient errors, switches to exponential backoff (cap 2 min) and
122
+ * resets on the first successful fetch.
123
+ * - Tolerates 404 so deployments without sys_notification degrade silently.
124
+ *
125
+ * Full server-push (SSE / WebSocket) is tracked separately as a M12
126
+ * enhancement; this adaptive poll already reduces perceived latency from
127
+ * ~15s (average half of the old 30s window) to ~5s and is sufficient for
128
+ * pilots up to ~50 concurrent users.
114
129
  */
115
130
  useEffect(() => {
116
131
  if (!dataSource || !isApp || !user?.id)
@@ -118,6 +133,11 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
118
133
  if (notificationsUnavailableRef.current)
119
134
  return;
120
135
  let cancelled = false;
136
+ let timer = null;
137
+ const ACTIVE_INTERVAL_MS = 10000;
138
+ const HIDDEN_INTERVAL_MS = 60000;
139
+ const MAX_BACKOFF_MS = 120000;
140
+ let backoffMs = ACTIVE_INTERVAL_MS;
121
141
  const isMissingResource = (err) => err?.httpStatus === 404 || err?.status === 404 || err?.code === 'object_not_found';
122
142
  const fetchOnce = async () => {
123
143
  try {
@@ -130,24 +150,108 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
130
150
  return;
131
151
  if (Array.isArray(res?.data))
132
152
  setNotifications(res.data);
153
+ backoffMs = ACTIVE_INTERVAL_MS;
133
154
  }
134
155
  catch (err) {
135
- if (isMissingResource(err))
156
+ if (isMissingResource(err)) {
136
157
  notificationsUnavailableRef.current = true;
158
+ return;
159
+ }
160
+ backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
137
161
  }
138
162
  };
139
- fetchOnce();
140
- const handle = setInterval(() => {
141
- if (typeof document !== 'undefined' && document.hidden)
163
+ const scheduleNext = () => {
164
+ if (cancelled || notificationsUnavailableRef.current)
142
165
  return;
143
- if (notificationsUnavailableRef.current) {
144
- clearInterval(handle);
166
+ const hidden = typeof document !== 'undefined' && document.hidden;
167
+ const delay = hidden ? HIDDEN_INTERVAL_MS : backoffMs;
168
+ timer = setTimeout(async () => {
169
+ await fetchOnce();
170
+ scheduleNext();
171
+ }, delay);
172
+ };
173
+ const onVisibilityChange = () => {
174
+ if (cancelled)
175
+ return;
176
+ if (typeof document === 'undefined' || document.hidden)
145
177
  return;
178
+ if (timer) {
179
+ clearTimeout(timer);
180
+ timer = null;
181
+ }
182
+ backoffMs = ACTIVE_INTERVAL_MS;
183
+ fetchOnce().finally(scheduleNext);
184
+ };
185
+ fetchOnce().finally(scheduleNext);
186
+ if (typeof document !== 'undefined') {
187
+ document.addEventListener('visibilitychange', onVisibilityChange);
188
+ }
189
+ return () => {
190
+ cancelled = true;
191
+ if (timer)
192
+ clearTimeout(timer);
193
+ if (typeof document !== 'undefined') {
194
+ document.removeEventListener('visibilitychange', onVisibilityChange);
146
195
  }
147
- fetchOnce();
148
- }, 30000);
149
- return () => { cancelled = true; clearInterval(handle); };
196
+ };
150
197
  }, [dataSource, isApp, user?.id]);
198
+ /**
199
+ * M11.C15: poll pending-approvals count for the topbar shortcut badge.
200
+ * Hits the framework's `/api/v1/approvals/requests?status=pending`
201
+ * endpoint with the user's identities (id, email, role:<r>). Degrades
202
+ * silently to zero on 404 (approvals plugin not installed).
203
+ */
204
+ useEffect(() => {
205
+ if (!isApp || !user?.id)
206
+ return;
207
+ if (approvalsUnavailableRef.current)
208
+ return;
209
+ const serverUrl = (import.meta.env?.VITE_SERVER_URL || '').replace(/\/$/, '');
210
+ const base = `${serverUrl}/api/v1/approvals/requests`;
211
+ const identities = [];
212
+ if (user.id)
213
+ identities.push(user.id);
214
+ if (user.email)
215
+ identities.push(user.email);
216
+ for (const r of (user.roles || [])) {
217
+ if (r)
218
+ identities.push(`role:${r}`);
219
+ }
220
+ let cancelled = false;
221
+ let timer = null;
222
+ const POLL_MS = 30000;
223
+ const fetchOnce = async () => {
224
+ try {
225
+ const seen = new Set();
226
+ if (identities.length === 0)
227
+ return;
228
+ for (const id of identities) {
229
+ const qs = new URLSearchParams({ status: 'pending', approverId: id });
230
+ const res = await fetch(`${base}?${qs}`, { credentials: 'include' });
231
+ if (res.status === 404) {
232
+ approvalsUnavailableRef.current = true;
233
+ return;
234
+ }
235
+ if (!res.ok)
236
+ return;
237
+ const payload = await res.json().catch(() => null);
238
+ for (const row of (payload?.data || []))
239
+ seen.add(row.id);
240
+ }
241
+ if (!cancelled)
242
+ setPendingApprovalsCount(seen.size);
243
+ }
244
+ catch { /* transient — keep last value */ }
245
+ };
246
+ const schedule = () => {
247
+ if (cancelled || approvalsUnavailableRef.current)
248
+ return;
249
+ timer = setTimeout(async () => { await fetchOnce(); schedule(); }, POLL_MS);
250
+ };
251
+ fetchOnce().finally(schedule);
252
+ return () => { cancelled = true; if (timer)
253
+ clearTimeout(timer); };
254
+ }, [isApp, user?.id]);
151
255
  const unreadCount = notifications.reduce((n, x) => n + (x.is_read ? 0 : 1), 0);
152
256
  const markNotificationRead = useCallback(async (id) => {
153
257
  setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n));
@@ -271,7 +375,10 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
271
375
  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) => {
272
376
  const isLast = i === extraSegments.length - 1;
273
377
  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));
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: () => {
378
+ }), _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 }) }), pendingApprovalsCount >= 0 && (_jsxs(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 relative shrink-0", "aria-label": t('sidebar.approvals', { defaultValue: 'Approvals' }), title: t('sidebar.approvals', { defaultValue: 'Approvals' }), onClick: () => {
379
+ const app = currentAppName ?? params.appName;
380
+ navigate(app ? `/apps/${app}/system/approvals` : '/apps/setup/system/approvals');
381
+ }, children: [_jsx(CheckSquare, { className: "h-4 w-4" }), pendingApprovalsCount > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 h-4 min-w-[16px] rounded-full bg-amber-500 text-[10px] leading-4 text-white text-center px-1", children: pendingApprovalsCount > 9 ? '9+' : pendingApprovalsCount }))] })), _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
382
  markNotificationRead(n.id);
276
383
  if (n.source_object && n.source_id) {
277
384
  // Navigate to the related record; relative to /apps/<currentApp> if any.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * crudAffordances — UI-side mirror of the framework's
3
+ * `resolveCrudAffordances()` helper (see
4
+ * `@objectstack/spec/data/object.zod.ts`).
5
+ *
6
+ * The framework tags every object with a `managedBy` lifecycle bucket
7
+ * (`platform | config | system | append-only | better-auth`) and an
8
+ * optional `userActions` override block. UI components ask this helper
9
+ * "should I show the New / Import / Edit / Delete / Export buttons?" so
10
+ * the toolbar tracks the object's lifecycle automatically — no need for
11
+ * each view to special-case `sys_*` names.
12
+ *
13
+ * Keep this in lockstep with the framework helper. The bucket defaults
14
+ * mirror what Salesforce / ServiceNow / Workday / Notion do for the
15
+ * equivalent categories of system tables.
16
+ */
17
+ export type ManagedByBucket = 'platform' | 'config' | 'system' | 'append-only' | 'better-auth';
18
+ export interface CrudAffordances {
19
+ /** Generic "New" button for single-record creation. */
20
+ create: boolean;
21
+ /** CSV bulk-import wizard. */
22
+ import: boolean;
23
+ /** Inline + form editing of existing rows. */
24
+ edit: boolean;
25
+ /** Row-level + bulk delete. */
26
+ delete: boolean;
27
+ /** CSV / clipboard export. */
28
+ exportCsv: boolean;
29
+ }
30
+ export interface UserActionsOverride {
31
+ create?: boolean;
32
+ import?: boolean;
33
+ edit?: boolean;
34
+ delete?: boolean;
35
+ exportCsv?: boolean;
36
+ }
37
+ export interface SchemaLike {
38
+ managedBy?: string | null;
39
+ userActions?: UserActionsOverride | null;
40
+ }
41
+ export declare function resolveCrudAffordances(obj: SchemaLike | null | undefined): CrudAffordances;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * crudAffordances — UI-side mirror of the framework's
3
+ * `resolveCrudAffordances()` helper (see
4
+ * `@objectstack/spec/data/object.zod.ts`).
5
+ *
6
+ * The framework tags every object with a `managedBy` lifecycle bucket
7
+ * (`platform | config | system | append-only | better-auth`) and an
8
+ * optional `userActions` override block. UI components ask this helper
9
+ * "should I show the New / Import / Edit / Delete / Export buttons?" so
10
+ * the toolbar tracks the object's lifecycle automatically — no need for
11
+ * each view to special-case `sys_*` names.
12
+ *
13
+ * Keep this in lockstep with the framework helper. The bucket defaults
14
+ * mirror what Salesforce / ServiceNow / Workday / Notion do for the
15
+ * equivalent categories of system tables.
16
+ */
17
+ const DEFAULTS = {
18
+ platform: { create: true, import: true, edit: true, delete: true, exportCsv: true },
19
+ config: { create: true, import: false, edit: true, delete: true, exportCsv: true },
20
+ system: { create: false, import: false, edit: false, delete: false, exportCsv: true },
21
+ 'append-only': { create: false, import: false, edit: false, delete: false, exportCsv: true },
22
+ 'better-auth': { create: false, import: false, edit: false, delete: false, exportCsv: true },
23
+ };
24
+ export function resolveCrudAffordances(obj) {
25
+ const bucket = obj?.managedBy ?? 'platform';
26
+ const base = DEFAULTS[bucket] ?? DEFAULTS.platform;
27
+ const o = obj?.userActions ?? {};
28
+ return {
29
+ create: o.create ?? base.create,
30
+ import: o.import ?? base.import,
31
+ edit: o.edit ?? base.edit,
32
+ delete: o.delete ?? base.delete,
33
+ exportCsv: o.exportCsv ?? base.exportCsv,
34
+ };
35
+ }
@@ -26,6 +26,8 @@ import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
26
26
  import { ViewConfigPanel } from './ViewConfigPanel';
27
27
  import { CreateViewDialog } from './CreateViewDialog';
28
28
  import { PageHeader } from '../layout/PageHeader';
29
+ import { ManagedByBanner } from '../components/ManagedByBanner';
30
+ import { resolveCrudAffordances } from '../utils/crudAffordances';
29
31
  import { useObjectActions } from '../hooks/useObjectActions';
30
32
  import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
31
33
  import { usePermissions } from '@object-ui/permissions';
@@ -47,6 +49,40 @@ const VIEW_TYPE_ICONS = {
47
49
  chart: BarChart3,
48
50
  };
49
51
  const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
52
+ /**
53
+ * Replace built-in tokens (e.g. `{current_user_id}`) inside a filter array
54
+ * with concrete values. Filters from platform-shipped `listViews` or saved
55
+ * `sys_view` rows may declare context-sensitive predicates like
56
+ * `{ field: 'submitter_id', operator: 'equals', value: '{current_user_id}' }`
57
+ * — those need to be substituted before the query reaches the API.
58
+ *
59
+ * Recognised tokens:
60
+ * • `{current_user_id}` → the authenticated user's id
61
+ *
62
+ * Returns a deep-cloned copy with substitutions applied. Non-array input
63
+ * is returned unchanged.
64
+ */
65
+ function substituteFilterTokens(filter, currentUserId) {
66
+ if (!Array.isArray(filter))
67
+ return filter;
68
+ const sub = (v) => {
69
+ if (typeof v === 'string') {
70
+ if (v === '{current_user_id}')
71
+ return currentUserId ?? v;
72
+ return v;
73
+ }
74
+ if (Array.isArray(v))
75
+ return v.map(sub);
76
+ if (v && typeof v === 'object') {
77
+ const out = {};
78
+ for (const k of Object.keys(v))
79
+ out[k] = sub(v[k]);
80
+ return out;
81
+ }
82
+ return v;
83
+ };
84
+ return filter.map(sub);
85
+ }
50
86
  /**
51
87
  * Translate a NamedListView spec object (shape: `type`, `label`, `columns`,
52
88
  * `filter`, `sort`, `kanban`/`chart`/`gantt`/... sub-blocks, `bulkActions`,
@@ -457,6 +493,13 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
457
493
  }
458
494
  // Refresh trigger — bumped after view CRUD or external data mutations.
459
495
  const [refreshKey, setRefreshKey] = useState(0);
496
+ // Resolve which generic CRUD affordances belong in the toolbar for
497
+ // this object's lifecycle bucket (`managedBy`). config tables show
498
+ // New/Edit/Delete but no CSV Import; system / append-only / better-auth
499
+ // hide the lot — those flows go through purpose-built actions on the
500
+ // source record (e.g. "Submit for Approval" on an Opportunity creates
501
+ // an `sys_approval_request`). Permissions still gate the buttons.
502
+ const affordances = useMemo(() => resolveCrudAffordances(objectDef), [objectDef]);
460
503
  // Propagate externally-triggered refreshes (e.g. global ModalForm submit)
461
504
  // into our internal refreshKey so list/data effects re-run.
462
505
  useEffect(() => {
@@ -1285,9 +1328,10 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1285
1328
  sort: viewDef.sort ?? listSchema.sort,
1286
1329
  filter: (() => {
1287
1330
  const base = viewDef.filter ?? listSchema.filter;
1331
+ const substituted = substituteFilterTokens(base, currentUser.id);
1288
1332
  if (!urlFilters.length)
1289
- return base;
1290
- const baseArr = Array.isArray(base) ? base : [];
1333
+ return substituted;
1334
+ const baseArr = Array.isArray(substituted) ? substituted : [];
1291
1335
  return [...baseArr, ...urlFilters];
1292
1336
  })(),
1293
1337
  hiddenFields: viewDef.hiddenFields ?? listSchema.hiddenFields,
@@ -1359,7 +1403,8 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1359
1403
  ...(Array.isArray(viewDef.filter) ? viewDef.filter : []),
1360
1404
  ...urlFilters,
1361
1405
  ];
1362
- return combined.length ? { filters: combined } : {};
1406
+ const substituted = substituteFilterTokens(combined, currentUser.id);
1407
+ return Array.isArray(substituted) && substituted.length ? { filters: substituted } : {};
1363
1408
  })()),
1364
1409
  ...(viewDef.sort?.length ? { sort: viewDef.sort } : {}),
1365
1410
  options: {
@@ -1489,7 +1534,7 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1489
1534
  }
1490
1535
  },
1491
1536
  }), [objectDef.name, onEdit, activeView?.showSearch, activeView?.showFilters, activeView?.showSort, navigate, viewId, isAdmin]);
1492
- return (_jsxs(ActionProvider, { context: { objectName: objectDef.name, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: [_jsxs("div", { className: "h-full flex flex-col bg-background min-w-0 overflow-hidden", children: [_jsx(PageHeader, { title: objectLabel(objectDef), description: objectDef.description ? objectDesc(objectDef) : undefined, icon: (() => { const I = getIcon(objectDef?.icon); return _jsx(I, { className: "h-4 w-4" }); })(), actions: _jsxs(_Fragment, { children: [can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", onClick: actions.create, className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.new') })] })), can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => setShowImport(true), className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", title: t('console.objectView.importTitle'), "data-testid": "object-view-import-button", children: [_jsx(Upload, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.import') })] })), objectDef.actions?.some((a) => a.locations?.includes('list_toolbar')) && (_jsx(SchemaRenderer, { schema: {
1537
+ return (_jsxs(ActionProvider, { context: { objectName: objectDef.name, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: [_jsxs("div", { className: "h-full flex flex-col bg-background min-w-0 overflow-hidden", children: [_jsx(ManagedByBanner, { managedBy: objectDef?.managedBy }), _jsx(PageHeader, { title: objectLabel(objectDef), description: objectDef.description ? objectDesc(objectDef) : undefined, icon: (() => { const I = getIcon(objectDef?.icon); return _jsx(I, { className: "h-4 w-4" }); })(), actions: _jsxs(_Fragment, { children: [affordances.create && can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", onClick: actions.create, className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.new') })] })), affordances.import && can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => setShowImport(true), className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", title: t('console.objectView.importTitle'), "data-testid": "object-view-import-button", children: [_jsx(Upload, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.import') })] })), objectDef.actions?.some((a) => a.locations?.includes('list_toolbar')) && (_jsx(SchemaRenderer, { schema: {
1493
1538
  type: 'action:bar',
1494
1539
  location: 'list_toolbar',
1495
1540
  actions: (objectDef.actions || []).map((a) => ({
@@ -17,6 +17,8 @@ import { toast } from 'sonner';
17
17
  import { Database, Users } from 'lucide-react';
18
18
  import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
19
19
  import { SkeletonDetail } from '../skeletons';
20
+ import { ManagedByBanner } from '../components/ManagedByBanner';
21
+ import { resolveCrudAffordances } from '../utils/crudAffordances';
20
22
  import { ActionConfirmDialog } from './ActionConfirmDialog';
21
23
  import { ActionParamDialog } from './ActionParamDialog';
22
24
  import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
@@ -701,13 +703,19 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
701
703
  onRowDelete,
702
704
  };
703
705
  });
706
+ const affordances = resolveCrudAffordances(objectDef);
704
707
  return {
705
708
  type: 'detail-view',
706
709
  objectName: objectDef.name,
707
710
  resourceId: pureRecordId,
708
711
  showBack: true,
709
712
  onBack: 'history',
710
- showEdit: true,
713
+ // Hide the Edit button for objects whose lifecycle isn't user-managed
714
+ // (approval requests, audit logs, better-auth tables, …). The
715
+ // underlying form is also disabled when `managedBy !== 'platform'`
716
+ // (see plugin-form/ObjectForm), so even if a stray Edit URL is
717
+ // visited the inputs render read-only.
718
+ showEdit: affordances.edit,
711
719
  title: objectDef.label,
712
720
  primaryField,
713
721
  sections,
@@ -732,7 +740,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
732
740
  if (!objectDef) {
733
741
  return (_jsx("div", { className: "flex h-full items-center justify-center p-4", 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(Database, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('empty.objectNotFound') }), _jsx(EmptyDescription, { children: t('empty.objectNotFoundDescription', { name: objectName }) })] }) }));
734
742
  }
735
- return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsx("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: t('recordDetail.viewersTooltip'), children: [_jsx(Users, { className: "h-3.5 w-3.5 text-muted-foreground" }), _jsx(PresenceAvatars, { users: recordViewers, size: "sm", maxVisible: 4, showStatus: true })] })) }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsx("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: _jsx(ActionProvider, { context: { record: {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), onDataLoaded: (record) => {
743
+ return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsx(ManagedByBanner, { managedBy: objectDef?.managedBy }), _jsx("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: t('recordDetail.viewersTooltip'), children: [_jsx(Users, { className: "h-3.5 w-3.5 text-muted-foreground" }), _jsx(PresenceAvatars, { users: recordViewers, size: "sm", maxVisible: 4, showStatus: true })] })) }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsx("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: _jsx(ActionProvider, { context: { record: {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), onDataLoaded: (record) => {
736
744
  if (!record || typeof record !== 'object')
737
745
  return;
738
746
  // Resolve the same way DetailView's header does, so the
@@ -39,6 +39,7 @@ import { useMetadata } from '../providers/MetadataProvider';
39
39
  import { useAdapter } from '../providers/AdapterProvider';
40
40
  import { ExpressionProvider, evaluateVisibility } from '../providers/ExpressionProvider';
41
41
  import { SkeletonDetail } from '../skeletons';
42
+ import { ManagedByBanner } from '../components/ManagedByBanner';
42
43
  import { useAuth } from '@object-ui/auth';
43
44
  import { ExpressionEvaluator } from '@object-ui/core';
44
45
  /**
@@ -161,7 +162,7 @@ export function RecordFormPage({ mode }) {
161
162
  if (!objectDef) {
162
163
  return (_jsx("div", { className: "flex h-full items-center justify-center p-4", 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(Database, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('empty.objectNotFound') }), _jsx(EmptyDescription, { children: t('empty.objectNotFoundDescription', { name: objectName }) }), _jsx("div", { className: "mt-4", children: _jsxs(Button, { variant: "outline", onClick: () => navigate(baseUrl), children: [_jsx(ArrowLeft, { className: "mr-2 h-4 w-4" }), t('empty.back')] }) })] }) }));
163
164
  }
164
- return (_jsx(ExpressionProvider, { user: expressionUser, app: { name: appName }, data: {}, children: _jsxs("div", { className: "flex flex-col h-full overflow-hidden bg-background", "data-testid": "record-form-page", "data-mode": mode, children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-center gap-3 border-b bg-background px-4 py-3 sm:px-6", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: goBack, "data-testid": "record-form-page-back", "aria-label": t('common.back', { defaultValue: 'Back' }), children: _jsx(ArrowLeft, { className: "h-4 w-4" }) }), _jsxs("nav", { "aria-label": "Breadcrumb", className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(Link, { to: objectListUrl, className: "hover:text-foreground transition-colors", children: label }), _jsx("span", { "aria-hidden": "true", children: "/" }), _jsx("span", { className: "text-foreground font-medium", "data-testid": "record-form-page-title", children: pageTitle })] })] }), _jsx("div", { className: "flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto max-w-4xl", children: _jsx(ObjectForm, { schema: {
165
+ return (_jsx(ExpressionProvider, { user: expressionUser, app: { name: appName }, data: {}, children: _jsxs("div", { className: "flex flex-col h-full overflow-hidden bg-background", "data-testid": "record-form-page", "data-mode": mode, children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-center gap-3 border-b bg-background px-4 py-3 sm:px-6", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: goBack, "data-testid": "record-form-page-back", "aria-label": t('common.back', { defaultValue: 'Back' }), children: _jsx(ArrowLeft, { className: "h-4 w-4" }) }), _jsxs("nav", { "aria-label": "Breadcrumb", className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(Link, { to: objectListUrl, className: "hover:text-foreground transition-colors", children: label }), _jsx("span", { "aria-hidden": "true", children: "/" }), _jsx("span", { className: "text-foreground font-medium", "data-testid": "record-form-page-title", children: pageTitle })] })] }), _jsx(ManagedByBanner, { managedBy: objectDef?.managedBy }), _jsx("div", { className: "flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto max-w-4xl", children: _jsx(ObjectForm, { schema: {
165
166
  type: 'object-form',
166
167
  formType: 'simple',
167
168
  objectName: objectDef.name,
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useCallback, useMemo, lazy, Suspense } from 'react';
3
3
  import { useParams } from 'react-router-dom';
4
4
  const ReportViewer = lazy(() => import('@object-ui/plugin-report').then((m) => ({ default: m.ReportViewer })));
5
+ const ReportRenderer = lazy(() => import('@object-ui/plugin-report').then((m) => ({ default: m.ReportRenderer })));
5
6
  const ReportConfigPanel = lazy(() => import('@object-ui/plugin-report').then((m) => ({ default: m.ReportConfigPanel })));
6
7
  import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
7
8
  import { Pencil, BarChart3, Loader2 } from 'lucide-react';
@@ -326,6 +327,20 @@ export function ReportView({ dataSource }) {
326
327
  };
327
328
  // Use live-edited schema for preview (persists after closing panel until metadata refreshes)
328
329
  const previewReport = editSchema || reportData;
330
+ // Route any object-backed spec report (matrix/joined/tabular/summary) through
331
+ // the spec ReportRenderer dispatcher. It handles aggregation, charts, KPIs
332
+ // and drill protocol end-to-end. The legacy ReportViewer is only used as a
333
+ // last resort for fully-legacy schemas that lack `objectName` (e.g. inline
334
+ // `fields` + `data` arrays from older app code).
335
+ const useSpecRenderer = Boolean(previewReport &&
336
+ previewReport.objectName &&
337
+ (previewReport.type === 'matrix' ||
338
+ previewReport.type === 'joined' ||
339
+ previewReport.type === 'summary' ||
340
+ previewReport.type === 'tabular' ||
341
+ previewReport.type === undefined ||
342
+ (Array.isArray(previewReport.groupingsAcross) && previewReport.groupingsAcross.length > 0) ||
343
+ Array.isArray(previewReport.columns)));
329
344
  const reportForViewer = mapReportForViewer(previewReport);
330
345
  const viewerSchema = {
331
346
  type: 'report-viewer',
@@ -335,5 +350,5 @@ export function ReportView({ dataSource }) {
335
350
  allowExport: true,
336
351
  loading: dataLoading, // Loading state for data fetching
337
352
  };
338
- 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: previewReport.title || previewReport.label || 'Report Viewer' }), previewReport.description && (_jsx("p", { className: "text-sm text-muted-foreground mt-1 line-clamp-2", children: previewReport.description }))] }), _jsx("div", { className: "shrink-0 flex items-center gap-1.5", children: _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": "report-edit-button", children: [_jsx(Pencil, { className: "h-3.5 w-3.5" }), "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-4 sm:p-6 lg:p-8 bg-muted/5", children: _jsx("div", { className: "max-w-5xl mx-auto shadow-sm border rounded-lg sm:rounded-xl bg-background overflow-hidden min-h-150", children: _jsx(Suspense, { fallback: _jsx("div", { className: "p-8 text-sm text-muted-foreground", children: "Loading report\u2026" }), children: _jsx(ReportViewer, { schema: viewerSchema }) }) }) }), _jsx(Suspense, { fallback: null, children: _jsx(ReportConfigPanel, { open: configPanelOpen, onClose: handleCloseConfigPanel, config: reportConfig, onSave: handleReportConfigSave, onFieldChange: handleReportFieldChange, availableFields: availableFields }) }), _jsx(MetadataPanel, { open: showDebug, sections: [{ title: 'Report Configuration', data: previewReport }] })] })] }));
353
+ 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: previewReport.title || previewReport.label || 'Report Viewer' }), previewReport.description && (_jsx("p", { className: "text-sm text-muted-foreground mt-1 line-clamp-2", children: previewReport.description }))] }), _jsx("div", { className: "shrink-0 flex items-center gap-1.5", children: _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": "report-edit-button", children: [_jsx(Pencil, { className: "h-3.5 w-3.5" }), "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-4 sm:p-6 lg:p-8 bg-muted/5", children: _jsx("div", { className: "w-full shadow-sm border rounded-lg sm:rounded-xl bg-background overflow-hidden min-h-150", children: _jsx(Suspense, { fallback: _jsx("div", { className: "p-8 text-sm text-muted-foreground", children: "Loading report\u2026" }), children: useSpecRenderer ? (_jsx("div", { className: "p-4 sm:p-6", children: _jsx(ReportRenderer, { schema: previewReport, dataSource: dataSource }) })) : (_jsx(ReportViewer, { schema: viewerSchema })) }) }) }), _jsx(Suspense, { fallback: null, children: _jsx(ReportConfigPanel, { open: configPanelOpen, onClose: handleCloseConfigPanel, config: reportConfig, onSave: handleReportConfigSave, onFieldChange: handleReportFieldChange, availableFields: availableFields }) }), _jsx(MetadataPanel, { open: showDebug, sections: [{ title: 'Report Configuration', data: previewReport }] })] })] }));
339
354
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/app-shell",
3
- "version": "4.0.12",
3
+ "version": "4.2.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Minimal application shell for ObjectUI - framework-agnostic rendering engine",
@@ -27,34 +27,34 @@
27
27
  "dependencies": {
28
28
  "lucide-react": "^1.16.0",
29
29
  "sonner": "^2.0.7",
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"
30
+ "@object-ui/auth": "4.2.1",
31
+ "@object-ui/collaboration": "4.2.1",
32
+ "@object-ui/components": "4.2.1",
33
+ "@object-ui/core": "4.2.1",
34
+ "@object-ui/data-objectstack": "4.2.1",
35
+ "@object-ui/fields": "4.2.1",
36
+ "@object-ui/i18n": "4.2.1",
37
+ "@object-ui/layout": "4.2.1",
38
+ "@object-ui/permissions": "4.2.1",
39
+ "@object-ui/react": "4.2.1",
40
+ "@object-ui/types": "4.2.1"
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.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"
46
+ "@object-ui/plugin-calendar": "^4.2.1",
47
+ "@object-ui/plugin-charts": "^4.2.1",
48
+ "@object-ui/plugin-chatbot": "^4.2.1",
49
+ "@object-ui/plugin-dashboard": "^4.2.1",
50
+ "@object-ui/plugin-designer": "^4.2.1",
51
+ "@object-ui/plugin-detail": "^4.2.1",
52
+ "@object-ui/plugin-form": "^4.2.1",
53
+ "@object-ui/plugin-grid": "^4.2.1",
54
+ "@object-ui/plugin-kanban": "^4.2.1",
55
+ "@object-ui/plugin-list": "^4.2.1",
56
+ "@object-ui/plugin-report": "^4.2.1",
57
+ "@object-ui/plugin-view": "^4.2.1"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^25.9.0",