@object-ui/app-shell 11.3.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 (90) hide show
  1. package/CHANGELOG.md +700 -0
  2. package/README.md +32 -0
  3. package/dist/chrome/KeyboardShortcutsDialog.js +2 -1
  4. package/dist/console/AppContent.js +145 -26
  5. package/dist/console/ConsoleShell.js +25 -3
  6. package/dist/console/home/CloudOnboardingNext.d.ts +9 -0
  7. package/dist/console/home/CloudOnboardingNext.js +14 -4
  8. package/dist/console/home/HomePage.js +34 -7
  9. package/dist/console/organizations/CreateWorkspaceDialog.js +33 -3
  10. package/dist/console/organizations/OrganizationsPage.js +16 -7
  11. package/dist/context/CommandPaletteProvider.js +2 -1
  12. package/dist/hooks/useConsoleActionRuntime.js +32 -3
  13. package/dist/hooks/useObjectActions.js +16 -4
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +3 -0
  16. package/dist/layout/AppHeader.js +13 -5
  17. package/dist/layout/AppSidebar.js +10 -4
  18. package/dist/observability/sentry.d.ts +5 -0
  19. package/dist/observability/sentry.js +6 -1
  20. package/dist/preview/DraftChangesPanel.d.ts +31 -1
  21. package/dist/preview/DraftChangesPanel.js +146 -18
  22. package/dist/urlParams.d.ts +68 -0
  23. package/dist/urlParams.js +76 -0
  24. package/dist/utils/appRoute.d.ts +15 -0
  25. package/dist/utils/appRoute.js +22 -0
  26. package/dist/utils/deriveRelatedLists.d.ts +20 -5
  27. package/dist/utils/deriveRelatedLists.js +31 -13
  28. package/dist/utils/index.d.ts +1 -1
  29. package/dist/utils/index.js +1 -1
  30. package/dist/utils/pageTabsUrlSync.d.ts +32 -0
  31. package/dist/utils/pageTabsUrlSync.js +43 -0
  32. package/dist/utils/recordFormNavigation.d.ts +40 -0
  33. package/dist/utils/recordFormNavigation.js +30 -0
  34. package/dist/utils/resolveViewId.d.ts +23 -0
  35. package/dist/utils/resolveViewId.js +37 -0
  36. package/dist/utils/warnSuppressedListNav.d.ts +10 -0
  37. package/dist/utils/warnSuppressedListNav.js +40 -0
  38. package/dist/views/InterfaceListPage.d.ts +1 -0
  39. package/dist/views/InterfaceListPage.js +7 -5
  40. package/dist/views/ObjectDataPage.d.ts +29 -0
  41. package/dist/views/ObjectDataPage.js +227 -0
  42. package/dist/views/ObjectView.js +65 -13
  43. package/dist/views/RecordDetailView.js +191 -123
  44. package/dist/views/RecordFormPage.js +7 -1
  45. package/dist/views/RelatedRecordActionsBridge.d.ts +33 -0
  46. package/dist/views/RelatedRecordActionsBridge.js +147 -0
  47. package/dist/views/metadata-admin/PackagesPage.js +18 -7
  48. package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +18 -1
  49. package/dist/views/metadata-admin/PermissionMatrixEditor.js +73 -14
  50. package/dist/views/metadata-admin/ResourceEditPage.js +39 -0
  51. package/dist/views/metadata-admin/i18n.d.ts +12 -21
  52. package/dist/views/metadata-admin/i18n.js +557 -6
  53. package/dist/views/metadata-admin/inspectors/AppNavInspector.d.ts +11 -4
  54. package/dist/views/metadata-admin/inspectors/AppNavInspector.js +141 -7
  55. package/dist/views/metadata-admin/inspectors/FlowReferenceField.d.ts +14 -0
  56. package/dist/views/metadata-admin/inspectors/FlowReferenceField.js +76 -5
  57. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +60 -30
  58. package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +8 -1
  59. package/dist/views/metadata-admin/inspectors/flow-node-config.js +3 -2
  60. package/dist/views/metadata-admin/inspectors/nav-target.d.ts +52 -0
  61. package/dist/views/metadata-admin/inspectors/nav-target.js +149 -0
  62. package/dist/views/metadata-admin/nav-selection.d.ts +20 -0
  63. package/dist/views/metadata-admin/nav-selection.js +81 -0
  64. package/dist/views/metadata-admin/permission-slice.d.ts +66 -0
  65. package/dist/views/metadata-admin/permission-slice.js +70 -0
  66. package/dist/views/metadata-admin/previews/AppNavCanvas.js +19 -7
  67. package/dist/views/metadata-admin/previews/AppPreview.js +4 -2
  68. package/dist/views/metadata-admin/previews/FlowRunsPanel.d.ts +16 -7
  69. package/dist/views/metadata-admin/previews/FlowRunsPanel.js +18 -2
  70. package/dist/views/studio-design/BuilderLanding.d.ts +15 -0
  71. package/dist/views/studio-design/BuilderLanding.js +126 -0
  72. package/dist/views/studio-design/ObjectFormDesigner.d.ts +33 -0
  73. package/dist/views/studio-design/ObjectFormDesigner.js +231 -0
  74. package/dist/views/studio-design/ObjectSettingsPanel.d.ts +30 -0
  75. package/dist/views/studio-design/ObjectSettingsPanel.js +46 -0
  76. package/dist/views/studio-design/ObjectValidationsPanel.d.ts +30 -0
  77. package/dist/views/studio-design/ObjectValidationsPanel.js +80 -0
  78. package/dist/views/studio-design/PackageIdInput.d.ts +31 -0
  79. package/dist/views/studio-design/PackageIdInput.js +40 -0
  80. package/dist/views/studio-design/StudioDesignSurface.d.ts +13 -0
  81. package/dist/views/studio-design/StudioDesignSurface.js +962 -145
  82. package/dist/views/studio-design/metadataError.d.ts +23 -0
  83. package/dist/views/studio-design/metadataError.js +44 -0
  84. package/dist/views/studio-design/packageSurfaces.d.ts +49 -0
  85. package/dist/views/studio-design/packageSurfaces.js +34 -0
  86. package/dist/views/studio-design/packages-io.d.ts +38 -0
  87. package/dist/views/studio-design/packages-io.js +73 -0
  88. package/dist/views/studio-design/skeletons.d.ts +16 -0
  89. package/dist/views/studio-design/skeletons.js +51 -0
  90. package/package.json +42 -39
