@object-ui/app-shell 4.2.1 → 4.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.
@@ -6,44 +6,61 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  * Uses the DetailView plugin component with auto-generated sections from
7
7
  * the object field definitions.
8
8
  */
9
- import { useState, useEffect, useCallback, useMemo } from 'react';
9
+ import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
10
10
  import { useParams, useNavigate } from 'react-router-dom';
11
11
  import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
12
12
  import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
13
13
  import { PresenceAvatars } from '@object-ui/collaboration';
14
14
  import { useAuth, createAuthenticatedFetch } from '@object-ui/auth';
15
- import { ActionProvider, useObjectTranslation, useObjectLabel } from '@object-ui/react';
15
+ import { ActionProvider, useObjectTranslation, useObjectLabel, usePageAssignment, RecordContextProvider, SchemaRenderer } from '@object-ui/react';
16
+ import { buildExpandFields } from '@object-ui/core';
16
17
  import { toast } from 'sonner';
17
18
  import { Database, Users } from 'lucide-react';
18
19
  import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
19
20
  import { SkeletonDetail } from '../skeletons';
20
- import { ManagedByBanner } from '../components/ManagedByBanner';
21
+ import { ManagedByBadge } from '../components/ManagedByBadge';
21
22
  import { resolveCrudAffordances } from '../utils/crudAffordances';
22
23
  import { ActionConfirmDialog } from './ActionConfirmDialog';
23
24
  import { ActionParamDialog } from './ActionParamDialog';
25
+ import { resolveActionParams } from '../utils/resolveActionParams';
24
26
  import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
27
+ import { useRecordApprovals } from '../hooks/useRecordApprovals';
25
28
  import { getRecordDisplayName } from '../utils';
26
29
  const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
27
30
  /**
28
31
  * 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`.
32
+ * Filtered out of the auto-generated body sections they are rendered
33
+ * separately as a single subtle one-line `<RecordMetaFooter>` (see
34
+ * `@object-ui/plugin-detail`) so provenance stays discoverable without a
35
+ * heavy "System Information" panel. The inline-edit drawer also hides
36
+ * them via `DEFAULT_SYSTEM_FIELDS` in
37
+ * `@object-ui/plugin-detail/RecordDetailDrawer`.
33
38
  */
34
39
  const AUDIT_FIELD_NAMES = new Set(['created_at', 'created_by', 'updated_at', 'updated_by']);
