@object-ui/app-shell 4.4.0 → 4.6.0

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.
@@ -14,7 +14,6 @@ import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
14
14
  const ObjectChart = lazy(() => import('@object-ui/plugin-charts').then((m) => ({ default: m.ObjectChart })));
15
15
  const ImportWizard = lazy(() => import('@object-ui/plugin-grid').then((m) => ({ default: m.ImportWizard })));
16
16
  import { ListView } from '@object-ui/plugin-list';
17
- import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
18
17
  import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@object-ui/plugin-view';
19
18
  // Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx
20
19
  // uses ComponentRegistry.registerLazy so heavy plugins stay code-split).
@@ -27,6 +26,7 @@ import { ViewConfigPanel } from './ViewConfigPanel';
27
26
  import { CreateViewDialog } from './CreateViewDialog';
28
27
  import { PageHeader } from '../layout/PageHeader';
29
28
  import { ManagedByBadge } from '../components/ManagedByBadge';
29
+ import { RecordDetailView } from './RecordDetailView';
30
30
  import { resolveCrudAffordances } from '../utils/crudAffordances';
31
31
  import { resolveManagedByEmptyState } from '../utils/managedByEmptyState';
32
32
  import { useObjectActions } from '../hooks/useObjectActions';
@@ -191,157 +191,6 @@ function fromSysViewRecord(sv) {
191
191
  pageSize: sv.page_size ?? sv.pageSize,
192
192
  };
193
193
  }
