@object-ui/app-shell 4.0.12 → 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 +57 -0
- package/dist/components/ManagedByBanner.d.ts +16 -0
- package/dist/components/ManagedByBanner.js +38 -0
- package/dist/layout/AppHeader.js +122 -15
- package/dist/utils/crudAffordances.d.ts +41 -0
- package/dist/utils/crudAffordances.js +35 -0
- package/dist/views/ObjectView.js +49 -4
- package/dist/views/RecordDetailView.js +10 -2
- package/dist/views/RecordFormPage.js +2 -1
- package/dist/views/ReportView.js +16 -1
- package/package.json +24 -24
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
# @object-ui/app-shell — Changelog
|
|
2
2
|
|
|
3
|
+
## 4.2.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 47c27c7: fix
|
|
8
|
+
- @object-ui/types@4.2.1
|
|
9
|
+
- @object-ui/core@4.2.1
|
|
10
|
+
- @object-ui/i18n@4.2.1
|
|
11
|
+
- @object-ui/react@4.2.1
|
|
12
|
+
- @object-ui/components@4.2.1
|
|
13
|
+
- @object-ui/fields@4.2.1
|
|
14
|
+
- @object-ui/layout@4.2.1
|
|
15
|
+
- @object-ui/data-objectstack@4.2.1
|
|
16
|
+
- @object-ui/auth@4.2.1
|
|
17
|
+
- @object-ui/permissions@4.2.1
|
|
18
|
+
- @object-ui/collaboration@4.2.1
|
|
19
|
+
|
|
20
|
+
## 4.2.0
|
|
21
|
+
|
|
22
|
+
### Patch Changes
|
|
23
|
+
|
|
24
|
+
- 786de60: ReportView no longer caps the report content at `max-w-5xl` (1024px). Reports now use the full available content width, matching DashboardView behavior. Matrix and grid reports in particular benefit from the additional horizontal real estate.
|
|
25
|
+
- Updated dependencies [eb738bd]
|
|
26
|
+
- Updated dependencies [650392e]
|
|
27
|
+
- Updated dependencies [84b4bf1]
|
|
28
|
+
- @object-ui/i18n@4.2.0
|
|
29
|
+
- @object-ui/components@4.2.0
|
|
30
|
+
- @object-ui/fields@4.2.0
|
|
31
|
+
- @object-ui/react@4.2.0
|
|
32
|
+
- @object-ui/layout@4.2.0
|
|
33
|
+
- @object-ui/types@4.2.0
|
|
34
|
+
- @object-ui/core@4.2.0
|
|
35
|
+
- @object-ui/data-objectstack@4.2.0
|
|
36
|
+
- @object-ui/auth@4.2.0
|
|
37
|
+
- @object-ui/permissions@4.2.0
|
|
38
|
+
- @object-ui/collaboration@4.2.0
|
|
39
|
+
|
|
40
|
+
## 4.1.0
|
|
41
|
+
|
|
42
|
+
### Patch Changes
|
|
43
|
+
|
|
44
|
+
- b4ce9e2: Fix summary reports: render chart + KPIs, correct empty-table on server-aggregated data.
|
|
45
|
+
- `plugin-report`: `SpecReportGrid` now renders a KPI strip (per aggregating column) and a chart section above the grid for `summary` reports. KPI section auto-hides when no aggregating columns. New `buildChartData()` adapter buckets aggregated `ReportRow[]` to chart-ready data, auto-sorts pie/funnel descending, and falls back to row count when the chart `yAxis` points at a non-numeric column. When the data is server-aggregated, the grid switches columns to `[groupings, ${field}__${agg}]` so cells aren't empty against a raw-row column schema.
|
|
46
|
+
- `plugin-charts`: register `'column'` as an alias of `'bar'` in `ChartRenderer` / `AdvancedChartImpl` (Recharts only has `BarChart`).
|
|
47
|
+
- `app-shell`: `ReportView` now routes any object-backed report (matrix/joined/summary/tabular/columns/groupingsAcross) through the spec `ReportRenderer`; fully-legacy `fields`+`data` schemas still use `ReportViewer`.
|
|
48
|
+
- @object-ui/types@4.1.0
|
|
49
|
+
- @object-ui/core@4.1.0
|
|
50
|
+
- @object-ui/i18n@4.1.0
|
|
51
|
+
- @object-ui/react@4.1.0
|
|
52
|
+
- @object-ui/components@4.1.0
|
|
53
|
+
- @object-ui/fields@4.1.0
|
|
54
|
+
- @object-ui/layout@4.1.0
|
|
55
|
+
- @object-ui/data-objectstack@4.1.0
|
|
56
|
+
- @object-ui/auth@4.1.0
|
|
57
|
+
- @object-ui/permissions@4.1.0
|
|
58
|
+
- @object-ui/collaboration@4.1.0
|
|
59
|
+
|
|
3
60
|
## 4.0.12
|
|
4
61
|
|
|
5
62
|
### Patch Changes
|
|
@@ -0,0 +1,16 @@
|
|
|
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;
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
}
|
package/dist/layout/AppHeader.js
CHANGED
|
@@ -19,7 +19,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
19
19
|
*/
|
|
20
20
|
import { useLocation, useParams, Link, useNavigate } from 'react-router-dom';
|
|
21
21
|
import { SidebarTrigger, Button, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuGroup, Avatar, AvatarImage, AvatarFallback, } from '@object-ui/components';
|
|
22
|
-
import { Search, HelpCircle, ChevronDown, Settings, LogOut, User as UserIcon, Boxes, Bell, } from 'lucide-react';
|
|
22
|
+
import { Search, HelpCircle, ChevronDown, Settings, LogOut, User as UserIcon, Boxes, Bell, CheckSquare, } from 'lucide-react';
|
|
23
23
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
24
24
|
import { useOffline } from '@object-ui/react';
|
|
25
25
|
import { PresenceAvatars } from '@object-ui/collaboration';
|
|
@@ -66,6 +66,9 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
|
|
|
66
66
|
const presenceUnavailableRef = useRef(false);
|
|
67
67
|
const activityUnavailableRef = useRef(false);
|
|
68
68
|
const notificationsUnavailableRef = useRef(false);
|
|
69
|
+
/** M11.C15: pending approvals count for the topbar shortcut. */
|
|
70
|
+
const [pendingApprovalsCount, setPendingApprovalsCount] = useState(0);
|
|
71
|
+
const approvalsUnavailableRef = useRef(false);
|
|
69
72
|
const fetchPresenceAndActivities = useCallback(async () => {
|
|
70
73
|
if (!dataSource || !isApp)
|
|
71
74
|
return;
|
|
@@ -107,10 +110,22 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
|
|
|
107
110
|
}, [dataSource, isApp]);
|
|
108
111
|
useEffect(() => { fetchPresenceAndActivities(); }, [fetchPresenceAndActivities]);
|
|
109
112
|
/**
|
|
110
|
-
* M10.8: poll sys_notification for the signed-in user.
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
113
|
+
* M10.8 + M11.B3: poll sys_notification for the signed-in user.
|
|
114
|
+
*
|
|
115
|
+
* - Limited to the 20 most-recent entries; unread count drives the bell badge.
|
|
116
|
+
* - Adaptive interval: 10s while the tab is foregrounded (was 30s) so the
|
|
117
|
+
* bell reflects mentions / assignments within seconds without requiring a
|
|
118
|
+
* server-push transport.
|
|
119
|
+
* - Immediate refetch on `visibilitychange` when the user returns to the
|
|
120
|
+
* tab, so a backgrounded tab catches up the moment it regains focus.
|
|
121
|
+
* - On transient errors, switches to exponential backoff (cap 2 min) and
|
|
122
|
+
* resets on the first successful fetch.
|
|
123
|
+
* - Tolerates 404 so deployments without sys_notification degrade silently.
|
|
124
|
+
*
|
|
125
|
+
* Full server-push (SSE / WebSocket) is tracked separately as a M12
|
|
126
|
+
* enhancement; this adaptive poll already reduces perceived latency from
|
|
127
|
+
* ~15s (average half of the old 30s window) to ~5s and is sufficient for
|
|
128
|
+
* pilots up to ~50 concurrent users.
|
|
114
129
|
*/
|
|
115
130
|
useEffect(() => {
|
|
116
131
|
if (!dataSource || !isApp || !user?.id)
|
|
@@ -118,6 +133,11 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
|
|
|
118
133
|
if (notificationsUnavailableRef.current)
|
|
119
134
|
return;
|
|
120
135
|
let cancelled = false;
|
|
136
|
+
let timer = null;
|
|
137
|
+
const ACTIVE_INTERVAL_MS = 10000;
|
|
138
|
+
const HIDDEN_INTERVAL_MS = 60000;
|
|
139
|
+
const MAX_BACKOFF_MS = 120000;
|
|
140
|
+
let backoffMs = ACTIVE_INTERVAL_MS;
|
|
121
141
|
const isMissingResource = (err) => err?.httpStatus === 404 || err?.status === 404 || err?.code === 'object_not_found';
|
|
122
142
|
const fetchOnce = async () => {
|
|
123
143
|
try {
|
|
@@ -130,24 +150,108 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
|
|
|
130
150
|
return;
|
|
131
151
|
if (Array.isArray(res?.data))
|
|
132
152
|
setNotifications(res.data);
|
|
153
|
+
backoffMs = ACTIVE_INTERVAL_MS;
|
|
133
154
|
}
|
|
134
155
|
catch (err) {
|
|
135
|
-
if (isMissingResource(err))
|
|
156
|
+
if (isMissingResource(err)) {
|
|
136
157
|
notificationsUnavailableRef.current = true;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
|
137
161
|
}
|
|
138
162
|
};
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (typeof document !== 'undefined' && document.hidden)
|
|
163
|
+
const scheduleNext = () => {
|
|
164
|
+
if (cancelled || notificationsUnavailableRef.current)
|
|
142
165
|
return;
|
|
143
|
-
|
|
144
|
-
|
|
166
|
+
const hidden = typeof document !== 'undefined' && document.hidden;
|
|
167
|
+
const delay = hidden ? HIDDEN_INTERVAL_MS : backoffMs;
|
|
168
|
+
timer = setTimeout(async () => {
|
|
169
|
+
await fetchOnce();
|
|
170
|
+
scheduleNext();
|
|
171
|
+
}, delay);
|
|
172
|
+
};
|
|
173
|
+
const onVisibilityChange = () => {
|
|
174
|
+
if (cancelled)
|
|
175
|
+
return;
|
|
176
|
+
if (typeof document === 'undefined' || document.hidden)
|
|
145
177
|
return;
|
|
178
|
+
if (timer) {
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
timer = null;
|
|
181
|
+
}
|
|
182
|
+
backoffMs = ACTIVE_INTERVAL_MS;
|
|
183
|
+
fetchOnce().finally(scheduleNext);
|
|
184
|
+
};
|
|
185
|
+
fetchOnce().finally(scheduleNext);
|
|
186
|
+
if (typeof document !== 'undefined') {
|
|
187
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
188
|
+
}
|
|
189
|
+
return () => {
|
|
190
|
+
cancelled = true;
|
|
191
|
+
if (timer)
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
if (typeof document !== 'undefined') {
|
|
194
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
146
195
|
}
|
|
147
|
-
|
|
148
|
-
}, 30000);
|
|
149
|
-
return () => { cancelled = true; clearInterval(handle); };
|
|
196
|
+
};
|
|
150
197
|
}, [dataSource, isApp, user?.id]);
|
|
198
|
+
/**
|
|
199
|
+
* M11.C15: poll pending-approvals count for the topbar shortcut badge.
|
|
200
|
+
* Hits the framework's `/api/v1/approvals/requests?status=pending`
|
|
201
|
+
* endpoint with the user's identities (id, email, role:<r>). Degrades
|
|
202
|
+
* silently to zero on 404 (approvals plugin not installed).
|
|
203
|
+
*/
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (!isApp || !user?.id)
|
|
206
|
+
return;
|
|
207
|
+
if (approvalsUnavailableRef.current)
|
|
208
|
+
return;
|
|
209
|
+
const serverUrl = (import.meta.env?.VITE_SERVER_URL || '').replace(/\/$/, '');
|
|
210
|
+
const base = `${serverUrl}/api/v1/approvals/requests`;
|
|
211
|
+
const identities = [];
|
|
212
|
+
if (user.id)
|
|
213
|
+
identities.push(user.id);
|
|
214
|
+
if (user.email)
|
|
215
|
+
identities.push(user.email);
|
|
216
|
+
for (const r of (user.roles || [])) {
|
|
217
|
+
if (r)
|
|
218
|
+
identities.push(`role:${r}`);
|
|
219
|
+
}
|
|
220
|
+
let cancelled = false;
|
|
221
|
+
let timer = null;
|
|
222
|
+
const POLL_MS = 30000;
|
|
223
|
+
const fetchOnce = async () => {
|
|
224
|
+
try {
|
|
225
|
+
const seen = new Set();
|
|
226
|
+
if (identities.length === 0)
|
|
227
|
+
return;
|
|
228
|
+
for (const id of identities) {
|
|
229
|
+
const qs = new URLSearchParams({ status: 'pending', approverId: id });
|
|
230
|
+
const res = await fetch(`${base}?${qs}`, { credentials: 'include' });
|
|
231
|
+
if (res.status === 404) {
|
|
232
|
+
approvalsUnavailableRef.current = true;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (!res.ok)
|
|
236
|
+
return;
|
|
237
|
+
const payload = await res.json().catch(() => null);
|
|
238
|
+
for (const row of (payload?.data || []))
|
|
239
|
+
seen.add(row.id);
|
|
240
|
+
}
|
|
241
|
+
if (!cancelled)
|
|
242
|
+
setPendingApprovalsCount(seen.size);
|
|
243
|
+
}
|
|
244
|
+
catch { /* transient — keep last value */ }
|
|
245
|
+
};
|
|
246
|
+
const schedule = () => {
|
|
247
|
+
if (cancelled || approvalsUnavailableRef.current)
|
|
248
|
+
return;
|
|
249
|
+
timer = setTimeout(async () => { await fetchOnce(); schedule(); }, POLL_MS);
|
|
250
|
+
};
|
|
251
|
+
fetchOnce().finally(schedule);
|
|
252
|
+
return () => { cancelled = true; if (timer)
|
|
253
|
+
clearTimeout(timer); };
|
|
254
|
+
}, [isApp, user?.id]);
|
|
151
255
|
const unreadCount = notifications.reduce((n, x) => n + (x.is_read ? 0 : 1), 0);
|
|
152
256
|
const markNotificationRead = useCallback(async (id) => {
|
|
153
257
|
setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n));
|
|
@@ -271,7 +375,10 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
|
|
|
271
375
|
return (_jsxs("div", { className: "flex items-center justify-between w-full h-full", children: [_jsxs("div", { className: "flex items-center min-w-0 flex-1", children: [_jsx(Link, { to: "/home", className: "flex items-center justify-center h-7 w-7 shrink-0 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors", title: "ObjectStack", children: _jsx(Boxes, { className: "h-4 w-4" }) }), resolvedVariant === 'home' && (_jsx("span", { className: "hidden sm:inline ml-2 text-sm font-semibold tracking-tight", children: "ObjectStack" })), resolvedVariant === 'orgs' && (_jsxs(_Fragment, { children: [_jsx(PathSep, {}), _jsx("span", { className: "text-sm font-medium text-foreground/80 px-1.5", children: t('organizations.title', { defaultValue: 'Organizations' }) })] })), isApp && (_jsxs(_Fragment, { children: [_jsx(SidebarTrigger, { className: "md:hidden shrink-0 ml-1", "aria-label": t('common.toggleSidebar') || 'Toggle sidebar' }), activeAppName && onAppChange ? (_jsxs(_Fragment, { children: [_jsx(PathSep, {}), _jsx(AppSwitcher, { activeAppName: activeAppName, onAppChange: onAppChange })] })) : appName ? (_jsxs(_Fragment, { children: [_jsx(PathSep, {}), _jsx("span", { className: "text-sm font-medium text-foreground/80 px-1.5", children: appName })] })) : null, extraSegments.map((seg, i) => {
|
|
272
376
|
const isLast = i === extraSegments.length - 1;
|
|
273
377
|
return (_jsxs("span", { className: "hidden sm:flex items-center min-w-0", children: [_jsx(PathSep, {}), seg.siblings && seg.siblings.length > 1 ? (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { className: `flex items-center gap-1 rounded-md px-1.5 py-1 text-sm font-medium transition-colors outline-none hover:bg-accent hover:text-foreground ${!isLast ? 'text-foreground/60' : 'text-foreground/80'}`, children: [seg.label, _jsx(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground" })] }), _jsxs(DropdownMenuContent, { align: "start", sideOffset: 8, className: "w-56 max-h-72 overflow-y-auto", children: [_jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground font-normal", children: "Switch Object" }), _jsx(DropdownMenuSeparator, {}), seg.siblings.map((sibling) => (_jsx(DropdownMenuItem, { asChild: true, children: _jsx(Link, { to: sibling.href, className: "w-full", children: sibling.label }) }, sibling.href)))] })] })) : seg.href ? (_jsx(Link, { to: seg.href, className: `rounded-md px-1.5 py-1 text-sm font-medium transition-colors hover:bg-accent hover:text-foreground truncate max-w-[160px] ${isLast ? 'text-foreground/80' : 'text-foreground/60'}`, children: seg.label })) : (_jsx("span", { className: `px-1.5 py-1 text-sm font-medium truncate max-w-[160px] ${isLast ? 'text-foreground/80' : 'text-foreground/60'}`, children: seg.label }))] }, i));
|
|
274
|
-
}), _jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: lastSegmentLabel })] }))] }), _jsxs("div", { className: "flex items-center gap-0.5 sm:gap-1 shrink-0 [&>*+*[data-topbar-group]]:ml-1 [&>[data-topbar-group]+[data-topbar-group]]:border-l [&>[data-topbar-group]+[data-topbar-group]]:border-border/60 [&>[data-topbar-group]+[data-topbar-group]]:pl-1 sm:[&>[data-topbar-group]+[data-topbar-group]]:pl-2 sm:[&>[data-topbar-group]+[data-topbar-group]]:ml-2", children: [!isOnline && (_jsxs("div", { className: "flex items-center gap-1 px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-medium", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-yellow-500 animate-pulse" }), "Offline"] })), isApp && connectionState && _jsx(ConnectionStatus, { state: connectionState }), isApp && activeUsers.length > 0 && (_jsx("div", { className: "hidden md:flex items-center shrink-0", title: "Users currently online", children: _jsx(PresenceAvatars, { users: activeUsers, size: "sm", maxVisible: 3, showStatus: true }) })), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 sm:gap-1 shrink-0", children: [_jsxs("button", { onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), className: "hidden lg:flex relative items-center gap-2 w-48 xl:w-64 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors", children: [_jsx(Search, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 text-left text-xs", children: t('console.search', { defaultValue: 'Search...' }) }), _jsxs("kbd", { className: "pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground", children: [_jsx("span", { className: "text-xs", children: "\u2318" }), "K"] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden h-8 w-8 shrink-0", onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), "aria-label": t('console.search', { defaultValue: 'Search...' }), children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(ActivityFeed, { activities: activeActivities }) }),
|
|
378
|
+
}), _jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: lastSegmentLabel })] }))] }), _jsxs("div", { className: "flex items-center gap-0.5 sm:gap-1 shrink-0 [&>*+*[data-topbar-group]]:ml-1 [&>[data-topbar-group]+[data-topbar-group]]:border-l [&>[data-topbar-group]+[data-topbar-group]]:border-border/60 [&>[data-topbar-group]+[data-topbar-group]]:pl-1 sm:[&>[data-topbar-group]+[data-topbar-group]]:pl-2 sm:[&>[data-topbar-group]+[data-topbar-group]]:ml-2", children: [!isOnline && (_jsxs("div", { className: "flex items-center gap-1 px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-medium", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-yellow-500 animate-pulse" }), "Offline"] })), isApp && connectionState && _jsx(ConnectionStatus, { state: connectionState }), isApp && activeUsers.length > 0 && (_jsx("div", { className: "hidden md:flex items-center shrink-0", title: "Users currently online", children: _jsx(PresenceAvatars, { users: activeUsers, size: "sm", maxVisible: 3, showStatus: true }) })), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 sm:gap-1 shrink-0", children: [_jsxs("button", { onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), className: "hidden lg:flex relative items-center gap-2 w-48 xl:w-64 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors", children: [_jsx(Search, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 text-left text-xs", children: t('console.search', { defaultValue: 'Search...' }) }), _jsxs("kbd", { className: "pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground", children: [_jsx("span", { className: "text-xs", children: "\u2318" }), "K"] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden h-8 w-8 shrink-0", onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), "aria-label": t('console.search', { defaultValue: 'Search...' }), children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(ActivityFeed, { activities: activeActivities }) }), pendingApprovalsCount >= 0 && (_jsxs(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 relative shrink-0", "aria-label": t('sidebar.approvals', { defaultValue: 'Approvals' }), title: t('sidebar.approvals', { defaultValue: 'Approvals' }), onClick: () => {
|
|
379
|
+
const app = currentAppName ?? params.appName;
|
|
380
|
+
navigate(app ? `/apps/${app}/system/approvals` : '/apps/setup/system/approvals');
|
|
381
|
+
}, children: [_jsx(CheckSquare, { className: "h-4 w-4" }), pendingApprovalsCount > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 h-4 min-w-[16px] rounded-full bg-amber-500 text-[10px] leading-4 text-white text-center px-1", children: pendingApprovalsCount > 9 ? '9+' : pendingApprovalsCount }))] })), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 relative shrink-0", "aria-label": t('sidebar.notifications', { defaultValue: 'Notifications' }), children: [_jsx(Bell, { className: "h-4 w-4" }), unreadCount > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 h-4 min-w-[16px] rounded-full bg-red-500 text-[10px] leading-4 text-white text-center px-1", children: unreadCount > 9 ? '9+' : unreadCount }))] }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-80 max-h-96 overflow-auto", children: [_jsxs(DropdownMenuLabel, { className: "flex items-center justify-between", children: [_jsx("span", { children: t('sidebar.notifications', { defaultValue: 'Notifications' }) }), unreadCount > 0 && (_jsx("button", { type: "button", onClick: (e) => { e.preventDefault(); markAllRead(); }, className: "text-xs text-muted-foreground hover:text-foreground", children: t('notifications.markAllRead', { defaultValue: 'Mark all read' }) }))] }), _jsx(DropdownMenuSeparator, {}), notifications.length === 0 ? (_jsx("div", { className: "px-3 py-6 text-sm text-muted-foreground text-center", children: t('notifications.empty', { defaultValue: 'No notifications' }) })) : (notifications.map((n) => (_jsxs(DropdownMenuItem, { className: `flex flex-col items-start gap-1 ${n.is_read ? '' : 'bg-accent/40'}`, onSelect: () => {
|
|
275
382
|
markNotificationRead(n.id);
|
|
276
383
|
if (n.source_object && n.source_id) {
|
|
277
384
|
// Navigate to the related record; relative to /apps/<currentApp> if any.
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
export type ManagedByBucket = 'platform' | 'config' | 'system' | 'append-only' | 'better-auth';
|
|
18
|
+
export interface CrudAffordances {
|
|
19
|
+
/** Generic "New" button for single-record creation. */
|
|
20
|
+
create: boolean;
|
|
21
|
+
/** CSV bulk-import wizard. */
|
|
22
|
+
import: boolean;
|
|
23
|
+
/** Inline + form editing of existing rows. */
|
|
24
|
+
edit: boolean;
|
|
25
|
+
/** Row-level + bulk delete. */
|
|
26
|
+
delete: boolean;
|
|
27
|
+
/** CSV / clipboard export. */
|
|
28
|
+
exportCsv: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface UserActionsOverride {
|
|
31
|
+
create?: boolean;
|
|
32
|
+
import?: boolean;
|
|
33
|
+
edit?: boolean;
|
|
34
|
+
delete?: boolean;
|
|
35
|
+
exportCsv?: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface SchemaLike {
|
|
38
|
+
managedBy?: string | null;
|
|
39
|
+
userActions?: UserActionsOverride | null;
|
|
40
|
+
}
|
|
41
|
+
export declare function resolveCrudAffordances(obj: SchemaLike | null | undefined): CrudAffordances;
|
|
@@ -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) => ({
|
|
@@ -17,6 +17,8 @@ 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';
|
|
@@ -701,13 +703,19 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
|
|
|
701
703
|
onRowDelete,
|
|
702
704
|
};
|
|
703
705
|
});
|
|
706
|
+
const affordances = resolveCrudAffordances(objectDef);
|
|
704
707
|
return {
|
|
705
708
|
type: 'detail-view',
|
|
706
709
|
objectName: objectDef.name,
|
|
707
710
|
resourceId: pureRecordId,
|
|
708
711
|
showBack: true,
|
|
709
712
|
onBack: 'history',
|
|
710
|
-
|
|
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,
|
|
711
719
|
title: objectDef.label,
|
|
712
720
|
primaryField,
|
|
713
721
|
sections,
|
|
@@ -732,7 +740,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
|
|
|
732
740
|
if (!objectDef) {
|
|
733
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 }) })] }) }));
|
|
734
742
|
}
|
|
735
|
-
return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsx("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: t('recordDetail.viewersTooltip'), children: [_jsx(Users, { className: "h-3.5 w-3.5 text-muted-foreground" }), _jsx(PresenceAvatars, { users: recordViewers, size: "sm", maxVisible: 4, showStatus: true })] })) }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsx("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: _jsx(ActionProvider, { context: { record: {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), onDataLoaded: (record) => {
|
|
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) => {
|
|
736
744
|
if (!record || typeof record !== 'object')
|
|
737
745
|
return;
|
|
738
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/app-shell",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.1",
|
|
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.
|
|
31
|
-
"@object-ui/collaboration": "4.
|
|
32
|
-
"@object-ui/components": "4.
|
|
33
|
-
"@object-ui/core": "4.
|
|
34
|
-
"@object-ui/data-objectstack": "4.
|
|
35
|
-
"@object-ui/fields": "4.
|
|
36
|
-
"@object-ui/i18n": "4.
|
|
37
|
-
"@object-ui/layout": "4.
|
|
38
|
-
"@object-ui/permissions": "4.
|
|
39
|
-
"@object-ui/react": "4.
|
|
40
|
-
"@object-ui/types": "4.
|
|
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"
|
|
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.
|
|
47
|
-
"@object-ui/plugin-charts": "4.
|
|
48
|
-
"@object-ui/plugin-chatbot": "4.
|
|
49
|
-
"@object-ui/plugin-dashboard": "4.
|
|
50
|
-
"@object-ui/plugin-designer": "4.
|
|
51
|
-
"@object-ui/plugin-detail": "4.
|
|
52
|
-
"@object-ui/plugin-form": "4.
|
|
53
|
-
"@object-ui/plugin-grid": "4.
|
|
54
|
-
"@object-ui/plugin-kanban": "4.
|
|
55
|
-
"@object-ui/plugin-list": "4.
|
|
56
|
-
"@object-ui/plugin-report": "4.
|
|
57
|
-
"@object-ui/plugin-view": "4.
|
|
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"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^25.9.0",
|