@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.
- package/CHANGELOG.md +416 -0
- package/dist/console/ConsoleShell.js +2 -2
- 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/pageSchemaIntrospect.d.ts +20 -0
- package/dist/utils/pageSchemaIntrospect.js +51 -0
- package/dist/views/ObjectView.js +48 -28
- package/dist/views/RecordDetailView.js +197 -7
- package/package.json +24 -24
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
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);
|