@object-ui/app-shell 11.4.0 → 11.5.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 (64) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/README.md +9 -0
  3. package/dist/chrome/KeyboardShortcutsDialog.js +2 -1
  4. package/dist/console/AppContent.js +145 -26
  5. package/dist/console/ConsoleShell.js +8 -1
  6. package/dist/context/CommandPaletteProvider.js +2 -1
  7. package/dist/hooks/useObjectActions.js +16 -4
  8. package/dist/layout/AppHeader.js +13 -5
  9. package/dist/layout/AppSidebar.js +10 -4
  10. package/dist/observability/sentry.d.ts +5 -0
  11. package/dist/observability/sentry.js +6 -1
  12. package/dist/preview/DraftChangesPanel.d.ts +29 -1
  13. package/dist/preview/DraftChangesPanel.js +141 -14
  14. package/dist/urlParams.d.ts +68 -0
  15. package/dist/urlParams.js +76 -0
  16. package/dist/utils/appRoute.d.ts +15 -0
  17. package/dist/utils/appRoute.js +22 -0
  18. package/dist/utils/index.d.ts +1 -1
  19. package/dist/utils/index.js +1 -1
  20. package/dist/utils/pageTabsUrlSync.d.ts +32 -0
  21. package/dist/utils/pageTabsUrlSync.js +43 -0
  22. package/dist/utils/recordFormNavigation.d.ts +40 -0
  23. package/dist/utils/recordFormNavigation.js +30 -0
  24. package/dist/views/InterfaceListPage.d.ts +1 -0
  25. package/dist/views/InterfaceListPage.js +1 -1
  26. package/dist/views/ObjectDataPage.d.ts +29 -0
  27. package/dist/views/ObjectDataPage.js +227 -0
  28. package/dist/views/ObjectView.js +4 -3
  29. package/dist/views/RecordDetailView.js +61 -20
  30. package/dist/views/RelatedRecordActionsBridge.d.ts +10 -1
  31. package/dist/views/RelatedRecordActionsBridge.js +49 -16
  32. package/dist/views/metadata-admin/ResourceEditPage.js +39 -0
  33. package/dist/views/metadata-admin/i18n.js +214 -4
  34. package/dist/views/metadata-admin/inspectors/AppNavInspector.d.ts +11 -4
  35. package/dist/views/metadata-admin/inspectors/AppNavInspector.js +141 -7
  36. package/dist/views/metadata-admin/inspectors/FlowReferenceField.d.ts +14 -0
  37. package/dist/views/metadata-admin/inspectors/FlowReferenceField.js +76 -5
  38. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +35 -19
  39. package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +8 -1
  40. package/dist/views/metadata-admin/inspectors/flow-node-config.js +3 -2
  41. package/dist/views/metadata-admin/inspectors/nav-target.d.ts +52 -0
  42. package/dist/views/metadata-admin/inspectors/nav-target.js +149 -0
  43. package/dist/views/metadata-admin/nav-selection.d.ts +20 -0
  44. package/dist/views/metadata-admin/nav-selection.js +81 -0
  45. package/dist/views/metadata-admin/previews/AppNavCanvas.js +9 -1
  46. package/dist/views/metadata-admin/previews/AppPreview.js +4 -2
  47. package/dist/views/studio-design/BuilderLanding.d.ts +1 -1
  48. package/dist/views/studio-design/BuilderLanding.js +12 -19
  49. package/dist/views/studio-design/ObjectFormDesigner.d.ts +5 -3
  50. package/dist/views/studio-design/ObjectFormDesigner.js +17 -12
  51. package/dist/views/studio-design/ObjectSettingsPanel.d.ts +1 -1
  52. package/dist/views/studio-design/ObjectSettingsPanel.js +4 -3
  53. package/dist/views/studio-design/ObjectValidationsPanel.js +6 -4
  54. package/dist/views/studio-design/PackageIdInput.d.ts +31 -0
  55. package/dist/views/studio-design/PackageIdInput.js +40 -0
  56. package/dist/views/studio-design/StudioDesignSurface.d.ts +13 -0
  57. package/dist/views/studio-design/StudioDesignSurface.js +227 -57
  58. package/dist/views/studio-design/packageSurfaces.d.ts +49 -0
  59. package/dist/views/studio-design/packageSurfaces.js +34 -0
  60. package/dist/views/studio-design/packages-io.d.ts +11 -0
  61. package/dist/views/studio-design/packages-io.js +12 -0
  62. package/dist/views/studio-design/skeletons.d.ts +16 -0
  63. package/dist/views/studio-design/skeletons.js +51 -0
  64. package/package.json +38 -38
