@object-ui/app-shell 4.3.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,121 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 4.5.0
4
+
5
+ ### Patch Changes
6
+
7
+ - d714e85: Lookup display-name resolution now falls back through a Salesforce-style chain
8
+ when an `$expand`'d reference object lacks a top-level `name`/`label`/
9
+ `display_name`/`title` field:
10
+ 1. Standard display fields (existing behaviour)
11
+ 2. `salutation first_name last_name` composite — handles person records that
12
+ only carry first/last name parts
13
+ 3. `email` — last-resort identifier, beats the opaque id
14
+
15
+ Applies to `LookupCellRenderer`, `PageHeader.subtitle` interpolation,
16
+ `DetailView` page-mode `titleFormat`, and the shared `formatRecordTitle`
17
+ utility. Concretely: a Contact reference with `first_name: Bob`, `last_name:
18
+ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
19
+ the email or [object Object] fallback.
20
+
21
+ - Updated dependencies [ab5e281]
22
+ - Updated dependencies [d714e85]
23
+ - Updated dependencies [6b6afd1]
24
+ - Updated dependencies [22fa558]
25
+ - Updated dependencies [aa7855f]
26
+ - Updated dependencies [170d89f]
27
+ - @object-ui/types@4.5.0
28
+ - @object-ui/fields@4.5.0
29
+ - @object-ui/layout@4.5.0
30
+ - @object-ui/components@4.5.0
31
+ - @object-ui/i18n@4.5.0
32
+ - @object-ui/auth@4.5.0
33
+ - @object-ui/collaboration@4.5.0
34
+ - @object-ui/core@4.5.0
35
+ - @object-ui/data-objectstack@4.5.0
36
+ - @object-ui/permissions@4.5.0
37
+ - @object-ui/react@4.5.0
38
+
39
+ ## 4.4.0
40
+
41
+ ### Patch Changes
42
+
43
+ - 501ce20: fix(detail): hide system/tenant fields from auto-generated record detail
44
+
45
+ The auto-generated detail section (used when an object has no explicit form
46
+ sections) was leading every record page with `organization_id` (rendered as
47
+ "ORGANIZATION: Admin's Workspace") — pure tenancy metadata with no business
48
+ value. Extended the existing audit-field filter to also drop
49
+ `organization_id`, `tenant_id`, `is_deleted`, and `deleted_at`. Objects that
50
+ intentionally surface tenant info can still do so via explicit
51
+ `views.form.sections`.
52
+
53
+ - 63eb66d: fix(detail): expand lookup fields so subtitle + lookup cells show display names
54
+
55
+ The record-page fetch in `RecordDetailView` (the page-mode path) now
56
+ requests `$expand` for every lookup/master_detail field on the object,
57
+ mirroring the behaviour the legacy `DetailView` already had. Combined
58
+ with two small downstream fixes — `PageHeader` subtitle interpolation
59
+ now extracts `name/label` from expanded reference objects instead of
60
+ rendering `[object Object]`, and `LookupCellRenderer` now short-circuits
61
+ to `pickRecordDisplayName` when the value is already a nested record —
62
+ all `record:*` renderers and the page header subtitle (`Owned by
63
+ {account}`) now display the related record's name rather than the raw
64
+ foreign-key id.
65
+
66
+ - 2bd45af: feat(shell): main becomes the scroll container; record tabs are sticky
67
+ - `AppShell`'s SidebarProvider wrapper is now constrained to viewport
68
+ height (`h-svh overflow-hidden`) instead of expanding with content via
69
+ the default `min-h-svh`. This makes the inner `<main>` (which is
70
+ `overflow-auto`) the actual scroll container instead of the window.
71
+ - `RecordDetailView` page-mode container drops the redundant
72
+ `h-full overflow-auto` (avoids nested scrollers; main owns scroll now).
73
+ - `page:tabs` (horizontal) gets `sticky top-0 z-20` with a translucent
74
+ backdrop so the tab strip stays visible while users scroll through
75
+ long record pages — the Salesforce Lightning behaviour our schemas
76
+ were already implying.
77
+
78
+ - e33d575: Support dotted paths (e.g. `{account.name}`) in object `titleFormat`. When a
79
+ placeholder resolves to an expanded reference object, automatically extract
80
+ its `name`/`label`/`display_name`/`title` so detail page titles render the
81
+ related record's display name instead of falling through to the object label.
82
+ - Updated dependencies [63eb66d]
83
+ - Updated dependencies [67dabe1]
84
+ - Updated dependencies [ef0e30d]
85
+ - Updated dependencies [2bd45af]
86
+ - @object-ui/layout@4.4.0
87
+ - @object-ui/fields@4.4.0
88
+ - @object-ui/components@4.4.0
89
+ - @object-ui/types@4.4.0
90
+ - @object-ui/core@4.4.0
91
+ - @object-ui/i18n@4.4.0
92
+ - @object-ui/react@4.4.0
93
+ - @object-ui/data-objectstack@4.4.0
94
+ - @object-ui/auth@4.4.0
95
+ - @object-ui/permissions@4.4.0
96
+ - @object-ui/collaboration@4.4.0
97
+
98
+ ## 4.3.1
99
+
100
+ ### Patch Changes
101
+
102
+ - 9167935: fix
103
+ - 52af5cf: fix
104
+ - Updated dependencies [5f4ac6e]
105
+ - Updated dependencies [6b683c8]
106
+ - Updated dependencies [0d8eb98]
107
+ - @object-ui/i18n@4.3.1
108
+ - @object-ui/components@4.3.1
109
+ - @object-ui/layout@4.3.1
110
+ - @object-ui/fields@4.3.1
111
+ - @object-ui/react@4.3.1
112
+ - @object-ui/types@4.3.1
113
+ - @object-ui/core@4.3.1
114
+ - @object-ui/data-objectstack@4.3.1
115
+ - @object-ui/auth@4.3.1
116
+ - @object-ui/permissions@4.3.1
117
+ - @object-ui/collaboration@4.3.1
118
+
3
119
  ## 4.3.0
4
120
 
5
121
  ### 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: [_jsx(QuickActions, {}), 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))) })] })] })] }));
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
- if (isOrganizationsLoading) {
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', {
@@ -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';
@@ -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 -top-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-primary text-[9px] font-bold text-primary-foreground flex items-center justify-center", children: activities.length > 9 ? '9+' : activities.length }))] }) }), _jsxs(SheetContent, { side: "right", className: "w-80 sm:w-96", children: [_jsx(SheetHeader, { children: _jsxs(SheetTitle, { className: "flex items-center justify-between", children: [t('layout.activityFeed.title'), _jsxs(Button, { variant: showFilters ? 'secondary' : 'ghost', size: "sm", className: "h-7 px-2", onClick: () => setShowFilters(!showFilters), children: [_jsx(Filter, { className: "h-3.5 w-3.5 mr-1" }), t('layout.activityFeed.filter')] })] }) }), showFilters && (_jsx("div", { className: "flex flex-wrap gap-1.5 mt-3 px-1", children: Object.keys(typeConfig).map(type => {
60
+ return (_jsxs(Sheet, { open: open, onOpenChange: setOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "icon", className: className ?? 'h-8 w-8', "aria-label": t('layout.activityFeed.ariaLabel'), children: [_jsx(Activity, { className: "h-4 w-4" }), activities.length > 0 && (_jsx("span", { "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 { type ActivityItem } from './ActivityFeed';
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 {
@@ -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, Bell, CheckSquare, } from 'lucide-react';
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 { ActivityFeed } from './ActivityFeed';
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("div", { className: "hidden sm:flex shrink-0", children: _jsx(ActivityFeed, { activities: activeActivities }) }), pendingApprovalsCount >= 0 && (_jsxs(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 relative shrink-0", "aria-label": t('sidebar.approvals', { defaultValue: 'Approvals' }), title: t('sidebar.approvals', { defaultValue: 'Approvals' }), onClick: () => {
379
- const app = currentAppName ?? params.appName;
380
- navigate(app ? `/apps/${app}/system/approvals` : '/apps/setup/system/approvals');
381
- }, children: [_jsx(CheckSquare, { className: "h-4 w-4" }), pendingApprovalsCount > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 h-4 min-w-[16px] rounded-full bg-amber-500 text-[10px] leading-4 text-white text-center px-1", children: pendingApprovalsCount > 9 ? '9+' : pendingApprovalsCount }))] })), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 relative shrink-0", "aria-label": t('sidebar.notifications', { defaultValue: 'Notifications' }), children: [_jsx(Bell, { className: "h-4 w-4" }), unreadCount > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 h-4 min-w-[16px] rounded-full bg-red-500 text-[10px] leading-4 text-white text-center px-1", children: unreadCount > 9 ? '9+' : unreadCount }))] }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-80 max-h-96 overflow-auto", children: [_jsxs(DropdownMenuLabel, { className: "flex items-center justify-between", children: [_jsx("span", { children: t('sidebar.notifications', { defaultValue: 'Notifications' }) }), unreadCount > 0 && (_jsx("button", { type: "button", onClick: (e) => { e.preventDefault(); markAllRead(); }, className: "text-xs text-muted-foreground hover:text-foreground", children: t('notifications.markAllRead', { defaultValue: 'Mark all read' }) }))] }), _jsx(DropdownMenuSeparator, {}), notifications.length === 0 ? (_jsx("div", { className: "px-3 py-6 text-sm text-muted-foreground text-center", children: t('notifications.empty', { defaultValue: 'No notifications' }) })) : (notifications.map((n) => (_jsxs(DropdownMenuItem, { className: `flex flex-col items-start gap-1 ${n.is_read ? '' : 'bg-accent/40'}`, onSelect: () => {
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
+ }
@@ -61,7 +61,33 @@ export function formatRecordTitle(titleFormat, record) {
61
61
  }
62
62
  let anyResolved = false;
63
63
  let out = template.replace(/\{([^{}]+)\}/g, (_match, fieldName) => {
64
- const value = record[fieldName.trim()];
64
+ // Support dotted paths (e.g. `{account.name}`) for $expanded lookups.
65
+ const parts = String(fieldName).trim().split('.');
66
+ let value = record;
67
+ for (const p of parts) {
68
+ if (value == null)
69
+ break;
70
+ value = value[p];
71
+ }
72
+ // Auto-extract display name from expanded reference objects, with a
73
+ // Salesforce-style fallback chain.
74
+ if (value && typeof value === 'object') {
75
+ const o = value;
76
+ let display = o.name ?? o.full_name ?? o.display_name ?? o.label ?? o.title ?? o.subject ?? null;
77
+ if (display == null || (typeof display === 'string' && !display.trim())) {
78
+ const composite = [o.salutation, o.first_name, o.last_name]
79
+ .filter((p) => typeof p === 'string' && p.trim())
80
+ .map((p) => p.trim())
81
+ .join(' ');
82
+ if (composite)
83
+ display = composite;
84
+ else if (typeof o.email === 'string' && o.email.trim())
85
+ display = o.email.trim();
86
+ else
87
+ display = null;
88
+ }
89
+ value = display;
90
+ }
65
91
  if (value === null || value === undefined || value === '') {
66
92
  return EMPTY_TOKEN;
67
93
  }
@@ -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 when the active org is known and
1157
- // not already set. Most better-auth org-scoped endpoints
1158
- // accept this shape; harmless for endpoints that ignore it.
1159
- if (!body.organizationId && activeOrganization?.id) {
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
- const res = await authFetch(url, {
1163
- method: 'POST',
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
- body: JSON.stringify(body),
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,127 @@ 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
+ // Pull `old_value` and `new_value` so we can render a per-field diff
362
+ // ("Industry: finance → healthcare") instead of just the action verb.
363
+ // The backend already authorises sys_audit_log row access; column-level
364
+ // redaction would need to happen server-side, not by omitting columns here.
365
+ dataSource
366
+ .find('sys_audit_log', {
367
+ $filter: { record_id: pureRecordId, object_name: objectDef.name },
368
+ $orderby: { created_at: 'desc' },
369
+ $top: 50,
370
+ $select: ['id', 'created_at', 'action', 'user_id', 'old_value', 'new_value'],
371
+ })
372
+ .then(async (res) => {
373
+ if (cancelled)
374
+ return;
375
+ const items = Array.isArray(res) ? res : res?.data || [];
376
+ // 1) Resolve actor display names + avatars in a single batched call
377
+ // so the timeline never falls back to a raw UUID.
378
+ const userIds = Array.from(new Set(items
379
+ .map((it) => it?.user_id)
380
+ .filter((v) => typeof v === 'string' && v.length > 0)));
381
+ let userMap = new Map();
382
+ if (userIds.length > 0) {
383
+ try {
384
+ const usersRes = await dataSource.find('sys_user', {
385
+ $filter: { id: { $in: userIds } },
386
+ $top: userIds.length,
387
+ $select: ['id', 'name', 'email', 'image'],
388
+ });
389
+ const users = Array.isArray(usersRes) ? usersRes : usersRes?.data || [];
390
+ userMap = new Map(users.map((u) => [u.id, { name: u.name || u.email || null, image: u.image || null }]));
391
+ }
392
+ catch (err) {
393
+ console.warn('[RecordDetailView] Failed to resolve audit user names:', err);
394
+ }
395
+ }
396
+ // 2) Build a label map for the current object so diff lines show
397
+ // "Industry: …" rather than the snake_case "industry: …".
398
+ const fieldLabels = {};
399
+ for (const [name, def] of Object.entries(objectDef.fields || {})) {
400
+ if (def?.label)
401
+ fieldLabels[name] = def.label;
402
+ }
403
+ const parseJson = (v) => {
404
+ if (!v)
405
+ return null;
406
+ if (typeof v === 'object')
407
+ return v;
408
+ if (typeof v === 'string') {
409
+ try {
410
+ return JSON.parse(v);
411
+ }
412
+ catch {
413
+ return null;
414
+ }
415
+ }
416
+ return null;
417
+ };
418
+ const enriched = items.map((it) => {
419
+ const u = it?.user_id ? userMap.get(it.user_id) : undefined;
420
+ const oldObj = parseJson(it?.old_value) || {};
421
+ const newObj = parseJson(it?.new_value) || {};
422
+ const fields = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
423
+ const changes = Array.from(fields)
424
+ .filter((f) => JSON.stringify(oldObj[f]) !== JSON.stringify(newObj[f]))
425
+ .map((f) => ({
426
+ field: f,
427
+ label: fieldLabels[f] || f,
428
+ from: oldObj[f],
429
+ to: newObj[f],
430
+ }));
431
+ return {
432
+ ...it,
433
+ user_name: u?.name ?? null,
434
+ user_avatar: u?.image ?? null,
435
+ changes: changes.length > 0 ? changes : undefined,
436
+ };
437
+ });
438
+ setHistoryEntries(enriched);
439
+ })
440
+ .catch((err) => {
441
+ if (cancelled)
442
+ return;
443
+ console.warn('[RecordDetailView] Failed to fetch sys_audit_log:', err);
444
+ setHistoryEntries([]);
445
+ })
446
+ .finally(() => {
447
+ if (!cancelled)
448
+ setHistoryLoading(false);
449
+ });
450
+ return () => { cancelled = true; };
451
+ }, [dataSource, pureRecordId, objectDef, historyEnabled]);
248
452
  // Memoize so the object identity is stable across renders — otherwise
249
453
  // any effect that depends on it (e.g. the feed loader below) would
250
454
  // re-fire every render and create an infinite request loop.
@@ -556,7 +760,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
556
760
  // redundant "Details" heading).
557
761
  showBorder: false,
558
762
  fields: Object.keys(objectDef.fields || {})
559
- .filter(key => !AUDIT_FIELD_NAMES.has(key))
763
+ .filter(key => !AUDIT_FIELD_NAMES.has(key) && !HIDDEN_SYSTEM_FIELD_NAMES.has(key))
560
764
  .map(key => {
561
765
  const fieldDef = objectDef.fields[key];
562
766
  const refTarget = fieldDef.reference_to || fieldDef.reference;
@@ -581,7 +785,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
581
785
  // Filter actions for record_header location and deduplicate by name
582
786
  const recordHeaderActions = (() => {
583
787
  const seen = new Set();
584
- return (objectDef.actions || []).filter((a) => {
788
+ const base = (objectDef.actions || []).filter((a) => {
585
789
  if (!a.locations?.includes('record_header'))
586
790
  return false;
587
791
  if (!a.name)
@@ -600,6 +804,59 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
600
804
  successMessage: actionSuccess(objectDef.name, a.name, a.successMessage),
601
805
  }),
602
806
  }));
807
+ // Inject approval actions — only when the approvals plugin is
808
+ // available and an active process exists for this object.
809
+ if (approvals.available && approvals.processes.length > 0) {
810
+ if (approvals.canSubmit) {
811
+ base.push({
812
+ name: 'submit_approval',
813
+ type: 'approval',
814
+ target: 'submit_approval',
815
+ label: t('approvals.submitForApproval', { defaultValue: 'Submit for Approval' }),
816
+ icon: 'send',
817
+ variant: 'default',
818
+ locations: ['record_header'],
819
+ refreshAfter: true,
820
+ successMessage: t('approvals.submitSuccess', { defaultValue: 'Approval request submitted' }),
821
+ ...(approvals.processes.length === 1
822
+ ? { params: { processName: approvals.processes[0].name } }
823
+ : {
824
+ collectParams: [{
825
+ name: 'processName',
826
+ label: t('approvals.process', { defaultValue: 'Process' }),
827
+ type: 'select',
828
+ required: true,
829
+ options: approvals.processes.map((p) => ({
830
+ value: p.name,
831
+ label: p.label || p.name,
832
+ })),
833
+ }, {
834
+ name: 'comment',
835
+ label: t('approvals.comment', { defaultValue: 'Comment (optional)' }),
836
+ type: 'text',
837
+ multiline: true,
838
+ }],
839
+ }),
840
+ });
841
+ }
842
+ if (approvals.canRecall) {
843
+ base.push({
844
+ name: 'recall_approval',
845
+ type: 'approval',
846
+ target: 'recall_approval',
847
+ label: t('approvals.recall', { defaultValue: 'Recall' }),
848
+ icon: 'undo',
849
+ variant: 'outline',
850
+ locations: ['record_header'],
851
+ refreshAfter: true,
852
+ confirmText: t('approvals.recallConfirm', {
853
+ defaultValue: 'Recall this pending approval request?',
854
+ }),
855
+ successMessage: t('approvals.recallSuccess', { defaultValue: 'Approval recalled' }),
856
+ });
857
+ }
858
+ }
859
+ return base;
603
860
  })();
604
861
  // Build highlightFields: exclusively from objectDef metadata (no hardcoded fallback)
605
862
  const highlightFields = objectDef.views?.detail?.highlightFields ?? [];
@@ -710,6 +967,12 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
710
967
  sections,
711
968
  autoTabs: true,
712
969
  autoDiscoverRelated: true,
970
+ ...(historyEnabled && {
971
+ history: {
972
+ entries: historyEntries ?? [],
973
+ loading: historyLoading && historyEntries === null,
974
+ },
975
+ }),
713
976
  ...(related.length > 0 && { related }),
714
977
  ...(highlightFields.length > 0 && { highlightFields }),
715
978
  ...(sectionGroups && sectionGroups.length > 0 && { sectionGroups }),
@@ -722,14 +985,17 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
722
985
  }),
723
986
  };
724
987
  // eslint-disable-next-line react-hooks/exhaustive-deps
725
- }, [objectDef?.name, pureRecordId, childRelatedData, actionRefreshKey, appName, navigate, dataSource, t, objectLabel, objects]);
988
+ }, [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
989
  if (isLoading) {
727
990
  return _jsx(SkeletonDetail, {});
728
991
  }
729
992
  if (!objectDef) {
730
993
  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
994
  }
732
- 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 }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), onDataLoaded: (record) => {
995
+ if (assignedPage) {
996
+ 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 }) }) }) }));
997
+ }
998
+ 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
999
  if (!record || typeof record !== 'object')
734
1000
  return;
735
1001
  // 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.0",
3
+ "version": "4.5.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.3.0",
31
- "@object-ui/collaboration": "4.3.0",
32
- "@object-ui/components": "4.3.0",
33
- "@object-ui/core": "4.3.0",
34
- "@object-ui/data-objectstack": "4.3.0",
35
- "@object-ui/fields": "4.3.0",
36
- "@object-ui/i18n": "4.3.0",
37
- "@object-ui/layout": "4.3.0",
38
- "@object-ui/permissions": "4.3.0",
39
- "@object-ui/react": "4.3.0",
40
- "@object-ui/types": "4.3.0"
30
+ "@object-ui/auth": "4.5.0",
31
+ "@object-ui/collaboration": "4.5.0",
32
+ "@object-ui/components": "4.5.0",
33
+ "@object-ui/core": "4.5.0",
34
+ "@object-ui/data-objectstack": "4.5.0",
35
+ "@object-ui/fields": "4.5.0",
36
+ "@object-ui/i18n": "4.5.0",
37
+ "@object-ui/layout": "4.5.0",
38
+ "@object-ui/permissions": "4.5.0",
39
+ "@object-ui/react": "4.5.0",
40
+ "@object-ui/types": "4.5.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.3.0",
47
- "@object-ui/plugin-charts": "^4.3.0",
48
- "@object-ui/plugin-chatbot": "^4.3.0",
49
- "@object-ui/plugin-dashboard": "^4.3.0",
50
- "@object-ui/plugin-designer": "^4.3.0",
51
- "@object-ui/plugin-detail": "^4.3.0",
52
- "@object-ui/plugin-form": "^4.3.0",
53
- "@object-ui/plugin-grid": "^4.3.0",
54
- "@object-ui/plugin-kanban": "^4.3.0",
55
- "@object-ui/plugin-list": "^4.3.0",
56
- "@object-ui/plugin-report": "^4.3.0",
57
- "@object-ui/plugin-view": "^4.3.0"
46
+ "@object-ui/plugin-calendar": "^4.5.0",
47
+ "@object-ui/plugin-charts": "^4.5.0",
48
+ "@object-ui/plugin-chatbot": "^4.5.0",
49
+ "@object-ui/plugin-dashboard": "^4.5.0",
50
+ "@object-ui/plugin-designer": "^4.5.0",
51
+ "@object-ui/plugin-detail": "^4.5.0",
52
+ "@object-ui/plugin-form": "^4.5.0",
53
+ "@object-ui/plugin-grid": "^4.5.0",
54
+ "@object-ui/plugin-kanban": "^4.5.0",
55
+ "@object-ui/plugin-list": "^4.5.0",
56
+ "@object-ui/plugin-report": "^4.5.0",
57
+ "@object-ui/plugin-view": "^4.5.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^25.9.0",