40
+ /**
41
+ * System/tenant fields that the framework auto-injects on every record but
42
+ * which carry no business value on a detail page. Hidden from the
43
+ * auto-generated section (when the object has no explicit form sections).
44
+ * Authors who really want to show these can still list them in
45
+ * `objectDef.views.form.sections`.
46
+ */
47
+ const HIDDEN_SYSTEM_FIELD_NAMES = new Set([
48
+ 'organization_id', 'tenant_id', 'is_deleted', 'deleted_at',
49
+ ]);
35
50
  export function RecordDetailView({ dataSource, objects, onEdit }) {
36
51
  const { appName, objectName, recordId } = useParams();
37
52
  const { showDebug } = useMetadataInspector();
38
53
  const { user } = useAuth();
39
54
  const navigate = useNavigate();
40
55
  const { t } = useObjectTranslation();
41
- const { objectLabel, viewLabel: _vLabel, sectionLabel, actionLabel, actionConfirm, actionSuccess } = useObjectLabel();
56
+ const { objectLabel, viewLabel: _vLabel, sectionLabel, actionLabel, actionConfirm, actionSuccess, fieldLabel, fieldOptionLabel } = useObjectLabel();
42
57
  const [isLoading, setIsLoading] = useState(true);
43
58
  const [feedItems, setFeedItems] = useState([]);
44
59
  const [recordViewers, setRecordViewers] = useState([]);
45
60
  const [actionRefreshKey, setActionRefreshKey] = useState(0);
46
61
  const [childRelatedData, setChildRelatedData] = useState({});
62
+ const [historyEntries, setHistoryEntries] = useState(null);
63
+ const [historyLoading, setHistoryLoading] = useState(false);
47
64
  const [recordTitle, setRecordTitle] = useState();
48
65
  const objectDef = objects.find((o) => o.name === objectName);
49
66
  // Publish record title to the navigation context so the top-bar breadcrumb
@@ -53,6 +70,40 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
53
70
  // Navigation code passes `record.id || record._id` directly into the URL
54
71
  // without adding any prefix, so no stripping is needed.
55
72
  const pureRecordId = recordId;
73
+ // ─── Page Assignment (Salesforce Lightning-style record Pages) ──────
74
+ // If a PageSchema(pageType='record') is authored for this object, render
75
+ // it via SchemaRenderer (which dispatches to the registered 'record'
76
+ // PageRenderer in @object-ui/components). Otherwise we fall through to
77
+ // the legacy auto-generated DetailView path below.
78
+ const { page: assignedPage } = usePageAssignment(objectName);
79
+ const [pageRecord, setPageRecord] = useState(null);
80
+ useEffect(() => {
81
+ let cancelled = false;
82
+ if (!assignedPage || !pureRecordId || !objectName || !dataSource?.findOne) {
83
+ setPageRecord(null);
84
+ return;
85
+ }
86
+ // Expand lookup/master_detail fields so the page receives display
87
+ // names (e.g. account.name) rather than raw foreign-key IDs. The
88
+ // page subtitle interpolation and record:* renderers depend on this.
89
+ const expandFields = buildExpandFields(objectDef?.fields);
90
+ const params = expandFields.length > 0 ? { $expand: expandFields } : undefined;
91
+ const findOnePromise = params
92
+ ? dataSource.findOne(objectName, pureRecordId, params)
93
+ : dataSource.findOne(objectName, pureRecordId);
94
+ findOnePromise
95
+ .then((rec) => {
96
+ if (!cancelled)
97
+ setPageRecord(rec);
98
+ })
99
+ .catch(() => {
100
+ if (!cancelled)
101
+ setPageRecord(null);
102
+ });
103
+ return () => {
104
+ cancelled = true;
105
+ };
106
+ }, [assignedPage, objectName, pureRecordId, dataSource, objectDef]);
56
107
  // ─── Action Provider Handlers ───────────────────────────────────────
57
108
  // Confirm dialog state (promise-based)
58
109
  const [confirmState, setConfirmState] = useState({ open: false, message: '' });
@@ -65,9 +116,15 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
65
116
  }, []);
66
117
  const paramCollectionHandler = useCallback((params) => {
67
118
  return new Promise((resolve) => {
68
- setParamState({ open: true, params, resolve });
119
+ const resolved = resolveActionParams(params, {
120
+ objectName: objectName || objectDef?.name || '',
121
+ objects: objects || [],
122
+ fieldLabel,
123
+ fieldOptionLabel,
124
+ });
125
+ setParamState({ open: true, params: resolved, resolve });
69
126
  });
70
- }, []);
127
+ }, [objectName, objectDef, objects, fieldLabel, fieldOptionLabel]);
71
128
  const toastHandler = useCallback((message, options) => {
72
129
  if (options?.type === 'error')
73
130
  toast.error(message);
@@ -187,6 +244,41 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
187
244
  return { success: false, error: error.message };
188
245
  }
189
246
  }, [authFetch, pureRecordId, objectName]);
