@object-ui/app-shell 4.7.0 → 5.0.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.
@@ -8,11 +8,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  */
9
9
  import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
10
10
  import { useParams, useNavigate } from 'react-router-dom';
11
- import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
11
+ import { DetailView, RecordChatterPanel, buildDefaultPageSchema } 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, usePageAssignment, RecordContextProvider, SchemaRenderer } from '@object-ui/react';
15
+ import { ActionProvider, useObjectTranslation, useObjectLabel, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider } from '@object-ui/react';
16
16
  import { buildExpandFields } from '@object-ui/core';
17
17
  import { toast } from 'sonner';
18
18
  import { Database, Users } from 'lucide-react';
@@ -20,6 +20,7 @@ import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
20
20
  import { SkeletonDetail } from '../skeletons';
21
21
  import { ManagedByBadge } from '../components/ManagedByBadge';
22
22
  import { resolveCrudAffordances } from '../utils/crudAffordances';
23
+ import { hasExplicitDiscussion } from '../utils/pageSchemaIntrospect';
23
24
  import { ActionConfirmDialog } from './ActionConfirmDialog';
24
25
  import { ActionParamDialog } from './ActionParamDialog';
25
26
  import { resolveActionParams } from '../utils/resolveActionParams';
@@ -79,11 +80,50 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
79
80
  // it via SchemaRenderer (which dispatches to the registered 'record'
80
81
  // PageRenderer in @object-ui/components). Otherwise we fall through to
81
82
  // the legacy auto-generated DetailView path below.
82
- const { page: assignedPage } = usePageAssignment(objectName);
83
+ //
84
+ // Track 3 Phase G slice 6 — `renderViaSchema` is now default-on. The
85
+ // no-assignedPage branch synthesizes a canonical Page via
86
+ // `buildDefaultPageSchema(objectDef)` so the default detail page rides
87
+ // the same SchemaRenderer pipeline as custom pages. Kill-switches:
88
+ // 1) URL query param `?renderViaSchema=0` (per-request fallback to
89
+ // the legacy DetailView monolith — useful for debugging regressions)
90
+ // 2) `objectDef.detail?.renderViaSchema === false` (per-object opt-out)
91
+ const { page: assignedPage, slots: assignedSlots } = usePageAssignment(objectName);
92
+ const renderViaSchemaFlag = useMemo(() => {
93
+ if (typeof window !== 'undefined') {
94
+ try {
95
+ const qp = new URLSearchParams(window.location.search).get('renderViaSchema');
96
+ if (qp === '0' || qp === 'false')
97
+ return false;
98
+ if (qp === '1' || qp === 'true')
99
+ return true;
100
+ }
101
+ catch { }
102
+ }
103
+ if (objectDef?.detail?.renderViaSchema === false)
104
+ return false;
105
+ return true;
106
+ }, [objectDef]);
107
+ const synthesizedPage = useMemo(() => {
108
+ // Synthesizer drives two cases:
109
+ // 1) no assignedPage at all → pure default detail page
110
+ // 2) assignedSlots (slotted page) → synth with slot overrides
111
+ // In either case the page-record load effect below only needs
112
+ // "is there a page?"; the fully-detailed schema is rebuilt at
113
+ // render time once `detailSchema.sections` are known.
114
+ if (assignedPage)
115
+ return null;
116
+ if (!objectDef)
117
+ return null;
118
+ if (!renderViaSchemaFlag && !assignedSlots)
119
+ return null;
120
+ return buildDefaultPageSchema(objectDef, assignedSlots ? { slots: assignedSlots } : undefined);
121
+ }, [renderViaSchemaFlag, assignedPage, assignedSlots, objectDef]);
122
+ const effectivePage = assignedPage || synthesizedPage;
83
123
  const [pageRecord, setPageRecord] = useState(null);
