@object-ui/app-shell 4.0.12 → 4.3.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.
@@ -0,0 +1,77 @@
1
+ /** Normalise an options entry (allowing bare strings) into label/value pairs. */
2
+ function normaliseOptions(options, objectName, fieldName, optionLabel) {
3
+ if (!Array.isArray(options) || options.length === 0)
4
+ return undefined;
5
+ return options.map((o) => {
6
+ const raw = typeof o === 'string' ? { label: o, value: o } : o;
7
+ const label = optionLabel
8
+ ? optionLabel(objectName, fieldName, raw.value, raw.label)
9
+ : raw.label;
10
+ return { label, value: raw.value };
11
+ });
12
+ }
13
+ /**
14
+ * Resolve a single raw param against object metadata. Inline params pass
15
+ * through (with safe defaults); field-backed params inherit from the
16
+ * referenced field and accept inline overrides on top.
17
+ */
18
+ export function resolveActionParam(param, ctx) {
19
+ /** Row-context default: when `defaultFromRow` and a row is present, the
20
+ * param's defaultValue is the row's value at the field key (or `name`). */
21
+ const rowKey = param.field ?? param.name;
22
+ const rowDefault = param.defaultFromRow && ctx.row && rowKey != null && Object.prototype.hasOwnProperty.call(ctx.row, rowKey)
23
+ ? ctx.row[rowKey]
24
+ : undefined;
25
+ // Inline param — no field reference, just normalise.
26
+ if (!param.field) {
27
+ return {
28
+ name: param.name ?? '',
29
+ label: param.label ?? param.name ?? '',
30
+ type: param.type ?? 'text',
31
+ required: param.required ?? false,
32
+ options: param.options,
33
+ placeholder: param.placeholder,
34
+ helpText: param.helpText,
35
+ defaultValue: rowDefault ?? param.defaultValue,
36
+ };
37
+ }
38
+ const ownerName = param.objectOverride ?? ctx.objectName;
39
+ const owner = ctx.objects.find((o) => o?.name === ownerName);
40
+ const field = owner?.fields?.[param.field];
41
+ if (!field) {
42
+ // Reference target missing — fall back to a plain text input so the
43
+ // action remains usable in environments where the metadata cache is
44
+ // partial (e.g. tests).
45
+ return {
46
+ name: param.name ?? param.field,
47
+ label: param.label ?? ctx.fieldLabel(ownerName, param.field, param.field),
48
+ type: param.type ?? 'text',
49
+ required: param.required ?? false,
50
+ options: param.options,
51
+ placeholder: param.placeholder,
52
+ helpText: param.helpText,
53
+ defaultValue: rowDefault ?? param.defaultValue,
54
+ };
55
+ }
56
+ const resolvedType = param.type ?? field.type ?? 'text';
57
+ const resolvedOptions = param.options
58
+ ?? normaliseOptions(field.options, ownerName, param.field, ctx.fieldOptionLabel);
59
+ const resolvedLabel = param.label
60
+ ?? ctx.fieldLabel(ownerName, param.field, field.label ?? param.field);
61
+ return {
62
+ name: param.name ?? param.field,
63
+ label: resolvedLabel,
64
+ type: resolvedType,
65
+ required: param.required ?? field.required ?? false,
66
+ options: resolvedOptions,
67
+ placeholder: param.placeholder ?? field.placeholder,
68
+ helpText: param.helpText ?? field.help ?? field.description,
69
+ defaultValue: rowDefault ?? param.defaultValue ?? field.defaultValue,
70
+ };
71
+ }
72
+ /** Resolve an array of raw action params. */
73
+ export function resolveActionParams(params, ctx) {
74
+ if (!Array.isArray(params))
75
+ return [];
76
+ return params.map((p) => resolveActionParam(p, ctx));
77
+ }
@@ -26,6 +26,9 @@ import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
26
26
  import { ViewConfigPanel } from './ViewConfigPanel';
27
27
  import { CreateViewDialog } from './CreateViewDialog';
28
28
  import { PageHeader } from '../layout/PageHeader';
29
+ import { ManagedByBadge } from '../components/ManagedByBadge';
30
+ import { resolveCrudAffordances } from '../utils/crudAffordances';
31
+ import { resolveManagedByEmptyState } from '../utils/managedByEmptyState';
29
32
  import { useObjectActions } from '../hooks/useObjectActions';
30
33
  import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
31
34
  import { usePermissions } from '@object-ui/permissions';
@@ -35,6 +38,7 @@ import { ActionProvider, useNavigationOverlay, SchemaRenderer } from '@object-ui
35
38
  import { toast } from 'sonner';
36
39
  import { ActionConfirmDialog } from './ActionConfirmDialog';
37
40
  import { ActionParamDialog } from './ActionParamDialog';
41
+ import { resolveActionParams } from '../utils/resolveActionParams';
38
42
  /** Map view types to Lucide icons (Airtable-style) */