@@ -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';
11
- import { DetailView, RecordChatterPanel, buildDefaultPageSchema, extractMentions } from '@object-ui/plugin-detail';
10
+ import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom';
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';
@@ -26,6 +26,9 @@ import { ActionConfirmDialog } from './ActionConfirmDialog';
26
26
  import { ActionParamDialog } from './ActionParamDialog';
27
27
  import { ActionResultDialog } from './ActionResultDialog';
28
28
  import { FlowRunner } from './FlowRunner';
29
+ import { RelatedRecordActionsBridge } from './RelatedRecordActionsBridge';
30
+ import { withPageTabsUrlSync } from '../utils/pageTabsUrlSync';
31
+ import { RECORD_DETAIL_TAB_PARAM } from '../urlParams';
29
32
  import { resolveActionParams } from '../utils/resolveActionParams';
30
33
  import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
31
34
  import { useRecordApprovals } from '../hooks/useRecordApprovals';
@@ -47,9 +50,8 @@ const AUDIT_FIELD_NAMES = new Set(['created_at', 'created_by', 'updated_at', 'up
47
50
  /**
48
51
  * System/tenant fields that the framework auto-injects on every record but
49
52
  * which carry no business value on a detail page. Hidden from the
50
- * auto-generated section (when the object has no explicit form sections).
51
- * Authors who really want to show these can still list them in
52
- * `objectDef.views.form.sections`.
53
+ * auto-generated sections. Authors who really want to surface one can
54
+ * assign it to a `fieldGroups` group explicitly (explicit listing wins).
53
55
  */
54
56
  const HIDDEN_SYSTEM_FIELD_NAMES = new Set([
55
57
  'organization_id', 'tenant_id', 'is_deleted', 'deleted_at',
@@ -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') {
@@ -359,38 +393,46 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
359
393
  case 'opportunity_mark_lost':
360
394
  await dataSource.update(objectName, pureRecordId, { stage: 'closed_lost', loss_reason: params.loss_reason });
361
395
  break;
362
- default:
363
- // Generic: update record with collected params
364
- if (Object.keys(params).length > 0) {
396
+ default: {
397
+ // Generic: update record with collected params. Related-list row
398
+ // actions retarget a CHILD record via explicit `objectName`/`recordId`;
399
+ // otherwise the update falls back to this page's record.
400
+ const targetObject = action.objectName ?? objectName;
401
+ const targetId = action.recordId ?? pureRecordId;
402
+ const isThisRecord = targetObject === objectName && String(targetId) === String(pureRecordId);
403
+ if (Object.keys(params).length > 0 && targetObject && targetId != null) {
365
404
  // Undoable single-record update: capture the changed fields' prior
366
405
  // values from the loaded record so the success toast can offer Undo.
367
- if (action.undoable && pageRecord && objectName && pureRecordId) {
406
+ // Only this page's record has its prior values loaded, so child-row
407
+ // updates skip undo capture.
408
+ if (action.undoable && isThisRecord && pageRecord) {
368
409
  const undoData = {};
369
410
  for (const k of Object.keys(params))
370
411
  undoData[k] = pageRecord[k] ?? null;
371
412
  undo = {
372
- id: `undo-${objectName}-${pureRecordId}-${Date.now()}`,
413
+ id: `undo-${targetObject}-${targetId}-${Date.now()}`,
373
414
  type: 'update',
374
- objectName,
375
- recordId: String(pureRecordId),
415
+ objectName: targetObject,
416
+ recordId: String(targetId),
376
417
  timestamp: Date.now(),
377
- description: action.label || `Undo ${objectName}`,
418
+ description: action.label || `Undo ${targetObject}`,
378
419
  undoData,
379
420
  redoData: { ...params },
380
421
  };
381
422
  }
382
- await dataSource.update(objectName, pureRecordId, params);
423
+ await dataSource.update(targetObject, String(targetId), params);
383
424
  }
384
425
  break;
426
+ }
385
427
  }
386
428
  const shouldRefresh = action.refreshAfter === true;
387
429
  if (shouldRefresh) {
388
- setActionRefreshKey(k => k + 1);
430
+ notifyRecordChanged();
389
431
  }
390
432
  else if (undo) {
391
433
  // Even when refreshAfter isn't set, reflect the change so the user sees
392
434
  // it (and the subsequent Undo) on the open record.
393
- setActionRefreshKey(k => k + 1);
435
+ notifyRecordChanged();
394
436
  }
395
437
  return { success: true, reload: shouldRefresh, undo };
396
438
  }
@@ -419,8 +461,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
419
461
  method: 'POST',
420
462
  headers: { 'Content-Type': 'application/json' },
421
463
  body: JSON.stringify({
422
- recordId: pureRecordId,
423
- objectName,
464
+ // Related-list row actions retarget the flow at a CHILD record via
465
+ // an explicit `recordId` / `objectName`; fall back to this page's
466
+ // record when the action carries none (header/more actions).
467
+ recordId: action.recordId ?? pureRecordId,
468
+ objectName: action.objectName ?? objectName,
424
469
  params: action.params ?? {},
425
470
  }),
426
471
  });
@@ -440,7 +485,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
440
485
  }
441
486
  const shouldRefresh = action.refreshAfter !== false;
442
487
  if (shouldRefresh) {
443
- setActionRefreshKey(k => k + 1);
488
+ notifyRecordChanged();
444
489
  }
445
490
  return { success: true, data: json?.data, reload: shouldRefresh };
446
491
  }
