@object-ui/app-shell 4.7.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 (_jsxs(_Fragment, { children: [_jsxs(Sidebar, { collapsible: "icon", className: "!top-14 !h-[calc(100svh-3.5rem)]", children: [_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) => {
176
- const AreaIcon = getIcon(area.icon);
177
- const isActiveArea = area.id === activeAreaId;
178
- 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));
179
- }) }) })] })), _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) => {
180
- const NavIcon = getIcon(item.icon);
181
- const isActive = location.pathname === item.url;
182
- 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));
183
- }) }) }) }), 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" }) })] }), isMobile && context === 'app' && (_jsx("div", { className: "fixed bottom-0 left-0 right-0 z-50 flex items-center justify-around border-t bg-background/95 backdrop-blur-sm px-2 py-1 sm:hidden safe-area-bottom", children: (() => {
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
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * Helpers that introspect a page schema tree without needing the React
6
+ * runtime. Used by RecordDetailView to decide whether to auto-append
7
+ * a discussion / chatter slot at the bottom of the page.
8
+ */
9
+ /**
10
+ * Walks a page schema tree and returns true if any node has a
11
+ * `type` of `record:discussion` or `record:chatter`.
12
+ *
13
+ * Recurses into:
14
+ * - `children`, `items`, `body`, `components`
15
+ * - `properties.children`, `properties.items`
16
+ * - `regions` (synth + full-Lightning pages nest components here)
17
+ *
18
+ * Cycles are guarded with a WeakSet.
19
+ */
20
+ export declare function hasExplicitDiscussion(root: unknown): boolean;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * Helpers that introspect a page schema tree without needing the React
6
+ * runtime. Used by RecordDetailView to decide whether to auto-append
7
+ * a discussion / chatter slot at the bottom of the page.
8
+ */
9
+ const DISCUSSION_TYPES = new Set(['record:discussion', 'record:chatter']);
10
+ /**
11
+ * Walks a page schema tree and returns true if any node has a
12
+ * `type` of `record:discussion` or `record:chatter`.
13
+ *
14
+ * Recurses into:
15
+ * - `children`, `items`, `body`, `components`
16
+ * - `properties.children`, `properties.items`
17
+ * - `regions` (synth + full-Lightning pages nest components here)
18
+ *
19
+ * Cycles are guarded with a WeakSet.
20
+ */
21
+ export function hasExplicitDiscussion(root) {
22
+ const seen = new WeakSet();
23
+ const walk = (node) => {
24
+ if (!node || typeof node !== 'object')
25
+ return false;
26
+ if (seen.has(node))
27
+ return false;
28
+ seen.add(node);
29
+ if (Array.isArray(node))
30
+ return node.some(walk);
31
+ const t = node?.type;
32
+ if (typeof t === 'string' && DISCUSSION_TYPES.has(t))
33
+ return true;
34
+ const candidates = [
35
+ node.children,
36
+ node.items,
37
+ node.body,
38
+ node.components,
39
+ node.properties?.children,
40
+ node.properties?.items,
41
+ // Synth + full-Lightning pages nest components inside
42
+ // `regions[].components[]`. Without this branch the walker
43
+ // fails to see the `record:discussion` baked in by
44
+ // `buildDefaultPageSchema`, and the host appends a second
45
+ // chatter panel on top of it.
46
+ node.regions,
47
+ ];
48
+ return candidates.some(walk);
49
+ };
50
+ return walk(root);
51
+ }
@@ -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
- type: 'action:bar',
1566
- location: 'list_toolbar',
1567
- actions: (objectDef.actions || []).map((a) => ({
1568
- ...a,
1569
- label: actionLabel(objectDef.name, a.name, a.label || a.name),
1570
- ...(a.confirmText !== undefined && {
1571
- confirmText: actionConfirm(objectDef.name, a.name, a.confirmText),
1572
- }),
1573
- ...(a.successMessage !== undefined && {
1574
- successMessage: actionSuccess(objectDef.name, a.name, a.successMessage),
1575
- }),
1576
- })),
1577
- size: 'sm',
1578
- variant: 'outline',
1579
- // On mobile, collapse all schema-driven toolbar actions
1580
- // into a single overflow menu so the icon-only New /
1581
- // Import buttons stay visible without pushing the page
1582
- // title off-screen.
1583
- mobileMaxVisible: 0,
1584
- } }))] }) }), 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]) => ({
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
- reorderable: false,
1624
- showAddButton: isAdmin,
1625
- showPinnedSection: true,
1626
- showVisibilityGroups: true,
1627
- }, 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 }))] }));
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);