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