@object-ui/app-shell 4.0.12 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,107 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 4.3.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 079c3b2: feat(plugin-report): per-block field resolution for joined reports
8
+
9
+ Joined report blocks can override `objectName` to query a different
10
+ object than the container, but the editor was always offering the
11
+ container's fields — wrong field names, wrong types, broken granularity
12
+ and chart-axis filtering.
13
+
14
+ `ReportConfigPanel` now accepts an optional `getFieldsForObject`
15
+ resolver. `JoinedBlocksEditor` uses it to source fields for each
16
+ block based on `block.objectName ?? containerObjectName`, falling
17
+ back to the static `availableFields` when the resolver returns
18
+ `undefined` (unknown object).
19
+
20
+ `ReportView` wires the resolver against the app's loaded `objects`
21
+ list and reuses the same parsing path internally to derive its
22
+ top-level `availableFields`, removing the duplicated schema lookup.
23
+
24
+ 5 new RTL tests verify the resolver wiring, fallback behaviour,
25
+ add-block flow, and inline duplicate-name validation (111 plugin-report
26
+ tests green).
27
+
28
+ - 154a36c: fix
29
+ - fed4897: fix
30
+ - Updated dependencies [f196cf4]
31
+ - Updated dependencies [ee1cc96]
32
+ - Updated dependencies [0b032be]
33
+ - Updated dependencies [115d36a]
34
+ - Updated dependencies [4e7bc1b]
35
+ - Updated dependencies [8442c05]
36
+ - @object-ui/i18n@4.3.0
37
+ - @object-ui/components@4.3.0
38
+ - @object-ui/fields@4.3.0
39
+ - @object-ui/react@4.3.0
40
+ - @object-ui/layout@4.3.0
41
+ - @object-ui/types@4.3.0
42
+ - @object-ui/core@4.3.0
43
+ - @object-ui/data-objectstack@4.3.0
44
+ - @object-ui/auth@4.3.0
45
+ - @object-ui/permissions@4.3.0
46
+ - @object-ui/collaboration@4.3.0
47
+
48
+ ## 4.2.1
49
+
50
+ ### Patch Changes
51
+
52
+ - 47c27c7: fix
53
+ - @object-ui/types@4.2.1
54
+ - @object-ui/core@4.2.1
55
+ - @object-ui/i18n@4.2.1
56
+ - @object-ui/react@4.2.1
57
+ - @object-ui/components@4.2.1
58
+ - @object-ui/fields@4.2.1
59
+ - @object-ui/layout@4.2.1
60
+ - @object-ui/data-objectstack@4.2.1
61
+ - @object-ui/auth@4.2.1
62
+ - @object-ui/permissions@4.2.1
63
+ - @object-ui/collaboration@4.2.1
64
+
65
+ ## 4.2.0
66
+
67
+ ### Patch Changes
68
+
69
+ - 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.
70
+ - Updated dependencies [eb738bd]
71
+ - Updated dependencies [650392e]
72
+ - Updated dependencies [84b4bf1]
73
+ - @object-ui/i18n@4.2.0
74
+ - @object-ui/components@4.2.0
75
+ - @object-ui/fields@4.2.0
76
+ - @object-ui/react@4.2.0
77
+ - @object-ui/layout@4.2.0
78
+ - @object-ui/types@4.2.0
79
+ - @object-ui/core@4.2.0
80
+ - @object-ui/data-objectstack@4.2.0
81
+ - @object-ui/auth@4.2.0
82
+ - @object-ui/permissions@4.2.0
83
+ - @object-ui/collaboration@4.2.0
84
+
85
+ ## 4.1.0
86
+
87
+ ### Patch Changes
88
+
89
+ - b4ce9e2: Fix summary reports: render chart + KPIs, correct empty-table on server-aggregated data.
90
+ - `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.
91
+ - `plugin-charts`: register `'column'` as an alias of `'bar'` in `ChartRenderer` / `AdvancedChartImpl` (Recharts only has `BarChart`).
92
+ - `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`.
93
+ - @object-ui/types@4.1.0
94
+ - @object-ui/core@4.1.0
95
+ - @object-ui/i18n@4.1.0
96
+ - @object-ui/react@4.1.0
97
+ - @object-ui/components@4.1.0
98
+ - @object-ui/fields@4.1.0
99
+ - @object-ui/layout@4.1.0
100
+ - @object-ui/data-objectstack@4.1.0
101
+ - @object-ui/auth@4.1.0
102
+ - @object-ui/permissions@4.1.0
103
+ - @object-ui/collaboration@4.1.0
104
+
3
105
  ## 4.0.12
4
106
 
5
107
  ### Patch Changes
@@ -0,0 +1,10 @@
1
+ export interface ManagedByBadgeProps {
2
+ /** The `managedBy` flag from the object schema. */
3
+ managedBy?: string;
4
+ /** Optional override for the human-readable system name shown in the tooltip. */
5
+ label?: string;
6
+ /** Optional extra classes. */
7
+ className?: string;
8
+ }
9
+ export declare function ManagedByBadge({ managedBy, label, className }: ManagedByBadgeProps): import("react/jsx-runtime").JSX.Element | null;
10
+ export default ManagedByBadge;
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ShieldAlert, Settings2, Lock, Archive } from 'lucide-react';
3
+ import { Badge, Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, cn, } from '@object-ui/components';
4
+ const VARIANTS = {
5
+ config: {
6
+ icon: Settings2,
7
+ short: 'Admin config',
8
+ title: 'Administrator configuration',
9
+ body: () => 'These rows define how the platform behaves at runtime. Author them here; the runtime data they produce lives in a separate table.',
10
+ tone: 'border-sky-300/60 bg-sky-50 text-sky-900 hover:bg-sky-100 dark:border-sky-500/40 dark:bg-sky-950/40 dark:text-sky-100',
11
+ },
12
+ system: {
13
+ icon: Lock,
14
+ short: 'System-managed',
15
+ title: 'Managed by the platform',
16
+ body: () => 'Rows here are created automatically when actions run on the source record. The list below is a read-only monitoring surface — row-level actions (Approve, Recall, Resend, …) live on each row.',
17
+ tone: 'border-slate-300/60 bg-slate-50 text-slate-900 hover:bg-slate-100 dark:border-slate-500/40 dark:bg-slate-950/40 dark:text-slate-100',
18
+ },
19
+ 'append-only': {
20
+ icon: Archive,
21
+ short: 'Read-only · Audit log',
22
+ title: 'Read-only historical record',
23
+ body: () => "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.",
24
+ tone: 'border-zinc-300/60 bg-zinc-50 text-zinc-900 hover:bg-zinc-100 dark:border-zinc-500/40 dark:bg-zinc-950/40 dark:text-zinc-100',
25
+ },
26
+ 'better-auth': {
27
+ icon: ShieldAlert,
28
+ short: 'Identity',
29
+ title: 'Managed by the identity provider',
30
+ body: (display) => `This object's schema is owned by ${display}. Direct edits bypass password hashing, session validation, two-factor checks, and audit hooks. Use the dedicated identity workflows instead (Invite User, Reset Password, Revoke Session, Rotate Key, …).`,
31
+ tone: 'border-amber-300/60 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-100',
32
+ },
33
+ };
34
+ export function ManagedByBadge({ managedBy, label, className }) {
35
+ if (!managedBy || managedBy === 'platform')
36
+ return null;
37
+ const variant = VARIANTS[managedBy];
38
+ if (!variant)
39
+ return null;
40
+ const Icon = variant.icon;
41
+ const display = label ?? 'better-auth';
42
+ return (_jsx(TooltipProvider, { delayDuration: 200, children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs(Badge, { variant: "outline", "data-testid": "managed-by-badge", "data-bucket": managedBy, className: cn('gap-1 font-normal text-[11px] leading-none py-0.5 px-1.5 cursor-help', variant.tone, className), children: [_jsx(Icon, { className: "h-3 w-3", "aria-hidden": "true" }), _jsx("span", { children: variant.short })] }) }), _jsxs(TooltipContent, { side: "bottom", align: "start", className: "max-w-xs text-xs leading-relaxed", children: [_jsx("p", { className: "font-semibold mb-1", children: variant.title }), _jsx("p", { className: "text-muted-foreground", children: variant.body(display) })] })] }) }));
43
+ }
44
+ export default ManagedByBadge;
@@ -9,7 +9,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  */
10
10
  import { useState } from 'react';
