@orsetra/shared-ui 1.9.0 → 1.9.2

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.
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react"
4
4
  import { usePathname, useSearchParams, useParams } from "next/navigation"
5
5
  import { MainSidebar, type SidebarMode } from "./sidebar/main-sidebar"
6
6
  import { Sidebar, SidebarProvider, useSidebar } from "./sidebar/sidebar"
7
- import { type SidebarMenus, type MainMenuItem, resolveIcon } from "./sidebar/data"
7
+ import { type SidebarMenus, type MainMenuItem, type SidebarMenuMeta, resolveIcon } from "./sidebar/data"
8
8
  import { Button } from "../ui"
9
9
  import { Logo } from "../ui/logo"
10
10
  import { getMenuFromPath } from "../../lib/menu-utils"
@@ -50,6 +50,7 @@ function LayoutContent({
50
50
  const [mainMenuItems, setMainMenuItems] = useState<MainMenuItem[]>([])
51
51
  const [fetchedSidebarMenus, setFetchedSidebarMenus] = useState<SidebarMenus>({})
52
52
  const [sectionLabels, setSectionLabels] = useState<Record<string, string>>({})
53
+ const [sidebarMenuMeta, setSidebarMenuMeta] = useState<Record<string, SidebarMenuMeta>>({})
53
54
 
54
55
  useEffect(() => {
55
56
  if (!fetchMenus) return
@@ -69,6 +70,7 @@ function LayoutContent({
69
70
 
70
71
  const menus: SidebarMenus = {}
71
72
  const labels: Record<string, string> = {}
73
+ const meta: Record<string, SidebarMenuMeta> = {}
72
74
  Object.entries(subMenus).forEach(([key, subMenu]) => {
73
75
  menus[key] = subMenu.items.map((item) => ({
74
76
  id: item.id,
@@ -78,9 +80,11 @@ function LayoutContent({
78
80
  icon: resolveIcon(item.icon),
79
81
  }))
80
82
  labels[key] = subMenu.header
83
+ meta[key] = { header: subMenu.header, description: subMenu.description }
81
84
  })
82
85
  setFetchedSidebarMenus(menus)
83
86
  setSectionLabels(labels)
87
+ setSidebarMenuMeta(meta)
84
88
  })
85
89
  // eslint-disable-next-line react-hooks/exhaustive-deps
86
90
  }, [])
@@ -148,6 +152,7 @@ function LayoutContent({
148
152
  mode={isHidden ? "expanded" : mode}
149
153
  sidebarMenus={effectiveSidebarMenus}
150
154
  mainMenuItems={mainMenuItems}
155
+ sidebarMenuMeta={sidebarMenuMeta}
151
156
  />
152
157
 
153
158
  <main className="flex-1 bg-ibm-gray-10 min-h-0 overflow-auto">
@@ -88,9 +88,15 @@ export interface ApiSubMenuItem {
88
88
  }
89
89
 
90
90
  export interface ApiSubMenu {
91
- id: string
92
- header: string
93
- items: ApiSubMenuItem[]
91
+ id: string
92
+ header: string
93
+ description?: string
94
+ items: ApiSubMenuItem[]
95
+ }
96
+
97
+ export interface SidebarMenuMeta {
98
+ header?: string
99
+ description?: string
94
100
  }
95
101
 
96
102
  export interface ApiMenuData {
@@ -3,13 +3,24 @@
3
3
  import * as React from "react"
4
4
  import { cn } from "../../../lib/utils"
5
5
  import type { MainMenuItem, SubMenuItem } from "./data"
6
+ import { Home, ExternalLink, type LucideIcon } from "lucide-react"
7
+
8
+ export interface FlyoutDefaultLink {
9
+ label: string
10
+ href: string
11
+ description?: string
12
+ icon?: LucideIcon
13
+ }
6
14
 
7
15
  interface MainSidebarFlyoutProps {
8
- item: MainMenuItem
9
- /** Sub-items with hrefs already resolved by the parent */
10
- subItems: SubMenuItem[]
11
- onMouseEnter: () => void
12
- onMouseLeave: () => void
16
+ item: MainMenuItem | null
17
+ subItems: SubMenuItem[]
18
+ onMouseEnter: () => void
19
+ onMouseLeave: () => void
20
+ main_base_url?: string
21
+ defaultTitle?: string
22
+ defaultDescription?: string
23
+ defaultLinks?: FlyoutDefaultLink[]
13
24
  }
14
25
 
15
26
  export function MainSidebarFlyout({
@@ -17,8 +28,21 @@ export function MainSidebarFlyout({
17
28
  subItems,
18
29
  onMouseEnter,
19
30
  onMouseLeave,
31
+ main_base_url = "/",
32
+ defaultTitle = "Camel X Platform",
33
+ defaultDescription,
34
+ defaultLinks = [],
20
35
  }: MainSidebarFlyoutProps) {
21
- const Icon = item.icon
36
+ const homePath = React.useMemo(() => {
37
+ if (!main_base_url) return "/"
38
+ try {
39
+ return (main_base_url.startsWith("http://") || main_base_url.startsWith("https://"))
40
+ ? new URL(main_base_url).pathname
41
+ : main_base_url
42
+ } catch {
43
+ return "/"
44
+ }
45
+ }, [main_base_url])
22
46
 
23
47
  return (
24
48
  <div
@@ -26,46 +50,98 @@ export function MainSidebarFlyout({
26
50
  onMouseLeave={onMouseLeave}
27
51
  className="h-full flex flex-col flex-shrink-0 overflow-hidden bg-gray-50 border-r border-ui-border shadow-[4px_0_20px_-4px_rgba(0,0,0,0.10)] w-72 lg:w-80"
28
52
  >
29
- {/* ── Section header ───────────────────────────────────────────────────── */}
30
- <div className="flex items-center px-4 lg:px-5 pt-5 pb-1 flex-shrink-0 gap-2.5">
31
- <Icon className="h-5 w-5 flex-shrink-0" style={{ color: "#4589ff" }} />
32
- <h2 className="text-sm font-semibold text-text-primary tracking-wide truncate flex-1">
33
- {item.label}
34
- </h2>
35
- </div>
53
+ {item ? (
54
+ /* ── Hovered item: sub-menu list ─────────────────────────────────── */
55
+ <>
56
+ <div className="flex items-center px-4 lg:px-5 pt-5 pb-1 flex-shrink-0 gap-2.5">
57
+ <item.icon className="h-5 w-5 flex-shrink-0" style={{ color: "#4589ff" }} />
58
+ <h2 className="text-sm font-semibold text-text-primary tracking-wide truncate flex-1">
59
+ {item.label}
60
+ </h2>
61
+ </div>
36
62
 
37
- {/* ── Section description ───────────────────────────────────────────────── */}
38
- {item.description && (
39
- <p className="px-4 lg:px-5 pt-1 pb-4 text-xs text-text-secondary leading-relaxed">
40
- {item.description}
41
- </p>
42
- )}
63
+ {item.description && (
64
+ <p className="px-4 lg:px-5 pt-1 pb-4 text-xs text-text-secondary leading-relaxed">
65
+ {item.description}
66
+ </p>
67
+ )}
43
68
 
44
- {/* ── Nav items ────────────────────────────────────────────────────────── */}
45
- <nav className="flex-1 overflow-y-auto px-2 lg:px-3 py-2 space-y-0.5">
46
- {subItems.map((sub) => {
47
- const SubIcon = sub.icon
48
- return (
69
+ <nav className="flex-1 overflow-y-auto px-2 lg:px-3 py-2 space-y-0.5">
70
+ {subItems.map((sub) => {
71
+ const SubIcon = sub.icon
72
+ return (
73
+ <a
74
+ key={sub.id}
75
+ href={sub.href}
76
+ className={cn(
77
+ "flex items-start transition-colors border-l-4",
78
+ "px-2 lg:px-3 py-2 gap-x-3",
79
+ "hover:bg-ui-background border-transparent hover:border-interactive",
80
+ )}
81
+ >
82
+ <SubIcon className="h-4 w-4 flex-shrink-0 mt-0.5" style={{ color: "#4589ff" }} />
83
+ <div className="min-w-0 flex-1">
84
+ <p className="text-sm font-medium text-text-primary leading-snug truncate">{sub.name}</p>
85
+ {sub.description && (
86
+ <p className="text-xs text-text-secondary leading-relaxed mt-0.5 line-clamp-2">{sub.description}</p>
87
+ )}
88
+ </div>
89
+ </a>
90
+ )
91
+ })}
92
+ </nav>
93
+ </>
94
+ ) : (
95
+ /* ── Default state: centré verticalement ────────────────────────── */
96
+ <div className="flex flex-col h-full items-center justify-center px-6 gap-6">
97
+ {/* Titre + texte + CTA */}
98
+ <div className="flex flex-col items-center gap-3 w-full">
99
+ <span className="text-sm font-semibold text-text-primary text-center">{defaultTitle}</span>
100
+ {defaultDescription && (
101
+ <p className="text-xs text-text-secondary text-center leading-relaxed">{defaultDescription}</p>
102
+ )}
49
103
  <a
50
- key={sub.id}
51
- href={sub.href}
52
- className={cn(
53
- "flex items-start transition-colors border-l-4",
54
- "px-2 lg:px-3 py-2 gap-x-3",
55
- "hover:bg-ui-background border-transparent hover:border-interactive",
56
- )}
104
+ href={homePath}
105
+ className="flex items-center justify-center gap-2 w-full px-3 py-2 border border-ui-border text-text-secondary hover:text-text-primary hover:bg-ui-background text-sm font-medium transition-colors"
57
106
  >
58
- <SubIcon className="h-4 w-4 flex-shrink-0 mt-0.5" style={{ color: "#4589ff" }} />
59
- <div className="min-w-0 flex-1">
60
- <p className="text-sm font-medium text-text-primary leading-snug truncate">{sub.name}</p>
61
- {sub.description && (
62
- <p className="text-xs text-text-secondary leading-relaxed mt-0.5 line-clamp-2">{sub.description}</p>
63
- )}
64
- </div>
107
+ <Home className="h-4 w-4 flex-shrink-0" />
108
+ Tableau de bord
65
109
  </a>
66
- )
67
- })}
68
- </nav>
110
+ </div>
111
+
112
+ {/* Liens ressources */}
113
+ {defaultLinks.length > 0 && (
114
+ <nav className="w-full space-y-0.5">
115
+ <p className="px-2 pb-1 text-[10px] font-semibold text-text-secondary uppercase tracking-widest">
116
+ Ressources
117
+ </p>
118
+ {defaultLinks.map((link, i) => {
119
+ const LinkIcon = link.icon ?? ExternalLink
120
+ const isExternal = link.href.startsWith("http://") || link.href.startsWith("https://")
121
+ return (
122
+ <a
123
+ key={i}
124
+ href={link.href}
125
+ {...(isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {})}
126
+ className="flex items-start gap-x-3 px-2 py-2 border-l-4 border-transparent hover:bg-ui-background hover:border-interactive transition-colors"
127
+ >
128
+ <LinkIcon className="h-4 w-4 flex-shrink-0 mt-0.5 text-text-secondary" />
129
+ <div className="min-w-0 flex-1">
130
+ <p className="text-sm font-medium text-text-primary leading-snug flex items-center gap-1.5">
131
+ {link.label}
132
+ {isExternal && <ExternalLink className="h-3 w-3 text-text-secondary flex-shrink-0" />}
133
+ </p>
134
+ {link.description && (
135
+ <p className="text-xs text-text-secondary leading-relaxed mt-0.5 line-clamp-2">{link.description}</p>
136
+ )}
137
+ </div>
138
+ </a>
139
+ )
140
+ })}
141
+ </nav>
142
+ )}
143
+ </div>
144
+ )}
69
145
  </div>
70
146
  )
71
147
  }
@@ -4,10 +4,14 @@ import * as React from "react"
4
4
  import { cn } from "../../../lib/utils"
5
5
  import { Button } from "../../ui/button"
6
6
  import { Logo } from "../../ui/logo"
7
- import { type MainMenuItem, type SidebarMenus } from "./data"
7
+ import { type MainMenuItem, type SidebarMenus, type SidebarMenuMeta } from "./data"
8
+
9
+ export type { SidebarMenuMeta }
8
10
  import { X, Menu, ChevronDown, ChevronRight } from "lucide-react"
9
11
  import { useIsMobile } from "../../../hooks/use-mobile"
10
- import { MainSidebarFlyout } from "./main-sidebar-flyout"
12
+ import { MainSidebarFlyout, type FlyoutDefaultLink } from "./main-sidebar-flyout"
13
+
14
+ export type { FlyoutDefaultLink }
11
15
 
12
16
  export type SidebarMode = 'expanded' | 'minimized' | 'hidden'
13
17
 
@@ -21,6 +25,8 @@ interface MainSidebarProps {
21
25
  onSecondarySidebarOpen?: () => void
22
26
  sidebarMenus?: SidebarMenus
23
27
  mainMenuItems?: MainMenuItem[]
28
+ /** Per-submenu metadata (header + description) derived from the API menu response. */
29
+ sidebarMenuMeta?: Record<string, SidebarMenuMeta>
24
30
  }
25
31
 
26
32
  export function MainSidebar({
@@ -33,6 +39,7 @@ export function MainSidebar({
33
39
  onSecondarySidebarOpen,
34
40
  sidebarMenus = {},
35
41
  mainMenuItems = [],
42
+ sidebarMenuMeta = {},
36
43
  }: MainSidebarProps) {
37
44
  const isMinimized = mode === 'minimized'
38
45
  const isMobile = useIsMobile()
@@ -66,6 +73,40 @@ export function MainSidebar({
66
73
  setHoveredItem(item)
67
74
  }
68
75
 
76
+ const applyTemplateVars = (href: string): string => {
77
+ // Guard: SSR has no window/localStorage
78
+ if (typeof window === "undefined") return href
79
+ try {
80
+ const projRaw = localStorage.getItem("current-business-unit")
81
+ const envRaw = localStorage.getItem("current-environment")
82
+ // Parse carefully: value may be null (not yet set), invalid JSON, or a non-object
83
+ const currentProject = typeof projRaw === "string"
84
+ ? (JSON.parse(projRaw)?.state?.currentProject ?? "")
85
+ : ""
86
+ const currentEnv = typeof envRaw === "string"
87
+ ? (JSON.parse(envRaw)?.state?.currentEnv ?? "")
88
+ : ""
89
+ // Only replace when the value is a non-empty string; if empty, leave the
90
+ // placeholder in the URL so the link is visibly broken rather than silently
91
+ // producing a protocol-relative URL (e.g. "//path") that navigates incorrectly.
92
+ let result = href
93
+ if (currentProject && typeof currentProject === "string") {
94
+ result = result.replace(/\$\{currentProject\}/g, currentProject)
95
+ }
96
+ if (currentEnv && typeof currentEnv === "string") {
97
+ result = result.replace(/\$\{currentEnv\}/g, currentEnv)
98
+ }
99
+ return result
100
+ } catch {
101
+ // localStorage blocked (Safari private mode), JSON parse error, etc. — return unchanged
102
+ return href
103
+ }
104
+ }
105
+
106
+ const safeUrlPathname = (url: string): string => {
107
+ try { return new URL(url).pathname } catch { return "/" }
108
+ }
109
+
69
110
  const handleMenuClick = (item: MainMenuItem) => {
70
111
  const menuId = item.id
71
112
  const hasSubMenu = (sidebarMenus[menuId]?.length ?? 0) > 0
@@ -81,26 +122,47 @@ export function MainSidebar({
81
122
  if (!isMinimized && onSecondarySidebarOpen) onSecondarySidebarOpen()
82
123
 
83
124
  if (item.href) {
84
- const path = item.href.startsWith('http://') || item.href.startsWith('https://')
85
- ? new URL(item.href).pathname
86
- : item.href
87
- window.location.href = path
125
+ try {
126
+ const resolved = applyTemplateVars(item.href)
127
+ const path = resolved.startsWith('http://') || resolved.startsWith('https://')
128
+ ? safeUrlPathname(resolved)
129
+ : resolved
130
+ window.location.href = path
131
+ } catch {
132
+ // navigation error — don't crash the sidebar
133
+ }
88
134
  }
89
135
 
90
136
  if (!isMinimized) onToggle()
91
137
  }
92
138
 
93
139
  const buildSubItemHref = (menuId: string, href: string): string => {
94
- if (href.startsWith('http://') || href.startsWith('https://')) {
95
- return new URL(href).pathname
140
+ try {
141
+ const resolved = applyTemplateVars(href)
142
+ if (resolved.startsWith('http://') || resolved.startsWith('https://')) {
143
+ return safeUrlPathname(resolved)
144
+ }
145
+ const cleanHref = resolved.replace(/^\//, '')
146
+ const cleanBase = (main_base_url.startsWith('http://') || main_base_url.startsWith('https://'))
147
+ ? safeUrlPathname(main_base_url).replace(/\/$/, '')
148
+ : (main_base_url ?? "").replace(/\/$/, '')
149
+ return `${cleanBase}/${menuId}/${cleanHref}`
150
+ } catch {
151
+ return "/"
96
152
  }
97
- const cleanHref = href.replace(/^\//, '')
98
- const cleanBase = (main_base_url.startsWith('http://') || main_base_url.startsWith('https://'))
99
- ? new URL(main_base_url).pathname.replace(/\/$/, '')
100
- : main_base_url.replace(/\/$/, '')
101
- return `${cleanBase}/${menuId}/${cleanHref}`
102
153
  }
103
154
 
155
+ // Default flyout content derived from the "main" submenu in the loaded menu data
156
+ const mainMeta = sidebarMenuMeta["main"] ?? {}
157
+ const defaultTitle = mainMeta.header ?? "Camel X Platform"
158
+ const defaultDesc = mainMeta.description ?? undefined
159
+ const defaultLinks: FlyoutDefaultLink[] = (sidebarMenus["main"] ?? []).map(sub => ({
160
+ label: sub.name,
161
+ href: sub.href,
162
+ description: sub.description,
163
+ icon: sub.icon,
164
+ }))
165
+
104
166
  const handleSubMenuClick = (e: React.MouseEvent, menuId: string, href: string) => {
105
167
  e.preventDefault()
106
168
  window.location.href = buildSubItemHref(menuId, href)
@@ -157,9 +219,14 @@ export function MainSidebar({
157
219
  const isFlyActive = hoveredItem?.id === item.id
158
220
 
159
221
  const itemHref = !hasSubMenu && item.href
160
- ? (item.href.startsWith('http://') || item.href.startsWith('https://')
161
- ? new URL(item.href).pathname
162
- : item.href)
222
+ ? (() => {
223
+ try {
224
+ const resolved = applyTemplateVars(item.href)
225
+ return resolved.startsWith('http://') || resolved.startsWith('https://')
226
+ ? safeUrlPathname(resolved)
227
+ : resolved
228
+ } catch { return item.href }
229
+ })()
163
230
  : undefined
164
231
 
165
232
  const itemCls = cn(
@@ -231,15 +298,23 @@ export function MainSidebar({
231
298
  </div>
232
299
 
233
300
  {/* ── Flyout — desktop only, flex child to the right of nav ─────────── */}
234
- {!isMobile && hoveredItem && (
301
+ {/* Shown when the sidebar is open (default state) or an item is hovered (sub-menu state) */}
302
+ {!isMobile && (hoveredItem || (isOpen && !isMinimized)) && (
235
303
  <MainSidebarFlyout
236
304
  item={hoveredItem}
237
- subItems={(sidebarMenus[hoveredItem.id] ?? []).map(sub => ({
238
- ...sub,
239
- href: buildSubItemHref(hoveredItem.id, sub.href),
240
- }))}
305
+ subItems={hoveredItem
306
+ ? (sidebarMenus[hoveredItem.id] ?? []).map(sub => ({
307
+ ...sub,
308
+ href: buildSubItemHref(hoveredItem.id, sub.href),
309
+ }))
310
+ : []
311
+ }
241
312
  onMouseEnter={cancelCloseTimer}
242
313
  onMouseLeave={startCloseTimer}
314
+ main_base_url={main_base_url}
315
+ defaultTitle={defaultTitle}
316
+ defaultDescription={defaultDesc}
317
+ defaultLinks={defaultLinks}
243
318
  />
244
319
  )}
245
320
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orsetra/shared-ui",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",