@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.
- package/README.md +13 -0
- package/package.json +1 -1
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +33 -4
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +14 -9
- package/src/components/logs/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +36 -21
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +106 -19
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/shared/DetailShell.tsx +9 -7
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +24 -4
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +9 -3
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +46 -31
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +6 -4
- package/src/components/ui/Toast.tsx +33 -26
- package/src/components/ui/drawer-components.test.tsx +253 -0
- package/src/components/ui/drawer-components.tsx +315 -11
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +54 -6
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +66 -2
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +22 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +56 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- 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=
|
|
69
|
+
podName={podName}
|
|
70
70
|
containers={containers}
|
|
71
71
|
fetchLogs={fetchLogs}
|
|
72
|
-
createStream={() => new EventSource(
|
|
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
|
-
|
|
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={
|
|
228
|
-
<label className={`flex items-center gap-1.5 text-xs ${palette.textSecondary} ${
|
|
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={
|
|
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={
|
|
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={
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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() :
|
|
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"
|