39
43
  const VIEW_TYPE_ICONS = {
40
44
  grid: TableIcon,
@@ -47,6 +51,40 @@ const VIEW_TYPE_ICONS = {
47
51
  chart: BarChart3,
48
52
  };
49
53
  const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
54
+ /**
55
+ * Replace built-in tokens (e.g. `{current_user_id}`) inside a filter array
56
+ * with concrete values. Filters from platform-shipped `listViews` or saved
57
+ * `sys_view` rows may declare context-sensitive predicates like
58
+ * `{ field: 'submitter_id', operator: 'equals', value: '{current_user_id}' }`
59
+ * — those need to be substituted before the query reaches the API.
60
+ *
61
+ * Recognised tokens:
62
+ * • `{current_user_id}` → the authenticated user's id
63
+ *
64
+ * Returns a deep-cloned copy with substitutions applied. Non-array input
65
+ * is returned unchanged.
66
+ */
67
+ function substituteFilterTokens(filter, currentUserId) {
68
+ if (!Array.isArray(filter))
69
+ return filter;
70
+ const sub = (v) => {
71
+ if (typeof v === 'string') {
72
+ if (v === '{current_user_id}')
73
+ return currentUserId ?? v;
74
+ return v;
75
+ }
76
+ if (Array.isArray(v))
77
+ return v.map(sub);
78
+ if (v && typeof v === 'object') {
79
+ const out = {};
80
+ for (const k of Object.keys(v))
81
+ out[k] = sub(v[k]);
82
+ return out;
83
+ }
84
+ return v;
85
+ };
86
+ return filter.map(sub);
87
+ }
50
88
  /**
51
89
  * Translate a NamedListView spec object (shape: `type`, `label`, `columns`,
52
90
  * `filter`, `sort`, `kanban`/`chart`/`gantt`/... sub-blocks, `bulkActions`,
@@ -310,7 +348,7 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
310
348
  const [searchParams, setSearchParams] = useSearchParams();
311
349
  const { showDebug } = useMetadataInspector();
312
350
  const { t } = useObjectTranslation();
313
- const { objectLabel, objectDescription: objectDesc, viewLabel, actionLabel, actionConfirm, actionSuccess } = useObjectLabel();
351
+ const { objectLabel, objectDescription: objectDesc, viewLabel, actionLabel, actionConfirm, actionSuccess, fieldLabel, fieldOptionLabel } = useObjectLabel();
314
352
  // Inline view config panel state (Airtable-style right sidebar)
315
353
  const [showViewConfigPanel, setShowViewConfigPanel] = useState(false);
316
354
  const [viewConfigPanelMode, setViewConfigPanelMode] = useState('edit');
@@ -447,7 +485,7 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
447
485
  // Record count tracking for footer
448
486
  const [recordCount, setRecordCount] = useState(undefined);
449
487
  // Admin users automatically get design tools (no toggle needed)
450
- const { user } = useAuth();
488
+ const { user, activeOrganization } = useAuth();
451
489
  const isAdmin = user?.role === 'admin';
452
490
  const { can } = usePermissions();
453
491
  // Get Object Definition
@@ -457,6 +495,13 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
457
495
  }
458
496
  // Refresh trigger — bumped after view CRUD or external data mutations.
459
497
  const [refreshKey, setRefreshKey] = useState(0);
498
+ // Resolve which generic CRUD affordances belong in the toolbar for
499
+ // this object's lifecycle bucket (`managedBy`). config tables show
500
+ // New/Edit/Delete but no CSV Import; system / append-only / better-auth
501
+ // hide the lot — those flows go through purpose-built actions on the
502
+ // source record (e.g. "Submit for Approval" on an Opportunity creates
503
+ // an `sys_approval_request`). Permissions still gate the buttons.
504
+ const affordances = useMemo(() => resolveCrudAffordances(objectDef), [objectDef]);
460
505
  // Propagate externally-triggered refreshes (e.g. global ModalForm submit)
461
506
  // into our internal refreshKey so list/data effects re-run.
462
507
  useEffect(() => {
@@ -828,11 +873,24 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
828
873
  setConfirmState({ open: true, message, options, resolve });
829
874
  });
830
875
  }, []);
831
- const paramCollectionHandler = useCallback((params) => {
876
+ const paramCollectionHandler = useCallback((params, action) => {
832
877
  return new Promise((resolve) => {
833
- setParamState({ open: true, params, resolve });
878
+ // List_item actions stash the row record under params._rowRecord
879
+ // (see ObjectGrid → onRowAction). Pull it out so resolveActionParams
880
+ // can pre-fill `defaultFromRow` params from the row's current values.
881
+ const row = action?.params && !Array.isArray(action.params)
882
+ ? action.params._rowRecord
883
+ : undefined;
884
+ const resolved = resolveActionParams(params, {
885
+ objectName: objectName || objectDef?.name || '',
886
+ objects: objects || [],
887
+ fieldLabel,
888
+ fieldOptionLabel,
889
+ row,
890
+ });
891
+ setParamState({ open: true, params: resolved, resolve });
834
892
  });
835
- }, []);
893
+ }, [objectName, objectDef, objects, fieldLabel, fieldOptionLabel]);
836
894
  const handleDeleteView = useCallback(async (vid) => {
837
895
  if (!dataSource)
838
896
  return;
@@ -1049,10 +1107,78 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1049
1107
  navigate(url);
1050
1108
  }
1051
1109
  }, [navigate]);
1110
+ // Authenticated fetch for direct backend calls (e.g. flow trigger,
1111
+ // schema actions targeting absolute API paths). Declared before
1112
+ // apiHandler so the latter can close over it.
1113
+ const authFetch = useMemo(() => createAuthenticatedFetch(), []);
1052
1114
  const apiHandler = useCallback(async (action) => {
1053
1115
  try {
1054
1116
  const target = action.target || action.name;
1055
1117
  const params = action.params || {};
1118
+ // Absolute HTTP target (e.g. /api/v1/auth/organization/invite-member,
1119
+ // http://..., https://...) — bypass dataSource and call the API
1120
+ // directly through the authenticated fetch wrapper so:
1121
+ // - Authorization: Bearer <token> is injected
1122
+ // - X-Tenant-ID is injected (multi-tenant routing)
1123
+ // - Same-origin cookies (better-auth.session_token) ride along
1124
+ //
1125
+ // This is the canonical path for schema actions on managed-by
1126
+ // tables (sys_user/invite_user, sys_session/revoke, …) where
1127
+ // generic CRUD does not apply.
1128
+ const targetStr = typeof target === 'string' ? target : '';
1129
+ const isAbsolute = targetStr.startsWith('/') || /^https?:\/\//i.test(targetStr);
1130
+ if (isAbsolute) {
1131
+ const baseUrl = import.meta.env.VITE_SERVER_URL || '';
1132
+ const url = targetStr.startsWith('http') ? targetStr : `${baseUrl}${targetStr}`;
1133
+ // Row context is stashed on params under `_rowRecord` by the
1134
+ // row-action dispatcher (see ObjectGrid → onRowAction). Pull
1135
+ // it out before assembling the request body.
1136
+ const rawParams = { ...params };
1137
+ const rowRecord = rawParams._rowRecord;
1138
+ delete rawParams._rowRecord;
1139
+ // Apply bodyShape: 'flat' (default) keeps user params at top
1140
+ // level; { wrap: 'data' } nests them under that key while
1141
+ // `recordIdParam` / `organizationId` stay flat (better-auth
1142
+ // organization/update semantics).
1143
+ const wrap = action.bodyShape && typeof action.bodyShape === 'object' && action.bodyShape.wrap
1144
+ ? action.bodyShape.wrap
1145
+ : undefined;
1146
+ const body = wrap
1147
+ ? { [wrap]: rawParams }
1148
+ : { ...rawParams };
1149
+ // Inject row id (or chosen row field) for list_item actions.
1150
+ if (rowRecord && action.recordIdParam) {
1151
+ const rowField = action.recordIdField || 'id';
1152
+ const rowValue = rowRecord[rowField];
1153
+ if (rowValue != null)
1154
+ body[action.recordIdParam] = rowValue;
1155
+ }
1156
+ // Auto-inject organizationId when the active org is known and
1157
+ // not already set. Most better-auth org-scoped endpoints
1158
+ // accept this shape; harmless for endpoints that ignore it.
1159
+ if (!body.organizationId && activeOrganization?.id) {
1160
+ body.organizationId = activeOrganization.id;
1161
+ }
1162
+ const res = await authFetch(url, {
1163
+ method: 'POST',
1164
+ headers: { 'Content-Type': 'application/json' },
1165
+ credentials: 'include',
1166
+ body: JSON.stringify(body),
1167
+ });
1168
+ if (!res.ok) {
1169
+ let detail = `HTTP ${res.status}`;
1170
+ try {
1171
+ const j = await res.json();
1172
+ detail = j?.error || j?.message || detail;
1173
+ }
1174
+ catch { /* response body not JSON */ }
1175
+ return { success: false, error: detail };
1176
+ }
1177
+ const data = await res.json().catch(() => ({}));
1178
+ if (action.refreshAfter !== false)
1179
+ setRefreshKey(k => k + 1);
1180
+ return { success: true, data, reload: action.refreshAfter !== false };
1181
+ }
1056
1182
  // Generic list-level API handler: update/execute via dataSource