@@ -554,14 +599,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
554
599
  }
555
600
  }
556
601
  if (action.refreshAfter === true)
557
- setActionRefreshKey(k => k + 1);
602
+ notifyRecordChanged();
558
603
  return { success: true };
559
604
  }
560
605
  const obj = action.objectName || objectName || 'global';
561
606
  const res = await authFetch(`${baseUrl}/api/v1/actions/${encodeURIComponent(obj)}/${encodeURIComponent(targetName)}`, {
562
607
  method: 'POST',
563
608
  headers: { 'Content-Type': 'application/json' },
564
- body: JSON.stringify({ recordId: pureRecordId, params }),
609
+ // Related-list row actions retarget a CHILD record via explicit
610
+ // `recordId`; header/more actions carry none and use this page's id.
611
+ body: JSON.stringify({ recordId: action.recordId ?? pureRecordId, params }),
565
612
  });
566
613
  const json = await res.json().catch(() => null);
567
614
  // The action route wraps the handler's return value in a {success, data}
@@ -580,14 +627,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
580
627
  }
581
628
  catch { /* ignore */ }
582
629
  }
583
- // Surface the failure this custom new-tab path bypasses
584
- // ActionRunner's toast-on-error, so otherwise the user gets no feedback.
585
- 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.
586
635
  return { success: false, error: errMsg };
