@object-ui/app-shell 4.2.1 → 4.4.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.
@@ -0,0 +1,84 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * InboxPopover
4
+ *
5
+ * UX P0-2: a single "inbox" surface that bundles the three things that
6
+ * used to occupy separate top-bar buttons:
7
+ * - Notifications (mentions, assignments, system alerts)
8
+ * - Approvals (pending approval requests for the user)
9
+ * - Activity (recent activity feed across the org)
10
+ *
11
+ * Rendered as a single bell button + popover with a tabbed body. The badge
12
+ * shows the combined unread count (notifications + approvals) so users
13
+ * still see at-a-glance pressure.
14
+ *
15
+ * @module
16
+ */
17
+ import { useState } from 'react';
18
+ import { useNavigate, useParams } from 'react-router-dom';
19
+ import { Button, Popover, PopoverContent, PopoverTrigger, Tabs, TabsList, TabsTrigger, TabsContent, } from '@object-ui/components';
20
+ import { Bell, CheckSquare, Activity as ActivityIcon } from 'lucide-react';
21
+ import { useObjectTranslation } from '@object-ui/i18n';
22
+ import { useNavigationContext } from '../context/NavigationContext';
23
+ function timeAgo(iso) {
24
+ if (!iso)
25
+ return '';
26
+ const ms = new Date(iso).getTime();
27
+ if (Number.isNaN(ms))
28
+ return '';
29
+ const diff = (Date.now() - ms) / 1000;
30
+ if (diff < 60)
31
+ return `${Math.max(1, Math.floor(diff))}s`;
32
+ if (diff < 3600)
33
+ return `${Math.floor(diff / 60)}m`;
34
+ if (diff < 86400)
35
+ return `${Math.floor(diff / 3600)}h`;
36
+ return `${Math.floor(diff / 86400)}d`;
37
+ }
38
+ export function InboxPopover({ notifications, unreadCount, pendingApprovalsCount, activities, onMarkAllRead, onMarkRead, }) {
39
+ const { t } = useObjectTranslation();
40
+ const navigate = useNavigate();
41
+ const params = useParams();
42
+ const { currentAppName } = useNavigationContext();
43
+ const [open, setOpen] = useState(false);
44
+ const [tab, setTab] = useState('notifications');
45
+ const totalBadge = unreadCount + pendingApprovalsCount;
46
+ const ariaLabel = t('sidebar.inboxAriaLabel', { defaultValue: 'Open inbox' });
47
+ const goToApprovals = () => {
48
+ setOpen(false);
49
+ const app = currentAppName ?? params.appName;
50
+ navigate(app ? `/apps/${app}/system/approvals` : '/apps/setup/system/approvals');
51
+ };
52
+ const goToAllNotifications = () => {
53
+ setOpen(false);
54
+ // Always route through the setup app's sys_notification list view —
55
+ // it's the canonical full-page inbox and lives outside per-app sidebars.
56
+ // The `?view=mine` query selects the "Mine" tab so the user sees their
57
+ // own notifications by default (matching the popover scope).
58
+ navigate('/apps/setup/sys_notification?view=mine');
59
+ };
60
+ const goToAllActivity = () => {
61
+ setOpen(false);
62
+ // Mirror of goToAllNotifications: drill from the popover Activity tab
63
+ // (capped at 20 rows) into the full sys_activity list page. Org-wide
64
+ // scope — no `?view=` qualifier — to match what the popover already shows.
65
+ navigate('/apps/setup/sys_activity');
66
+ };
67
+ const handleNotificationClick = (n) => {
68
+ onMarkRead(n.id);
69
+ if (n.source_object && n.source_id) {
70
+ setOpen(false);
71
+ const app = currentAppName ?? params.appName;
72
+ const target = app
73
+ ? `/apps/${app}/${n.source_object}/${n.source_id}`
74
+ : `/objects/${n.source_object}/${n.source_id}`;
75
+ navigate(target);
76
+ }
77
+ };
78
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 relative shrink-0", "aria-label": ariaLabel, title: t('sidebar.inbox', { defaultValue: 'Inbox' }), children: [_jsx(Bell, { className: "h-4 w-4" }), totalBadge > 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: totalBadge > 9 ? '9+' : totalBadge }))] }) }), _jsxs(PopoverContent, { align: "end", sideOffset: 8, className: "w-96 p-0", children: [_jsxs("div", { className: "flex items-center justify-between px-3 pt-3 pb-1", children: [_jsx("div", { className: "text-sm font-semibold", children: t('sidebar.inbox', { defaultValue: 'Inbox' }) }), tab === 'notifications' && unreadCount > 0 && (_jsx("button", { type: "button", onClick: onMarkAllRead, className: "text-xs text-muted-foreground hover:text-foreground", children: t('notifications.markAllRead', { defaultValue: 'Mark all read' }) }))] }), _jsxs(Tabs, { value: tab, onValueChange: (v) => setTab(v), className: "w-full", children: [_jsxs(TabsList, { className: "w-full justify-start rounded-none border-b bg-transparent px-1 h-9", children: [_jsxs(TabsTrigger, { value: "notifications", className: "text-xs gap-1.5 data-[state=active]:bg-transparent", children: [_jsx(Bell, { className: "h-3.5 w-3.5" }), t('sidebar.notifications', { defaultValue: 'Notifications' }), unreadCount > 0 && (_jsx("span", { className: "ml-0.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] text-white", children: unreadCount > 9 ? '9+' : unreadCount }))] }), _jsxs(TabsTrigger, { value: "approvals", className: "text-xs gap-1.5 data-[state=active]:bg-transparent", children: [_jsx(CheckSquare, { className: "h-3.5 w-3.5" }), t('sidebar.approvals', { defaultValue: 'Approvals' }), pendingApprovalsCount > 0 && (_jsx("span", { className: "ml-0.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] text-white", children: pendingApprovalsCount > 9 ? '9+' : pendingApprovalsCount }))] }), _jsxs(TabsTrigger, { value: "activity", className: "text-xs gap-1.5 data-[state=active]:bg-transparent", children: [_jsx(ActivityIcon, { className: "h-3.5 w-3.5" }), t('sidebar.activityFeed', { defaultValue: 'Activity' })] })] }), _jsxs(TabsContent, { value: "notifications", className: "m-0 max-h-80 overflow-auto", children: [notifications.length === 0 ? (_jsx("div", { className: "px-3 py-8 text-sm text-muted-foreground text-center", children: t('notifications.empty', { defaultValue: 'No notifications' }) })) : (_jsx("ul", { className: "divide-y", children: notifications.map((n) => (_jsx("li", { children: _jsx("button", { type: "button", onClick: () => handleNotificationClick(n), className: `w-full text-left px-3 py-2.5 hover:bg-accent transition-colors ${n.is_read ? '' : 'bg-accent/40'}`, children: _jsxs("div", { className: "flex items-start gap-2", children: [!n.is_read && (_jsx("span", { className: "mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary", "aria-hidden": true })), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-sm font-medium leading-tight truncate", children: n.title }), n.body && (_jsx("div", { className: "text-xs text-muted-foreground line-clamp-2 mt-0.5", children: n.body })), _jsx("div", { className: "text-[10px] text-muted-foreground mt-1", children: timeAgo(n.created_at) })] })] }) }) }, n.id))) })), _jsx("div", { className: "border-t px-3 py-2 text-center", children: _jsx("button", { type: "button", onClick: goToAllNotifications, className: "text-xs text-primary hover:underline", children: t('notifications.viewAll', { defaultValue: 'View all notifications' }) }) })] }), _jsx(TabsContent, { value: "approvals", className: "m-0 max-h-80 overflow-auto", children: _jsx("div", { className: "px-3 py-6 text-center", children: pendingApprovalsCount > 0 ? (_jsxs(_Fragment, { children: [_jsx(CheckSquare, { className: "mx-auto h-6 w-6 text-amber-500" }), _jsx("div", { className: "mt-2 text-sm font-medium", children: t('notifications.approvalsPending', {
79
+ defaultValue: '{{count}} pending approvals',
80
+ count: pendingApprovalsCount,
81
+ }) }), _jsx(Button, { size: "sm", className: "mt-3", onClick: goToApprovals, children: t('notifications.viewApprovals', { defaultValue: 'View approvals' }) })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "text-sm text-muted-foreground", children: t('notifications.noPendingApprovals', {
82
+ defaultValue: 'No pending approvals',
83
+ }) }), _jsx("button", { type: "button", onClick: goToApprovals, className: "mt-2 text-xs text-primary hover:underline", children: t('notifications.openApprovalsInbox', { defaultValue: 'Open Approvals Inbox' }) })] })) }) }), _jsxs(TabsContent, { value: "activity", className: "m-0 max-h-80 overflow-auto", children: [activities.length === 0 ? (_jsx("div", { className: "px-3 py-8 text-sm text-muted-foreground text-center", children: t('layout.activityFeed.empty', { defaultValue: 'No recent activity' }) })) : (_jsx("ul", { className: "divide-y", children: activities.slice(0, 20).map((a) => (_jsxs("li", { className: "px-3 py-2.5", children: [_jsxs("div", { className: "text-sm leading-tight truncate", children: [_jsx("span", { className: "font-medium", children: a.user }), ' ', _jsx("span", { className: "text-muted-foreground", children: a.description })] }), _jsxs("div", { className: "text-[10px] text-muted-foreground mt-0.5", children: [timeAgo(a.timestamp), " \u00B7 ", a.objectName] })] }, a.id))) })), _jsx("div", { className: "border-t px-3 py-2 text-center", children: _jsx("button", { type: "button", onClick: goToAllActivity, className: "text-xs text-primary hover:underline", children: t('layout.activityFeed.viewAll', { defaultValue: 'View all activity' }) }) })] })] })] })] }));
84
+ }
@@ -61,7 +61,23 @@ export function formatRecordTitle(titleFormat, record) {
61
61
  }
