@skyhook-io/k8s-ui 1.18.1 → 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 +8 -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/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsTableView.tsx +13 -8
- package/src/components/issues/IssuesView.tsx +2 -2
- 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/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.tsx +26 -5
- 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.tsx +238 -66
- 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/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.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 +14 -3
- 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.tsx +135 -2
- package/src/components/shared/ResourceActionsBar.tsx +22 -6
- package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
- package/src/components/shared/SetImageDialog.tsx +3 -0
- 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/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/PageHeader.tsx +3 -2
- package/src/components/ui/SummaryTile.tsx +13 -3
- 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 +27 -0
- package/src/components/ui/drawer-components.tsx +22 -42
- package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
- package/src/components/workload/WorkloadView.tsx +57 -34
- 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/types/core.ts +53 -1
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/gitops-owner.ts +136 -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 +1 -0
- package/src/utils/read-actions.ts +34 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { useMemo, type ReactNode } from 'react'
|
|
2
|
+
import type { YamlPreviewResult } from './YamlReview'
|
|
3
|
+
import { parseAllDocuments } from 'yaml'
|
|
4
|
+
import { splitYamlDocuments } from '../../utils/yaml'
|
|
5
|
+
import { apiVersionToGroup } from '../../utils/navigation'
|
|
6
|
+
import { resourceKey } from '../../utils/resource-refs'
|
|
7
|
+
import { getCrossplanePauseState, isComposite, isCrossplanePaused, isManagedResource } from '../resources/resource-utils-crossplane'
|
|
8
|
+
import { AlertBanner } from './drawer-components'
|
|
9
|
+
|
|
10
|
+
const crossplaneFinalizers = new Set([
|
|
11
|
+
'finalizer.managedresource.crossplane.io',
|
|
12
|
+
'composite.apiextensions.crossplane.io',
|
|
13
|
+
'finalizer.apiextensions.crossplane.io',
|
|
14
|
+
])
|
|
15
|
+
|
|
16
|
+
function isCrossplaneOperationResource(data: any): boolean {
|
|
17
|
+
return isManagedResource(data) || isComposite(data) || getCrossplanePauseState(data) === 'acknowledged'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isAnnotatedCrossplanePause(data: any): boolean {
|
|
21
|
+
return isCrossplanePaused(data) && isCrossplaneOperationResource(data)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resourceNames(resources: any[]): string {
|
|
25
|
+
const names = resources.slice(0, 5).map(data =>
|
|
26
|
+
`${data.kind ?? 'Resource'} ${data.metadata?.namespace ? `${data.metadata.namespace}/` : ''}${data.metadata?.name ?? 'Unnamed'}`,
|
|
27
|
+
)
|
|
28
|
+
if (resources.length > 5) names.push(`and ${resources.length - 5} more`)
|
|
29
|
+
return names.join(', ')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function CrossplaneDeleteNotice({ resources, force = false }: { resources: any[]; force?: boolean }) {
|
|
33
|
+
const paused = resources.filter(isAnnotatedCrossplanePause)
|
|
34
|
+
if (paused.length === 0) return null
|
|
35
|
+
const blocked = paused.filter(data =>
|
|
36
|
+
data.metadata?.finalizers?.some((finalizer: string) => crossplaneFinalizers.has(finalizer)),
|
|
37
|
+
)
|
|
38
|
+
const forceTargets = paused.filter(data => data.metadata?.finalizers?.length > 0)
|
|
39
|
+
const alreadyDeleting = blocked.some(data => data.metadata?.deletionTimestamp)
|
|
40
|
+
if (force ? forceTargets.length === 0 : blocked.length === 0) {
|
|
41
|
+
return <p className="text-xs text-theme-text-secondary">
|
|
42
|
+
Reconciliation pause is requested for {resourceNames(paused)}. The pause annotation alone does not block Kubernetes deletion; remaining finalizers determine whether cleanup must finish first.
|
|
43
|
+
</p>
|
|
44
|
+
}
|
|
45
|
+
return <AlertBanner
|
|
46
|
+
variant="warning"
|
|
47
|
+
className="mb-2"
|
|
48
|
+
title={force ? 'Force delete bypasses controller cleanup' : alreadyDeleting ? 'Deletion requested while reconciliation is paused' : 'Pause can hold deletion in Terminating'}
|
|
49
|
+
message={<>
|
|
50
|
+
{force
|
|
51
|
+
? resources.length === 1
|
|
52
|
+
? <p>Reconciliation pause is requested for {resourceNames(forceTargets)}. This resource has finalizers.</p>
|
|
53
|
+
: <p>Of {resources.length} selected resources, {forceTargets.length} {forceTargets.length === 1 ? 'has' : 'have'} pause requested and finalizers: {resourceNames(forceTargets)}.</p>
|
|
54
|
+
: resources.length === 1
|
|
55
|
+
? <p>Pause requested on {resourceNames(blocked)}. This resource holds a Crossplane finalizer.</p>
|
|
56
|
+
: <p>Of {resources.length} selected resources, {blocked.length} {blocked.length === 1 ? 'has' : 'have'} pause requested and {blocked.length === 1 ? 'holds' : 'hold'} a Crossplane finalizer: {resourceNames(blocked)}.</p>}
|
|
57
|
+
{force ? <p className="mt-1">Force delete strips finalizers and can leave external or related resources behind. It does not resume reconciliation or guarantee what other controllers will delete.</p> : <p className="mt-1">{alreadyDeleting ? resources.length === 1 ? 'Deletion has already been requested.' : 'Some deletions have already been requested.' : 'Kubernetes can accept the deletion request.'} The pause annotation tells {blocked.length === 1 ? 'this resource’s controller' : 'these resources’ controllers'} to defer finalizer cleanup. Removing the pause requests resumption and may allow pending work, including deletion under {blocked.length === 1 ? 'its policies' : 'their policies'}.</p>}
|
|
58
|
+
</>}
|
|
59
|
+
/>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function CrossplaneApplyNotice({ resources, unknownResources = [], children }: { resources: any[]; unknownResources?: any[]; children?: ReactNode }) {
|
|
63
|
+
const paused = resources.filter(isAnnotatedCrossplanePause)
|
|
64
|
+
if (paused.length === 0 && unknownResources.length === 0) return null
|
|
65
|
+
return <AlertBanner
|
|
66
|
+
variant="info"
|
|
67
|
+
className="mb-2"
|
|
68
|
+
title={paused.length ? 'Pause affects reconciliation' : 'Existing pause state is unknown'}
|
|
69
|
+
message={<>
|
|
70
|
+
{paused.length > 0 && <>
|
|
71
|
+
<p>{resourceNames(paused)}.</p>
|
|
72
|
+
<p className="mt-1">Kubernetes can accept edits. While <code>crossplane.io/paused</code> remains <code>"true"</code>, {paused.length === 1 ? 'this resource’s controller will' : 'these resources’ controllers will'} defer reconciliation. Removing the annotation requests resumption and may allow pending work, including deletion. Other resources' controllers may continue independently.</p>
|
|
73
|
+
</>}
|
|
74
|
+
{unknownResources.length > 0 && <>
|
|
75
|
+
<p className="mt-1">Live pause state is unverified for {resourceNames(unknownResources)}.</p>
|
|
76
|
+
<p className="mt-1">If <code>crossplane.io/paused</code> remains <code>"true"</code> on an existing resource, its controller defers reconciliation. Omitting the annotation from server-side apply YAML does not guarantee its removal.</p>
|
|
77
|
+
</>}
|
|
78
|
+
{children}
|
|
79
|
+
</>}
|
|
80
|
+
/>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function yamlObjects(yaml: string): any[] {
|
|
84
|
+
try {
|
|
85
|
+
return parseAllDocuments(yaml).map(document => document.errors.length ? null : document.toJS({ maxAliasCount: 100 }))
|
|
86
|
+
} catch {
|
|
87
|
+
return []
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function CrossplaneYamlApplyNotice({ yaml, baselineYaml = '', baselineUnavailable = false, previewDocuments, mode = 'apply', resourceData }: {
|
|
92
|
+
yaml: string
|
|
93
|
+
baselineYaml?: string
|
|
94
|
+
baselineUnavailable?: boolean
|
|
95
|
+
previewDocuments?: YamlPreviewResult[]
|
|
96
|
+
mode?: 'apply' | 'create' | 'update'
|
|
97
|
+
resourceData?: unknown
|
|
98
|
+
}) {
|
|
99
|
+
const context = useMemo(() => {
|
|
100
|
+
const submitted = splitYamlDocuments(yaml).map(document => document.content)
|
|
101
|
+
const original = splitYamlDocuments(baselineYaml).map(document => document.content)
|
|
102
|
+
const matchesTarget = (data: any, target: any) => Boolean(data && target
|
|
103
|
+
&& typeof data.apiVersion === 'string' && typeof target.apiVersion === 'string'
|
|
104
|
+
&& resourceKey(apiVersionToGroup(data.apiVersion), data.kind, data.metadata?.namespace ?? '', data.metadata?.name)
|
|
105
|
+
=== resourceKey(apiVersionToGroup(target.apiVersion), target.kind, target.metadata?.namespace ?? '', target.metadata?.name))
|
|
106
|
+
const pairs = (previewDocuments
|
|
107
|
+
? previewDocuments.map(preview => ({
|
|
108
|
+
after: yamlObjects(preview.predictedYaml || preview.submittedYaml || submitted[preview.index] || '')[0],
|
|
109
|
+
before: yamlObjects(preview.baselineYaml || original[preview.index] || '')[0],
|
|
110
|
+
preview,
|
|
111
|
+
}))
|
|
112
|
+
: Array.from({ length: Math.max(submitted.length, original.length) }, (_, index) => ({
|
|
113
|
+
after: yamlObjects(submitted[index] || '')[0],
|
|
114
|
+
before: yamlObjects(original[index] || '')[0],
|
|
115
|
+
preview: undefined,
|
|
116
|
+
}))).map(pair => matchesTarget(pair.before, pair.after) ? pair : { ...pair, before: undefined })
|
|
117
|
+
const rawSource = pairs.length === 1 && isAnnotatedCrossplanePause(resourceData) ? resourceData as any : undefined
|
|
118
|
+
const matchesRaw = (data: any) => matchesTarget(data, rawSource)
|
|
119
|
+
const rawPause = matchesRaw(pairs[0]?.after) ? rawSource : undefined
|
|
120
|
+
const hasPause = (data: any) => isCrossplanePaused(data) && (isCrossplaneOperationResource(data) || Boolean(rawPause && matchesRaw(data)))
|
|
121
|
+
const resources = pairs.flatMap(pair => {
|
|
122
|
+
const source = hasPause(pair.after) ? pair.after : hasPause(pair.before) ? pair.before : undefined
|
|
123
|
+
return source ? [isAnnotatedCrossplanePause(source) ? source : rawPause] : []
|
|
124
|
+
})
|
|
125
|
+
const unknownResources = pairs.filter(pair => mode !== 'create' && (baselineUnavailable || pair.preview?.status === 'unavailable' && !pair.preview.predictedYaml && !pair.preview.baselineYaml && !pair.before && pair.preview.action !== 'create'))
|
|
126
|
+
.map(pair => pair.after).filter(data => data && (isCrossplaneOperationResource(data) || Boolean(rawPause && matchesRaw(data))))
|
|
127
|
+
const kept = pairs.filter(pair => pair.preview?.predictedYaml && hasPause(pair.before) && hasPause(pair.after)).map(pair => pair.after)
|
|
128
|
+
const requested = pairs.filter(pair => pair.preview?.predictedYaml && !hasPause(pair.before) && hasPause(pair.after)).map(pair => pair.after)
|
|
129
|
+
const cleared = pairs.filter(pair => pair.preview?.predictedYaml && hasPause(pair.before) && !isCrossplanePaused(pair.after)).map(pair => pair.before)
|
|
130
|
+
const omissionUnverified = pairs.some(pair => !pair.preview?.predictedYaml && hasPause(pair.before) && pair.after?.metadata?.annotations?.['crossplane.io/paused'] === undefined)
|
|
131
|
+
return { resources, unknownResources, kept, requested, cleared, omissionUnverified }
|
|
132
|
+
}, [yaml, baselineYaml, baselineUnavailable, previewDocuments, mode, resourceData])
|
|
133
|
+
return <CrossplaneApplyNotice resources={context.resources} unknownResources={context.unknownResources}>
|
|
134
|
+
{context.kept.length > 0 && <p className="mt-1">Server preview keeps the pause request on {resourceNames(context.kept)}.</p>}
|
|
135
|
+
{context.requested.length > 0 && <p className="mt-1">Server preview requests a pause on {resourceNames(context.requested)}.</p>}
|
|
136
|
+
{context.cleared.length > 0 && <p className="mt-1">Server preview clears the pause request on {resourceNames(context.cleared)}.</p>}
|
|
137
|
+
{context.omissionUnverified && <p className="mt-1">Omitting the annotation from server-side apply YAML does not guarantee its removal.</p>}
|
|
138
|
+
</CrossplaneApplyNotice>
|
|
139
|
+
}
|
|
@@ -22,15 +22,15 @@ afterEach(async () => {
|
|
|
22
22
|
describe('Deletion preview states', () => {
|
|
23
23
|
it('states the namespace-wide deletion consequence without relying on topology', async () => {
|
|
24
24
|
await act(async () => root.render(<ForceDeleteConfirmDialog {...base} resourceKind="Namespace" resourceName="demo" namespaceName="" cascadeRootResolved={false} />))
|
|
25
|
-
expect(document.body.textContent).toContain('delete the namespace "demo" and
|
|
25
|
+
expect(document.body.textContent).toContain('delete the namespace "demo" and all resources in it')
|
|
26
26
|
expect(document.body.textContent).not.toContain("not in Radar's topology")
|
|
27
27
|
})
|
|
28
28
|
it('shows one advisory statement for a resolved preview', async () => {
|
|
29
29
|
await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved cascadeDependents={[{ kind: 'Pod', namespace: 'demo', name: 'web-pod' }]} />))
|
|
30
|
-
const toggle = [...document.querySelectorAll('button')].find(b => b.textContent?.includes('May
|
|
30
|
+
const toggle = [...document.querySelectorAll('button')].find(b => b.textContent?.includes('May also delete'))!
|
|
31
31
|
await act(async () => toggle.click())
|
|
32
32
|
expect(document.body.textContent).toContain('web-pod')
|
|
33
|
-
expect(document.body.textContent
|
|
33
|
+
expect(document.body.textContent).toContain("Kubernetes deletes only those that name this one as their owner")
|
|
34
34
|
expect(document.body.textContent).not.toContain('Other resources may be affected')
|
|
35
35
|
})
|
|
36
36
|
it.each([[], undefined])('omits the advisory statement for an empty resolved preview (%j)', async (dependents) => {
|
|
@@ -42,9 +42,10 @@ describe('Deletion preview states', () => {
|
|
|
42
42
|
})
|
|
43
43
|
it('distinguishes an observed unresolved root from a fetch failure', async () => {
|
|
44
44
|
await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved={false} />))
|
|
45
|
-
expect(document.body.textContent).toContain("
|
|
45
|
+
expect(document.body.textContent).toContain("Radar couldn't verify which dependent resources")
|
|
46
46
|
await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved={false} cascadePreviewFailed cascadeDependents={[{ kind: 'Pod', namespace: 'demo', name: 'stale' }]} />))
|
|
47
|
-
expect(document.body.textContent).toContain('
|
|
47
|
+
expect(document.body.textContent).toContain('Deletion preview unavailable')
|
|
48
|
+
expect(document.body.textContent).toContain('Additional resources may be deleted.')
|
|
48
49
|
expect(document.body.textContent).not.toContain('This resource is not in')
|
|
49
50
|
expect(document.body.textContent).not.toContain('Other resources may be affected')
|
|
50
51
|
expect(document.body.textContent).not.toContain('May affect')
|
|
@@ -55,8 +56,56 @@ describe('Deletion preview states', () => {
|
|
|
55
56
|
})
|
|
56
57
|
it('shows loading without presenting stale failure or success as a result', async () => {
|
|
57
58
|
await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeLoading cascadePreviewFailed cascadeRootResolved />))
|
|
58
|
-
expect(document.body.textContent).toContain('Checking
|
|
59
|
+
expect(document.body.textContent).toContain('Checking for dependent resources')
|
|
60
|
+
const confirm = Array.from(document.body.querySelectorAll('button')).find(button => button.textContent === 'Delete')!
|
|
61
|
+
expect(confirm.disabled).toBe(true)
|
|
59
62
|
expect(document.body.textContent).not.toContain('unavailable')
|
|
60
63
|
expect(document.body.textContent).not.toContain('does not predict')
|
|
61
64
|
})
|
|
62
65
|
})
|
|
66
|
+
|
|
67
|
+
it('separates observed collection, uncertainty and partial coverage', async () => {
|
|
68
|
+
await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved cascadeDependents={[{kind:'Pod', namespace:'demo', name:'certain'}]} cascadeDetail={{basis:'ownerReferences', possibleDependents:[{kind:'ConfigMap', namespace:'demo', name:'possible'}], coverage:{complete:false,narrowed:true}}} />))
|
|
69
|
+
expect(document.body.textContent).toContain('Garbage collection is expected to delete 1 dependent resource')
|
|
70
|
+
expect(document.body.textContent).toContain('May also delete 1 resource')
|
|
71
|
+
expect(document.body.textContent).toContain('This preview is partial')
|
|
72
|
+
})
|
|
73
|
+
it('does not promise force deletion can undo controller cleanup', async () => {
|
|
74
|
+
await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeDetail={{controllerTeardown:{controller:'Argo CD', action:'prune', destination:'remote'}}} />))
|
|
75
|
+
const force = document.querySelector('input[type="checkbox"]') as HTMLInputElement
|
|
76
|
+
await act(async () => force.click())
|
|
77
|
+
expect(document.body.textContent).toContain('remaining cleanup may be skipped')
|
|
78
|
+
expect(document.body.textContent).toContain('cannot undo it')
|
|
79
|
+
expect(document.body.textContent).not.toContain('Resources stay')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('changes the pending-orphan warning when force is selected', async () => {
|
|
83
|
+
await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved cascadeDependents={[{kind:'Pod', namespace:'demo', name:'child'}]} cascadeDetail={{basis:'ownerReferences', propagation:'Orphan'}} />))
|
|
84
|
+
expect(document.body.textContent).toContain('dependents are orphaned')
|
|
85
|
+
expect(document.body.textContent).not.toContain('Garbage collection is expected')
|
|
86
|
+
await act(async () => (document.querySelector('input[type="checkbox"]') as HTMLInputElement).click())
|
|
87
|
+
expect(document.body.textContent).not.toContain('dependents are orphaned')
|
|
88
|
+
expect(document.body.textContent).toContain('Force delete removes the orphan finalizer')
|
|
89
|
+
expect(document.body.textContent).toContain('Garbage collection is expected')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('paused Crossplane deletion confirmation', () => {
|
|
93
|
+
it('keeps context and permissions while changing only the explicit force selection', async () => {
|
|
94
|
+
const resourceData = { metadata: { name: 'bucket', annotations: { 'crossplane.io/paused': 'true' }, finalizers: ['finalizer.managedresource.crossplane.io'] }, spec: { providerConfigRef: { name: 'default' } } }
|
|
95
|
+
await act(async () => root.render(<ForceDeleteConfirmDialog {...base} resourceData={resourceData} />))
|
|
96
|
+
expect(document.body.textContent).toContain('Pause can hold deletion in Terminating')
|
|
97
|
+
const confirm = [...document.querySelectorAll('button')].find(b => b.textContent === 'Delete')!
|
|
98
|
+
expect(confirm.disabled).toBe(false)
|
|
99
|
+
await act(async () => confirm.click())
|
|
100
|
+
expect(base.onConfirm).toHaveBeenLastCalledWith(false)
|
|
101
|
+
expect(document.body.textContent).toContain('Kubernetes can accept the deletion request')
|
|
102
|
+
const force = document.querySelector('input[type="checkbox"]') as HTMLInputElement
|
|
103
|
+
await act(async () => force.click())
|
|
104
|
+
expect(document.body.textContent).toContain('Force delete bypasses controller cleanup')
|
|
105
|
+
const forceConfirm = [...document.querySelectorAll('button')].find(b => b.textContent === 'Force Delete')!
|
|
106
|
+
await act(async () => forceConfirm.click())
|
|
107
|
+
expect(base.onConfirm).toHaveBeenLastCalledWith(true)
|
|
108
|
+
expect(resourceData.metadata.finalizers).toEqual(['finalizer.managedresource.crossplane.io'])
|
|
109
|
+
expect(resourceData.metadata.annotations['crossplane.io/paused']).toBe('true')
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -2,6 +2,8 @@ import { useState, useMemo } from 'react'
|
|
|
2
2
|
import { Loader2 } from 'lucide-react'
|
|
3
3
|
import { Collapse, CollapseChevron, useDisclosure } from './Collapse'
|
|
4
4
|
import { ConfirmDialog } from './ConfirmDialog'
|
|
5
|
+
import { AlertBanner } from './drawer-components'
|
|
6
|
+
import { CrossplaneDeleteNotice } from './CrossplaneOperationNotice'
|
|
5
7
|
import { pluralize } from '../../utils/pluralize'
|
|
6
8
|
|
|
7
9
|
export interface CascadeDependent {
|
|
@@ -11,6 +13,27 @@ export interface CascadeDependent {
|
|
|
11
13
|
group?: string
|
|
12
14
|
}
|
|
13
15
|
|
|
16
|
+
/** Cached deletion evidence; all lists are observations, not completion guarantees. */
|
|
17
|
+
export interface CascadeDetail {
|
|
18
|
+
propagation?: string
|
|
19
|
+
coverage?: { complete: boolean; narrowed?: boolean; truncated?: boolean; notChecked?: unknown[] }
|
|
20
|
+
unavailableReason?: string
|
|
21
|
+
notEnumerated?: string[]
|
|
22
|
+
/** "ownerReferences" when dependents are garbage-collection results; absent from older servers. */
|
|
23
|
+
basis?: string
|
|
24
|
+
/** Deleted unless another owner Radar can't see is still live. */
|
|
25
|
+
possibleDependents?: CascadeDependent[]
|
|
26
|
+
/** What Argo CD or Flux deletes through its finalizer. */
|
|
27
|
+
controllerTeardown?: {
|
|
28
|
+
controller: string
|
|
29
|
+
action: 'prune' | 'uninstall' | string
|
|
30
|
+
destination: 'local' | 'remote' | 'unknown'
|
|
31
|
+
resources?: CascadeDependent[]
|
|
32
|
+
/** Already being deleted: the teardown may be underway. */
|
|
33
|
+
terminating?: boolean
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
14
37
|
interface ForceDeleteConfirmDialogProps {
|
|
15
38
|
open: boolean
|
|
16
39
|
onClose: () => void
|
|
@@ -23,6 +46,8 @@ interface ForceDeleteConfirmDialogProps {
|
|
|
23
46
|
cascadeLoading?: boolean
|
|
24
47
|
cascadeRootResolved?: boolean
|
|
25
48
|
cascadePreviewFailed?: boolean
|
|
49
|
+
cascadeDetail?: CascadeDetail
|
|
50
|
+
resourceData?: unknown
|
|
26
51
|
}
|
|
27
52
|
|
|
28
53
|
export function ForceDeleteConfirmDialog({
|
|
@@ -36,10 +61,14 @@ export function ForceDeleteConfirmDialog({
|
|
|
36
61
|
cascadeDependents,
|
|
37
62
|
cascadeLoading,
|
|
38
63
|
cascadeRootResolved,
|
|
64
|
+
cascadeDetail,
|
|
39
65
|
cascadePreviewFailed,
|
|
66
|
+
resourceData,
|
|
40
67
|
}: ForceDeleteConfirmDialogProps) {
|
|
41
68
|
const [forceDelete, setForceDelete] = useState(false)
|
|
69
|
+
const orphaning = cascadeDetail?.propagation === 'Orphan' && !forceDelete
|
|
42
70
|
const isNamespace = resourceKind.toLowerCase() === 'namespace'
|
|
71
|
+
const isCRD = ['customresourcedefinition', 'customresourcedefinitions'].includes(resourceKind.toLowerCase())
|
|
43
72
|
|
|
44
73
|
function handleClose() {
|
|
45
74
|
onClose()
|
|
@@ -57,41 +86,69 @@ export function ForceDeleteConfirmDialog({
|
|
|
57
86
|
onConfirm={handleConfirm}
|
|
58
87
|
title="Delete Resource"
|
|
59
88
|
message={`Are you sure you want to delete "${resourceName}"?`}
|
|
60
|
-
details={isNamespace ? `This will permanently delete the namespace "${resourceName}" and
|
|
89
|
+
details={isNamespace ? `This will permanently delete the namespace "${resourceName}" and all resources in it.` : isCRD ? `This deletes the CRD and ALL its instances across the cluster, including those not shown here.` : namespaceName
|
|
90
|
+
? `This will permanently delete the ${resourceKind} "${resourceName}" from the "${namespaceName}" namespace.`
|
|
91
|
+
: `This will permanently delete the cluster-scoped ${resourceKind} "${resourceName}".`}
|
|
61
92
|
confirmLabel={forceDelete ? 'Force Delete' : 'Delete'}
|
|
62
93
|
variant="danger"
|
|
63
94
|
isLoading={isLoading}
|
|
95
|
+
confirmDisabled={cascadeLoading}
|
|
64
96
|
>
|
|
65
97
|
<div className="flex flex-col gap-3 pb-1">
|
|
66
98
|
{cascadeLoading && (
|
|
67
99
|
<div className="flex items-center gap-2 text-xs text-theme-text-tertiary">
|
|
68
100
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
69
|
-
Checking
|
|
101
|
+
Checking for dependent resources...
|
|
70
102
|
</div>
|
|
71
103
|
)}
|
|
72
104
|
|
|
73
|
-
{!cascadeLoading && !cascadePreviewFailed && cascadeDependents && cascadeDependents.length > 0 && (
|
|
74
|
-
|
|
105
|
+
{!cascadeLoading && !cascadePreviewFailed && !orphaning && cascadeDependents && cascadeDependents.length > 0 && (
|
|
106
|
+
cascadeDetail?.basis === 'ownerReferences' ? (
|
|
107
|
+
<CascadeDependentsList
|
|
108
|
+
dependents={cascadeDependents}
|
|
109
|
+
title={`Garbage collection is expected to delete ${pluralize(cascadeDependents.length, 'dependent resource')}`}
|
|
110
|
+
note="Kubernetes deletes these through their owner references. Finalizers may delay deletion. This is an observation of the current cache, not a guarantee of completion."
|
|
111
|
+
/>
|
|
112
|
+
) : (
|
|
113
|
+
<CascadeDependentsList
|
|
114
|
+
dependents={cascadeDependents}
|
|
115
|
+
title={`May also delete ${pluralize(cascadeDependents.length, 'related resource')}`}
|
|
116
|
+
note="This Radar version can't tell owned resources from other links. Kubernetes deletes only those that name this one as their owner."
|
|
117
|
+
/>
|
|
118
|
+
)
|
|
75
119
|
)}
|
|
76
120
|
|
|
77
|
-
{!cascadeLoading && cascadePreviewFailed && (
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
121
|
+
{!cascadeLoading && !cascadePreviewFailed && !orphaning && cascadeDetail?.possibleDependents && cascadeDetail.possibleDependents.length > 0 && (
|
|
122
|
+
<CascadeDependentsList
|
|
123
|
+
dependents={cascadeDetail.possibleDependents}
|
|
124
|
+
title={`May also delete ${pluralize(cascadeDetail.possibleDependents.length, 'resource')}`}
|
|
125
|
+
note="Another owner or a chain of owners could not be resolved. Kubernetes deletes these only if no live owner remains."
|
|
126
|
+
/>
|
|
81
127
|
)}
|
|
82
128
|
|
|
83
|
-
{!
|
|
84
|
-
<p className="text-xs text-theme-text-
|
|
85
|
-
|
|
86
|
-
|
|
129
|
+
{!cascadeLoading && !cascadePreviewFailed && cascadeDetail?.coverage && !orphaning && !cascadeDetail.coverage.complete ? (
|
|
130
|
+
<p className="text-xs text-theme-text-secondary">This preview is partial: Radar may not collect or you may not be allowed to read all dependents. Additional resources may be deleted.</p>
|
|
131
|
+
) : null}
|
|
132
|
+
{!cascadeLoading && !cascadePreviewFailed && cascadeDetail?.controllerTeardown && (
|
|
133
|
+
<ControllerTeardownNotice teardown={cascadeDetail.controllerTeardown} force={forceDelete} />
|
|
87
134
|
)}
|
|
88
135
|
|
|
89
|
-
{!cascadeLoading && !cascadePreviewFailed && cascadeRootResolved ===
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
136
|
+
{!cascadeLoading && !cascadePreviewFailed && cascadeRootResolved === false && (
|
|
137
|
+
<AlertBanner
|
|
138
|
+
variant="warning"
|
|
139
|
+
title="Dependent resources unverified"
|
|
140
|
+
message="Radar couldn't verify which dependent resources Kubernetes will also delete. Additional resources may be deleted but aren't shown here."
|
|
141
|
+
className="mb-0"
|
|
142
|
+
/>
|
|
93
143
|
)}
|
|
94
144
|
|
|
145
|
+
{!cascadeLoading && !cascadePreviewFailed && orphaning && <p className="text-xs text-theme-text-secondary">Keeping the pending orphan policy: owner-reference dependents are orphaned. Controller cleanup may still delete resources.</p>}
|
|
146
|
+
{!cascadeLoading && !cascadePreviewFailed && forceDelete && cascadeDetail?.propagation === 'Orphan' && <p className="text-xs text-warning-text">Force delete removes the orphan finalizer and uses Background propagation. Owner-reference dependents may now be garbage collected.</p>}
|
|
147
|
+
{!cascadeLoading && !cascadePreviewFailed && !orphaning && cascadeDetail?.coverage?.complete && cascadeDetail.coverage.narrowed && <p className="text-xs text-theme-text-secondary">Only resources Radar observes and you can read are listed.</p>}
|
|
148
|
+
{!cascadeLoading && cascadePreviewFailed && <AlertBanner variant="warning" title="Deletion preview unavailable" message="Additional resources may be deleted." className="mb-0" />}
|
|
149
|
+
{!cascadeLoading && !cascadePreviewFailed && cascadeDetail?.notEnumerated?.map(item => <p key={item} className="text-xs text-theme-text-secondary">Not enumerated: {item}.</p>)}
|
|
150
|
+
<CrossplaneDeleteNotice resources={[resourceData]} force={forceDelete} />
|
|
151
|
+
|
|
95
152
|
<label className="flex items-center gap-2 text-sm text-theme-text-secondary">
|
|
96
153
|
<input
|
|
97
154
|
type="checkbox"
|
|
@@ -108,16 +165,64 @@ export function ForceDeleteConfirmDialog({
|
|
|
108
165
|
|
|
109
166
|
const MAX_NAMES_PER_KIND = 8
|
|
110
167
|
|
|
111
|
-
|
|
168
|
+
// How a managed resource opts out of its controller's teardown. A Helm
|
|
169
|
+
// uninstall keeps resources by Helm's own annotation, not Flux's.
|
|
170
|
+
function optOut(teardown: NonNullable<CascadeDetail['controllerTeardown']>): string | undefined {
|
|
171
|
+
if (teardown.action === 'uninstall') return 'helm.sh/resource-policy: keep'
|
|
172
|
+
if (teardown.controller === 'Argo CD') return 'argocd.argoproj.io/sync-options: Delete=false'
|
|
173
|
+
if (teardown.controller === 'Flux') return 'kustomize.toolkit.fluxcd.io/prune: disabled'
|
|
174
|
+
return undefined
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function ControllerTeardownNotice({ teardown, force }: { teardown: NonNullable<CascadeDetail['controllerTeardown']>; force: boolean }) {
|
|
178
|
+
const resources = teardown.resources ?? []
|
|
179
|
+
const destinationNotice = teardown.destination === 'remote'
|
|
180
|
+
? 'This controller targets a destination Radar cannot confirm is this cluster. Radar cannot list what it will delete there.'
|
|
181
|
+
: teardown.destination === 'unknown'
|
|
182
|
+
? 'The destination cluster is unknown. Radar cannot show where controller cleanup will run.'
|
|
183
|
+
: ''
|
|
184
|
+
const what = teardown.action === 'uninstall' ? 'uninstall the Helm release' : 'delete the resources it manages'
|
|
185
|
+
if (force) {
|
|
186
|
+
return (
|
|
187
|
+
<AlertBanner
|
|
188
|
+
variant="warning"
|
|
189
|
+
title="Controller cleanup may be skipped"
|
|
190
|
+
message={<>
|
|
191
|
+
{destinationNotice && <>{destinationNotice} </>}
|
|
192
|
+
{teardown.terminating
|
|
193
|
+
? `This is already being deleted, so ${teardown.controller} may already be working to ${what}. Force delete removes its finalizer but can't undo that.`
|
|
194
|
+
: `Force delete removes ${teardown.controller}'s finalizer, so ${teardown.controller}'s remaining cleanup may be skipped. Cleanup may already have started; removing the finalizer cannot undo it.`}
|
|
195
|
+
</>}
|
|
196
|
+
className="mb-0"
|
|
197
|
+
/>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
const title = teardown.destination === 'unknown'
|
|
201
|
+
? `${teardown.controller} has a cleanup finalizer`
|
|
202
|
+
: teardown.action === 'uninstall'
|
|
203
|
+
? `${teardown.controller} is configured to uninstall the Helm release`
|
|
204
|
+
: `${teardown.controller} is configured to prune managed resources`
|
|
205
|
+
return (
|
|
206
|
+
<CascadeDependentsList
|
|
207
|
+
dependents={resources}
|
|
208
|
+
title={resources.length > 0 ? `${title} (${resources.length} shown)` : title}
|
|
209
|
+
summary={`${destinationNotice ? destinationNotice + ' ' : ''}${teardown.action === 'uninstall' ? 'Helm uninstalls resources in its stored release manifest' : `${teardown.controller} deletes what its own inventory records`}, which can include resources Radar doesn't show.${optOut(teardown) ? ` Resources marked ${optOut(teardown)} stay.` : ''}`}
|
|
210
|
+
/>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// `note` explains the expanded list; `summary` is a caveat that must read
|
|
215
|
+
// without expanding it.
|
|
216
|
+
function CascadeDependentsList({ dependents, title, note, summary }: { dependents: CascadeDependent[]; title: string; note?: string; summary?: string }) {
|
|
112
217
|
const [expanded, setExpanded] = useState(false)
|
|
113
218
|
const { panelId, buttonProps } = useDisclosure(expanded)
|
|
114
219
|
|
|
115
220
|
const grouped = useMemo(() => {
|
|
116
221
|
const map = new Map<string, string[]>()
|
|
117
222
|
for (const dep of dependents) {
|
|
118
|
-
const kind = dep.kind
|
|
223
|
+
const kind = dep.group ? `${dep.kind} (${dep.group})` : dep.kind
|
|
119
224
|
if (!map.has(kind)) map.set(kind, [])
|
|
120
|
-
map.get(kind)!.push(dep.name)
|
|
225
|
+
map.get(kind)!.push(dep.namespace ? `${dep.namespace}/${dep.name}` : dep.name)
|
|
121
226
|
}
|
|
122
227
|
return Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0]))
|
|
123
228
|
}, [dependents])
|
|
@@ -131,10 +236,9 @@ function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[]
|
|
|
131
236
|
className="flex items-center gap-2 w-full px-3 py-2 text-left text-xs font-medium text-amber-400 hover:bg-amber-500/10 transition-colors"
|
|
132
237
|
>
|
|
133
238
|
<CollapseChevron open={expanded} inheritColor className="w-3.5 h-3.5" />
|
|
134
|
-
<span>
|
|
135
|
-
May affect {pluralize(dependents.length, 'related resource')}
|
|
136
|
-
</span>
|
|
239
|
+
<span>{title}</span>
|
|
137
240
|
</button>
|
|
241
|
+
{summary && <p className="px-3 pb-2 text-xs text-theme-text-secondary">{summary}</p>}
|
|
138
242
|
|
|
139
243
|
<Collapse open={expanded} id={panelId}>
|
|
140
244
|
<div className="px-3 pb-2.5 space-y-1.5">
|
|
@@ -150,6 +254,7 @@ function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[]
|
|
|
150
254
|
</div>
|
|
151
255
|
</div>
|
|
152
256
|
))}
|
|
257
|
+
{note && <p className="text-xs text-theme-text-tertiary">{note}</p>}
|
|
153
258
|
</div>
|
|
154
259
|
</Collapse>
|
|
155
260
|
</div>
|
|
@@ -19,7 +19,8 @@ interface PageHeaderProps {
|
|
|
19
19
|
export function PageHeader({ icon: Icon, title, description, onBack, actions }: PageHeaderProps) {
|
|
20
20
|
return (
|
|
21
21
|
// Phones: actions (freshness, summary tiles) wrap under the title
|
|
22
|
-
// instead of squeezing it to nothing
|
|
22
|
+
// instead of squeezing it to nothing, and take the full row so summary
|
|
23
|
+
// tiles can grow into it.
|
|
23
24
|
<div className="flex items-center gap-4 phone:flex-wrap phone:gap-y-3">
|
|
24
25
|
{onBack && (
|
|
25
26
|
<button
|
|
@@ -39,7 +40,7 @@ export function PageHeader({ icon: Icon, title, description, onBack, actions }:
|
|
|
39
40
|
<p className="text-sm text-theme-text-tertiary mt-1 ml-7 truncate phone:whitespace-normal phone:line-clamp-2">{description}</p>
|
|
40
41
|
)}
|
|
41
42
|
</div>
|
|
42
|
-
{actions && <div className="flex flex-wrap items-center justify-end gap-2 shrink-0 phone:shrink phone:justify-start">{actions}</div>}
|
|
43
|
+
{actions && <div className="flex flex-wrap items-center justify-end gap-2 shrink-0 phone:shrink phone:basis-full phone:justify-start">{actions}</div>}
|
|
43
44
|
</div>
|
|
44
45
|
)
|
|
45
46
|
}
|
|
@@ -8,6 +8,7 @@ export type SummaryTone = 'neutral' | 'success' | 'warning' | 'error' | 'info'
|
|
|
8
8
|
// Applications both surface their status rollup as a row of these in the header.
|
|
9
9
|
export function SummaryTile({
|
|
10
10
|
label,
|
|
11
|
+
phoneLabel,
|
|
11
12
|
value,
|
|
12
13
|
tone = 'neutral',
|
|
13
14
|
onClick,
|
|
@@ -15,6 +16,8 @@ export function SummaryTile({
|
|
|
15
16
|
loading = false,
|
|
16
17
|
}: {
|
|
17
18
|
label: string
|
|
19
|
+
/** Shorter label for phones, where a row of tiles has to fit 360px. */
|
|
20
|
+
phoneLabel?: string
|
|
18
21
|
value: number
|
|
19
22
|
tone?: SummaryTone
|
|
20
23
|
onClick?: () => void
|
|
@@ -42,10 +45,17 @@ export function SummaryTile({
|
|
|
42
45
|
) : (
|
|
43
46
|
<div className={`text-sm font-semibold ${toneClass}`}>{value}</div>
|
|
44
47
|
)
|
|
45
|
-
const label$ =
|
|
48
|
+
const label$ = phoneLabel ? (
|
|
49
|
+
<div className="text-xs text-theme-text-tertiary">
|
|
50
|
+
<span className="phone:hidden">{label}</span>
|
|
51
|
+
<span className="hidden phone:inline">{phoneLabel}</span>
|
|
52
|
+
</div>
|
|
53
|
+
) : (
|
|
54
|
+
<div className="text-xs text-theme-text-tertiary">{label}</div>
|
|
55
|
+
)
|
|
46
56
|
if (!onClick) {
|
|
47
57
|
return (
|
|
48
|
-
<div className="rounded-md border border-theme-border bg-theme-base px-3 py-2">
|
|
58
|
+
<div className="rounded-md border border-theme-border bg-theme-base px-3 py-2 phone:flex-1 phone:basis-0 phone:whitespace-nowrap phone:px-2 phone:py-1.5">
|
|
49
59
|
{value$}
|
|
50
60
|
{label$}
|
|
51
61
|
</div>
|
|
@@ -57,7 +67,7 @@ export function SummaryTile({
|
|
|
57
67
|
onClick={onClick}
|
|
58
68
|
aria-pressed={active}
|
|
59
69
|
className={clsx(
|
|
60
|
-
'cursor-pointer rounded-md border bg-theme-base px-3 py-2 text-left transition-colors hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-skyhook-500',
|
|
70
|
+
'cursor-pointer rounded-md border bg-theme-base px-3 py-2 phone:flex-1 phone:basis-0 phone:whitespace-nowrap phone:px-2 phone:py-1.5 text-left transition-colors hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-skyhook-500',
|
|
61
71
|
active ? activeBorderClass : 'border-theme-border',
|
|
62
72
|
)}
|
|
63
73
|
>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// A tap fires mouseenter and focus. On an enabled control the tooltip must not
|
|
4
|
+
// pop over what the tap opens; on a disabled control the tap is how a touch
|
|
5
|
+
// user reads why it's disabled.
|
|
6
|
+
import { act } from 'react'
|
|
7
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
8
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
9
|
+
import { Tooltip } from './Tooltip'
|
|
10
|
+
|
|
11
|
+
let root: Root | undefined
|
|
12
|
+
let host: HTMLDivElement | undefined
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
act(() => root?.unmount())
|
|
15
|
+
host?.remove()
|
|
16
|
+
vi.useRealTimers()
|
|
17
|
+
// The last touch time is module state; a key press clears it for the next file.
|
|
18
|
+
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
function render(child: React.ReactNode) {
|
|
22
|
+
host = document.createElement('div')
|
|
23
|
+
document.body.appendChild(host)
|
|
24
|
+
root = createRoot(host)
|
|
25
|
+
act(() => root!.render(<Tooltip content="Why" delay={0}>{child}</Tooltip>))
|
|
26
|
+
return host.querySelector('span')!
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function touchPress() {
|
|
30
|
+
const e = new Event('pointerdown', { bubbles: true }) as Event & { pointerType: string }
|
|
31
|
+
Object.defineProperty(e, 'pointerType', { value: 'touch' })
|
|
32
|
+
document.body.dispatchEvent(e)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hover(trigger: Element) {
|
|
36
|
+
vi.useFakeTimers()
|
|
37
|
+
act(() => { trigger.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, relatedTarget: document.body })) })
|
|
38
|
+
act(() => { vi.advanceTimersByTime(10) })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const tooltipShown = () => document.body.textContent?.includes('Why') ?? false
|
|
42
|
+
|
|
43
|
+
it('stays closed when a tap activates an enabled control', () => {
|
|
44
|
+
const trigger = render(<button type="button">Open</button>)
|
|
45
|
+
touchPress()
|
|
46
|
+
hover(trigger)
|
|
47
|
+
expect(tooltipShown()).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('still explains a disabled control on tap', () => {
|
|
51
|
+
const trigger = render(<button type="button" disabled>Restart</button>)
|
|
52
|
+
touchPress()
|
|
53
|
+
hover(trigger)
|
|
54
|
+
expect(tooltipShown()).toBe(true)
|
|
55
|
+
})
|
|
@@ -2,6 +2,9 @@ import { ReactNode, useState, useRef, useEffect, useCallback } from 'react'
|
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { computeTooltipPosition } from './tooltip-position'
|
|
5
|
+
import { isTouchIntent } from '../../utils/touch-intent'
|
|
6
|
+
|
|
7
|
+
const ENABLED_CONTROL = 'button:not(:disabled), a[href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [role="button"]:not([aria-disabled="true"])'
|
|
5
8
|
|
|
6
9
|
// Module-level singleton coordinator: only one Tooltip can be visible
|
|
7
10
|
// at a time across the whole app. Without this, two Tooltip instances
|
|
@@ -221,6 +224,8 @@ export function Tooltip({
|
|
|
221
224
|
}
|
|
222
225
|
}, [isVisible])
|
|
223
226
|
|
|
227
|
+
const tapActivatesControl = () => isTouchIntent() && !!triggerRef.current?.querySelector(ENABLED_CONTROL)
|
|
228
|
+
|
|
224
229
|
if ((disabled && !preserveWrapperWhenDisabled) || !content) {
|
|
225
230
|
return <>{children}</>
|
|
226
231
|
}
|
|
@@ -231,9 +236,13 @@ export function Tooltip({
|
|
|
231
236
|
ref={triggerRef}
|
|
232
237
|
className={clsx('inline-flex max-w-full', wrapperClassName)}
|
|
233
238
|
style={wrapperStyle}
|
|
234
|
-
|
|
239
|
+
// A tap fires mouseenter and focus too. On an enabled control the
|
|
240
|
+
// tap has its own action (often opening a menu), and the tooltip
|
|
241
|
+
// would pop over it; on disabled or plain content the tap is the
|
|
242
|
+
// only way a touch user can read the explanation, so it still shows.
|
|
243
|
+
onMouseEnter={() => { if (!tapActivatesControl()) showTooltip() }}
|
|
235
244
|
onMouseLeave={scheduleHideTooltip}
|
|
236
|
-
onFocus={showTooltip}
|
|
245
|
+
onFocus={() => { if (!tapActivatesControl()) showTooltip() }}
|
|
237
246
|
onBlur={hideTooltip}
|
|
238
247
|
// pointerdown fires before click, so the tooltip is gone before
|
|
239
248
|
// the trigger's action runs. Required when the child handles
|