587
636
  }
588
637
  const shouldRefresh = action.refreshAfter !== false;
589
638
  if (shouldRefresh)
590
- setActionRefreshKey(k => k + 1);
639
+ notifyRecordChanged();
591
640
  const result = json?.data;
592
641
  // ── redirectUrl convention ────────────────────────────────────────
593
642
  // A script-action handler can return `{ redirectUrl: 'https://…' }`
@@ -653,7 +702,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
653
702
  catch { /* ignore */ }
654
703
  }
655
704
  const msg = error.message;
656
- toast.error(msg);
705
+ // Don't toast here — the ActionRunner post-execution hook toasts the
706
+ // returned `error` once (see the failure branch above).
657
707
  return { success: false, error: msg };
658
708
  }
659
709
  finally {
@@ -686,7 +736,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
686
736
  else {
687
737
  return { success: false, error: `Unknown approval target: ${target}` };
688
738
  }
689
- setActionRefreshKey((k) => k + 1);
739
+ notifyRecordChanged();
690
740
  return { success: true, reload: true };
691
741
  }
692
742
  catch (err) {
@@ -1215,76 +1265,49 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1215
1265
  if (!objectDef) {
1216
1266
  return { type: 'detail-view' };
1217
1267
  }
1218
- // Auto-detect primary field: prefer objectDef metadata, then 'name' or 'title' heuristic
1268
+ // Auto-detect primary field: prefer objectDef metadata `primaryField`
1269
+ // (objectui-local override), then the spec-canonical `nameField` and its
1270
+ // deprecated `displayNameField` alias (ADR-0079) — then the 'name'/'title'
1271
+ // heuristic.
1219
1272
  const primaryField = objectDef.primaryField
1273
+ || objectDef.nameField
1274
+ || objectDef.displayNameField
1220
1275
  || Object.keys(objectDef.fields || {}).find((key) => key === 'name' || key === 'title');
1221
- // Build sections: prefer form sections from objectDef, fallback to flat field list
1222
- const formSections = objectDef.views?.form?.sections;
1223
- const sections = formSections && formSections.length > 0
1224
- ? formSections.map((sec) => ({
1225
- title: sec.name ? sectionLabel(objectDef.name, sec.name, sec.title || sec.name) : sec.title,
1226
- collapsible: sec.collapsible,
1227
- defaultCollapsed: sec.defaultCollapsed,
1228
- fields: (sec.fields || [])
1229
- .filter((f) => {
1230
- // Honor `hidden: true` on a field def even when the form
1231
- // section explicitly lists it. Hidden fields are typically
1232
- // internal artifacts (e.g. database URL, environment id)
1233
- // that platform actions read but end-users shouldn't see.
1234
- const fieldName = typeof f === 'string' ? f : f.name;
1235
- const fieldDef = objectDef.fields?.[fieldName];
1236
- return !fieldDef?.hidden;
1237
- })
1238
- .map((f) => {
1239
- const fieldName = typeof f === 'string' ? f : f.name;
1240
- const fieldDef = objectDef.fields[fieldName];
1241
- if (!fieldDef) {
1242
- console.warn(`[RecordDetailView] Field "${fieldName}" not found in ${objectDef.name} definition`);
1243
- return { name: fieldName, label: fieldName };
1244
- }
1245
- const refTarget = fieldDef.reference_to || fieldDef.reference;
1246
- return {
1247
- name: fieldName,
1248
- label: fieldDef.label || fieldName,
1249
- type: fieldDef.type || 'text',
1250
- ...(fieldDef.options && { options: fieldDef.options }),
1251
- ...(refTarget && { reference_to: refTarget }),
1252
- ...(fieldDef.reference_field && { reference_field: fieldDef.reference_field }),
1253
- ...(fieldDef.currency && { currency: fieldDef.currency }),
1254
- };
1255
- }),
1256
- }))
1257
- : (() => {
1258
- // Auto-grouping (platform B): when no form sections are authored,
1259
- // split fields into a primary section and a collapsible
1260
- // "More details" section so long-form/secondary fields don't
1261
- // dilute the main grid. The primary section stays untitled so
1262
- // DetailSection still flattens its chrome when alone.
1263
- const allFields = Object.keys(objectDef.fields || {})
1264
- .filter((key) => !AUDIT_FIELD_NAMES.has(key) && !HIDDEN_SYSTEM_FIELD_NAMES.has(key) && !objectDef.fields[key]?.hidden);
1265
- const toField = (key) => {
1266
- const fieldDef = objectDef.fields[key];
1267
- const refTarget = fieldDef.reference_to || fieldDef.reference;
1268
- return {
1269
- name: key,
1270
- label: fieldDef.label || key,
1271
- type: fieldDef.type || 'text',
1272
- ...(fieldDef.options && { options: fieldDef.options }),
1273
- ...(refTarget && { reference_to: refTarget }),
1274
- ...(fieldDef.reference_field && { reference_field: fieldDef.reference_field }),
1275
- ...(fieldDef.currency && { currency: fieldDef.currency }),
1276
- };
1276
+ // Build sections (ADR-0085: grouping is the `fieldGroups` semantic
1277
+ // role there is no per-surface sections override; per-page
1278
+ // customization goes through an assigned Page schema):
1279
+ // 1) sections derived from the object's `fieldGroups`;
1280
+ // 2) auto-grouping (primary + collapsible "More details").
1281
+ const sections = (() => {
1282
+ const toField = (key) => {
1283
+ const fieldDef = objectDef.fields[key];
1284
+ const refTarget = fieldDef.reference_to || fieldDef.reference;
1285
+ return {
1286
+ name: key,
1287
+ label: fieldDef.label || key,
1288
+ type: fieldDef.type || 'text',
1289
+ ...(fieldDef.options && { options: fieldDef.options }),
1290
+ ...(refTarget && { reference_to: refTarget }),
1291
+ ...(fieldDef.reference_field && { reference_field: fieldDef.reference_field }),
1292
+ ...(fieldDef.currency && { currency: fieldDef.currency }),
1277
1293
  };
1278
- const primaryKeys = allFields.filter((k) => !isSecondaryField(k, objectDef.fields[k]));
1279
- const secondaryKeys = allFields.filter((k) => isSecondaryField(k, objectDef.fields[k]));
1280
- // Below ~6 primary fields the second section often looks awkward
1281
- // keep the legacy single-untitled-section behaviour. Also
1282
- // honour the "no secondary fields" case the same way.
1294
+ };
1295
+ // Auto-grouping (platform B): split fields into a primary section
1296
+ // and a collapsible "More details" section so long-form/secondary
1297
+ // fields don't dilute the main grid. The primary section stays
1298
+ // untitled so DetailSection still flattens its chrome when alone.
1299
+ // Shared by the pure-fallback path and the ungrouped remainder of
1300
+ // the fieldGroups path below.
1301
+ const splitPrimarySecondary = (keys) => {
1302
+ const primaryKeys = keys.filter((k) => !isSecondaryField(k, objectDef.fields[k]));
1303
+ const secondaryKeys = keys.filter((k) => isSecondaryField(k, objectDef.fields[k]));
1304
+ // Keep the legacy single-untitled-section behaviour when the
1305
+ // split would leave one side empty.
1283
1306
  if (secondaryKeys.length === 0 || primaryKeys.length === 0) {
1284
1307
  return [
1285
1308
  {
1286
1309
  showBorder: false,
1287
- fields: allFields.map(toField),
1310
+ fields: keys.map(toField),
1288
1311
  },
1289
1312
  ];
1290
1313
  }
@@ -1302,7 +1325,31 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1302
1325
  fields: secondaryKeys.map(toField),
1303
1326
  },
1304
1327
  ];
1305
- })();
1328
+ };
1329
+ // 1) fieldGroups-derived sections (the ADR-0085 semantic role,
1330
+ // same shared derivation the runtime form honours). Declared
1331
+ // groups render as titled cards in declared order; the
1332
+ // trailing untitled bucket (ungrouped fields) still goes
1333
+ // through the primary/"More details" split so long-form
1334
+ // fields stay tucked away.
1335
+ const grouped = deriveFieldGroupDetailSections(objectDef);
1336
+ if (grouped) {
1337
+ return grouped.flatMap((sec) => {
1338
+ if (!sec.name) {
1339
+ return splitPrimarySecondary(sec.fields.map((f) => f.name));
1340
+ }
1341
+ return [{
1342
+ ...sec,
1343
+ title: sectionLabel(objectDef.name, sec.name, sec.title),
1344
+ showBorder: true,
1345
+ }];
1346
+ });
1347
+ }
1348
+ // 3) Pure auto-grouping fallback.
1349
+ const allFields = Object.keys(objectDef.fields || {})
1350
+ .filter((key) => !AUDIT_FIELD_NAMES.has(key) && !HIDDEN_SYSTEM_FIELD_NAMES.has(key) && !objectDef.fields[key]?.hidden);
1351
+ return splitPrimarySecondary(allFields);
1352
+ })();
1306
1353
  // Audit fields (created_at/created_by/updated_at/updated_by) are NOT