62
62
  let anyResolved = false;
63
63
  let out = template.replace(/\{([^{}]+)\}/g, (_match, fieldName) => {
64
- const value = record[fieldName.trim()];
64
+ // Support dotted paths (e.g. `{account.name}`) for $expanded lookups.
65
+ const parts = String(fieldName).trim().split('.');
66
+ let value = record;
67
+ for (const p of parts) {
68
+ if (value == null)
69
+ break;
70
+ value = value[p];
71
+ }
72
+ // Auto-extract display name from expanded reference objects.
73
+ if (value && typeof value === 'object') {
74
+ value =
75
+ value.name ??
76
+ value.label ??
77
+ value.display_name ??
78
+ value.title ??
79
+ null;
80
+ }
65
81
  if (value === null || value === undefined || value === '') {
66
82
  return EMPTY_TOKEN;
67
83
  }
@@ -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 {};
@@ -0,0 +1,77 @@
1
+ /** Normalise an options entry (allowing bare strings) into label/value pairs. */
2
+ function normaliseOptions(options, objectName, fieldName, optionLabel) {
3
+ if (!Array.isArray(options) || options.length === 0)
4
+ return undefined;
5
+ return options.map((o) => {
6
+ const raw = typeof o === 'string' ? { label: o, value: o } : o;
7
+ const label = optionLabel
8
+ ? optionLabel(objectName, fieldName, raw.value, raw.label)
9
+ : raw.label;
10
+ return { label, value: raw.value };
11
+ });
12
+ }
13
+ /**
14
+ * Resolve a single raw param against object metadata. Inline params pass
15
+ * through (with safe defaults); field-backed params inherit from the
16
+ * referenced field and accept inline overrides on top.
17
+ */
18
+ export function resolveActionParam(param, ctx) {
19
+ /** Row-context default: when `defaultFromRow` and a row is present, the
20
+ * param's defaultValue is the row's value at the field key (or `name`). */
21
+ const rowKey = param.field ?? param.name;
22
+ const rowDefault = param.defaultFromRow && ctx.row && rowKey != null && Object.prototype.hasOwnProperty.call(ctx.row, rowKey)
23
+ ? ctx.row[rowKey]
24
+ : undefined;
25
+ // Inline param — no field reference, just normalise.
26
+ if (!param.field) {
27
+ return {
28
+ name: param.name ?? '',
29
+ label: param.label ?? param.name ?? '',
30
+ type: param.type ?? 'text',
31
+ required: param.required ?? false,
32
+ options: param.options,
33
+ placeholder: param.placeholder,
34
+ helpText: param.helpText,
35
+ defaultValue: rowDefault ?? param.defaultValue,
36
+ };
37
+ }
38
+ const ownerName = param.objectOverride ?? ctx.objectName;
39
+ const owner = ctx.objects.find((o) => o?.name === ownerName);
40
+ const field = owner?.fields?.[param.field];
41
+ if (!field) {
42
+ // Reference target missing — fall back to a plain text input so the
43
+ // action remains usable in environments where the metadata cache is
44
+ // partial (e.g. tests).
45
+ return {
46
+ name: param.name ?? param.field,
47
+ label: param.label ?? ctx.fieldLabel(ownerName, param.field, param.field),
48
+ type: param.type ?? 'text',
49
+ required: param.required ?? false,
50
+ options: param.options,
51
+ placeholder: param.placeholder,
52
+ helpText: param.helpText,
53
+ defaultValue: rowDefault ?? param.defaultValue,
54
+ };
55
+ }
56
+ const resolvedType = param.type ?? field.type ?? 'text';
57
+ const resolvedOptions = param.options
58
+ ?? normaliseOptions(field.options, ownerName, param.field, ctx.fieldOptionLabel);
59
+ const resolvedLabel = param.label
60
+ ?? ctx.fieldLabel(ownerName, param.field, field.label ?? param.field);
61
+ return {
62
+ name: param.name ?? param.field,
63
+ label: resolvedLabel,
64
+ type: resolvedType,
65
+ required: param.required ?? field.required ?? false,
66
+ options: resolvedOptions,
67
+ placeholder: param.placeholder ?? field.placeholder,
68
+ helpText: param.helpText ?? field.help ?? field.description,
69
+ defaultValue: rowDefault ?? param.defaultValue ?? field.defaultValue,
70
+ };
71
+ }
72
+ /** Resolve an array of raw action params. */
73
+ export function resolveActionParams(params, ctx) {
74
+ if (!Array.isArray(params))
75
+ return [];
76
+ return params.map((p) => resolveActionParam(p, ctx));
77
+ }
@@ -26,8 +26,9 @@ 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';
29
+ import { ManagedByBadge } from '../components/ManagedByBadge';
30
30
  import { resolveCrudAffordances } from '../utils/crudAffordances';
31
+ import { resolveManagedByEmptyState } from '../utils/managedByEmptyState';
31
32
  import { useObjectActions } from '../hooks/useObjectActions';
32
33
  import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
33
34
  import { usePermissions } from '@object-ui/permissions';
@@ -37,6 +38,7 @@ import { ActionProvider, useNavigationOverlay, SchemaRenderer } from '@object-ui
37
38
  import { toast } from 'sonner';
38
39
  import { ActionConfirmDialog } from './ActionConfirmDialog';
39
40
  import { ActionParamDialog } from './ActionParamDialog';
41
+ import { resolveActionParams } from '../utils/resolveActionParams';
40
42
  /** Map view types to Lucide icons (Airtable-style) */
41
43
  const VIEW_TYPE_ICONS = {
42
44
  grid: TableIcon,
@@ -112,7 +114,7 @@ function toSysViewPayload(config, objectName, opts = {}) {
112
114
  }
113
115
  }
114
116
  const EXTRA_CONFIG_KEYS = [
115
- 'bulkActions', 'rowActions', 'pagination', 'navigation',
117
+ 'bulkActions', 'bulkActionDefs', 'rowActions', 'pagination', 'navigation',
116
118
  'emptyState', 'exportOptions', 'rowHeight',
117
119
  'isPinned', 'isDefault', 'visibility', 'sortOrder',
118
120
  'showSort',
@@ -346,7 +348,7 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
346
348
  const [searchParams, setSearchParams] = useSearchParams();
347
349
  const { showDebug } = useMetadataInspector();
348
350
  const { t } = useObjectTranslation();
349
- const { objectLabel, objectDescription: objectDesc, viewLabel, actionLabel, actionConfirm, actionSuccess } = useObjectLabel();
351
+ const { objectLabel, objectDescription: objectDesc, viewLabel, actionLabel, actionConfirm, actionSuccess, fieldLabel, fieldOptionLabel } = useObjectLabel();
350
352
  // Inline view config panel state (Airtable-style right sidebar)
351
353
  const [showViewConfigPanel, setShowViewConfigPanel] = useState(false);
352
354
  const [viewConfigPanelMode, setViewConfigPanelMode] = useState('edit');
@@ -483,7 +485,7 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
483
485
  // Record count tracking for footer
484
486
  const [recordCount, setRecordCount] = useState(undefined);
485
487
  // Admin users automatically get design tools (no toggle needed)
486
- const { user } = useAuth();
488
+ const { user, activeOrganization } = useAuth();
487
489
  const isAdmin = user?.role === 'admin';
488
490
  const { can } = usePermissions();
489
491
  // Get Object Definition
@@ -871,11 +873,24 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
871
873
  setConfirmState({ open: true, message, options, resolve });
872
874
  });
873
875
  }, []);
