@orsetra/shared-ui 1.10.16 → 1.10.18

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.
@@ -49,6 +49,7 @@ export { Popover, PopoverTrigger, PopoverContent } from './popover'
49
49
  export { Progress } from './progress'
50
50
  export { RadioGroup, RadioGroupItem } from './radio-group'
51
51
  export { RelativeTimeRangePicker, type RelativeTimeRangeOption, type RelativeTimeRangePickerProps } from './relative-time-range-picker'
52
+ export { TimeRangePicker, resolveTimeRange, type TimeRangeValue, type TimeRangePickerProps } from './time-range-picker'
52
53
  export { ResizablePanelGroup, ResizablePanel, ResizableHandle } from './resizable'
53
54
  export { ScrollArea, ScrollBar } from './scroll-area'
54
55
  export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator } from './select'
@@ -0,0 +1,251 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Check, ChevronDown, Clock } from "lucide-react"
5
+ import type { DateRange } from "react-day-picker"
6
+ import { cn } from "../../lib/utils"
7
+ import { Button } from "./button"
8
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover"
9
+ import { Calendar } from "./calendar"
10
+ import { Input } from "./input"
11
+ import type { RelativeTimeRangeOption } from "./relative-time-range-picker"
12
+
13
+ export type { RelativeTimeRangeOption }
14
+
15
+ /**
16
+ * A relative window ("last N seconds", resolved against "now" on every fetch/refresh) or a
17
+ * fixed absolute one (a specific start/end, in epoch seconds — doesn't move on refresh).
18
+ */
19
+ export type TimeRangeValue =
20
+ | { mode: "relative"; seconds: number }
21
+ | { mode: "absolute"; from: number; to: number }
22
+
23
+ /** Turns either shape into the `{start, end}` window callers actually query with. */
24
+ export function resolveTimeRange(value: TimeRangeValue): { start: number; end: number } {
25
+ if (value.mode === "absolute") {
26
+ return { start: Math.min(value.from, value.to), end: Math.max(value.from, value.to) }
27
+ }
28
+ const end = Math.floor(Date.now() / 1000)
29
+ return { start: end - value.seconds, end }
30
+ }
31
+
32
+ export interface TimeRangePickerProps {
33
+ id?: string
34
+ value: TimeRangeValue
35
+ onChange: (value: TimeRangeValue) => void
36
+ options: RelativeTimeRangeOption[]
37
+ disabled?: boolean
38
+ className?: string
39
+ /** Popover alignment relative to the trigger. Defaults to "end" (picker anchored right — the
40
+ * common placement next to a panel's close button). */
41
+ align?: "start" | "center" | "end"
42
+ /** Accessible name and popover heading. Defaults to "Time range". */
43
+ triggerLabel?: string
44
+ }
45
+
46
+ const DAY_SECONDS = 24 * 60 * 60
47
+
48
+ // Refreshed periodically (see the interval effect below) so a relative selection's label
49
+ // doesn't drift far from reality if the picker is left open for a while without changing.
50
+ const REFRESH_INTERVAL_MS = 30_000
51
+
52
+ const timeFormatter = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hour12: false })
53
+ const dayFormatter = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" })
54
+
55
+ function formatSince(sinceMs: number, isDayScale: boolean): string {
56
+ const date = new Date(sinceMs)
57
+ const time = timeFormatter.format(date)
58
+ return isDayScale ? `Since ${dayFormatter.format(date)}, ${time}` : `Since ${time}`
59
+ }
60
+
61
+ function formatAbsolute(fromSec: number, toSec: number): string {
62
+ const from = new Date(fromSec * 1000)
63
+ const to = new Date(toSec * 1000)
64
+ const sameDay = from.toDateString() === to.toDateString()
65
+ const fromStr = `${dayFormatter.format(from)}, ${timeFormatter.format(from)}`
66
+ const toStr = sameDay ? timeFormatter.format(to) : `${dayFormatter.format(to)}, ${timeFormatter.format(to)}`
67
+ return `${fromStr} → ${toStr}`
68
+ }
69
+
70
+ function toLocalDateInputParts(sec: number): { date: Date; time: string } {
71
+ const d = new Date(sec * 1000)
72
+ const hh = String(d.getHours()).padStart(2, "0")
73
+ const mm = String(d.getMinutes()).padStart(2, "0")
74
+ return { date: d, time: `${hh}:${mm}` }
75
+ }
76
+
77
+ function combineDateAndTime(date: Date, time: string): number {
78
+ const [hh, mm] = time.split(":").map(n => parseInt(n, 10) || 0)
79
+ const d = new Date(date)
80
+ d.setHours(hh, mm, 0, 0)
81
+ return Math.floor(d.getTime() / 1000)
82
+ }
83
+
84
+ /**
85
+ * Flyout time-range picker with two tabs — Relative (a gallery of fixed windows, "20m"/"1h"/...)
86
+ * and Absolute (a date-range calendar plus start/end time-of-day) — for the cases where a
87
+ * relative-only picker (`RelativeTimeRangePicker`) isn't enough, e.g. inspecting a specific past
88
+ * incident window rather than "the last N minutes".
89
+ */
90
+ export function TimeRangePicker({
91
+ id,
92
+ value,
93
+ onChange,
94
+ options,
95
+ disabled = false,
96
+ className,
97
+ align = "end",
98
+ triggerLabel = "Time range",
99
+ }: TimeRangePickerProps) {
100
+ const [open, setOpen] = React.useState(false)
101
+ const [tab, setTab] = React.useState<"relative" | "absolute">(value.mode)
102
+
103
+ // Draft absolute selection — only committed to `onChange` on "Apply", so picking a start date
104
+ // doesn't fire a query before the end date/time are set too.
105
+ const [draftRange, setDraftRange] = React.useState<DateRange | undefined>(() =>
106
+ value.mode === "absolute"
107
+ ? { from: toLocalDateInputParts(value.from).date, to: toLocalDateInputParts(value.to).date }
108
+ : undefined
109
+ )
110
+ const [draftStartTime, setDraftStartTime] = React.useState(() =>
111
+ value.mode === "absolute" ? toLocalDateInputParts(value.from).time : "00:00"
112
+ )
113
+ const [draftEndTime, setDraftEndTime] = React.useState(() =>
114
+ value.mode === "absolute" ? toLocalDateInputParts(value.to).time : "23:59"
115
+ )
116
+
117
+ // Re-seed the draft from the live value whenever the popover opens, so reopening it doesn't
118
+ // show a stale in-progress edit from last time.
119
+ React.useEffect(() => {
120
+ if (!open) return
121
+ setTab(value.mode)
122
+ if (value.mode === "absolute") {
123
+ const from = toLocalDateInputParts(value.from)
124
+ const to = toLocalDateInputParts(value.to)
125
+ setDraftRange({ from: from.date, to: to.date })
126
+ setDraftStartTime(from.time)
127
+ setDraftEndTime(to.time)
128
+ }
129
+ // eslint-disable-next-line react-hooks/exhaustive-deps
130
+ }, [open])
131
+
132
+ const relativeSelected = value.mode === "relative" ? options.find(o => o.seconds === value.seconds) : undefined
133
+
134
+ const [now, setNow] = React.useState(() => Date.now())
135
+ React.useEffect(() => { setNow(Date.now()) }, [value, open])
136
+ React.useEffect(() => {
137
+ const interval = setInterval(() => setNow(Date.now()), REFRESH_INTERVAL_MS)
138
+ return () => clearInterval(interval)
139
+ }, [])
140
+
141
+ const triggerText = value.mode === "relative"
142
+ ? (relativeSelected ? formatSince(now - relativeSelected.seconds * 1000, relativeSelected.seconds >= DAY_SECONDS) : "Select…")
143
+ : formatAbsolute(value.from, value.to)
144
+
145
+ const applyAbsolute = () => {
146
+ if (!draftRange?.from) return
147
+ const from = combineDateAndTime(draftRange.from, draftStartTime)
148
+ const to = combineDateAndTime(draftRange.to ?? draftRange.from, draftEndTime)
149
+ onChange({ mode: "absolute", from, to })
150
+ setOpen(false)
151
+ }
152
+
153
+ return (
154
+ <Popover open={open} onOpenChange={setOpen}>
155
+ <PopoverTrigger asChild>
156
+ <Button
157
+ id={id}
158
+ type="button"
159
+ variant="secondary"
160
+ size="sm"
161
+ disabled={disabled}
162
+ aria-label={triggerLabel}
163
+ className={cn("rounded-none", className)}
164
+ leftIcon={<Clock className="h-3.5 w-3.5" />}
165
+ rightIcon={<ChevronDown className="h-3.5 w-3.5 opacity-60" />}
166
+ >
167
+ {triggerText}
168
+ </Button>
169
+ </PopoverTrigger>
170
+ <PopoverContent align={align} sideOffset={6} className="w-auto rounded-none p-0">
171
+ <div className="flex border-b border-ibm-gray-20">
172
+ {(["relative", "absolute"] as const).map(t => (
173
+ <button
174
+ key={t}
175
+ type="button"
176
+ onClick={() => setTab(t)}
177
+ className={cn(
178
+ "flex-1 h-9 px-4 text-sm font-medium border-b-2 -mb-px transition-colors capitalize",
179
+ tab === t
180
+ ? "border-ibm-blue-60 text-ibm-blue-60 bg-ibm-blue-10/60"
181
+ : "border-transparent text-ibm-gray-60 hover:text-ibm-blue-60 hover:bg-ibm-blue-10/40"
182
+ )}
183
+ >
184
+ {t}
185
+ </button>
186
+ ))}
187
+ </div>
188
+
189
+ {tab === "relative" ? (
190
+ <div className="p-2">
191
+ <p className="px-1 pb-2 text-xs font-medium text-ibm-gray-50">{triggerLabel}</p>
192
+ <div role="listbox" aria-label={triggerLabel} className="grid grid-cols-3 gap-1.5">
193
+ {options.map(opt => {
194
+ const isSelected = value.mode === "relative" && opt.seconds === value.seconds
195
+ return (
196
+ <button
197
+ key={opt.seconds}
198
+ type="button"
199
+ role="option"
200
+ aria-selected={isSelected}
201
+ onClick={() => { onChange({ mode: "relative", seconds: opt.seconds }); setOpen(false) }}
202
+ className={cn(
203
+ "flex items-center justify-center gap-1 h-9 px-2 text-sm font-medium border transition-colors whitespace-nowrap",
204
+ isSelected
205
+ ? "bg-ibm-blue-60 text-white border-ibm-blue-60"
206
+ : "bg-white text-ibm-gray-100 border-ibm-gray-20 hover:bg-ibm-blue-10 hover:border-ibm-blue-40"
207
+ )}
208
+ >
209
+ {isSelected && <Check className="h-3 w-3 flex-shrink-0" />}
210
+ {opt.label}
211
+ </button>
212
+ )
213
+ })}
214
+ </div>
215
+ </div>
216
+ ) : (
217
+ <div className="p-3 space-y-3">
218
+ <Calendar
219
+ mode="range"
220
+ selected={draftRange}
221
+ onSelect={setDraftRange}
222
+ numberOfMonths={1}
223
+ className="mx-auto"
224
+ />
225
+ <div className="grid grid-cols-2 gap-3">
226
+ <div>
227
+ <label className="block text-xs font-medium text-ibm-gray-50 mb-1">Start time</label>
228
+ <Input type="time" value={draftStartTime} onChange={e => setDraftStartTime(e.target.value)} />
229
+ </div>
230
+ <div>
231
+ <label className="block text-xs font-medium text-ibm-gray-50 mb-1">End time</label>
232
+ <Input type="time" value={draftEndTime} onChange={e => setDraftEndTime(e.target.value)} />
233
+ </div>
234
+ </div>
235
+ <div className="flex justify-end pt-1">
236
+ <Button
237
+ type="button"
238
+ variant="primary"
239
+ className="rounded-none"
240
+ disabled={!draftRange?.from}
241
+ onClick={applyAbsolute}
242
+ >
243
+ Apply
244
+ </Button>
245
+ </div>
246
+ </div>
247
+ )}
248
+ </PopoverContent>
249
+ </Popover>
250
+ )
251
+ }
@@ -1,5 +1,11 @@
1
1
  import { Interceptors } from "./http-client";