1307
1354
  // appended as a section here — they are surfaced by `<RecordMetaFooter>`
1308
1355
  // (rendered by DetailView) as a single subtle line below the content,
@@ -1372,10 +1419,22 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1372
1419
  }
1373
1420
  return base;
1374
1421
  })();
1375
- // Build highlightFields: exclusively from objectDef metadata (no hardcoded fallback)
1376
- const highlightFields = objectDef.views?.detail?.highlightFields ?? [];
1377
- // Build sectionGroups from objectDef detail/form config if available
1378
- const sectionGroups = objectDef.views?.detail?.sectionGroups ?? objectDef.views?.form?.sectionGroups;
1422
+ // Build highlightFields from the object's semantic role (ADR-0085).
1423
+ // Bare field names resolve label/type from the field def.
1424
+ const rawHighlightFields = objectDef.highlightFields ?? [];
1425
+ const highlightFields = (Array.isArray(rawHighlightFields) ? rawHighlightFields : [])
1426
+ .map((f) => {
1427
+ const name = typeof f === 'string' ? f : f?.name;
1428
+ if (!name)
1429
+ return null;
1430
+ const fieldDef = objectDef.fields?.[name];
1431
+ return {
1432
+ name,
1433
+ label: fieldDef?.label || name,
1434
+ ...(fieldDef?.type ? { type: fieldDef.type } : {}),
1435
+ };
1436
+ })
1437
+ .filter((f) => !!f);
1379
1438
  // Build related entries from reverse-reference child objects.