247
+ // ─── Approvals ─────────────────────────────────────────────────────
248
+ // Surfaces "Submit for Approval" / "Recall" buttons on the record header
249
+ // when an active approval process is registered for this object, and a
250
+ // status badge when a request exists.
251
+ const approvals = useRecordApprovals(objectName, pureRecordId, user?.id);
252
+ // Hold latest approvals snapshot in a ref so the action handler
253
+ // (memoized once inside ActionRunner) always sees fresh state instead of
254
+ // the stale closure captured at the first render.
255
+ const approvalsRef = useRef(approvals);
256
+ approvalsRef.current = approvals;
257
+ const approvalHandler = useCallback(async (action) => {
258
+ const target = action.target || action.name;
259
+ const params = (action.params && !Array.isArray(action.params))
260
+ ? action.params
261
+ : {};
262
+ try {
263
+ if (target === 'submit_approval') {
264
+ await approvalsRef.current.submit({
265
+ processName: params.processName,
266
+ comment: params.comment,
267
+ });
268
+ }
269
+ else if (target === 'recall_approval') {
270
+ await approvalsRef.current.recall({ comment: params.comment });
271
+ }
272
+ else {
273
+ return { success: false, error: `Unknown approval target: ${target}` };
274
+ }
275
+ setActionRefreshKey((k) => k + 1);
276
+ return { success: true, reload: true };
277
+ }
278
+ catch (err) {
279
+ return { success: false, error: err?.message || String(err) };
280
+ }
281
+ }, []);
190
282
  // Discover reverse references: other objects with lookup/master_detail fields
191
283
  // pointing to the current object (e.g., order_item.order → order).
192
284
  const childRelations = useMemo(() => {
@@ -236,6 +328,61 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
236
328
  });
237
329
  return () => { cancelled = true; };
238
330
  }, [dataSource, pureRecordId, childRelations]);
331
+ // ── Audit history fetch ────────────────────────────────────────────
332
+ // Loads recent sys_audit_log entries for this record so the DetailView can
333
+ // render a read-only "History" tab. Gated on three preconditions to keep
334
+ // the network and the UI quiet for objects that opt out of history:
335
+ // 1) trackHistory must be explicitly true on the object capabilities
336
+ // (the framework default is false, so we never speculatively fetch).
337
+ // 2) sys_audit_log must be present in the registered objects list — if
338
+ // the platform-objects package isn't deployed the tab makes no sense.
339
+ // 3) The object being viewed must not be sys_audit_log itself, to avoid
340
+ // a recursive tab on the audit log detail page.
341
+ // We request only the safe projection (created_at, action, user_id) so the
342
+ // browser never receives serialized old_value/new_value payloads, which
343
+ // can contain restricted fields. Field-level redaction in PR2 will harden
344
+ // this further once a backend-scoped audit endpoint exists.
345
+ const historyEnabled = useMemo(() => {
346
+ if (!objectDef)
347
+ return false;
348
+ if (objectDef.name === 'sys_audit_log')
349
+ return false;
350
+ if (objectDef.enable?.trackHistory !== true)
351
+ return false;
352
+ return objects.some((o) => o.name === 'sys_audit_log');
353
+ }, [objectDef, objects]);
354
+ useEffect(() => {
355
+ if (!dataSource || !pureRecordId || !objectDef || !historyEnabled) {
356
+ setHistoryEntries(null);
357
+ return;
358
+ }
359
+ let cancelled = false;
360
+ setHistoryLoading(true);
361
+ dataSource
362
+ .find('sys_audit_log', {
363
+ $filter: { record_id: pureRecordId, object_name: objectDef.name },
364
+ $orderby: { created_at: 'desc' },
365
+ $top: 50,
366
+ $select: ['id', 'created_at', 'action', 'user_id'],
367
+ })
368
+ .then((res) => {
369
+ if (cancelled)
370
+ return;
371
+ const items = Array.isArray(res) ? res : res?.data || [];
372
+ setHistoryEntries(items);
373
+ })
374
+ .catch((err) => {
375
+ if (cancelled)
376
+ return;
377
+ console.warn('[RecordDetailView] Failed to fetch sys_audit_log:', err);
378
+ setHistoryEntries([]);
379
+ })
380
+ .finally(() => {
381
+ if (!cancelled)
382
+ setHistoryLoading(false);
383
+ });
384
+ return () => { cancelled = true; };
385
+ }, [dataSource, pureRecordId, objectDef, historyEnabled]);
239
386
  // Memoize so the object identity is stable across renders — otherwise
240
387
  // any effect that depends on it (e.g. the feed loader below) would
