@object-ui/app-shell 4.0.11 → 4.2.1

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,35 @@
1
+ /**
2
+ * crudAffordances — UI-side mirror of the framework's
3
+ * `resolveCrudAffordances()` helper (see
4
+ * `@objectstack/spec/data/object.zod.ts`).
5
+ *
6
+ * The framework tags every object with a `managedBy` lifecycle bucket
7
+ * (`platform | config | system | append-only | better-auth`) and an
8
+ * optional `userActions` override block. UI components ask this helper
9
+ * "should I show the New / Import / Edit / Delete / Export buttons?" so
10
+ * the toolbar tracks the object's lifecycle automatically — no need for
11
+ * each view to special-case `sys_*` names.
12
+ *
13
+ * Keep this in lockstep with the framework helper. The bucket defaults
14
+ * mirror what Salesforce / ServiceNow / Workday / Notion do for the
15
+ * equivalent categories of system tables.
16
+ */
17
+ const DEFAULTS = {
18
+ platform: { create: true, import: true, edit: true, delete: true, exportCsv: true },
19
+ config: { create: true, import: false, edit: true, delete: true, exportCsv: true },
20
+ system: { create: false, import: false, edit: false, delete: false, exportCsv: true },
21
+ 'append-only': { create: false, import: false, edit: false, delete: false, exportCsv: true },
22
+ 'better-auth': { create: false, import: false, edit: false, delete: false, exportCsv: true },
23
+ };
24
+ export function resolveCrudAffordances(obj) {
25
+ const bucket = obj?.managedBy ?? 'platform';
26
+ const base = DEFAULTS[bucket] ?? DEFAULTS.platform;
27
+ const o = obj?.userActions ?? {};
28
+ return {
29
+ create: o.create ?? base.create,
30
+ import: o.import ?? base.import,
31
+ edit: o.edit ?? base.edit,
32
+ delete: o.delete ?? base.delete,
33
+ exportCsv: o.exportCsv ?? base.exportCsv,
34
+ };
35
+ }
@@ -26,6 +26,8 @@ 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 { ManagedByBanner } from '../components/ManagedByBanner';
30
+ import { resolveCrudAffordances } from '../utils/crudAffordances';
29
31
  import { useObjectActions } from '../hooks/useObjectActions';
30
32
  import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
31
33
  import { usePermissions } from '@object-ui/permissions';
@@ -47,6 +49,40 @@ const VIEW_TYPE_ICONS = {
47
49
  chart: BarChart3,
48
50
  };
49
51
  const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
