@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 +102 -0
- package/dist/components/ManagedByBadge.d.ts +10 -0
- package/dist/components/ManagedByBadge.js +44 -0
- package/dist/layout/ActivityFeed.js +3 -3
- package/dist/layout/AppHeader.js +122 -15
- package/dist/utils/crudAffordances.d.ts +41 -0
- package/dist/utils/crudAffordances.js +35 -0
- package/dist/utils/managedByEmptyState.d.ts +29 -0
- package/dist/utils/managedByEmptyState.js +24 -0
- package/dist/utils/resolveActionParams.d.ts +86 -0
- package/dist/utils/resolveActionParams.js +77 -0
- package/dist/views/ObjectView.js +160 -13
- package/dist/views/RecordDetailView.js +32 -35
- package/dist/views/RecordFormPage.js +2 -1
- package/dist/views/ReportView.js +36 -19
- package/package.json +24 -24
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 {
|
|
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(
|
|
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(
|
|
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
|
}) }))] })] }));
|
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
|
+
}
|
|
@@ -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 {};
|