@skyhook-io/k8s-ui 1.8.7 → 1.8.9
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/package.json +3 -3
- package/src/components/applications/ApplicationsList.tsx +5 -2
- package/src/components/applications/ApplicationsView.tsx +37 -30
- package/src/components/checks/ChecksView.tsx +25 -14
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
- package/src/components/gitops/GitOpsTableView.tsx +163 -88
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
- package/src/components/issues/IssuesView.tsx +41 -5
- package/src/components/issues/ResourceIssuesSection.tsx +3 -0
- package/src/components/issues/diagnostic.ts +22 -0
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +21 -0
- package/src/components/issues/types.ts +18 -0
- package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
- package/src/components/namespace-switcher/index.ts +6 -0
- package/src/components/resources/ResourcesView.tsx +20 -81
- package/src/components/scope-pill/ScopePill.tsx +35 -0
- package/src/components/scope-pill/index.ts +2 -0
- package/src/components/timeline/TimelineList.tsx +27 -1
- package/src/components/topology/TopologyControls.tsx +90 -14
- package/src/components/ui/FreshnessControl.tsx +153 -0
- package/src/components/ui/SortableTh.tsx +16 -10
- package/src/components/ui/SummaryTile.tsx +9 -1
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
- package/src/components/workload/WorkloadView.tsx +26 -8
- package/src/filter-state/filter-state-core.test.ts +98 -0
- package/src/filter-state/filter-state-core.ts +138 -0
- package/src/filter-state/filter-state.tsx +127 -0
- package/src/filter-state/index.ts +16 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useKeyboardShortcuts.tsx +23 -2
- package/src/hooks/useRefreshAnimation.ts +15 -2
- package/src/index.ts +12 -0
- package/src/types/core.ts +42 -0
- package/src/types/gitops-insights.ts +4 -0
- package/src/utils/animation.ts +10 -0
- package/src/utils/format-freshness.test.ts +34 -0
- package/src/utils/format.ts +32 -0
- package/src/utils/resource-hierarchy.test.ts +51 -0
- package/src/utils/resource-hierarchy.ts +7 -4
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { useState, useCallback, useEffect, useRef, type ReactNode } from 'react'
|
|
2
|
-
import {
|
|
1
|
+
import { useState, useCallback, useEffect, useLayoutEffect, useReducer, useRef, type ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
DURATION_DRAWER_MORPH,
|
|
4
|
+
EASE_DRAWER_MORPH,
|
|
5
|
+
} from '../../utils/animation'
|
|
3
6
|
import { clsx } from 'clsx'
|
|
4
7
|
import type { SelectedResource } from '../../types'
|
|
5
8
|
import { useDockReservedHeight } from '../dock/DockContext'
|
|
@@ -16,8 +19,13 @@ interface ResourceDetailDrawerProps {
|
|
|
16
19
|
expanded?: boolean
|
|
17
20
|
/** Called when user clicks collapse in expanded mode */
|
|
18
21
|
onCollapse?: () => void
|
|
19
|
-
/** Called when user clicks expand button
|
|
20
|
-
|
|
22
|
+
/** Called when user clicks expand button. `opts.yaml` is true when expanding
|
|
23
|
+
* from the drawer's YAML view, so the host can open the full view on YAML. */
|
|
24
|
+
onExpand?: (resource: SelectedResource, opts?: { yaml?: boolean }) => void
|
|
25
|
+
/** Whether the expanded view can collapse back to a side drawer. False on
|
|
26
|
+
* mobile (no room for a drawer) — the collapse-to-drawer control is hidden
|
|
27
|
+
* there and the host's onCollapse should close instead. Default true. */
|
|
28
|
+
canCollapseToDrawer?: boolean
|
|
21
29
|
/** Navigate to another resource within expanded WorkloadView */
|
|
22
30
|
onNavigateToResource?: (resource: SelectedResource) => void
|
|
23
31
|
/** Height of the host app's top navigation bar in px (default: 49) */
|
|
@@ -28,9 +36,16 @@ interface ResourceDetailDrawerProps {
|
|
|
28
36
|
children: (props: {
|
|
29
37
|
resource: SelectedResource
|
|
30
38
|
expanded: boolean
|
|
39
|
+
/** false on the outgoing layer mid-transition — suspend shortcuts/interaction */
|
|
40
|
+
active: boolean
|
|
31
41
|
initialTab?: 'detail' | 'yaml'
|
|
32
42
|
onClose: () => void
|
|
33
|
-
onExpand?: () => void
|
|
43
|
+
onExpand?: (opts?: { yaml?: boolean }) => void
|
|
44
|
+
/** Signal (hover/press the expand control) that expand is likely — pre-mounts
|
|
45
|
+
* the heavy fullscreen layer invisibly so the click starts the morph instantly. */
|
|
46
|
+
onExpandIntent?: () => void
|
|
47
|
+
/** Intent withdrawn (pointer left the expand control) — discard the pre-mount. */
|
|
48
|
+
onCancelExpandIntent?: () => void
|
|
34
49
|
onBack?: () => void
|
|
35
50
|
onNavigateToResource?: (resource: SelectedResource) => void
|
|
36
51
|
onCollapseToDrawer?: () => void
|
|
@@ -52,18 +67,101 @@ function getDefaultWidth(kind: string): number {
|
|
|
52
67
|
return WIDE_KINDS.has(kind.toLowerCase()) ? WIDE_WIDTH : DEFAULT_WIDTH
|
|
53
68
|
}
|
|
54
69
|
|
|
55
|
-
|
|
70
|
+
function usePrefersReducedMotion(): boolean {
|
|
71
|
+
const [reduced, setReduced] = useState(
|
|
72
|
+
() => typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches,
|
|
73
|
+
)
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const mq = window.matchMedia?.('(prefers-reduced-motion: reduce)')
|
|
76
|
+
if (!mq) return
|
|
77
|
+
const onChange = () => setReduced(mq.matches)
|
|
78
|
+
mq.addEventListener('change', onChange)
|
|
79
|
+
return () => mq.removeEventListener('change', onChange)
|
|
80
|
+
}, [])
|
|
81
|
+
return reduced
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function ResourceDetailDrawer({ resource, onClose, onNavigate, initialTab, isOpen = true, expanded, onCollapse, onExpand, canCollapseToDrawer = true, onNavigateToResource, headerHeight: headerHeightProp, leftOffset = 0, children }: ResourceDetailDrawerProps) {
|
|
56
85
|
const [drawerWidth, setDrawerWidth] = useState(() => getDefaultWidth(resource.kind))
|
|
57
86
|
const [isResizing, setIsResizing] = useState(false)
|
|
58
87
|
const resizeStartX = useRef(0)
|
|
59
88
|
const resizeStartWidth = useRef(getDefaultWidth(resource.kind))
|
|
89
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
90
|
+
const prefersReducedMotion = usePrefersReducedMotion()
|
|
91
|
+
|
|
92
|
+
// Expand/collapse crossfade. During the window we mount BOTH the drawer and
|
|
93
|
+
// full-screen layouts, each pinned to its own width (so neither reflows or
|
|
94
|
+
// squashes), and crossfade opacity while the container width (the frame)
|
|
95
|
+
// animates between them.
|
|
96
|
+
//
|
|
97
|
+
// `settledExpanded` is the last finished state. While it differs from the
|
|
98
|
+
// incoming `expanded` prop we're mid-transition: render both layers, fade
|
|
99
|
+
// `crossfadeArmed` 0→1 once a start frame has painted, then settle.
|
|
100
|
+
const settledExpanded = useRef(!!expanded)
|
|
101
|
+
const [crossfadeArmed, setCrossfadeArmed] = useState(false)
|
|
102
|
+
const [fullWidthPx, setFullWidthPx] = useState<number | null>(null)
|
|
103
|
+
const [, forceSettle] = useReducer((c: number) => c + 1, 0)
|
|
104
|
+
const transitioning = settledExpanded.current !== !!expanded
|
|
60
105
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
106
|
+
// Pre-mount: hovering/pressing the expand control mounts the heavy fullscreen
|
|
107
|
+
// layer invisibly ahead of the click, so the click reuses it (same key) and the
|
|
108
|
+
// morph's first frames aren't competing with the layer's mount cost.
|
|
109
|
+
const [prewarmExpand, setPrewarmExpand] = useState(false)
|
|
110
|
+
|
|
111
|
+
const measureFullWidth = useCallback(() => {
|
|
112
|
+
const el = containerRef.current
|
|
113
|
+
const parent = el?.offsetParent as HTMLElement | null
|
|
114
|
+
const measured = parent ? parent.clientWidth - leftOffset : el?.clientWidth
|
|
115
|
+
if (measured && measured > 0) setFullWidthPx(measured)
|
|
116
|
+
}, [leftOffset])
|
|
117
|
+
|
|
118
|
+
const handleExpandIntent = useCallback(() => {
|
|
119
|
+
if (expanded || transitioning) return
|
|
120
|
+
measureFullWidth()
|
|
121
|
+
setPrewarmExpand(true)
|
|
122
|
+
}, [expanded, transitioning, measureFullWidth])
|
|
123
|
+
|
|
124
|
+
const handleCancelExpandIntent = useCallback(() => {
|
|
125
|
+
if (transitioning || expanded) return
|
|
126
|
+
setPrewarmExpand(false)
|
|
127
|
+
}, [transitioning, expanded])
|
|
128
|
+
|
|
129
|
+
useLayoutEffect(() => {
|
|
130
|
+
if (settledExpanded.current === !!expanded) return
|
|
131
|
+
if (prefersReducedMotion) {
|
|
132
|
+
settledExpanded.current = !!expanded
|
|
133
|
+
setPrewarmExpand(false)
|
|
134
|
+
forceSettle()
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
// Pin the full-screen layer to the measured expanded width so its content
|
|
138
|
+
// lays out at its final width from the first frame (no live reflow).
|
|
139
|
+
measureFullWidth()
|
|
140
|
+
// The prewarm layer (if any) becomes the real transition layer now (reused by key).
|
|
141
|
+
setPrewarmExpand(false)
|
|
142
|
+
// Double-rAF so the incoming layer's mount+paint lands BEFORE the motion starts
|
|
143
|
+
// (else it drops frames in the first third). Prewarm keeps the heavy mount off
|
|
144
|
+
// the click path; these two frames are then cheap (the layer's already there).
|
|
145
|
+
setCrossfadeArmed(false)
|
|
146
|
+
let raf2 = 0
|
|
147
|
+
let timer = 0
|
|
148
|
+
const arm = () => {
|
|
149
|
+
setCrossfadeArmed(true)
|
|
150
|
+
timer = window.setTimeout(() => {
|
|
151
|
+
settledExpanded.current = !!expanded
|
|
152
|
+
setCrossfadeArmed(false)
|
|
153
|
+
forceSettle()
|
|
154
|
+
}, DURATION_DRAWER_MORPH)
|
|
155
|
+
}
|
|
156
|
+
const raf1 = requestAnimationFrame(() => {
|
|
157
|
+
raf2 = requestAnimationFrame(arm)
|
|
158
|
+
})
|
|
159
|
+
return () => {
|
|
160
|
+
cancelAnimationFrame(raf1)
|
|
161
|
+
cancelAnimationFrame(raf2)
|
|
162
|
+
clearTimeout(timer)
|
|
163
|
+
}
|
|
164
|
+
}, [expanded, prefersReducedMotion, measureFullWidth])
|
|
67
165
|
|
|
68
166
|
// Reset drawer width when resource kind changes
|
|
69
167
|
useEffect(() => {
|
|
@@ -72,14 +170,20 @@ export function ResourceDetailDrawer({ resource, onClose, onNavigate, initialTab
|
|
|
72
170
|
resizeStartWidth.current = w
|
|
73
171
|
}, [resource.kind])
|
|
74
172
|
|
|
75
|
-
//
|
|
173
|
+
// Drop a pending prewarm if the drawer closes or the target resource changes —
|
|
174
|
+
// its hover intent is stale, and a reopened drawer shouldn't pre-mount unbidden.
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
setPrewarmExpand(false)
|
|
177
|
+
}, [isOpen, resource.kind, resource.namespace, resource.name])
|
|
178
|
+
|
|
179
|
+
// Resize handlers (disabled when expanded or mid-transition)
|
|
76
180
|
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
|
77
|
-
if (expanded) return
|
|
181
|
+
if (expanded || transitioning) return
|
|
78
182
|
e.preventDefault()
|
|
79
183
|
setIsResizing(true)
|
|
80
184
|
resizeStartX.current = e.clientX
|
|
81
185
|
resizeStartWidth.current = drawerWidth
|
|
82
|
-
}, [drawerWidth, expanded])
|
|
186
|
+
}, [drawerWidth, expanded, transitioning])
|
|
83
187
|
|
|
84
188
|
useEffect(() => {
|
|
85
189
|
if (!isResizing) return
|
|
@@ -114,27 +218,72 @@ export function ResourceDetailDrawer({ resource, onClose, onNavigate, initialTab
|
|
|
114
218
|
const headerHeight = headerHeightProp ?? 49
|
|
115
219
|
const dockInset = useDockReservedHeight()
|
|
116
220
|
|
|
221
|
+
// Width animates during expand/collapse, but snaps during manual resize and
|
|
222
|
+
// when the user prefers reduced motion.
|
|
223
|
+
const animateWidth = !isResizing && !prefersReducedMotion
|
|
224
|
+
|
|
225
|
+
// During the pre-mount frames (transitioning but not yet armed) hold the frame
|
|
226
|
+
// at its START width so the width transition fires only once the incoming
|
|
227
|
+
// layer has painted; otherwise it snaps to the target.
|
|
228
|
+
const fullW = `calc(100% - ${leftOffset}px)`
|
|
229
|
+
const startWidth = settledExpanded.current ? fullW : drawerWidth
|
|
230
|
+
const targetWidth = expanded ? fullW : drawerWidth
|
|
231
|
+
const containerWidth = transitioning && !crossfadeArmed ? startWidth : targetWidth
|
|
232
|
+
|
|
233
|
+
const renderLayer = (layerExpanded: boolean, active: boolean) =>
|
|
234
|
+
children({
|
|
235
|
+
resource,
|
|
236
|
+
expanded: layerExpanded,
|
|
237
|
+
active,
|
|
238
|
+
initialTab,
|
|
239
|
+
onClose,
|
|
240
|
+
onExpand: onExpand ? (opts) => onExpand(resource, opts) : undefined,
|
|
241
|
+
onExpandIntent: onExpand ? handleExpandIntent : undefined,
|
|
242
|
+
onCancelExpandIntent: onExpand ? handleCancelExpandIntent : undefined,
|
|
243
|
+
onBack: onCollapse ? () => onCollapse() : undefined,
|
|
244
|
+
onNavigateToResource: handleNavigate,
|
|
245
|
+
// Hidden on mobile (no drawer to collapse to) — the host routes the
|
|
246
|
+
// back/close control through onCollapse instead.
|
|
247
|
+
onCollapseToDrawer: (canCollapseToDrawer && onCollapse) ? () => onCollapse() : undefined,
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// While transitioning render both layers (outgoing = settled state, incoming
|
|
251
|
+
// = target). Keying by expanded-ness keeps the incoming layer's React
|
|
252
|
+
// identity stable into idle, so it survives (state intact) while only the
|
|
253
|
+
// outgoing layer unmounts when the window ends. While prewarming (collapsed,
|
|
254
|
+
// intent signalled) also mount the expanded layer invisibly — keyed 'expanded'
|
|
255
|
+
// so it's reused (not remounted) when the real expand starts.
|
|
256
|
+
const showPrewarmLayer = prewarmExpand && isOpen && !transitioning && !expanded
|
|
257
|
+
const layerExpandedValues = transitioning ? [true, false] : showPrewarmLayer ? [false, true] : [!!expanded]
|
|
258
|
+
|
|
117
259
|
return (
|
|
118
260
|
<div
|
|
261
|
+
ref={containerRef}
|
|
119
262
|
className={clsx(
|
|
120
|
-
'absolute right-0 bg-theme-surface border-l border-theme-border flex flex-col
|
|
121
|
-
|
|
263
|
+
'absolute right-0 bg-theme-surface border-l border-theme-border flex flex-col z-40',
|
|
264
|
+
// Clip the wider layer only while morphing; keep overflow visible at idle
|
|
265
|
+
// so drawer popovers/tooltips aren't clipped.
|
|
266
|
+
transitioning && 'overflow-hidden',
|
|
267
|
+
// No drawer shadow in fullscreen — it's a full page, not a floating panel.
|
|
268
|
+
!expanded && 'shadow-drawer',
|
|
122
269
|
isOpen
|
|
123
270
|
? 'translate-x-0 opacity-100'
|
|
124
271
|
: 'translate-x-full opacity-0',
|
|
125
272
|
expanded && '!border-l-0',
|
|
126
273
|
)}
|
|
127
274
|
style={{
|
|
128
|
-
width:
|
|
275
|
+
width: containerWidth,
|
|
129
276
|
top: headerHeight,
|
|
130
277
|
height: `calc(100% - ${headerHeight}px - ${dockInset}px)`,
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
278
|
+
// Inline so the morph duration/easing is controlled precisely (and stays
|
|
279
|
+
// in lockstep with the crossfade + JS window). Width only animates when
|
|
280
|
+
// not resizing / reduced-motion.
|
|
281
|
+
transition: `translate ${DURATION_DRAWER_MORPH}ms ${EASE_DRAWER_MORPH}, opacity ${DURATION_DRAWER_MORPH}ms ${EASE_DRAWER_MORPH}${animateWidth ? `, width ${DURATION_DRAWER_MORPH}ms ${EASE_DRAWER_MORPH}` : ''}`,
|
|
282
|
+
willChange: 'transform, width',
|
|
134
283
|
}}
|
|
135
284
|
>
|
|
136
|
-
{/* Resize handle — hidden when expanded or on mobile */}
|
|
137
|
-
{!expanded && (
|
|
285
|
+
{/* Resize handle — hidden when expanded or mid-transition or on mobile */}
|
|
286
|
+
{!expanded && !transitioning && (
|
|
138
287
|
<div
|
|
139
288
|
onMouseDown={handleResizeStart}
|
|
140
289
|
className={clsx(
|
|
@@ -145,15 +294,49 @@ export function ResourceDetailDrawer({ resource, onClose, onNavigate, initialTab
|
|
|
145
294
|
/>
|
|
146
295
|
)}
|
|
147
296
|
|
|
148
|
-
{
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
297
|
+
{layerExpandedValues.map((layerExpanded) => {
|
|
298
|
+
if (!transitioning) {
|
|
299
|
+
// Prewarm: the expanded layer mounted invisibly ahead of the click.
|
|
300
|
+
// Pinned at full width + inert + opacity 0 so its mount cost is paid now,
|
|
301
|
+
// off-screen, and it's reused (same key) when the morph actually starts.
|
|
302
|
+
if (showPrewarmLayer && layerExpanded) {
|
|
303
|
+
return (
|
|
304
|
+
<div
|
|
305
|
+
key="expanded"
|
|
306
|
+
aria-hidden
|
|
307
|
+
inert
|
|
308
|
+
className="absolute top-0 bottom-0 right-0 overflow-hidden pointer-events-none opacity-0"
|
|
309
|
+
style={{ width: fullWidthPx ?? `calc(100% - ${leftOffset}px)` }}
|
|
310
|
+
>
|
|
311
|
+
{renderLayer(true, false)}
|
|
312
|
+
</div>
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
// Idle (or the visible collapsed layer during prewarm): fills the container.
|
|
316
|
+
return (
|
|
317
|
+
<div key={layerExpanded ? 'expanded' : 'collapsed'} className="absolute inset-0">
|
|
318
|
+
{renderLayer(layerExpanded, true)}
|
|
319
|
+
</div>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
const isIncoming = layerExpanded === !!expanded
|
|
323
|
+
return (
|
|
324
|
+
<div
|
|
325
|
+
key={layerExpanded ? 'expanded' : 'collapsed'}
|
|
326
|
+
className="absolute top-0 bottom-0 right-0 overflow-hidden pointer-events-none"
|
|
327
|
+
style={{
|
|
328
|
+
width: layerExpanded ? (fullWidthPx ?? `calc(100% - ${leftOffset}px)`) : drawerWidth,
|
|
329
|
+
opacity: isIncoming ? (crossfadeArmed ? 1 : 0) : (crossfadeArmed ? 0 : 1),
|
|
330
|
+
transition: `opacity ${DURATION_DRAWER_MORPH}ms ${EASE_DRAWER_MORPH}`,
|
|
331
|
+
willChange: 'opacity',
|
|
332
|
+
}}
|
|
333
|
+
aria-hidden={!isIncoming}
|
|
334
|
+
>
|
|
335
|
+
{/* Both layers inactive mid-crossfade — shortcuts shouldn't dispatch
|
|
336
|
+
during the animation; the settled layer owns them once idle. */}
|
|
337
|
+
{renderLayer(layerExpanded, false)}
|
|
338
|
+
</div>
|
|
339
|
+
)
|
|
157
340
|
})}
|
|
158
341
|
</div>
|
|
159
342
|
)
|
|
@@ -71,10 +71,17 @@ interface WorkloadViewProps {
|
|
|
71
71
|
onCollapseToDrawer?: () => void
|
|
72
72
|
/** false = collapsed drawer mode, true (default) = full expanded mode */
|
|
73
73
|
expanded?: boolean
|
|
74
|
+
/** false on the outgoing layer during an expand/collapse crossfade — suspend
|
|
75
|
+
* keyboard shortcuts so the invisible layer doesn't capture them (default true) */
|
|
76
|
+
active?: boolean
|
|
74
77
|
/** Close the drawer (collapsed mode) */
|
|
75
78
|
onClose?: () => void
|
|
76
|
-
/** Expand from drawer to full view
|
|
77
|
-
|
|
79
|
+
/** Expand from drawer to full view. `opts.yaml` true when expanding from the
|
|
80
|
+
* drawer's YAML view so the full view opens on the YAML tab (edits carry over). */
|
|
81
|
+
onExpand?: (opts?: { yaml?: boolean }) => void
|
|
82
|
+
/** Hover/press the expand control = likely expand → pre-mount the full view. */
|
|
83
|
+
onExpandIntent?: () => void
|
|
84
|
+
onCancelExpandIntent?: () => void
|
|
78
85
|
/** Initial view tab — 'yaml' opens YAML directly */
|
|
79
86
|
initialTab?: 'detail' | 'yaml'
|
|
80
87
|
/** API group for CRD resources */
|
|
@@ -248,8 +255,11 @@ export function WorkloadView({
|
|
|
248
255
|
onNavigateToResource,
|
|
249
256
|
onCollapseToDrawer,
|
|
250
257
|
expanded = true,
|
|
258
|
+
active = true,
|
|
251
259
|
onClose,
|
|
252
260
|
onExpand,
|
|
261
|
+
onExpandIntent,
|
|
262
|
+
onCancelExpandIntent,
|
|
253
263
|
initialTab,
|
|
254
264
|
group,
|
|
255
265
|
breadcrumb,
|
|
@@ -524,9 +534,12 @@ export function WorkloadView({
|
|
|
524
534
|
keys: 'Escape',
|
|
525
535
|
description: expanded ? 'Go back' : 'Close drawer',
|
|
526
536
|
category: expanded ? 'Navigation' as const : 'Drawer' as const,
|
|
527
|
-
|
|
537
|
+
// 'drawer' (top priority) in both modes so when this is the fullscreen
|
|
538
|
+
// overlay its Escape unambiguously wins over any background view's Escape
|
|
539
|
+
// (incl. another 'global'-scope WorkloadView mounted underneath).
|
|
540
|
+
scope: 'drawer' as const,
|
|
528
541
|
handler: expanded ? onBack : () => onClose?.(),
|
|
529
|
-
enabled:
|
|
542
|
+
enabled: active,
|
|
530
543
|
},
|
|
531
544
|
{
|
|
532
545
|
id: 'drawer-yaml',
|
|
@@ -535,7 +548,7 @@ export function WorkloadView({
|
|
|
535
548
|
category: 'Drawer' as const,
|
|
536
549
|
scope: 'drawer' as const,
|
|
537
550
|
handler: () => switchView(true),
|
|
538
|
-
enabled: !expanded,
|
|
551
|
+
enabled: active && !expanded,
|
|
539
552
|
},
|
|
540
553
|
{
|
|
541
554
|
id: 'drawer-detail',
|
|
@@ -544,9 +557,9 @@ export function WorkloadView({
|
|
|
544
557
|
category: 'Drawer' as const,
|
|
545
558
|
scope: 'drawer' as const,
|
|
546
559
|
handler: () => switchView(false),
|
|
547
|
-
enabled: !expanded,
|
|
560
|
+
enabled: active && !expanded,
|
|
548
561
|
},
|
|
549
|
-
], [expanded, onBack, onClose, switchView]))
|
|
562
|
+
], [active, expanded, onBack, onClose, switchView]))
|
|
550
563
|
|
|
551
564
|
const status = getResourceStatus(apiKind, resource)
|
|
552
565
|
|
|
@@ -586,7 +599,12 @@ export function WorkloadView({
|
|
|
586
599
|
<div className="flex items-center gap-1">
|
|
587
600
|
{onExpand && (
|
|
588
601
|
<button
|
|
589
|
-
onClick={onExpand}
|
|
602
|
+
onClick={() => onExpand({ yaml: showYaml })}
|
|
603
|
+
// Pre-mount the fullscreen view on hover/press so the click starts
|
|
604
|
+
// the morph instantly (its heavy mount is already paid for).
|
|
605
|
+
onPointerEnter={onExpandIntent}
|
|
606
|
+
onPointerDown={onExpandIntent}
|
|
607
|
+
onPointerLeave={onCancelExpandIntent}
|
|
590
608
|
className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
|
|
591
609
|
title="Open full view"
|
|
592
610
|
>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
decodeFilters,
|
|
4
|
+
defineFilterSchema,
|
|
5
|
+
withField,
|
|
6
|
+
withToggle,
|
|
7
|
+
withCleared,
|
|
8
|
+
isFilterActive,
|
|
9
|
+
} from './filter-state-core'
|
|
10
|
+
|
|
11
|
+
const schema = defineFilterSchema({
|
|
12
|
+
ns: { param: 'namespace', type: 'set' },
|
|
13
|
+
sev: { param: 'severity', type: 'set' },
|
|
14
|
+
q: { param: 'q', type: 'text' },
|
|
15
|
+
group: { param: 'group', type: 'single', default: 'none' },
|
|
16
|
+
managed: { param: 'managed', type: 'boolean' },
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const p = (s = '') => new URLSearchParams(s)
|
|
20
|
+
|
|
21
|
+
describe('filter-state core', () => {
|
|
22
|
+
it('decodes params by type', () => {
|
|
23
|
+
const v = decodeFilters(schema, p('namespace=argocd,default&severity=high&q=foo&managed=1'))
|
|
24
|
+
expect([...v.ns]).toEqual(['argocd', 'default'])
|
|
25
|
+
expect([...v.sev]).toEqual(['high'])
|
|
26
|
+
expect(v.q).toBe('foo')
|
|
27
|
+
expect(v.managed).toBe(true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('applies string defaults when the param is absent', () => {
|
|
31
|
+
const v = decodeFilters(schema, p(''))
|
|
32
|
+
expect(v.group).toBe('none')
|
|
33
|
+
expect(v.q).toBe('')
|
|
34
|
+
expect(v.managed).toBe(false)
|
|
35
|
+
expect(v.ns.size).toBe(0)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('toggles a set member and omits the param when empty (empty = all)', () => {
|
|
39
|
+
const added = withToggle(schema, p(''), 'ns', 'argocd')
|
|
40
|
+
expect(added.toString()).toBe('namespace=argocd')
|
|
41
|
+
const removed = withToggle(schema, added, 'ns', 'argocd')
|
|
42
|
+
expect(removed.toString()).toBe('')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('composes successive toggles (each sees the previous result)', () => {
|
|
46
|
+
let params = p('')
|
|
47
|
+
params = withToggle(schema, params, 'ns', 'a')
|
|
48
|
+
params = withToggle(schema, params, 'ns', 'b')
|
|
49
|
+
expect([...decodeFilters(schema, params).ns].sort()).toEqual(['a', 'b'])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('withField replaces a set wholesale and clears when empty', () => {
|
|
53
|
+
const set = withField(schema, p('namespace=x'), 'ns', new Set(['a', 'b']))
|
|
54
|
+
expect([...decodeFilters(schema, set).ns]).toEqual(['a', 'b'])
|
|
55
|
+
expect(withField(schema, set, 'ns', new Set<string>()).toString()).toBe('')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('omits a string param when equal to its default', () => {
|
|
59
|
+
expect(withField(schema, p(''), 'group', 'namespace').toString()).toBe('group=namespace')
|
|
60
|
+
expect(withField(schema, p('group=namespace'), 'group', 'none').toString()).toBe('')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('encodes a boolean as =1 and omits false', () => {
|
|
64
|
+
expect(withField(schema, p(''), 'managed', true).toString()).toBe('managed=1')
|
|
65
|
+
expect(withField(schema, p('managed=1'), 'managed', false).toString()).toBe('')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('clears one field or all schema fields, leaving unrelated params', () => {
|
|
69
|
+
const one = withCleared(schema, p('namespace=a&severity=high&keep=yes'), ['ns'])
|
|
70
|
+
const oneV = decodeFilters(schema, one)
|
|
71
|
+
expect(oneV.ns.size).toBe(0)
|
|
72
|
+
expect(oneV.sev.size).toBe(1)
|
|
73
|
+
expect(one.get('keep')).toBe('yes')
|
|
74
|
+
expect(withCleared(schema, p('namespace=a&severity=high&keep=yes')).toString()).toBe('keep=yes')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('isFilterActive is true only when a field diverges from its default', () => {
|
|
78
|
+
expect(isFilterActive(schema, decodeFilters(schema, p('q=foo')))).toBe(true)
|
|
79
|
+
expect(isFilterActive(schema, decodeFilters(schema, p('group=none')))).toBe(false)
|
|
80
|
+
expect(isFilterActive(schema, decodeFilters(schema, p('')))).toBe(false)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('writes set values sorted, so the URL is canonical regardless of order', () => {
|
|
84
|
+
const a = withToggle(schema, withToggle(schema, p(''), 'ns', 'zebra'), 'ns', 'alpha')
|
|
85
|
+
expect(a.get('namespace')).toBe('alpha,zebra')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('decodes an empty string param (?group=) as the default', () => {
|
|
89
|
+
expect(decodeFilters(schema, p('group=')).group).toBe('none')
|
|
90
|
+
expect(decodeFilters(schema, p('q=')).q).toBe('')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('defineFilterSchema rejects duplicate URL params', () => {
|
|
94
|
+
expect(() =>
|
|
95
|
+
defineFilterSchema({ a: { param: 'x', type: 'set' }, b: { param: 'x', type: 'set' } }),
|
|
96
|
+
).toThrow(/duplicate/)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Pure URL <-> filter-state transforms. No React, no router, no window — just
|
|
2
|
+
// URLSearchParams in, URLSearchParams / typed values out. This is the behavioral
|
|
3
|
+
// contract every list view shares; keeping it pure makes it exhaustively
|
|
4
|
+
// testable and keeps the React hook a thin wrapper.
|
|
5
|
+
|
|
6
|
+
export interface FilterFieldDef {
|
|
7
|
+
/** URL query-param name. Stable — this is a durable, shareable public API. */
|
|
8
|
+
param: string
|
|
9
|
+
/**
|
|
10
|
+
* - 'set' multi-select; comma-list. Empty ⇒ param omitted ⇒ "all" (no
|
|
11
|
+
* narrowing). Values must not contain commas (k8s identifiers and
|
|
12
|
+
* controlled enums never do). Written sorted, so the URL is
|
|
13
|
+
* canonical regardless of selection order. Value: Set<string>.
|
|
14
|
+
* - 'text' free-text search; written with history `replace` (no entry per
|
|
15
|
+
* keystroke). Omitted when empty / equal to `default`. Value: string.
|
|
16
|
+
* - 'single' single choice (enum / dropdown, incl. tri-state all|yes|no);
|
|
17
|
+
* pushes a history entry. Omitted when equal to `default`. Value:
|
|
18
|
+
* string.
|
|
19
|
+
* - 'boolean' two-state flag; `param=1` when true, omitted when false. For
|
|
20
|
+
* three states use 'single' with an enum default. Value: boolean.
|
|
21
|
+
*/
|
|
22
|
+
type: 'set' | 'text' | 'single' | 'boolean'
|
|
23
|
+
/** Default for 'text'/'single' (omitted from the URL when the value equals it). */
|
|
24
|
+
default?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type FilterSchema = Record<string, FilterFieldDef>
|
|
28
|
+
|
|
29
|
+
type FieldValue<F extends FilterFieldDef> = F['type'] extends 'set'
|
|
30
|
+
? Set<string>
|
|
31
|
+
: F['type'] extends 'boolean'
|
|
32
|
+
? boolean
|
|
33
|
+
: string
|
|
34
|
+
|
|
35
|
+
export type FilterValues<S extends FilterSchema> = { [K in keyof S]: FieldValue<S[K]> }
|
|
36
|
+
|
|
37
|
+
/** Keys of a schema whose field is one of the given kinds — for type-safe setters. */
|
|
38
|
+
export type FieldKeysOfType<S extends FilterSchema, T extends FilterFieldDef['type']> = {
|
|
39
|
+
[K in keyof S]: S[K]['type'] extends T ? K : never
|
|
40
|
+
}[keyof S]
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Identity helper that preserves literal types (so `values.x` is precisely typed)
|
|
44
|
+
* and validates the schema — duplicate URL params would silently clobber each
|
|
45
|
+
* other, so we fail loudly at module load.
|
|
46
|
+
*/
|
|
47
|
+
export function defineFilterSchema<const S extends FilterSchema>(schema: S): S {
|
|
48
|
+
const seen = new Set<string>()
|
|
49
|
+
for (const key in schema) {
|
|
50
|
+
const { param } = schema[key]
|
|
51
|
+
if (seen.has(param)) throw new Error(`defineFilterSchema: duplicate URL param "${param}"`)
|
|
52
|
+
seen.add(param)
|
|
53
|
+
}
|
|
54
|
+
return schema
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function decodeFilters<S extends FilterSchema>(schema: S, params: URLSearchParams): FilterValues<S> {
|
|
58
|
+
const out = {} as FilterValues<S>
|
|
59
|
+
for (const key in schema) {
|
|
60
|
+
const def = schema[key]
|
|
61
|
+
const raw = params.get(def.param)
|
|
62
|
+
if (def.type === 'set') {
|
|
63
|
+
;(out[key] as Set<string>) = new Set((raw ?? '').split(',').filter(Boolean))
|
|
64
|
+
} else if (def.type === 'boolean') {
|
|
65
|
+
;(out[key] as boolean) = raw === '1'
|
|
66
|
+
} else {
|
|
67
|
+
// text / single: an empty param (?f=) reads as the default, same as omission.
|
|
68
|
+
;(out[key] as string) = raw ? raw : (def.default ?? '')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Write one field into `params` in place. Read (decode) and write agree exactly. */
|
|
75
|
+
function writeField(params: URLSearchParams, def: FilterFieldDef, value: Set<string> | string | boolean): void {
|
|
76
|
+
if (def.type === 'set') {
|
|
77
|
+
const set = value as Set<string>
|
|
78
|
+
if (set.size === 0) params.delete(def.param)
|
|
79
|
+
else params.set(def.param, [...set].sort().join(','))
|
|
80
|
+
} else if (def.type === 'boolean') {
|
|
81
|
+
if (value) params.set(def.param, '1')
|
|
82
|
+
else params.delete(def.param)
|
|
83
|
+
} else {
|
|
84
|
+
const str = value as string
|
|
85
|
+
if (!str || str === (def.default ?? '')) params.delete(def.param)
|
|
86
|
+
else params.set(def.param, str)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Return new params with one field set to `value`. */
|
|
91
|
+
export function withField<S extends FilterSchema>(
|
|
92
|
+
schema: S,
|
|
93
|
+
params: URLSearchParams,
|
|
94
|
+
key: keyof S,
|
|
95
|
+
value: Set<string> | string | boolean,
|
|
96
|
+
): URLSearchParams {
|
|
97
|
+
const next = new URLSearchParams(params)
|
|
98
|
+
writeField(next, schema[key], value)
|
|
99
|
+
return next
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Return new params with one member added to / removed from a 'set' field. */
|
|
103
|
+
export function withToggle<S extends FilterSchema>(
|
|
104
|
+
schema: S,
|
|
105
|
+
params: URLSearchParams,
|
|
106
|
+
key: keyof S,
|
|
107
|
+
value: string,
|
|
108
|
+
): URLSearchParams {
|
|
109
|
+
const next = new URLSearchParams(params)
|
|
110
|
+
const cur = new Set((next.get(schema[key].param) ?? '').split(',').filter(Boolean))
|
|
111
|
+
if (cur.has(value)) cur.delete(value)
|
|
112
|
+
else cur.add(value)
|
|
113
|
+
writeField(next, schema[key], cur)
|
|
114
|
+
return next
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Default (empty) value for a field, used to reset it. */
|
|
118
|
+
export function emptyValue(def: FilterFieldDef): Set<string> | string | boolean {
|
|
119
|
+
return def.type === 'set' ? new Set<string>() : def.type === 'boolean' ? false : (def.default ?? '')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Return new params with the given schema fields cleared (all of them if omitted). */
|
|
123
|
+
export function withCleared<S extends FilterSchema>(schema: S, params: URLSearchParams, keys?: (keyof S)[]): URLSearchParams {
|
|
124
|
+
const next = new URLSearchParams(params)
|
|
125
|
+
for (const key of keys ?? (Object.keys(schema) as (keyof S)[])) next.delete(schema[key].param)
|
|
126
|
+
return next
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function isFilterActive<S extends FilterSchema>(schema: S, values: FilterValues<S>): boolean {
|
|
130
|
+
for (const key in schema) {
|
|
131
|
+
const def = schema[key]
|
|
132
|
+
const v = values[key]
|
|
133
|
+
if (def.type === 'set' && (v as Set<string>).size > 0) return true
|
|
134
|
+
if (def.type === 'boolean' && (v as boolean)) return true
|
|
135
|
+
if ((def.type === 'text' || def.type === 'single') && (v as string) !== (def.default ?? '')) return true
|
|
136
|
+
}
|
|
137
|
+
return false
|
|
138
|
+
}
|