@object-ui/app-shell 4.3.0 → 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 +80 -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 +1 -1
- 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/views/ObjectView.js +36 -10
- package/dist/views/RecordDetailView.js +206 -6
- package/package.json +24 -24
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
1
1
|
# @object-ui/app-shell — Changelog
|
|
2
2
|
|
|
3
|
+
## 4.4.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 501ce20: fix(detail): hide system/tenant fields from auto-generated record detail
|
|
8
|
+
|
|
9
|
+
The auto-generated detail section (used when an object has no explicit form
|
|
10
|
+
sections) was leading every record page with `organization_id` (rendered as
|
|
11
|
+
"ORGANIZATION: Admin's Workspace") — pure tenancy metadata with no business
|
|
12
|
+
value. Extended the existing audit-field filter to also drop
|
|
13
|
+
`organization_id`, `tenant_id`, `is_deleted`, and `deleted_at`. Objects that
|
|
14
|
+
intentionally surface tenant info can still do so via explicit
|
|
15
|
+
`views.form.sections`.
|
|
16
|
+
|
|
17
|
+
- 63eb66d: fix(detail): expand lookup fields so subtitle + lookup cells show display names
|
|
18
|
+
|
|
19
|
+
The record-page fetch in `RecordDetailView` (the page-mode path) now
|
|
20
|
+
requests `$expand` for every lookup/master_detail field on the object,
|
|
21
|
+
mirroring the behaviour the legacy `DetailView` already had. Combined
|
|
22
|
+
with two small downstream fixes — `PageHeader` subtitle interpolation
|
|
23
|
+
now extracts `name/label` from expanded reference objects instead of
|
|
24
|
+
rendering `[object Object]`, and `LookupCellRenderer` now short-circuits
|
|
25
|
+
to `pickRecordDisplayName` when the value is already a nested record —
|
|
26
|
+
all `record:*` renderers and the page header subtitle (`Owned by
|
|
27
|
+
{account}`) now display the related record's name rather than the raw
|
|
28
|
+
foreign-key id.
|
|
29
|
+
|
|
30
|
+
- 2bd45af: feat(shell): main becomes the scroll container; record tabs are sticky
|
|
31
|
+
- `AppShell`'s SidebarProvider wrapper is now constrained to viewport
|
|
32
|
+
height (`h-svh overflow-hidden`) instead of expanding with content via
|
|
33
|
+
the default `min-h-svh`. This makes the inner `<main>` (which is
|
|
34
|
+
`overflow-auto`) the actual scroll container instead of the window.
|
|
35
|
+
- `RecordDetailView` page-mode container drops the redundant
|
|
36
|
+
`h-full overflow-auto` (avoids nested scrollers; main owns scroll now).
|
|
37
|
+
- `page:tabs` (horizontal) gets `sticky top-0 z-20` with a translucent
|
|
38
|
+
backdrop so the tab strip stays visible while users scroll through
|
|
39
|
+
long record pages — the Salesforce Lightning behaviour our schemas
|
|
40
|
+
were already implying.
|
|
41
|
+
|
|
42
|
+
- e33d575: Support dotted paths (e.g. `{account.name}`) in object `titleFormat`. When a
|
|
43
|
+
placeholder resolves to an expanded reference object, automatically extract
|
|
44
|
+
its `name`/`label`/`display_name`/`title` so detail page titles render the
|
|
45
|
+
related record's display name instead of falling through to the object label.
|
|
46
|
+
- Updated dependencies [63eb66d]
|
|
47
|
+
- Updated dependencies [67dabe1]
|
|
48
|
+
- Updated dependencies [ef0e30d]
|
|
49
|
+
- Updated dependencies [2bd45af]
|
|
50
|
+
- @object-ui/layout@4.4.0
|
|
51
|
+
- @object-ui/fields@4.4.0
|
|
52
|
+
- @object-ui/components@4.4.0
|
|
53
|
+
- @object-ui/types@4.4.0
|
|
54
|
+
- @object-ui/core@4.4.0
|
|
55
|
+
- @object-ui/i18n@4.4.0
|
|
56
|
+
- @object-ui/react@4.4.0
|
|
57
|
+
- @object-ui/data-objectstack@4.4.0
|
|
58
|
+
- @object-ui/auth@4.4.0
|
|
59
|
+
- @object-ui/permissions@4.4.0
|
|
60
|
+
- @object-ui/collaboration@4.4.0
|
|
61
|
+
|
|
62
|
+
## 4.3.1
|
|
63
|
+
|
|
64
|
+
### Patch Changes
|
|
65
|
+
|
|
66
|
+
- 9167935: fix
|
|
67
|
+
- 52af5cf: fix
|
|
68
|
+
- Updated dependencies [5f4ac6e]
|
|
69
|
+
- Updated dependencies [6b683c8]
|
|
70
|
+
- Updated dependencies [0d8eb98]
|
|
71
|
+
- @object-ui/i18n@4.3.1
|
|
72
|
+
- @object-ui/components@4.3.1
|
|
73
|
+
- @object-ui/layout@4.3.1
|
|
74
|
+
- @object-ui/fields@4.3.1
|
|
75
|
+
- @object-ui/react@4.3.1
|
|
76
|
+
- @object-ui/types@4.3.1
|
|
77
|
+
- @object-ui/core@4.3.1
|
|
78
|
+
- @object-ui/data-objectstack@4.3.1
|
|
79
|
+
- @object-ui/auth@4.3.1
|
|
80
|
+
- @object-ui/permissions@4.3.1
|
|
81
|
+
- @object-ui/collaboration@4.3.1
|
|
82
|
+
|
|
3
83
|
## 4.3.0
|
|
4
84
|
|
|
5
85
|
### Patch Changes
|
|
@@ -297,8 +297,8 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
|
|
|
297
297
|
showSubmit: true,
|
|
298
298
|
showCancel: true,
|
|
299
299
|
submitText: editingRecord
|
|
300
|
-
? t('form.update', { defaultValue: t('common.save'
|
|
301
|
-
: t('form.create', { defaultValue: t('common.create'
|
|
300
|
+
? t('form.update', { defaultValue: t('common.save', { defaultValue: 'Save' }) })
|
|
301
|
+
: t('form.create', { defaultValue: t('common.create', { defaultValue: 'Create' }) }),
|
|
302
302
|
cancelText: t('common.cancel'),
|
|
303
303
|
}, dataSource: dataSource }, editingRecord?.id || 'new'))] })] }));
|
|
304
304
|
}
|
|
@@ -21,7 +21,6 @@ import { useMetadata } from '../../providers/MetadataProvider';
|
|
|
21
21
|
import { useRecentItems } from '../../hooks/useRecentItems';
|
|
22
22
|
import { useFavorites } from '../../hooks/useFavorites';
|
|
23
23
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
24
|
-
import { QuickActions } from './QuickActions';
|
|
25
24
|
import { AppCard } from './AppCard';
|
|
26
25
|
import { RecentApps } from './RecentApps';
|
|
27
26
|
import { StarredApps } from './StarredApps';
|
|
@@ -52,5 +51,5 @@ export function HomePage() {
|
|
|
52
51
|
defaultValue: 'Get started by creating your first application or configure your system settings.',
|
|
53
52
|
}) }), _jsxs("div", { className: "mt-6 flex flex-col sm:flex-row items-center gap-3", children: [_jsxs(Button, { onClick: () => navigate('/create-app'), "data-testid": "create-first-app-btn", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('home.createFirstApp', { defaultValue: 'Create Your First App' })] }), _jsxs(Button, { variant: "outline", onClick: () => navigate('/apps/setup'), "data-testid": "go-to-settings-btn", children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('home.systemSettings', { defaultValue: 'System Settings' })] })] })] }) }));
|
|
54
53
|
}
|
|
55
|
-
return (_jsxs("div", { className: "bg-background", children: [_jsxs("div", { className: "px-4 sm:px-6 pt-6 pb-4", children: [_jsx("h1", { className: "text-2xl sm:text-3xl font-bold tracking-tight", children: t('home.title', { defaultValue: 'Home' }) }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: t('home.subtitle', { defaultValue: 'Your workspace dashboard' }) })] }), _jsxs("div", { className: "px-4 sm:px-6 py-4 space-y-8", children: [
|
|
54
|
+
return (_jsxs("div", { className: "bg-background", children: [_jsxs("div", { className: "px-4 sm:px-6 pt-6 pb-4", children: [_jsx("h1", { className: "text-2xl sm:text-3xl font-bold tracking-tight", children: t('home.title', { defaultValue: 'Home' }) }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: t('home.subtitle', { defaultValue: 'Your workspace dashboard' }) })] }), _jsxs("div", { className: "px-4 sm:px-6 py-4 space-y-8", children: [starredApps.length > 0 && (_jsx(StarredApps, { items: starredApps })), recentApps.length > 0 && (_jsx(RecentApps, { items: recentApps })), _jsxs("section", { children: [_jsx("h2", { className: "text-2xl font-semibold tracking-tight mb-4", children: t('home.allApps', { defaultValue: 'All Applications' }) }), _jsx("div", { className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4", children: activeApps.map((app) => (_jsx(AppCard, { app: app, onClick: () => navigate(`/apps/${app.name}`), isFavorite: favorites.some(f => f.id === `app:${app.name}`) }, app.name))) })] })] })] }));
|
|
56
55
|
}
|
|
@@ -7,7 +7,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
7
7
|
* organization and navigates to `/home`), or create a new organization.
|
|
8
8
|
* @module
|
|
9
9
|
*/
|
|
10
|
-
import { useMemo, useState } from 'react';
|
|
10
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
11
11
|
import { Avatar, AvatarImage, AvatarFallback, Button, Input, Empty, EmptyTitle, EmptyDescription, } from '@object-ui/components';
|
|
12
12
|
import { Plus, Search, Loader2 } from 'lucide-react';
|
|
13
13
|
import { useAuth } from '@object-ui/auth';
|
|
@@ -55,7 +55,33 @@ export function OrganizationsPage() {
|
|
|
55
55
|
setIsCreateOpen(false);
|
|
56
56
|
handleSelect(org);
|
|
57
57
|
};
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* UX P0-1: auto-skip the picker when the user belongs to exactly one
|
|
60
|
+
* organization. The picker is friction for >90% of users (single-org
|
|
61
|
+
* tenants) and only useful when there's an actual choice. We still mount
|
|
62
|
+
* the page (so direct navigation to `/organizations` works) but redirect
|
|
63
|
+
* once orgs have finished loading.
|
|
64
|
+
*
|
|
65
|
+
* Guarded by a ref so we only auto-redirect once per mount — if the user
|
|
66
|
+
* later creates a second org and returns here, they'll see the picker.
|
|
67
|
+
*/
|
|
68
|
+
const autoSelectedRef = useRef(false);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (autoSelectedRef.current)
|
|
71
|
+
return;
|
|
72
|
+
if (isOrganizationsLoading)
|
|
73
|
+
return;
|
|
74
|
+
if (orgList.length !== 1)
|
|
75
|
+
return;
|
|
76
|
+
autoSelectedRef.current = true;
|
|
77
|
+
void handleSelect(orgList[0]);
|
|
78
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
79
|
+
}, [isOrganizationsLoading, orgList.length]);
|
|
80
|
+
// Show a spinner while we're either still loading, or about to auto-redirect
|
|
81
|
+
// because there's only one org. This prevents the picker from briefly
|
|
82
|
+
// flashing on screen for single-org users.
|
|
83
|
+
const willAutoSelect = !isOrganizationsLoading && orgList.length === 1;
|
|
84
|
+
if (isOrganizationsLoading || willAutoSelect) {
|
|
59
85
|
return (_jsx("div", { className: "flex flex-1 items-center justify-center py-20", children: _jsx(Loader2, { className: "h-5 w-5 animate-spin text-muted-foreground" }) }));
|
|
60
86
|
}
|
|
61
87
|
return (_jsxs("div", { className: "mx-auto w-full max-w-5xl px-4 sm:px-6 py-8 sm:py-12", children: [_jsxs("div", { className: "mb-8", children: [_jsx("h1", { className: "text-2xl sm:text-3xl font-bold tracking-tight", children: t('organizations.heading', { defaultValue: 'Your Organizations' }) }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: t('organizations.subtitle', {
|
package/dist/hooks/index.d.ts
CHANGED
|
@@ -4,4 +4,5 @@ export { useNavPins } from './useNavPins';
|
|
|
4
4
|
export { useNavigationSync, NavigationSyncEffect, generateNavId, addNavigationItem, removeNavigationItems, renameNavigationItems, navigationEqual, type UseNavigationSyncReturn, } from './useNavigationSync';
|
|
5
5
|
export { useObjectActions } from './useObjectActions';
|
|
6
6
|
export { useRecentItems, type RecentItem } from './useRecentItems';
|
|
7
|
+
export { useRecordApprovals, type ApprovalProcessLite, type ApprovalRequestLite } from './useRecordApprovals';
|
|
7
8
|
export { useResponsiveSidebar } from './useResponsiveSidebar';
|
package/dist/hooks/index.js
CHANGED
|
@@ -4,4 +4,5 @@ export { useNavPins } from './useNavPins';
|
|
|
4
4
|
export { useNavigationSync, NavigationSyncEffect, generateNavId, addNavigationItem, removeNavigationItems, renameNavigationItems, navigationEqual, } from './useNavigationSync';
|
|
5
5
|
export { useObjectActions } from './useObjectActions';
|
|
6
6
|
export { useRecentItems } from './useRecentItems';
|
|
7
|
+
export { useRecordApprovals } from './useRecordApprovals';
|
|
7
8
|
export { useResponsiveSidebar } from './useResponsiveSidebar';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useRecordApprovals
|
|
3
|
+
*
|
|
4
|
+
* Resolves the approval state for a single record so the detail-view header
|
|
5
|
+
* can surface "Submit for Approval" / "Recall" actions and a status badge.
|
|
6
|
+
*
|
|
7
|
+
* Talks directly to the framework REST endpoints under
|
|
8
|
+
* `/api/v1/approvals/*`. Fails open: if the approvals plugin is not
|
|
9
|
+
* installed (404) or the user has no identity, returns inert state so the
|
|
10
|
+
* detail view continues to render normally.
|
|
11
|
+
*/
|
|
12
|
+
export interface ApprovalProcessLite {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
label?: string;
|
|
16
|
+
object_name: string;
|
|
17
|
+
active?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface ApprovalRequestLite {
|
|
20
|
+
id: string;
|
|
21
|
+
process_name: string;
|
|
22
|
+
object_name: string;
|
|
23
|
+
record_id: string;
|
|
24
|
+
status: 'pending' | 'approved' | 'rejected' | 'recalled' | string;
|
|
25
|
+
submitter_id?: string | null;
|
|
26
|
+
current_step?: string | null;
|
|
27
|
+
pending_approvers?: string[] | null;
|
|
28
|
+
submitted_at?: string;
|
|
29
|
+
completed_at?: string | null;
|
|
30
|
+
}
|
|
31
|
+
interface UseRecordApprovalsResult {
|
|
32
|
+
loading: boolean;
|
|
33
|
+
available: boolean;
|
|
34
|
+
processes: ApprovalProcessLite[];
|
|
35
|
+
pendingRequest: ApprovalRequestLite | null;
|
|
36
|
+
latestRequest: ApprovalRequestLite | null;
|
|
37
|
+
canSubmit: boolean;
|
|
38
|
+
canRecall: boolean;
|
|
39
|
+
submit: (input?: {
|
|
40
|
+
processName?: string;
|
|
41
|
+
comment?: string;
|
|
42
|
+
}) => Promise<ApprovalRequestLite>;
|
|
43
|
+
recall: (input?: {
|
|
44
|
+
comment?: string;
|
|
45
|
+
}) => Promise<ApprovalRequestLite>;
|
|
46
|
+
refresh: () => Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
export declare function useRecordApprovals(objectName: string | undefined, recordId: string | undefined, currentUserId?: string | null): UseRecordApprovalsResult;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useRecordApprovals
|
|
3
|
+
*
|
|
4
|
+
* Resolves the approval state for a single record so the detail-view header
|
|
5
|
+
* can surface "Submit for Approval" / "Recall" actions and a status badge.
|
|
6
|
+
*
|
|
7
|
+
* Talks directly to the framework REST endpoints under
|
|
8
|
+
* `/api/v1/approvals/*`. Fails open: if the approvals plugin is not
|
|
9
|
+
* installed (404) or the user has no identity, returns inert state so the
|
|
10
|
+
* detail view continues to render normally.
|
|
11
|
+
*/
|
|
12
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
13
|
+
function apiBase() {
|
|
14
|
+
const url = import.meta.env?.VITE_SERVER_URL || '';
|
|
15
|
+
return `${String(url).replace(/\/$/, '')}/api/v1`;
|
|
16
|
+
}
|
|
17
|
+
async function fetchJson(path, init) {
|
|
18
|
+
const res = await fetch(`${apiBase()}${path}`, {
|
|
19
|
+
credentials: 'include',
|
|
20
|
+
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
|
|
21
|
+
...init,
|
|
22
|
+
});
|
|
23
|
+
let payload = null;
|
|
24
|
+
try {
|
|
25
|
+
payload = await res.json();
|
|
26
|
+
}
|
|
27
|
+
catch { /* empty */ }
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const err = new Error(payload?.error || `HTTP ${res.status}`);
|
|
30
|
+
err.code = payload?.code ?? `HTTP_${res.status}`;
|
|
31
|
+
err.status = res.status;
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
return payload;
|
|
35
|
+
}
|
|
36
|
+
export function useRecordApprovals(objectName, recordId, currentUserId) {
|
|
37
|
+
const [loading, setLoading] = useState(false);
|
|
38
|
+
const [available, setAvailable] = useState(true);
|
|
39
|
+
const [processes, setProcesses] = useState([]);
|
|
40
|
+
const [requests, setRequests] = useState([]);
|
|
41
|
+
const unavailableRef = useRef(false);
|
|
42
|
+
const refresh = useCallback(async () => {
|
|
43
|
+
if (!objectName || !recordId)
|
|
44
|
+
return;
|
|
45
|
+
if (unavailableRef.current)
|
|
46
|
+
return;
|
|
47
|
+
setLoading(true);
|
|
48
|
+
try {
|
|
49
|
+
const [procResp, reqResp] = await Promise.all([
|
|
50
|
+
fetchJson(`/approvals/processes?object=${encodeURIComponent(objectName)}&activeOnly=true`),
|
|
51
|
+
fetchJson(`/approvals/requests?object=${encodeURIComponent(objectName)}&recordId=${encodeURIComponent(recordId)}`),
|
|
52
|
+
]);
|
|
53
|
+
setProcesses(procResp?.data ?? []);
|
|
54
|
+
setRequests(reqResp?.data ?? []);
|
|
55
|
+
setAvailable(true);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
if (err?.status === 404) {
|
|
59
|
+
unavailableRef.current = true;
|
|
60
|
+
setAvailable(false);
|
|
61
|
+
}
|
|
62
|
+
// Other errors are transient — silently keep last state.
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
setLoading(false);
|
|
66
|
+
}
|
|
67
|
+
}, [objectName, recordId]);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!objectName || !recordId) {
|
|
70
|
+
setProcesses([]);
|
|
71
|
+
setRequests([]);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
refresh();
|
|
75
|
+
}, [objectName, recordId, refresh]);
|
|
76
|
+
const pendingRequest = useMemo(() => requests.find((r) => r.status === 'pending') ?? null, [requests]);
|
|
77
|
+
const latestRequest = useMemo(() => {
|
|
78
|
+
if (requests.length === 0)
|
|
79
|
+
return null;
|
|
80
|
+
const sorted = [...requests].sort((a, b) => {
|
|
81
|
+
const at = a.submitted_at || a.completed_at || '';
|
|
82
|
+
const bt = b.submitted_at || b.completed_at || '';
|
|
83
|
+
return bt.localeCompare(at);
|
|
84
|
+
});
|
|
85
|
+
return sorted[0] ?? null;
|
|
86
|
+
}, [requests]);
|
|
87
|
+
const canSubmit = available && processes.length > 0 && !pendingRequest;
|
|
88
|
+
const canRecall = !!pendingRequest && !!currentUserId
|
|
89
|
+
&& pendingRequest.submitter_id === currentUserId;
|
|
90
|
+
const submit = useCallback(async (input) => {
|
|
91
|
+
if (!objectName || !recordId)
|
|
92
|
+
throw new Error('Missing object or record');
|
|
93
|
+
const processName = input?.processName
|
|
94
|
+
?? (processes.length === 1 ? processes[0].name : undefined);
|
|
95
|
+
const row = await fetchJson(`/approvals/requests`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
object: objectName,
|
|
99
|
+
recordId,
|
|
100
|
+
...(processName ? { processName } : {}),
|
|
101
|
+
...(input?.comment ? { comment: input.comment } : {}),
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
await refresh();
|
|
105
|
+
return row;
|
|
106
|
+
}, [objectName, recordId, processes, refresh]);
|
|
107
|
+
const recall = useCallback(async (input) => {
|
|
108
|
+
if (!pendingRequest)
|
|
109
|
+
throw new Error('No pending request');
|
|
110
|
+
const out = await fetchJson(`/approvals/requests/${encodeURIComponent(pendingRequest.id)}/recall`, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
...(currentUserId ? { actorId: currentUserId } : {}),
|
|
114
|
+
...(input?.comment ? { comment: input.comment } : {}),
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
await refresh();
|
|
118
|
+
return out.request;
|
|
119
|
+
}, [pendingRequest, currentUserId, refresh]);
|
|
120
|
+
return {
|
|
121
|
+
loading,
|
|
122
|
+
available,
|
|
123
|
+
processes,
|
|
124
|
+
pendingRequest,
|
|
125
|
+
latestRequest,
|
|
126
|
+
canSubmit,
|
|
127
|
+
canRecall,
|
|
128
|
+
submit,
|
|
129
|
+
recall,
|
|
130
|
+
refresh,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -57,7 +57,7 @@ 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(Activity, { className: "h-4 w-4" }), activities.length > 0 && (_jsx("span", { className: "absolute
|
|
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", { "aria-hidden": "true", className: "absolute top-1 right-1 h-1.5 w-1.5 rounded-full bg-sky-500 ring-2 ring-background" }))] }) }), _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));
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* @module
|
|
18
18
|
*/
|
|
19
19
|
import { type PresenceUser } from '@object-ui/collaboration';
|
|
20
|
-
import {
|
|
20
|
+
import type { ActivityItem } from './ActivityFeed';
|
|
21
21
|
import type { ConnectionState } from '@object-ui/data-objectstack';
|
|
22
22
|
export type AppHeaderVariant = 'app' | 'home' | 'orgs';
|
|
23
23
|
export interface AppHeaderProps {
|
package/dist/layout/AppHeader.js
CHANGED
|
@@ -19,14 +19,14 @@ 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,
|
|
22
|
+
import { Search, HelpCircle, ChevronDown, Settings, LogOut, User as UserIcon, Boxes, } 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';
|
|
26
26
|
import { ModeToggle } from './ModeToggle';
|
|
27
27
|
import { LocaleSwitcher } from './LocaleSwitcher';
|
|
28
28
|
import { ConnectionStatus } from './ConnectionStatus';
|
|
29
|
-
import {
|
|
29
|
+
import { InboxPopover } from './InboxPopover';
|
|
30
30
|
import { AppSwitcher } from './AppSwitcher';
|
|
31
31
|
import { useAdapter } from '../providers/AdapterProvider';
|
|
32
32
|
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
|
|
@@ -375,18 +375,5 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
|
|
|
375
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) => {
|
|
376
376
|
const isLast = i === extraSegments.length - 1;
|
|
377
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));
|
|
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("
|
|
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: () => {
|
|
382
|
-
markNotificationRead(n.id);
|
|
383
|
-
if (n.source_object && n.source_id) {
|
|
384
|
-
// Navigate to the related record; relative to /apps/<currentApp> if any.
|
|
385
|
-
const app = currentAppName ?? params.appName;
|
|
386
|
-
const target = app
|
|
387
|
-
? `/apps/${app}/${n.source_object}/${n.source_id}`
|
|
388
|
-
: `/objects/${n.source_object}/${n.source_id}`;
|
|
389
|
-
navigate(target);
|
|
390
|
-
}
|
|
391
|
-
}, children: [_jsx("div", { className: "text-sm font-medium leading-tight", children: n.title }), n.body && (_jsx("div", { className: "text-xs text-muted-foreground line-clamp-2", children: n.body })), n.created_at && (_jsx("div", { className: "text-[10px] text-muted-foreground", children: new Date(n.created_at).toLocaleString() }))] }, n.id))))] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 hidden md:flex shrink-0", asChild: true, "aria-label": t('sidebar.helpTooltip', { defaultValue: 'Help & Documentation' }), children: _jsx("a", { href: "https://docs.objectstack.ai", target: "_blank", rel: "noopener noreferrer", children: _jsx(HelpCircle, { 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(ModeToggle, {}) }), _jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(LocaleSwitcher, {}) }), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0 rounded-full", children: _jsxs(Avatar, { className: "h-7 w-7 rounded-full", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-full bg-primary text-primary-foreground text-xs", children: getUserInitials(user) })] }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "min-w-64 rounded-lg", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-2 py-2", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] })] }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [hasOrgSection && (_jsxs(DropdownMenuItem, { onClick: () => navigate('/organizations'), className: "cursor-pointer", children: [_jsx(Boxes, { className: "mr-2 h-4 w-4" }), t('organizations.mine', { defaultValue: 'My Organizations' })] })), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup/system/profile'), children: [_jsx(UserIcon, { className: "mr-2 h-4 w-4" }), t('user.profile', { defaultValue: 'Profile' })] }), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup'), children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('sidebar.settings', { defaultValue: 'Settings' })] })] }), isAuthEnabled && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "text-destructive focus:text-destructive", onClick: () => signOut(), children: [_jsx(LogOut, { className: "mr-2 h-4 w-4" }), t('user.logout', { defaultValue: 'Log out' })] })] }))] })] })] })] })] }));
|
|
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(InboxPopover, { notifications: notifications, unreadCount: unreadCount, pendingApprovalsCount: pendingApprovalsCount, activities: activeActivities, onMarkAllRead: markAllRead, onMarkRead: markNotificationRead }), _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 hidden md:flex shrink-0", asChild: true, "aria-label": t('sidebar.helpTooltip', { defaultValue: 'Help & Documentation' }), children: _jsx("a", { href: "https://docs.objectstack.ai", target: "_blank", rel: "noopener noreferrer", children: _jsx(HelpCircle, { className: "h-4 w-4" }) }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [" ", _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0 rounded-full", children: _jsxs(Avatar, { className: "h-7 w-7 rounded-full", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-full bg-primary text-primary-foreground text-xs", children: getUserInitials(user) })] }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "min-w-64 rounded-lg", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-2 py-2", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] })] }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [hasOrgSection && (_jsxs(DropdownMenuItem, { onClick: () => navigate('/organizations'), className: "cursor-pointer", children: [_jsx(Boxes, { className: "mr-2 h-4 w-4" }), t('organizations.mine', { defaultValue: 'My Organizations' })] })), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup/system/profile'), children: [_jsx(UserIcon, { className: "mr-2 h-4 w-4" }), t('user.profile', { defaultValue: 'Profile' })] }), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup'), children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('sidebar.settings', { defaultValue: 'Settings' })] })] }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuLabel, { className: "text-[11px] font-normal text-muted-foreground uppercase tracking-wide px-2", children: t('user.preferences', { defaultValue: 'Preferences' }) }), _jsxs("div", { className: "flex items-center justify-between px-2 py-1.5 text-sm", children: [_jsx("span", { className: "text-foreground/80", children: t('user.theme', { defaultValue: 'Theme' }) }), _jsx(ModeToggle, {})] }), _jsxs("div", { className: "flex items-center justify-between px-2 py-1.5 text-sm", children: [_jsx("span", { className: "text-foreground/80", children: t('user.language', { defaultValue: 'Language' }) }), _jsx(LocaleSwitcher, {})] }), isAuthEnabled && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "text-destructive focus:text-destructive", onClick: () => signOut(), children: [_jsx(LogOut, { className: "mr-2 h-4 w-4" }), t('user.logout', { defaultValue: 'Log out' })] })] }))] })] })] })] })] }));
|
|
392
379
|
}
|
|
@@ -39,7 +39,7 @@ export function ConsoleLayout({ children, activeAppName, activeApp, onAppChange,
|
|
|
39
39
|
setContext('app');
|
|
40
40
|
setCurrentAppName(activeAppName);
|
|
41
41
|
}, [setContext, setCurrentAppName, activeAppName]);
|
|
42
|
-
return (_jsxs(AppShell, { sidebar: _jsx(UnifiedSidebar, { activeAppName: activeAppName, onAppChange: onAppChange }), navbar: _jsx(AppHeader, { variant: "app", appName: appLabel, objects: objects, connectionState: connectionState, activeAppName: activeAppName, onAppChange: onAppChange }), className: "!p-0 overflow-hidden bg-muted/5", branding: activeApp?.branding
|
|
42
|
+
return (_jsxs(AppShell, { sidebar: _jsx(UnifiedSidebar, { activeAppName: activeAppName, onAppChange: onAppChange }), navbar: _jsx(AppHeader, { variant: "app", appName: appLabel, objects: objects, connectionState: connectionState, activeAppName: activeAppName, onAppChange: onAppChange }), className: "!p-0 overflow-y-auto overflow-x-hidden bg-muted/5", branding: activeApp?.branding
|
|
43
43
|
? {
|
|
44
44
|
primaryColor: activeApp.branding.primaryColor,
|
|
45
45
|
accentColor: activeApp.branding.accentColor,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InboxPopover
|
|
3
|
+
*
|
|
4
|
+
* UX P0-2: a single "inbox" surface that bundles the three things that
|
|
5
|
+
* used to occupy separate top-bar buttons:
|
|
6
|
+
* - Notifications (mentions, assignments, system alerts)
|
|
7
|
+
* - Approvals (pending approval requests for the user)
|
|
8
|
+
* - Activity (recent activity feed across the org)
|
|
9
|
+
*
|
|
10
|
+
* Rendered as a single bell button + popover with a tabbed body. The badge
|
|
11
|
+
* shows the combined unread count (notifications + approvals) so users
|
|
12
|
+
* still see at-a-glance pressure.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
import type { ActivityItem } from './ActivityFeed';
|
|
17
|
+
export interface InboxNotification {
|
|
18
|
+
id: string;
|
|
19
|
+
type: string;
|
|
20
|
+
title: string;
|
|
21
|
+
body?: string | null;
|
|
22
|
+
source_object?: string | null;
|
|
23
|
+
source_id?: string | null;
|
|
24
|
+
actor_name?: string | null;
|
|
25
|
+
is_read?: boolean;
|
|
26
|
+
created_at?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface InboxPopoverProps {
|
|
29
|
+
notifications: InboxNotification[];
|
|
30
|
+
unreadCount: number;
|
|
31
|
+
pendingApprovalsCount: number;
|
|
32
|
+
activities: ActivityItem[];
|
|
33
|
+
onMarkAllRead: () => void;
|
|
34
|
+
onMarkRead: (id: string) => void;
|
|
35
|
+
}
|
|
36
|
+
export declare function InboxPopover({ notifications, unreadCount, pendingApprovalsCount, activities, onMarkAllRead, onMarkRead, }: InboxPopoverProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -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
|
}
|
package/dist/views/ObjectView.js
CHANGED
|
@@ -114,7 +114,7 @@ function toSysViewPayload(config, objectName, opts = {}) {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
const EXTRA_CONFIG_KEYS = [
|
|
117
|
-
'bulkActions', 'rowActions', 'pagination', 'navigation',
|
|
117
|
+
'bulkActions', 'bulkActionDefs', 'rowActions', 'pagination', 'navigation',
|
|
118
118
|
'emptyState', 'exportOptions', 'rowHeight',
|
|
119
119
|
'isPinned', 'isDefault', 'visibility', 'sortOrder',
|
|
120
120
|
'showSort',
|
|
@@ -1129,13 +1129,23 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
|
|
|
1129
1129
|
const isAbsolute = targetStr.startsWith('/') || /^https?:\/\//i.test(targetStr);
|
|
1130
1130
|
if (isAbsolute) {
|
|
1131
1131
|
const baseUrl = import.meta.env.VITE_SERVER_URL || '';
|
|
1132
|
-
const url = targetStr.startsWith('http') ? targetStr : `${baseUrl}${targetStr}`;
|
|
1133
1132
|
// Row context is stashed on params under `_rowRecord` by the
|
|
1134
1133
|
// row-action dispatcher (see ObjectGrid → onRowAction). Pull
|
|
1135
1134
|
// it out before assembling the request body.
|
|
1136
1135
|
const rawParams = { ...params };
|
|
1137
1136
|
const rowRecord = rawParams._rowRecord;
|
|
1138
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}`;
|
|
1139
1149
|
// Apply bodyShape: 'flat' (default) keeps user params at top
|
|
1140
1150
|
// level; { wrap: 'data' } nests them under that key while
|
|
1141
1151
|
// `recordIdParam` / `organizationId` stay flat (better-auth
|
|
@@ -1153,18 +1163,33 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
|
|
|
1153
1163
|
if (rowValue != null)
|
|
1154
1164
|
body[action.recordIdParam] = rowValue;
|
|
1155
1165
|
}
|
|
1156
|
-
// Auto-inject organizationId
|
|
1157
|
-
//
|
|
1158
|
-
//
|
|
1159
|
-
|
|
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) {
|
|
1160
1172
|
body.organizationId = activeOrganization.id;
|
|
1161
1173
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
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,
|
|
1164
1183
|
headers: { 'Content-Type': 'application/json' },
|
|
1165
1184
|
credentials: 'include',
|
|
1166
|
-
|
|
1167
|
-
|
|
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);
|
|
1168
1193
|
if (!res.ok) {
|
|
1169
1194
|
let detail = `HTTP ${res.status}`;
|
|
1170
1195
|
try {
|
|
@@ -1478,6 +1503,7 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
|
|
|
1478
1503
|
? objectDef.actions.filter((a) => Array.isArray(a?.locations) && a.locations.includes('list_item'))
|
|
1479
1504
|
: []),
|
|
1480
1505
|
bulkActions: viewDef.bulkActions ?? listSchema.bulkActions,
|
|
1506
|
+
bulkActionDefs: viewDef.bulkActionDefs ?? listSchema.bulkActionDefs,
|
|
1481
1507
|
sharing: viewDef.sharing ?? listSchema.sharing,
|
|
1482
1508
|
addRecord: viewDef.addRecord ?? listSchema.addRecord,
|
|
1483
1509
|
conditionalFormatting: viewDef.conditionalFormatting ?? listSchema.conditionalFormatting,
|
|
@@ -6,13 +6,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
* Uses the DetailView plugin component with auto-generated sections from
|
|
7
7
|
* the object field definitions.
|
|
8
8
|
*/
|
|
9
|
-
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
9
|
+
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
10
10
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
11
11
|
import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
|
|
12
12
|
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
|
|
13
13
|
import { PresenceAvatars } from '@object-ui/collaboration';
|
|
14
14
|
import { useAuth, createAuthenticatedFetch } from '@object-ui/auth';
|
|
15
|
-
import { ActionProvider, useObjectTranslation, useObjectLabel } from '@object-ui/react';
|
|
15
|
+
import { ActionProvider, useObjectTranslation, useObjectLabel, usePageAssignment, RecordContextProvider, SchemaRenderer } from '@object-ui/react';
|
|
16
|
+
import { buildExpandFields } from '@object-ui/core';
|
|
16
17
|
import { toast } from 'sonner';
|
|
17
18
|
import { Database, Users } from 'lucide-react';
|
|
18
19
|
import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
|
|
@@ -23,6 +24,7 @@ import { ActionConfirmDialog } from './ActionConfirmDialog';
|
|
|
23
24
|
import { ActionParamDialog } from './ActionParamDialog';
|
|
24
25
|
import { resolveActionParams } from '../utils/resolveActionParams';
|
|
25
26
|
import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
|
|
27
|
+
import { useRecordApprovals } from '../hooks/useRecordApprovals';
|
|
26
28
|
import { getRecordDisplayName } from '../utils';
|
|
27
29
|
const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
|
|
28
30
|
/**
|
|
@@ -35,6 +37,16 @@ const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
|
|
|
35
37
|
* `@object-ui/plugin-detail/RecordDetailDrawer`.
|
|
36
38
|
*/
|
|
37
39
|
const AUDIT_FIELD_NAMES = new Set(['created_at', 'created_by', 'updated_at', 'updated_by']);
|
|
40
|
+
/**
|
|
41
|
+
* System/tenant fields that the framework auto-injects on every record but
|
|
42
|
+
* which carry no business value on a detail page. Hidden from the
|
|
43
|
+
* auto-generated section (when the object has no explicit form sections).
|
|
44
|
+
* Authors who really want to show these can still list them in
|
|
45
|
+
* `objectDef.views.form.sections`.
|
|
46
|
+
*/
|
|
47
|
+
const HIDDEN_SYSTEM_FIELD_NAMES = new Set([
|
|
48
|
+
'organization_id', 'tenant_id', 'is_deleted', 'deleted_at',
|
|
49
|
+
]);
|
|
38
50
|
export function RecordDetailView({ dataSource, objects, onEdit }) {
|
|
39
51
|
const { appName, objectName, recordId } = useParams();
|
|
40
52
|
const { showDebug } = useMetadataInspector();
|
|
@@ -47,6 +59,8 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
|
|
|
47
59
|
const [recordViewers, setRecordViewers] = useState([]);
|
|
48
60
|
const [actionRefreshKey, setActionRefreshKey] = useState(0);
|
|
49
61
|
const [childRelatedData, setChildRelatedData] = useState({});
|
|
62
|
+
const [historyEntries, setHistoryEntries] = useState(null);
|
|
63
|
+
const [historyLoading, setHistoryLoading] = useState(false);
|
|
50
64
|
const [recordTitle, setRecordTitle] = useState();
|
|
51
65
|
const objectDef = objects.find((o) => o.name === objectName);
|
|
52
66
|
// Publish record title to the navigation context so the top-bar breadcrumb
|
|
@@ -56,6 +70,40 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
|
|
|
56
70
|
// Navigation code passes `record.id || record._id` directly into the URL
|
|
57
71
|
// without adding any prefix, so no stripping is needed.
|
|
58
72
|
const pureRecordId = recordId;
|
|
73
|
+
// ─── Page Assignment (Salesforce Lightning-style record Pages) ──────
|
|
74
|
+
// If a PageSchema(pageType='record') is authored for this object, render
|
|
75
|
+
// it via SchemaRenderer (which dispatches to the registered 'record'
|
|
76
|
+
// PageRenderer in @object-ui/components). Otherwise we fall through to
|
|
77
|
+
// the legacy auto-generated DetailView path below.
|
|
78
|
+
const { page: assignedPage } = usePageAssignment(objectName);
|
|
79
|
+
const [pageRecord, setPageRecord] = useState(null);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
let cancelled = false;
|
|
82
|
+
if (!assignedPage || !pureRecordId || !objectName || !dataSource?.findOne) {
|
|
83
|
+
setPageRecord(null);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Expand lookup/master_detail fields so the page receives display
|
|
87
|
+
// names (e.g. account.name) rather than raw foreign-key IDs. The
|
|
88
|
+
// page subtitle interpolation and record:* renderers depend on this.
|
|
89
|
+
const expandFields = buildExpandFields(objectDef?.fields);
|
|
90
|
+
const params = expandFields.length > 0 ? { $expand: expandFields } : undefined;
|
|
91
|
+
const findOnePromise = params
|
|
92
|
+
? dataSource.findOne(objectName, pureRecordId, params)
|
|
93
|
+
: dataSource.findOne(objectName, pureRecordId);
|
|
94
|
+
findOnePromise
|
|
95
|
+
.then((rec) => {
|
|
96
|
+
if (!cancelled)
|
|
97
|
+
setPageRecord(rec);
|
|
98
|
+
})
|
|
99
|
+
.catch(() => {
|
|
100
|
+
if (!cancelled)
|
|
101
|
+
setPageRecord(null);
|
|
102
|
+
});
|
|
103
|
+
return () => {
|
|
104
|
+
cancelled = true;
|
|
105
|
+
};
|
|
106
|
+
}, [assignedPage, objectName, pureRecordId, dataSource, objectDef]);
|
|
59
107
|
// ─── Action Provider Handlers ───────────────────────────────────────
|
|
60
108
|
// Confirm dialog state (promise-based)
|
|
61
109
|
const [confirmState, setConfirmState] = useState({ open: false, message: '' });
|
|
@@ -196,6 +244,41 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
|
|
|
196
244
|
return { success: false, error: error.message };
|
|
197
245
|
}
|
|
198
246
|
}, [authFetch, pureRecordId, objectName]);
|
|
247
|
+
// ─── Approvals ─────────────────────────────────────────────────────
|
|
248
|
+
// Surfaces "Submit for Approval" / "Recall" buttons on the record header
|
|
249
|
+
// when an active approval process is registered for this object, and a
|
|
250
|
+
// status badge when a request exists.
|
|
251
|
+
const approvals = useRecordApprovals(objectName, pureRecordId, user?.id);
|
|
252
|
+
// Hold latest approvals snapshot in a ref so the action handler
|
|
253
|
+
// (memoized once inside ActionRunner) always sees fresh state instead of
|
|
254
|
+
// the stale closure captured at the first render.
|
|
255
|
+
const approvalsRef = useRef(approvals);
|
|
256
|
+
approvalsRef.current = approvals;
|
|
257
|
+
const approvalHandler = useCallback(async (action) => {
|
|
258
|
+
const target = action.target || action.name;
|
|
259
|
+
const params = (action.params && !Array.isArray(action.params))
|
|
260
|
+
? action.params
|
|
261
|
+
: {};
|
|
262
|
+
try {
|
|
263
|
+
if (target === 'submit_approval') {
|
|
264
|
+
await approvalsRef.current.submit({
|
|
265
|
+
processName: params.processName,
|
|
266
|
+
comment: params.comment,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
else if (target === 'recall_approval') {
|
|
270
|
+
await approvalsRef.current.recall({ comment: params.comment });
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
return { success: false, error: `Unknown approval target: ${target}` };
|
|
274
|
+
}
|
|
275
|
+
setActionRefreshKey((k) => k + 1);
|
|
276
|
+
return { success: true, reload: true };
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
return { success: false, error: err?.message || String(err) };
|
|
280
|
+
}
|
|
281
|
+
}, []);
|
|
199
282
|
// Discover reverse references: other objects with lookup/master_detail fields
|
|
200
283
|
// pointing to the current object (e.g., order_item.order → order).
|
|
201
284
|
const childRelations = useMemo(() => {
|
|
@@ -245,6 +328,61 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
|
|
|
245
328
|
});
|
|
246
329
|
return () => { cancelled = true; };
|
|
247
330
|
}, [dataSource, pureRecordId, childRelations]);
|
|
331
|
+
// ── Audit history fetch ────────────────────────────────────────────
|
|
332
|
+
// Loads recent sys_audit_log entries for this record so the DetailView can
|
|
333
|
+
// render a read-only "History" tab. Gated on three preconditions to keep
|
|
334
|
+
// the network and the UI quiet for objects that opt out of history:
|
|
335
|
+
// 1) trackHistory must be explicitly true on the object capabilities
|
|
336
|
+
// (the framework default is false, so we never speculatively fetch).
|
|
337
|
+
// 2) sys_audit_log must be present in the registered objects list — if
|
|
338
|
+
// the platform-objects package isn't deployed the tab makes no sense.
|
|
339
|
+
// 3) The object being viewed must not be sys_audit_log itself, to avoid
|
|
340
|
+
// a recursive tab on the audit log detail page.
|
|
341
|
+
// We request only the safe projection (created_at, action, user_id) so the
|
|
342
|
+
// browser never receives serialized old_value/new_value payloads, which
|
|
343
|
+
// can contain restricted fields. Field-level redaction in PR2 will harden
|
|
344
|
+
// this further once a backend-scoped audit endpoint exists.
|
|
345
|
+
const historyEnabled = useMemo(() => {
|
|
346
|
+
if (!objectDef)
|
|
347
|
+
return false;
|
|
348
|
+
if (objectDef.name === 'sys_audit_log')
|
|
349
|
+
return false;
|
|
350
|
+
if (objectDef.enable?.trackHistory !== true)
|
|
351
|
+
return false;
|
|
352
|
+
return objects.some((o) => o.name === 'sys_audit_log');
|
|
353
|
+
}, [objectDef, objects]);
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
if (!dataSource || !pureRecordId || !objectDef || !historyEnabled) {
|
|
356
|
+
setHistoryEntries(null);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
let cancelled = false;
|
|
360
|
+
setHistoryLoading(true);
|
|
361
|
+
dataSource
|
|
362
|
+
.find('sys_audit_log', {
|
|
363
|
+
$filter: { record_id: pureRecordId, object_name: objectDef.name },
|
|
364
|
+
$orderby: { created_at: 'desc' },
|
|
365
|
+
$top: 50,
|
|
366
|
+
$select: ['id', 'created_at', 'action', 'user_id'],
|
|
367
|
+
})
|
|
368
|
+
.then((res) => {
|
|
369
|
+
if (cancelled)
|
|
370
|
+
return;
|
|
371
|
+
const items = Array.isArray(res) ? res : res?.data || [];
|
|
372
|
+
setHistoryEntries(items);
|
|
373
|
+
})
|
|
374
|
+
.catch((err) => {
|
|
375
|
+
if (cancelled)
|
|
376
|
+
return;
|
|
377
|
+
console.warn('[RecordDetailView] Failed to fetch sys_audit_log:', err);
|
|
378
|
+
setHistoryEntries([]);
|
|
379
|
+
})
|
|
380
|
+
.finally(() => {
|
|
381
|
+
if (!cancelled)
|
|
382
|
+
setHistoryLoading(false);
|
|
383
|
+
});
|
|
384
|
+
return () => { cancelled = true; };
|
|
385
|
+
}, [dataSource, pureRecordId, objectDef, historyEnabled]);
|
|
248
386
|
// Memoize so the object identity is stable across renders — otherwise
|
|
249
387
|
// any effect that depends on it (e.g. the feed loader below) would
|
|
250
388
|
// re-fire every render and create an infinite request loop.
|
|
@@ -556,7 +694,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
|
|
|
556
694
|
// redundant "Details" heading).
|
|
557
695
|
showBorder: false,
|
|
558
696
|
fields: Object.keys(objectDef.fields || {})
|
|
559
|
-
.filter(key => !AUDIT_FIELD_NAMES.has(key))
|
|
697
|
+
.filter(key => !AUDIT_FIELD_NAMES.has(key) && !HIDDEN_SYSTEM_FIELD_NAMES.has(key))
|
|
560
698
|
.map(key => {
|
|
561
699
|
const fieldDef = objectDef.fields[key];
|
|
562
700
|
const refTarget = fieldDef.reference_to || fieldDef.reference;
|
|
@@ -581,7 +719,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
|
|
|
581
719
|
// Filter actions for record_header location and deduplicate by name
|
|
582
720
|
const recordHeaderActions = (() => {
|
|
583
721
|
const seen = new Set();
|
|
584
|
-
|
|
722
|
+
const base = (objectDef.actions || []).filter((a) => {
|
|
585
723
|
if (!a.locations?.includes('record_header'))
|
|
586
724
|
return false;
|
|
587
725
|
if (!a.name)
|
|
@@ -600,6 +738,59 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
|
|
|
600
738
|
successMessage: actionSuccess(objectDef.name, a.name, a.successMessage),
|
|
601
739
|
}),
|
|
602
740
|
}));
|
|
741
|
+
// Inject approval actions — only when the approvals plugin is
|
|
742
|
+
// available and an active process exists for this object.
|
|
743
|
+
if (approvals.available && approvals.processes.length > 0) {
|
|
744
|
+
if (approvals.canSubmit) {
|
|
745
|
+
base.push({
|
|
746
|
+
name: 'submit_approval',
|
|
747
|
+
type: 'approval',
|
|
748
|
+
target: 'submit_approval',
|
|
749
|
+
label: t('approvals.submitForApproval', { defaultValue: 'Submit for Approval' }),
|
|
750
|
+
icon: 'send',
|
|
751
|
+
variant: 'default',
|
|
752
|
+
locations: ['record_header'],
|
|
753
|
+
refreshAfter: true,
|
|
754
|
+
successMessage: t('approvals.submitSuccess', { defaultValue: 'Approval request submitted' }),
|
|
755
|
+
...(approvals.processes.length === 1
|
|
756
|
+
? { params: { processName: approvals.processes[0].name } }
|
|
757
|
+
: {
|
|
758
|
+
collectParams: [{
|
|
759
|
+
name: 'processName',
|
|
760
|
+
label: t('approvals.process', { defaultValue: 'Process' }),
|
|
761
|
+
type: 'select',
|
|
762
|
+
required: true,
|
|
763
|
+
options: approvals.processes.map((p) => ({
|
|
764
|
+
value: p.name,
|
|
765
|
+
label: p.label || p.name,
|
|
766
|
+
})),
|
|
767
|
+
}, {
|
|
768
|
+
name: 'comment',
|
|
769
|
+
label: t('approvals.comment', { defaultValue: 'Comment (optional)' }),
|
|
770
|
+
type: 'text',
|
|
771
|
+
multiline: true,
|
|
772
|
+
}],
|
|
773
|
+
}),
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
if (approvals.canRecall) {
|
|
777
|
+
base.push({
|
|
778
|
+
name: 'recall_approval',
|
|
779
|
+
type: 'approval',
|
|
780
|
+
target: 'recall_approval',
|
|
781
|
+
label: t('approvals.recall', { defaultValue: 'Recall' }),
|
|
782
|
+
icon: 'undo',
|
|
783
|
+
variant: 'outline',
|
|
784
|
+
locations: ['record_header'],
|
|
785
|
+
refreshAfter: true,
|
|
786
|
+
confirmText: t('approvals.recallConfirm', {
|
|
787
|
+
defaultValue: 'Recall this pending approval request?',
|
|
788
|
+
}),
|
|
789
|
+
successMessage: t('approvals.recallSuccess', { defaultValue: 'Approval recalled' }),
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return base;
|
|
603
794
|
})();
|
|
604
795
|
// Build highlightFields: exclusively from objectDef metadata (no hardcoded fallback)
|
|
605
796
|
const highlightFields = objectDef.views?.detail?.highlightFields ?? [];
|
|
@@ -710,6 +901,12 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
|
|
|
710
901
|
sections,
|
|
711
902
|
autoTabs: true,
|
|
712
903
|
autoDiscoverRelated: true,
|
|
904
|
+
...(historyEnabled && {
|
|
905
|
+
history: {
|
|
906
|
+
entries: historyEntries ?? [],
|
|
907
|
+
loading: historyLoading && historyEntries === null,
|
|
908
|
+
},
|
|
909
|
+
}),
|
|
713
910
|
...(related.length > 0 && { related }),
|
|
714
911
|
...(highlightFields.length > 0 && { highlightFields }),
|
|
715
912
|
...(sectionGroups && sectionGroups.length > 0 && { sectionGroups }),
|
|
@@ -722,14 +919,17 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
|
|
|
722
919
|
}),
|
|
723
920
|
};
|
|
724
921
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
725
|
-
}, [objectDef?.name, pureRecordId, childRelatedData, actionRefreshKey, appName, navigate, dataSource, t, objectLabel, objects]);
|
|
922
|
+
}, [objectDef?.name, pureRecordId, childRelatedData, actionRefreshKey, appName, navigate, dataSource, t, objectLabel, objects, historyEnabled, historyEntries, historyLoading, approvals.available, approvals.processes, approvals.canSubmit, approvals.canRecall, approvals.pendingRequest, approvals.latestRequest]);
|
|
726
923
|
if (isLoading) {
|
|
727
924
|
return _jsx(SkeletonDetail, {});
|
|
728
925
|
}
|
|
729
926
|
if (!objectDef) {
|
|
730
927
|
return (_jsx("div", { className: "flex h-full items-center justify-center p-4", children: _jsxs(Empty, { children: [_jsx("div", { className: "mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted", children: _jsx(Database, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('empty.objectNotFound') }), _jsx(EmptyDescription, { children: t('empty.objectNotFoundDescription', { name: objectName }) })] }) }));
|
|
731
928
|
}
|
|
732
|
-
|
|
929
|
+
if (assignedPage) {
|
|
930
|
+
return (_jsx(RecordContextProvider, { objectName: objectName, recordId: pureRecordId, data: pageRecord, objectSchema: objectDef, dataSource: dataSource, children: _jsx(ActionProvider, { context: { record: pageRecord || {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler, approval: approvalHandler }, children: _jsx("div", { className: "bg-background p-3 sm:p-4 lg:p-6", children: _jsx(SchemaRenderer, { schema: assignedPage }) }) }) }));
|
|
931
|
+
}
|
|
932
|
+
return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsxs("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: [_jsx(ManagedByBadge, { managedBy: objectDef?.managedBy }), recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: t('recordDetail.viewersTooltip'), children: [_jsx(Users, { className: "h-3.5 w-3.5 text-muted-foreground" }), _jsx(PresenceAvatars, { users: recordViewers, size: "sm", maxVisible: 4, showStatus: true })] }))] }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsx("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: _jsx(ActionProvider, { context: { record: {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler, approval: approvalHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), onDataLoaded: (record) => {
|
|
733
933
|
if (!record || typeof record !== 'object')
|
|
734
934
|
return;
|
|
735
935
|
// Resolve the same way DetailView's header does, so the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/app-shell",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Minimal application shell for ObjectUI - framework-agnostic rendering engine",
|
|
@@ -27,34 +27,34 @@
|
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"lucide-react": "^1.16.0",
|
|
29
29
|
"sonner": "^2.0.7",
|
|
30
|
-
"@object-ui/auth": "4.
|
|
31
|
-
"@object-ui/collaboration": "4.
|
|
32
|
-
"@object-ui/components": "4.
|
|
33
|
-
"@object-ui/core": "4.
|
|
34
|
-
"@object-ui/data-objectstack": "4.
|
|
35
|
-
"@object-ui/fields": "4.
|
|
36
|
-
"@object-ui/i18n": "4.
|
|
37
|
-
"@object-ui/layout": "4.
|
|
38
|
-
"@object-ui/permissions": "4.
|
|
39
|
-
"@object-ui/react": "4.
|
|
40
|
-
"@object-ui/types": "4.
|
|
30
|
+
"@object-ui/auth": "4.4.0",
|
|
31
|
+
"@object-ui/collaboration": "4.4.0",
|
|
32
|
+
"@object-ui/components": "4.4.0",
|
|
33
|
+
"@object-ui/core": "4.4.0",
|
|
34
|
+
"@object-ui/data-objectstack": "4.4.0",
|
|
35
|
+
"@object-ui/fields": "4.4.0",
|
|
36
|
+
"@object-ui/i18n": "4.4.0",
|
|
37
|
+
"@object-ui/layout": "4.4.0",
|
|
38
|
+
"@object-ui/permissions": "4.4.0",
|
|
39
|
+
"@object-ui/react": "4.4.0",
|
|
40
|
+
"@object-ui/types": "4.4.0"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"react": "^18.0.0 || ^19.0.0",
|
|
44
44
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
45
45
|
"react-router-dom": "^6.0.0 || ^7.0.0",
|
|
46
|
-
"@object-ui/plugin-calendar": "^4.
|
|
47
|
-
"@object-ui/plugin-charts": "^4.
|
|
48
|
-
"@object-ui/plugin-chatbot": "^4.
|
|
49
|
-
"@object-ui/plugin-dashboard": "^4.
|
|
50
|
-
"@object-ui/plugin-designer": "^4.
|
|
51
|
-
"@object-ui/plugin-detail": "^4.
|
|
52
|
-
"@object-ui/plugin-form": "^4.
|
|
53
|
-
"@object-ui/plugin-grid": "^4.
|
|
54
|
-
"@object-ui/plugin-kanban": "^4.
|
|
55
|
-
"@object-ui/plugin-list": "^4.
|
|
56
|
-
"@object-ui/plugin-report": "^4.
|
|
57
|
-
"@object-ui/plugin-view": "^4.
|
|
46
|
+
"@object-ui/plugin-calendar": "^4.4.0",
|
|
47
|
+
"@object-ui/plugin-charts": "^4.4.0",
|
|
48
|
+
"@object-ui/plugin-chatbot": "^4.4.0",
|
|
49
|
+
"@object-ui/plugin-dashboard": "^4.4.0",
|
|
50
|
+
"@object-ui/plugin-designer": "^4.4.0",
|
|
51
|
+
"@object-ui/plugin-detail": "^4.4.0",
|
|
52
|
+
"@object-ui/plugin-form": "^4.4.0",
|
|
53
|
+
"@object-ui/plugin-grid": "^4.4.0",
|
|
54
|
+
"@object-ui/plugin-kanban": "^4.4.0",
|
|
55
|
+
"@object-ui/plugin-list": "^4.4.0",
|
|
56
|
+
"@object-ui/plugin-report": "^4.4.0",
|
|
57
|
+
"@object-ui/plugin-view": "^4.4.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^25.9.0",
|