@object-ui/app-shell 11.3.0 → 11.4.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 (50) hide show
  1. package/CHANGELOG.md +522 -0
  2. package/README.md +23 -0
  3. package/dist/console/ConsoleShell.js +17 -2
  4. package/dist/console/home/CloudOnboardingNext.d.ts +9 -0
  5. package/dist/console/home/CloudOnboardingNext.js +14 -4
  6. package/dist/console/home/HomePage.js +34 -7
  7. package/dist/console/organizations/CreateWorkspaceDialog.js +33 -3
  8. package/dist/console/organizations/OrganizationsPage.js +16 -7
  9. package/dist/hooks/useConsoleActionRuntime.js +32 -3
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.js +3 -0
  12. package/dist/preview/DraftChangesPanel.d.ts +3 -1
  13. package/dist/preview/DraftChangesPanel.js +6 -5
  14. package/dist/utils/deriveRelatedLists.d.ts +20 -5
  15. package/dist/utils/deriveRelatedLists.js +31 -13
  16. package/dist/utils/resolveViewId.d.ts +23 -0
  17. package/dist/utils/resolveViewId.js +37 -0
  18. package/dist/utils/warnSuppressedListNav.d.ts +10 -0
  19. package/dist/utils/warnSuppressedListNav.js +40 -0
  20. package/dist/views/InterfaceListPage.js +6 -4
  21. package/dist/views/ObjectView.js +61 -10
  22. package/dist/views/RecordDetailView.js +131 -104
  23. package/dist/views/RecordFormPage.js +7 -1
  24. package/dist/views/RelatedRecordActionsBridge.d.ts +24 -0
  25. package/dist/views/RelatedRecordActionsBridge.js +114 -0
  26. package/dist/views/metadata-admin/PackagesPage.js +18 -7
  27. package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +18 -1
  28. package/dist/views/metadata-admin/PermissionMatrixEditor.js +73 -14
  29. package/dist/views/metadata-admin/i18n.d.ts +12 -21
  30. package/dist/views/metadata-admin/i18n.js +343 -2
  31. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +25 -11
  32. package/dist/views/metadata-admin/permission-slice.d.ts +66 -0
  33. package/dist/views/metadata-admin/permission-slice.js +70 -0
  34. package/dist/views/metadata-admin/previews/AppNavCanvas.js +11 -7
  35. package/dist/views/metadata-admin/previews/FlowRunsPanel.d.ts +16 -7
  36. package/dist/views/metadata-admin/previews/FlowRunsPanel.js +18 -2
  37. package/dist/views/studio-design/BuilderLanding.d.ts +15 -0
  38. package/dist/views/studio-design/BuilderLanding.js +133 -0
  39. package/dist/views/studio-design/ObjectFormDesigner.d.ts +31 -0
  40. package/dist/views/studio-design/ObjectFormDesigner.js +226 -0
  41. package/dist/views/studio-design/ObjectSettingsPanel.d.ts +30 -0
  42. package/dist/views/studio-design/ObjectSettingsPanel.js +45 -0
  43. package/dist/views/studio-design/ObjectValidationsPanel.d.ts +30 -0
  44. package/dist/views/studio-design/ObjectValidationsPanel.js +78 -0
  45. package/dist/views/studio-design/StudioDesignSurface.js +793 -146
  46. package/dist/views/studio-design/metadataError.d.ts +23 -0
  47. package/dist/views/studio-design/metadataError.js +44 -0
  48. package/dist/views/studio-design/packages-io.d.ts +27 -0
  49. package/dist/views/studio-design/packages-io.js +61 -0
  50. package/package.json +42 -39