194
- /**
195
- * DrawerDetailContent — extracted component for NavigationOverlay content.
196
- * Needs to be a proper component (not a render prop) so it can use hooks
197
- * for data fetching, comment handling, etc.
198
- */
199
- function DrawerDetailContent({ objectDef, recordId, dataSource, onEdit }) {
200
- const { user } = useAuth();
201
- const currentUser = user
202
- ? { id: user.id, name: user.name, avatar: user.image }
203
- : FALLBACK_USER;
204
- const [feedItems, setFeedItems] = useState([]);
205
- // Fetch persisted comments from API
206
- useEffect(() => {
207
- if (!dataSource || !objectDef?.name || !recordId)
208
- return;
209
- const threadId = `${objectDef.name}:${recordId}`;
210
- dataSource.find('sys_comment', { $filter: { threadId }, $orderby: { createdAt: 'asc' } })
211
- .then((res) => {
212
- if (res.data?.length) {
213
- setFeedItems(res.data.map((c) => ({
214
- id: c.id,
215
- type: 'comment',
216
- actor: c.author?.name ?? 'Unknown',
217
- actorAvatarUrl: c.author?.avatar,
218
- body: c.content,
219
- createdAt: c.createdAt,
220
- updatedAt: c.updatedAt,
221
- parentId: c.parentId,
222
- reactions: c.reactions
223
- ? Object.entries(c.reactions).map(([emoji, userIds]) => ({
224
- emoji,
225
- count: userIds.length,
226
- reacted: userIds.includes(currentUser.id),
227
- }))
228
- : undefined,
229
- })));
230
- }
231
- })
232
- .catch(() => { });
233
- }, [dataSource, objectDef?.name, recordId, currentUser.id]);
234
- const handleAddComment = useCallback(async (text) => {
235
- const newItem = {
236
- id: crypto.randomUUID(),
237
- type: 'comment',
238
- actor: currentUser.name,
239
- actorAvatarUrl: 'avatar' in currentUser ? currentUser.avatar : undefined,
240
- body: text,
241
- createdAt: new Date().toISOString(),
242
- };
243
- setFeedItems(prev => [...prev, newItem]);
244
- if (dataSource) {
245
- const threadId = `${objectDef.name}:${recordId}`;
246
- dataSource.create('sys_comment', {
247
- id: newItem.id,
248
- threadId,
249
- author: currentUser,
250
- content: text,
251
- mentions: [],
252
- createdAt: newItem.createdAt,
253
- }).catch(() => { });
254
- }
255
- }, [currentUser, dataSource, objectDef?.name, recordId]);
256
- const handleAddReply = useCallback(async (parentId, text) => {
257
- const newItem = {
258
- id: crypto.randomUUID(),
259
- type: 'comment',
260
- actor: currentUser.name,
261
- actorAvatarUrl: 'avatar' in currentUser ? currentUser.avatar : undefined,
262
- body: text,
263
- createdAt: new Date().toISOString(),
264
- parentId,
265
- };
266
- setFeedItems(prev => {
267
- const updated = [...prev, newItem];
268
- return updated.map(item => item.id === parentId
269
- ? { ...item, replyCount: (item.replyCount ?? 0) + 1 }
270
- : item);
271
- });
272
- if (dataSource) {
273
- const threadId = `${objectDef.name}:${recordId}`;
274
- dataSource.create('sys_comment', {
275
- id: newItem.id,
276
- threadId,
277
- author: currentUser,
278
- content: text,
279
- mentions: [],
280
- createdAt: newItem.createdAt,
281
- parentId,
282
- }).catch(() => { });
283
- }
284
- }, [currentUser, dataSource, objectDef?.name, recordId]);
285
- const handleToggleReaction = useCallback((itemId, emoji) => {
286
- setFeedItems(prev => prev.map(item => {
287
- if (item.id !== itemId)
288
- return item;
289
- const reactions = [...(item.reactions ?? [])];
290
- const idx = reactions.findIndex(r => r.emoji === emoji);
291
- if (idx >= 0) {
292
- const r = reactions[idx];
293
- if (r.reacted) {
294
- if (r.count <= 1) {
295
- reactions.splice(idx, 1);
296
- }
297
- else {
298
- reactions[idx] = { ...r, count: r.count - 1, reacted: false };
299
- }
300
- }
301
- else {
302
- reactions[idx] = { ...r, count: r.count + 1, reacted: true };
303
- }
304
- }
305
- else {
306
- reactions.push({ emoji, count: 1, reacted: true });
307
- }
308
- const updated = { ...item, reactions };
309
- if (dataSource) {
310
- dataSource.update('sys_comment', String(itemId), {
311
- $toggleReaction: { emoji, userId: currentUser.id },
312
- }).catch(() => { });
313
- }
314
- return updated;
315
- }));
316
- }, [currentUser.id, dataSource]);
317
- return (_jsxs("div", { className: "h-full bg-background overflow-auto p-3 sm:p-4 lg:p-6", children: [_jsx(DetailView, { schema: {
318
- type: 'detail-view',
319
- objectName: objectDef.name,
320
- resourceId: recordId,
321
- showBack: false,
322
- showEdit: true,
323
- title: objectDef.label,
324
- sections: [
325
- {
326
- title: 'Details',
327
- fields: Object.keys(objectDef.fields || {}).map((key) => ({
328
- name: key,
329
- label: objectDef.fields[key].label || key,
330
- type: objectDef.fields[key].type || 'text'
331
- })),
332
- }
333
- ]
334
- }, dataSource: dataSource, onEdit: () => onEdit({ id: recordId }) }), _jsx("div", { className: "mt-6 border-t pt-6", children: _jsx(RecordChatterPanel, { config: {
335
- position: 'bottom',
336
- collapsible: true,
337
- defaultCollapsed: true,
338
- feed: {
339
- enableReactions: true,
340
- enableThreading: true,
341
- showCommentInput: true,
342
- },
343
- }, items: feedItems, onAddComment: handleAddComment, onAddReply: handleAddReply, onToggleReaction: handleToggleReaction }) })] }));
344
- }
345
194
  export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }) {
346
195
  const navigate = useNavigate();
347
196
  const { objectName, viewId } = useParams();
@@ -1326,9 +1175,16 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1326
1175
  }
1327
1176
  }, [dataSource, objectDef.name, refreshKey]);
1328
1177
  // Navigation overlay for record detail (supports drawer/modal/split/popover via config)