1057
1183
  if (typeof dataSource.execute === 'function') {
1058
1184
  await dataSource.execute(objectDef.name, target, params);
@@ -1069,9 +1195,7 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1069
1195
  catch (error) {
1070
1196
  return { success: false, error: error.message };
1071
1197
  }
1072
- }, [dataSource, objectDef.name]);
1073
- // Authenticated fetch for direct backend calls (e.g. flow trigger).
1074
- const authFetch = useMemo(() => createAuthenticatedFetch(), []);
1198
+ }, [dataSource, objectDef.name, authFetch, activeOrganization]);
1075
1199
  // Flow action handler — POST to /api/v1/automation/{name}/trigger.
1076
1200
  // Triggered when an Action with `type: 'flow'` is invoked from list-level
1077
1201
  // locations (list_toolbar, list_item). For list_item the row's recordId is
@@ -1285,9 +1409,10 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1285
1409
  sort: viewDef.sort ?? listSchema.sort,
1286
1410
  filter: (() => {
1287
1411
  const base = viewDef.filter ?? listSchema.filter;
1412
+ const substituted = substituteFilterTokens(base, currentUser.id);
1288
1413
  if (!urlFilters.length)
1289
- return base;
1290
- const baseArr = Array.isArray(base) ? base : [];
1414
+ return substituted;
1415
+ const baseArr = Array.isArray(substituted) ? substituted : [];
1291
1416
  return [...baseArr, ...urlFilters];
1292
1417
  })(),