874
- const paramCollectionHandler = useCallback((params) => {
876
+ const paramCollectionHandler = useCallback((params, action) => {
875
877
  return new Promise((resolve) => {
876
- setParamState({ open: true, params, resolve });
878
+ // List_item actions stash the row record under params._rowRecord
879
+ // (see ObjectGrid → onRowAction). Pull it out so resolveActionParams
880
+ // can pre-fill `defaultFromRow` params from the row's current values.
881
+ const row = action?.params && !Array.isArray(action.params)
882
+ ? action.params._rowRecord
883
+ : undefined;
884
+ const resolved = resolveActionParams(params, {
885
+ objectName: objectName || objectDef?.name || '',
886
+ objects: objects || [],
887
+ fieldLabel,
888
+ fieldOptionLabel,
889
+ row,
890
+ });
891
+ setParamState({ open: true, params: resolved, resolve });
877
892
  });
878
- }, []);
893
+ }, [objectName, objectDef, objects, fieldLabel, fieldOptionLabel]);
879
894
  const handleDeleteView = useCallback(async (vid) => {
880
895
  if (!dataSource)
881
896
  return;
@@ -1092,10 +1107,103 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1092
1107
  navigate(url);
1093
1108
  }
1094
1109
  }, [navigate]);
1110
+ // Authenticated fetch for direct backend calls (e.g. flow trigger,
1111
+ // schema actions targeting absolute API paths). Declared before
1112
+ // apiHandler so the latter can close over it.
1113
+ const authFetch = useMemo(() => createAuthenticatedFetch(), []);
1095
1114
  const apiHandler = useCallback(async (action) => {
1096
1115
  try {
1097
1116
  const target = action.target || action.name;
1098
1117
  const params = action.params || {};
1118
+ // Absolute HTTP target (e.g. /api/v1/auth/organization/invite-member,
1119
+ // http://..., https://...) — bypass dataSource and call the API
1120
+ // directly through the authenticated fetch wrapper so:
1121
+ // - Authorization: Bearer <token> is injected
1122
+ // - X-Tenant-ID is injected (multi-tenant routing)
1123
+ // - Same-origin cookies (better-auth.session_token) ride along
1124
+ //
1125
+ // This is the canonical path for schema actions on managed-by
1126
+ // tables (sys_user/invite_user, sys_session/revoke, …) where
1127
+ // generic CRUD does not apply.
1128
+ const targetStr = typeof target === 'string' ? target : '';
1129
+ const isAbsolute = targetStr.startsWith('/') || /^https?:\/\//i.test(targetStr);
1130
+ if (isAbsolute) {
1131
+ const baseUrl = import.meta.env.VITE_SERVER_URL || '';
1132
+ // Row context is stashed on params under `_rowRecord` by the
1133
+ // row-action dispatcher (see ObjectGrid → onRowAction). Pull
1134
+ // it out before assembling the request body.
1135
+ const rawParams = { ...params };
1136
+ const rowRecord = rawParams._rowRecord;
1137
+ delete rawParams._rowRecord;
1138
+ // Interpolate `{field}` tokens in the target URL from the
1139
+ // row record. Lets actions hit data-API endpoints like
1140
+ // `PATCH /api/v1/sys_api_key/{id}` without bespoke wiring.
1141
+ let resolvedTarget = targetStr;
1142
+ if (rowRecord && /\{[a-z_][a-z0-9_]*\}/i.test(resolvedTarget)) {
1143
+ resolvedTarget = resolvedTarget.replace(/\{([a-z_][a-z0-9_]*)\}/gi, (_, k) => {
1144
+ const v = rowRecord[k];
1145
+ return v == null ? '' : encodeURIComponent(String(v));
1146
+ });
1147
+ }
1148
+ const url = resolvedTarget.startsWith('http') ? resolvedTarget : `${baseUrl}${resolvedTarget}`;
1149
+ // Apply bodyShape: 'flat' (default) keeps user params at top
1150
+ // level; { wrap: 'data' } nests them under that key while
1151
+ // `recordIdParam` / `organizationId` stay flat (better-auth
1152
+ // organization/update semantics).
1153
+ const wrap = action.bodyShape && typeof action.bodyShape === 'object' && action.bodyShape.wrap
1154
+ ? action.bodyShape.wrap
1155
+ : undefined;
1156
+ const body = wrap
1157
+ ? { [wrap]: rawParams }
1158
+ : { ...rawParams };
1159
+ // Inject row id (or chosen row field) for list_item actions.
1160
+ if (rowRecord && action.recordIdParam) {
1161
+ const rowField = action.recordIdField || 'id';
1162
+ const rowValue = rowRecord[rowField];
1163
+ if (rowValue != null)
1164
+ body[action.recordIdParam] = rowValue;
1165
+ }
1166
+ // Auto-inject organizationId only for better-auth org-scoped
1167
+ // endpoints. Data-API endpoints (/api/v1/{object}/{id}) must
1168
+ // NOT receive a stray organizationId field — that would try
1169
+ // to overwrite the row's tenant column.
1170
+ const isAuthOrgEndpoint = /\/api\/v1\/auth\//.test(resolvedTarget);
1171
+ if (isAuthOrgEndpoint && !body.organizationId && activeOrganization?.id) {
1172
+ body.organizationId = activeOrganization.id;
1173
+ }
1174
+ // Merge static body fragment last so constants override any
1175
+ // user-collected values (e.g. resend:true on resend-invite,
1176
+ // revoked:true on revoke-api-key).
1177
+ if (action.bodyExtra && typeof action.bodyExtra === 'object') {
1178
+ Object.assign(body, action.bodyExtra);
1179
+ }
1180
+ const method = (action.method || 'POST').toUpperCase();
1181
+ const init = {
1182
+ method,
1183
+ headers: { 'Content-Type': 'application/json' },
1184
+ credentials: 'include',
1185
+ };
1186
+ // GET/DELETE conventionally have no body; for everything else
1187
+ // we serialize. (We don't expect GET here today, but keep the
1188
+ // guard for safety.)
1189
+ if (method !== 'GET' && method !== 'DELETE') {
1190
+ init.body = JSON.stringify(body);
1191
+ }
1192
+ const res = await authFetch(url, init);
1193
+ if (!res.ok) {
1194
+ let detail = `HTTP ${res.status}`;
1195
+ try {
1196
+ const j = await res.json();
1197
+ detail = j?.error || j?.message || detail;
1198
+ }
1199
+ catch { /* response body not JSON */ }
1200
+ return { success: false, error: detail };
1201
+ }
1202
+ const data = await res.json().catch(() => ({}));
1203
+ if (action.refreshAfter !== false)
1204
+ setRefreshKey(k => k + 1);
1205
+ return { success: true, data, reload: action.refreshAfter !== false };
1206
+ }
1099
1207
  // Generic list-level API handler: update/execute via dataSource
1100
1208
  if (typeof dataSource.execute === 'function') {
1101
1209
  await dataSource.execute(objectDef.name, target, params);
@@ -1112,9 +1220,7 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1112
1220
  catch (error) {
1113
1221
  return { success: false, error: error.message };
1114
1222
  }
1115
- }, [dataSource, objectDef.name]);
1116
- // Authenticated fetch for direct backend calls (e.g. flow trigger).
1117
- const authFetch = useMemo(() => createAuthenticatedFetch(), []);
1223
+ }, [dataSource, objectDef.name, authFetch, activeOrganization]);
1118
1224
  // Flow action handler — POST to /api/v1/automation/{name}/trigger.
