@object-ui/app-shell 4.5.0 → 4.7.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +21 -0
  3. package/dist/console/AppContent.js +6 -39
  4. package/dist/console/ConsoleShell.js +45 -4
  5. package/dist/console/home/AppCard.d.ts +2 -1
  6. package/dist/console/home/AppCard.js +24 -5
  7. package/dist/console/home/HomePage.js +29 -7
  8. package/dist/console/home/QuickActions.js +13 -7
  9. package/dist/console/home/RecentApps.js +12 -5
  10. package/dist/console/home/StarredApps.js +12 -5
  11. package/dist/context/FavoritesProvider.d.ts +9 -12
  12. package/dist/context/FavoritesProvider.js +83 -37
  13. package/dist/context/RecentItemsProvider.d.ts +45 -0
  14. package/dist/context/RecentItemsProvider.js +139 -0
  15. package/dist/context/UserStateAdapters.d.ts +61 -0
  16. package/dist/context/UserStateAdapters.js +141 -0
  17. package/dist/context/index.d.ts +4 -0
  18. package/dist/context/index.js +2 -0
  19. package/dist/hooks/index.d.ts +1 -0
  20. package/dist/hooks/index.js +1 -0
  21. package/dist/hooks/useRecentItems.d.ts +9 -17
  22. package/dist/hooks/useRecentItems.js +9 -46
  23. package/dist/hooks/useTrackRouteAsRecent.d.ts +18 -0
  24. package/dist/hooks/useTrackRouteAsRecent.js +91 -0
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.js +1 -1
  27. package/dist/layout/PageHeader.d.ts +29 -2
  28. package/dist/layout/PageHeader.js +20 -2
  29. package/dist/utils/resolveActionParams.d.ts +11 -0
  30. package/dist/utils/resolveActionParams.js +19 -0
  31. package/dist/views/ActionParamDialog.js +31 -2
  32. package/dist/views/ObjectView.js +63 -164
  33. package/dist/views/RecordDetailView.d.ts +20 -1
  34. package/dist/views/RecordDetailView.js +23 -9
  35. package/package.json +24 -24
@@ -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.
@@ -734,7 +738,17 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
734
738
  title: sec.name ? sectionLabel(objectDef.name, sec.name, sec.title || sec.name) : sec.title,
735
739
  collapsible: sec.collapsible,
736
740
  defaultCollapsed: sec.defaultCollapsed,
737
- 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) => {
738
752
  const fieldName = typeof f === 'string' ? f : f.name;
739
753
  const fieldDef = objectDef.fields[fieldName];
740
754
  if (!fieldDef) {
@@ -760,7 +774,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
760
774
  // redundant "Details" heading).
761
775
  showBorder: false,
762
776
  fields: Object.keys(objectDef.fields || {})
763
- .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)
764
778
  .map(key => {
765
779
  const fieldDef = objectDef.fields[key];
766
780
  const refTarget = fieldDef.reference_to || fieldDef.reference;
@@ -954,7 +968,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
954
968
  type: 'detail-view',
955
969
  objectName: objectDef.name,
956
970
  resourceId: pureRecordId,
957
- showBack: true,
971
+ showBack: !embedded,
958
972
  onBack: 'history',
959
973
  // Hide the Edit button for objects whose lifecycle isn't user-managed
960
974
  // (approval requests, audit logs, better-auth tables, …). The
@@ -985,7 +999,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
985
999
  }),
986
1000
  };
987
1001
  // eslint-disable-next-line react-hooks/exhaustive-deps
988
- }, [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]);
989
1003
  if (isLoading) {
990
1004
  return _jsx(SkeletonDetail, {});
991
1005
  }
@@ -993,7 +1007,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
993
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 }) })] }) }));
994
1008
  }
995
1009
  if (assignedPage) {
996
- 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 }) }) }) }));
997
1011
  }
998
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) => {
999
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.5.0",
3
+ "version": "4.7.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.5.0",
31
- "@object-ui/collaboration": "4.5.0",
32
- "@object-ui/components": "4.5.0",
33
- "@object-ui/core": "4.5.0",
34
- "@object-ui/data-objectstack": "4.5.0",
35
- "@object-ui/fields": "4.5.0",
36
- "@object-ui/i18n": "4.5.0",
37
- "@object-ui/layout": "4.5.0",
38
- "@object-ui/permissions": "4.5.0",
39
- "@object-ui/react": "4.5.0",
40
- "@object-ui/types": "4.5.0"
30
+ "@object-ui/auth": "4.7.0",
31
+ "@object-ui/collaboration": "4.7.0",
32
+ "@object-ui/components": "4.7.0",
33
+ "@object-ui/core": "4.7.0",
34
+ "@object-ui/data-objectstack": "4.7.0",
35
+ "@object-ui/fields": "4.7.0",
36
+ "@object-ui/i18n": "4.7.0",
37
+ "@object-ui/layout": "4.7.0",
38
+ "@object-ui/permissions": "4.7.0",
39
+ "@object-ui/react": "4.7.0",
40
+ "@object-ui/types": "4.7.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.5.0",
47
- "@object-ui/plugin-charts": "^4.5.0",
48
- "@object-ui/plugin-chatbot": "^4.5.0",
49
- "@object-ui/plugin-dashboard": "^4.5.0",
50
- "@object-ui/plugin-designer": "^4.5.0",
51
- "@object-ui/plugin-detail": "^4.5.0",
52
- "@object-ui/plugin-form": "^4.5.0",
53
- "@object-ui/plugin-grid": "^4.5.0",
54
- "@object-ui/plugin-kanban": "^4.5.0",
55
- "@object-ui/plugin-list": "^4.5.0",
56
- "@object-ui/plugin-report": "^4.5.0",
57
- "@object-ui/plugin-view": "^4.5.0"
46
+ "@object-ui/plugin-calendar": "^4.7.0",
47
+ "@object-ui/plugin-charts": "^4.7.0",
48
+ "@object-ui/plugin-chatbot": "^4.7.0",
49
+ "@object-ui/plugin-dashboard": "^4.7.0",
50
+ "@object-ui/plugin-designer": "^4.7.0",
51
+ "@object-ui/plugin-detail": "^4.7.0",
52
+ "@object-ui/plugin-form": "^4.7.0",
53
+ "@object-ui/plugin-grid": "^4.7.0",
54
+ "@object-ui/plugin-kanban": "^4.7.0",
55
+ "@object-ui/plugin-list": "^4.7.0",
56
+ "@object-ui/plugin-report": "^4.7.0",
57
+ "@object-ui/plugin-view": "^4.7.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^25.9.0",