@object-ui/app-shell 4.6.0 → 4.8.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 +131 -0
- package/dist/layout/AppHeader.js +14 -4
- package/dist/layout/AppSidebar.js +1 -0
- package/dist/layout/ConsoleLayout.js +12 -11
- package/dist/layout/MobileViewSwitcherContext.d.ts +79 -0
- package/dist/layout/MobileViewSwitcherContext.js +81 -0
- package/dist/layout/UnifiedSidebar.js +12 -43
- package/dist/utils/resolveActionParams.d.ts +11 -0
- package/dist/utils/resolveActionParams.js +19 -0
- package/dist/views/ActionParamDialog.js +16 -1
- package/dist/views/ObjectView.js +48 -28
- package/package.json +24 -24
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,136 @@
|
|
|
1
1
|
# @object-ui/app-shell — Changelog
|
|
2
2
|
|
|
3
|
+
## 4.8.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3a17c8d: Mobile UI: aggressive chrome reduction to match real mobile-app conventions.
|
|
8
|
+
|
|
9
|
+
Real mobile CRMs (Salesforce, HubSpot, Notion, Linear) keep one row of
|
|
10
|
+
chrome on phones: title + 1 primary action, plus content. We were
|
|
11
|
+
shipping ~5 rows of toolbars + chips + tabs above the data. This commit
|
|
12
|
+
hides the desktop-only chrome at the `<sm` breakpoint:
|
|
13
|
+
- **ListView**: TabBar (view switcher), UserFilters chip row, quick-filters
|
|
14
|
+
chip row, Sort button, list-scoped Search popover, and the
|
|
15
|
+
(newly-added) mobile-only ViewSettingsPopover gear are all hidden on
|
|
16
|
+
phones. Only the **Filter** icon survives on mobile — paired with the
|
|
17
|
+
global ⌘K top-bar search, that is the entire mobile control surface.
|
|
18
|
+
- **Kanban**: previous commit replaced verbose swipe text with a dot
|
|
19
|
+
indicator; that stands.
|
|
20
|
+
- **ObjectView page header**: the Import (CSV upload) button is hidden
|
|
21
|
+
on mobile — CSV import is a desktop workflow.
|
|
22
|
+
|
|
23
|
+
Net effect on a 390px viewport: ListView toolbar collapses from
|
|
24
|
+
~10 controls (5 chips + 5 icons) to a single Filter icon next to the
|
|
25
|
+
title; the body of the page is reachable without scrolling past 3 rows
|
|
26
|
+
of chrome.
|
|
27
|
+
|
|
28
|
+
Desktop and tablet behavior is unchanged.
|
|
29
|
+
|
|
30
|
+
- 51e274a: feat(app-shell,plugin-list): mobile Airtable-style topbar + filter chip row
|
|
31
|
+
|
|
32
|
+
Refactor mobile object-view layout to match the Airtable Interface
|
|
33
|
+
pattern:
|
|
34
|
+
- **AppHeader**: the mobile topbar's static page label is now a
|
|
35
|
+
view-switcher dropdown (`<viewName> ▾`). Tapping opens a list of
|
|
36
|
+
available views with icons + active-state checkmark. Falls back to
|
|
37
|
+
plain text when only one view exists, or when the current page has
|
|
38
|
+
no view-switching surface (Home, Settings, …).
|
|
39
|
+
- **ObjectView**: drops the standalone mobile `sm:hidden` view-select
|
|
40
|
+
row that previously lived between the desktop tab bar and the
|
|
41
|
+
content area. View switching is now exposed exclusively via the
|
|
42
|
+
topbar dropdown on mobile, eliminating the duplicated `object name`
|
|
43
|
+
vs `view name` rows.
|
|
44
|
+
- **ListView**: un-hides the `UserFilters` chip row on mobile.
|
|
45
|
+
Single-line, horizontally scrollable, matches the Airtable Interface
|
|
46
|
+
filter chip strip.
|
|
47
|
+
- New lightweight `MobileViewSwitcherContext` provides a
|
|
48
|
+
page → header data channel (no zustand dependency added).
|
|
49
|
+
|
|
50
|
+
Net effect on mobile (390×844):
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
☰ 客户卡片 ▾ 🔍 🔔 M ← topbar
|
|
54
|
+
类型 ▾ 行业 ▾ 是否活跃 ▾ 更多 3 ▾ ⛛ ← chip row
|
|
55
|
+
[content cards] ← content
|
|
56
|
+
(+) ← FAB
|
|
57
|
+
[Leads | Accounts | Contacts | …] ← bottom nav
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- 7feed12: Mobile UX: Home affordance + chrome reduction
|
|
61
|
+
|
|
62
|
+
Two fixes that match what users actually need on a 390px viewport:
|
|
63
|
+
- **Add Home link to mobile sidebar.** When inside an app, the sidebar
|
|
64
|
+
drawer previously listed only the current app's nav groups, with no
|
|
65
|
+
way back to the home page (the desktop topbar's logo and AppSwitcher
|
|
66
|
+
pill are hidden on phones). Now the mobile sidebar opens with a
|
|
67
|
+
prominent "Home" row (`/home`) at the top, gated to mobile + app
|
|
68
|
+
context so the desktop layout is untouched.
|
|
69
|
+
- **Cut a row of top chrome.** The list/object PageHeader (icon + title
|
|
70
|
+
- create / import / more actions) duplicated the page title already
|
|
71
|
+
shown in the topbar. On mobile it's hidden entirely; the primary
|
|
72
|
+
create action moves to a floating "+" button anchored above the
|
|
73
|
+
bottom nav. Desktop still renders the full PageHeader.
|
|
74
|
+
|
|
75
|
+
- 00363fd: feat(app-shell): remove mobile bottom-tab navigation
|
|
76
|
+
|
|
77
|
+
The mobile bottom-tab strip was rendering the first 5 leaf items of
|
|
78
|
+
the app's navigation tree — exactly the same items that the drawer
|
|
79
|
+
(`☰`) surfaces, just without grouping, favourites, or recents.
|
|
80
|
+
|
|
81
|
+
Per the Notion / Linear mobile convention, we now rely on the drawer
|
|
82
|
+
alone. Bottom-tab strips work when they expose **orthogonal**
|
|
83
|
+
top-level sections (Airtable's Home / Bases / Notifications / Account)
|
|
84
|
+
— but ours was a duplicate of the drawer, so it was pure visual
|
|
85
|
+
weight: ~52px of vertical real estate, redundant taps, and clashes
|
|
86
|
+
with the FAB and chat-bubble stack at the bottom-right corner.
|
|
87
|
+
|
|
88
|
+
Net effect:
|
|
89
|
+
- Drawer remains the single source of in-app navigation.
|
|
90
|
+
- ~52px reclaimed for list/kanban content on every mobile screen.
|
|
91
|
+
- FAB and chat-bubble keep their existing offsets (no overlap;
|
|
92
|
+
bottom-nav was already accounted for above them).
|
|
93
|
+
|
|
94
|
+
- faba0e3: Mobile UX cleanup:
|
|
95
|
+
- `app-shell/AppHeader`: hide the platform-logo, app-switcher pill, and
|
|
96
|
+
intermediate path separators on mobile when inside an app route. The
|
|
97
|
+
sidebar already exposes those affordances; the topbar now reads
|
|
98
|
+
`☰ + page title + Search + Inbox + Avatar`.
|
|
99
|
+
- `plugin-list`: replace the hidden mobile TabBar with a new compact
|
|
100
|
+
`TabBarSelect` dropdown (current view name + chevron → menu of every
|
|
101
|
+
view). Phone users keep view-switching without burning a row on chip
|
|
102
|
+
pills. Desktop continues to render the inline TabBar.
|
|
103
|
+
|
|
104
|
+
### Patch Changes
|
|
105
|
+
|
|
106
|
+
- @object-ui/types@4.8.0
|
|
107
|
+
- @object-ui/core@4.8.0
|
|
108
|
+
- @object-ui/i18n@4.8.0
|
|
109
|
+
- @object-ui/react@4.8.0
|
|
110
|
+
- @object-ui/components@4.8.0
|
|
111
|
+
- @object-ui/fields@4.8.0
|
|
112
|
+
- @object-ui/layout@4.8.0
|
|
113
|
+
- @object-ui/data-objectstack@4.8.0
|
|
114
|
+
- @object-ui/auth@4.8.0
|
|
115
|
+
- @object-ui/permissions@4.8.0
|
|
116
|
+
- @object-ui/collaboration@4.8.0
|
|
117
|
+
|
|
118
|
+
## 4.7.0
|
|
119
|
+
|
|
120
|
+
### Patch Changes
|
|
121
|
+
|
|
122
|
+
- @object-ui/types@4.7.0
|
|
123
|
+
- @object-ui/core@4.7.0
|
|
124
|
+
- @object-ui/i18n@4.7.0
|
|
125
|
+
- @object-ui/react@4.7.0
|
|
126
|
+
- @object-ui/components@4.7.0
|
|
127
|
+
- @object-ui/fields@4.7.0
|
|
128
|
+
- @object-ui/layout@4.7.0
|
|
129
|
+
- @object-ui/data-objectstack@4.7.0
|
|
130
|
+
- @object-ui/auth@4.7.0
|
|
131
|
+
- @object-ui/permissions@4.7.0
|
|
132
|
+
- @object-ui/collaboration@4.7.0
|
|
133
|
+
|
|
3
134
|
## 4.6.0
|
|
4
135
|
|
|
5
136
|
### Patch Changes
|
package/dist/layout/AppHeader.js
CHANGED
|
@@ -18,8 +18,8 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
18
18
|
* @module
|
|
19
19
|
*/
|
|
20
20
|
import { useLocation, useParams, Link, useNavigate } from 'react-router-dom';
|
|
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, } from 'lucide-react';
|
|
21
|
+
import { SidebarTrigger, Button, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuGroup, Avatar, AvatarImage, AvatarFallback, cn, } from '@object-ui/components';
|
|
22
|
+
import { Search, HelpCircle, ChevronDown, Check, Lock, 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';
|
|
@@ -32,6 +32,7 @@ import { useAdapter } from '../providers/AdapterProvider';
|
|
|
32
32
|
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
|
|
33
33
|
import { useAuth, getUserInitials } from '@object-ui/auth';
|
|
34
34
|
import { useMetadata } from '../providers/MetadataProvider';
|
|
35
|
+
import { useMobileViewSwitcher } from './MobileViewSwitcherContext';
|
|
35
36
|
import { useNavigationContext } from '../context/NavigationContext';
|
|
36
37
|
function humanizeSlug(slug) {
|
|
37
38
|
return slug.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
@@ -56,6 +57,7 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
|
|
|
56
57
|
const { objectLabel, dashboardLabel, pageLabel, reportLabel, viewLabel } = useObjectLabel();
|
|
57
58
|
const { apps: metadataApps, dashboards: metadataDashboards, pages: metadataPages, reports: metadataReports } = useMetadata();
|
|
58
59
|
const { currentAppName, recordTitle } = useNavigationContext();
|
|
60
|
+
const mobileSwitcher = useMobileViewSwitcher();
|
|
59
61
|
const [apiPresenceUsers, setApiPresenceUsers] = useState(null);
|
|
60
62
|
const [apiActivities, setApiActivities] = useState(null);
|
|
61
63
|
/** M10.8: in-header notifications. Polled from sys_notification scoped to current user. */
|
|
@@ -372,8 +374,16 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
|
|
|
372
374
|
}
|
|
373
375
|
}
|
|
374
376
|
const lastSegmentLabel = extraSegments[extraSegments.length - 1]?.label || appName || '';
|
|
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) => {
|
|
377
|
+
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: cn("flex items-center justify-center h-7 w-7 shrink-0 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors", isApp && "hidden sm:flex"), 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("span", { className: "hidden sm:flex items-center", children: _jsx(PathSep, {}) }), _jsx("div", { className: "hidden sm:flex items-center", children: _jsx(AppSwitcher, { activeAppName: activeAppName, onAppChange: onAppChange }) })] })) : appName ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "hidden sm:flex items-center", children: _jsx(PathSep, {}) }), _jsx("span", { className: "hidden sm:inline text-sm font-medium text-foreground/80 px-1.5", children: appName })] })) : null, extraSegments.map((seg, i) => {
|
|
376
378
|
const isLast = i === extraSegments.length - 1;
|
|
377
379
|
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
|
-
}),
|
|
380
|
+
}), mobileSwitcher && mobileSwitcher.views.length > 0 ? (mobileSwitcher.views.length > 1 ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: "sm:hidden flex items-center gap-0.5 min-w-0 ml-1 rounded-md px-1.5 py-1 text-sm font-medium hover:bg-accent active:bg-accent/80 transition-colors", "aria-label": "Switch view", children: [_jsx("span", { className: "truncate max-w-[180px]", children: mobileSwitcher.triggerLabel ??
|
|
381
|
+
mobileSwitcher.views.find((v) => v.id === mobileSwitcher.activeViewId)?.label ??
|
|
382
|
+
lastSegmentLabel }), _jsx(ChevronDown, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" })] }) }), _jsx(DropdownMenuContent, { align: "start", className: "min-w-[220px] max-w-[280px]", children: mobileSwitcher.views.map((v) => {
|
|
383
|
+
const isActive = v.id === mobileSwitcher.activeViewId;
|
|
384
|
+
return (_jsxs(DropdownMenuItem, { onSelect: () => {
|
|
385
|
+
if (!isActive)
|
|
386
|
+
mobileSwitcher.onChange(v.id);
|
|
387
|
+
}, className: "gap-2", children: [v.icon ? (_jsx("span", { className: "shrink-0 text-muted-foreground [&>svg]:h-4 [&>svg]:w-4", children: v.icon })) : null, _jsx("span", { className: "flex-1 truncate", children: v.label }), v.locked ? (_jsx(Lock, { className: "h-3 w-3 shrink-0 text-muted-foreground", "aria-hidden": true })) : null, isActive ? (_jsx(Check, { className: "h-4 w-4 shrink-0 text-foreground", "aria-hidden": true })) : null] }, v.id));
|
|
388
|
+
}) })] })) : (_jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: mobileSwitcher.triggerLabel ?? mobileSwitcher.views[0].label }))) : (_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' })] })] }))] })] })] })] })] }));
|
|
379
389
|
}
|
|
@@ -193,6 +193,7 @@ export function AppSidebar({ activeAppName, onAppChange }) {
|
|
|
193
193
|
{ id: 'sys-users', label: 'Users', type: 'url', url: '/apps/setup/system/users', icon: 'users' },
|
|
194
194
|
{ id: 'sys-orgs', label: 'Organizations', type: 'url', url: '/apps/setup/system/organizations', icon: 'building-2' },
|
|
195
195
|
{ id: 'sys-roles', label: 'Roles', type: 'url', url: '/apps/setup/system/roles', icon: 'shield' },
|
|
196
|
+
{ id: 'sys-config', label: 'Configuration', type: 'url', url: '/apps/setup/system/settings', icon: 'sliders-horizontal' },
|
|
196
197
|
{ id: 'sys-create-app', label: 'Create App', type: 'url', url: '/create-app', icon: 'plus' },
|
|
197
198
|
], []);
|
|
198
199
|
return (_jsxs(_Fragment, { children: [_jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx(SidebarMenu, { children: _jsx(SidebarMenuItem, { children: activeApp ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(SidebarMenuButton, { size: "lg", className: "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground", children: [_jsx("div", { className: "flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground", style: primaryColor ? { backgroundColor: primaryColor } : undefined, children: logo ? (_jsx("img", { src: logo, alt: resolveI18nLabel(activeApp.label, t), className: "size-6 object-contain" })) : (React.createElement(getIcon(activeApp.icon), { className: "size-4" })) }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: resolveI18nLabel(activeApp.label, t) }), _jsx("span", { className: "truncate text-xs", children: resolveI18nLabel(activeApp.description, t) || `${activeApps.length} Apps Available` })] }), _jsx(ChevronsUpDown, { className: "ml-auto" })] }) }), _jsxs(DropdownMenuContent, { className: "w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg", align: "start", side: isMobile ? "bottom" : "right", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground", children: "Switch Application" }), activeApps.map((app) => (_jsxs(DropdownMenuItem, { onClick: () => onAppChange(app.name), className: "gap-2 p-2", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-sm border", children: app.icon ? React.createElement(getIcon(app.icon), { className: "size-3" }) : _jsx(Database, { className: "size-3" }) }), resolveI18nLabel(app.label, t), activeApp.name === app.name && _jsx("span", { className: "ml-auto text-xs", children: "\u2713" })] }, app.name))), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => navigate('/home'), "data-testid": "home-link-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(Home, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.home') })] }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => navigate(`/apps/${activeAppName}/create-app`), "data-testid": "add-app-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(Plus, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.addApp') })] }), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => navigate(`/apps/${activeAppName}/edit-app/${activeAppName}`), "data-testid": "edit-app-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(Pencil, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.editApp') })] }), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => navigate('/apps/setup/system/apps'), "data-testid": "manage-all-apps-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(Settings, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.manageAllApps') })] })] })] })) : (_jsxs(SidebarMenuButton, { size: "lg", onClick: () => navigate('/apps/setup'), "data-testid": "system-sidebar-header", children: [_jsx("div", { className: "flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground", children: _jsx(Settings, { className: "size-4" }) }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: t('layout.appSwitcher.systemConsole') }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: t('layout.appSwitcher.noAppsConfigured') })] })] })) }) }) }), _jsx(SidebarContent, { children: activeApp ? (_jsxs(_Fragment, { children: [areas.length > 1 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Layers, { className: "h-3.5 w-3.5" }), "Area"] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: areas.map((area) => {
|
|
@@ -16,6 +16,7 @@ import { useDiscovery } from '@object-ui/react';
|
|
|
16
16
|
const ConsoleFloatingChatbot = lazy(() => import('./ConsoleFloatingChatbot'));
|
|
17
17
|
import { UnifiedSidebar } from './UnifiedSidebar';
|
|
18
18
|
import { AppHeader } from './AppHeader';
|
|
19
|
+
import { MobileViewSwitcherProvider } from './MobileViewSwitcherContext';
|
|
19
20
|
import { useResponsiveSidebar } from '../hooks/useResponsiveSidebar';
|
|
20
21
|
import { useNavigationContext } from '../context/NavigationContext';
|
|
21
22
|
import { resolveI18nLabel } from '../utils';
|
|
@@ -39,15 +40,15 @@ export function ConsoleLayout({ children, activeAppName, activeApp, onAppChange,
|
|
|
39
40
|
setContext('app');
|
|
40
41
|
setCurrentAppName(activeAppName);
|
|
41
42
|
}, [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-y-auto overflow-x-hidden bg-muted/5", branding: activeApp?.branding
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
43
|
+
return (_jsx(MobileViewSwitcherProvider, { children: _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
|
|
44
|
+
? {
|
|
45
|
+
primaryColor: activeApp.branding.primaryColor,
|
|
46
|
+
accentColor: activeApp.branding.accentColor,
|
|
47
|
+
favicon: activeApp.branding.favicon,
|
|
48
|
+
logo: activeApp.branding.logo,
|
|
49
|
+
title: activeApp.label
|
|
50
|
+
? `${resolveI18nLabel(activeApp.label)} — ObjectStack Console`
|
|
51
|
+
: undefined,
|
|
52
|
+
}
|
|
53
|
+
: undefined, children: [_jsx(ConsoleLayoutInner, { children: children }), showChatbot && (_jsx(Suspense, { fallback: null, children: _jsx(ConsoleFloatingChatbot, { appLabel: appLabel, objects: objects }) }))] }) }));
|
|
53
54
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MobileViewSwitcherContext
|
|
3
|
+
*
|
|
4
|
+
* Lightweight page → header data channel that lets a view-driven page
|
|
5
|
+
* (e.g. `ObjectView`) expose its list of available views, the active
|
|
6
|
+
* view id, and a change handler to the mobile `AppHeader` topbar.
|
|
7
|
+
*
|
|
8
|
+
* On mobile (<sm), the AppHeader replaces its static page label with a
|
|
9
|
+
* `<viewName> ▾` dropdown trigger when a switcher has been registered.
|
|
10
|
+
* Desktop continues to use the inline `ViewTabBar` and ignores this
|
|
11
|
+
* context entirely.
|
|
12
|
+
*
|
|
13
|
+
* Design notes:
|
|
14
|
+
* - The provider stores a single nullable value (last registered wins).
|
|
15
|
+
* This matches reality — only one ObjectView is rendered at a time
|
|
16
|
+
* under the AppHeader. Concurrent registrations are not supported.
|
|
17
|
+
* - Consumers must wrap their registration object in `useMemo` (or
|
|
18
|
+
* pass primitive refs) so the value identity is stable across renders
|
|
19
|
+
* and we don't spam AppHeader re-renders.
|
|
20
|
+
* - When the page unmounts or no longer wants to expose a switcher,
|
|
21
|
+
* the effect cleanup sets the value back to `null` and AppHeader
|
|
22
|
+
* falls back to the static breadcrumb label.
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
import React from 'react';
|
|
27
|
+
/** Shape of a single view option in the mobile dropdown. */
|
|
28
|
+
export interface MobileViewSwitcherItem {
|
|
29
|
+
/** Stable view identifier (matches `activeViewId`). */
|
|
30
|
+
id: string;
|
|
31
|
+
/** Display label shown in the dropdown row. */
|
|
32
|
+
label: string;
|
|
33
|
+
/** Optional Lucide icon node rendered before the label. */
|
|
34
|
+
icon?: React.ReactNode;
|
|
35
|
+
/** Optional secondary hint (e.g. owner, "private"). */
|
|
36
|
+
hint?: string;
|
|
37
|
+
/** When true, show a lock indicator (e.g. permission-locked). */
|
|
38
|
+
locked?: boolean;
|
|
39
|
+
}
|
|
40
|
+
/** Value registered by a page (e.g. ObjectView) for the mobile switcher. */
|
|
41
|
+
export interface MobileViewSwitcherValue {
|
|
42
|
+
/** All views available on this page. May be a single entry. */
|
|
43
|
+
views: MobileViewSwitcherItem[];
|
|
44
|
+
/** Currently active view id. */
|
|
45
|
+
activeViewId: string;
|
|
46
|
+
/** Invoked when the user picks a view from the dropdown. */
|
|
47
|
+
onChange: (id: string) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Optional override for the trigger label. Defaults to the active
|
|
50
|
+
* view's `label`. Provide e.g. `Object · View` if you want both.
|
|
51
|
+
*/
|
|
52
|
+
triggerLabel?: string;
|
|
53
|
+
}
|
|
54
|
+
/** Provider — mount once near the top of the console tree (above AppHeader). */
|
|
55
|
+
export declare function MobileViewSwitcherProvider({ children }: {
|
|
56
|
+
children: React.ReactNode;
|
|
57
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
58
|
+
/** Read the currently registered switcher value (or `null`). */
|
|
59
|
+
export declare function useMobileViewSwitcher(): MobileViewSwitcherValue | null;
|
|
60
|
+
/**
|
|
61
|
+
* Register a switcher value for the duration of the calling component's
|
|
62
|
+
* lifetime. Passing `null` (or `undefined`) is a no-op — useful for
|
|
63
|
+
* conditional pages that sometimes don't want a switcher.
|
|
64
|
+
*
|
|
65
|
+
* Wrap the value in `useMemo` and ensure `onChange` is stable
|
|
66
|
+
* (`useCallback`) so we only re-publish when something materially changes.
|
|
67
|
+
*/
|
|
68
|
+
export declare function useRegisterMobileViewSwitcher(value: MobileViewSwitcherValue | null | undefined): void;
|
|
69
|
+
/**
|
|
70
|
+
* Convenience: build + register in one call. Stabilises identity via
|
|
71
|
+
* useMemo so callers don't need to memoize themselves.
|
|
72
|
+
*/
|
|
73
|
+
export declare function useMobileViewSwitcherRegistration(input: {
|
|
74
|
+
views: MobileViewSwitcherItem[];
|
|
75
|
+
activeViewId: string;
|
|
76
|
+
onChange: (id: string) => void;
|
|
77
|
+
triggerLabel?: string;
|
|
78
|
+
enabled?: boolean;
|
|
79
|
+
}): void;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* MobileViewSwitcherContext
|
|
4
|
+
*
|
|
5
|
+
* Lightweight page → header data channel that lets a view-driven page
|
|
6
|
+
* (e.g. `ObjectView`) expose its list of available views, the active
|
|
7
|
+
* view id, and a change handler to the mobile `AppHeader` topbar.
|
|
8
|
+
*
|
|
9
|
+
* On mobile (<sm), the AppHeader replaces its static page label with a
|
|
10
|
+
* `<viewName> ▾` dropdown trigger when a switcher has been registered.
|
|
11
|
+
* Desktop continues to use the inline `ViewTabBar` and ignores this
|
|
12
|
+
* context entirely.
|
|
13
|
+
*
|
|
14
|
+
* Design notes:
|
|
15
|
+
* - The provider stores a single nullable value (last registered wins).
|
|
16
|
+
* This matches reality — only one ObjectView is rendered at a time
|
|
17
|
+
* under the AppHeader. Concurrent registrations are not supported.
|
|
18
|
+
* - Consumers must wrap their registration object in `useMemo` (or
|
|
19
|
+
* pass primitive refs) so the value identity is stable across renders
|
|
20
|
+
* and we don't spam AppHeader re-renders.
|
|
21
|
+
* - When the page unmounts or no longer wants to expose a switcher,
|
|
22
|
+
* the effect cleanup sets the value back to `null` and AppHeader
|
|
23
|
+
* falls back to the static breadcrumb label.
|
|
24
|
+
*
|
|
25
|
+
* @module
|
|
26
|
+
*/
|
|
27
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
28
|
+
const MobileViewSwitcherContext = createContext(null);
|
|
29
|
+
/** Provider — mount once near the top of the console tree (above AppHeader). */
|
|
30
|
+
export function MobileViewSwitcherProvider({ children }) {
|
|
31
|
+
const [value, setValue] = useState(null);
|
|
32
|
+
const ctx = useMemo(() => ({ value, setValue }), [value]);
|
|
33
|
+
return (_jsx(MobileViewSwitcherContext.Provider, { value: ctx, children: children }));
|
|
34
|
+
}
|
|
35
|
+
/** Read the currently registered switcher value (or `null`). */
|
|
36
|
+
export function useMobileViewSwitcher() {
|
|
37
|
+
const ctx = useContext(MobileViewSwitcherContext);
|
|
38
|
+
return ctx?.value ?? null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Register a switcher value for the duration of the calling component's
|
|
42
|
+
* lifetime. Passing `null` (or `undefined`) is a no-op — useful for
|
|
43
|
+
* conditional pages that sometimes don't want a switcher.
|
|
44
|
+
*
|
|
45
|
+
* Wrap the value in `useMemo` and ensure `onChange` is stable
|
|
46
|
+
* (`useCallback`) so we only re-publish when something materially changes.
|
|
47
|
+
*/
|
|
48
|
+
export function useRegisterMobileViewSwitcher(value) {
|
|
49
|
+
const ctx = useContext(MobileViewSwitcherContext);
|
|
50
|
+
const lastRef = useRef(null);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!ctx)
|
|
53
|
+
return undefined;
|
|
54
|
+
const next = value ?? null;
|
|
55
|
+
ctx.setValue(next);
|
|
56
|
+
lastRef.current = next;
|
|
57
|
+
return () => {
|
|
58
|
+
// Only clear if nobody else has overwritten us in the meantime.
|
|
59
|
+
// (Last-mounted wins; on unmount, we still reset to null so a stale
|
|
60
|
+
// switcher doesn't leak into the next page.)
|
|
61
|
+
ctx.setValue(null);
|
|
62
|
+
};
|
|
63
|
+
}, [ctx, value]);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Convenience: build + register in one call. Stabilises identity via
|
|
67
|
+
* useMemo so callers don't need to memoize themselves.
|
|
68
|
+
*/
|
|
69
|
+
export function useMobileViewSwitcherRegistration(input) {
|
|
70
|
+
const { views, activeViewId, onChange, triggerLabel, enabled = true } = input;
|
|
71
|
+
// Stabilise onChange identity if caller didn't already useCallback.
|
|
72
|
+
const handlerRef = useRef(onChange);
|
|
73
|
+
handlerRef.current = onChange;
|
|
74
|
+
const stableOnChange = useCallback((id) => handlerRef.current(id), []);
|
|
75
|
+
const value = useMemo(() => {
|
|
76
|
+
if (!enabled)
|
|
77
|
+
return null;
|
|
78
|
+
return { views, activeViewId, onChange: stableOnChange, triggerLabel };
|
|
79
|
+
}, [enabled, views, activeViewId, stableOnChange, triggerLabel]);
|
|
80
|
+
useRegisterMobileViewSwitcher(value);
|
|
81
|
+
}
|
|
@@ -16,8 +16,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
16
16
|
import * as React from 'react';
|
|
17
17
|
import { Link, useLocation } from 'react-router-dom';
|
|
18
18
|
import { getIcon } from '../utils/getIcon';
|
|
19
|
-
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarMenuAction, SidebarTrigger, useSidebar, } from '@object-ui/components';
|
|
20
|
-
import { Clock, Star, StarOff, ChevronRight, Layers, } from 'lucide-react';
|
|
19
|
+
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarGroup, SidebarGroupLabel, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarMenuAction, SidebarTrigger, useSidebar, } from '@object-ui/components';
|
|
20
|
+
import { Clock, Star, StarOff, ChevronRight, Home, Layers, } from 'lucide-react';
|
|
21
21
|
import { NavigationRenderer } from '@object-ui/layout';
|
|
22
22
|
import { useMetadata } from '../providers/MetadataProvider';
|
|
23
23
|
import { useExpressionContext, evaluateVisibility } from '../providers/ExpressionProvider';
|
|
@@ -25,7 +25,6 @@ import { usePermissions } from '@object-ui/permissions';
|
|
|
25
25
|
import { useRecentItems } from '../hooks/useRecentItems';
|
|
26
26
|
import { useFavorites } from '../hooks/useFavorites';
|
|
27
27
|
import { useNavPins } from '../hooks/useNavPins';
|
|
28
|
-
import { resolveI18nLabel } from '../utils';
|
|
29
28
|
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
|
|
30
29
|
// useObjectLabel provides appLabel/appDescription for convention-based
|
|
31
30
|
// i18n lookup — `{ns}.apps.{name}.label` resolves to the translated label
|
|
@@ -86,7 +85,7 @@ function useNavOrder(appName) {
|
|
|
86
85
|
return { applyOrder, handleReorder };
|
|
87
86
|
}
|
|
88
87
|
export function UnifiedSidebar({ activeAppName }) {
|
|
89
|
-
const { isMobile } = useSidebar();
|
|
88
|
+
const { isMobile, setOpenMobile } = useSidebar();
|
|
90
89
|
const location = useLocation();
|
|
91
90
|
const { t } = useObjectTranslation();
|
|
92
91
|
const { objectLabel: resolveNavObjectLabel, dashboardLabel: resolveNavDashboardLabel, navGroupLabel: resolveNavGroupLabel } = useObjectLabel();
|
|
@@ -172,43 +171,13 @@ export function UnifiedSidebar({ activeAppName }) {
|
|
|
172
171
|
return true;
|
|
173
172
|
}, [registeredObjectNames]);
|
|
174
173
|
const basePath = context === 'app' && activeApp ? `/apps/${activeApp.name}` : '';
|
|
175
|
-
return (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// Flatten group items so apps that organise navigation into groups
|
|
185
|
-
// (e.g. Setup → Overview / Administration / …) still surface real
|
|
186
|
-
// leaf links in the mobile bottom nav instead of rendering nothing.
|
|
187
|
-
const leaves = [];
|
|
188
|
-
for (const item of processedNavigation) {
|
|
189
|
-
if (item.type === 'group') {
|
|
190
|
-
for (const child of (item.children || [])) {
|
|
191
|
-
if (child && child.type !== 'group')
|
|
192
|
-
leaves.push(child);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
else {
|
|
196
|
-
leaves.push(item);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
return leaves.slice(0, 5).map((item) => {
|
|
200
|
-
const NavIcon = getIcon(item.icon);
|
|
201
|
-
let href = item.url || '#';
|
|
202
|
-
if (item.type === 'object') {
|
|
203
|
-
href = `${basePath}/${item.objectName}`;
|
|
204
|
-
if (item.viewName)
|
|
205
|
-
href += `/view/${item.viewName}`;
|
|
206
|
-
}
|
|
207
|
-
else if (item.type === 'dashboard')
|
|
208
|
-
href = item.dashboardName ? `${basePath}/dashboard/${item.dashboardName}` : '#';
|
|
209
|
-
else if (item.type === 'page')
|
|
210
|
-
href = item.pageName ? `${basePath}/page/${item.pageName}` : '#';
|
|
211
|
-
return (_jsxs(Link, { to: href, className: "flex flex-col items-center gap-0.5 px-2 py-1.5 text-muted-foreground hover:text-foreground transition-colors min-w-[44px] min-h-[44px] justify-center", children: [_jsx(NavIcon, { className: "h-5 w-5" }), _jsx("span", { className: "text-[10px] truncate max-w-[60px]", children: resolveI18nLabel(item.label, t) })] }, item.id));
|
|
212
|
-
});
|
|
213
|
-
})() }))] }));
|
|
174
|
+
return (_jsx(_Fragment, { children: _jsxs(Sidebar, { collapsible: "icon", className: "!top-14 !h-[calc(100svh-3.5rem)]", children: [isMobile && context === 'app' && (_jsx(SidebarHeader, { className: "border-b p-1.5", children: _jsx(SidebarMenu, { children: _jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, className: "h-9 text-sm font-medium", children: _jsxs(Link, { to: "/home", onClick: () => setOpenMobile(false), "data-testid": "mobile-sidebar-home", children: [_jsx(Home, { className: "h-4 w-4" }), _jsx("span", { children: t('home.nav', { defaultValue: 'Home' }) })] }) }) }) }) })), _jsx(SidebarContent, { className: "pt-2", children: _jsx("div", { className: "transition-opacity duration-200 ease-in-out", children: context === 'app' && activeApp ? (_jsxs(_Fragment, { children: [areas.length > 1 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Layers, { className: "h-3.5 w-3.5" }), t('sidebar.area', { defaultValue: 'Area' })] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: areas.map((area) => {
|
|
175
|
+
const AreaIcon = getIcon(area.icon);
|
|
176
|
+
const isActiveArea = area.id === activeAreaId;
|
|
177
|
+
return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActiveArea, tooltip: area.label, onClick: () => setActiveAreaId(area.id), children: [_jsx(AreaIcon, { className: "h-4 w-4" }), _jsx("span", { children: area.label })] }) }, area.id));
|
|
178
|
+
}) }) })] })), _jsx(NavigationRenderer, { items: processedNavigation, basePath: basePath, evaluateVisibility: evalVis, checkPermission: checkPerm, checkCapability: checkCap, enablePinning: !isMobile, onPinToggle: togglePin, enableReorder: !isMobile, onReorder: handleReorder, resolveObjectLabel: (objectName, fallback) => resolveNavObjectLabel({ name: objectName, label: fallback }), resolveDashboardLabel: (dashboardName, fallback) => resolveNavDashboardLabel({ name: dashboardName, label: fallback }), resolveGroupLabel: activeApp ? (groupId, fallback) => resolveNavGroupLabel(activeApp.name, groupId, fallback) : undefined, t: t }), recentItems.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5 cursor-pointer select-none", onClick: () => setRecentExpanded(prev => !prev), children: [_jsx(ChevronRight, { className: `h-3 w-3 transition-transform duration-150 ${recentExpanded ? 'rotate-90' : ''}` }), _jsx(Clock, { className: "h-3.5 w-3.5" }), t('sidebar.recent', { defaultValue: 'Recent' })] }), recentExpanded && (_jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: recentItems.slice(0, 5).map(item => (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄' }), _jsx("span", { className: "truncate", children: item.label })] }) }) }, item.id))) }) }))] })), favorites.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Star, { className: "h-3.5 w-3.5" }), t('sidebar.favorites', { defaultValue: 'Favorites' })] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: favorites.slice(0, 8).map(item => (_jsxs(SidebarMenuItem, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : item.type === 'page' ? '📄' : '📋' }), _jsx("span", { className: "truncate", children: item.label })] }) }), _jsx(SidebarMenuAction, { showOnHover: true, onClick: (e) => { e.stopPropagation(); removeFavorite(item.id); }, "aria-label": t('sidebar.removeFromFavorites', { defaultValue: 'Remove {{name}} from favorites', name: item.label }), children: _jsx(StarOff, { className: "h-3 w-3" }) })] }, item.id))) }) })] }))] })) : (_jsxs(_Fragment, { children: [_jsx(SidebarGroup, { children: _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: homeNavigation.map((item) => {
|
|
179
|
+
const NavIcon = getIcon(item.icon);
|
|
180
|
+
const isActive = location.pathname === item.url;
|
|
181
|
+
return (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, isActive: isActive, children: _jsxs(Link, { to: item.url || '/home', children: [_jsx(NavIcon, { className: "h-4 w-4" }), _jsx("span", { children: item.label })] }) }) }, item.id));
|
|
182
|
+
}) }) }) }), favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Star, { className: "h-3.5 w-3.5" }), t('sidebar.starred', { defaultValue: 'Starred' })] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').slice(0, 8).map(item => (_jsxs(SidebarMenuItem, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'page' ? '📄' : '📋' }), _jsx("span", { className: "truncate", children: item.label })] }) }), _jsx(SidebarMenuAction, { showOnHover: true, onClick: (e) => { e.stopPropagation(); removeFavorite(item.id); }, "aria-label": t('sidebar.removeFromFavorites', { defaultValue: 'Remove {{name}} from favorites', name: item.label }), children: _jsx(StarOff, { className: "h-3 w-3" }) })] }, item.id))) }) })] }))] })) }) }), _jsx(SidebarFooter, { className: "border-t p-1", children: _jsx(SidebarTrigger, { className: "w-full justify-start pl-2 group-data-[state=collapsed]:justify-center group-data-[state=collapsed]:pl-0" }) })] }) }));
|
|
214
183
|
}
|
|
@@ -54,6 +54,17 @@ interface RuntimeField {
|
|
|
54
54
|
} | string>;
|
|
55
55
|
multiple?: boolean;
|
|
56
56
|
defaultValue?: unknown;
|
|
57
|
+
reference_to?: string;
|
|
58
|
+
reference?: string;
|
|
59
|
+
display_field?: string;
|
|
60
|
+
reference_field?: string;
|
|
61
|
+
id_field?: string;
|
|
62
|
+
description_field?: string;
|
|
63
|
+
title_format?: string;
|
|
64
|
+
lookup_columns?: unknown[];
|
|
65
|
+
lookup_filters?: unknown[];
|
|
66
|
+
lookup_page_size?: number;
|
|
67
|
+
depends_on?: unknown[];
|
|
57
68
|
}
|
|
58
69
|
interface RuntimeObject {
|
|
59
70
|
name?: string;
|
|
@@ -58,6 +58,24 @@ export function resolveActionParam(param, ctx) {
|
|
|
58
58
|
?? normaliseOptions(field.options, ownerName, param.field, ctx.fieldOptionLabel);
|
|
59
59
|
const resolvedLabel = param.label
|
|
60
60
|
?? ctx.fieldLabel(ownerName, param.field, field.label ?? param.field);
|
|
61
|
+
/** Lookup/reference params carry extra picker config that the dialog
|
|
62
|
+
* forwards to `<LookupField>`. Without these the picker would fall back
|
|
63
|
+
* to a plain text input. */
|
|
64
|
+
const isLookupResolvedType = resolvedType === 'lookup' || resolvedType === 'reference';
|
|
65
|
+
const lookupExtras = isLookupResolvedType
|
|
66
|
+
? {
|
|
67
|
+
referenceTo: field.reference_to ?? field.reference,
|
|
68
|
+
displayField: field.display_field ?? field.reference_field,
|
|
69
|
+
idField: field.id_field,
|
|
70
|
+
descriptionField: field.description_field,
|
|
71
|
+
multiple: field.multiple,
|
|
72
|
+
titleFormat: field.title_format,
|
|
73
|
+
lookupColumns: field.lookup_columns,
|
|
74
|
+
lookupFilters: field.lookup_filters,
|
|
75
|
+
lookupPageSize: field.lookup_page_size,
|
|
76
|
+
dependsOn: field.depends_on,
|
|
77
|
+
}
|
|
78
|
+
: {};
|
|
61
79
|
return {
|
|
62
80
|
name: param.name ?? param.field,
|
|
63
81
|
label: resolvedLabel,
|
|
@@ -67,6 +85,7 @@ export function resolveActionParam(param, ctx) {
|
|
|
67
85
|
placeholder: param.placeholder ?? field.placeholder,
|
|
68
86
|
helpText: param.helpText ?? field.help ?? field.description,
|
|
69
87
|
defaultValue: rowDefault ?? param.defaultValue ?? field.defaultValue,
|
|
88
|
+
...lookupExtras,
|
|
70
89
|
};
|
|
71
90
|
}
|
|
72
91
|
/** Resolve an array of raw action params. */
|
|
@@ -13,6 +13,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
13
13
|
import { useState, useEffect } from 'react';
|
|
14
14
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Button, Input, Label, Textarea, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Checkbox, } from '@object-ui/components';
|
|
15
15
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
16
|
+
import { LookupField } from '@object-ui/fields';
|
|
16
17
|
export function ActionParamDialog({ state, onOpenChange }) {
|
|
17
18
|
const { t } = useObjectTranslation();
|
|
18
19
|
const [values, setValues] = useState({});
|
|
@@ -77,7 +78,21 @@ export function ActionParamDialog({ state, onOpenChange }) {
|
|
|
77
78
|
return (_jsxs("div", { className: "grid gap-1", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx(Checkbox, { id: param.name, checked: checked, onCheckedChange: (c) => updateValue(param.name, c === true), className: "mt-0.5" }), _jsxs(Label, { htmlFor: param.name, className: "font-normal cursor-pointer", children: [param.label, param.required && _jsx("span", { className: "text-destructive ml-1", children: "*" })] })] }), errors[param.name] && (_jsx("p", { className: "text-xs text-destructive ml-6", children: t('actionDialog.requiredError', { label: param.label }) })), param.helpText && (_jsx("p", { className: "text-xs text-muted-foreground ml-6", children: param.helpText }))] }, param.name));
|
|
78
79
|
}
|
|
79
80
|
const isLookupParam = param.type === 'lookup' || param.type === 'reference';
|
|
80
|
-
return (_jsxs("div", { className: "grid gap-2", children: [_jsxs(Label, { htmlFor: param.name, children: [param.label, param.required && _jsx("span", { className: "text-destructive ml-1", children: "*" })] }), param.type === 'select' && param.options ? (_jsxs(Select, { value: values[param.name] ?? '', onValueChange: (val) => updateValue(param.name, val), children: [_jsx(SelectTrigger, { id: param.name, className: errors[param.name] ? 'border-destructive' : '', children: _jsx(SelectValue, { placeholder: param.placeholder || t('actionDialog.selectPlaceholder', { label: param.label }) }) }), _jsx(SelectContent, { children: param.options.map((opt) => (_jsx(SelectItem, { value: opt.value, children: opt.label }, opt.value))) })] })) :
|
|
81
|
+
return (_jsxs("div", { className: "grid gap-2", children: [_jsxs(Label, { htmlFor: param.name, children: [param.label, param.required && _jsx("span", { className: "text-destructive ml-1", children: "*" })] }), param.type === 'select' && param.options ? (_jsxs(Select, { value: values[param.name] ?? '', onValueChange: (val) => updateValue(param.name, val), children: [_jsx(SelectTrigger, { id: param.name, className: errors[param.name] ? 'border-destructive' : '', children: _jsx(SelectValue, { placeholder: param.placeholder || t('actionDialog.selectPlaceholder', { label: param.label }) }) }), _jsx(SelectContent, { children: param.options.map((opt) => (_jsx(SelectItem, { value: opt.value, children: opt.label }, opt.value))) })] })) : isLookupParam && param.referenceTo ? (_jsx(LookupField, { value: values[param.name] ?? null, onChange: (v) => updateValue(param.name, v), field: {
|
|
82
|
+
name: param.name,
|
|
83
|
+
type: 'lookup',
|
|
84
|
+
reference_to: param.referenceTo,
|
|
85
|
+
display_field: param.displayField,
|
|
86
|
+
id_field: param.idField,
|
|
87
|
+
description_field: param.descriptionField,
|
|
88
|
+
multiple: param.multiple,
|
|
89
|
+
title_format: param.titleFormat,
|
|
90
|
+
lookup_columns: param.lookupColumns,
|
|
91
|
+
lookup_filters: param.lookupFilters,
|
|
92
|
+
lookup_page_size: param.lookupPageSize,
|
|
93
|
+
depends_on: param.dependsOn,
|
|
94
|
+
placeholder: param.placeholder,
|
|
95
|
+
} })) : param.type === 'textarea' ? (_jsx(Textarea, { id: param.name, value: values[param.name] ?? '', onChange: (e) => updateValue(param.name, e.target.value), placeholder: param.placeholder, className: errors[param.name] ? 'border-destructive' : '' })) : param.type === 'number' ? (_jsx(Input, { id: param.name, type: "number", value: values[param.name] ?? '', onChange: (e) => updateValue(param.name, e.target.value === '' ? undefined : e.target.valueAsNumber), placeholder: param.placeholder, className: errors[param.name] ? 'border-destructive' : '' })) : (_jsx(Input, { id: param.name, type: ['email', 'url', 'date', 'datetime-local', 'time', 'password'].includes(param.type) ? param.type : 'text', value: values[param.name] ?? '', onChange: (e) => updateValue(param.name, e.target.value), placeholder: param.placeholder ||
|
|
81
96
|
(isLookupParam ? t('actionDialog.lookupPlaceholder', { label: param.label }) : undefined), className: errors[param.name] ? 'border-destructive' : '' })), errors[param.name] && (_jsx("p", { className: "text-xs text-destructive", children: t('actionDialog.requiredError', { label: param.label }) })), param.helpText && (_jsx("p", { className: "text-xs text-muted-foreground", children: param.helpText })), isLookupParam && !param.helpText && (_jsx("p", { className: "text-xs text-muted-foreground", children: t('actionDialog.lookupHelpText') }))] }, param.name));
|
|
82
97
|
}) }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: handleCancel, children: t('actionDialog.cancel') }), _jsx(Button, { onClick: handleSubmit, children: t('actionDialog.confirm') })] })] }) }));
|
|
83
98
|
}
|
package/dist/views/ObjectView.js
CHANGED
|
@@ -18,13 +18,14 @@ import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@
|
|
|
18
18
|
// Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx
|
|
19
19
|
// uses ComponentRegistry.registerLazy so heavy plugins stay code-split).
|
|
20
20
|
// Do NOT add eager `import '@object-ui/plugin-*'` side-effect imports here.
|
|
21
|
-
import { Button, Empty, EmptyTitle, EmptyDescription, NavigationOverlay } from '@object-ui/components';
|
|
21
|
+
import { Button, Empty, EmptyTitle, EmptyDescription, NavigationOverlay, } from '@object-ui/components';
|
|
22
22
|
import { Plus, Upload, Table as TableIcon, KanbanSquare, Calendar, LayoutGrid, Activity, GanttChart, MapPin, BarChart3 } from 'lucide-react';
|
|
23
23
|
import { getIcon } from '../utils/getIcon';
|
|
24
24
|
import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
|
|
25
25
|
import { ViewConfigPanel } from './ViewConfigPanel';
|
|
26
26
|
import { CreateViewDialog } from './CreateViewDialog';
|
|
27
27
|
import { PageHeader } from '../layout/PageHeader';
|
|
28
|
+
import { useMobileViewSwitcherRegistration } from '../layout/MobileViewSwitcherContext';
|
|
28
29
|
import { ManagedByBadge } from '../components/ManagedByBadge';
|
|
29
30
|
import { RecordDetailView } from './RecordDetailView';
|
|
30
31
|
import { resolveCrudAffordances } from '../utils/crudAffordances';
|
|
@@ -674,6 +675,25 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
|
|
|
674
675
|
navigate(`view/${matchedView.id}`);
|
|
675
676
|
}
|
|
676
677
|
};
|
|
678
|
+
// Mobile view switcher — registers our view list with the AppHeader so
|
|
679
|
+
// the topbar can render a `<viewName> ▾` dropdown instead of the static
|
|
680
|
+
// page label. Desktop ignores this (ViewTabBar handles switching there).
|
|
681
|
+
const mobileViewSwitcherItems = useMemo(() => {
|
|
682
|
+
return (views || []).map((view) => {
|
|
683
|
+
const Icon = VIEW_TYPE_ICONS[view.type];
|
|
684
|
+
return {
|
|
685
|
+
id: view.id,
|
|
686
|
+
label: viewLabel(objectDef.name, view.name || view.id, view.label || view.name || view.id),
|
|
687
|
+
icon: Icon ? _jsx(Icon, { className: "h-4 w-4" }) : undefined,
|
|
688
|
+
};
|
|
689
|
+
});
|
|
690
|
+
}, [views, objectDef.name, viewLabel]);
|
|
691
|
+
useMobileViewSwitcherRegistration({
|
|
692
|
+
views: mobileViewSwitcherItems,
|
|
693
|
+
activeViewId: activeViewId ?? '',
|
|
694
|
+
onChange: handleViewChange,
|
|
695
|
+
enabled: mobileViewSwitcherItems.length > 0 && !!activeViewId,
|
|
696
|
+
});
|
|
677
697
|
// ViewSwitcher callbacks — wired to both PluginObjectView instances
|
|
678
698
|
const handleCreateView = useCallback(() => {
|
|
679
699
|
setShowCreateViewDialog(true);
|
|
@@ -1561,27 +1581,27 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
|
|
|
1561
1581
|
activeOrganization: activeOrganization
|
|
1562
1582
|
? { id: activeOrganization.id, slug: activeOrganization.slug, name: activeOrganization.name }
|
|
1563
1583
|
: null,
|
|
1564
|
-
}, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: [_jsxs("div", { className: "h-full flex flex-col bg-background min-w-0 overflow-hidden", children: [_jsx(PageHeader, { title: _jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { className: "truncate", children: objectLabel(objectDef) }), _jsx(ManagedByBadge, { managedBy: objectDef?.managedBy })] }), description: objectDef.description ? objectDesc(objectDef) : undefined, icon: (() => { const I = getIcon(objectDef?.icon); return _jsx(I, { className: "h-4 w-4" }); })(), actions: _jsxs(_Fragment, { children: [affordances.create && can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", onClick: actions.create, className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.new') })] })), affordances.import && can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => setShowImport(true), className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", title: t('console.objectView.importTitle'), "data-testid": "object-view-import-button", children: [_jsx(Upload, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.import') })] })), objectDef.actions?.some((a) => a.locations?.includes('list_toolbar')) && (_jsx(SchemaRenderer, { schema: {
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1584
|
+
}, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: [_jsxs("div", { className: "h-full flex flex-col bg-background min-w-0 overflow-hidden", children: [_jsx("div", { className: "hidden sm:block", children: _jsx(PageHeader, { title: _jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { className: "truncate", children: objectLabel(objectDef) }), _jsx(ManagedByBadge, { managedBy: objectDef?.managedBy })] }), description: objectDef.description ? objectDesc(objectDef) : undefined, icon: (() => { const I = getIcon(objectDef?.icon); return _jsx(I, { className: "h-4 w-4" }); })(), actions: _jsxs(_Fragment, { children: [affordances.create && can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", onClick: actions.create, className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.new') })] })), affordances.import && can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => setShowImport(true), className: "hidden sm:inline-flex shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", title: t('console.objectView.importTitle'), "data-testid": "object-view-import-button", children: [_jsx(Upload, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.import') })] })), objectDef.actions?.some((a) => a.locations?.includes('list_toolbar')) && (_jsx(SchemaRenderer, { schema: {
|
|
1585
|
+
type: 'action:bar',
|
|
1586
|
+
location: 'list_toolbar',
|
|
1587
|
+
actions: (objectDef.actions || []).map((a) => ({
|
|
1588
|
+
...a,
|
|
1589
|
+
label: actionLabel(objectDef.name, a.name, a.label || a.name),
|
|
1590
|
+
...(a.confirmText !== undefined && {
|
|
1591
|
+
confirmText: actionConfirm(objectDef.name, a.name, a.confirmText),
|
|
1592
|
+
}),
|
|
1593
|
+
...(a.successMessage !== undefined && {
|
|
1594
|
+
successMessage: actionSuccess(objectDef.name, a.name, a.successMessage),
|
|
1595
|
+
}),
|
|
1596
|
+
})),
|
|
1597
|
+
size: 'sm',
|
|
1598
|
+
variant: 'outline',
|
|
1599
|
+
// On mobile, collapse all schema-driven toolbar actions
|
|
1600
|
+
// into a single overflow menu so the icon-only New /
|
|
1601
|
+
// Import buttons stay visible without pushing the page
|
|
1602
|
+
// title off-screen.
|
|
1603
|
+
mobileMaxVisible: 0,
|
|
1604
|
+
} }))] }) }) }), affordances.create && can(objectDef.name, 'create') && (_jsx("button", { type: "button", onClick: actions.create, className: "sm:hidden fixed right-4 bottom-36 z-40 h-12 w-12 rounded-full bg-primary text-primary-foreground shadow-lg active:scale-95 transition-transform inline-flex items-center justify-center", "aria-label": t('console.objectView.new'), "data-testid": "mobile-fab-create", children: _jsx(Plus, { className: "h-5 w-5" }) })), showImport && (_jsx(Suspense, { fallback: null, children: _jsx(ImportWizard, { open: showImport, onOpenChange: setShowImport, objectName: objectDef.name, objectLabel: objectLabel(objectDef), fields: Object.entries(objectDef.fields || {}).map(([name, def]) => ({
|
|
1585
1605
|
name,
|
|
1586
1606
|
label: def?.label || name,
|
|
1587
1607
|
type: def?.type || 'text',
|
|
@@ -1619,12 +1639,12 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
|
|
|
1619
1639
|
: undefined,
|
|
1620
1640
|
};
|
|
1621
1641
|
});
|
|
1622
|
-
return (_jsxs("div", { className: "border-b px-3 sm:px-4 bg-background overflow-x-auto shrink-0", children: [_jsx(ViewTabBar, { views: viewTabItems, activeViewId: activeViewId, onViewChange: handleViewChange, viewTypeIcons: VIEW_TYPE_ICONS, config: {
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1642
|
+
return (_jsx(_Fragment, { children: _jsxs("div", { className: "hidden sm:block border-b px-3 sm:px-4 bg-background overflow-x-auto shrink-0", children: [_jsx(ViewTabBar, { views: viewTabItems, activeViewId: activeViewId, onViewChange: handleViewChange, viewTypeIcons: VIEW_TYPE_ICONS, config: {
|
|
1643
|
+
reorderable: false,
|
|
1644
|
+
showAddButton: isAdmin,
|
|
1645
|
+
showPinnedSection: true,
|
|
1646
|
+
showVisibilityGroups: true,
|
|
1647
|
+
}, onAddView: isAdmin ? handleAddView : undefined, onRenameView: isAdmin ? handleRenameView : undefined, onDeleteView: isAdmin ? handleDeleteView : undefined, onDuplicateView: isAdmin ? handleDuplicateView : undefined, onPinView: isAdmin ? handlePinView : undefined, onSetDefaultView: isAdmin ? handleSetDefaultView : undefined, onConfigView: isAdmin ? handleConfigView : undefined, onManageViews: isAdmin ? () => setManageViewsOpen(true) : undefined }), isAdmin && (_jsx(ManageViewsDialog, { open: manageViewsOpen, onOpenChange: setManageViewsOpen, views: viewTabItems, activeViewId: activeViewId, viewTypeIcons: VIEW_TYPE_ICONS, onRename: handleRenameView, onDelete: handleDeleteView, onDuplicate: handleDuplicateView, onSetDefault: handleSetDefaultView, onSetPinned: handlePinView, onReorder: handleReorderViews, onAddView: handleAddView, onConfigView: handleConfigView }))] }) }));
|
|
1628
1648
|
})(), _jsxs("div", { className: "flex-1 overflow-hidden relative flex flex-row", children: [navOverlay.mode === 'split' && navOverlay.isOpen ? (_jsx(NavigationOverlay, { ...navOverlay, setIsOpen: (open) => { if (!open)
|
|
1629
1649
|
handleDrawerClose(); }, title: objectLabel(objectDef), onExpand: handleExpandDrawer, expandLabel: t('console.objectView.expandToPage', { defaultValue: 'Open as full page' }), storageKey: `drawer-width:${objectDef.name}`, mainContent: _jsxs("div", { className: "flex-1 min-w-0 relative h-full flex flex-col", children: [_jsx("div", { className: "flex-1 relative overflow-hidden", children: _jsx("div", { className: "h-full overflow-auto", children: _jsx(PluginObjectView, { schema: objectViewSchema, dataSource: dataSource, views: mergedViews, activeViewId: activeViewId, onViewChange: handleViewChange, onEdit: (record) => onEdit?.(record), onRowClick: (record, event) => {
|
|
1630
1650
|
handleRowClick(record, event);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/app-shell",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.8.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.8.0",
|
|
31
|
+
"@object-ui/collaboration": "4.8.0",
|
|
32
|
+
"@object-ui/components": "4.8.0",
|
|
33
|
+
"@object-ui/core": "4.8.0",
|
|
34
|
+
"@object-ui/data-objectstack": "4.8.0",
|
|
35
|
+
"@object-ui/fields": "4.8.0",
|
|
36
|
+
"@object-ui/i18n": "4.8.0",
|
|
37
|
+
"@object-ui/layout": "4.8.0",
|
|
38
|
+
"@object-ui/permissions": "4.8.0",
|
|
39
|
+
"@object-ui/react": "4.8.0",
|
|
40
|
+
"@object-ui/types": "4.8.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.8.0",
|
|
47
|
+
"@object-ui/plugin-charts": "^4.8.0",
|
|
48
|
+
"@object-ui/plugin-chatbot": "^4.8.0",
|
|
49
|
+
"@object-ui/plugin-dashboard": "^4.8.0",
|
|
50
|
+
"@object-ui/plugin-designer": "^4.8.0",
|
|
51
|
+
"@object-ui/plugin-detail": "^4.8.0",
|
|
52
|
+
"@object-ui/plugin-form": "^4.8.0",
|
|
53
|
+
"@object-ui/plugin-grid": "^4.8.0",
|
|
54
|
+
"@object-ui/plugin-kanban": "^4.8.0",
|
|
55
|
+
"@object-ui/plugin-list": "^4.8.0",
|
|
56
|
+
"@object-ui/plugin-report": "^4.8.0",
|
|
57
|
+
"@object-ui/plugin-view": "^4.8.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^25.9.0",
|