1119
1225
  // Triggered when an Action with `type: 'flow'` is invoked from list-level
1120
1226
  // locations (list_toolbar, list_item). For list_item the row's recordId is
@@ -1385,7 +1491,19 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1385
1491
  filterableFields: viewDef.filterableFields ?? listSchema.filterableFields,
1386
1492
  resizable: viewDef.resizable ?? listSchema.resizable,
1387
1493
  rowActions: viewDef.rowActions ?? listSchema.rowActions,
1494
+ /**
1495
+ * Row-context action definitions derived from `objectDef.actions`
1496
+ * filtered by `locations.includes('list_item')`. These are full
1497
+ * `ActionDef` records (with label/icon/variant/params/recordIdParam
1498
+ * /bodyShape) the row menu renders with i18n-correct labels and
1499
+ * dispatches via the action runner; legacy `rowActions: string[]`
1500
+ * remains for back-compat where the action lives elsewhere.
1501
+ */
1502
+ rowActionDefs: (Array.isArray(objectDef?.actions)
1503
+ ? objectDef.actions.filter((a) => Array.isArray(a?.locations) && a.locations.includes('list_item'))
1504
+ : []),
1388
1505
  bulkActions: viewDef.bulkActions ?? listSchema.bulkActions,
1506
+ bulkActionDefs: viewDef.bulkActionDefs ?? listSchema.bulkActionDefs,
1389
1507
  sharing: viewDef.sharing ?? listSchema.sharing,
