@skyhook-io/k8s-ui 1.17.1 → 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 +4 -4
- 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/GitOpsGraphFilterRail.tsx +2 -2
- 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/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
- package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
- package/src/components/gitops/insights/insights-helpers.ts +7 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
- package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
- package/src/components/gitops/tree/tree-helpers.ts +5 -1
- package/src/components/issues/IssuesView.tsx +18 -10
- package/src/components/issues/ResourceIssuesSection.tsx +5 -2
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +26 -0
- package/src/components/issues/severity.ts +9 -2
- package/src/components/issues/types.ts +32 -4
- package/src/components/logs/LogCore.theme.test.tsx +126 -1
- package/src/components/logs/LogCore.tsx +159 -40
- package/src/components/logs/LogsViewer.test.tsx +183 -60
- package/src/components/logs/LogsViewer.tsx +49 -12
- package/src/components/logs/StructuredLogLine.tsx +1 -1
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
- package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
- package/src/components/logs/useLogBuffer.ts +48 -18
- package/src/components/logs/useLogSearch.ts +3 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
- package/src/components/resources/ColumnPickerList.test.tsx +46 -0
- package/src/components/resources/ColumnPickerList.tsx +174 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +36 -21
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
- 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 +198 -93
- package/src/components/resources/column-order.test.ts +104 -0
- package/src/components/resources/column-order.ts +64 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
- package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
- package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +72 -20
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/resources/resource-utils.ts +15 -2
- package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
- package/src/components/shared/DetailShell.tsx +9 -7
- package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
- package/src/components/shared/DrainPlanDialog.tsx +6 -2
- 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 +12 -6
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +48 -31
- package/src/components/timeline/DiffViewer.test.tsx +69 -0
- package/src/components/timeline/DiffViewer.tsx +113 -4
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/timeline/shared.test.tsx +48 -0
- package/src/components/timeline/shared.tsx +14 -19
- 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 +48 -30
- 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/ui/index.ts +1 -1
- 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 +68 -14
- 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 +91 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops-insights.ts +1 -0
- package/src/types/gitops-tree.ts +2 -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
|
@@ -35,6 +35,12 @@ type RawLogEntry = Omit<LogEntry, 'id' | 'level' | 'levelSource' | 'isJson' | 'i
|
|
|
35
35
|
|
|
36
36
|
interface UseLogBufferReturn {
|
|
37
37
|
entries: LogEntry[]
|
|
38
|
+
isFollowing: boolean
|
|
39
|
+
retainedLineCount: number
|
|
40
|
+
missingLineCount: number
|
|
41
|
+
getRetainedEntries: () => LogEntry[]
|
|
42
|
+
pendingLineCount: number
|
|
43
|
+
setFollowing: (following: boolean) => void
|
|
38
44
|
append: (entry: RawLogEntry) => void
|
|
39
45
|
appendBatch: (entries: RawLogEntry[]) => void
|
|
40
46
|
set: (entries: RawLogEntry[]) => void
|
|
@@ -42,7 +48,7 @@ interface UseLogBufferReturn {
|
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
export function useLogBuffer(): UseLogBufferReturn {
|
|
45
|
-
const [
|
|
51
|
+
const [buffer, setBuffer] = useState<{ latest: LogEntry[]; reading: LogEntry[] | null }>({ latest: [], reading: null })
|
|
46
52
|
const idCounter = useRef(0)
|
|
47
53
|
const pendingRef = useRef<RawLogEntry[]>([])
|
|
48
54
|
const rafRef = useRef<number | null>(null)
|
|
@@ -65,30 +71,35 @@ export function useLogBuffer(): UseLogBufferReturn {
|
|
|
65
71
|
const batch = pendingRef.current
|
|
66
72
|
if (batch.length === 0) return
|
|
67
73
|
pendingRef.current = []
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const combined = [...prev, ...enriched]
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
const enriched = batch.map(enrichEntry)
|
|
75
|
+
|
|
76
|
+
setBuffer(prev => {
|
|
77
|
+
const combined = [...prev.latest, ...enriched]
|
|
78
|
+
const latest = combined.length > MAX_BUFFER_SIZE ? combined.slice(-MAX_BUFFER_SIZE) : combined
|
|
79
|
+
// Keep appending while the reader's window fits. Once eviction begins,
|
|
80
|
+
// retain that window rather than replacing the lines being read.
|
|
81
|
+
const reading = prev.reading === null ? null
|
|
82
|
+
: prev.reading === prev.latest && combined.length <= MAX_BUFFER_SIZE ? latest : prev.reading
|
|
83
|
+
return { latest, reading }
|
|
76
84
|
})
|
|
77
85
|
}, [enrichEntry])
|
|
78
86
|
|
|
79
87
|
const append = useCallback((entry: RawLogEntry) => {
|
|
80
88
|
pendingRef.current.push(entry)
|
|
89
|
+
// Hidden tabs can stop delivering animation frames while logs still arrive.
|
|
90
|
+
if (pendingRef.current.length >= MAX_BUFFER_SIZE) {
|
|
91
|
+
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
|
|
92
|
+
flushPending()
|
|
93
|
+
return
|
|
94
|
+
}
|
|
81
95
|
if (rafRef.current === null) {
|
|
82
96
|
rafRef.current = requestAnimationFrame(flushPending)
|
|
83
97
|
}
|
|
84
98
|
}, [flushPending])
|
|
85
99
|
|
|
86
100
|
const appendBatch = useCallback((batch: RawLogEntry[]) => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
rafRef.current = requestAnimationFrame(flushPending)
|
|
90
|
-
}
|
|
91
|
-
}, [flushPending])
|
|
101
|
+
for (const entry of batch) append(entry)
|
|
102
|
+
}, [append])
|
|
92
103
|
|
|
93
104
|
const set = useCallback((rawEntries: RawLogEntry[]) => {
|
|
94
105
|
// Cancel any pending RAF
|
|
@@ -100,9 +111,9 @@ export function useLogBuffer(): UseLogBufferReturn {
|
|
|
100
111
|
|
|
101
112
|
const enriched = rawEntries.map(enrichEntry)
|
|
102
113
|
if (enriched.length > MAX_BUFFER_SIZE) {
|
|
103
|
-
|
|
114
|
+
setBuffer({ latest: enriched.slice(enriched.length - MAX_BUFFER_SIZE), reading: null })
|
|
104
115
|
} else {
|
|
105
|
-
|
|
116
|
+
setBuffer({ latest: enriched, reading: null })
|
|
106
117
|
}
|
|
107
118
|
}, [enrichEntry])
|
|
108
119
|
|
|
@@ -112,8 +123,27 @@ export function useLogBuffer(): UseLogBufferReturn {
|
|
|
112
123
|
rafRef.current = null
|
|
113
124
|
}
|
|
114
125
|
pendingRef.current = []
|
|
115
|
-
|
|
126
|
+
setBuffer({ latest: [], reading: null })
|
|
127
|
+
}, [])
|
|
128
|
+
|
|
129
|
+
const setFollowing = useCallback((following: boolean) => {
|
|
130
|
+
setBuffer(prev => {
|
|
131
|
+
if (following) return prev.reading === null ? prev : { ...prev, reading: null }
|
|
132
|
+
return prev.reading === null ? { ...prev, reading: prev.latest } : prev
|
|
133
|
+
})
|
|
116
134
|
}, [])
|
|
117
135
|
|
|
118
|
-
|
|
136
|
+
const entries = buffer.reading ?? buffer.latest
|
|
137
|
+
const pendingLineCount = (buffer.latest.at(-1)?.id ?? -1) - (entries.at(-1)?.id ?? -1)
|
|
138
|
+
const readingLastId = entries.at(-1)?.id ?? -1
|
|
139
|
+
const latestFirstId = buffer.latest[0]?.id ?? 0
|
|
140
|
+
const overlap = Math.max(0, readingLastId - latestFirstId + 1)
|
|
141
|
+
const retainedLineCount = buffer.reading === null ? buffer.latest.length : entries.length + buffer.latest.length - overlap
|
|
142
|
+
const missingLineCount = buffer.reading === null ? 0 : Math.max(0, latestFirstId - readingLastId - 1)
|
|
143
|
+
const getRetainedEntries = useCallback(() => {
|
|
144
|
+
if (buffer.reading === null || buffer.reading === buffer.latest) return buffer.latest
|
|
145
|
+
const lastId = buffer.reading.at(-1)?.id ?? -1
|
|
146
|
+
return [...buffer.reading, ...buffer.latest.filter(entry => entry.id > lastId)]
|
|
147
|
+
}, [buffer])
|
|
148
|
+
return { entries, retainedLineCount, missingLineCount, getRetainedEntries, isFollowing: buffer.reading === null, pendingLineCount, setFollowing, append, appendBatch, set, clear }
|
|
119
149
|
}
|
|
@@ -54,6 +54,7 @@ export function useLogSearch(
|
|
|
54
54
|
virtuosoRef: React.RefObject<VirtuosoHandle | null>,
|
|
55
55
|
/** Id of the record an entry belongs to (its first line's id); defaults to the entry itself */
|
|
56
56
|
recordIdOf?: (entry: LogEntry) => number,
|
|
57
|
+
onNavigate?: () => void,
|
|
57
58
|
): UseLogSearchReturn {
|
|
58
59
|
const [query, setQuery] = useState('')
|
|
59
60
|
const [isRegex, setIsRegex] = useState(false)
|
|
@@ -122,6 +123,7 @@ export function useLogSearch(
|
|
|
122
123
|
const scrollToMatch = useCallback((matchIdx: number) => {
|
|
123
124
|
if (matchIdx < 0 || matchIdx >= matchIndices.length) return
|
|
124
125
|
if (mode === 'hide') return
|
|
126
|
+
onNavigate?.()
|
|
125
127
|
if (mode === 'only') {
|
|
126
128
|
// The list holds whole matching records, so find the matched line within it
|
|
127
129
|
const index = filteredIndexById?.get(entries[matchIndices[matchIdx]].id)
|
|
@@ -139,7 +141,7 @@ export function useLogSearch(
|
|
|
139
141
|
behavior: 'smooth',
|
|
140
142
|
})
|
|
141
143
|
}
|
|
142
|
-
}, [entries, matchIndices, mode, filteredIndexById, virtuosoRef])
|
|
144
|
+
}, [entries, matchIndices, mode, filteredIndexById, virtuosoRef, onNavigate])
|
|
143
145
|
|
|
144
146
|
const goToNext = useCallback(() => {
|
|
145
147
|
if (matchIndices.length === 0 || mode === 'hide') return
|
|
@@ -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"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { ColumnPickerList } from './ColumnPickerList'
|
|
6
|
+
|
|
7
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
8
|
+
|
|
9
|
+
describe('ColumnPickerList keyboard focus', () => {
|
|
10
|
+
it.each([
|
|
11
|
+
['namespace', 'ArrowUp'],
|
|
12
|
+
['age', 'ArrowDown'],
|
|
13
|
+
])('does not restore stale focus after repeated boundary moves on %s', (key, direction) => {
|
|
14
|
+
const host = document.createElement('div')
|
|
15
|
+
document.body.append(host)
|
|
16
|
+
const root = createRoot(host)
|
|
17
|
+
const onReorder = vi.fn()
|
|
18
|
+
const columns = ['name', 'namespace', 'age'].map(key => ({ key, label: key }))
|
|
19
|
+
const render = () => act(() => root.render(<ColumnPickerList
|
|
20
|
+
columns={columns}
|
|
21
|
+
visibleColumns={new Set(['name', 'namespace', 'age'])}
|
|
22
|
+
customColumnKeys={new Set()}
|
|
23
|
+
onToggle={() => {}}
|
|
24
|
+
onRemoveCustom={() => {}}
|
|
25
|
+
onReorder={onReorder}
|
|
26
|
+
/>))
|
|
27
|
+
try {
|
|
28
|
+
render()
|
|
29
|
+
const inputs = [...host.querySelectorAll<HTMLInputElement>('input')]
|
|
30
|
+
const current = inputs[columns.findIndex(column => column.key === key)]
|
|
31
|
+
const other = inputs[key === 'namespace' ? 2 : 1]
|
|
32
|
+
if (!current || !other) throw new Error('Expected both movable checkboxes')
|
|
33
|
+
current.focus()
|
|
34
|
+
for (let i = 0; i < 3; i++) {
|
|
35
|
+
act(() => current.dispatchEvent(new KeyboardEvent('keydown', { key: direction, altKey: true, bubbles: true })))
|
|
36
|
+
}
|
|
37
|
+
other.focus()
|
|
38
|
+
render()
|
|
39
|
+
expect(document.activeElement).toBe(other)
|
|
40
|
+
expect(onReorder).not.toHaveBeenCalled()
|
|
41
|
+
} finally {
|
|
42
|
+
act(() => root.unmount())
|
|
43
|
+
host.remove()
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
})
|