1329
- // Priority: activeView.navigation > objectDef.navigation > default page
1330
- // Memoize to avoid unstable object identity on every render (stale closure prevention)
1331
- const detailNavigation = useMemo(() => activeView?.navigation ?? objectDef.navigation ?? { mode: 'page' }, [activeView?.navigation, objectDef.navigation]);
1178
+ // Priority: activeView.navigation > objectDef.navigation > default drawer.
1179
+ //
1180
+ // Default mode = 'drawer'. Mirrors Linear / Notion / Airtable / Jira where
1181
+ // record peek is the primary interaction and full-page is the upgrade.
1182
+ // Direct URL access (`/record/:id`) still opens as a full page because
1183
+ // RecordDetailView owns its own route — only same-page click navigation
1184
+ // is drawer-by-default. Per-view config can still override (e.g. a heavy
1185
+ // detail object can set `navigation.mode = 'page'`).
1186
+ const detailNavigation = useMemo(() => activeView?.navigation ??
1187
+ objectDef.navigation ?? { mode: 'drawer', width: 'min(92vw, 1280px)' }, [activeView?.navigation, objectDef.navigation]);
1332
1188
  const drawerRecordId = searchParams.get('recordId');
1333
1189
  /**
1334
1190
  * URL-derived equality filters in the form `?filter[<field>]=<value>`.
@@ -1379,6 +1235,49 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1379
1235
  newParams.delete('recordId');
1380
1236
  setSearchParams(newParams);
1381
1237
  };
1238
+ /**
1239
+ * Row-click handler used by all list/grid/gallery/kanban surfaces inside
1240
+ * this object view. Wraps `navOverlay.handleClick` so that drawer-mode
1241
+ * opens are URL-driven (writes `?recordId=…`) — making the drawer state
1242
+ * shareable, refresh-safe, and respected by browser back/forward. The
1243
+ * URL→state sync effect below handles actually opening the drawer.
1244
+ */
1245
+ const handleRowClick = useCallback((record, event) => {
1246
+ // Cmd/Ctrl/middle-click — let the hook open in a new tab (full page)
1247
+ // regardless of configured mode. Matches browser link convention.
1248
+ const isModifier = !!(event && (event.metaKey || event.ctrlKey || event.button === 1));
1249
+ if (isModifier) {
1250
+ navOverlay.handleClick(record, event);
1251
+ return;
1252
+ }
1253
+ // Drawer mode → URL is the source of truth. Push `?recordId=…`
1254
+ // and let the existing URL-sync effect open the overlay.
1255
+ if (navOverlay.mode === 'drawer') {
1256
+ const id = (record?.id ?? record?._id);
1257
+ if (id != null) {
1258
+ const next = new URLSearchParams(searchParams);
1259
+ next.set('recordId', String(id));
1260
+ setSearchParams(next);
1261
+ return;
1262
+ }
1263
+ }
1264
+ // All other modes (page / modal / split / popover / new_window / none)
1265
+ // — delegate to the hook.
1266
+ navOverlay.handleClick(record, event);
1267
+ }, [navOverlay, searchParams, setSearchParams]);
1268
+ /**
1269
+ * "Expand to full page" — invoked from the drawer header chevron. Closes
1270
+ * the drawer (which clears `?recordId=…`) and router-pushes to the
1271
+ * dedicated `/record/:id` route. Mirrors Linear/Notion peek-to-page.
1272
+ */
1273
+ const handleExpandDrawer = useCallback(() => {
1274
+ const rec = navOverlay.selectedRecord;
1275
+ const id = rec && (rec.id ?? rec._id);
1276
+ if (id == null)
1277
+ return;
1278
+ handleDrawerClose();
1279
+ handleNavOverlayNavigate(id);
1280
+ }, [navOverlay.selectedRecord, handleNavOverlayNavigate]);
1382
1281
  // Sync URL-based recordId to overlay state
1383
1282
  useEffect(() => {
1384
1283
  if (drawerRecordId && !navOverlay.isOpen) {
@@ -1606,8 +1505,8 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1606
1505
  }),
1607
1506
  params: { records: valid },
1608
1507
  });
