@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.
- package/CHANGELOG.md +125 -0
- package/dist/components/ManagedByBadge.d.ts +10 -0
- package/dist/components/ManagedByBadge.js +44 -0
- package/dist/console/AppContent.js +2 -2
- package/dist/console/home/HomePage.js +1 -2
- package/dist/console/organizations/OrganizationsPage.js +28 -2
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useRecordApprovals.d.ts +49 -0
- package/dist/hooks/useRecordApprovals.js +132 -0
- package/dist/layout/ActivityFeed.js +3 -3
- package/dist/layout/AppHeader.d.ts +1 -1
- package/dist/layout/AppHeader.js +3 -16
- package/dist/layout/ConsoleLayout.js +1 -1
- package/dist/layout/InboxPopover.d.ts +36 -0
- package/dist/layout/InboxPopover.js +84 -0
- package/dist/utils/index.js +17 -1
- 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 +140 -12
- package/dist/views/RecordDetailView.js +229 -40
- package/dist/views/RecordFormPage.js +2 -2
- package/dist/views/ReportView.js +21 -19
- package/package.json +24 -24
- package/dist/components/ManagedByBanner.d.ts +0 -16
- package/dist/components/ManagedByBanner.js +0 -38
|
@@ -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
|
+
}
|
package/dist/utils/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/views/ObjectView.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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) => ({
|