11
11
  import { Button, Badge, Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, } from '@object-ui/components';
12
- import { Bell, Plus, Pencil, Trash2, MessageSquare, Filter } from 'lucide-react';
12
+ import { Activity, Plus, Pencil, Trash2, MessageSquare, Filter } from 'lucide-react';
13
13
  import { useObjectTranslation } from '@object-ui/i18n';
14
14
  const typeConfig = {
15
15
  create: { icon: Plus, color: 'text-green-500' },
@@ -57,11 +57,11 @@ export function ActivityFeed({ activities = [], className }) {
57
57
  delete: t('layout.activityFeed.typeDelete'),
58
58
  comment: t('layout.activityFeed.typeComment'),
59
59
  };
60
- return (_jsxs(Sheet, { open: open, onOpenChange: setOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "icon", className: className ?? 'h-8 w-8', "aria-label": t('layout.activityFeed.ariaLabel'), children: [_jsx(Bell, { className: "h-4 w-4" }), activities.length > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-primary text-[9px] font-bold text-primary-foreground flex items-center justify-center", children: activities.length > 9 ? '9+' : activities.length }))] }) }), _jsxs(SheetContent, { side: "right", className: "w-80 sm:w-96", children: [_jsx(SheetHeader, { children: _jsxs(SheetTitle, { className: "flex items-center justify-between", children: [t('layout.activityFeed.title'), _jsxs(Button, { variant: showFilters ? 'secondary' : 'ghost', size: "sm", className: "h-7 px-2", onClick: () => setShowFilters(!showFilters), children: [_jsx(Filter, { className: "h-3.5 w-3.5 mr-1" }), t('layout.activityFeed.filter')] })] }) }), showFilters && (_jsx("div", { className: "flex flex-wrap gap-1.5 mt-3 px-1", children: Object.keys(typeConfig).map(type => {
60
+ return (_jsxs(Sheet, { open: open, onOpenChange: setOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "icon", className: className ?? 'h-8 w-8', "aria-label": t('layout.activityFeed.ariaLabel'), children: [_jsx(Activity, { className: "h-4 w-4" }), activities.length > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-primary text-[9px] font-bold text-primary-foreground flex items-center justify-center", children: activities.length > 9 ? '9+' : activities.length }))] }) }), _jsxs(SheetContent, { side: "right", className: "w-80 sm:w-96", children: [_jsx(SheetHeader, { children: _jsxs(SheetTitle, { className: "flex items-center justify-between", children: [t('layout.activityFeed.title'), _jsxs(Button, { variant: showFilters ? 'secondary' : 'ghost', size: "sm", className: "h-7 px-2", onClick: () => setShowFilters(!showFilters), children: [_jsx(Filter, { className: "h-3.5 w-3.5 mr-1" }), t('layout.activityFeed.filter')] })] }) }), showFilters && (_jsx("div", { className: "flex flex-wrap gap-1.5 mt-3 px-1", children: Object.keys(typeConfig).map(type => {
61
61
  const { icon: Icon, color } = typeConfig[type];
62
62
  const active = notificationPreferences[type];
63
63
  return (_jsxs(Badge, { variant: active ? 'default' : 'outline', className: "cursor-pointer select-none gap-1", onClick: () => togglePreference(type), children: [_jsx(Icon, { className: `h-3 w-3 ${active ? '' : color}` }), typeLabels[type]] }, type));
64
- }) })), filteredActivities.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground", children: [_jsx(Bell, { className: "h-8 w-8 opacity-40" }), _jsx("p", { className: "text-sm", children: t('layout.activityFeed.empty') })] })) : (_jsx("ul", { className: "mt-4 space-y-1 overflow-y-auto max-h-[calc(100vh-8rem)]", children: filteredActivities.map((item) => {
64
+ }) })), filteredActivities.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground", children: [_jsx(Activity, { className: "h-8 w-8 opacity-40" }), _jsx("p", { className: "text-sm", children: t('layout.activityFeed.empty') })] })) : (_jsx("ul", { className: "mt-4 space-y-1 overflow-y-auto max-h-[calc(100vh-8rem)]", children: filteredActivities.map((item) => {
65
65
  const { icon: Icon, color } = typeConfig[item.type];