@@ -53,8 +53,9 @@ function resolveSourceView(objectDef, sourceView) {
53
53
  /**
54
54
  * Default column set when the resolved view carries none — mirrors
55
55
  * ObjectView's data-mode fallback so an interface page never renders a
56
- * column-less grid. Priority: curated `compactLayout`, else the first
57
- * business fields (system/audit columns excluded).
56
+ * column-less grid. Priority: the `highlightFields` semantic role
57
+ * (ADR-0085), else the first business fields (system/audit columns
58
+ * excluded).
58
59
  */
59
60
  const SYSTEM_FIELDS = new Set([
60
61
  'id', 'created_at', 'createdAt', 'updated_at', 'updatedAt',
@@ -62,8 +63,9 @@ const SYSTEM_FIELDS = new Set([
62
63
  'updated_by', 'updatedBy', '_version', '_rev',
63
64
  ]);
64
65
  function defaultColumnsFromObject(objectDef) {
65
- if (Array.isArray(objectDef?.compactLayout) && objectDef.compactLayout.length > 0) {
66
- return objectDef.compactLayout.filter((n) => objectDef.fields?.[n]);
66
+ const curated = objectDef?.highlightFields;
67
+ if (Array.isArray(curated) && curated.length > 0) {
68
+ return curated.filter((n) => objectDef.fields?.[n]);
67
69
  }
68
70
  const fields = objectDef?.fields;
69
71
  if (fields && typeof fields === 'object') {
@@ -15,7 +15,7 @@ import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlSta
15
15
  const ObjectChart = lazy(() => import('@object-ui/plugin-charts').then((m) => ({ default: m.ObjectChart })));
16
16
  const ImportWizard = lazy(() => import('@object-ui/plugin-grid').then((m) => ({ default: m.ImportWizard })));
17
17
  import { ListView } from '@object-ui/plugin-list';
18
- import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@object-ui/plugin-view';
18
+ import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog, deriveRecordSurface, overlayWidthFor } from '@object-ui/plugin-view';
19
19
  // Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx
20
20
  // uses ComponentRegistry.registerLazy so heavy plugins stay code-split).
21
21
  // Do NOT add eager `import '@object-ui/plugin-*'` side-effect imports here.
@@ -34,6 +34,8 @@ import { ManagedByBadge } from '../components/ManagedByBadge';
34
34
  import { RecordDetailView } from './RecordDetailView';
35
35
  import { resolveCrudAffordances } from '../utils/crudAffordances';
36
36
  import { resolveManagedByEmptyState } from '../utils/managedByEmptyState';
37
+ import { resolveViewId } from '../utils/resolveViewId';
38
+ import { warnSuppressedListNav } from '../utils/warnSuppressedListNav';
37
39
  import { useObjectActions } from '../hooks/useObjectActions';
38
40
  import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
39
41
  import { usePermissions } from '@object-ui/permissions';
@@ -190,8 +192,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }) {
190
192
  'updated_by', 'updatedBy', '_version', '_rev',
191
193
  ]);
192
194
  let defaultColumns = [];
193
- if (Array.isArray(objectDef?.compactLayout) && objectDef.compactLayout.length > 0) {
194
- defaultColumns = objectDef.compactLayout.filter((n) => objectDef.fields?.[n]);
195
+ const curated = objectDef?.highlightFields;
196
+ if (Array.isArray(curated) && curated.length > 0) {
197
+ defaultColumns = curated.filter((n) => objectDef.fields?.[n]);
195
198
  }
196
199
  else if (objectDef?.fields) {
197
200
  defaultColumns = Object.entries(objectDef.fields)
@@ -430,7 +433,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }) {
430
433
  // Resolve Views from objectDef.listViews (camelCase per @objectstack/spec)
431
434
  const views = useMemo(() => {
432
435
  // Default column resolution priority:
433
- // 1. `compactLayout` (curated primary business fields).
436
+ // 1. The `highlightFields` semantic role (ADR-0085).
434
437
  // 2. Business fields only — exclude system-managed identifiers/audit
435
438
  // columns (id, created_at, updated_at, …) and fields explicitly
436
439
  // marked hidden/readonly on the schema. First 5 kept for compactness.
@@ -440,8 +443,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }) {
440
443
  'updated_by', 'updatedBy', '_version', '_rev',
441
444
  ]);
442
445
  const resolveDefaultColumns = () => {
443
- if (Array.isArray(objectDef.compactLayout) && objectDef.compactLayout.length > 0) {
444
- return objectDef.compactLayout.filter((n) => objectDef.fields?.[n]);
446
+ const curated = objectDef.highlightFields;
447
+ if (Array.isArray(curated) && curated.length > 0) {
448
+ return curated.filter((n) => objectDef.fields?.[n]);
445
449
  }
446
450
  if (objectDef.fields) {
447
451
  return Object.entries(objectDef.fields)
@@ -606,7 +610,20 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }) {
606
610
  const def = views.find((v) => v.isDefault);
607
611
  return def?.id;
608
612
  }, [views]);
609
- const activeViewId = viewId || searchParams.get('view') || defaultViewId || views[0]?.id;
613
+ // Canonical view ids are fully qualified (`<object>.<viewKind>`, see
614
+ // MetadataProvider), but nav items emit `viewName` verbatim — usually
615
+ // the short form — and legacy embedded listViews carry bare keys (incl.
616
+ // the `'all'` fallback). Resolve the URL-requested name in both
617
+ // directions, and never swallow a miss silently (#2217).
618
+ const requestedViewId = viewId || searchParams.get('view') || undefined;
619
+ const resolvedViewId = useMemo(() => resolveViewId(requestedViewId, views.map((v) => v.id), objectDef.name), [requestedViewId, views, objectDef.name]);
620
+ useEffect(() => {
621
+ if (requestedViewId && !resolvedViewId) {
622
+ console.warn(`[ObjectView] Requested view "${requestedViewId}" not found on object "${objectDef.name}"; ` +
623
+ `falling back to the default view. Known views: ${views.map((v) => v.id).join(', ')}`);
624
+ }
625
+ }, [requestedViewId, resolvedViewId, objectDef.name, views]);
626
+ const activeViewId = resolvedViewId || defaultViewId || views[0]?.id;
610
627
  const baseView = views.find((v) => v.id === activeViewId) || views[0];
611
628
  const activeView = viewDraft && viewDraft.id === baseView?.id
612
629
  ? { ...baseView, ...viewDraft }
@@ -874,8 +891,26 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }) {
874
891
  // RecordDetailView owns its own route — only same-page click navigation
875
892
  // is drawer-by-default. Per-view config can still override (e.g. a heavy
876
893
  // detail object can set `navigation.mode = 'page'`).
877
- const detailNavigation = useMemo(() => activeView?.navigation ??
878
- objectDef.navigation ?? { mode: 'drawer', width: 'min(92vw, 1280px)' }, [activeView?.navigation, objectDef.navigation]);
894
+ const detailNavigation = useMemo(() => {
895
+ const authored = activeView?.navigation ?? objectDef.navigation;
896
+ if (authored) {
897
+ // Authored config wins. For an overlay, resolve the `size`
898
+ // bucket (or 'auto') to a viewport-clamped width when no explicit
899
+ // width was given — #2578: a pixel width can't be authored blind.
900
+ if (authored.mode === 'page')
901
+ return authored;
902
+ return {
903
+ ...authored,
904
+ width: authored.width ?? overlayWidthFor(authored.size, objectDef),
905
+ };
906
+ }
907
+ // #2578: derive surface + width from FIELD COUNT. Field-heavy → full
908
+ // page (cramped in a drawer); light → a drawer sized to the content
909
+ // and clamped to the viewport. Mobile always pages (in deriveRecordSurface).
910
+ return deriveRecordSurface(objectDef) === 'page'
911
+ ? { mode: 'page' }
912
+ : { mode: 'drawer', width: overlayWidthFor('auto', objectDef) };
913
+ }, [activeView?.navigation, objectDef]);
879
914
  const drawerRecordId = searchParams.get('recordId');
880
915
  /**
881
916
  * URL-derived equality filters in the form `?filter[<field>]=<value>`.
@@ -1066,6 +1101,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }) {
1066
1101
  className: 'h-[400px] w-full',
1067
1102
  } }) }, key));
1068
1103
  }
1104
+ // ADR-0053 wrong-context authoring: userFilters/quickFilters on an
1105
+ // object list view are suppressed below — say so instead of letting
1106
+ // the author stare at a toolbar with nothing where their filter
1107
+ // controls should be (#2219).
1108
+ warnSuppressedListNav(objectDef.name, viewDef.id || viewDef.name || '', viewDef, listSchema);
1069
1109
  const fullSchema = {
1070
1110
  ...listSchema,
1071
1111
  // Propagate appearance/view-config properties for live preview
@@ -1369,11 +1409,22 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }) {
1369
1409
  // Import buttons stay visible without pushing the page
1370
1410
  // title off-screen.
1371
1411
  mobileMaxVisible: 0,
1372
- } })))] }) }) }), affordances.create && can(objectDef.name, 'create') && (_jsx("button", { type: "button", onClick: actions.create, className: "sm:hidden fixed right-4 bottom-36 z-40 h-12 w-12 rounded-full bg-primary text-primary-foreground shadow-lg active:scale-95 transition-transform inline-flex items-center justify-center", "aria-label": t('console.objectView.new'), "data-testid": "mobile-fab-create", children: _jsx(Plus, { className: "h-5 w-5" }) })), showImport && (_jsx(Suspense, { fallback: null, children: _jsx(ImportWizard, { open: showImport, onOpenChange: setShowImport, objectName: objectDef.name, objectLabel: objectLabel(objectDef), fields: Object.entries(objectDef.fields || {}).map(([name, def]) => ({
1412
+ } })))] }) }) }), affordances.create && can(objectDef.name, 'create') && (_jsx("button", { type: "button", onClick: actions.create, className: "sm:hidden fixed right-4 bottom-36 z-40 h-12 w-12 rounded-full bg-primary text-primary-foreground shadow-lg active:scale-95 transition-transform inline-flex items-center justify-center", "aria-label": t('console.objectView.new'), "data-testid": "mobile-fab-create", children: _jsx(Plus, { className: "h-5 w-5" }) })), showImport && (_jsx(Suspense, { fallback: null, children: _jsx(ImportWizard, { open: showImport, onOpenChange: setShowImport, objectName: objectDef.name, objectLabel: objectLabel(objectDef), fields: Object.entries(objectDef.fields || {})
1413
+ // Only writable fields are importable targets. Computed
1414
+ // types (formula/summary/autonumber) and fields flagged
1415
+ // readonly / write:false are server-rejected, so we omit
1416
+ // them from the mapping step rather than let a user map to
1417
+ // a column the import will silently drop.
1418
+ .filter(([, def]) => !['formula', 'summary', 'autonumber'].includes(def?.type) &&
1419
+ !def?.readonly &&
1420
+ def?.permissions?.write !== false)
1421
+ .map(([name, def]) => ({
1373
1422
  name,
1374
1423
  label: def?.label || name,
1375
1424
  type: def?.type || 'text',
1376
1425
  required: !!def?.required,
1426
+ // Enum options seed the downloadable template's example row.
1427
+ ...(def?.options ? { options: def.options } : {}),
1377
1428
  })), dataSource: dataSource, onComplete: (result) => {
1378
1429
  setRefreshKey(k => k + 1);
1379
1430
  const ok = result.importedRows;
@@ -8,7 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  */
9
9
  import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
10
10
  import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
11
- import { DetailView, RecordChatterPanel, buildDefaultPageSchema, extractMentions } from '@object-ui/plugin-detail';
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
14
  import { ActionProvider, useObjectTranslation, useObjectLabel, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, useGlobalUndo } from '@object-ui/react';
@@ -26,6 +26,7 @@ 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';
29
30
  import { resolveActionParams } from '../utils/resolveActionParams';
30
31
  import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
31
32
  import { useRecordApprovals } from '../hooks/useRecordApprovals';
@@ -47,9 +48,8 @@ const AUDIT_FIELD_NAMES = new Set(['created_at', 'created_by', 'updated_at', 'up
47
48
  /**
48
49
  * System/tenant fields that the framework auto-injects on every record but
49
50
  * 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`.
51
+ * auto-generated sections. Authors who really want to surface one can
52
+ * assign it to a `fieldGroups` group explicitly (explicit listing wins).
53
53
  */
54
54
  const HIDDEN_SYSTEM_FIELD_NAMES = new Set([
55
55
  'organization_id', 'tenant_id', 'is_deleted', 'deleted_at',
@@ -359,29 +359,37 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
359
359
  case 'opportunity_mark_lost':
360
360
  await dataSource.update(objectName, pureRecordId, { stage: 'closed_lost', loss_reason: params.loss_reason });
361
361
  break;
362
- default:
363
- // Generic: update record with collected params
364
- if (Object.keys(params).length > 0) {
362
+ default: {
363
+ // Generic: update record with collected params. Related-list row
364
+ // actions retarget a CHILD record via explicit `objectName`/`recordId`;
365
+ // otherwise the update falls back to this page's record.
366
+ const targetObject = action.objectName ?? objectName;
367
+ const targetId = action.recordId ?? pureRecordId;
368
+ const isThisRecord = targetObject === objectName && String(targetId) === String(pureRecordId);
369
+ if (Object.keys(params).length > 0 && targetObject && targetId != null) {
365
370
  // Undoable single-record update: capture the changed fields' prior
366
371
  // values from the loaded record so the success toast can offer Undo.
367
- if (action.undoable && pageRecord && objectName && pureRecordId) {
372
+ // Only this page's record has its prior values loaded, so child-row
373
+ // updates skip undo capture.
374
+ if (action.undoable && isThisRecord && pageRecord) {
368
375
  const undoData = {};
369
376
  for (const k of Object.keys(params))
370
377
  undoData[k] = pageRecord[k] ?? null;
371
378
  undo = {
372
- id: `undo-${objectName}-${pureRecordId}-${Date.now()}`,
379
+ id: `undo-${targetObject}-${targetId}-${Date.now()}`,
373
380
  type: 'update',
374
- objectName,
375
- recordId: String(pureRecordId),
381
+ objectName: targetObject,
382
+ recordId: String(targetId),
376
383
  timestamp: Date.now(),
377
- description: action.label || `Undo ${objectName}`,
384
+ description: action.label || `Undo ${targetObject}`,
378
385
  undoData,
379
386
  redoData: { ...params },
380
387
  };
381
388
  }
382
- await dataSource.update(objectName, pureRecordId, params);
389
+ await dataSource.update(targetObject, String(targetId), params);
383
390
  }
384
391
  break;
392
+ }
385
393
  }
386
394
  const shouldRefresh = action.refreshAfter === true;
387
395
  if (shouldRefresh) {
@@ -419,8 +427,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
419
427
  method: 'POST',
420
428
  headers: { 'Content-Type': 'application/json' },
421
429
  body: JSON.stringify({
422
- recordId: pureRecordId,
423
- objectName,
430
+ // Related-list row actions retarget the flow at a CHILD record via
431
+ // an explicit `recordId` / `objectName`; fall back to this page's
432
+ // record when the action carries none (header/more actions).
433
+ recordId: action.recordId ?? pureRecordId,
434
+ objectName: action.objectName ?? objectName,
424
435
  params: action.params ?? {},
425
436
  }),
426
437
  });
@@ -561,7 +572,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
561
572
  const res = await authFetch(`${baseUrl}/api/v1/actions/${encodeURIComponent(obj)}/${encodeURIComponent(targetName)}`, {
562
573
  method: 'POST',
563
574
  headers: { 'Content-Type': 'application/json' },
564
- body: JSON.stringify({ recordId: pureRecordId, params }),
575
+ // Related-list row actions retarget a CHILD record via explicit
576
+ // `recordId`; header/more actions carry none and use this page's id.
577
+ body: JSON.stringify({ recordId: action.recordId ?? pureRecordId, params }),
565
578
  });
566
579
  const json = await res.json().catch(() => null);
567
580
  // The action route wraps the handler's return value in a {success, data}
@@ -1215,76 +1228,49 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1215
1228
  if (!objectDef) {
1216
1229
  return { type: 'detail-view' };
1217
1230
  }
1218
- // Auto-detect primary field: prefer objectDef metadata, then 'name' or 'title' heuristic
1231
+ // Auto-detect primary field: prefer objectDef metadata `primaryField`
1232
+ // (objectui-local override), then the spec-canonical `nameField` and its
1233
+ // deprecated `displayNameField` alias (ADR-0079) — then the 'name'/'title'
1234
+ // heuristic.
1219
1235
  const primaryField = objectDef.primaryField
1236
+ || objectDef.nameField
1237
+ || objectDef.displayNameField
1220
1238
  || 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
- };
1239
+ // Build sections (ADR-0085: grouping is the `fieldGroups` semantic
1240
+ // role there is no per-surface sections override; per-page
1241
+ // customization goes through an assigned Page schema):
1242
+ // 1) sections derived from the object's `fieldGroups`;
1243
+ // 2) auto-grouping (primary + collapsible "More details").
1244
+ const sections = (() => {
1245
+ const toField = (key) => {
1246
+ const fieldDef = objectDef.fields[key];
1247
+ const refTarget = fieldDef.reference_to || fieldDef.reference;
1248
+ return {
1249
+ name: key,
1250
+ label: fieldDef.label || key,
1251
+ type: fieldDef.type || 'text',
1252
+ ...(fieldDef.options && { options: fieldDef.options }),
1253
+ ...(refTarget && { reference_to: refTarget }),
1254
+ ...(fieldDef.reference_field && { reference_field: fieldDef.reference_field }),
1255
+ ...(fieldDef.currency && { currency: fieldDef.currency }),
1277
1256
  };
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.
1257
+ };
1258
+ // Auto-grouping (platform B): split fields into a primary section
1259
+ // and a collapsible "More details" section so long-form/secondary
1260
+ // fields don't dilute the main grid. The primary section stays
1261
+ // untitled so DetailSection still flattens its chrome when alone.
1262
+ // Shared by the pure-fallback path and the ungrouped remainder of
1263
+ // the fieldGroups path below.
1264
+ const splitPrimarySecondary = (keys) => {
1265
+ const primaryKeys = keys.filter((k) => !isSecondaryField(k, objectDef.fields[k]));
1266
+ const secondaryKeys = keys.filter((k) => isSecondaryField(k, objectDef.fields[k]));
1267
+ // Keep the legacy single-untitled-section behaviour when the
1268
+ // split would leave one side empty.
1283
1269
  if (secondaryKeys.length === 0 || primaryKeys.length === 0) {
1284
1270
  return [
1285
1271
  {
1286
1272
  showBorder: false,
1287
- fields: allFields.map(toField),
1273
+ fields: keys.map(toField),
1288
1274
  },
1289
1275
  ];
1290
1276
  }
@@ -1302,7 +1288,31 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1302
1288
  fields: secondaryKeys.map(toField),
1303
1289
  },
1304
1290
  ];
1305
- })();
1291
+ };
1292
+ // 1) fieldGroups-derived sections (the ADR-0085 semantic role,
1293
+ // same shared derivation the runtime form honours). Declared
1294
+ // groups render as titled cards in declared order; the
1295
+ // trailing untitled bucket (ungrouped fields) still goes
1296
+ // through the primary/"More details" split so long-form
1297
+ // fields stay tucked away.
1298
+ const grouped = deriveFieldGroupDetailSections(objectDef);
1299
+ if (grouped) {
1300
+ return grouped.flatMap((sec) => {
1301
+ if (!sec.name) {
1302
+ return splitPrimarySecondary(sec.fields.map((f) => f.name));
1303
+ }
1304
+ return [{
1305
+ ...sec,
1306
+ title: sectionLabel(objectDef.name, sec.name, sec.title),
1307
+ showBorder: true,
1308
+ }];
1309
+ });
1310
+ }
1311
+ // 3) Pure auto-grouping fallback.
1312
+ const allFields = Object.keys(objectDef.fields || {})
1313
+ .filter((key) => !AUDIT_FIELD_NAMES.has(key) && !HIDDEN_SYSTEM_FIELD_NAMES.has(key) && !objectDef.fields[key]?.hidden);
1314
+ return splitPrimarySecondary(allFields);
1315
+ })();
1306
1316
  // Audit fields (created_at/created_by/updated_at/updated_by) are NOT