241
388
  // re-fire every render and create an infinite request loop.
@@ -547,7 +694,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
547
694
  // redundant "Details" heading).
548
695
  showBorder: false,
549
696
  fields: Object.keys(objectDef.fields || {})
550
- .filter(key => !AUDIT_FIELD_NAMES.has(key))
697
+ .filter(key => !AUDIT_FIELD_NAMES.has(key) && !HIDDEN_SYSTEM_FIELD_NAMES.has(key))
551
698
  .map(key => {
552
699
  const fieldDef = objectDef.fields[key];
553
700
  const refTarget = fieldDef.reference_to || fieldDef.reference;
@@ -563,36 +710,16 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
563
710
  }),
564
711
  },
565
712
  ];
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
- }
713
+ // Audit fields (created_at/created_by/updated_at/updated_by) are NOT
714
+ // appended as a section here they are surfaced by `<RecordMetaFooter>`
715
+ // (rendered by DetailView) as a single subtle line below the content,
716
+ // replacing the old card-style "System Information" panel. The inline-edit
717
+ // drawer continues to hide them via `DEFAULT_SYSTEM_FIELDS` in
718
+ // `@object-ui/plugin-detail/RecordDetailDrawer`.
592
719
  // Filter actions for record_header location and deduplicate by name
593
720
  const recordHeaderActions = (() => {
594
721
  const seen = new Set();
595
- return (objectDef.actions || []).filter((a) => {
722
+ const base = (objectDef.actions || []).filter((a) => {
596
723
  if (!a.locations?.includes('record_header'))
597
724
  return false;
598
725
  if (!a.name)
@@ -611,6 +738,59 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
611
738
  successMessage: actionSuccess(objectDef.name, a.name, a.successMessage),
612
739
  }),
613
740
  }));
741
+ // Inject approval actions — only when the approvals plugin is
742
+ // available and an active process exists for this object.
743
+ if (approvals.available && approvals.processes.length > 0) {
744
+ if (approvals.canSubmit) {
745
+ base.push({
746
+ name: 'submit_approval',
747
+ type: 'approval',
748
+ target: 'submit_approval',
749
+ label: t('approvals.submitForApproval', { defaultValue: 'Submit for Approval' }),
750
+ icon: 'send',
751
+ variant: 'default',
752
+ locations: ['record_header'],
753
+ refreshAfter: true,
754
+ successMessage: t('approvals.submitSuccess', { defaultValue: 'Approval request submitted' }),
755
+ ...(approvals.processes.length === 1
756
+ ? { params: { processName: approvals.processes[0].name } }
757
+ : {
758
+ collectParams: [{
759
+ name: 'processName',
760
+ label: t('approvals.process', { defaultValue: 'Process' }),
761
+ type: 'select',
762
+ required: true,
763
+ options: approvals.processes.map((p) => ({
764
+ value: p.name,
765
+ label: p.label || p.name,
766
+ })),
767
+ }, {
768
+ name: 'comment',
769
+ label: t('approvals.comment', { defaultValue: 'Comment (optional)' }),
770
+ type: 'text',
771
+ multiline: true,
772
+ }],
773
+ }),
774
+ });
775
+ }
776
+ if (approvals.canRecall) {
777
+ base.push({
778
+ name: 'recall_approval',
779
+ type: 'approval',
780
+ target: 'recall_approval',
781
+ label: t('approvals.recall', { defaultValue: 'Recall' }),
782
+ icon: 'undo',
783
+ variant: 'outline',
784
+ locations: ['record_header'],
785
+ refreshAfter: true,
786
+ confirmText: t('approvals.recallConfirm', {
787
+ defaultValue: 'Recall this pending approval request?',
788
+ }),
789
+ successMessage: t('approvals.recallSuccess', { defaultValue: 'Approval recalled' }),
790
+ });
791
+ }
792
+ }
793
+ return base;
614
794
  })();
615
795
  // Build highlightFields: exclusively from objectDef metadata (no hardcoded fallback)