@@ -7,11 +7,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  * the object field definitions.
8
8
  */
9
9
  import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
10
- import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
10
+ import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom';
11
11
  import { DetailView, RecordChatterPanel, buildDefaultPageSchema, deriveFieldGroupDetailSections, extractMentions } from '@object-ui/plugin-detail';
12
12
  import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
13
13
  import { useAuth, createAuthenticatedFetch } from '@object-ui/auth';
14
- import { ActionProvider, useObjectTranslation, useObjectLabel, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, useGlobalUndo } from '@object-ui/react';
14
+ import { ActionProvider, useObjectTranslation, useObjectLabel, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, useGlobalUndo, useDataInvalidation, notifyDataChanged } from '@object-ui/react';
15
15
  import { buildExpandFields } from '@object-ui/core';
16
16
  import { toast } from 'sonner';
17
17
  import { useRecordPresence, PresenceAvatars } from '@object-ui/collaboration';
@@ -27,6 +27,8 @@ import { ActionParamDialog } from './ActionParamDialog';
27
27
  import { ActionResultDialog } from './ActionResultDialog';
28
28
  import { FlowRunner } from './FlowRunner';
29
29
  import { RelatedRecordActionsBridge } from './RelatedRecordActionsBridge';
30
+ import { withPageTabsUrlSync } from '../utils/pageTabsUrlSync';
31
+ import { RECORD_DETAIL_TAB_PARAM } from '../urlParams';
30
32
  import { resolveActionParams } from '../utils/resolveActionParams';
31
33
  import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
32
34
  import { useRecordApprovals } from '../hooks/useRecordApprovals';
@@ -80,6 +82,20 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
80
82
  const { showDebug } = useMetadataInspector();
81
83
  const { user } = useAuth();
82
84
  const navigate = useNavigate();
85
+ // objectui#2257 — the active detail tab is URL-addressable (`?tab=`), so it
86
+ // survives the page subtree remounting (refreshKey-style save refreshes;
87
+ // dev-StrictMode URL churn). Written with `replace` — switching tabs must
88
+ // not stack history entries (Back would page through tabs and fight the
89
+ // overlay contract where Back closes `?form=…`).
90
+ const [tabSearchParams, setTabSearchParams] = useSearchParams();
91
+ const activeTabParam = tabSearchParams.get(RECORD_DETAIL_TAB_PARAM) ?? undefined;
92
+ const handleTabChange = useCallback((value) => {
93
+ const sp = new URLSearchParams(window.location.search);
94
+ if (sp.get(RECORD_DETAIL_TAB_PARAM) === value)
95
+ return;
96
+ sp.set(RECORD_DETAIL_TAB_PARAM, value);
97
+ setTabSearchParams(sp, { replace: true });
98
+ }, [setTabSearchParams]);
83
99
  const location = useLocation();
84
100
  const originFrom = location.state?.from;
85
101
  const { t } = useObjectTranslation();
@@ -89,7 +105,6 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
89
105
  const [isLoading, setIsLoading] = useState(true);
90
106
  const [feedItems, setFeedItems] = useState([]);
91
107
  const [mentionSuggestions, setMentionSuggestions] = useState([]);
92
- const [actionRefreshKey, setActionRefreshKey] = useState(0);
93
108
  // Screen-flow runtime: a paused `screen`-node flow launched from a record action.
94
109
  const [screenFlow, setScreenFlow] = useState(null);
95
110
  const [childRelatedData, setChildRelatedData] = useState({});
@@ -105,6 +120,17 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
105
120
  // Navigation code passes `record.id || record._id` directly into the URL
106
121
  // without adding any prefix, so no stripping is needed.
107
122
  const pureRecordId = recordId;
