@skyhook-io/k8s-ui 1.18.0 → 1.18.2

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 (128) hide show
  1. package/README.md +21 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  5. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  6. package/src/components/compare/ResourceCompareView.tsx +6 -2
  7. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  9. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  10. package/src/components/gitops/GitOpsTableView.tsx +46 -12
  11. package/src/components/gitops/detail-helpers.test.ts +58 -13
  12. package/src/components/gitops/detail-helpers.ts +37 -6
  13. package/src/components/gitops/index.ts +1 -0
  14. package/src/components/issues/IssuesView.tsx +16 -11
  15. package/src/components/issues/diagnostic.ts +2 -0
  16. package/src/components/issues/severity.ts +1 -0
  17. package/src/components/logs/LogCore.tsx +13 -11
  18. package/src/components/logs/LogsViewer.test.tsx +104 -59
  19. package/src/components/logs/LogsViewer.tsx +28 -8
  20. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  21. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  22. package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
  23. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  24. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  25. package/src/components/resources/ResourcesSidebar.tsx +62 -26
  26. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  27. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  28. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  29. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  30. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  31. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  32. package/src/components/resources/ResourcesView.tsx +342 -83
  33. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  34. package/src/components/resources/generic-status.ts +15 -0
  35. package/src/components/resources/printer-columns.test.ts +36 -0
  36. package/src/components/resources/printer-columns.ts +12 -1
  37. package/src/components/resources/readiness-gates.test.ts +95 -0
  38. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  39. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  40. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  41. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  42. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  43. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  44. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  45. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  46. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  47. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  48. package/src/components/resources/renderers/index.ts +3 -0
  49. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  50. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  51. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  52. package/src/components/resources/resource-utils-flagger.ts +76 -0
  53. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  54. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  55. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  56. package/src/components/resources/resource-utils-trivy.ts +122 -0
  57. package/src/components/resources/resource-utils.ts +80 -2
  58. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  59. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  60. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  61. package/src/components/shared/DetailShell.tsx +21 -8
  62. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  63. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  64. package/src/components/shared/EditableYamlView.tsx +18 -3
  65. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  66. package/src/components/shared/FilterRailPanel.tsx +62 -0
  67. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  68. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  69. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  70. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  71. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  72. package/src/components/shared/ManagedByChip.tsx +158 -5
  73. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  74. package/src/components/shared/ResourceActionsBar.tsx +31 -9
  75. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  76. package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
  77. package/src/components/shared/SetImageDialog.tsx +3 -0
  78. package/src/components/timeline/TimelineList.tsx +5 -1
  79. package/src/components/topology/TopologyControls.tsx +1 -1
  80. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  81. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  82. package/src/components/ui/FetchResult.test.tsx +19 -2
  83. package/src/components/ui/FetchResult.tsx +13 -1
  84. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  85. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  86. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  87. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  88. package/src/components/ui/PageHeader.tsx +7 -4
  89. package/src/components/ui/SummaryTile.tsx +13 -3
  90. package/src/components/ui/Toast.tsx +33 -26
  91. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  92. package/src/components/ui/Tooltip.tsx +11 -2
  93. package/src/components/ui/YamlReview.test.tsx +52 -0
  94. package/src/components/ui/YamlReview.tsx +7 -0
  95. package/src/components/ui/drawer-components.test.tsx +280 -0
  96. package/src/components/ui/drawer-components.tsx +302 -18
  97. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  98. package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
  99. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  100. package/src/components/workload/WorkloadView.tsx +105 -34
  101. package/src/hooks/index.ts +2 -0
  102. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  103. package/src/hooks/useKeepInViewport.test.ts +27 -0
  104. package/src/hooks/useKeepInViewport.ts +87 -0
  105. package/src/hooks/useMediaQuery.ts +21 -0
  106. package/src/hooks/usePhoneLayout.ts +12 -0
  107. package/src/theme/tailwind-theme.css +5 -0
  108. package/src/types/core.ts +119 -3
  109. package/src/types/gitops-flux-inventory.test.ts +24 -0
  110. package/src/types/gitops.ts +5 -5
  111. package/src/types/index.ts +3 -0
  112. package/src/types/read-coverage.ts +65 -0
  113. package/src/types/resource-coverage.ts +40 -0
  114. package/src/utils/api-resources.ts +1 -0
  115. package/src/utils/format.test.ts +7 -0
  116. package/src/utils/format.ts +6 -1
  117. package/src/utils/gitops-owner.test.ts +42 -1
  118. package/src/utils/gitops-owner.ts +158 -8
  119. package/src/utils/gitops-write-guard.test.ts +332 -0
  120. package/src/utils/gitops-write-guard.ts +175 -11
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/read-actions.test.ts +22 -0
  123. package/src/utils/read-actions.ts +90 -0
  124. package/src/utils/resource-coverage.test.ts +121 -0
  125. package/src/utils/resource-coverage.ts +117 -0
  126. package/src/utils/touch-intent.test.ts +39 -0
  127. package/src/utils/touch-intent.ts +35 -0
  128. package/vitest.config.ts +26 -0