84
124
  useEffect(() => {
85
125
  let cancelled = false;
86
- if (!assignedPage || !pureRecordId || !objectName || !dataSource?.findOne) {
126
+ if (!effectivePage || !pureRecordId || !objectName || !dataSource?.findOne) {
87
127
  setPageRecord(null);
88
128
  return;
89
129
  }
@@ -107,7 +147,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
107
147
  return () => {
108
148
  cancelled = true;
109
149
  };
110
- }, [assignedPage, objectName, pureRecordId, dataSource, objectDef]);
150
+ }, [effectivePage, objectName, pureRecordId, dataSource, objectDef]);
111
151
  // ─── Action Provider Handlers ───────────────────────────────────────
112
152
  // Confirm dialog state (promise-based)
113
153
  const [confirmState, setConfirmState] = useState({ open: false, message: '' });
@@ -1006,8 +1046,158 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1006
1046
  if (!objectDef) {
1007
1047
  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 }) })] }) }));
1008
1048
  }
1009
- if (assignedPage) {
1010
- return (_jsx(RecordContextProvider, { objectName: objectName, recordId: pureRecordId, data: pageRecord, objectSchema: objectDef, dataSource: dataSource, embedded: embedded, 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 }) }) }) }));
1049
+ if (effectivePage) {
1050
+ const disableDiscussion = effectivePage?.disableDiscussion === true;
1051
+ // When the page schema embeds an explicit `record:discussion` /
1052
+ // `record:chatter` slot, skip the bottom auto-append so the
1053
+ // author placement (or synth default) wins. The walker recurses
1054
+ // into `regions[]` so `buildDefaultPageSchema` output and
1055
+ // full-Lightning authored pages are both detected.
1056
+ const hasDiscussion = hasExplicitDiscussion(effectivePage);
1057
+ const showAutoDiscussion = !disableDiscussion && !hasDiscussion;
1058
+ // Slice 2 — when we're synthesizing (no author assignedPage), rebuild
1059
+ // the schema with the actual detailSchema.sections + highlight fields
1060
+ // so record:details renders the same field layout the legacy
1061
+ // DetailView would have produced.
1062
+ // Slice 4 — also forward header actions, related lists, activities,
1063
+ // and history so the synthesized page reaches parity with the
1064
+ // monolithic DetailView (tabs strip + record_header quick actions).
1065
+ // Business / custom actions authored on objectDef and routed to the
1066
+ // record_header location (e.g. Lead.convert, Contact.set_primary).
1067
+ const synthBusinessActions = (() => {
1068
+ const acts = detailSchema.actions;
1069
+ if (!Array.isArray(acts))
1070
+ return [];
1071
+ // detailSchema wraps actions in a `{type:'action:bar', actions:[]}`
1072
+ // shape; unwrap to the flat ActionDef[] the renderer expects.
1073
+ const bar = acts.find((a) => Array.isArray(a?.actions));
1074
+ const flat = bar?.actions ?? acts;
1075
+ return Array.isArray(flat) ? flat : [];
1076
+ })();
1077
+ // System actions (Edit / Share / Delete) — the legacy DetailView
1078
+ // monolith always synthesized these. The synth-path replacement
1079
+ // (Phase G slice 6) initially dropped them, leaving objects without
1080
+ // authored record_header actions with a bare header. Re-inject here
1081
+ // so every record page surfaces the basic affordances.
1082
+ const synthSystemActions = (() => {
1083
+ const affordances = resolveCrudAffordances(objectDef);
1084
+ const items = [];
1085
+ if (affordances.edit) {
1086
+ items.push({
1087
+ name: 'sys_edit',
1088
+ label: t('detail.edit', { defaultValue: 'Edit' }),
1089
+ type: 'script',
1090
+ locations: ['record_header'],
1091
+ variant: 'default',
1092
+ onClick: () => onEdit({ id: pureRecordId }),
1093
+ });
1094
+ }
1095
+ items.push({
1096
+ name: 'sys_share',
1097
+ label: t('detail.share', { defaultValue: 'Share' }),
1098
+ type: 'script',
1099
+ locations: ['record_header'],
1100
+ variant: 'outline',
1101
+ onClick: async () => {
1102
+ try {
1103
+ if (navigator.share) {
1104
+ await navigator.share({
1105
+ title: document.title,
1106
+ url: window.location.href,
1107
+ });
1108
+ }
1109
+ else {
1110
+ await navigator.clipboard.writeText(window.location.href);
1111
+ toast.success(t('detail.linkCopied', { defaultValue: 'Link copied' }));
1112
+ }
1113
+ }
1114
+ catch {
1115
+ // user dismissed share sheet — no-op
1116
+ }
1117
+ },
1118
+ });
1119
+ if (affordances.delete) {
1120
+ items.push({
1121
+ name: 'sys_delete',
1122
+ label: t('detail.delete', { defaultValue: 'Delete' }),
1123
+ type: 'script',
1124
+ locations: ['record_header'],
1125
+ variant: 'outline',
1126
+ onClick: async () => {
1127
+ const msg = t('detail.deleteConfirmation', {
1128
+ defaultValue: 'Are you sure you want to delete this record?',
1129
+ });
1130
+ if (!window.confirm(msg))
1131
+ return;
1132
+ try {
1133
+ await dataSource.delete(objectName, pureRecordId);
1134
+ toast.success(t('detail.deleted', { defaultValue: 'Record deleted' }));
1135
+ const baseAppUrl = appName ? `/apps/${appName}` : '';
1136
+ navigate(`${baseAppUrl}/${objectName}`, { replace: true });
1137
+ }
1138
+ catch (err) {
1139
+ toast.error(err?.message || 'Delete failed');
1140
+ }
1141
+ },
1142
+ });
1143
+ }
1144
+ return items;
1145
+ })();
1146
+ // The synth path now hands ONLY business actions to the page schema.
1147
+ // System actions (Edit / Share / Delete) ride through
1148
+ // `RecordContext.headerSystemActions` instead, so they reach both
1149
+ // synth/slotted pages AND authored full-Lightning pages without
1150
+ // mutating the assignedPage tree. `PageHeaderRenderer` dedupes by
1151
+ // name so authored business actions still win on collision.
1152
+ const synthHeaderActions = synthBusinessActions.length > 0 ? synthBusinessActions : undefined;
1153
+ const synthRelated = Array.isArray(detailSchema.related)
1154
+ ? detailSchema.related
1155
+ .filter((r) => r?.api && r?.referenceField)
1156
+ .map((r) => ({
1157
+ title: r.title,
1158
+ objectName: r.api,
1159
+ relationshipField: r.referenceField,
1160
+ ...(Array.isArray(r.columns) ? { columns: r.columns } : {}),
1161
+ ...(typeof r.pageSize === 'number' ? { limit: r.pageSize } : {}),
1162
+ ...(r.icon ? { icon: r.icon } : {}),
1163
+ }))
1164
+ : undefined;
1165
+ const synthHistory = detailSchema.history
1166
+ ? {
1167
+ entries: detailSchema.history.entries ?? [],
1168
+ loading: !!detailSchema.history.loading,
1169
+ emptyText: detailSchema.history.emptyText,
1170
+ }
1171
+ : undefined;
1172
+ const renderedPage = assignedPage
1173
+ ? effectivePage
1174
+ : buildDefaultPageSchema(objectDef, {
1175
+ sections: detailSchema.sections,
1176
+ highlightFields: Array.isArray(detailSchema.highlightFields)
1177
+ ? detailSchema.highlightFields
1178
+ .map((f) => (typeof f === 'string' ? f : f?.name))
1179
+ .filter((n) => !!n)
1180
+ : undefined,
1181
+ headerActions: synthHeaderActions,
1182
+ related: synthRelated,
1183
+ history: synthHistory,
1184
+ ...(assignedSlots ? { slots: assignedSlots } : {}),
1185
+ });
1186
+ 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 })] }))] }), _jsx(RecordContextProvider, { objectName: objectName, recordId: pureRecordId, data: pageRecord, objectSchema: objectDef, dataSource: dataSource, embedded: embedded, headerSystemActions: synthSystemActions, children: _jsx(HighlightFieldsProvider, { children: _jsx(DiscussionContextProvider, { items: feedItems, onAddComment: handleAddComment, onAddReply: handleAddReply, onToggleReaction: handleToggleReaction, 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: _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsxs("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: [_jsx(SchemaRenderer, { schema: renderedPage }), showAutoDiscussion && (_jsx("div", { className: "mt-6", children: _jsx(RecordChatterPanel, { config: {
1187
+ position: 'bottom',
1188
+ collapsible: false,
1189
+ feed: {
1190
+ enableReactions: true,
1191
+ enableThreading: true,
1192
+ showCommentInput: true,
1193
+ },
1194
+ }, items: feedItems, onAddComment: handleAddComment, onAddReply: handleAddReply, onToggleReaction: handleToggleReaction }) }))] }), _jsx(MetadataPanel, { open: showDebug, sections: [{ title: 'Page Schema', data: renderedPage }] })] }) }) }) }) }), _jsx(ActionConfirmDialog, { state: confirmState, onOpenChange: (open) => {
1195
+ if (!open)
1196
+ setConfirmState(s => ({ ...s, open: false }));
1197
+ } }), _jsx(ActionParamDialog, { state: paramState, onOpenChange: (open) => {
1198
+ if (!open)
1199
+ setParamState(s => ({ ...s, open: false }));
1200
+ } })] }));
1011
1201
  }