123
+ // #2269 — "refresh data, don't rebuild UI": after an action/save succeeds
124
+ // we INVALIDATE this record on the bus instead of bumping a key that
125
+ // remounted the whole DetailView (destroying tab/scroll/inline-edit state).
126
+ // `recordInvalidationNonce` is the read side: it bumps when THIS record (or
127
+ // its object) is invalidated by anyone — the form overlay, an action, an
128
+ // undo, or any dataSource write via the MutationEvent bridge.
129
+ const recordInvalidationNonce = useDataInvalidation(objectName || undefined, pureRecordId || undefined);
130
+ const notifyRecordChanged = useCallback(() => {
131
+ if (objectName)
132
+ notifyDataChanged({ objectName, recordId: pureRecordId || undefined });
133
+ }, [objectName, pureRecordId]);
108
134
  // Record-scoped presence ("who else is viewing this record"). The default
109
135
  // PresenceProvider source is a no-op, so this resolves to `[]` until a
110
136
  // realtime transport (WebSocket-backed source) is wired in by the host
@@ -231,7 +257,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
231
257
  cancelled = true;
232
258
  window.removeEventListener('objectui:record-changed', onChanged);
233
259
  };
234
- }, [effectivePage, objectName, pureRecordId, dataSource, objectDef]);
260
+ // #2269: recordInvalidationNonce re-runs this fetch in place whenever the
261
+ // record (or its object) is invalidated on the bus.
262
+ }, [effectivePage, objectName, pureRecordId, dataSource, objectDef, recordInvalidationNonce]);
235
263
  // Schema-driven path: derive a human-readable record title from the
236
264
  // loaded `pageRecord` so favourites (record:*) and the breadcrumb show
237
265
  // e.g. "Acme Corporation" instead of the raw record id. The legacy
@@ -308,7 +336,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
308
336
  // "Undo" button (for `undoable` actions) restores the record's prior values.
309
337
  const undoCtl = useGlobalUndo({
310
338
  dataSource,
311
- onUndo: () => { setActionRefreshKey(k => k + 1); toast.success('Change undone'); },
339
+ onUndo: (op) => {
340
+ if (op?.objectName)
341
+ notifyDataChanged({ objectName: op.objectName, recordId: op.recordId });
342
+ else
343
+ notifyRecordChanged();
344
+ toast.success('Change undone');
345
+ },
312
346
  });
313
347
  const toastHandler = useCallback((message, options) => {
314
348
  if (options?.type === 'error') {
@@ -393,12 +427,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
393
427
  }
394
428
  const shouldRefresh = action.refreshAfter === true;
395
429
  if (shouldRefresh) {
396
- setActionRefreshKey(k => k + 1);
430
+ notifyRecordChanged();
397
431
  }
398
432
  else if (undo) {
399
433
  // Even when refreshAfter isn't set, reflect the change so the user sees
400
434
  // it (and the subsequent Undo) on the open record.
401
- setActionRefreshKey(k => k + 1);
435
+ notifyRecordChanged();
402
436
  }
403
437
  return { success: true, reload: shouldRefresh, undo };
404
438
  }
@@ -451,7 +485,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
451
485
  }
452
486
  const shouldRefresh = action.refreshAfter !== false;
453
487
  if (shouldRefresh) {
454
- setActionRefreshKey(k => k + 1);
488
+ notifyRecordChanged();
455
489
  }
456
490
  return { success: true, data: json?.data, reload: shouldRefresh };
457
491
  }
@@ -565,7 +599,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
565
599
  }
566
600
  }
567
601
  if (action.refreshAfter === true)
568
- setActionRefreshKey(k => k + 1);
602
+ notifyRecordChanged();
569
603
  return { success: true };
570
604
  }
571
605
  const obj = action.objectName || objectName || 'global';
@@ -593,14 +627,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
593
627
  }
594
628
  catch { /* ignore */ }
595
629
  }
596
- // Surface the failure this custom new-tab path bypasses
597
- // ActionRunner's toast-on-error, so otherwise the user gets no feedback.
598
- toast.error(errMsg);
630
+ // Don't toast here. This handler always runs through the ActionRunner
631
+ // (registered as the `script` handler on the ActionProvider below, which
632
+ // wires `onToast`), whose post-execution hook surfaces the returned
633
+ // `error` as one toast. Toasting again double-fired the message
634
+ // (e.g. RECORD_LOCKED appeared twice). Mirrors useConsoleActionRuntime.
599
635
  return { success: false, error: errMsg };