52
+ /**
53
+ * Replace built-in tokens (e.g. `{current_user_id}`) inside a filter array
54
+ * with concrete values. Filters from platform-shipped `listViews` or saved
55
+ * `sys_view` rows may declare context-sensitive predicates like
56
+ * `{ field: 'submitter_id', operator: 'equals', value: '{current_user_id}' }`
57
+ * — those need to be substituted before the query reaches the API.
58
+ *
59
+ * Recognised tokens:
60
+ * • `{current_user_id}` → the authenticated user's id
61
+ *
62
+ * Returns a deep-cloned copy with substitutions applied. Non-array input
63
+ * is returned unchanged.
64
+ */
65
+ function substituteFilterTokens(filter, currentUserId) {
66
+ if (!Array.isArray(filter))
67
+ return filter;
68
+ const sub = (v) => {
69
+ if (typeof v === 'string') {
70
+ if (v === '{current_user_id}')
71
+ return currentUserId ?? v;
72
+ return v;
73
+ }
74
+ if (Array.isArray(v))
75
+ return v.map(sub);
76
+ if (v && typeof v === 'object') {
77
+ const out = {};
78
+ for (const k of Object.keys(v))
79
+ out[k] = sub(v[k]);
80
+ return out;
81
+ }
82
+ return v;
83
+ };
84
+ return filter.map(sub);
85
+ }
50
86
  /**
51
87
  * Translate a NamedListView spec object (shape: `type`, `label`, `columns`,
52
88
  * `filter`, `sort`, `kanban`/`chart`/`gantt`/... sub-blocks, `bulkActions`,
@@ -457,6 +493,13 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
457
493
  }
458
494
  // Refresh trigger — bumped after view CRUD or external data mutations.
459
495
  const [refreshKey, setRefreshKey] = useState(0);
496
+ // Resolve which generic CRUD affordances belong in the toolbar for
497
+ // this object's lifecycle bucket (`managedBy`). config tables show
498
+ // New/Edit/Delete but no CSV Import; system / append-only / better-auth
499
+ // hide the lot — those flows go through purpose-built actions on the
500
+ // source record (e.g. "Submit for Approval" on an Opportunity creates
501
+ // an `sys_approval_request`). Permissions still gate the buttons.
502
+ const affordances = useMemo(() => resolveCrudAffordances(objectDef), [objectDef]);
460
503
  // Propagate externally-triggered refreshes (e.g. global ModalForm submit)
461
504
  // into our internal refreshKey so list/data effects re-run.
462
505
  useEffect(() => {
@@ -1285,9 +1328,10 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1285
1328
  sort: viewDef.sort ?? listSchema.sort,
1286
1329
  filter: (() => {
1287
1330
  const base = viewDef.filter ?? listSchema.filter;
1331
+ const substituted = substituteFilterTokens(base, currentUser.id);
1288
1332
  if (!urlFilters.length)
1289
- return base;
1290
- const baseArr = Array.isArray(base) ? base : [];
1333
+ return substituted;
1334
+ const baseArr = Array.isArray(substituted) ? substituted : [];
1291
1335
  return [...baseArr, ...urlFilters];
1292
1336
  })(),
1293
1337
  hiddenFields: viewDef.hiddenFields ?? listSchema.hiddenFields,
@@ -1359,7 +1403,8 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1359
1403
  ...(Array.isArray(viewDef.filter) ? viewDef.filter : []),
1360
1404
  ...urlFilters,
1361
1405
  ];
1362
- return combined.length ? { filters: combined } : {};
1406
+ const substituted = substituteFilterTokens(combined, currentUser.id);
1407
+ return Array.isArray(substituted) && substituted.length ? { filters: substituted } : {};
1363
1408
  })()),
1364
1409
  ...(viewDef.sort?.length ? { sort: viewDef.sort } : {}),
1365
1410
  options: {
@@ -1489,7 +1534,7 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1489
1534
  }
1490
1535
  },
1491
1536
  }), [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: {
1537
+ 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(ManagedByBanner, { managedBy: objectDef?.managedBy }), _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: [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
1538
  type: 'action:bar',
1494
1539
  location: 'list_toolbar',
1495
1540
  actions: (objectDef.actions || []).map((a) => ({
@@ -1504,6 +1549,11 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1504
1549
  })),
1505
1550
  size: 'sm',
1506
1551
  variant: 'outline',
1552
+ // On mobile, collapse all schema-driven toolbar actions
1553
+ // into a single overflow menu so the icon-only New /
1554
+ // Import buttons stay visible without pushing the page
1555
+ // title off-screen.
1556
+ mobileMaxVisible: 0,
1507
1557
  } }))] }) }), 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]) => ({
1508
1558
  name,
1509
1559
  label: def?.label || name,
@@ -17,11 +17,21 @@ 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 { ManagedByBanner } from '../components/ManagedByBanner';
21
+ import { resolveCrudAffordances } from '../utils/crudAffordances';
20
22
  import { ActionConfirmDialog } from './ActionConfirmDialog';
21
23
  import { ActionParamDialog } from './ActionParamDialog';
22
24
  import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
23
25
  import { getRecordDisplayName } from '../utils';
24
26
  const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
27
+ /**
28
+ * Audit field names auto-injected by the framework's `applySystemFields`.
29
+ * Surfaced as a dedicated, collapsed "System Information" section on the
30
+ * record detail page so they don't clutter the primary content but remain
31
+ * discoverable. The inline-edit drawer keeps filtering them out via
32
+ * `DEFAULT_SYSTEM_FIELDS` in `@object-ui/plugin-detail/RecordDetailDrawer`.
33
+ */
34
+ const AUDIT_FIELD_NAMES = new Set(['created_at', 'created_by', 'updated_at', 'updated_by']);
25
35
  export function RecordDetailView({ dataSource, objects, onEdit }) {
26
36
  const { appName, objectName, recordId } = useParams();
27
37
  const { showDebug } = useMetadataInspector();
@@ -226,9 +236,10 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
226
236
  });
