@skyhook-io/k8s-ui 1.18.0 → 1.18.1

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 (63) hide show
  1. package/README.md +13 -0
  2. package/package.json +1 -1
  3. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  4. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  6. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  7. package/src/components/gitops/GitOpsTableView.tsx +33 -4
  8. package/src/components/gitops/detail-helpers.test.ts +58 -13
  9. package/src/components/gitops/detail-helpers.ts +37 -6
  10. package/src/components/gitops/index.ts +1 -0
  11. package/src/components/issues/IssuesView.tsx +14 -9
  12. package/src/components/logs/LogsViewer.test.tsx +104 -59
  13. package/src/components/logs/LogsViewer.tsx +28 -8
  14. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
  16. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  17. package/src/components/resources/ResourcesSidebar.tsx +36 -21
  18. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  19. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  20. package/src/components/resources/ResourcesView.tsx +106 -19
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  23. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  24. package/src/components/resources/resource-utils-trivy.ts +122 -0
  25. package/src/components/shared/DetailShell.tsx +9 -7
  26. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  27. package/src/components/shared/ManagedByChip.tsx +24 -4
  28. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  29. package/src/components/shared/ResourceActionsBar.tsx +9 -3
  30. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  31. package/src/components/shared/ResourceRendererDispatch.tsx +46 -31
  32. package/src/components/timeline/TimelineList.tsx +5 -1
  33. package/src/components/ui/FetchResult.test.tsx +19 -2
  34. package/src/components/ui/FetchResult.tsx +13 -1
  35. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  36. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  37. package/src/components/ui/PageHeader.tsx +6 -4
  38. package/src/components/ui/Toast.tsx +33 -26
  39. package/src/components/ui/drawer-components.test.tsx +253 -0
  40. package/src/components/ui/drawer-components.tsx +315 -11
  41. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  42. package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
  43. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  44. package/src/components/workload/WorkloadView.tsx +54 -6
  45. package/src/hooks/index.ts +2 -0
  46. package/src/hooks/useMediaQuery.ts +21 -0
  47. package/src/hooks/usePhoneLayout.ts +12 -0
  48. package/src/theme/tailwind-theme.css +5 -0
  49. package/src/types/core.ts +66 -2
  50. package/src/types/gitops-flux-inventory.test.ts +24 -0
  51. package/src/types/gitops.ts +5 -5
  52. package/src/types/index.ts +3 -0
  53. package/src/types/read-coverage.ts +65 -0
  54. package/src/types/resource-coverage.ts +40 -0
  55. package/src/utils/format.test.ts +7 -0
  56. package/src/utils/format.ts +6 -1
  57. package/src/utils/gitops-owner.test.ts +42 -1
  58. package/src/utils/gitops-owner.ts +22 -0
  59. package/src/utils/index.ts +2 -0
  60. package/src/utils/read-actions.test.ts +22 -0
  61. package/src/utils/read-actions.ts +56 -0
  62. package/src/utils/resource-coverage.test.ts +121 -0
  63. package/src/utils/resource-coverage.ts +117 -0
@@ -60,16 +60,16 @@ afterEach(async () => {
60
60
  vi.unstubAllGlobals()
61
61
  })
62
62
 
