@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.
- package/components/layout/sidebar/data.tsx +2 -0
- package/components/layout/sidebar/main-sidebar-flyout.tsx +7 -2
- package/components/layout/sidebar/main-sidebar.tsx +73 -79
- package/components/layout/sidebar/sidebar.tsx +23 -21
- package/components/layout/sidebar/template-vars.ts +73 -0
- package/components/ui/relative-time-range-picker.tsx +37 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
335
|
+
if (isDisabled) {
|
|
336
|
+
e.preventDefault()
|
|
337
|
+
return
|
|
338
|
+
}
|
|
328
339
|
if (item.target === "_blank") {
|
|
329
340
|
e.preventDefault()
|
|
330
|
-
|
|
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 (
|
|
344
|
+
if (resolvedHref.startsWith('http://') || resolvedHref.startsWith('https://')) {
|
|
347
345
|
e.preventDefault()
|
|
348
|
-
window.location.href = new URL(
|
|
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
|
-
|
|
356
|
-
? "
|
|
357
|
-
:
|
|
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={
|
|
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
|
-
{
|
|
98
|
+
{triggerText}
|
|
63
99
|
</Button>
|
|
64
100
|
</PopoverTrigger>
|
|
65
101
|
<PopoverContent align={align} sideOffset={6} className="w-auto rounded-none p-2">
|