1609
- }, onRowClick: (record) => {
1610
- navOverlay.handleClick(record);
1508
+ }, onRowClick: (record, event) => {
1509
+ handleRowClick(record, event);
1611
1510
  }, onSortChange: (sort) => {
1612
1511
  persistViewPatch(viewDef.id, viewDef, { sort });
1613
1512
  }, onFilterChange: (filter) => {
@@ -1727,20 +1626,20 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1727
1626
  showVisibilityGroups: true,
1728
1627
  }, onAddView: isAdmin ? handleAddView : undefined, onRenameView: isAdmin ? handleRenameView : undefined, onDeleteView: isAdmin ? handleDeleteView : undefined, onDuplicateView: isAdmin ? handleDuplicateView : undefined, onPinView: isAdmin ? handlePinView : undefined, onSetDefaultView: isAdmin ? handleSetDefaultView : undefined, onConfigView: isAdmin ? handleConfigView : undefined, onManageViews: isAdmin ? () => setManageViewsOpen(true) : undefined }), isAdmin && (_jsx(ManageViewsDialog, { open: manageViewsOpen, onOpenChange: setManageViewsOpen, views: viewTabItems, activeViewId: activeViewId, viewTypeIcons: VIEW_TYPE_ICONS, onRename: handleRenameView, onDelete: handleDeleteView, onDuplicate: handleDuplicateView, onSetDefault: handleSetDefaultView, onSetPinned: handlePinView, onReorder: handleReorderViews, onAddView: handleAddView, onConfigView: handleConfigView }))] }));