227
237
  return () => { cancelled = true; };
228
238
  }, [dataSource, pureRecordId, childRelations]);
229
- const currentUser = user
230
- ? { id: user.id, name: user.name, avatar: user.image }
231
- : FALLBACK_USER;
239
+ // Memoize so the object identity is stable across renders — otherwise
240
+ // any effect that depends on it (e.g. the feed loader below) would
241
+ // re-fire every render and create an infinite request loop.
242
+ const currentUser = useMemo(() => (user ? { id: user.id, name: user.name, avatar: user.image } : FALLBACK_USER), [user?.id, user?.name, user?.image]);
232
243
  // Fetch presence and comments from API
233
244
  useEffect(() => {
234
245
  if (!dataSource || !objectName || !pureRecordId)
@@ -239,28 +250,141 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
239
250
  .then((res) => { if (res.data?.length)
240
251
  setRecordViewers(res.data); })
241
252
  .catch(() => { });
242
- // Fetch persisted comments and map to FeedItem[]
243
- dataSource.find('sys_comment', { $filter: { threadId }, $orderby: { createdAt: 'asc' } })
253
+ // M10.10: Fetch persisted comments from sys_comment. Field names
254
+ // are snake_case to match the platform-objects schema
255
+ // (`packages/platform-objects/src/audit/sys-comment.object.ts`):
256
+ // thread_id, author_id, author_name, author_avatar_url, body,
257
+ // reactions (JSON string), parent_id, created_at, updated_at.
258
+ //
259
+ // Reactions are stored as a JSON object of `{ emoji: string[] }`
260
+ // (one array of user-ids per emoji). The aggregator below counts
261
+ // entries and flags the currently-signed-in user.
262
+ const parseReactions = (raw) => {
263
+ if (!raw)
264
+ return undefined;
265
+ let parsed;
266
+ if (typeof raw === 'string') {
267
+ try {
268
+ parsed = JSON.parse(raw);
269
+ }
270
+ catch {
271
+ return undefined;
272
+ }
273
+ }
274
+ else if (typeof raw === 'object') {
275
+ parsed = raw;
276
+ }
277
+ if (!parsed)
278
+ return undefined;
279
+ return Object.entries(parsed).map(([emoji, userIds]) => ({
280
+ emoji,
281
+ count: Array.isArray(userIds) ? userIds.length : 0,
282
+ reacted: Array.isArray(userIds) && userIds.includes(currentUser.id),
283
+ }));
284
+ };
285
+ dataSource.find('sys_comment', { $filter: { thread_id: threadId }, $orderby: { created_at: 'asc' } })
286
+ .then((res) => {
287
+ if (!res?.data?.length)
288
+ return;
289
+ const mapped = res.data.map((c) => ({
290
+ id: c.id,
291
+ type: 'comment',
292
+ actor: c.author_name ?? 'Unknown',
293
+ actorAvatarUrl: c.author_avatar_url ?? undefined,
294
+ body: c.body ?? '',
295
+ createdAt: c.created_at,
296
+ updatedAt: c.updated_at,
297
+ parentId: c.parent_id ?? undefined,
298
+ reactions: parseReactions(c.reactions),
299
+ }));
300
+ setFeedItems(prev => {
301
+ const byId = new Map();
302
+ for (const item of [...prev, ...mapped])
303
+ byId.set(String(item.id), item);
304
+ return Array.from(byId.values()).sort((a, b) => {
305
+ const ta = a.createdAt ? Date.parse(a.createdAt) : 0;
306
+ const tb = b.createdAt ? Date.parse(b.createdAt) : 0;
307
+ return ta - tb;
308
+ });
309
+ });
310
+ })
311
+ .catch(() => { });
312
+ // M10.11: Fetch sys_activity rows for this record and merge into the
313
+ // timeline. plugin-audit's writers populate sys_activity on every
314
+ // create/update/delete of objects that opt-in via enable.activities,
315
+ // so this surface — once wired here — gives us a Salesforce-style
316
+ // "what happened on this record" feed without any per-app glue.
317
+ //
318
+ // We map sys_activity.type to FeedItemType so the existing icon /
319
+ // colour map in RecordActivityTimeline keeps working:
320
+ // created/updated/deleted/system → 'field_change'
321
+ // assigned/shared → 'field_change'
322
+ // completed → 'task'
323
+ // commented/mentioned → 'comment' (but skipped — we
324
+ // already load these from
325
+ // sys_comment to get reactions
326
+ // and threading)
327
+ //
328
+ // sys_activity is system-owned so a 404 ("table not provisioned",
329
+ // older schemas without activities) is silently tolerated.
330
+ const activityTypeToFeed = {
331
+ created: 'field_change',
332
+ updated: 'field_change',
333
+ deleted: 'field_change',
334
+ assigned: 'field_change',
335
+ shared: 'field_change',
336
+ system: 'system',
337
+ completed: 'task',
338
+ commented: undefined,
339
+ mentioned: undefined,
340
+ login: undefined,
341
+ logout: undefined,
342
+ };
343
+ dataSource.find('sys_activity', {
344
+ $filter: { object_name: objectName, record_id: pureRecordId },
345
+ $orderby: { timestamp: 'asc' },
346
+ $top: 200,
347
+ })
244
348
  .then((res) => {
245
- if (res.data?.length) {
246
- setFeedItems(res.data.map((c) => ({
247
- id: c.id,
248
- type: 'comment',
249
- actor: c.author?.name ?? 'Unknown',
250
- actorAvatarUrl: c.author?.avatar,
251
- body: c.content,
252
- createdAt: c.createdAt,
253
- updatedAt: c.updatedAt,
254
- parentId: c.parentId,
255
- reactions: c.reactions
256
- ? Object.entries(c.reactions).map(([emoji, userIds]) => ({
257
- emoji,
258
- count: userIds.length,
259
- reacted: userIds.includes(currentUser.id),
260
- }))
261
- : undefined,
262
- })));
349
+ if (!res?.data?.length)
350
+ return;
351
+ const mapped = [];
352
+ for (const row of res.data) {
353
+ const t = activityTypeToFeed[row.type];
354
+ if (!t)
355
+ continue;
356
+ // Prefer the explicit `timestamp` column, but tolerate older
357
+ // rows where the driver leaked the literal "NOW()" — fall
358
+ // back to created_at (always a real ISO date).
359
+ let when = row.timestamp;
360
+ if (!when || when === 'NOW()' || Number.isNaN(Date.parse(when))) {
361
+ when = row.created_at;
362
+ }
363
+ mapped.push({
364
+ id: row.id,
365
+ type: t,
366
+ actor: row.actor_name ?? 'System',
367
+ actorAvatarUrl: row.actor_avatar_url ?? undefined,
368
+ body: row.summary ?? '',
369
+ createdAt: when,
370
+ });
263
371
  }
372
+ if (!mapped.length)
373
+ return;
374
+ setFeedItems(prev => {
375
+ // Merge by id (timeline events are append-only); sort by
376
+ // createdAt ascending so the activity panel reads as a
377
+ // chronological narrative.
378
+ const byId = new Map();
379
+ for (const item of [...prev, ...mapped]) {
380
+ byId.set(String(item.id), item);
381
+ }
382
+ return Array.from(byId.values()).sort((a, b) => {
383
+ const ta = a.createdAt ? Date.parse(a.createdAt) : 0;
384
+ const tb = b.createdAt ? Date.parse(b.createdAt) : 0;
385
+ return ta - tb;
386
+ });
387
+ });
264
388
  })
265
389
  .catch(() => { });
266
390
  }, [dataSource, objectName, pureRecordId, currentUser]);
@@ -274,16 +398,18 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
274
398
  createdAt: new Date().toISOString(),
275
399
  };