63
- async function renderViewer(fetchLogs: (params: LogsFetchParams) => Promise<{ [container: string]: string }>, autoStream = false, containers = ['app']) {
63
+ async function renderViewer(fetchLogs: (params: LogsFetchParams) => Promise<{ [container: string]: string }>, autoStream = false, containers = ['app'], podName = 'web-0') {
64
64
  await act(async () =>
65
65
  root.render(
66
66
  <ToastProvider>
67
67
  <LogsViewer
68
68
  namespace="default"
69
- podName="web-0"
69
+ podName={podName}
70
70
  containers={containers}
71
71
  fetchLogs={fetchLogs}
72
- createStream={() => new EventSource('/stream')}
72
+ createStream={() => new EventSource(`/pods/default/${podName}/logs/stream`)}
73
73
  autoStream={autoStream}
74
74
  />
75
75
  </ToastProvider>,
@@ -234,62 +234,6 @@ it('with auto-stream on, a time range picked after the stream ended loads it ins
234
234
  expect(fetchLogs).toHaveBeenLastCalledWith(expect.objectContaining({ previous: true, sinceSeconds: expect.any(Number) }))
235
235
  })
236
236
 
237
- it('with auto-stream on, a range picked while the stream opens reopens it with that range', async () => {
238
- const fetchLogs = vi.fn(async () => ({ app: '' }))
239
- await renderViewer(fetchLogs, true)
240
- const opening = FakeEventSource.last
241
-
242
- const range = element.querySelector<HTMLSelectElement>('select')
243
- if (!range) throw new Error('range select not rendered')
244
- await act(async () => {
245
- range.value = range.options[range.options.length - 1].value
246
- range.dispatchEvent(new Event('change', { bubbles: true }))
247
- })
248
-
249
- expect(opening?.closed).toBe(true)
250
- expect(FakeEventSource.last).not.toBe(opening)
251
- expect(fetchLogs).not.toHaveBeenCalled()
252
- })
253
-
254
- it('with auto-stream on, after the stream ended and was started again, a range picked while it opens reopens it', async () => {
255
- const fetchLogs = vi.fn(async () => ({ app: '' }))
256
- await renderViewer(fetchLogs, true)
257
- await act(async () => FakeEventSource.last?.emit('connected', {}))
258
- await act(async () => FakeEventSource.last?.emit('end', {}))
259
- const range = element.querySelector<HTMLSelectElement>('select')
260
- if (!range) throw new Error('range select not rendered')
261
- const pick = (index: number) => act(async () => {
262
- range.value = range.options[index].value
263
- range.dispatchEvent(new Event('change', { bubbles: true }))
264
- })
265
- await pick(range.options.length - 1)
266
- await act(async () => button('Stream').click())
267
- const opening = FakeEventSource.last
268
-
269
- await pick(range.options.length - 2)
270
-
271
- expect(opening?.closed).toBe(true)
272
- expect(FakeEventSource.last).not.toBe(opening)
273
- })
274
-
275
- it('a range picked while a manually started stream opens does not cancel it', async () => {
276
- const fetchLogs = vi.fn(async () => ({ app: '' }))
277
- await renderViewer(fetchLogs, true)
278
- await act(async () => FakeEventSource.last?.emit('connected', {}))
279
- await act(async () => button('Stop').click())
280
- await act(async () => button('Stream').click())
281
- const opening = FakeEventSource.last
282
- const range = element.querySelector<HTMLSelectElement>('select')
283
- if (!range) throw new Error('range select not rendered')
284
-
285
- await act(async () => {
286
- range.value = range.options[range.options.length - 1].value
287
- range.dispatchEvent(new Event('change', { bubbles: true }))
288
- })
289
-
290
- expect(opening?.closed).toBe(false)
291
- })
292
-
293
237
  it('with auto-stream on, the open stream is not replaced by a snapshot', async () => {
294
238
  const fetchLogs = vi.fn(async () => ({ app: '' }))
295
239
  await renderViewer(fetchLogs, true)
@@ -367,3 +311,104 @@ it('still shows a Radar failure as an error', async () => {
367
311
  await renderViewer(async () => { throw new ApiError(failure, { error: failure }) })
368
312
  expect(messageClass(failure)).toMatch(/text-red/)
369
313
  })
314
+
315
+ function rangeSelect(): HTMLSelectElement {
316
+ const range = element.querySelector<HTMLSelectElement>('select')
317
+ if (!range) throw new Error('range select not rendered')
318
+ return range
319
+ }
320
+
321
+ const nextFrame = () => act(async () => { await new Promise(resolve => requestAnimationFrame(() => resolve(null))) })
322
+
323
+ it('opening another pod with the same container name streams it, even after the first pod\'s stream ended', async () => {
324
+ const fetchLogs = vi.fn(async () => ({ app: '' }))
325
+ await renderViewer(fetchLogs, true, ['app'], 'web-0')
326
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
327
+ await act(async () => FakeEventSource.last?.emit('end', {}))
328
+ const ended = FakeEventSource.last
329
+
330
+ await renderViewer(fetchLogs, true, ['app'], 'web-1')
331
+
332
+ expect(FakeEventSource.last).not.toBe(ended)
333
+ expect(FakeEventSource.last?.url).toBe('/pods/default/web-1/logs/stream')
334
+ expect(fetchLogs).not.toHaveBeenCalled()
335
+ })
336
+
337
+ it('a manual Stop on one pod does not stop the next pod from streaming', async () => {
338
+ const fetchLogs = vi.fn(async () => ({ app: '' }))
339
+ await renderViewer(fetchLogs, true, ['app'], 'web-0')
340
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
341
+ await act(async () => button('Stop').click())
342
+
343
+ await renderViewer(fetchLogs, true, ['app'], 'web-1')
344
+
345
+ expect(FakeEventSource.last?.url).toBe('/pods/default/web-1/logs/stream')
346
+ })
347
+
348
+ it('Previous and the range are locked while an auto-stream opens', async () => {
349
+ await renderViewer(async () => ({ app: '' }), true)
350
+ expect(previousCheckbox().disabled).toBe(true)
351
+ expect(rangeSelect().disabled).toBe(true)
352
+
353
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
354
+ await act(async () => FakeEventSource.last?.emit('end', {}))
355
+ expect(previousCheckbox().disabled).toBe(false)
356
+ expect(rangeSelect().disabled).toBe(false)
357
+ })
358
+
359
+ it('Previous and the range are locked while a manually started stream opens', async () => {
360
+ await renderViewer(async () => ({ app: '' }))
361
+ expect(previousCheckbox().disabled).toBe(false)
362
+
363
+ await act(async () => button('Stream').click())
364
+
365
+ expect(previousCheckbox().disabled).toBe(true)
366
+ expect(rangeSelect().disabled).toBe(true)
367
+ })
368
+
369
+ it('a stream that is still opening can be stopped, which unlocks Previous', async () => {
370
+ await renderViewer(async () => ({ app: '' }))
371
+ await act(async () => button('Stream').click())
372
+ const opening = FakeEventSource.last
373
+
374
+ await act(async () => button('Stop').click())
375
+
376
+ expect(opening?.closed).toBe(true)
377
+ expect(previousCheckbox().disabled).toBe(false)
378
+ expect(button('Stream')).toBeTruthy()
379
+ })
380
+
381
+ it('a stream failure after lines arrived is shown above them', async () => {
382
+ await renderViewer(async () => ({ app: '' }))
383
+ await act(async () => button('Stream').click())
384
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
385
+ await act(async () => FakeEventSource.last?.emit('log', { timestamp: '2026-10-14T10:00:00Z', content: 'live line', container: 'app' }))
386
+ await nextFrame()
387
+ await act(async () => FakeEventSource.last?.emit('error', { error: 'Read error: unexpected EOF' }))
388
+
389
+ expect(element.querySelector('[role="alert"]')?.textContent).toBe('Read error: unexpected EOF · Previously loaded logs remain below.')
390
+ expect(element.textContent).toContain('OTHER 1')
391
+ })
392
+
393
+ const NOT_STARTED = 'This container has not started yet, so it has no logs. Kubernetes reports ContainerCreating.'
394
+
395
+ it('shows a container that has not started in the empty-pane style', async () => {
396
+ await renderViewer(async () => { throw new ApiError(NOT_STARTED, { error: NOT_STARTED, error_code: 'container_not_started' }) })
397
+ expect(messageClass(NOT_STARTED)).not.toMatch(/text-red/)
398
+ })
399
+
400
+ it('shows what the live stream is waiting for, and drops it once lines arrive', async () => {
401
+ const waiting = `${NOT_STARTED} Logs will show here once it starts.`
402
+ await renderViewer(async () => ({ app: '' }), true)
403
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
404
+ await act(async () => FakeEventSource.last?.emit('notice', { notice: waiting, notice_code: 'container_not_started' }))
405
+ expect(messageClass(waiting)).not.toMatch(/text-red/)
406
+ expect(button('Stop')).toBeTruthy()
407
+
408
+ await act(async () => FakeEventSource.last?.emit('notice', { notice: '' }))
409
+ await act(async () => FakeEventSource.last?.emit('log', { timestamp: '2026-10-14T10:00:00Z', content: 'started', container: 'app' }))
410
+ await nextFrame()
411
+
412
+ expect(element.textContent).not.toContain(waiting)
413
+ expect(element.textContent).toContain('OTHER 1')
414
+ })
@@ -43,7 +43,7 @@ export interface LogsViewerProps {
43
43
  // Kubernetes' answers that there are no logs to show, such as "no earlier run".
44
44
  // They describe the pod, not a failure in Radar, so they are shown like an
45
45
  // empty pane rather than as an error.
46
- const CLUSTER_ANSWER_CODES = new Set(['no_previous_run', 'logs_unavailable'])
46
+ const CLUSTER_ANSWER_CODES = new Set(['no_previous_run', 'logs_unavailable', 'container_not_started'])
47
47
 
48
48
  function isClusterAnswerCode(code: unknown): boolean {
49
49
  return typeof code === 'string' && CLUSTER_ANSWER_CODES.has(code)
@@ -56,7 +56,14 @@ function clusterAnswerOf(err: unknown): string | null {
56
56
  return isClusterAnswerCode(data.error_code) ? err.message : null
57
57
  }
58
58
 
59
- export function LogsViewer({
59
+ // Keyed by pod: everything the viewer remembers (the container, Previous, the
60
+ // auto-start latch, a manual Stop) belongs to one pod, and a host that swaps
61
+ // the pod without remounting would carry it over to the next one.
62
+ export function LogsViewer(props: LogsViewerProps) {
63
+ return <PodLogsViewer key={`${props.namespace}/${props.podName}`} {...props} />
64
+ }
65
+
66
+ function PodLogsViewer({
60
67
  namespace: _namespace,
61
68
  podName,
62
69
  containers,
@@ -74,6 +81,8 @@ export function LogsViewer({
74
81
  const [emptyMessage, setEmptyMessage] = useState<string | null>(null)
75
82
  const [logRange, setLogRange] = useState('500')
76
83
  const [showPrevious, setShowPrevious] = useState(false)
84
+ // What an open stream says it is waiting for, e.g. a container to start.
85
+ const [streamNotice, setStreamNotice] = useState<string | null>(null)
77
86
  const { showError, showSuccess } = useToast()
78
87
 
79
88
  const { tailLines, sinceSeconds } = parseLogRange(logRange)
@@ -109,6 +118,7 @@ export function LogsViewer({
109
118
  setIsLoading(true)
110
119
  setFetchError(null)
111
120
  setEmptyMessage(null)
121
+ setStreamNotice(null)
112
122
  try {
113
123
  const data = await fetchLogs({ container: selectedContainer, tailLines, sinceSeconds, previous: showPrevious })
114
124
  if (seq !== loadSeqRef.current) return
@@ -171,6 +181,7 @@ export function LogsViewer({
171
181
  setIsLoading(false)
172
182
  setFetchError(null)
173
183
  setEmptyMessage(null)
184
+ setStreamNotice(null)
174
185
  // The stream replays the last N lines (TailLines + Follow); clear first so
175
186
  // they don't duplicate lines already in the buffer (the snapshot on the
176
187
  // manual path, or an earlier stream on restart).
@@ -184,6 +195,10 @@ export function LogsViewer({
184
195
  content: data.content || '',
185
196
  container: data.container || selectedContainer,
186
197
  }),
198
+ onNotice: (data: unknown) => {
199
+ const notice = typeof data === 'object' && data !== null && 'notice' in data && typeof data.notice === 'string' ? data.notice : ''
200
+ setStreamNotice(notice || null)
201
+ },
187
202
  },
188
203
  'Log stream connection failed',
189
204
  )
@@ -191,6 +206,7 @@ export function LogsViewer({
191
206
 
192
207
  const handleStopStreaming = useCallback(() => {
193
208
  userStoppedRef.current = true
209
+ setStreamNotice(null)
194
210
  stopStreaming()
195
211
  }, [stopStreaming])
196
212
 
@@ -220,24 +236,28 @@ export function LogsViewer({
220
236
  }
221
237
  }, [podName, selectedContainer, overrideDownload, showError, showSuccess])
222
238
 
239
+ // Previous and the range describe what a snapshot loads, so they stay
240
+ // locked from the moment a stream starts opening, not only once it is open.
241
+ const streamBusy = isStreaming || connecting
242
+
223
243
  const renderToolbarExtra = ({ isDark, palette }: { isDark: boolean; palette: LogPalette }) => (
224
244
  <>
225
245
  <ContainerSelect containers={containers} value={selectedContainer} onChange={setSelectedContainer} isDark={isDark} />
226
246
 
227
- <Tooltip content={isStreaming ? 'Stop streaming to view the previous instance' : "Show logs from the pod's previous instance (if it was restarted). Useful for troubleshooting crashed containers."} position="bottom">
228
- <label className={`flex items-center gap-1.5 text-xs ${palette.textSecondary} ${isStreaming ? 'opacity-50 cursor-not-allowed' : ''}`}>
247
+ <Tooltip content={streamBusy ? 'Stop streaming to view the previous instance' : "Show logs from the pod's previous instance (if it was restarted). Useful for troubleshooting crashed containers."} position="bottom">
248
+ <label className={`flex items-center gap-1.5 text-xs ${palette.textSecondary} ${streamBusy ? 'opacity-50 cursor-not-allowed' : ''}`}>
229
249
  <input
230
250
  type="checkbox"
231
251
  checked={showPrevious}
232
252
  onChange={(e) => setShowPrevious(e.target.checked)}
233
- disabled={isStreaming}
253
+ disabled={streamBusy}
234
254
  className={`w-3 h-3 rounded ${palette.borderLight} ${palette.elevatedBg} text-blue-500 focus:ring-blue-500 focus:ring-offset-0`}
235
255
  />
236
256
  <span className={`border-b border-dotted ${isDark ? 'border-slate-500' : 'border-slate-400'}`}>Previous</span>
237
257
  </label>
238
258
  </Tooltip>
239
259
 
240
- <LogRangeSelect value={logRange} onChange={setLogRange} isDark={isDark} disabled={isStreaming} />
260
+ <LogRangeSelect value={logRange} onChange={setLogRange} isDark={isDark} disabled={streamBusy} />
241
261
  </>
242
262
  )
243
263
 
@@ -260,13 +280,13 @@ export function LogsViewer({
260
280
  getRetainedEntries={getRetainedEntries}
261
281
  isLoading={isLoading || isConnecting}
262
282
  errorMessage={entries.length === 0 ? fetchError || (!emptyMessage && !streamAnswer ? streamError : null) : null}
263
- isStreaming={isStreaming}
283
+ isStreaming={streamBusy}
264
284
  onStartStream={createStream ? handleStartStreaming : undefined}
265
285
  onStopStream={handleStopStreaming}
266
286
  onRefresh={loadLogs}
267
287
  onDownload={downloadLogs}
268
288
  onClear={clear}
269
- emptyMessage={emptyMessage ?? streamAnswer ?? undefined}
289
+ emptyMessage={emptyMessage ?? streamAnswer ?? streamNotice ?? undefined}
270
290
  toolbarExtra={renderToolbarExtra}
271
291
  forceDark={forceDark}
272
292
  defaultDark={defaultDark}
@@ -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
+ })
@@ -82,7 +82,8 @@ export interface NamespacePickerProps {
82
82
  *
83
83
  * Selection model: the dropdown keeps a draft Set<string>; toggling rows
84
84
  * mutates the draft locally; closing the dropdown applies the draft in a single
85
- * onApply. "Clear all" applies immediately and closes.
85
+ * onApply. "Clear all" applies immediately and closes, as does a row's "Only"
86
+ * button, which switches to that namespace alone.
86
87
  */
87
88
  export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePickerProps>(function NamespacePicker(
88
89
  { scope, onApply, loading = false, pending = false, disabled = false, disabledTooltip, className = '', variant = 'chip', label, limitedListHelp },
@@ -95,6 +96,7 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
95
96
 
96
97
  const triggerRef = useRef<HTMLButtonElement>(null)
97
98
  const dropdownRef = useRef<HTMLDivElement>(null)
99
+ const [restoreFocus, setRestoreFocus] = useState(false)
98
100
 
99
101
  const scopeActives = useMemo(() => scope?.actives ?? [], [scope?.actives])
100
102
  const activesKey = useMemo(() => [...scopeActives].sort().join(','), [scopeActives])
@@ -149,12 +151,34 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
149
151
  applySelection(draft)
150
152
  }, [applySelection, draft])
151
153
 
152
- useImperativeHandle(ref, () => ({
153
- open: () => {
154
- if (disabled || loading || pending) return
155
- setIsOpen(true)
156
- },
157
- }), [disabled, loading, pending])
154
+ const openPicker = useCallback(() => {
155
+ if (isOpen || disabled || loading || pending) return
156
+ setDraft(new Set(scopeActives))
157
+ setIsOpen(true)
158
+ }, [isOpen, disabled, loading, pending, scopeActives])
159
+
160
+ useImperativeHandle(ref, () => ({ open: openPicker }), [openPicker])
161
+
162
+ // Disabling the trigger while a switch is pending drops its focus, so this stays armed through that cycle.
163
+ useEffect(() => {
164
+ if (!restoreFocus || isOpen || disabled || loading || pending) return
165
+ const active = document.activeElement
166
+ if (!active || active === document.body) triggerRef.current?.focus()
167
+ }, [restoreFocus, isOpen, disabled, loading, pending])
168
+
169
+ useEffect(() => {
170
+ if (!restoreFocus) return
171
+ const onFocusIn = (e: FocusEvent) => {
172
+ if (e.target !== triggerRef.current) setRestoreFocus(false)
173
+ }
174
+ const onPointerDown = () => setRestoreFocus(false)
175
+ document.addEventListener('focusin', onFocusIn)
176
+ document.addEventListener('pointerdown', onPointerDown)
177
+ return () => {
178
+ document.removeEventListener('focusin', onFocusIn)
179
+ document.removeEventListener('pointerdown', onPointerDown)
180
+ }
181
+ }, [restoreFocus])
158
182
 
159
183
  useEffect(() => {
160
184
  if (!isOpen) return
@@ -198,6 +222,15 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
198
222
  setDraft(next)
199
223
  }
200
224
 
225
+ const selectOnly = (ns: string) => {
226
+ const next = new Set([ns])
227
+ setRestoreFocus(true)
228
+ setDraft(next)
229
+ setIsOpen(false)
230
+ setSearch('')
231
+ applySelection(next)
232
+ }
233
+
201
234
  const clearAll = () => {
202
235
  if (scope.cacheScoped) return
203
236
  setDraft(new Set())
@@ -255,7 +288,7 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
255
288
  >
256
289
  <button
257
290
  ref={triggerRef}
258
- onClick={() => !isDisabled && (isOpen ? closeAndApply() : setIsOpen(true))}
291
+ onClick={() => !isDisabled && (isOpen ? closeAndApply() : openPicker())}
259
292
  disabled={isDisabled}
260
293
  className={
261
294
  variant === 'segment'
@@ -350,6 +383,7 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
350
383
  onSelectionChange={setDraft}
351
384
  onClearAll={clearAll}
352
385
  onDone={closeAndApply}
386
+ onSelectOnly={selectOnly}
353
387
  search={search}
354
388
  onSearchChange={setSearch}
355
389
  searchPlaceholder="Filter namespaces"