1380
1439
  // `referenceField` is the FK field on the child pointing back to this
1381
1440
  // record — passed so the related-list renderer can hide the redundant
@@ -1468,9 +1527,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1468
1527
  // Surface the child object's canonical display field so the
1469
1528
  // right-rail can show meaningful labels (`user_agent`, `email`,
1470
1529
  // …) instead of opaque IDs like `kCc8mhJr0bRs0r9Ykd09…`.
1471
- displayField: childObjectDef?.displayNameField ||
1472
- (Array.isArray(childObjectDef?.compactLayout)
1473
- ? childObjectDef.compactLayout[0]
1530
+ displayField: childObjectDef?.nameField ||
1531
+ childObjectDef?.displayNameField ||
1532
+ (Array.isArray(childObjectDef?.highlightFields)
1533
+ ? childObjectDef.highlightFields[0]
1474
1534
  : undefined),
1475
1535
  onNew,
1476
1536
  onViewAll,
@@ -1496,6 +1556,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1496
1556
  primaryField,
1497
1557
  sections,
1498
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,
1499
1563
  autoDiscoverRelated: true,
1500
1564
  ...(historyEnabled && {
1501
1565
  history: {
@@ -1505,7 +1569,6 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1505
1569
  }),
1506
1570
  ...(related.length > 0 && { related }),
1507
1571
  ...(highlightFields.length > 0 && { highlightFields }),
1508
- ...(sectionGroups && sectionGroups.length > 0 && { sectionGroups }),
1509
1572
  ...(recordHeaderActions.length > 0 && {
1510
1573
  actions: [{
1511
1574
  type: 'action:bar',
@@ -1515,7 +1578,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1515
1578
  }),
1516
1579
  };
1517
1580
  // eslint-disable-next-line react-hooks/exhaustive-deps
1518
- }, [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]);
1519
1582
  if (isLoading) {
1520
1583
  return _jsx(SkeletonDetail, {});
1521
1584
  }
@@ -1666,14 +1729,21 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1666
1729
  const synthRelated = Array.isArray(detailSchema.related)
1667
1730
  ? detailSchema.related
1668
1731
  .filter((r) => r?.api && r?.referenceField)
1669
- .map((r) => ({
1670
- title: r.title,
1671
- objectName: r.api,
1672
- relationshipField: r.referenceField,
1673
- ...(Array.isArray(r.columns) ? { columns: r.columns } : {}),
1674
- ...(typeof r.pageSize === 'number' ? { limit: r.pageSize } : {}),
1675
- ...(r.icon ? { icon: r.icon } : {}),
1676
- }))
1732
+ .map((r) => {
1733
+ // Carry the `relatedList: 'primary'` prominence flag from the derived
1734
+ // relationship graph. Matched by (childObject, referenceField) — the
1735
+ // unique key of a related list — so it is robust to ordering/filtering.
1736
+ const derived = childRelations.find((c) => c.childObject === r.api && c.referenceField === r.referenceField);
1737
+ return {
1738
+ title: r.title,
1739
+ objectName: r.api,
1740
+ relationshipField: r.referenceField,
1741
+ ...(Array.isArray(r.columns) ? { columns: r.columns } : {}),
1742
+ ...(typeof r.pageSize === 'number' ? { limit: r.pageSize } : {}),
1743
+ ...(r.icon ? { icon: r.icon } : {}),
1744
+ ...(derived?.isPrimary ? { isPrimary: true } : {}),
1745
+ };
1746
+ })
1677
1747
  : undefined;
1678
1748
  const synthHistory = detailSchema.history
1679
1749
  ? {
@@ -1694,16 +1764,14 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1694
1764
  headerActions: synthHeaderActions,
1695
1765
  related: synthRelated,
1696
1766
  history: synthHistory,
1697
- // Per-object opt-outs read from `objectDef.detail.*`. Lets
1698
- // catalog/atomic objects (product, task, ...) keep a focused
1699
- // single-column layout instead of inheriting the rail.
1700
- showReferenceRail: objectDef?.detail?.showReferenceRail === true || undefined,
1701
- hideReferenceRail: objectDef?.detail?.hideReferenceRail === true || undefined,
1702
- hideRelatedTab: objectDef?.detail?.hideRelatedTab === true || undefined,
1703
- relatedLayout: objectDef?.detail?.relatedLayout === 'tabs' ? 'tabs' : undefined,
1767
+ // ADR-0085 removed the per-object `detail.*` presentation
1768
+ // toggles (show/hideReferenceRail, hideRelatedTab, relatedLayout)
1769
+ // — the synth defaults apply; per-page layout goes through an
1770
+ // assigned Page schema (`record:reference_rail` stays available
1771
+ // there as a renderer capability).
1704
1772
  ...(assignedSlots ? { slots: assignedSlots } : {}),
1705
1773
  });
