@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.
Files changed (42) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationsList.tsx +5 -2
  3. package/src/components/applications/ApplicationsView.tsx +37 -30
  4. package/src/components/checks/ChecksView.tsx +25 -14
  5. package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
  6. package/src/components/gitops/GitOpsTableView.tsx +163 -88
  7. package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
  8. package/src/components/issues/IssuesView.tsx +41 -5
  9. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  10. package/src/components/issues/diagnostic.ts +22 -0
  11. package/src/components/issues/index.ts +1 -1
  12. package/src/components/issues/issues.test.ts +21 -0
  13. package/src/components/issues/types.ts +18 -0
  14. package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
  15. package/src/components/namespace-switcher/index.ts +6 -0
  16. package/src/components/resources/ResourcesView.tsx +20 -81
  17. package/src/components/scope-pill/ScopePill.tsx +35 -0
  18. package/src/components/scope-pill/index.ts +2 -0
  19. package/src/components/timeline/TimelineList.tsx +27 -1
  20. package/src/components/topology/TopologyControls.tsx +90 -14
  21. package/src/components/ui/FreshnessControl.tsx +153 -0
  22. package/src/components/ui/SortableTh.tsx +16 -10
  23. package/src/components/ui/SummaryTile.tsx +9 -1
  24. package/src/components/ui/Toast.tsx +1 -1
  25. package/src/components/ui/index.ts +2 -0
  26. package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
  27. package/src/components/workload/WorkloadView.tsx +26 -8
  28. package/src/filter-state/filter-state-core.test.ts +98 -0
  29. package/src/filter-state/filter-state-core.ts +138 -0
  30. package/src/filter-state/filter-state.tsx +127 -0
  31. package/src/filter-state/index.ts +16 -0
  32. package/src/hooks/index.ts +1 -0
  33. package/src/hooks/useKeyboardShortcuts.tsx +23 -2
  34. package/src/hooks/useRefreshAnimation.ts +15 -2
  35. package/src/index.ts +12 -0
  36. package/src/types/core.ts +42 -0
  37. package/src/types/gitops-insights.ts +4 -0
  38. package/src/utils/animation.ts +10 -0
  39. package/src/utils/format-freshness.test.ts +34 -0
  40. package/src/utils/format.ts +32 -0
  41. package/src/utils/resource-hierarchy.test.ts +51 -0
  42. package/src/utils/resource-hierarchy.ts +7 -4
@@ -1,5 +1,8 @@
1
- import { useState, useCallback, useEffect, useRef, type ReactNode } from 'react'
2
- import { TRANSITION_DRAWER } from '../../utils/animation'
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
- onExpand?: (resource: SelectedResource) => void
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
- export function ResourceDetailDrawer({ resource, onClose, onNavigate, initialTab, isOpen = true, expanded, onCollapse, onExpand, onNavigateToResource, headerHeight: headerHeightProp, leftOffset = 0, children }: ResourceDetailDrawerProps) {
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
- // Detect collapse direction: was expanded last render, now not.
62
- // Width snaps instantly on collapse (0ms) so content and size match.
63
- // Expand keeps the nice 300ms width animation via TRANSITION_DRAWER.
64
- const wasExpanded = useRef(!!expanded)
65
- const isCollapsing = wasExpanded.current && !expanded
66
- wasExpanded.current = !!expanded
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
- // Resize handlers (disabled when expanded)
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 shadow-drawer z-40',
121
- TRANSITION_DRAWER,
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: expanded ? `calc(100% - ${leftOffset}px)` : drawerWidth,
275
+ width: containerWidth,
129
276
  top: headerHeight,
130
277
  height: `calc(100% - ${headerHeight}px - ${dockInset}px)`,
131
- // Collapse is instant — no animation, content and width snap together.
132
- // Expand + slide-in/out animate via TRANSITION_DRAWER class.
133
- ...(isCollapsing && { transition: 'none' }),
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
- {children({
149
- resource,
150
- expanded: !!expanded,
151
- initialTab,
152
- onClose,
153
- onExpand: onExpand ? () => onExpand(resource) : undefined,
154
- onBack: onCollapse ? () => onCollapse() : undefined,
155
- onNavigateToResource: handleNavigate,
156
- onCollapseToDrawer: onCollapse ? () => onCollapse() : undefined,
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
- onExpand?: () => void
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
- scope: expanded ? 'global' as const : 'drawer' as const,
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: true,
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
+ }