1012
1202
  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) => {
1013
1203
  if (!record || typeof record !== 'object')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/app-shell",
3
- "version": "4.7.0",
3
+ "version": "5.0.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.7.0",
31
- "@object-ui/collaboration": "4.7.0",
32
- "@object-ui/components": "4.7.0",
33
- "@object-ui/core": "4.7.0",
34
- "@object-ui/data-objectstack": "4.7.0",
35
- "@object-ui/fields": "4.7.0",
36
- "@object-ui/i18n": "4.7.0",
37
- "@object-ui/layout": "4.7.0",
38
- "@object-ui/permissions": "4.7.0",
39
- "@object-ui/react": "4.7.0",
40
- "@object-ui/types": "4.7.0"
30
+ "@object-ui/auth": "5.0.0",
31
+ "@object-ui/collaboration": "5.0.0",
32
+ "@object-ui/components": "5.0.0",
33
+ "@object-ui/core": "5.0.0",
34
+ "@object-ui/data-objectstack": "5.0.0",
35
+ "@object-ui/fields": "5.0.0",
36
+ "@object-ui/i18n": "5.0.0",
37
+ "@object-ui/layout": "5.0.0",
38
+ "@object-ui/permissions": "5.0.0",
39
+ "@object-ui/react": "5.0.0",
40
+ "@object-ui/types": "5.0.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.7.0",
47
- "@object-ui/plugin-charts": "^4.7.0",
48
- "@object-ui/plugin-chatbot": "^4.7.0",
49
- "@object-ui/plugin-dashboard": "^4.7.0",
50
- "@object-ui/plugin-designer": "^4.7.0",
51
- "@object-ui/plugin-detail": "^4.7.0",
52
- "@object-ui/plugin-form": "^4.7.0",
53
- "@object-ui/plugin-grid": "^4.7.0",
54
- "@object-ui/plugin-kanban": "^4.7.0",
55
- "@object-ui/plugin-list": "^4.7.0",
56
- "@object-ui/plugin-report": "^4.7.0",
57
- "@object-ui/plugin-view": "^4.7.0"
46
+ "@object-ui/plugin-calendar": "^5.0.0",
47
+ "@object-ui/plugin-charts": "^5.0.0",
48
+ "@object-ui/plugin-chatbot": "^5.0.0",
49
+ "@object-ui/plugin-dashboard": "^5.0.0",
50
+ "@object-ui/plugin-designer": "^5.0.0",
51
+ "@object-ui/plugin-detail": "^5.0.0",
52
+ "@object-ui/plugin-form": "^5.0.0",
53
+ "@object-ui/plugin-grid": "^5.0.0",
54
+ "@object-ui/plugin-kanban": "^5.0.0",
55
+ "@object-ui/plugin-list": "^5.0.0",
56
+ "@object-ui/plugin-report": "^5.0.0",
57
+ "@object-ui/plugin-view": "^5.0.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^25.9.0",