1307
1317
  // appended as a section here — they are surfaced by `<RecordMetaFooter>`
1308
1318
  // (rendered by DetailView) as a single subtle line below the content,
@@ -1372,10 +1382,22 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1372
1382
  }
1373
1383
  return base;
1374
1384
  })();
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;
1385
+ // Build highlightFields from the object's semantic role (ADR-0085).
1386
+ // Bare field names resolve label/type from the field def.
1387
+ const rawHighlightFields = objectDef.highlightFields ?? [];
1388
+ const highlightFields = (Array.isArray(rawHighlightFields) ? rawHighlightFields : [])
1389
+ .map((f) => {
1390
+ const name = typeof f === 'string' ? f : f?.name;
1391
+ if (!name)
1392
+ return null;
1393
+ const fieldDef = objectDef.fields?.[name];
1394
+ return {
1395
+ name,
1396
+ label: fieldDef?.label || name,
1397
+ ...(fieldDef?.type ? { type: fieldDef.type } : {}),
1398
+ };
1399
+ })
1400
+ .filter((f) => !!f);
1379
1401
  // Build related entries from reverse-reference child objects.
1380
1402
  // `referenceField` is the FK field on the child pointing back to this
1381
1403
  // record — passed so the related-list renderer can hide the redundant
@@ -1468,9 +1490,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1468
1490
  // Surface the child object's canonical display field so the