1390
1508
  addRecord: viewDef.addRecord ?? listSchema.addRecord,
1391
1509
  conditionalFormatting: viewDef.conditionalFormatting ?? listSchema.conditionalFormatting,
@@ -1394,7 +1512,9 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1394
1512
  showRecordCount: viewDef.showRecordCount ?? listSchema.showRecordCount,
1395
1513
  allowPrinting: viewDef.allowPrinting ?? listSchema.allowPrinting,
1396
1514
  virtualScroll: viewDef.virtualScroll ?? listSchema.virtualScroll,
1397
- emptyState: viewDef.emptyState ?? listSchema.emptyState,
1515
+ emptyState: viewDef.emptyState
1516
+ ?? listSchema.emptyState
1517
+ ?? resolveManagedByEmptyState(objectDef?.managedBy),
1398
1518
  aria: viewDef.aria ?? listSchema.aria,
1399
1519
  tabs: listSchema.tabs,
1400
1520
  // Propagate filter/sort as default filters/sort for data flow
@@ -1534,7 +1654,15 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1534
1654
  }
1535
1655
  },
1536
1656
  }), [objectDef.name, onEdit, activeView?.showSearch, activeView?.showFilters, activeView?.showSort, navigate, viewId, isAdmin]);
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: {
1657
+ return (_jsxs(ActionProvider, { context: {
1658
+ objectName: objectDef.name,
1659
+ user: currentUser,
1660
+ // Expose active org so `type: 'url'` actions can template
1661
+ // `/organizations/${activeOrganization.slug}/...` etc.
1662
+ activeOrganization: activeOrganization
1663
+ ? { id: activeOrganization.id, slug: activeOrganization.slug, name: activeOrganization.name }
1664
+ : null,
1665
+ }, 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: _jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { className: "truncate", children: objectLabel(objectDef) }), _jsx(ManagedByBadge, { managedBy: objectDef?.managedBy })] }), 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: {
1538
1666
  type: 'action:bar',
1539
1667
  location: 'list_toolbar',
1540
1668
  actions: (objectDef.actions || []).map((a) => ({