616
796
  const highlightFields = objectDef.views?.detail?.highlightFields ?? [];
@@ -721,6 +901,12 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
721
901
  sections,
722
902
  autoTabs: true,
723
903
  autoDiscoverRelated: true,
904
+ ...(historyEnabled && {
905
+ history: {
906
+ entries: historyEntries ?? [],
907
+ loading: historyLoading && historyEntries === null,
908
+ },
909
+ }),
724
910
  ...(related.length > 0 && { related }),
725
911
  ...(highlightFields.length > 0 && { highlightFields }),
726
912
  ...(sectionGroups && sectionGroups.length > 0 && { sectionGroups }),
@@ -733,14 +919,17 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
733
919
  }),
734
920
  };
735
921
  // eslint-disable-next-line react-hooks/exhaustive-deps
736
- }, [objectDef?.name, pureRecordId, childRelatedData, actionRefreshKey, appName, navigate, dataSource, t, objectLabel, objects]);
922
+ }, [objectDef?.name, pureRecordId, childRelatedData, actionRefreshKey, appName, navigate, dataSource, t, objectLabel, objects, historyEnabled, historyEntries, historyLoading, approvals.available, approvals.processes, approvals.canSubmit, approvals.canRecall, approvals.pendingRequest, approvals.latestRequest]);
737
923
  if (isLoading) {
738
924
  return _jsx(SkeletonDetail, {});
739
925
  }
740
926
  if (!objectDef) {
741
927
  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 }) })] }) }));