1469
1491
  // right-rail can show meaningful labels (`user_agent`, `email`,
1470
1492
  // …) instead of opaque IDs like `kCc8mhJr0bRs0r9Ykd09…`.
1471
- displayField: childObjectDef?.displayNameField ||
1472
- (Array.isArray(childObjectDef?.compactLayout)
1473
- ? childObjectDef.compactLayout[0]
1493
+ displayField: childObjectDef?.nameField ||
1494
+ childObjectDef?.displayNameField ||
1495
+ (Array.isArray(childObjectDef?.highlightFields)
1496
+ ? childObjectDef.highlightFields[0]
1474
1497
  : undefined),
1475
1498
  onNew,
1476
1499
  onViewAll,
@@ -1505,7 +1528,6 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1505
1528
  }),
1506
1529
  ...(related.length > 0 && { related }),
1507
1530
  ...(highlightFields.length > 0 && { highlightFields }),
1508
- ...(sectionGroups && sectionGroups.length > 0 && { sectionGroups }),
1509
1531
  ...(recordHeaderActions.length > 0 && {
1510
1532
  actions: [{
1511
1533
  type: 'action:bar',
@@ -1666,14 +1688,21 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1666
1688
  const synthRelated = Array.isArray(detailSchema.related)
1667
1689
  ? detailSchema.related
1668
1690
  .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
- }))
1691
+ .map((r) => {
1692
+ // Carry the `relatedList: 'primary'` prominence flag from the derived
1693
+ // relationship graph. Matched by (childObject, referenceField) — the
1694
+ // unique key of a related list — so it is robust to ordering/filtering.
1695
+ const derived = childRelations.find((c) => c.childObject === r.api && c.referenceField === r.referenceField);
1696
+ return {
1697
+ title: r.title,
1698
+ objectName: r.api,
1699
+ relationshipField: r.referenceField,
1700
+ ...(Array.isArray(r.columns) ? { columns: r.columns } : {}),
1701
+ ...(typeof r.pageSize === 'number' ? { limit: r.pageSize } : {}),
1702
+ ...(r.icon ? { icon: r.icon } : {}),
1703
+ ...(derived?.isPrimary ? { isPrimary: true } : {}),
1704
+ };
1705
+ })
1677
1706
  : undefined;
1678
1707
  const synthHistory = detailSchema.history
1679
1708
  ? {
@@ -1694,16 +1723,14 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1694
1723
  headerActions: synthHeaderActions,
1695
1724
  related: synthRelated,
1696
1725
  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,
1726
+ // ADR-0085 removed the per-object `detail.*` presentation
1727
+ // toggles (show/hideReferenceRail, hideRelatedTab, relatedLayout)
1728
+ // — the synth defaults apply; per-page layout goes through an
1729
+ // assigned Page schema (`record:reference_rail` stays available
1730
+ // there as a renderer capability).
1704
1731
  ...(assignedSlots ? { slots: assignedSlots } : {}),
1705
1732
  });
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: {
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: {
1707
1734
  position: 'bottom',
1708
1735
  collapsible: false,
1709
1736
  feed: {
@@ -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,24 @@
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
+ /** Notify open related lists for `objectName` to refetch (see RelatedList). */
9
+ export declare function notifyRelatedChanged(objectName: string): void;
10
+ /** i18n label resolver signature (matches `useObjectLabel().actionLabel`). */
11
+ type ActionLabelFn = (objectName: string | undefined, actionName: string, fallback: string) => string;
12
+ export interface RelatedRecordActionsBridgeProps {
13
+ /** Current app segment used to build `/apps/:appName/...` routes. */
14
+ appName?: string;
15
+ /** All object definitions (to resolve the child object + its actions). */
16
+ objects: any[];
17
+ /** Data source for delete + action dispatch. */
18
+ dataSource: any;
19
+ /** Localizes a child action's label (falls back to the raw label). */
20
+ actionLabel: ActionLabelFn;
21
+ children: React.ReactNode;
22
+ }
23
+ export declare function RelatedRecordActionsBridge({ appName, objects, dataSource, actionLabel, children, }: RelatedRecordActionsBridgeProps): import("react").JSX.Element;
24
+ export {};