1293
1418
  hiddenFields: viewDef.hiddenFields ?? listSchema.hiddenFields,
@@ -1341,6 +1466,17 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1341
1466
  filterableFields: viewDef.filterableFields ?? listSchema.filterableFields,
1342
1467
  resizable: viewDef.resizable ?? listSchema.resizable,
1343
1468
  rowActions: viewDef.rowActions ?? listSchema.rowActions,
1469
+ /**
1470
+ * Row-context action definitions derived from `objectDef.actions`
1471
+ * filtered by `locations.includes('list_item')`. These are full
1472
+ * `ActionDef` records (with label/icon/variant/params/recordIdParam
1473
+ * /bodyShape) the row menu renders with i18n-correct labels and
1474
+ * dispatches via the action runner; legacy `rowActions: string[]`
1475
+ * remains for back-compat where the action lives elsewhere.
1476
+ */
1477
+ rowActionDefs: (Array.isArray(objectDef?.actions)
1478
+ ? objectDef.actions.filter((a) => Array.isArray(a?.locations) && a.locations.includes('list_item'))
1479
+ : []),
1344
1480
  bulkActions: viewDef.bulkActions ?? listSchema.bulkActions,
1345
1481
  sharing: viewDef.sharing ?? listSchema.sharing,
1346
1482
  addRecord: viewDef.addRecord ?? listSchema.addRecord,
@@ -1350,7 +1486,9 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1350
1486
  showRecordCount: viewDef.showRecordCount ?? listSchema.showRecordCount,
1351
1487
  allowPrinting: viewDef.allowPrinting ?? listSchema.allowPrinting,
1352
1488
  virtualScroll: viewDef.virtualScroll ?? listSchema.virtualScroll,
1353
- emptyState: viewDef.emptyState ?? listSchema.emptyState,
1489
+ emptyState: viewDef.emptyState
1490
+ ?? listSchema.emptyState
1491
+ ?? resolveManagedByEmptyState(objectDef?.managedBy),
1354
1492
  aria: viewDef.aria ?? listSchema.aria,
1355
1493
  tabs: listSchema.tabs,
1356
1494
  // Propagate filter/sort as default filters/sort for data flow
@@ -1359,7 +1497,8 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1359
1497
  ...(Array.isArray(viewDef.filter) ? viewDef.filter : []),
1360
1498
  ...urlFilters,
1361
1499
  ];
1362
- return combined.length ? { filters: combined } : {};
1500
+ const substituted = substituteFilterTokens(combined, currentUser.id);
1501
+ return Array.isArray(substituted) && substituted.length ? { filters: substituted } : {};
1363
1502
  })()),
1364
1503
  ...(viewDef.sort?.length ? { sort: viewDef.sort } : {}),
1365
1504
  options: {
@@ -1489,7 +1628,15 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1489
1628
  }
1490
1629
  },
1491
1630
  }), [objectDef.name, onEdit, activeView?.showSearch, activeView?.showFilters, activeView?.showSort, navigate, viewId, isAdmin]);