@@ -1,8 +1,8 @@
1
1
  // @vitest-environment jsdom
2
- import { act, type ReactNode } from 'react'
2
+ import { act, createRef, useState, type ReactNode } from 'react'
3
3
  import { createRoot, type Root } from 'react-dom/client'
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
- import { NamespacePicker, type NamespacePickerProps, type NamespaceScopeView } from './NamespacePicker'
5
+ import { NamespacePicker, type NamespacePickerHandle, type NamespacePickerProps, type NamespaceScopeView } from './NamespacePicker'
6
6
 
7
7
  vi.mock('../ui/Tooltip', () => ({
8
8
  Tooltip: ({ children, content }: { children: ReactNode; content: ReactNode }) => (
@@ -138,3 +138,237 @@ describe('NamespacePicker incomplete-list notice', () => {
138
138
  expect(onApply).toHaveBeenCalledWith(['team-a'])
139
139
  })
140
140
  })
141
+
142
+ describe('NamespacePicker Only button', () => {
143
+ const scope = { ...baseScope, actives: ['team-a'], accessibleNamespaces: ['team-a', 'team-b', 'team-c'] }
144
+
145
+ const triggerButton = () =>
146
+ document.querySelector<HTMLButtonElement>('button[aria-label="Switch active namespaces"]')!
147
+
148
+ async function open(onApply = vi.fn()) {
149
+ await act(async () => {
150
+ root.render(<NamespacePicker scope={scope} onApply={onApply} />)
151
+ })
152
+ await act(async () => { triggerButton().click() })
153
+ return onApply
154
+ }
155
+
156
+ const onlyButton = (ns: string) =>
157
+ document.querySelector<HTMLButtonElement>(`button[aria-label="Show only ${ns}"]`)!
158
+ const row = (ns: string) =>
159
+ Array.from(document.querySelectorAll('label')).find(l => l.textContent?.includes(ns))!
160
+ const checkbox = (ns: string) => row(ns).querySelector<HTMLInputElement>('input[type="checkbox"]')!
161
+ const doneButton = () =>
162
+ Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Done')!
163
+ const isOpen = () => document.querySelector('input[type="checkbox"]') !== null
164
+
165
+ it('puts an Only button on every row', async () => {
166
+ await open()
167
+ for (const ns of ['team-a', 'team-b', 'team-c']) expect(onlyButton(ns).textContent).toBe('Only')
168
+ })
169
+
170
+ it('applies just that namespace and closes the dropdown', async () => {
171
+ const onApply = await open()
172
+ expect(isOpen()).toBe(true)
173
+ await act(async () => { onlyButton('team-c').click() })
174
+
175
+ expect(onApply).toHaveBeenCalledTimes(1)
176
+ expect(onApply).toHaveBeenCalledWith(['team-c'])
177
+ expect(isOpen()).toBe(false)
178
+ })
179
+
180
+ it('replaces unsaved checkbox toggles', async () => {
181
+ const onApply = await open()
182
+ await act(async () => { checkbox('team-b').click() })
183
+ await act(async () => { onlyButton('team-c').click() })
184
+
185
+ expect(onApply).toHaveBeenCalledWith(['team-c'])
186
+ })
187
+
188
+ it('does not re-apply when that namespace is already the only one active', async () => {
189
+ const onApply = await open()
190
+ await act(async () => { onlyButton('team-a').click() })
191
+
192
+ expect(onApply).not.toHaveBeenCalled()
193
+ expect(isOpen()).toBe(false)
194
+ })
195
+
196
+ it('keeps the name and checkbox as add/remove toggles', async () => {
197
+ const onApply = await open()
198
+ await act(async () => { checkbox('team-b').click() })
199
+ expect(checkbox('team-b').checked).toBe(true)
200
+ expect(onApply).not.toHaveBeenCalled()
201
+ expect(isOpen()).toBe(true)
202
+
203
+ const name = Array.from(row('team-c').querySelectorAll('span')).find(s => s.textContent === 'team-c')!
204
+ await act(async () => { name.click() })
205
+ expect(checkbox('team-c').checked).toBe(true)
206
+ expect(onApply).not.toHaveBeenCalled()
207
+ expect(isOpen()).toBe(true)
208
+
209
+ await act(async () => { doneButton().click() })
210
+ expect(onApply).toHaveBeenCalledWith(['team-a', 'team-b', 'team-c'])
211
+ })
212
+
213
+ it('keeps the button out of the row label', async () => {
214
+ await open()
215
+ expect(document.querySelector('label button')).toBeNull()
216
+ expect(onlyButton('team-b').parentElement).toBe(row('team-b').parentElement)
217
+ })
218
+
219
+ it('is reachable by keyboard, with no tabindex taking it out of the tab order', async () => {
220
+ await open()
221
+ const only = onlyButton('team-b')
222
+ expect(only.tabIndex).toBe(0)
223
+ only.focus()
224
+ expect(document.activeElement).toBe(only)
225
+ })
226
+
227
+ it.each(['Only', 'Done', 'Clear all'])('reopens from active namespaces after an unacknowledged %s apply', async (action) => {
228
+ const onApply = await open()
229
+ if (action === 'Only') {
230
+ await act(async () => { onlyButton('team-c').click() })
231
+ } else if (action === 'Done') {
232
+ await act(async () => { checkbox('team-b').click() })
233
+ await act(async () => { doneButton().click() })
234
+ } else {
235
+ const clear = document.querySelector<HTMLButtonElement>('button[aria-label="Clear namespace selection"]')!
236
+ await act(async () => { clear.click() })
237
+ }
238
+ expect(onApply).toHaveBeenCalledTimes(1)
239
+ expect(isOpen()).toBe(false)
240
+
241
+ await act(async () => { triggerButton().click() })
242
+ expect(checkbox('team-a').checked).toBe(true)
243
+ expect(checkbox('team-b').checked).toBe(false)
244
+ expect(checkbox('team-c').checked).toBe(false)
245
+ await act(async () => { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) })
246
+ expect(onApply).toHaveBeenCalledTimes(1)
247
+ })
248
+
249
+ it('also refreshes the draft when the keyboard shortcut opens the picker', async () => {
250
+ const ref = createRef<NamespacePickerHandle>()
251
+ const onApply = vi.fn()
252
+ await act(async () => { root.render(<NamespacePicker ref={ref} scope={scope} onApply={onApply} />) })
253
+ await act(async () => { ref.current?.open() })
254
+ await act(async () => { onlyButton('team-c').click() })
255
+ await act(async () => { ref.current?.open() })
256
+ expect(checkbox('team-a').checked).toBe(true)
257
+ expect(checkbox('team-c').checked).toBe(false)
258
+ await act(async () => { doneButton().click() })
259
+ expect(onApply).toHaveBeenCalledTimes(1)
260
+ })
261
+
262
+ it('keeps current edits if the open shortcut is used while the menu is already open', async () => {
263
+ const ref = createRef<NamespacePickerHandle>()
264
+ const onApply = vi.fn()
265
+ await act(async () => { root.render(<NamespacePicker ref={ref} scope={scope} onApply={onApply} />) })
266
+ await act(async () => { ref.current?.open() })
267
+ await act(async () => { checkbox('team-b').click() })
268
+ await act(async () => { ref.current?.open() })
269
+ expect(checkbox('team-a').checked).toBe(true)
270
+ expect(checkbox('team-b').checked).toBe(true)
271
+ expect(onApply).not.toHaveBeenCalled()
272
+ })
273
+
274
+ describe('returning focus after the switch', () => {
275
+ let setPending: (pending: boolean) => void
276
+
277
+ function Host({ onApply, pendingOnApply = true }: { onApply: (namespaces: string[]) => void; pendingOnApply?: boolean }) {
278
+ const [pending, setPendingState] = useState(false)
279
+ setPending = setPendingState
280
+ return (
281
+ <NamespacePicker
282
+ scope={scope}
283
+ pending={pending}
284
+ onApply={namespaces => {
285
+ if (pendingOnApply) setPendingState(true)
286
+ onApply(namespaces)
287
+ }}
288
+ />
289
+ )
290
+ }
291
+
292
+ async function openAndPressOnly(ns: string, pendingOnApply = true) {
293
+ const onApply = vi.fn()
294
+ await act(async () => { root.render(<Host onApply={onApply} pendingOnApply={pendingOnApply} />) })
295
+ await act(async () => { triggerButton().click() })
296
+ const only = onlyButton(ns)
297
+ only.focus()
298
+ await act(async () => { only.click() })
299
+ return onApply
300
+ }
301
+
302
+ // A browser blurs a focused button the moment it becomes disabled; jsdom does not.
303
+ async function startPending() {
304
+ ;(document.activeElement as HTMLElement | null)?.blur()
305
+ await act(async () => { setPending(true) })
306
+ }
307
+
308
+ it('puts focus back on the trigger when the switch is instant', async () => {
309
+ await open()
310
+ const only = onlyButton('team-a')
311
+ only.focus()
312
+ await act(async () => { only.click() })
313
+
314
+ expect(document.activeElement).toBe(triggerButton())
315
+ })
316
+
317
+ it('waits for the pending switch to finish, because the trigger is inert until then', async () => {
318
+ await openAndPressOnly('team-c')
319
+ expect(triggerButton().disabled).toBe(true)
320
+ expect(document.activeElement).toBe(document.body)
321
+
322
+ await act(async () => { setPending(false) })
323
+ expect(document.activeElement).toBe(triggerButton())
324
+ })
325
+
326
+ it('restores focus again when the host only reports pending after the menu has closed', async () => {
327
+ await openAndPressOnly('team-c', false)
328
+ expect(document.activeElement).toBe(triggerButton())
329
+
330
+ await startPending()
331
+ expect(triggerButton().disabled).toBe(true)
332
+ expect(document.activeElement).toBe(document.body)
333
+
334
+ await act(async () => { setPending(false) })
335
+ expect(document.activeElement).toBe(triggerButton())
336
+ })
337
+
338
+ it('leaves focus alone when the user moved elsewhere during the switch', async () => {
339
+ const elsewhere = document.createElement('input')
340
+ document.body.appendChild(elsewhere)
341
+ await openAndPressOnly('team-c')
342
+ await act(async () => { elsewhere.focus() })
343
+
344
+ await act(async () => { setPending(false) })
345
+ expect(document.activeElement).toBe(elsewhere)
346
+ })
347
+
348
+ it('leaves focus alone when the user clicked elsewhere during the switch', async () => {
349
+ await openAndPressOnly('team-c')
350
+ await act(async () => { document.body.dispatchEvent(new Event('pointerdown', { bubbles: true })) })
351
+
352
+ await act(async () => { setPending(false) })
353
+ expect(document.activeElement).toBe(document.body)
354
+ })
355
+
356
+ it('does not claim focus for a later switch once the user has moved on', async () => {
357
+ const elsewhere = document.createElement('input')
358
+ document.body.appendChild(elsewhere)
359
+ await openAndPressOnly('team-c', false)
360
+ await act(async () => { elsewhere.focus() })
361
+ await act(async () => { elsewhere.blur() })
362
+
363
+ await startPending()
364
+ await act(async () => { setPending(false) })
365
+ expect(document.activeElement).toBe(document.body)
366
+ })
367
+
368
+ it('does not move focus for other ways of closing', async () => {
369
+ await open()
370
+ await act(async () => { doneButton().click() })
371
+ expect(document.activeElement).toBe(document.body)
372
+ })
373
+ })
374
+ })
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
3
3
  import { ChevronDown, Globe, Search } from 'lucide-react'
4
4
  import { Badge } from '../ui/Badge'
5
5
  import { Tooltip } from '../ui/Tooltip'
6
+ import { useKeepInViewport } from '../../hooks/useKeepInViewport'
6
7
  import { MultiSelectPicker } from '../ui/MultiSelectPicker'
7
8
 
8
9
  /**
@@ -82,7 +83,8 @@ export interface NamespacePickerProps {
82
83
  *
83
84
  * Selection model: the dropdown keeps a draft Set<string>; toggling rows
84
85
  * mutates the draft locally; closing the dropdown applies the draft in a single
85
- * onApply. "Clear all" applies immediately and closes.
86
+ * onApply. "Clear all" applies immediately and closes, as does a row's "Only"
87
+ * button, which switches to that namespace alone.
86
88
  */
87
89
  export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePickerProps>(function NamespacePicker(
88
90
  { scope, onApply, loading = false, pending = false, disabled = false, disabledTooltip, className = '', variant = 'chip', label, limitedListHelp },
@@ -95,6 +97,8 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
95
97
 
96
98
  const triggerRef = useRef<HTMLButtonElement>(null)
97
99
  const dropdownRef = useRef<HTMLDivElement>(null)
100
+ useKeepInViewport(dropdownRef, isOpen)
101
+ const [restoreFocus, setRestoreFocus] = useState(false)
98
102
 
99
103
  const scopeActives = useMemo(() => scope?.actives ?? [], [scope?.actives])
100
104
  const activesKey = useMemo(() => [...scopeActives].sort().join(','), [scopeActives])
@@ -149,12 +153,34 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
149
153
  applySelection(draft)
150
154
  }, [applySelection, draft])
151
155
 
152
- useImperativeHandle(ref, () => ({
153
- open: () => {
154
- if (disabled || loading || pending) return
155
- setIsOpen(true)
156
- },
157
- }), [disabled, loading, pending])
156
+ const openPicker = useCallback(() => {
157
+ if (isOpen || disabled || loading || pending) return
158
+ setDraft(new Set(scopeActives))
159
+ setIsOpen(true)
160
+ }, [isOpen, disabled, loading, pending, scopeActives])
161
+
162
+ useImperativeHandle(ref, () => ({ open: openPicker }), [openPicker])
163
+
164
+ // Disabling the trigger while a switch is pending drops its focus, so this stays armed through that cycle.
165
+ useEffect(() => {
166
+ if (!restoreFocus || isOpen || disabled || loading || pending) return
167
+ const active = document.activeElement
168
+ if (!active || active === document.body) triggerRef.current?.focus()
169
+ }, [restoreFocus, isOpen, disabled, loading, pending])
170
+
171
+ useEffect(() => {
172
+ if (!restoreFocus) return
173
+ const onFocusIn = (e: FocusEvent) => {
174
+ if (e.target !== triggerRef.current) setRestoreFocus(false)
175
+ }
176
+ const onPointerDown = () => setRestoreFocus(false)
177
+ document.addEventListener('focusin', onFocusIn)
178
+ document.addEventListener('pointerdown', onPointerDown)
179
+ return () => {
180
+ document.removeEventListener('focusin', onFocusIn)
181
+ document.removeEventListener('pointerdown', onPointerDown)
182
+ }
183
+ }, [restoreFocus])
158
184
 
159
185
  useEffect(() => {
160
186
  if (!isOpen) return
@@ -198,6 +224,15 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
198
224
  setDraft(next)
199
225
  }
200
226
 
227
+ const selectOnly = (ns: string) => {
228
+ const next = new Set([ns])
229
+ setRestoreFocus(true)
230
+ setDraft(next)
231
+ setIsOpen(false)
232
+ setSearch('')
233
+ applySelection(next)
234
+ }
235
+
201
236
  const clearAll = () => {
202
237
  if (scope.cacheScoped) return
203
238
  setDraft(new Set())
@@ -255,7 +290,7 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
255
290
  >
256
291
  <button
257
292
  ref={triggerRef}
258
- onClick={() => !isDisabled && (isOpen ? closeAndApply() : setIsOpen(true))}
293
+ onClick={() => !isDisabled && (isOpen ? closeAndApply() : openPicker())}
259
294
  disabled={isDisabled}
260
295
  className={
261
296
  variant === 'segment'
@@ -350,6 +385,7 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
350
385
  onSelectionChange={setDraft}
351
386
  onClearAll={clearAll}
352
387
  onDone={closeAndApply}
388
+ onSelectOnly={selectOnly}
353
389
  search={search}
354
390
  onSearchChange={setSearch}
355
391
  searchPlaceholder="Filter namespaces"
@@ -40,3 +40,31 @@ it('opens a matching View with Enter when search finds no kind', () => {
40
40
  act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
41
41
  expect(view).toHaveBeenCalledTimes(1)
42
42
  })
43
+ it('in a drawer, Escape in a non-empty kind filter clears it without reaching the dialog', () => {
44
+ const outside = vi.fn()
45
+ document.addEventListener('keydown', outside)
46
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
47
+ act(() => root.render(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} containEscape />))
48
+ const input = host.querySelector('input')!
49
+ const type = (v: string) => act(() => {
50
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, v)
51
+ input.dispatchEvent(new Event('input', { bubbles: true }))
52
+ })
53
+ const escape = () => act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })))
54
+ type('pod'); escape()
55
+ expect(input.value).toBe('')
56
+ expect(outside).not.toHaveBeenCalled()
57
+ // An empty filter lets Escape through, so the drawer still closes on it.
58
+ escape()
59
+ expect(outside).toHaveBeenCalledTimes(1)
60
+ document.removeEventListener('keydown', outside)
61
+ })
62
+ it('reads and reports display settings through the parent when it holds them', () => {
63
+ const onChange = vi.fn()
64
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
65
+ act(() => root.render(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} pinned={[{ name: 'pods', kind: 'Pod', group: '' }]} displayState={{ showEmptyKinds: false, favoritesExpanded: false }} onDisplayStateChange={onChange} />))
66
+ const favorites = [...host.querySelectorAll('button')].find((b) => b.textContent?.toLowerCase().includes('favorites'))!
67
+ expect(favorites.getAttribute('aria-expanded')).toBe('false')
68
+ act(() => favorites.click())
69
+ expect(onChange).toHaveBeenCalledWith({ showEmptyKinds: false, favoritesExpanded: true })
70
+ })
@@ -68,6 +68,7 @@ describe('ResourcesSidebar count visibility', () => {
68
68
  onSelectedKindChange={() => {}}
69
69
  apiResources={[horizontalPodAutoscaler]}
70
70
  resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }}