600
636
  }
601
637
  const shouldRefresh = action.refreshAfter !== false;
602
638
  if (shouldRefresh)
603
- setActionRefreshKey(k => k + 1);
639
+ notifyRecordChanged();
604
640
  const result = json?.data;
605
641
  // ── redirectUrl convention ────────────────────────────────────────
606
642
  // A script-action handler can return `{ redirectUrl: 'https://…' }`
@@ -666,7 +702,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
666
702
  catch { /* ignore */ }
667
703
  }
668
704
  const msg = error.message;
669
- toast.error(msg);
705
+ // Don't toast here — the ActionRunner post-execution hook toasts the
706
+ // returned `error` once (see the failure branch above).
670
707
  return { success: false, error: msg };
671
708
  }
672
709
  finally {
@@ -699,7 +736,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
699
736
  else {
700
737
  return { success: false, error: `Unknown approval target: ${target}` };
701
738
  }
702
- setActionRefreshKey((k) => k + 1);
739
+ notifyRecordChanged();
703
740
  return { success: true, reload: true };
704
741
  }
705
742
  catch (err) {
@@ -1519,6 +1556,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1519
1556
  primaryField,
1520
1557
  sections,
1521
1558
  autoTabs: true,
1559
+ // objectui#2257 — URL-driven active tab (same `?tab=` contract as the
1560
+ // schema path's page:tabs node).
1561
+ defaultTab: activeTabParam,
1562
+ onTabChange: handleTabChange,
1522
1563
  autoDiscoverRelated: true,
1523
1564
  ...(historyEnabled && {
1524
1565
  history: {
@@ -1537,7 +1578,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1537
1578
  }),
1538
1579
  };
1539
1580
  // eslint-disable-next-line react-hooks/exhaustive-deps
1540
- }, [objectDef?.name, pureRecordId, childRelatedData, actionRefreshKey, appName, navigate, dataSource, t, objectLabel, objects, historyEnabled, historyEntries, historyLoading, approvals.available, approvals.canDecide, approvals.pendingRequest, approvals.latestRequest, embedded]);
1581
+ }, [objectDef?.name, pureRecordId, childRelatedData, appName, navigate, dataSource, t, objectLabel, objects, historyEnabled, historyEntries, historyLoading, approvals.available, approvals.canDecide, approvals.pendingRequest, approvals.latestRequest, embedded, activeTabParam, handleTabChange]);
1541
1582
  if (isLoading) {
1542
1583
  return _jsx(SkeletonDetail, {});
1543
1584
  }
@@ -1730,7 +1771,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1730
1771
  // there as a renderer capability).
1731
1772
  ...(assignedSlots ? { slots: assignedSlots } : {}),
1732
1773
  });