66
66
  return (_jsxs("li", { className: "flex items-start gap-3 rounded-md px-2 py-2 hover:bg-muted/50 transition-colors", children: [_jsx("span", { className: `mt-0.5 shrink-0 ${color}`, children: _jsx(Icon, { className: "h-4 w-4" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "text-sm leading-snug", children: item.description }), _jsxs("p", { className: "mt-0.5 text-xs text-muted-foreground", children: [item.user, " \u00B7 ", formatRelativeTime(item.timestamp, t)] })] })] }, item.id));
67
67
  }) }))] })] }));
@@ -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. Limited to
111
- * the 20 most-recent entries; unread count drives the bell badge.
112
- * Polls every 30s while the tab is foregrounded. Tolerates 404 so
113
- * older deployments without sys_notification degrade silently.
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
- fetchOnce();
140
- const handle = setInterval(() => {
141
- if (typeof document !== 'undefined' && document.hidden)
163
+ const scheduleNext = () => {
164
+ if (cancelled || notificationsUnavailableRef.current)
142
165
  return;
143
- if (notificationsUnavailableRef.current) {
144
- clearInterval(handle);
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
- fetchOnce();
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 }) }), _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: () => {
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
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Derive a context-aware empty-state config for object lists whose
3
+ * `managedBy` bucket means the user can't create rows directly from
4
+ * this list view.
5
+ *
6
+ * Background: previously the entire affordance story was carried by a
7
+ * full-width banner pinned to the top of every list/detail/form page.
8
+ * That violated the principle most enterprise platforms (Salesforce,
9
+ * ServiceNow, SAP Fiori, Notion, Linear) settled on long ago: hide the
10
+ * affordance you don't want users to take, and use the *empty state* as
11
+ * the only place to explain why the list is empty and where new entries
12
+ * come from.
13
+ *
14
+ * This helper returns an `emptyState` payload compatible with the
15
+ * `ListView` schema (`{ title, message, icon }`). It only fires for the
16
+ * buckets where the default empty state ("No records yet") would be
17
+ * misleading; for `platform`/`config` it returns `undefined` so the
18
+ * caller falls back to the user-defined view-level empty state.
19
+ *
20
+ * The bucket → message mapping mirrors `ManagedByBadge` so that the badge
21
+ * (in the header) and the empty state (in the body) tell a consistent
22
+ * story without repeating themselves verbatim.
23
+ */
24
+ export interface ManagedByEmptyState {
25
+ title: string;
26
+ message: string;
27
+ icon: string;
28
+ }
29
+ export declare function resolveManagedByEmptyState(managedBy: string | undefined | null): ManagedByEmptyState | undefined;
@@ -0,0 +1,24 @@
1
+ export function resolveManagedByEmptyState(managedBy) {
2
+ switch (managedBy) {
3
+ case 'system':
4
+ return {
5
+ icon: 'Lock',
6
+ title: 'Nothing here yet',
7
+ message: 'Entries appear automatically when their source action runs (e.g. Submit for Approval, Share, Invite). Trigger one of those on a source record to create a row.',
8
+ };
9
+ case 'append-only':
10
+ return {
11
+ icon: 'Archive',
12
+ title: 'No events recorded',
13
+ message: 'This is an immutable audit log. Rows are written by the platform when events occur — you can export the history but cannot create entries from here.',
14
+ };
15
+ case 'better-auth':
16
+ return {
17
+ icon: 'ShieldAlert',
18
+ title: 'No identity records',
19
+ message: 'Identity rows are managed by the authentication provider. Use the dedicated identity workflows (Invite User, Reset Password, …) to create new entries.',
20
+ };
21
+ default:
22
+ return undefined;
23
+ }
24
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * resolveActionParams — Resolves field-backed action parameters against
3
+ * runtime object metadata.
4
+ *
5
+ * Action params (`packages/spec/src/ui/action.zod.ts ActionParamSchema`) may
6
+ * either be declared inline (`{ name, label, type, ... }`) or reference an
7
+ * existing object field via `{ field, objectOverride? }`. Field-backed
8
+ * params inherit label (i18n via `fieldLabel()`), type, options, validation,
9
+ * placeholder, and help text from the object's field definition. Inline
10
+ * properties on a field-backed param act as overrides.
11
+ *
12
+ * The resolver flattens each param to the runtime `ActionParamDef` shape
13
+ * expected by `ActionParamDialog`, so the dialog itself stays agnostic to
14
+ * field references.
15
+ */
16
+ import type { ActionParamDef } from '@object-ui/core';
17
+ /**
18
+ * `ActionParamDialog` switches on raw `FieldType` values
19
+ * (`text` / `email` / `select` / `textarea` / `number` / `url` / `date` / …),
20
+ * matching the `FieldType` enum in `@objectstack/spec`. **Do not** route
21
+ * through `mapFieldTypeToFormType()` here — that helper translates into the
22
+ * FormField widget vocabulary (`field:select`, …) which the dialog does not
23
+ * understand.
24
+ */
25
+ /** Raw param as authored on a schema action (post-zod). */
26
+ export interface RawActionParam {
27
+ name?: string;
28
+ field?: string;
29
+ objectOverride?: string;
30
+ label?: string;
31
+ type?: string;
32
+ required?: boolean;
33
+ options?: Array<{
34
+ label: string;
35
+ value: string;
36
+ }>;
37
+ placeholder?: string;
38
+ helpText?: string;
39
+ defaultValue?: unknown;
40
+ /** When true, seed defaultValue from the row record using the field name. */
41
+ defaultFromRow?: boolean;
42
+ }
43
+ /** Field metadata as exposed by `useMetadata().objects[].fields`. */
44
+ interface RuntimeField {
45
+ type?: string;
46
+ label?: string;
47
+ required?: boolean;
48
+ placeholder?: string;
49
+ help?: string;
50
+ description?: string;
51
+ options?: Array<{
52
+ label: string;
53
+ value: string;
54
+ } | string>;
55
+ multiple?: boolean;
56
+ defaultValue?: unknown;
57
+ }
58
+ interface RuntimeObject {
59
+ name?: string;
60
+ fields?: Record<string, RuntimeField>;
61
+ }
62
+ export interface ResolveActionParamsContext {
63
+ /** Default object name when a param's `objectOverride` is absent. */
64
+ objectName: string;
65
+ /** All known runtime objects (`useMetadata().objects`). */
66
+ objects: RuntimeObject[];
67
+ /** i18n resolver — `useObjectLabel().fieldLabel`. */
68
+ fieldLabel: (objectName: string, fieldName: string, fallback: string) => string;
69
+ /** Optional option-label translator — `useObjectLabel().fieldOptionLabel`. */
70
+ fieldOptionLabel?: (objectName: string, fieldName: string, optionValue: string, fallback: string) => string;
71
+ /**
72
+ * Row record providing default values for params with `defaultFromRow` set.
73
+ * Used by list_item actions (edit/delete dialogs) so the dialog opens with
74
+ * the row's current values pre-filled.
75
+ */
76
+ row?: Record<string, unknown>;
77
+ }
78
+ /**
79
+ * Resolve a single raw param against object metadata. Inline params pass
80
+ * through (with safe defaults); field-backed params inherit from the
81
+ * referenced field and accept inline overrides on top.
82
+ */
83
+ export declare function resolveActionParam(param: RawActionParam, ctx: ResolveActionParamsContext): ActionParamDef;
84
+ /** Resolve an array of raw action params. */
85
+ export declare function resolveActionParams(params: RawActionParam[] | undefined, ctx: ResolveActionParamsContext): ActionParamDef[];
86
+ export {};