@orsetra/shared-ui 1.10.5 → 1.10.7

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.
@@ -55,6 +55,8 @@ export interface SubMenuItem {
55
55
  icon: LucideIcon
56
56
  applied?: boolean
57
57
  target?: string
58
+ /** True when the href still has an unresolved "${...}" placeholder (e.g. no project/environment selected yet) — render as non-navigable. */
59
+ disabled?: boolean
58
60
  }
59
61
 
60
62
  export interface SidebarMenus {
@@ -78,13 +78,18 @@ export function MainSidebarFlyout({
78
78
  return (
79
79
  <a
80
80
  key={sub.id}
81
- href={sub.href}
81
+ href={sub.disabled ? undefined : sub.href}
82
82
  target={sub.target}
83
83
  rel={sub.target === "_blank" ? "noopener noreferrer" : undefined}
84
+ aria-disabled={sub.disabled}
85
+ title={sub.disabled ? "Select a project/environment first" : undefined}
86
+ onClick={(e) => { if (sub.disabled) e.preventDefault() }}
84
87
  className={cn(
85
88
  "flex items-start transition-colors border-l-4",
86
89
  "px-2 lg:px-3 py-2 gap-x-3",
87
- "hover:bg-ui-background border-transparent hover:border-interactive",
90
+ sub.disabled
91
+ ? "opacity-40 cursor-not-allowed border-transparent"
92
+ : "hover:bg-ui-background border-transparent hover:border-interactive",
88
93
  )}
89
94
  >
90
95
  <SubIcon className="h-4 w-4 flex-shrink-0 mt-0.5" style={{ color: "#4589ff" }} />
@@ -11,6 +11,7 @@ import { X, Menu, ChevronDown, ChevronRight } from "lucide-react"
11
11
  import { useIsMobile } from "../../../hooks/use-mobile"
12
12
  import { MainSidebarFlyout, type FlyoutDefaultLink } from "./main-sidebar-flyout"
13
13
  import { useQuickAccess } from "../../../hooks/use-quick-access"
14
+ import { resolveTemplateVars } from "./template-vars"
14
15
 
15
16
  export type { FlyoutDefaultLink }
16
17
 
@@ -75,36 +76,6 @@ export function MainSidebar({
75
76
  setHoveredItem(item)
76
77
  }
77
78
 
78
- const applyTemplateVars = (href: string): string => {
79
- // Guard: SSR has no window/localStorage
80
- if (typeof window === "undefined") return href
81
- try {
82
- const projRaw = localStorage.getItem("current-business-unit")
83
- const envRaw = localStorage.getItem("current-environment")
84
- // Parse carefully: value may be null (not yet set), invalid JSON, or a non-object
85
- const currentProject = typeof projRaw === "string"
86
- ? (JSON.parse(projRaw)?.state?.currentProject ?? "")
87
- : ""
88
- const currentEnv = typeof envRaw === "string"
89
- ? (JSON.parse(envRaw)?.state?.currentEnv ?? "")
90
- : ""
91
- // Only replace when the value is a non-empty string; if empty, leave the
92
- // placeholder in the URL so the link is visibly broken rather than silently
93
- // producing a protocol-relative URL (e.g. "//path") that navigates incorrectly.
94
- let result = href
95
- if (currentProject && typeof currentProject === "string") {
96
- result = result.replace(/\$\{currentProject\}/g, currentProject)
97
- }
98
- if (currentEnv && typeof currentEnv === "string") {
99
- result = result.replace(/\$\{currentEnv\}/g, currentEnv)
100
- }
101
- return result
102
- } catch {
103
- // localStorage blocked (Safari private mode), JSON parse error, etc. — return unchanged
104
- return href
105
- }
106
- }
107
-
108
79
  const safeUrlPathname = (url: string): string => {
109
80
  try { return new URL(url).pathname } catch { return "/" }
110
81
  }
@@ -124,34 +95,40 @@ export function MainSidebar({
124
95
  if (!isMinimized && onSecondarySidebarOpen) onSecondarySidebarOpen()
125
96
 
126
97
  if (item.href) {
127
- try {
128
- const resolved = applyTemplateVars(item.href)
129
- const path = resolved.startsWith('http://') || resolved.startsWith('https://')
130
- ? safeUrlPathname(resolved)
131
- : resolved
132
- window.location.href = path
133
- } catch {
134
- // navigation error — don't crash the sidebar
98
+ const { href: resolved, isResolved } = resolveTemplateVars(item.href)
99
+ if (isResolved) {
100
+ try {
101
+ const path = resolved.startsWith('http://') || resolved.startsWith('https://')
102
+ ? safeUrlPathname(resolved)
103
+ : resolved
104
+ window.location.href = path
105
+ } catch {
106
+ // navigation error — don't crash the sidebar
107
+ }
135
108
  }
109
+ // Unresolved (missing project/environment context): do nothing rather than navigate to a
110
+ // URL with a literal "${...}" in it.
136
111
  }
137
112
 
138
113
  if (!isMinimized) onToggle()
139
114
  }
140
115
 
141
- const buildSubItemHref = (menuId: string, href: string): string => {
142
- try {
143
- const resolved = applyTemplateVars(href)
144
- if (resolved.startsWith('http://') || resolved.startsWith('https://')) {
145
- return resolved // keep full URL for external links
146
- }
147
- const cleanHref = resolved.replace(/^\//, '')
148
- const cleanBase = (main_base_url.startsWith('http://') || main_base_url.startsWith('https://'))
149
- ? safeUrlPathname(main_base_url).replace(/\/$/, '')
150
- : (main_base_url ?? "").replace(/\/$/, '')
151
- return `${cleanBase}/${menuId}/${cleanHref}`
152
- } catch {
153
- return "/"
116
+ interface ResolvedSubItemHref {
117
+ href: string
118
+ disabled: boolean
119
+ }
120
+
121
+ const buildSubItemHref = (menuId: string, href: string): ResolvedSubItemHref => {
122
+ const { href: resolved, isResolved } = resolveTemplateVars(href)
123
+ if (!isResolved) return { href: "#", disabled: true }
124
+ if (resolved.startsWith('http://') || resolved.startsWith('https://')) {
125
+ return { href: resolved, disabled: false } // keep full URL for external links
154
126
  }
127
+ const cleanHref = resolved.replace(/^\//, '')
128
+ const cleanBase = (main_base_url.startsWith('http://') || main_base_url.startsWith('https://'))
129
+ ? safeUrlPathname(main_base_url).replace(/\/$/, '')
130
+ : (main_base_url ?? "").replace(/\/$/, '')
131
+ return { href: `${cleanBase}/${menuId}/${cleanHref}`, disabled: false }
155
132
  }
156
133
 
157
134
  // Default flyout content derived from the menu metadata
@@ -167,7 +144,8 @@ export function MainSidebar({
167
144
 
168
145
  const handleSubMenuClick = (e: React.MouseEvent, menuId: string, href: string, target?: string) => {
169
146
  e.preventDefault()
170
- const resolved = buildSubItemHref(menuId, href)
147
+ const { href: resolved, disabled } = buildSubItemHref(menuId, href)
148
+ if (disabled) return
171
149
  if (target === "_blank") {
172
150
  window.open(resolved, "_blank", "noopener,noreferrer")
173
151
  } else {
@@ -228,15 +206,12 @@ export function MainSidebar({
228
206
  const isExpanded = expandedMenu === item.id
229
207
  const isFlyActive = hoveredItem?.id === item.id
230
208
 
231
- const itemHref = !hasSubMenu && item.href
232
- ? (() => {
233
- try {
234
- const resolved = applyTemplateVars(item.href)
235
- return resolved.startsWith('http://') || resolved.startsWith('https://')
236
- ? safeUrlPathname(resolved)
237
- : resolved
238
- } catch { return item.href }
239
- })()
209
+ const itemTemplate = !hasSubMenu && item.href ? resolveTemplateVars(item.href) : null
210
+ const itemDisabled = !!itemTemplate && !itemTemplate.isResolved
211
+ const itemHref = itemTemplate && itemTemplate.isResolved
212
+ ? (itemTemplate.href.startsWith('http://') || itemTemplate.href.startsWith('https://')
213
+ ? safeUrlPathname(itemTemplate.href)
214
+ : itemTemplate.href)
240
215
  : undefined
241
216
 
242
217
  const itemCls = cn(
@@ -277,7 +252,13 @@ export function MainSidebar({
277
252
  {itemContent}
278
253
  </a>
279
254
  ) : (
280
- <button onClick={() => handleMenuClick(item)} className={itemCls} title={isMinimized ? item.label : undefined}>
255
+ <button
256
+ onClick={itemDisabled ? undefined : () => handleMenuClick(item)}
257
+ disabled={itemDisabled}
258
+ aria-disabled={itemDisabled}
259
+ className={cn(itemCls, itemDisabled && "opacity-40 cursor-not-allowed pointer-events-none")}
260
+ title={itemDisabled ? "Select a project/environment first" : (isMinimized ? item.label : undefined)}
261
+ >
281
262
  {itemContent}
282
263
  </button>
283
264
  )}
@@ -288,19 +269,32 @@ export function MainSidebar({
288
269
  <p className="px-2 pt-1 pb-0.5 text-[10px] font-semibold text-text-secondary uppercase tracking-widest">
289
270
  {item.label}
290
271
  </p>
291
- {sidebarMenus[item.id].map((subItem) => (
292
- <a
293
- key={subItem.id}
294
- href={buildSubItemHref(item.id, subItem.href)}
295
- target={subItem.target}
296
- rel={subItem.target === "_blank" ? "noopener noreferrer" : undefined}
297
- onClick={(e) => handleSubMenuClick(e, item.id, subItem.href, subItem.target)}
298
- className="flex items-center gap-2 lg:gap-3 px-2 lg:px-3 py-1.5 lg:py-2 text-sm text-text-secondary hover:bg-ui-background hover:text-text-primary transition-colors no-underline border-l-2 border-ui-border hover:border-interactive"
299
- >
300
- <subItem.icon className="h-4 w-4 flex-shrink-0 text-text-secondary" />
301
- {subItem.name}
302
- </a>
303
- ))}
272
+ {sidebarMenus[item.id].map((subItem) => {
273
+ const { href: subHref, disabled: subDisabled } = buildSubItemHref(item.id, subItem.href)
274
+ return (
275
+ <a
276
+ key={subItem.id}
277
+ href={subDisabled ? undefined : subHref}
278
+ target={subItem.target}
279
+ rel={subItem.target === "_blank" ? "noopener noreferrer" : undefined}
280
+ aria-disabled={subDisabled}
281
+ title={subDisabled ? "Select a project/environment first" : undefined}
282
+ onClick={(e) => {
283
+ if (subDisabled) { e.preventDefault(); return }
284
+ handleSubMenuClick(e, item.id, subItem.href, subItem.target)
285
+ }}
286
+ className={cn(
287
+ "flex items-center gap-2 lg:gap-3 px-2 lg:px-3 py-1.5 lg:py-2 text-sm transition-colors no-underline border-l-2",
288
+ subDisabled
289
+ ? "text-text-secondary/40 border-ui-border cursor-not-allowed"
290
+ : "text-text-secondary hover:bg-ui-background hover:text-text-primary border-ui-border hover:border-interactive",
291
+ )}
292
+ >
293
+ <subItem.icon className="h-4 w-4 flex-shrink-0 text-text-secondary" />
294
+ {subItem.name}
295
+ </a>
296
+ )
297
+ })}
304
298
  </div>
305
299
  )}
306
300
  </div>
@@ -315,10 +309,10 @@ export function MainSidebar({
315
309
  <MainSidebarFlyout
316
310
  item={hoveredItem}
317
311
  subItems={hoveredItem
318
- ? (sidebarMenus[hoveredItem.id] ?? []).map(sub => ({
319
- ...sub,
320
- href: buildSubItemHref(hoveredItem.id, sub.href),
321
- }))
312
+ ? (sidebarMenus[hoveredItem.id] ?? []).map(sub => {
313
+ const { href, disabled } = buildSubItemHref(hoveredItem.id, sub.href)
314
+ return { ...sub, href, disabled }
315
+ })
322
316
  : []
323
317
  }
324
318
  onMouseEnter={cancelCloseTimer}
@@ -20,6 +20,7 @@ import {
20
20
  } from "../../ui/tooltip"
21
21
  import { type SidebarMenus, type SubMenuItem, type MainMenuItem } from "./data"
22
22
  import { Skeleton } from "../skeleton"
23
+ import { resolveTemplateVars } from "./template-vars"
23
24
 
24
25
  // ── Dynamic secondary navigation ──────────────────────────────────────────────
25
26
 
@@ -323,38 +324,37 @@ function Sidebar({ currentMenu, sidebarMenus = {}, main_base_url = "", sectionLa
323
324
  ? item.id === activeItemId
324
325
  : (pathname === itemPath || pathname.startsWith(`${itemPath}/`))
325
326
 
327
+ // Resolved once so the anchor's `href` attribute itself is already correct — not just
328
+ // the JS click handler — so middle-click / right-click "open in new tab" (which bypass
329
+ // onClick entirely) don't navigate to a literal "${envAlias}"-style broken URL.
330
+ const templateResolution = item.target === "_blank" ? resolveTemplateVars(item.href) : null
331
+ const isDisabled = !!templateResolution && !templateResolution.isResolved
332
+ const resolvedHref = templateResolution ? templateResolution.href : item.href
333
+
326
334
  const handleClick = (e: React.MouseEvent) => {
327
- const href = item.href
335
+ if (isDisabled) {
336
+ e.preventDefault()
337
+ return
338
+ }
328
339
  if (item.target === "_blank") {
329
340
  e.preventDefault()
330
- try {
331
- let resolved = href
332
- if (typeof window !== "undefined") {
333
- const projRaw = localStorage.getItem("current-business-unit")
334
- const cp = typeof projRaw === "string" ? (JSON.parse(projRaw)?.state?.currentProject ?? "") : ""
335
- if (cp) resolved = resolved.replace(/\$\{currentProject\}/g, cp)
336
- const envRaw = localStorage.getItem("current-environment")
337
- const ce = typeof envRaw === "string" ? (JSON.parse(envRaw)?.state?.currentEnv ?? "") : ""
338
- if (ce) resolved = resolved.replace(/\$\{currentEnv\}/g, ce)
339
- }
340
- window.open(resolved, "_blank", "noopener,noreferrer")
341
- } catch {
342
- window.open(href, "_blank", "noopener,noreferrer")
343
- }
341
+ window.open(resolvedHref, "_blank", "noopener,noreferrer")
344
342
  return
345
343
  }
346
- if (href.startsWith('http://') || href.startsWith('https://')) {
344
+ if (resolvedHref.startsWith('http://') || resolvedHref.startsWith('https://')) {
347
345
  e.preventDefault()
348
- window.location.href = new URL(href).pathname
346
+ window.location.href = new URL(resolvedHref).pathname
349
347
  }
350
348
  }
351
349
 
352
350
  const linkCls = cn(
353
351
  "flex items-center transition-colors border-l-4",
354
352
  isCollapsed ? "justify-center p-2" : "px-2 lg:px-3 py-1.5 lg:py-2 gap-x-3 text-sm",
355
- isActive
356
- ? "bg-interactive/10 text-interactive font-medium border-interactive"
357
- : "text-text-secondary hover:bg-ui-background hover:text-text-primary border-transparent"
353
+ isDisabled
354
+ ? "opacity-40 cursor-not-allowed text-text-secondary border-transparent"
355
+ : isActive
356
+ ? "bg-interactive/10 text-interactive font-medium border-interactive"
357
+ : "text-text-secondary hover:bg-ui-background hover:text-text-primary border-transparent"
358
358
  )
359
359
 
360
360
  const linkContent = (
@@ -372,8 +372,10 @@ function Sidebar({ currentMenu, sidebarMenus = {}, main_base_url = "", sectionLa
372
372
  const linkEl = item.target === "_blank" ? (
373
373
  <a
374
374
  key={item.id}
375
- href={item.href}
375
+ href={isDisabled ? undefined : resolvedHref}
376
376
  onClick={handleClick}
377
+ aria-disabled={isDisabled}
378
+ title={isDisabled ? "Select a project/environment first" : undefined}
377
379
  className={linkCls}
378
380
  >
379
381
  {linkContent}
@@ -0,0 +1,73 @@
1
+ // Resolves "${currentProject}"/"${currentEnv}"/"${envAlias}" placeholders in menu hrefs (see
2
+ // apps/platform/app/data/menu.ts) against whichever app's Zustand-persisted project/environment
3
+ // store happens to be on localStorage for the current origin.
4
+ //
5
+ // There is no single shared store: every app under this platform owns its own
6
+ // `store/project-store.ts` / `store/app-store.ts` Zustand instance, and each persists under its
7
+ // own localStorage key (`persist({ name: ... })`). Since this sidebar is rendered inside every
8
+ // app, it has to know all of those key names to find the right value regardless of which app's
9
+ // page it's currently showing. Keep this list in sync with each app's own store file.
10
+ const PROJECT_STORAGE_KEYS = [
11
+ "current-business-unit",
12
+ "network-manager-project",
13
+ ]
14
+
15
+ const ENVIRONMENT_STORAGE_KEYS = [
16
+ "current-environment",
17
+ "api-manager-current-environment",
18
+ "auth-manager-current-environment",
19
+ "network-manager-environment",
20
+ "monitoring-app-state",
21
+ ]
22
+
23
+ const PLACEHOLDER_PATTERN = /\$\{[a-zA-Z_]+\}/
24
+
25
+ /** Reads `state[stateKey]` from the first of `keys` that holds a non-empty string value. */
26
+ function readFirstMatch(keys: string[], stateKey: string): string {
27
+ for (const key of keys) {
28
+ try {
29
+ const raw = localStorage.getItem(key)
30
+ if (!raw) continue
31
+ const value = JSON.parse(raw)?.state?.[stateKey]
32
+ if (typeof value === "string" && value) return value
33
+ } catch {
34
+ // Invalid JSON / blocked storage (Safari private mode) — try the next key.
35
+ }
36
+ }
37
+ return ""
38
+ }
39
+
40
+ export interface TemplateResolution {
41
+ /** `href` with every placeholder that had a value substituted in. */
42
+ href: string
43
+ /** False when one or more "${...}" placeholders are still present — required context is missing. */
44
+ isResolved: boolean
45
+ }
46
+
47
+ /**
48
+ * Replaces "${currentProject}", "${currentEnv}" and "${envAlias}" in `href` with values read
49
+ * from localStorage. A placeholder with no available value is left in place — callers must check
50
+ * `isResolved` and disable the link rather than navigate to a URL with a literal "${...}" in it
51
+ * or silently substitute an empty string (which can turn an absolute URL into a broken
52
+ * protocol-relative one, e.g. "//path").
53
+ */
54
+ export function resolveTemplateVars(href: string): TemplateResolution {
55
+ // Guard: SSR has no window/localStorage — treat as unresolved so server-rendered markup never
56
+ // claims a link is usable before the client can actually check.
57
+ if (typeof window === "undefined") {
58
+ return { href, isResolved: !PLACEHOLDER_PATTERN.test(href) }
59
+ }
60
+
61
+ let result = href
62
+
63
+ const currentProject = readFirstMatch(PROJECT_STORAGE_KEYS, "currentProject")
64
+ if (currentProject) result = result.replace(/\$\{currentProject\}/g, currentProject)
65
+
66
+ const currentEnv = readFirstMatch(ENVIRONMENT_STORAGE_KEYS, "currentEnv")
67
+ if (currentEnv) result = result.replace(/\$\{currentEnv\}/g, currentEnv)
68
+
69
+ const envAlias = readFirstMatch(ENVIRONMENT_STORAGE_KEYS, "currentEnvAlias") || currentEnv
70
+ if (envAlias) result = result.replace(/\$\{envAlias\}/g, envAlias)
71
+
72
+ return { href: result, isResolved: !PLACEHOLDER_PATTERN.test(result) }
73
+ }
@@ -26,11 +26,30 @@ export interface RelativeTimeRangePickerProps {
26
26
  triggerLabel?: string
27
27
  }
28
28
 
29
+ const DAY_SECONDS = 24 * 60 * 60
30
+
31
+ // Refreshed periodically (see the interval effect below) so the label doesn't drift far
32
+ // from reality if the picker is left open for a while without the selection changing.
33
+ const REFRESH_INTERVAL_MS = 30_000
34
+
35
+ const timeFormatter = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hour12: false })
36
+ const dayFormatter = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" })
37
+
38
+ /** "Since 14:32" for hour/minute-scale options, "Since Aug 18, 14:32" once the option spans a day or more. */
39
+ function formatSince(sinceMs: number, isDayScale: boolean): string {
40
+ const date = new Date(sinceMs)
41
+ const time = timeFormatter.format(date)
42
+ return isDayScale ? `Since ${dayFormatter.format(date)}, ${time}` : `Since ${time}`
43
+ }
44
+
29
45
  /**
30
46
  * Flyout picker for a small set of relative time ranges (20m, 1h, 3h, ...), styled as a gallery
31
47
  * of choices rather than a dropdown list — the current selection is highlighted with both a
32
48
  * filled background and a check mark (not color alone), mirroring how Calendar marks the
33
49
  * selected day and Combobox marks the selected item in this same component set.
50
+ *
51
+ * The trigger shows the resolved absolute start of the window ("Since ...") rather than the
52
+ * bare option label, so the picker doubles as a readout of what's actually being queried.
34
53
  */
35
54
  export function RelativeTimeRangePicker({
36
55
  id,
@@ -45,6 +64,23 @@ export function RelativeTimeRangePicker({
45
64
  const [open, setOpen] = React.useState(false)
46
65
  const selected = options.find(o => o.seconds === value)
47
66
 
67
+ const [now, setNow] = React.useState(() => Date.now())
68
+
69
+ // Freshen immediately on selection change and whenever the flyout opens.
70
+ React.useEffect(() => {
71
+ setNow(Date.now())
72
+ }, [value, open])
73
+
74
+ // ...and keep it from going stale while left open/mounted without interaction.
75
+ React.useEffect(() => {
76
+ const interval = setInterval(() => setNow(Date.now()), REFRESH_INTERVAL_MS)
77
+ return () => clearInterval(interval)
78
+ }, [])
79
+
80
+ const triggerText = selected
81
+ ? formatSince(now - selected.seconds * 1000, selected.seconds >= DAY_SECONDS)
82
+ : "Select…"
83
+
48
84
  return (
49
85
  <Popover open={open} onOpenChange={setOpen}>
50
86
  <PopoverTrigger asChild>
@@ -59,7 +95,7 @@ export function RelativeTimeRangePicker({
59
95
  leftIcon={<Clock className="h-3.5 w-3.5" />}
60
96
  rightIcon={<ChevronDown className="h-3.5 w-3.5 opacity-60" />}
61
97
  >
62
- {selected?.label ?? "Select…"}
98
+ {triggerText}
63
99
  </Button>
64
100
  </PopoverTrigger>
65
101
  <PopoverContent align={align} sideOffset={6} className="w-auto rounded-none p-2">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orsetra/shared-ui",
3
- "version": "1.10.5",
3
+ "version": "1.10.7",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",