1492
- return (_jsxs(ActionProvider, { context: { objectName: objectDef.name, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: [_jsxs("div", { className: "h-full flex flex-col bg-background min-w-0 overflow-hidden", children: [_jsx(PageHeader, { title: objectLabel(objectDef), description: objectDef.description ? objectDesc(objectDef) : undefined, icon: (() => { const I = getIcon(objectDef?.icon); return _jsx(I, { className: "h-4 w-4" }); })(), actions: _jsxs(_Fragment, { children: [can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", onClick: actions.create, className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.new') })] })), can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => setShowImport(true), className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", title: t('console.objectView.importTitle'), "data-testid": "object-view-import-button", children: [_jsx(Upload, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.import') })] })), objectDef.actions?.some((a) => a.locations?.includes('list_toolbar')) && (_jsx(SchemaRenderer, { schema: {
1631
+ return (_jsxs(ActionProvider, { context: {
1632
+ objectName: objectDef.name,
1633
+ user: currentUser,
1634
+ // Expose active org so `type: 'url'` actions can template
1635
+ // `/organizations/${activeOrganization.slug}/...` etc.
1636
+ activeOrganization: activeOrganization
1637
+ ? { id: activeOrganization.id, slug: activeOrganization.slug, name: activeOrganization.name }
1638
+ : null,
1639
+ }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: [_jsxs("div", { className: "h-full flex flex-col bg-background min-w-0 overflow-hidden", children: [_jsx(PageHeader, { title: _jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { className: "truncate", children: objectLabel(objectDef) }), _jsx(ManagedByBadge, { managedBy: objectDef?.managedBy })] }), description: objectDef.description ? objectDesc(objectDef) : undefined, icon: (() => { const I = getIcon(objectDef?.icon); return _jsx(I, { className: "h-4 w-4" }); })(), actions: _jsxs(_Fragment, { children: [affordances.create && can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", onClick: actions.create, className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.new') })] })), affordances.import && can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => setShowImport(true), className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", title: t('console.objectView.importTitle'), "data-testid": "object-view-import-button", children: [_jsx(Upload, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.import') })] })), objectDef.actions?.some((a) => a.locations?.includes('list_toolbar')) && (_jsx(SchemaRenderer, { schema: {
1493
1640
  type: 'action:bar',
1494
1641
  location: 'list_toolbar',
1495
1642
  actions: (objectDef.actions || []).map((a) => ({
@@ -17,17 +17,22 @@ import { toast } from 'sonner';
17
17
  import { Database, Users } from 'lucide-react';
18
18
  import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
19
19
  import { SkeletonDetail } from '../skeletons';
20
+ import { ManagedByBadge } from '../components/ManagedByBadge';
21
+ import { resolveCrudAffordances } from '../utils/crudAffordances';
20
22
  import { ActionConfirmDialog } from './ActionConfirmDialog';
21
23
  import { ActionParamDialog } from './ActionParamDialog';
24
+ import { resolveActionParams } from '../utils/resolveActionParams';
22
25
  import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
23
26
  import { getRecordDisplayName } from '../utils';
24
27
  const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
25
28
  /**
26
29
  * Audit field names auto-injected by the framework's `applySystemFields`.
27
- * Surfaced as a dedicated, collapsed "System Information" section on the
28
- * record detail page so they don't clutter the primary content but remain
29
- * discoverable. The inline-edit drawer keeps filtering them out via
30
- * `DEFAULT_SYSTEM_FIELDS` in `@object-ui/plugin-detail/RecordDetailDrawer`.
30
+ * Filtered out of the auto-generated body sections they are rendered
31
+ * separately as a single subtle one-line `<RecordMetaFooter>` (see
32
+ * `@object-ui/plugin-detail`) so provenance stays discoverable without a
33
+ * heavy "System Information" panel. The inline-edit drawer also hides
34
+ * them via `DEFAULT_SYSTEM_FIELDS` in
35
+ * `@object-ui/plugin-detail/RecordDetailDrawer`.
31
36
  */
32
37
  const AUDIT_FIELD_NAMES = new Set(['created_at', 'created_by', 'updated_at', 'updated_by']);
33
38
  export function RecordDetailView({ dataSource, objects, onEdit }) {
@@ -36,7 +41,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
36
41
  const { user } = useAuth();
37
42
  const navigate = useNavigate();
38
43
  const { t } = useObjectTranslation();
39
- const { objectLabel, viewLabel: _vLabel, sectionLabel, actionLabel, actionConfirm, actionSuccess } = useObjectLabel();
44
+ const { objectLabel, viewLabel: _vLabel, sectionLabel, actionLabel, actionConfirm, actionSuccess, fieldLabel, fieldOptionLabel } = useObjectLabel();
40
45
  const [isLoading, setIsLoading] = useState(true);
41
46
  const [feedItems, setFeedItems] = useState([]);
42
47
  const [recordViewers, setRecordViewers] = useState([]);
@@ -63,9 +68,15 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
63
68
  }, []);
64
69
  const paramCollectionHandler = useCallback((params) => {
65
70
  return new Promise((resolve) => {
66
- setParamState({ open: true, params, resolve });
71
+ const resolved = resolveActionParams(params, {
72
+ objectName: objectName || objectDef?.name || '',
73
+ objects: objects || [],
74
+ fieldLabel,
75
+ fieldOptionLabel,
76
+ });
77
+ setParamState({ open: true, params: resolved, resolve });
67
78
  });
68
- }, []);
79
+ }, [objectName, objectDef, objects, fieldLabel, fieldOptionLabel]);
69
80
  const toastHandler = useCallback((message, options) => {
70
81
  if (options?.type === 'error')
71
82
  toast.error(message);
@@ -561,32 +572,12 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
561
572
  }),
562
573
  },
563
574
  ];
564
- // Append a dedicated, collapsed "System Information" section listing
565
- // audit fields (created/updated at/by) when the schema declares them
566
- // and no author-defined section has already surfaced them. The framework
567
- // auto-injects these as `system: true, readonly: true` via
568
- // `applySystemFields`; rendering them here gives users visibility into
569
- // record provenance without polluting the primary content area.
570
- const fieldsAlreadyShown = new Set(sections.flatMap((s) => (s.fields || []).map((f) => f.name)));
571
- const auditFieldsToShow = Array.from(AUDIT_FIELD_NAMES).filter(name => objectDef.fields?.[name] && !fieldsAlreadyShown.has(name));
572
- if (auditFieldsToShow.length > 0) {
573
- sections.push({
574
- title: sectionLabel(objectDef.name, 'system_info', 'System Information'),
575
- collapsible: true,
576
- defaultCollapsed: true,
577
- fields: auditFieldsToShow.map(key => {
578
- const fieldDef = objectDef.fields[key];
579
- const refTarget = fieldDef.reference_to || fieldDef.reference;
580
- return {
581
- name: key,
582
- label: fieldDef.label || key,
583
- type: fieldDef.type || 'text',
584
- readonly: true,
585
- ...(refTarget && { reference_to: refTarget }),
586
- };
587
- }),
588
- });
589
- }
575
+ // Audit fields (created_at/created_by/updated_at/updated_by) are NOT
576
+ // appended as a section here they are surfaced by `<RecordMetaFooter>`
577
+ // (rendered by DetailView) as a single subtle line below the content,
578
+ // replacing the old card-style "System Information" panel. The inline-edit
579
+ // drawer continues to hide them via `DEFAULT_SYSTEM_FIELDS` in
580
+ // `@object-ui/plugin-detail/RecordDetailDrawer`.
590
581
  // Filter actions for record_header location and deduplicate by name
591
582
  const recordHeaderActions = (() => {
592
583
  const seen = new Set();
@@ -701,13 +692,19 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
701
692
  onRowDelete,
702
693
  };
703
694
  });
695
+ const affordances = resolveCrudAffordances(objectDef);
704
696
  return {
705
697
  type: 'detail-view',
706
698
  objectName: objectDef.name,
707
699
  resourceId: pureRecordId,
708
700
  showBack: true,
709
701
  onBack: 'history',
710
- showEdit: true,
702
+ // Hide the Edit button for objects whose lifecycle isn't user-managed
703
+ // (approval requests, audit logs, better-auth tables, …). The
704
+ // underlying form is also disabled when `managedBy !== 'platform'`
705
+ // (see plugin-form/ObjectForm), so even if a stray Edit URL is
706
+ // visited the inputs render read-only.
707
+ showEdit: affordances.edit,
711
708
  title: objectDef.label,
712
709
  primaryField,
713
710
  sections,
@@ -732,7 +729,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
732
729
  if (!objectDef) {
733
730
  return (_jsx("div", { className: "flex h-full items-center justify-center p-4", children: _jsxs(Empty, { children: [_jsx("div", { className: "mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted", children: _jsx(Database, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('empty.objectNotFound') }), _jsx(EmptyDescription, { children: t('empty.objectNotFoundDescription', { name: objectName }) })] }) }));
734
731
  }
735
- return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsx("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: t('recordDetail.viewersTooltip'), children: [_jsx(Users, { className: "h-3.5 w-3.5 text-muted-foreground" }), _jsx(PresenceAvatars, { users: recordViewers, size: "sm", maxVisible: 4, showStatus: true })] })) }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsx("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: _jsx(ActionProvider, { context: { record: {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), onDataLoaded: (record) => {
732
+ return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsxs("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: [_jsx(ManagedByBadge, { managedBy: objectDef?.managedBy }), recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: t('recordDetail.viewersTooltip'), children: [_jsx(Users, { className: "h-3.5 w-3.5 text-muted-foreground" }), _jsx(PresenceAvatars, { users: recordViewers, size: "sm", maxVisible: 4, showStatus: true })] }))] }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsx("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: _jsx(ActionProvider, { context: { record: {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), onDataLoaded: (record) => {
736
733
  if (!record || typeof record !== 'object')
737
734
  return;
738
735
  // Resolve the same way DetailView's header does, so the
@@ -39,6 +39,7 @@ import { useMetadata } from '../providers/MetadataProvider';
39
39
  import { useAdapter } from '../providers/AdapterProvider';
40
40
  import { ExpressionProvider, evaluateVisibility } from '../providers/ExpressionProvider';
41
41
  import { SkeletonDetail } from '../skeletons';
42
+ import { ManagedByBadge } from '../components/ManagedByBadge';
42
43
  import { useAuth } from '@object-ui/auth';
43
44
  import { ExpressionEvaluator } from '@object-ui/core';
44
45
  /**
@@ -161,7 +162,7 @@ export function RecordFormPage({ mode }) {
161
162
  if (!objectDef) {
162
163
  return (_jsx("div", { className: "flex h-full items-center justify-center p-4", children: _jsxs(Empty, { children: [_jsx("div", { className: "mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted", children: _jsx(Database, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('empty.objectNotFound') }), _jsx(EmptyDescription, { children: t('empty.objectNotFoundDescription', { name: objectName }) }), _jsx("div", { className: "mt-4", children: _jsxs(Button, { variant: "outline", onClick: () => navigate(baseUrl), children: [_jsx(ArrowLeft, { className: "mr-2 h-4 w-4" }), t('empty.back')] }) })] }) }));
163
164
  }
164
- return (_jsx(ExpressionProvider, { user: expressionUser, app: { name: appName }, data: {}, children: _jsxs("div", { className: "flex flex-col h-full overflow-hidden bg-background", "data-testid": "record-form-page", "data-mode": mode, children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-center gap-3 border-b bg-background px-4 py-3 sm:px-6", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: goBack, "data-testid": "record-form-page-back", "aria-label": t('common.back', { defaultValue: 'Back' }), children: _jsx(ArrowLeft, { className: "h-4 w-4" }) }), _jsxs("nav", { "aria-label": "Breadcrumb", className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(Link, { to: objectListUrl, className: "hover:text-foreground transition-colors", children: label }), _jsx("span", { "aria-hidden": "true", children: "/" }), _jsx("span", { className: "text-foreground font-medium", "data-testid": "record-form-page-title", children: pageTitle })] })] }), _jsx("div", { className: "flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto max-w-4xl", children: _jsx(ObjectForm, { schema: {
165
+ return (_jsx(ExpressionProvider, { user: expressionUser, app: { name: appName }, data: {}, children: _jsxs("div", { className: "flex flex-col h-full overflow-hidden bg-background", "data-testid": "record-form-page", "data-mode": mode, children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-center gap-3 border-b bg-background px-4 py-3 sm:px-6", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: goBack, "data-testid": "record-form-page-back", "aria-label": t('common.back', { defaultValue: 'Back' }), children: _jsx(ArrowLeft, { className: "h-4 w-4" }) }), _jsxs("nav", { "aria-label": "Breadcrumb", className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(Link, { to: objectListUrl, className: "hover:text-foreground transition-colors", children: label }), _jsx("span", { "aria-hidden": "true", children: "/" }), _jsx("span", { className: "text-foreground font-medium", "data-testid": "record-form-page-title", children: pageTitle }), _jsx(ManagedByBadge, { managedBy: objectDef?.managedBy, className: "ml-1" })] })] }), _jsx("div", { className: "flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto max-w-4xl", children: _jsx(ObjectForm, { schema: {
165
166
  type: 'object-form',
166
167
  formType: 'simple',
167
168
  objectName: objectDef.name,
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useCallback, useMemo, lazy, Suspense } from 'react';
3
3
  import { useParams } from 'react-router-dom';
4
4
  const ReportViewer = lazy(() => import('@object-ui/plugin-report').then((m) => ({ default: m.ReportViewer })));
5
+ const ReportRenderer = lazy(() => import('@object-ui/plugin-report').then((m) => ({ default: m.ReportRenderer })));
5
6
  const ReportConfigPanel = lazy(() => import('@object-ui/plugin-report').then((m) => ({ default: m.ReportConfigPanel })));
6
7
  import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
7
8
  import { Pencil, BarChart3, Loader2 } from 'lucide-react';
@@ -37,29 +38,31 @@ export function ReportView({ dataSource }) {
37
38
  // State for report runtime data
38
39
  const [reportRuntimeData, setReportRuntimeData] = useState([]);
39
40
  const [dataLoading, setDataLoading] = useState(false);
41
+ const getFieldsForObject = useCallback((objName) => {
42
+ if (!objName || !objects?.length)
43
+ return undefined;
44
+ const objDef = objects.find((o) => o.name === objName);
45
+ if (!objDef?.fields)
46
+ return undefined;
47
+ const fields = objDef.fields;
48
+ if (Array.isArray(fields)) {
49
+ return fields.map((f) => typeof f === 'string'
50
+ ? { value: f, label: f, type: 'text' }
51
+ : { value: f.name, label: f.label || f.name, type: f.type || 'text' });
52
+ }
53
+ return Object.entries(fields).map(([name, def]) => ({
54
+ value: name,
55
+ label: def.label || name,
56
+ type: def.type || 'text',
57
+ }));
58
+ }, [objects]);
40
59
  // Derive available fields from object schema for filter/sort editors
41
60
  // Uses live editSchema when available to respond to objectName changes
42
61
  const availableFields = useMemo(() => {
43
62
  const liveReport = editSchema || reportData;
44
63
  const objName = liveReport?.objectName || liveReport?.dataSource?.object || liveReport?.dataSource?.resource;
45
- if (objName && objects?.length) {
46
- const objDef = objects.find((o) => o.name === objName);
47
- if (objDef?.fields) {
48
- const fields = objDef.fields;
49
- if (Array.isArray(fields)) {
50
- return fields.map((f) => typeof f === 'string'
51
- ? { value: f, label: f, type: 'text' }
52
- : { value: f.name, label: f.label || f.name, type: f.type || 'text' });
53
- }
54
- return Object.entries(fields).map(([name, def]) => ({
55
- value: name,
56
- label: def.label || name,
57
- type: def.type || 'text',
58
- }));
59
- }
60
- }
61
- return FALLBACK_FIELDS;
62
- }, [editSchema, reportData, objects]);
64
+ return getFieldsForObject(objName) ?? FALLBACK_FIELDS;
65
+ }, [editSchema, reportData, getFieldsForObject]);
63
66
  // ---- Save helper --------------------------------------------------------
64
67
  const saveSchema = useCallback(async (schema) => {
65
68
  try {
@@ -326,6 +329,20 @@ export function ReportView({ dataSource }) {
326
329
  };
327
330
  // Use live-edited schema for preview (persists after closing panel until metadata refreshes)
328
331
  const previewReport = editSchema || reportData;
332
+ // Route any object-backed spec report (matrix/joined/tabular/summary) through
333
+ // the spec ReportRenderer dispatcher. It handles aggregation, charts, KPIs
334
+ // and drill protocol end-to-end. The legacy ReportViewer is only used as a
335
+ // last resort for fully-legacy schemas that lack `objectName` (e.g. inline
336
+ // `fields` + `data` arrays from older app code).
337
+ const useSpecRenderer = Boolean(previewReport &&
338
+ previewReport.objectName &&
339
+ (previewReport.type === 'matrix' ||
340
+ previewReport.type === 'joined' ||
341
+ previewReport.type === 'summary' ||
342
+ previewReport.type === 'tabular' ||
343
+ previewReport.type === undefined ||
344
+ (Array.isArray(previewReport.groupingsAcross) && previewReport.groupingsAcross.length > 0) ||
345
+ Array.isArray(previewReport.columns)));
329
346
  const reportForViewer = mapReportForViewer(previewReport);
330
347
  const viewerSchema = {
331
348
  type: 'report-viewer',
@@ -335,5 +352,5 @@ export function ReportView({ dataSource }) {
335
352
  allowExport: true,
336
353
  loading: dataLoading, // Loading state for data fetching
337
354
  };
338
- return (_jsxs("div", { className: "flex flex-col h-full overflow-hidden bg-background", children: [_jsxs("div", { className: "flex flex-col sm:flex-row justify-between sm:items-center gap-3 sm:gap-4 p-4 sm:p-6 border-b shrink-0", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h1", { className: "text-lg sm:text-xl md:text-2xl font-bold tracking-tight truncate", children: previewReport.title || previewReport.label || 'Report Viewer' }), previewReport.description && (_jsx("p", { className: "text-sm text-muted-foreground mt-1 line-clamp-2", children: previewReport.description }))] }), _jsx("div", { className: "shrink-0 flex items-center gap-1.5", children: _jsxs("button", { type: "button", onClick: handleOpenConfigPanel, className: "inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground shadow-sm hover:bg-accent hover:text-accent-foreground", "data-testid": "report-edit-button", children: [_jsx(Pencil, { className: "h-3.5 w-3.5" }), "Edit"] }) })] }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-col sm:flex-row relative", children: [_jsx("div", { className: "flex-1 min-w-0 overflow-auto p-4 sm:p-6 lg:p-8 bg-muted/5", children: _jsx("div", { className: "max-w-5xl mx-auto shadow-sm border rounded-lg sm:rounded-xl bg-background overflow-hidden min-h-150", children: _jsx(Suspense, { fallback: _jsx("div", { className: "p-8 text-sm text-muted-foreground", children: "Loading report\u2026" }), children: _jsx(ReportViewer, { schema: viewerSchema }) }) }) }), _jsx(Suspense, { fallback: null, children: _jsx(ReportConfigPanel, { open: configPanelOpen, onClose: handleCloseConfigPanel, config: reportConfig, onSave: handleReportConfigSave, onFieldChange: handleReportFieldChange, availableFields: availableFields }) }), _jsx(MetadataPanel, { open: showDebug, sections: [{ title: 'Report Configuration', data: previewReport }] })] })] }));
355
+ return (_jsxs("div", { className: "flex flex-col h-full overflow-hidden bg-background", children: [_jsxs("div", { className: "flex flex-col sm:flex-row justify-between sm:items-center gap-3 sm:gap-4 p-4 sm:p-6 border-b shrink-0", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h1", { className: "text-lg sm:text-xl md:text-2xl font-bold tracking-tight truncate", children: previewReport.title || previewReport.label || 'Report Viewer' }), previewReport.description && (_jsx("p", { className: "text-sm text-muted-foreground mt-1 line-clamp-2", children: previewReport.description }))] }), _jsx("div", { className: "shrink-0 flex items-center gap-1.5", children: _jsxs("button", { type: "button", onClick: handleOpenConfigPanel, className: "inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground shadow-sm hover:bg-accent hover:text-accent-foreground", "data-testid": "report-edit-button", children: [_jsx(Pencil, { className: "h-3.5 w-3.5" }), "Edit"] }) })] }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-col sm:flex-row relative", children: [_jsx("div", { className: "flex-1 min-w-0 overflow-auto p-4 sm:p-6 lg:p-8 bg-muted/5", children: _jsx("div", { className: "w-full shadow-sm border rounded-lg sm:rounded-xl bg-background overflow-hidden min-h-150", children: _jsx(Suspense, { fallback: _jsx("div", { className: "p-8 text-sm text-muted-foreground", children: "Loading report\u2026" }), children: useSpecRenderer ? (_jsx("div", { className: "p-4 sm:p-6", children: _jsx(ReportRenderer, { schema: previewReport, dataSource: dataSource }) })) : (_jsx(ReportViewer, { schema: viewerSchema })) }) }) }), _jsx(Suspense, { fallback: null, children: _jsx(ReportConfigPanel, { open: configPanelOpen, onClose: handleCloseConfigPanel, config: reportConfig, onSave: handleReportConfigSave, onFieldChange: handleReportFieldChange, availableFields: availableFields, getFieldsForObject: getFieldsForObject }) }), _jsx(MetadataPanel, { open: showDebug, sections: [{ title: 'Report Configuration', data: previewReport }] })] })] }));
339
356
  }