@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.
- package/CHANGELOG.md +86 -0
- package/dist/components/ManagedByBanner.d.ts +16 -0
- package/dist/components/ManagedByBanner.js +38 -0
- package/dist/console/home/HomeLayout.js +1 -1
- package/dist/console/home/HomePage.js +3 -1
- package/dist/layout/AppHeader.js +187 -2
- package/dist/layout/AppSidebar.js +32 -14
- package/dist/layout/UnifiedSidebar.js +30 -13
- package/dist/utils/crudAffordances.d.ts +41 -0
- package/dist/utils/crudAffordances.js +35 -0
- package/dist/views/ObjectView.js +54 -4
- package/dist/views/RecordDetailView.js +223 -40
- package/dist/views/RecordFormPage.js +2 -1
- package/dist/views/ReportView.js +16 -1
- package/package.json +27 -27
|
@@ -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
|
+
}
|
package/dist/views/ObjectView.js
CHANGED
|
@@ -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
|
|
1290
|
-
const baseArr = Array.isArray(
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
243
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
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
|
-
|
|
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 || {})
|
|
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
|
-
|
|
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,
|
package/dist/views/ReportView.js
CHANGED
|
@@ -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: "
|
|
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
|
}
|