1733
- 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: [recordPresence.length > 0 && (_jsx(PresenceAvatars, { users: recordPresence, size: "sm", maxVisible: 3, showStatus: true })), _jsx(ManagedByBadge, { managedBy: objectDef?.managedBy })] }), _jsx(RecordContextProvider, { objectName: objectName, recordId: pureRecordId, data: pageRecord, objectSchema: objectDef, dataSource: dataSource, embedded: embedded, headerSystemActions: synthSystemActions, isFavorite: isRecordFavorite, onToggleFavorite: favoriteRecord ? handleToggleRecordFavorite : undefined, children: _jsx(HighlightFieldsProvider, { children: _jsx(DiscussionContextProvider, { items: feedItems, onAddComment: handleAddComment, onAddReply: handleAddReply, onToggleReaction: handleToggleReaction, mentionSuggestions: mentionSuggestions, children: _jsxs(ActionProvider, { context: { record: pageRecord || {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, onResultDialog: resultDialogHandler, onModal: modalHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, approval: approvalHandler }, children: [_jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsxs("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: [originFrom?.pathname && originFrom?.label && (_jsxs(Link, { to: originFrom.pathname, className: "inline-flex items-center gap-1 mb-3 text-sm text-muted-foreground hover:text-foreground transition-colors", children: [_jsx(ChevronLeft, { className: "h-4 w-4" }), _jsx("span", { children: originFrom.label })] })), _jsx(RelatedRecordActionsBridge, { appName: appName, objects: objects, dataSource: dataSource, actionLabel: actionLabel, children: _jsx(SchemaRenderer, { schema: renderedPage }) }), showAutoDiscussion && (_jsx("div", { className: "mt-6", children: _jsx(RecordChatterPanel, { config: {
1774
+ 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: [recordPresence.length > 0 && (_jsx(PresenceAvatars, { users: recordPresence, size: "sm", maxVisible: 3, showStatus: true })), _jsx(ManagedByBadge, { managedBy: objectDef?.managedBy })] }), _jsx(RecordContextProvider, { objectName: objectName, recordId: pureRecordId, data: pageRecord, objectSchema: objectDef, dataSource: dataSource, embedded: embedded, headerSystemActions: synthSystemActions, isFavorite: isRecordFavorite, onToggleFavorite: favoriteRecord ? handleToggleRecordFavorite : undefined, children: _jsx(HighlightFieldsProvider, { children: _jsx(DiscussionContextProvider, { items: feedItems, onAddComment: handleAddComment, onAddReply: handleAddReply, onToggleReaction: handleToggleReaction, mentionSuggestions: mentionSuggestions, children: _jsxs(ActionProvider, { context: { record: pageRecord || {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, onResultDialog: resultDialogHandler, onModal: modalHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, approval: approvalHandler }, children: [_jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsxs("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: [originFrom?.pathname && originFrom?.label && (_jsxs(Link, { to: originFrom.pathname, className: "inline-flex items-center gap-1 mb-3 text-sm text-muted-foreground hover:text-foreground transition-colors", children: [_jsx(ChevronLeft, { className: "h-4 w-4" }), _jsx("span", { children: originFrom.label })] })), _jsx(RelatedRecordActionsBridge, { appName: appName, objects: objects, dataSource: dataSource, actionLabel: actionLabel, children: _jsx(SchemaRenderer, { schema: withPageTabsUrlSync(renderedPage, { defaultTab: activeTabParam, onTabChange: handleTabChange }) }) }), showAutoDiscussion && (_jsx("div", { className: "mt-6", children: _jsx(RecordChatterPanel, { config: {
1734
1775
  position: 'bottom',
1735
1776
  collapsible: false,
1736
1777
  feed: {
@@ -1747,7 +1788,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1747
1788
  } }), _jsx(ActionResultDialog, { state: resultDialogState, onAcknowledge: () => {
1748
1789
  resultDialogState.resolve?.();
1749
1790
  setResultDialogState({ open: false });
1750
- } }), _jsx(FlowRunner, { state: screenFlow, authFetch: authFetch, baseUrl: import.meta.env.VITE_SERVER_URL || '', dataSource: dataSource, objects: objects, onClose: () => setScreenFlow(null), onComplete: () => { setScreenFlow(null); setActionRefreshKey(k => k + 1); } })] }));
1791
+ } }), _jsx(FlowRunner, { state: screenFlow, authFetch: authFetch, baseUrl: import.meta.env.VITE_SERVER_URL || '', dataSource: dataSource, objects: objects, onClose: () => setScreenFlow(null), onComplete: () => { setScreenFlow(null); notifyRecordChanged(); } })] }));
1751
1792
  }
1752
1793
  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: [recordPresence.length > 0 && (_jsx(PresenceAvatars, { users: recordPresence, size: "sm", maxVisible: 3, showStatus: true })), _jsx(ManagedByBadge, { managedBy: objectDef?.managedBy })] }), _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("div", { className: "mx-auto w-full max-w-[1400px]", children: _jsxs(ActionProvider, { context: { record: {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, onResultDialog: resultDialogHandler, onModal: modalHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, approval: approvalHandler }, children: [_jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), isFavorite: isRecordFavorite, onToggleFavorite: favoriteRecord ? handleToggleRecordFavorite : undefined, onDataLoaded: (record) => {
1753
1794
  if (!record || typeof record !== 'object')
@@ -1769,7 +1810,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1769
1810
  enableThreading: true,
1770
1811
  showCommentInput: true,
1771
1812
  },
1772
- }, items: feedItems, onAddComment: handleAddComment, onAddReply: handleAddReply, onToggleReaction: handleToggleReaction }) }, actionRefreshKey), modalElement] }) }) }), _jsx(MetadataPanel, { open: showDebug, sections: [{ title: 'View Schema', data: detailSchema }] })] }), _jsx(ActionConfirmDialog, { state: confirmState, onOpenChange: (open) => {
1813
+ }, items: feedItems, onAddComment: handleAddComment, onAddReply: handleAddReply, onToggleReaction: handleToggleReaction }) }), modalElement] }) }) }), _jsx(MetadataPanel, { open: showDebug, sections: [{ title: 'View Schema', data: detailSchema }] })] }), _jsx(ActionConfirmDialog, { state: confirmState, onOpenChange: (open) => {
1773
1814
  if (!open)
1774
1815
  setConfirmState(s => ({ ...s, open: false }));
1775
1816
  } }), _jsx(ActionParamDialog, { state: paramState, onOpenChange: (open) => {
@@ -1778,5 +1819,5 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1778
1819
  } }), _jsx(ActionResultDialog, { state: resultDialogState, onAcknowledge: () => {
1779
1820
  resultDialogState.resolve?.();
1780
1821
  setResultDialogState({ open: false });
1781
- } }), _jsx(FlowRunner, { state: screenFlow, authFetch: authFetch, baseUrl: import.meta.env.VITE_SERVER_URL || '', dataSource: dataSource, objects: objects, onClose: () => setScreenFlow(null), onComplete: () => { setScreenFlow(null); setActionRefreshKey(k => k + 1); } })] }));
1822
+ } }), _jsx(FlowRunner, { state: screenFlow, authFetch: authFetch, baseUrl: import.meta.env.VITE_SERVER_URL || '', dataSource: dataSource, objects: objects, onClose: () => setScreenFlow(null), onComplete: () => { setScreenFlow(null); notifyRecordChanged(); } })] }));
1782
1823
  }