71
+ resourceCoverage={{ 'autoscaling/HorizontalPodAutoscaler': { status: 'complete', gaps: [], scope: { type: 'all_namespaces' }, read: { type: 'all_namespaces' } } }}
71
72
  />
72
73
  )
73
74
 
@@ -82,6 +83,7 @@ describe('ResourcesSidebar count visibility', () => {
82
83
  onSelectedKindChange={() => {}}
83
84
  apiResources={[horizontalPodAutoscaler]}
84
85
  resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }}
86
+ resourceCoverage={{ 'autoscaling/HorizontalPodAutoscaler': { status: 'complete', gaps: [], scope: { type: 'all_namespaces' }, read: { type: 'all_namespaces' } } }}
85
87
  />
86
88
  )
87
89
 
@@ -96,6 +98,7 @@ describe('ResourcesSidebar count visibility', () => {
96
98
  onSelectedKindChange={() => {}}
97
99
  apiResources={[horizontalPodAutoscaler]}
98
100
  resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }}
101
+ resourceCoverage={{ 'autoscaling/HorizontalPodAutoscaler': { status: 'complete', gaps: [], scope: { type: 'all_namespaces' }, read: { type: 'all_namespaces' } } }}
99
102
  pinned={[{ name: 'horizontalpodautoscalers', kind: 'HorizontalPodAutoscaler', group: 'autoscaling' }]}