1706
- 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(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: {
1707
1775
  position: 'bottom',
1708
1776
  collapsible: false,
1709
1777
  feed: {
@@ -1720,7 +1788,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1720
1788
  } }), _jsx(ActionResultDialog, { state: resultDialogState, onAcknowledge: () => {
1721
1789
  resultDialogState.resolve?.();
1722
1790
  setResultDialogState({ open: false });
1723
- } }), _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(); } })] }));
1724
1792
  }
1725
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) => {
1726
1794
  if (!record || typeof record !== 'object')
@@ -1742,7 +1810,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1742
1810
  enableThreading: true,
1743
1811
  showCommentInput: true,
1744
1812
  },
1745
- }, 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) => {
1746
1814
  if (!open)
1747
1815
  setConfirmState(s => ({ ...s, open: false }));
1748
1816
  } }), _jsx(ActionParamDialog, { state: paramState, onOpenChange: (open) => {
@@ -1751,5 +1819,5 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1751
1819
  } }), _jsx(ActionResultDialog, { state: resultDialogState, onAcknowledge: () => {
1752
1820
  resultDialogState.resolve?.();
1753
1821
  setResultDialogState({ open: false });
1754
- } }), _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(); } })] }));
1755
1823
  }
@@ -181,7 +181,13 @@ export function RecordFormPage({ mode }) {
181
181
  // `simple` here (see the form-layout-vs-presentation modelling note in #1890).
182
182
  const formDef = objectDef.form ?? objectDef.formViews?.default ?? {};
183
183
  const pageFormType = ['tabbed', 'wizard', 'split'].includes(formDef.type) ? formDef.type : 'simple';
184
- const formLayoutProps = pageFormType !== 'simple' && Array.isArray(formDef.sections)
184
+ // Curated `sections` are wired through for EVERY layout family — a `simple`
185
+ // form view's sections still carry the authored field selection, order,
186
+ // grouping and per-field `visibleOn` predicates (#2212). Dropping them for
187
+ // `simple` made the page-mode form fall back to the raw schema (every field,
188
+ // no conditional visibility) while the New/Edit modal honored the same view
189
+ // via resolveFormViewLayout.
190
+ const formLayoutProps = Array.isArray(formDef.sections) && formDef.sections.length > 0
185
191
  ? {
186
192
  sections: formDef.sections,
187
193
  defaultTab: formDef.defaultTab,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
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
+ */
18
+ export declare function notifyRelatedChanged(objectName: string): void;
19
+ /** i18n label resolver signature (matches `useObjectLabel().actionLabel`). */
20
+ type ActionLabelFn = (objectName: string | undefined, actionName: string, fallback: string) => string;
21
+ export interface RelatedRecordActionsBridgeProps {
22
+ /** Current app segment used to build `/apps/:appName/...` routes. */
23
+ appName?: string;
24
+ /** All object definitions (to resolve the child object + its actions). */
25
+ objects: any[];
26
+ /** Data source for delete + action dispatch. */
27
+ dataSource: any;
28
+ /** Localizes a child action's label (falls back to the raw label). */
29
+ actionLabel: ActionLabelFn;
30
+ children: React.ReactNode;
31
+ }
32
+ export declare function RelatedRecordActionsBridge({ appName, objects, dataSource, actionLabel, children, }: RelatedRecordActionsBridgeProps): import("react").JSX.Element;
33
+ export {};