1729
1628
  })(), _jsxs("div", { className: "flex-1 overflow-hidden relative flex flex-row", children: [navOverlay.mode === 'split' && navOverlay.isOpen ? (_jsx(NavigationOverlay, { ...navOverlay, setIsOpen: (open) => { if (!open)
1730
- handleDrawerClose(); }, title: objectLabel(objectDef), mainContent: _jsxs("div", { className: "flex-1 min-w-0 relative h-full flex flex-col", children: [_jsx("div", { className: "flex-1 relative overflow-hidden", children: _jsx("div", { className: "h-full overflow-auto", children: _jsx(PluginObjectView, { schema: objectViewSchema, dataSource: dataSource, views: mergedViews, activeViewId: activeViewId, onViewChange: handleViewChange, onEdit: (record) => onEdit?.(record), onRowClick: (record) => {
1731
- navOverlay.handleClick(record);
1629
+ handleDrawerClose(); }, title: objectLabel(objectDef), onExpand: handleExpandDrawer, expandLabel: t('console.objectView.expandToPage', { defaultValue: 'Open as full page' }), storageKey: `drawer-width:${objectDef.name}`, mainContent: _jsxs("div", { className: "flex-1 min-w-0 relative h-full flex flex-col", children: [_jsx("div", { className: "flex-1 relative overflow-hidden", children: _jsx("div", { className: "h-full overflow-auto", children: _jsx(PluginObjectView, { schema: objectViewSchema, dataSource: dataSource, views: mergedViews, activeViewId: activeViewId, onViewChange: handleViewChange, onEdit: (record) => onEdit?.(record), onRowClick: (record, event) => {
1630
+ handleRowClick(record, event);
1732
1631
  }, renderListView: renderListView, onCreateView: handleCreateView, onViewAction: handleViewAction }) }) }), typeof recordCount === 'number' && (_jsx("div", { "data-testid": "record-count-footer", className: "border-t px-3 sm:px-4 py-1.5 text-xs text-muted-foreground bg-muted/5 shrink-0", children: t('console.objectView.recordCount', { count: recordCount }) }))] }), children: (record) => {
1733
1632
  const recordId = (record.id || record._id);
1734
- return (_jsx(DrawerDetailContent, { objectDef: objectDef, recordId: recordId, dataSource: dataSource, onEdit: onEdit }));
1735
- } })) : (_jsx("div", { className: "flex-1 min-w-0 relative h-full flex flex-col", children: _jsx("div", { className: "flex-1 relative overflow-hidden", children: _jsx("div", { className: "h-full overflow-auto", children: _jsx(PluginObjectView, { schema: objectViewSchema, dataSource: dataSource, views: mergedViews, activeViewId: activeViewId, onViewChange: handleViewChange, onEdit: (record) => onEdit?.(record), onRowClick: (record) => {
1736
- navOverlay.handleClick(record);
1633
+ return (_jsx(RecordDetailView, { dataSource: dataSource, objects: objects, onEdit: onEdit, objectNameOverride: objectDef.name, recordIdOverride: recordId, embedded: true }));
1634
+ } })) : (_jsx("div", { className: "flex-1 min-w-0 relative h-full flex flex-col", children: _jsx("div", { className: "flex-1 relative overflow-hidden", children: _jsx("div", { className: "h-full overflow-auto", children: _jsx(PluginObjectView, { schema: objectViewSchema, dataSource: dataSource, views: mergedViews, activeViewId: activeViewId, onViewChange: handleViewChange, onEdit: (record) => onEdit?.(record), onRowClick: (record, event) => {
1635
+ handleRowClick(record, event);
1737
1636
  }, renderListView: renderListView, onCreateView: handleCreateView, onViewAction: handleViewAction }) }) }) })), _jsx(MetadataPanel, { open: showDebug && isAdmin, sections: [
1738
1637
  { title: 'View Configuration', data: activeView },
1739
1638
  { title: 'Object Definition', data: objectDef },
1740
1639
  ] }), _jsx("div", { "data-testid": "view-config-panel-wrapper", className: `transition-[max-width,opacity] duration-300 ease-in-out overflow-hidden ${showViewConfigPanel && isAdmin ? 'max-w-[280px] opacity-100' : 'max-w-0 opacity-0'}`, children: _jsx(ViewConfigPanel, { open: showViewConfigPanel && isAdmin, onClose: () => { setShowViewConfigPanel(false); setViewConfigPanelMode('edit'); }, mode: viewConfigPanelMode, activeView: activeView, objectDef: objectDef, recordCount: recordCount, onSave: handleViewConfigSave, onViewUpdate: handleViewUpdate, onCreate: handleViewCreate }) }), _jsx(CreateViewDialog, { open: showCreateViewDialog && isAdmin, onOpenChange: setShowCreateViewDialog, existingLabels: views.map((v) => v.label).filter(Boolean), objectDef: objectDef, onCreate: (cfg) => handleViewCreate(cfg) })] }), navOverlay.mode !== 'split' && (_jsx(NavigationOverlay, { ...navOverlay, setIsOpen: (open) => { if (!open)
1741
- handleDrawerClose(); }, title: objectDef.label, className: navOverlay.mode === 'drawer' ? 'w-[90vw] sm:max-w-2xl p-0 overflow-hidden' : undefined, children: (record) => {
1640
+ handleDrawerClose(); }, title: objectDef.label, onExpand: handleExpandDrawer, expandLabel: t('console.objectView.expandToPage', { defaultValue: 'Open as full page' }), storageKey: `drawer-width:${objectDef.name}`, children: (record) => {
1742
1641
  const recordId = (record.id || record._id);
1743
- return (_jsx(DrawerDetailContent, { objectDef: objectDef, recordId: recordId, dataSource: dataSource, onEdit: onEdit }));
1642
+ return (_jsx(RecordDetailView, { dataSource: dataSource, objects: objects, onEdit: onEdit, objectNameOverride: objectDef.name, recordIdOverride: recordId, embedded: true }));
1744
1643
  } }))] }), _jsx(ActionConfirmDialog, { state: confirmState, onOpenChange: (open) => {
1745
1644
  if (!open)
1746
1645
  setConfirmState({ open: false, message: '' });
@@ -9,6 +9,25 @@ interface RecordDetailViewProps {
9
9
  dataSource: any;
10
10
  objects: any[];
11
11
  onEdit: (record: any) => void;
12
+ /**
13
+ * When provided, this object name overrides the value derived from the
14
+ * current URL (`useParams()`). Used when rendering the detail inside a
15
+ * navigation drawer or split-pane — the parent route owns the URL while
16
+ * the embedded detail is keyed by an in-memory record selection.
17
+ */
18
+ objectNameOverride?: string;
19
+ /**
20
+ * When provided, this record id overrides the value derived from the
21
+ * current URL. See {@link objectNameOverride}.
22
+ */
23
+ recordIdOverride?: string;
24
+ /**
25
+ * `true` when this view is embedded (drawer / split-pane). Suppresses
26
+ * side effects that would conflict with the host route — namely the
27
+ * breadcrumb title publish (the parent route already owns the
28
+ * breadcrumb).
29
+ */
30
+ embedded?: boolean;
12
31
  }
13
- export declare function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailViewProps): import("react/jsx-runtime").JSX.Element;
32
+ export declare function RecordDetailView({ dataSource, objects, onEdit, objectNameOverride, recordIdOverride, embedded }: RecordDetailViewProps): import("react/jsx-runtime").JSX.Element;
14
33
  export {};
@@ -47,8 +47,11 @@ const AUDIT_FIELD_NAMES = new Set(['created_at', 'created_by', 'updated_at', 'up
47
47
  const HIDDEN_SYSTEM_FIELD_NAMES = new Set([
48
48
  'organization_id', 'tenant_id', 'is_deleted', 'deleted_at',
49
49
  ]);
50
- export function RecordDetailView({ dataSource, objects, onEdit }) {
51
- const { appName, objectName, recordId } = useParams();
50
+ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverride, recordIdOverride, embedded }) {
51
+ const params = useParams();
52
+ const appName = params.appName;
53
+ const objectName = objectNameOverride ?? params.objectName;
54
+ const recordId = recordIdOverride ?? params.recordId;
52
55
  const { showDebug } = useMetadataInspector();
53
56
  const { user } = useAuth();
54
57
  const navigate = useNavigate();
@@ -64,8 +67,9 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
64
67
  const [recordTitle, setRecordTitle] = useState();
65
68
  const objectDef = objects.find((o) => o.name === objectName);
66
69
  // Publish record title to the navigation context so the top-bar breadcrumb
67
- // can display "Acme Platform Upgrade" instead of "#9U1_MmmxjiGR…".
68
- useRecordBreadcrumbTitle(recordTitle);
70
+ // can display "Acme Platform Upgrade" instead of "#9U1_MmmxjiGR…". Skip
71
+ // when embedded (drawer/split): the parent list route owns the breadcrumb.
72
+ useRecordBreadcrumbTitle(embedded ? undefined : recordTitle);
69
73
  // Use the URL recordId as-is — it contains the actual record id.
70
74
  // Navigation code passes `record.id || record._id` directly into the URL
71
75
  // without adding any prefix, so no stripping is needed.
@@ -358,18 +362,84 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
358
362
  }
359
363
  let cancelled = false;
360
364
  setHistoryLoading(true);
365
+ // Pull `old_value` and `new_value` so we can render a per-field diff
366
+ // ("Industry: finance → healthcare") instead of just the action verb.
367
+ // The backend already authorises sys_audit_log row access; column-level
368
+ // redaction would need to happen server-side, not by omitting columns here.
361
369
  dataSource
362
370
  .find('sys_audit_log', {
363
371
  $filter: { record_id: pureRecordId, object_name: objectDef.name },
364
372
  $orderby: { created_at: 'desc' },
365
373
  $top: 50,
366
- $select: ['id', 'created_at', 'action', 'user_id'],
374
+ $select: ['id', 'created_at', 'action', 'user_id', 'old_value', 'new_value'],
367
375
  })
368
- .then((res) => {
376
+ .then(async (res) => {
369
377
  if (cancelled)
370
378
  return;
371
379
  const items = Array.isArray(res) ? res : res?.data || [];
372
- setHistoryEntries(items);
380
+ // 1) Resolve actor display names + avatars in a single batched call
381
+ // so the timeline never falls back to a raw UUID.
382
+ const userIds = Array.from(new Set(items
383
+ .map((it) => it?.user_id)
384
+ .filter((v) => typeof v === 'string' && v.length > 0)));
385
+ let userMap = new Map();
386
+ if (userIds.length > 0) {
387
+ try {
388
+ const usersRes = await dataSource.find('sys_user', {
389
+ $filter: { id: { $in: userIds } },
390
+ $top: userIds.length,
391
+ $select: ['id', 'name', 'email', 'image'],
392
+ });
393
+ const users = Array.isArray(usersRes) ? usersRes : usersRes?.data || [];
394
+ userMap = new Map(users.map((u) => [u.id, { name: u.name || u.email || null, image: u.image || null }]));
395
+ }
396
+ catch (err) {
397
+ console.warn('[RecordDetailView] Failed to resolve audit user names:', err);
398
+ }
399
+ }
400
+ // 2) Build a label map for the current object so diff lines show
401
+ // "Industry: …" rather than the snake_case "industry: …".
402
+ const fieldLabels = {};
403
+ for (const [name, def] of Object.entries(objectDef.fields || {})) {
404
+ if (def?.label)
405
+ fieldLabels[name] = def.label;
406
+ }
407
+ const parseJson = (v) => {
408
+ if (!v)
409
+ return null;
410
+ if (typeof v === 'object')
411
+ return v;
412
+ if (typeof v === 'string') {
413
+ try {
414
+ return JSON.parse(v);
415
+ }
416
+ catch {
417
+ return null;
418
+ }
419
+ }
420
+ return null;
421
+ };
422
+ const enriched = items.map((it) => {
423
+ const u = it?.user_id ? userMap.get(it.user_id) : undefined;
424
+ const oldObj = parseJson(it?.old_value) || {};
425
+ const newObj = parseJson(it?.new_value) || {};
426
+ const fields = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
427
+ const changes = Array.from(fields)
428
+ .filter((f) => JSON.stringify(oldObj[f]) !== JSON.stringify(newObj[f]))
429
+ .map((f) => ({
430
+ field: f,
431
+ label: fieldLabels[f] || f,
432
+ from: oldObj[f],
433
+ to: newObj[f],
434
+ }));
435
+ return {
436
+ ...it,
437
+ user_name: u?.name ?? null,
438
+ user_avatar: u?.image ?? null,
439
+ changes: changes.length > 0 ? changes : undefined,
440
+ };
441
+ });
442
+ setHistoryEntries(enriched);
373
443
  })
374
444
  .catch((err) => {
375
445
  if (cancelled)
@@ -668,7 +738,17 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
668
738
  title: sec.name ? sectionLabel(objectDef.name, sec.name, sec.title || sec.name) : sec.title,
669
739
  collapsible: sec.collapsible,
670
740
  defaultCollapsed: sec.defaultCollapsed,
671
- fields: (sec.fields || []).map((f) => {
741
+ fields: (sec.fields || [])
742
+ .filter((f) => {
743
+ // Honor `hidden: true` on a field def even when the form
744
+ // section explicitly lists it. Hidden fields are typically
745
+ // internal artifacts (e.g. database URL, environment id)
746
+ // that platform actions read but end-users shouldn't see.
747
+ const fieldName = typeof f === 'string' ? f : f.name;
748
+ const fieldDef = objectDef.fields?.[fieldName];
749
+ return !fieldDef?.hidden;
750
+ })
751
+ .map((f) => {
672
752
  const fieldName = typeof f === 'string' ? f : f.name;
673
753
  const fieldDef = objectDef.fields[fieldName];
674
754
  if (!fieldDef) {
@@ -694,7 +774,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
694
774
  // redundant "Details" heading).
695
775
  showBorder: false,
696
776
  fields: Object.keys(objectDef.fields || {})
697
- .filter(key => !AUDIT_FIELD_NAMES.has(key) && !HIDDEN_SYSTEM_FIELD_NAMES.has(key))
777
+ .filter(key => !AUDIT_FIELD_NAMES.has(key) && !HIDDEN_SYSTEM_FIELD_NAMES.has(key) && !objectDef.fields[key]?.hidden)
698
778
  .map(key => {
699
779
  const fieldDef = objectDef.fields[key];
700
780
  const refTarget = fieldDef.reference_to || fieldDef.reference;
@@ -888,7 +968,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
888
968
  type: 'detail-view',
889
969
  objectName: objectDef.name,
890
970
  resourceId: pureRecordId,
891
- showBack: true,
971
+ showBack: !embedded,
892
972
  onBack: 'history',
893
973
  // Hide the Edit button for objects whose lifecycle isn't user-managed
894
974
  // (approval requests, audit logs, better-auth tables, …). The
@@ -919,7 +999,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
919
999
  }),
920
1000
  };
921
1001
  // eslint-disable-next-line react-hooks/exhaustive-deps
922
- }, [objectDef?.name, pureRecordId, childRelatedData, actionRefreshKey, appName, navigate, dataSource, t, objectLabel, objects, historyEnabled, historyEntries, historyLoading, approvals.available, approvals.processes, approvals.canSubmit, approvals.canRecall, approvals.pendingRequest, approvals.latestRequest]);
1002
+ }, [objectDef?.name, pureRecordId, childRelatedData, actionRefreshKey, appName, navigate, dataSource, t, objectLabel, objects, historyEnabled, historyEntries, historyLoading, approvals.available, approvals.processes, approvals.canSubmit, approvals.canRecall, approvals.pendingRequest, approvals.latestRequest, embedded]);
923
1003
  if (isLoading) {
924
1004
  return _jsx(SkeletonDetail, {});
925
1005
  }
@@ -927,7 +1007,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
927
1007
  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 }) })] }) }));
928
1008
  }
929
1009
  if (assignedPage) {
930
- return (_jsx(RecordContextProvider, { objectName: objectName, recordId: pureRecordId, data: pageRecord, objectSchema: objectDef, dataSource: dataSource, children: _jsx(ActionProvider, { context: { record: pageRecord || {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler, approval: approvalHandler }, children: _jsx("div", { className: "bg-background p-3 sm:p-4 lg:p-6", children: _jsx(SchemaRenderer, { schema: assignedPage }) }) }) }));
1010
+ return (_jsx(RecordContextProvider, { objectName: objectName, recordId: pureRecordId, data: pageRecord, objectSchema: objectDef, dataSource: dataSource, embedded: embedded, children: _jsx(ActionProvider, { context: { record: pageRecord || {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler, approval: approvalHandler }, children: _jsx("div", { className: "bg-background p-3 sm:p-4 lg:p-6", children: _jsx(SchemaRenderer, { schema: assignedPage }) }) }) }));
931
1011
  }
932
1012
  return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsxs("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: [_jsx(ManagedByBadge, { managedBy: objectDef?.managedBy }), 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, approval: approvalHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), onDataLoaded: (record) => {
933
1013
  if (!record || typeof record !== 'object')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/app-shell",
3
- "version": "4.4.0",
3
+ "version": "4.6.0",
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.4.0",
31
- "@object-ui/collaboration": "4.4.0",
32
- "@object-ui/components": "4.4.0",
33
- "@object-ui/core": "4.4.0",
34
- "@object-ui/data-objectstack": "4.4.0",
35
- "@object-ui/fields": "4.4.0",
36
- "@object-ui/i18n": "4.4.0",
37
- "@object-ui/layout": "4.4.0",
38
- "@object-ui/permissions": "4.4.0",
39
- "@object-ui/react": "4.4.0",
40
- "@object-ui/types": "4.4.0"
30
+ "@object-ui/auth": "4.6.0",
31
+ "@object-ui/collaboration": "4.6.0",
32
+ "@object-ui/components": "4.6.0",
33
+ "@object-ui/core": "4.6.0",
34
+ "@object-ui/data-objectstack": "4.6.0",
35
+ "@object-ui/fields": "4.6.0",
36
+ "@object-ui/i18n": "4.6.0",
37
+ "@object-ui/layout": "4.6.0",
38
+ "@object-ui/permissions": "4.6.0",
39
+ "@object-ui/react": "4.6.0",
40
+ "@object-ui/types": "4.6.0"
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.4.0",
47
- "@object-ui/plugin-charts": "^4.4.0",
48
- "@object-ui/plugin-chatbot": "^4.4.0",
49
- "@object-ui/plugin-dashboard": "^4.4.0",
50
- "@object-ui/plugin-designer": "^4.4.0",
51
- "@object-ui/plugin-detail": "^4.4.0",
52
- "@object-ui/plugin-form": "^4.4.0",
53
- "@object-ui/plugin-grid": "^4.4.0",
54
- "@object-ui/plugin-kanban": "^4.4.0",
55
- "@object-ui/plugin-list": "^4.4.0",
56
- "@object-ui/plugin-report": "^4.4.0",
57
- "@object-ui/plugin-view": "^4.4.0"
46
+ "@object-ui/plugin-calendar": "^4.6.0",
47
+ "@object-ui/plugin-charts": "^4.6.0",
48
+ "@object-ui/plugin-chatbot": "^4.6.0",
49
+ "@object-ui/plugin-dashboard": "^4.6.0",
50
+ "@object-ui/plugin-designer": "^4.6.0",
51
+ "@object-ui/plugin-detail": "^4.6.0",
52
+ "@object-ui/plugin-form": "^4.6.0",
53
+ "@object-ui/plugin-grid": "^4.6.0",
54
+ "@object-ui/plugin-kanban": "^4.6.0",
55
+ "@object-ui/plugin-list": "^4.6.0",
56
+ "@object-ui/plugin-report": "^4.6.0",
57
+ "@object-ui/plugin-view": "^4.6.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^25.9.0",