2
2
 
3
+ // The Zitadel authority itself — a 401 straight from the identity provider (as opposed to our
4
+ // own API backend) means the session is truly invalid there; no retry or token refresh will
5
+ // help, only a full re-authentication. Each app already bakes this in via NEXT_PUBLIC_ZITADEL_AUTHORITY
6
+ // (this file ships as source into every app's own Next.js build, so the env var is inlined
7
+ // per-app exactly as it is in the services that call this authority directly).
8
+ const ZITADEL_AUTHORITY = process.env.NEXT_PUBLIC_ZITADEL_AUTHORITY;
3
9
 
4
10
  export const interceptors: Interceptors = {
5
11
  onResponse: (response: any) => {
@@ -7,6 +13,10 @@ export const interceptors: Interceptors = {
7
13
  },
8
14
  onResponseError: (error: any) => {
9
15
  if (error.response?.status === 401 && typeof window !== 'undefined') {
16
+ const requestBaseUrl: string | undefined = error.config?.baseURL;
17
+ if (ZITADEL_AUTHORITY && requestBaseUrl === ZITADEL_AUTHORITY) {
18
+ window.dispatchEvent(new CustomEvent('auth-unauthorized'));
19
+ }
10
20
  window.dispatchEvent(new CustomEvent('http-unauthorized'));
11
21
  }
12
22
  return Promise.reject(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orsetra/shared-ui",
3
- "version": "1.10.16",
3
+ "version": "1.10.18",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",