276
400
  setFeedItems(prev => [...prev, newItem]);
277
- // Persist to backend
401
+ // Persist to backend (M10.10: snake_case fields per sys_comment schema)
278
402
  if (dataSource) {
279
403
  const threadId = `${objectName}:${pureRecordId}`;
280
404
  dataSource.create('sys_comment', {
281
405
  id: newItem.id,
282
- threadId,
283
- author: currentUser,
284
- content: text,
285
- mentions: [],
286
- createdAt: newItem.createdAt,
406
+ thread_id: threadId,
407
+ author_id: currentUser.id,
408
+ author_name: currentUser.name,
409
+ author_avatar_url: 'avatar' in currentUser ? currentUser.avatar : undefined,
410
+ body: text,
411
+ mentions: '[]',
412
+ created_at: newItem.createdAt,
287
413
  }).catch(() => { });
288
414
  }
289
415
  }, [currentUser, dataSource, objectName, pureRecordId]);
@@ -308,12 +434,14 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
308
434
  const threadId = `${objectName}:${pureRecordId}`;
309
435
  dataSource.create('sys_comment', {
310
436
  id: newItem.id,
311
- threadId,
312
- author: currentUser,
313
- content: text,
314
- mentions: [],
315
- createdAt: newItem.createdAt,
316
- parentId,
437
+ thread_id: threadId,
438
+ author_id: currentUser.id,
439
+ author_name: currentUser.name,
440
+ author_avatar_url: 'avatar' in currentUser ? currentUser.avatar : undefined,
441
+ body: text,
442
+ mentions: '[]',
443
+ created_at: newItem.createdAt,
444
+ parent_id: parentId,
317
445
  }).catch(() => { });