100
103
  isPinned={(name, group) => name === 'horizontalpodautoscalers' && group === 'autoscaling'}
101
104
  />
@@ -197,6 +200,33 @@ describe('ResourcesSidebar category workspaces', () => {
197
200
  })
198
201
  })
199
202
 
203
+ describe('ResourcesSidebar counts from a Radar that predates coverage', () => {
204
+ it('hides an empty kind and shows plain counts, as before coverage', () => {
205
+ const hidden = renderToString(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={[horizontalPodAutoscaler]} resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }} resourceCoverage={{ 'autoscaling/HorizontalPodAutoscaler': { status: 'unknown' } }} />)
206
+ expect(hidden).not.toContain('HorizontalPodAutoscaler')
207
+ const shown = renderToString(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={[horizontalPodAutoscaler]} resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 23 }} />)
208
+ expect(shown).toContain('>23</')
209
+ expect(shown).not.toContain('≥')
210
+ })
211
+ })
212
+
213
+ it('sizes the count badge to fit a bounded count in the thousands', () => {
214
+ const html = renderToString(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={[horizontalPodAutoscaler]} resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 12345 }} resourceCoverage={{ 'autoscaling/HorizontalPodAutoscaler': { status: 'partial', gaps: [], scope: { type: 'all_namespaces' }, read: { type: 'namespaces', namespaces: { names: ['alpha'], count: 1 } } } }} />)
215
+ const label = `≥${(12345).toLocaleString()}`
216
+ const badge = html.slice(html.lastIndexOf('<span', html.indexOf(label)), html.indexOf(label))
217
+ const classes = badge.match(/class="([^"]*)"/)?.[1].split(/\s+/) ?? []
218
+ expect(classes).toContain('min-w-8')
219
+ expect(classes).not.toContain('w-8')
220
+ expect(classes).not.toContain('w-9')
221
+ })
222
+
223
+ it('keeps a partial zero visible rather than hiding the kind as empty', () => {
224
+ const html = renderToString(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={[horizontalPodAutoscaler]} resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }} resourceCoverage={{ 'autoscaling/HorizontalPodAutoscaler': { status: 'partial', gaps: [], scope: { type: 'all_namespaces' }, read: { type: 'namespaces', namespaces: { names: ['alpha'], count: 1 } } } }} />)
225
+ expect(html).toContain('HorizontalPodAutoscaler')
226
+ expect(html).toContain('–')
227
+ expect(html).not.toContain('≥0')
228
+ })
229
+
200
230
  describe('sidebarDestinationsMatching', () => {
201
231
  const views = ['Clusters', 'Backups', 'Declarations', 'Pooling', 'Operator'].map((label) => ({ id: label, label, onSelect: () => {} }))
202
232
  const resources = [{ group: 'postgresql.cnpg.io' }, { group: 'barmancloud.cnpg.io' }]
@@ -215,3 +245,22 @@ describe('sidebarDestinationsMatching', () => {
215
245
  expect(labels(' ')).toHaveLength(5)
216
246
  })
217
247
  })