742
928
  }
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) => {
929
+ if (assignedPage) {
930
+ return (_jsx(RecordContextProvider, { objectName: objectName, recordId: pureRecordId, data: pageRecord, objectSchema: objectDef, dataSource: dataSource, children: _jsx(ActionProvider, { context: { record: pageRecord || {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler, approval: approvalHandler }, children: _jsx("div", { className: "bg-background p-3 sm:p-4 lg:p-6", children: _jsx(SchemaRenderer, { schema: assignedPage }) }) }) }));
931
+ }
932
+ 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, approval: approvalHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), onDataLoaded: (record) => {
744
933
  if (!record || typeof record !== 'object')
745
934
  return;
746
935
  // Resolve the same way DetailView's header does, so the
@@ -39,7 +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
+ import { ManagedByBadge } from '../components/ManagedByBadge';
43
43
  import { useAuth } from '@object-ui/auth';
44
44
  import { ExpressionEvaluator } from '@object-ui/core';
45
45
  /**
@@ -162,7 +162,7 @@ export function RecordFormPage({ mode }) {
162
162
  if (!objectDef) {
163
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')] }) })] }) }));
164
164
  }
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
+ 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: {
166
166
  type: 'object-form',
167
167
  formType: 'simple',
168
168
  objectName: objectDef.name,
@@ -38,29 +38,31 @@ export function ReportView({ dataSource }) {
38
38
  // State for report runtime data
39
39
  const [reportRuntimeData, setReportRuntimeData] = useState([]);
40
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]);
41
59
  // Derive available fields from object schema for filter/sort editors
42
60
  // Uses live editSchema when available to respond to objectName changes
43
61
  const availableFields = useMemo(() => {
44
62
  const liveReport = editSchema || reportData;
45
63
  const objName = liveReport?.objectName || liveReport?.dataSource?.object || liveReport?.dataSource?.resource;
46
- if (objName && objects?.length) {
47
- const objDef = objects.find((o) => o.name === objName);
48
- if (objDef?.fields) {
49
- const fields = objDef.fields;
50
- if (Array.isArray(fields)) {
51
- return fields.map((f) => typeof f === 'string'
52
- ? { value: f, label: f, type: 'text' }
53
- : { value: f.name, label: f.label || f.name, type: f.type || 'text' });
54
- }
55
- return Object.entries(fields).map(([name, def]) => ({
56
- value: name,
57
- label: def.label || name,
58
- type: def.type || 'text',
59
- }));
60
- }
61
- }
62
- return FALLBACK_FIELDS;
63
- }, [editSchema, reportData, objects]);
64
+ return getFieldsForObject(objName) ?? FALLBACK_FIELDS;
65
+ }, [editSchema, reportData, getFieldsForObject]);
64
66
  // ---- Save helper --------------------------------------------------------
65
67
  const saveSchema = useCallback(async (schema) => {
66
68
  try {
@@ -350,5 +352,5 @@ export function ReportView({ dataSource }) {
350
352
  allowExport: true,
351
353
  loading: dataLoading, // Loading state for data fetching
352
354
  };
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 }] })] })] }));
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 }] })] })] }));
354
356
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/app-shell",
3
- "version": "4.2.1",
3
+ "version": "4.4.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Minimal application shell for ObjectUI - framework-agnostic rendering engine",
@@ -27,34 +27,34 @@
27
27
  "dependencies": {
28
28
  "lucide-react": "^1.16.0",
29
29
  "sonner": "^2.0.7",
30
- "@object-ui/auth": "4.2.1",
31
- "@object-ui/collaboration": "4.2.1",
32
- "@object-ui/components": "4.2.1",
33
- "@object-ui/core": "4.2.1",
34
- "@object-ui/data-objectstack": "4.2.1",
35
- "@object-ui/fields": "4.2.1",
36
- "@object-ui/i18n": "4.2.1",
37
- "@object-ui/layout": "4.2.1",
38
- "@object-ui/permissions": "4.2.1",
39
- "@object-ui/react": "4.2.1",
40
- "@object-ui/types": "4.2.1"
30
+ "@object-ui/auth": "4.4.0",
31
+ "@object-ui/collaboration": "4.4.0",
32
+ "@object-ui/components": "4.4.0",
33
+ "@object-ui/core": "4.4.0",
34
+ "@object-ui/data-objectstack": "4.4.0",
35
+ "@object-ui/fields": "4.4.0",
36
+ "@object-ui/i18n": "4.4.0",
37
+ "@object-ui/layout": "4.4.0",
38
+ "@object-ui/permissions": "4.4.0",
39
+ "@object-ui/react": "4.4.0",
40
+ "@object-ui/types": "4.4.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "react": "^18.0.0 || ^19.0.0",
44
44
  "react-dom": "^18.0.0 || ^19.0.0",
45
45
  "react-router-dom": "^6.0.0 || ^7.0.0",
46
- "@object-ui/plugin-calendar": "^4.2.1",
47
- "@object-ui/plugin-charts": "^4.2.1",
48
- "@object-ui/plugin-chatbot": "^4.2.1",
49
- "@object-ui/plugin-dashboard": "^4.2.1",
50
- "@object-ui/plugin-designer": "^4.2.1",
51
- "@object-ui/plugin-detail": "^4.2.1",
52
- "@object-ui/plugin-form": "^4.2.1",
53
- "@object-ui/plugin-grid": "^4.2.1",
54
- "@object-ui/plugin-kanban": "^4.2.1",
55
- "@object-ui/plugin-list": "^4.2.1",
56
- "@object-ui/plugin-report": "^4.2.1",
57
- "@object-ui/plugin-view": "^4.2.1"
46
+ "@object-ui/plugin-calendar": "^4.4.0",
47
+ "@object-ui/plugin-charts": "^4.4.0",
48
+ "@object-ui/plugin-chatbot": "^4.4.0",
49
+ "@object-ui/plugin-dashboard": "^4.4.0",
50
+ "@object-ui/plugin-designer": "^4.4.0",
51
+ "@object-ui/plugin-detail": "^4.4.0",
52
+ "@object-ui/plugin-form": "^4.4.0",
53
+ "@object-ui/plugin-grid": "^4.4.0",
54
+ "@object-ui/plugin-kanban": "^4.4.0",
55
+ "@object-ui/plugin-list": "^4.4.0",
56
+ "@object-ui/plugin-report": "^4.4.0",
57
+ "@object-ui/plugin-view": "^4.4.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^25.9.0",
@@ -1,16 +0,0 @@
1
- export interface ManagedByBannerProps {
2
- /** The `managedBy` flag from the object schema. */
3
- managedBy?: string;
4
- /** Optional override for the human-readable system name. */
5
- label?: string;
6
- /** Optional documentation link rendered as "Learn more →". */
7
- docHref?: string;
8
- /**
9
- * Optional human-readable name for the source record / parent workflow
10
- * referenced in `system`-bucket banners (e.g. "Opportunity"). When
11
- * provided the banner reads "Use the Opportunity record's Submit for
12
- * Approval action…" instead of the generic phrasing.
13
- */
14
- sourceRecordLabel?: string;
15
- }
16
- export declare function ManagedByBanner({ managedBy, label, docHref, sourceRecordLabel }: ManagedByBannerProps): import("react/jsx-runtime").JSX.Element | null;
@@ -1,38 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { ShieldAlert, Info, Lock, Archive } from 'lucide-react';
3
- const VARIANTS = {
4
- config: {
5
- icon: Info,
6
- tone: 'border-sky-300/60 bg-sky-50 text-sky-900 dark:border-sky-500/40 dark:bg-sky-950/40 dark:text-sky-100',
7
- title: 'Administrator configuration',
8
- body: () => "These rows define how the platform behaves at runtime — they're authored here, but the runtime data they produce lives in a separate table. Changes take effect once a row is marked Active.",
9
- },
10
- system: {
11
- icon: Lock,
12
- tone: 'border-slate-300/60 bg-slate-50 text-slate-900 dark:border-slate-500/40 dark:bg-slate-950/40 dark:text-slate-100',
13
- title: 'Managed by the platform',
14
- body: (_display, src) => `Rows here are created and updated automatically by the platform engine. To start a new one, use the action button on the ${src ?? 'source record'} (e.g. "Submit for Approval", "Share", "Invite"). The list below is the audit / monitoring surface — actions like Approve, Recall, or Resend live on the row.`,
15
- },
16
- 'append-only': {
17
- icon: Archive,
18
- tone: 'border-zinc-300/60 bg-zinc-50 text-zinc-900 dark:border-zinc-500/40 dark:bg-zinc-950/40 dark:text-zinc-100',
19
- title: 'Read-only historical record',
20
- body: () => "This is an immutable audit log. Rows cannot be created, edited, or deleted from the UI — they're written by the platform when events occur. Use Export to download for compliance review.",
21
- },
22
- 'better-auth': {
23
- icon: ShieldAlert,
24
- tone: 'border-amber-300/60 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-100',
25
- title: 'Managed by better-auth',
26
- body: (display) => `This object's schema is owned by ${display}. Direct edits here bypass password hashing, session validation, two-factor checks, and audit hooks — and may corrupt account state. Use the dedicated identity workflows instead (Invite User, Reset Password, Revoke Session, Rotate Key, …).`,
27
- },
28
- };
29
- export function ManagedByBanner({ managedBy, label, docHref, sourceRecordLabel }) {
30
- if (!managedBy || managedBy === 'platform')
31
- return null;
32
- const variant = VARIANTS[managedBy];
33
- if (!variant)
34
- return null;
35
- const display = label ?? managedBy;
36
- const Icon = variant.icon;
37
- return (_jsxs("div", { role: "note", "data-testid": "managed-by-banner", "data-bucket": managedBy, className: `flex items-start gap-3 border-b px-4 py-2.5 text-sm ${variant.tone}`, children: [_jsx(Icon, { className: "mt-0.5 h-4 w-4 flex-none" }), _jsxs("div", { className: "flex-1", children: [_jsxs("strong", { className: "font-semibold", children: [variant.title, "."] }), ' ', variant.body(display, sourceRecordLabel), docHref && (_jsxs(_Fragment, { children: [' ', _jsx("a", { href: docHref, target: "_blank", rel: "noreferrer", className: "underline underline-offset-2 hover:opacity-80", children: "Learn more \u2192" })] }))] })] }));
38
- }