@@ -5,7 +5,16 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- /** Notify open related lists for `objectName` to refetch (see RelatedList). */
8
+ /**
9
+ * Notify open related lists for `objectName` to refetch.
10
+ *
11
+ * Since #2269 this is a thin alias over the data-invalidation bus — the bus
12
+ * dispatches the legacy `objectui:related-changed` window event RelatedList
13
+ * listens for, plus every `useDataInvalidation` reader. Kept for callers
14
+ * whose writes BYPASS the dataSource (row actions over the ActionRunner);
15
+ * dataSource writes need no manual call (the MutationEvent bridge covers
16
+ * them).
17
+ */
9
18
  export declare function notifyRelatedChanged(objectName: string): void;
10
19
  /** i18n label resolver signature (matches `useObjectLabel().actionLabel`). */
11
20
  type ActionLabelFn = (objectName: string | undefined, actionName: string, fallback: string) => string;
@@ -16,10 +16,18 @@ import { jsx as _jsx } from "react/jsx-runtime";
16
16
  * (mounted inside the page's `ActionProvider`) closes that gap:
17
17
  *
18
18
  * - 查看详情 → navigate to the child record's detail route
19
- * - 增 navigate to the child's `/new` page, pre-linking the parent
20
- * via `?<relationshipField>=<parentId>` (the convention
21
- * RecordFormPage already reads as create-mode initial values)
22
- * - 改 → navigate to the child record's `/edit` page
19
+ * - 增 / 改 open the child form as an OVERLAY on the parent detail
20
+ * (#2604 D3: a child task's return target is ALWAYS the parent
21
+ * detail with the subtable refreshed never a route, which
22
+ * would drop the parent's scroll/tab context and refetch it).
23
+ * Implemented by pushing the console's record-form URL params
24
+ * (`?form=…&formObject=…&formLink=…`) — the ONE global record
25
+ * form overlay in `AppContent` picks them up, pre-links the
26
+ * parent from `formLink`, sizes to the CHILD object, and on
27
+ * save stays put + refetches the child's related lists
28
+ * (`notifyRelatedChanged`). URL-driven means browser Back
29
+ * closes the overlay and a refresh reopens it STILL correctly
30
+ * parent-linked.
23
31
  * - 删 → `dataSource.delete(child, id)` (RelatedList shows the confirm
24
32
  * dialog and refreshes afterwards)
25
33
  * - 子对象 action → the child object's `list_item` actions, executed against
@@ -30,14 +38,22 @@ import { jsx as _jsx } from "react/jsx-runtime";
30
38
  * absent (e.g. the Studio designer) the related list stays read-only.
31
39
  */
32
40
  import { useCallback, useMemo } from 'react';
33
- import { useNavigate } from 'react-router-dom';
34
- import { RelatedRecordActionsProvider, useAction, } from '@object-ui/react';
41
+ import { useNavigate, useSearchParams } from 'react-router-dom';
42
+ import { RelatedRecordActionsProvider, notifyDataChanged, useAction, } from '@object-ui/react';
35
43
  import { resolveCrudAffordances } from '../utils/crudAffordances';
36
- /** Notify open related lists for `objectName` to refetch (see RelatedList). */
44
+ import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams';
45
+ /**
46
+ * Notify open related lists for `objectName` to refetch.
47
+ *
48
+ * Since #2269 this is a thin alias over the data-invalidation bus — the bus
49
+ * dispatches the legacy `objectui:related-changed` window event RelatedList
50
+ * listens for, plus every `useDataInvalidation` reader. Kept for callers
51
+ * whose writes BYPASS the dataSource (row actions over the ActionRunner);
52
+ * dataSource writes need no manual call (the MutationEvent bridge covers
53
+ * them).
54
+ */
37
55
  export function notifyRelatedChanged(objectName) {
38
- if (typeof window === 'undefined')
39
- return;
40
- window.dispatchEvent(new CustomEvent('objectui:related-changed', { detail: { objectName } }));
56
+ notifyDataChanged({ objectName });
41
57
  }
42
58
  /**
43
59
  * Derive the child object's row actions (metadata `actions` filtered to the
@@ -55,7 +71,22 @@ function deriveRowActions(childDef, actionLabel) {
55
71
  export function RelatedRecordActionsBridge({ appName, objects, dataSource, actionLabel, children, }) {
56
72
  const navigate = useNavigate();
57
73
  const { execute } = useAction();
74
+ const [, setSearchParams] = useSearchParams();
58
75
  const base = appName ? `/apps/${appName}` : '';
76
+ // #2604 D3 — open a child create/edit task as the console's global record
77
+ // form overlay, by URL params. Pushes ONE history entry (Back = close, the
78
+ // parent detail stays mounted underneath). The read side lives in
79
+ // `AppContent` (see its record-form URL contract).
80
+ const openChildForm = useCallback((opts) => {
81
+ const sp = new URLSearchParams(window.location.search);
82
+ sp.set(RECORD_FORM_PARAM, opts.recordId ?? 'new');
83
+ sp.set(RECORD_FORM_OBJECT_PARAM, opts.objectName);
84
+ if (opts.link)
85
+ sp.set(RECORD_FORM_LINK_PARAM, `${opts.link.field}:${opts.link.parentId}`);
86
+ else
87
+ sp.delete(RECORD_FORM_LINK_PARAM);
88
+ setSearchParams(sp); // push → Back closes the overlay
89
+ }, [setSearchParams]);
59
90
  // Execute a child object's row action against the clicked record. Reuses the
60
91
  // page's ActionRunner (confirm dialog, toast, param collection are handled by
61
92
  // it) but retargets it at the CHILD object + row via the action's
@@ -88,14 +119,16 @@ export function RelatedRecordActionsBridge({ appName, objects, dataSource, actio
88
119
  if (aff.create) {
89
120
  handlers.onCreate = () => {
90
121
  const canLink = relationshipField && parentId != null && parentId !== '';
91
- const qs = canLink
92
- ? `?${encodeURIComponent(relationshipField)}=${encodeURIComponent(String(parentId))}`
93
- : '';
94
- navigate(`${base}/${objectName}/new${qs}`);
122
+ openChildForm({
123
+ objectName,
124
+ link: canLink
125
+ ? { field: relationshipField, parentId: parentId }
126
+ : undefined,
127
+ });
95
128
  };
96
129
  }
97
130
  if (aff.edit) {
98
- handlers.onEdit = (id) => navigate(`${detailUrl(id)}/edit`);
131
+ handlers.onEdit = (id) => openChildForm({ objectName, recordId: String(id) });
99
132
  }
100
133
  if (aff.delete) {
101
134
  handlers.onDelete = async (id) => {
@@ -109,6 +142,6 @@ export function RelatedRecordActionsBridge({ appName, objects, dataSource, actio
109
142
  }
110
143
  return handlers;
111
144
  },
112
- }), [objects, base, navigate, dataSource, actionLabel, runRowAction]);
145
+ }), [objects, base, navigate, dataSource, actionLabel, runRowAction, openChildForm]);
113
146
  return (_jsx(RelatedRecordActionsProvider, { value: value, children: children }));
114
147
  }
@@ -21,6 +21,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
21
21
  */
22
22
  import * as React from 'react';
23
23
  import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
24
+ import { DESIGNER_SEL_PARAM, parseNavSelParam, formatNavSelParam, findNavPositionById, navIdAtPosition, } from './nav-selection';
24
25
  import { Save, RotateCcw, Trash2, History, Link2, Loader2, AlertTriangle, Layers3, GitCompareArrows, Boxes, Eye, Pencil, X, PanelRightClose, PanelRightOpen, Maximize2, Minimize2, MousePointer2, SlidersHorizontal, FileCode2, Zap, ZapOff, Send, Undo2, Lock, ShieldCheck, } from 'lucide-react';
25
26
  import { Button } from '@object-ui/components';
26
27
  import { Badge } from '@object-ui/components';
@@ -357,6 +358,44 @@ function MetadataResourceEditPageImpl({ type, name, createMode, embedded, }) {
357
358
  if (!editing)
358
359
  setSelection(null);
359
360
  }, [editing]);
361
+ // #2272 — designer deep-link: `?sel=nav:<id>` selects the nav item with
362
+ // that spec `id` (stable across reorders, unlike the positional selection
363
+ // ids the canvas/inspector exchange internally). Applied once per
364
+ // param/item; entering edit mode is implied — a selection is meaningless
365
+ // in the read-only state (the effect above would clear it).
366
+ const navSelParam = parseNavSelParam(searchParams.get(DESIGNER_SEL_PARAM));
367
+ const appliedNavSelRef = React.useRef(null);
368
+ React.useEffect(() => {
369
+ if (type !== 'app' || !navSelParam)
370
+ return;
371
+ if (appliedNavSelRef.current === `${name}:${navSelParam}`)
372
+ return;
373
+ if (!draft || Object.keys(draft).length === 0)
374
+ return;
375
+ const hit = findNavPositionById(draft, navSelParam);
376
+ if (!hit)
377
+ return;
378
+ appliedNavSelRef.current = `${name}:${navSelParam}`;
379
+ setEditing(true);
380
+ setSelection({ kind: 'nav', id: hit.selectionId, label: hit.label });
381
+ }, [type, name, navSelParam, draft]);
382
+ // Mirror nav selections back to the URL (replace — no history spam, same
383
+ // convention as ADR-0047 `uf_*`) so the designer's selected menu is
384
+ // shareable and survives reload. Non-nav selections clear the param.
385
+ React.useEffect(() => {
386
+ if (type !== 'app')
387
+ return;
388
+ const navId = selection?.kind === 'nav' ? navIdAtPosition(draft, selection.id) : null;
389
+ setSearchParams((prev) => {
390
+ const next = new URLSearchParams(prev);
391
+ if (navId)
392
+ next.set(DESIGNER_SEL_PARAM, formatNavSelParam(navId));
393
+ else
394
+ next.delete(DESIGNER_SEL_PARAM);
395
+ return next;
396
+ }, { replace: true });
397
+ // eslint-disable-next-line react-hooks/exhaustive-deps
398
+ }, [type, selection]);
360
399
  // Snapshot of the last saved draft. Used by Cancel to revert in-flight
361
400
  // edits, and as the source-of-truth when entering edit mode.
362
401
  const draftSnapshotRef = React.useRef(null);