248
+
249
+ it('qualifies the collapsed category total as well as the individual kind', () => {
250
+ const widget = { name: 'widgets', kind: 'Widget', group: 'read.example.test', version: 'v1', namespaced: true, isCrd: true, verbs: ['get', 'list', 'watch'] }
251
+ const html = renderToString(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={[widget]} resourceCounts={{ 'read.example.test/Widget': 3 }} resourceCoverage={{ 'read.example.test/Widget': { status: 'partial', gaps: [], scope: { type: 'all_namespaces' }, read: { type: 'namespaces', namespaces: { names: ['alpha'], count: 1 } } } }} />)
252
+ const start = html.indexOf('>Test Read Example</span>')
253
+ expect(start).toBeGreaterThan(-1)
254
+ expect(html.slice(start, html.indexOf('</button>', start))).toContain('≥3')
255
+ })
256
+
257
+ it('bounds a category total over kinds Radar collects in only some namespaces', () => {
258
+ const widget = { name: 'widgets', kind: 'Widget', group: 'read.example.test', version: 'v1', namespaced: true, isCrd: true, verbs: ['get', 'list', 'watch'] }
259
+ const gadget = { ...widget, name: 'gadgets', kind: 'Gadget' }
260
+ const collected = (ns: string) => ({ status: 'complete' as const, narrowedBy: ['namespace_collection'], gaps: [], scope: { type: 'namespaces' as const, namespaces: { names: [ns], count: 1 } }, read: { type: 'namespaces' as const, namespaces: { names: [ns], count: 1 } } })
261
+ const html = renderToString(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={[widget, gadget]} resourceCounts={{ 'read.example.test/Widget': 3, 'read.example.test/Gadget': 2 }} resourceCoverage={{ 'read.example.test/Widget': collected('alpha'), 'read.example.test/Gadget': collected('beta') }} />)
262
+ const start = html.indexOf('>Test Read Example</span>')
263
+ expect(start).toBeGreaterThan(-1)
264
+ // Each kind is exact where Radar collects it; their sum is exact for no scope.
265
+ expect(html.slice(start, html.indexOf('</button>', start))).toContain('≥5')
266
+ })