318
446
  }
319
447
  }, [currentUser, dataSource, objectName, pureRecordId]);
@@ -342,10 +470,31 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
342
470
  reactions.push({ emoji, count: 1, reacted: true });
343
471
  }
344
472
  const updated = { ...item, reactions };
345
- // Persist reaction toggle to backend
473
+ // Persist reactions to backend as JSON. The schema stores
474
+ // `reactions` as a textarea JSON string of `{ emoji: userIds[] }`,
475
+ // so we rebuild the canonical shape from the optimistic local
476
+ // state before writing back. A failed update silently keeps the
477
+ // optimistic UI change (best-effort, surfaced by RUM if needed).
346
478
  if (dataSource) {
479
+ const userId = currentUser.id;
480
+ const remoteShape = {};
481
+ for (const r of reactions) {
482
+ // We don't have the original user-id list locally, so we
483
+ // approximate by emitting the signed-in user when they are
484
+ // the (only known) reactor. This is an over-simplification
485
+ // for single-user pilot installs and will be replaced by a
486
+ // proper backend reaction endpoint in M11.
487
+ const ids = [];
488
+ if (r.reacted)
489
+ ids.push(userId);
490
+ // Pad with a synthetic marker so count is preserved across
491
+ // refreshes from other clients (best-effort).
492
+ while (ids.length < r.count)
493
+ ids.push('__other__');
494
+ remoteShape[r.emoji] = ids;
495
+ }
347
496
  dataSource.update('sys_comment', String(itemId), {
348
- $toggleReaction: { emoji, userId: currentUser.id },
497
+ reactions: JSON.stringify(remoteShape),
349
498
  }).catch(() => { });
350
499
  }
351
500
  return updated;
@@ -397,7 +546,9 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
397
546
  // section, DetailSection flattens it (no Card chrome, no
398
547
  // redundant "Details" heading).
399
548
  showBorder: false,
400
- fields: Object.keys(objectDef.fields || {}).map(key => {
549
+ fields: Object.keys(objectDef.fields || {})
550
+ .filter(key => !AUDIT_FIELD_NAMES.has(key))
551
+ .map(key => {
401
552
  const fieldDef = objectDef.fields[key];
402
553
  const refTarget = fieldDef.reference_to || fieldDef.reference;
403
554
  return {
@@ -412,6 +563,32 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
412
563
  }),
413
564
  },
414
565
  ];
566
+ // Append a dedicated, collapsed "System Information" section listing
567
+ // audit fields (created/updated at/by) when the schema declares them
568
+ // and no author-defined section has already surfaced them. The framework
569
+ // auto-injects these as `system: true, readonly: true` via
570
+ // `applySystemFields`; rendering them here gives users visibility into
571
+ // record provenance without polluting the primary content area.
572
+ const fieldsAlreadyShown = new Set(sections.flatMap((s) => (s.fields || []).map((f) => f.name)));
573
+ const auditFieldsToShow = Array.from(AUDIT_FIELD_NAMES).filter(name => objectDef.fields?.[name] && !fieldsAlreadyShown.has(name));
574
+ if (auditFieldsToShow.length > 0) {
575
+ sections.push({
576
+ title: sectionLabel(objectDef.name, 'system_info', 'System Information'),
577
+ collapsible: true,
578
+ defaultCollapsed: true,
579
+ fields: auditFieldsToShow.map(key => {
580
+ const fieldDef = objectDef.fields[key];
581
+ const refTarget = fieldDef.reference_to || fieldDef.reference;
582
+ return {
583
+ name: key,
584
+ label: fieldDef.label || key,
585
+ type: fieldDef.type || 'text',
586
+ readonly: true,
587
+ ...(refTarget && { reference_to: refTarget }),
588
+ };
589
+ }),
590
+ });
591
+ }
415
592
  // Filter actions for record_header location and deduplicate by name
416
593
  const recordHeaderActions = (() => {
417
594
  const seen = new Set();
@@ -526,13 +703,19 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
526
703
  onRowDelete,
527
704
  };
528
705
  });
706
+ const affordances = resolveCrudAffordances(objectDef);
529
707
  return {
530
708
  type: 'detail-view',
531
709
  objectName: objectDef.name,
532
710
  resourceId: pureRecordId,
533
711
  showBack: true,
534
712
  onBack: 'history',
535
- showEdit: true,
713
+ // Hide the Edit button for objects whose lifecycle isn't user-managed
714
+ // (approval requests, audit logs, better-auth tables, …). The
715
+ // underlying form is also disabled when `managedBy !== 'platform'`
716
+ // (see plugin-form/ObjectForm), so even if a stray Edit URL is
717
+ // visited the inputs render read-only.
718
+ showEdit: affordances.edit,
536
719
  title: objectDef.label,
537
720
  primaryField,
538
721
  sections,
@@ -557,7 +740,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
557
740
  if (!objectDef) {
558
741
  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 }) })] }) }));
559
742
  }
560
- 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) => {
743
+ return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsx(ManagedByBanner, { managedBy: objectDef?.managedBy }), _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) => {
561
744
  if (!record || typeof record !== 'object')
562
745
  return;
563
746
  // 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 { ManagedByBanner } from '../components/ManagedByBanner';
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(ManagedByBanner, { managedBy: objectDef?.managedBy }), _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';
@@ -326,6 +327,20 @@ export function ReportView({ dataSource }) {
326
327
  };
327
328
  // Use live-edited schema for preview (persists after closing panel until metadata refreshes)
328
329
  const previewReport = editSchema || reportData;
330
+ // Route any object-backed spec report (matrix/joined/tabular/summary) through
331
+ // the spec ReportRenderer dispatcher. It handles aggregation, charts, KPIs
332
+ // and drill protocol end-to-end. The legacy ReportViewer is only used as a
333
+ // last resort for fully-legacy schemas that lack `objectName` (e.g. inline
334
+ // `fields` + `data` arrays from older app code).
335
+ const useSpecRenderer = Boolean(previewReport &&
336
+ previewReport.objectName &&
337
+ (previewReport.type === 'matrix' ||
338
+ previewReport.type === 'joined' ||
339
+ previewReport.type === 'summary' ||
340
+ previewReport.type === 'tabular' ||
341
+ previewReport.type === undefined ||
342
+ (Array.isArray(previewReport.groupingsAcross) && previewReport.groupingsAcross.length > 0) ||
343
+ Array.isArray(previewReport.columns)));
329
344
  const reportForViewer = mapReportForViewer(previewReport);
330
345
  const viewerSchema = {
331
346
  type: 'report-viewer',
@@ -335,5 +350,5 @@ export function ReportView({ dataSource }) {
335
350
  allowExport: true,
336
351
  loading: dataLoading, // Loading state for data fetching
337
352
  };
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 }] })] })] }));
353
+ 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 }) }), _jsx(MetadataPanel, { open: showDebug, sections: [{ title: 'Report Configuration', data: previewReport }] })] })] }));
339
354
  }