@skyhook-io/k8s-ui 1.17.0 → 1.18.0
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 +41 -0
- package/package.json +6 -6
- package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
- package/src/components/charts/AreaChart.test.tsx +25 -0
- package/src/components/charts/AreaChart.tsx +141 -12
- package/src/components/charts/index.ts +1 -0
- package/src/components/checks/ChecksView.tsx +18 -1
- package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
- package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
- package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
- package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
- package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
- package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
- package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
- package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
- package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
- package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
- package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
- package/src/components/cnpg/backupRuns.test.ts +17 -0
- package/src/components/cnpg/backupRuns.ts +19 -0
- package/src/components/cnpg/connect.test.ts +124 -0
- package/src/components/cnpg/connect.ts +155 -0
- package/src/components/cnpg/databaseRole.test.ts +38 -0
- package/src/components/cnpg/databaseRole.ts +80 -0
- package/src/components/cnpg/ha.test.ts +446 -0
- package/src/components/cnpg/ha.ts +621 -0
- package/src/components/cnpg/index.ts +18 -0
- package/src/components/cnpg/logicalReplication.test.ts +121 -0
- package/src/components/cnpg/logicalReplication.ts +252 -0
- package/src/components/cnpg/pooler.test.ts +84 -0
- package/src/components/cnpg/pooler.ts +225 -0
- package/src/components/cnpg/primitives.tsx +21 -117
- package/src/components/cnpg/problems.ts +12 -0
- package/src/components/cnpg/relations.test.ts +63 -13
- package/src/components/cnpg/relations.ts +61 -52
- package/src/components/cnpg/schedule.ts +35 -0
- package/src/components/cnpg/workspace-disk.test.ts +97 -0
- package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
- package/src/components/cnpg/workspace-problems.test.ts +175 -0
- package/src/components/cnpg/workspace.test.ts +380 -9
- package/src/components/cnpg/workspace.ts +1137 -136
- package/src/components/dock/DockContext.test.tsx +32 -0
- package/src/components/dock/DockContext.tsx +18 -4
- package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
- package/src/components/dock/LocalTerminalTab.tsx +4 -3
- package/src/components/dock/TerminalTab.tsx +20 -8
- package/src/components/facts/certainty.tsx +36 -0
- package/src/components/facts/facts.test.tsx +24 -0
- package/src/components/facts/facts.tsx +62 -0
- package/src/components/facts/index.ts +6 -0
- package/src/components/facts/managed-by.test.tsx +17 -0
- package/src/components/facts/managed-by.tsx +36 -0
- package/src/components/facts/public-exports.test.tsx +35 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +7 -3
- package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +142 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
- package/src/components/gitops/insights/DriftPanel.tsx +62 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -81
- 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 +91 -2
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +76 -76
- package/src/components/gitops/tree/layout.test.ts +111 -0
- package/src/components/gitops/tree/layout.ts +128 -0
- package/src/components/gitops/tree/tree-helpers.test.ts +23 -0
- package/src/components/gitops/tree/tree-helpers.ts +12 -2
- package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
- package/src/components/issues/IssuesView.tsx +44 -6
- package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
- package/src/components/issues/ResourceIssuesSection.tsx +8 -2
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +12 -1
- package/src/components/issues/issues.test.ts +75 -1
- package/src/components/issues/severity.ts +30 -2
- package/src/components/issues/types.ts +55 -4
- package/src/components/logs/LogCore.theme.test.tsx +206 -3
- package/src/components/logs/LogCore.tsx +180 -53
- package/src/components/logs/LogToolbarSelects.tsx +3 -1
- package/src/components/logs/LogsViewer.test.tsx +369 -0
- package/src/components/logs/LogsViewer.tsx +91 -13
- package/src/components/logs/StructuredLogLine.test.tsx +76 -0
- package/src/components/logs/StructuredLogLine.tsx +23 -31
- package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +46 -11
- package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- package/src/components/logs/useLogBuffer.ts +48 -18
- package/src/components/logs/useLogSearch.ts +3 -1
- package/src/components/logs/useLogStream.ts +17 -1
- package/src/components/problems/index.ts +1 -0
- package/src/components/problems/problems.test.tsx +63 -0
- package/src/components/problems/problems.tsx +165 -0
- package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
- package/src/components/resources/ActiveFilterBar.tsx +86 -0
- package/src/components/resources/ColumnPickerList.test.tsx +46 -0
- package/src/components/resources/ColumnPickerList.tsx +174 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
- package/src/components/resources/ResourcesSidebar.tsx +92 -41
- package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +296 -0
- package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
- package/src/components/resources/ResourcesView.tsx +392 -181
- package/src/components/resources/column-order.test.ts +104 -0
- package/src/components/resources/column-order.ts +64 -0
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
- package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
- package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
- package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
- package/src/components/resources/renderers/HPARenderer.tsx +56 -16
- package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
- package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
- package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
- package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
- package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
- package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -40
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
- package/src/components/resources/renderers/VPARenderer.tsx +17 -11
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
- package/src/components/resources/resource-utils-cnpg.ts +51 -20
- package/src/components/resources/resource-utils-contour.test.ts +44 -0
- package/src/components/resources/resource-utils-contour.ts +16 -0
- package/src/components/resources/resource-utils-istio.ts +12 -8
- package/src/components/resources/resource-utils-knative.ts +18 -0
- package/src/components/resources/resource-utils.ts +96 -7
- package/src/components/resources/rules-column-width.test.ts +1 -1
- package/src/components/resources/summarize-scheduler-message.test.ts +31 -0
- package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
- package/src/components/shared/ActionConfirmDialog.tsx +246 -0
- package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
- package/src/components/shared/CreateResourceDialog.tsx +126 -80
- package/src/components/shared/DetailShell.test.tsx +80 -0
- package/src/components/shared/DetailShell.tsx +85 -10
- package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
- package/src/components/shared/DrainPlanDialog.tsx +6 -2
- package/src/components/shared/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/ResourceActionsBar.tsx +14 -7
- package/src/components/shared/ResourceRendererDispatch.tsx +15 -10
- package/src/components/shared/index.ts +2 -0
- package/src/components/timeline/DiffViewer.test.tsx +69 -0
- package/src/components/timeline/DiffViewer.tsx +113 -4
- package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
- package/src/components/timeline/shared.test.tsx +48 -0
- package/src/components/timeline/shared.tsx +14 -19
- package/src/components/timeline/timeline-filters.ts +1 -0
- package/src/components/topology/K8sResourceNode.test.ts +18 -1
- package/src/components/topology/K8sResourceNode.tsx +44 -2
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +3 -71
- package/src/components/topology/TopologySearch.tsx +1 -0
- package/src/components/topology/internet-groups.test.ts +35 -0
- package/src/components/topology/internet-groups.ts +55 -0
- package/src/components/topology/layout.ts +1 -1
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/FoldSection.tsx +68 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
- package/src/components/ui/RefLink.tsx +20 -0
- package/src/components/ui/RowActionMenu.tsx +8 -3
- package/src/components/ui/Toast.tsx +15 -4
- package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
- package/src/components/ui/drawer-components.test.tsx +110 -1
- package/src/components/ui/drawer-components.tsx +62 -9
- package/src/components/ui/index.ts +4 -2
- package/src/components/ui/monacoEditorFeatures.ts +77 -0
- package/src/components/ui/monacoRuntime.ts +14 -19
- package/src/components/ui/status-tone.test.ts +10 -1
- package/src/components/ui/status-tone.tsx +29 -0
- package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
- package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
- package/src/components/workload/WorkloadView.tsx +193 -71
- package/src/index.ts +14 -1
- package/src/monaco-deep.d.ts +19 -12
- package/src/theme/components.css +13 -0
- package/src/types/capacity.ts +11 -2
- package/src/types/core.ts +53 -2
- package/src/types/gitops-flux-status.test.ts +32 -1
- package/src/types/gitops-insights.ts +20 -2
- package/src/types/gitops-tree.ts +12 -0
- package/src/types/gitops.ts +26 -6
- package/src/utils/api-resources.ts +7 -0
- package/src/utils/application-history.ts +1 -0
- package/src/utils/cnpg-backup.test.ts +26 -0
- package/src/utils/cnpg-backup.ts +70 -0
- package/src/utils/format.test.ts +9 -0
- package/src/utils/format.ts +4 -0
- package/src/utils/gitops-owner.ts +3 -2
- package/src/utils/grant.test.ts +19 -0
- package/src/utils/grant.ts +35 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/k8s-errors.ts +7 -7
- package/src/utils/log-format.test.ts +41 -0
- package/src/utils/log-format.ts +71 -15
- package/src/utils/log-level.ts +12 -8
- package/src/utils/navigation.test.ts +57 -0
- package/src/utils/navigation.ts +86 -8
- package/src/utils/object-reference-navigation.test.ts +43 -0
- package/src/utils/resource-hierarchy.test.ts +37 -0
- package/src/utils/resource-hierarchy.ts +15 -7
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/skeleton-yaml.test.ts +21 -0
- package/src/utils/skeleton-yaml.ts +16 -0
- package/src/utils/url-path.test.ts +35 -0
- package/src/utils/url-path.ts +21 -0
- package/src/utils/validators.test.ts +4 -13
- package/src/utils/validators.ts +5 -19
- package/src/utils/view-transition.test.ts +35 -1
- package/src/utils/view-transition.ts +16 -1
|
@@ -16,6 +16,17 @@ export interface LogStreamHandlers {
|
|
|
16
16
|
onEnd?: (data: unknown) => void
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function errorCodeOf(event: Event): string | null {
|
|
20
|
+
if (!(event instanceof MessageEvent) || typeof event.data !== 'string') return null
|
|
21
|
+
try {
|
|
22
|
+
const data: unknown = JSON.parse(event.data)
|
|
23
|
+
if (typeof data === 'object' && data !== null && 'error_code' in data && typeof data.error_code === 'string') return data.error_code
|
|
24
|
+
} catch {
|
|
25
|
+
// Not JSON: no code.
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
/**
|
|
20
31
|
* Manages an SSE log stream: EventSource lifecycle, isStreaming state, cleanup.
|
|
21
32
|
* Callers provide a factory function that creates the EventSource with current params.
|
|
@@ -24,6 +35,8 @@ export function useLogStream() {
|
|
|
24
35
|
const [isStreaming, setIsStreaming] = useState(false)
|
|
25
36
|
// Set when the connection fails (and not on a clean end — see endedRef).
|
|
26
37
|
const [streamError, setStreamError] = useState<string | null>(null)
|
|
38
|
+
// The server's error_code for that failure, when it sent one.
|
|
39
|
+
const [streamErrorCode, setStreamErrorCode] = useState<string | null>(null)
|
|
27
40
|
// True from a start attempt until the stream first settles (connected / end /
|
|
28
41
|
// error / stop). Lets callers show a connecting spinner that won't reappear
|
|
29
42
|
// after a clean end. Starts true so an auto-stream viewer paints the spinner
|
|
@@ -40,6 +53,7 @@ export function useLogStream() {
|
|
|
40
53
|
setIsStreaming(false)
|
|
41
54
|
setConnecting(false)
|
|
42
55
|
setStreamError(null)
|
|
56
|
+
setStreamErrorCode(null)
|
|
43
57
|
}, [])
|
|
44
58
|
|
|
45
59
|
const startStreaming = useCallback((
|
|
@@ -50,6 +64,7 @@ export function useLogStream() {
|
|
|
50
64
|
eventSourceRef.current?.close()
|
|
51
65
|
endedRef.current = false
|
|
52
66
|
setStreamError(null)
|
|
67
|
+
setStreamErrorCode(null)
|
|
53
68
|
setConnecting(true)
|
|
54
69
|
const es = create()
|
|
55
70
|
// Ignore events from a superseded source: closing/replacing an EventSource
|
|
@@ -124,6 +139,7 @@ export function useLogStream() {
|
|
|
124
139
|
// 'end'; that's not a failure, so don't log it or surface it.
|
|
125
140
|
if (endedRef.current) return
|
|
126
141
|
setStreamError(handleSSEError(event, errorContext, () => {}))
|
|
142
|
+
setStreamErrorCode(errorCodeOf(event))
|
|
127
143
|
})
|
|
128
144
|
|
|
129
145
|
eventSourceRef.current = es
|
|
@@ -132,5 +148,5 @@ export function useLogStream() {
|
|
|
132
148
|
// Cleanup on unmount
|
|
133
149
|
useEffect(() => () => { eventSourceRef.current?.close() }, [])
|
|
134
150
|
|
|
135
|
-
return { isStreaming, streamError, connecting, startStreaming, stopStreaming }
|
|
151
|
+
return { isStreaming, streamError, streamErrorCode, connecting, startStreaming, stopStreaming }
|
|
136
152
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './problems'
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from 'vitest'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { ProblemMeta, WorkspaceProblemCallout, ProblemList, problemOriginLabel, type WorkspaceProblem } from './problems'
|
|
4
|
+
|
|
5
|
+
const details = (kind: string) => ({
|
|
6
|
+
id: 'p',
|
|
7
|
+
tone: 'degraded' as const,
|
|
8
|
+
category: 'availability',
|
|
9
|
+
title: 'Something needs a look',
|
|
10
|
+
subject: { kind, group: 'example.io', namespace: 'ns', name: 'child-1' },
|
|
11
|
+
})
|
|
12
|
+
const problem = (kind: string): Extract<WorkspaceProblem, { source: 'issue' }> => ({ ...details(kind), source: 'issue' })
|
|
13
|
+
|
|
14
|
+
describe('ProblemMeta', () => {
|
|
15
|
+
it('names the subject only when it is not the workspace root kind', () => {
|
|
16
|
+
expect(renderToStaticMarkup(<ProblemMeta problem={problem('Backup')} rootKind="Cluster" />)).toContain('Backup')
|
|
17
|
+
expect(renderToStaticMarkup(<ProblemMeta problem={problem('Cluster')} rootKind="Cluster" />)).not.toContain('child-1')
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('problemOriginLabel', () => {
|
|
22
|
+
it('says who measured a measurement, and never calls an issue generic', () => {
|
|
23
|
+
expect(problemOriginLabel({ ...details('Cluster'), source: 'measurement', measuredBy: 'Prometheus' }).label).toBe('Measured by Prometheus')
|
|
24
|
+
expect(problemOriginLabel(problem('Cluster')).label).toBe('Detected by Radar')
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('keeps original scheduler evidence behind a closed disclosure in callouts and lists', () => {
|
|
29
|
+
const p = { ...problem('Pod'), detail: 'both nodes have reached their Pod limit', rawDetailLabel: 'Scheduler message', rawDetail: '0/2 nodes are available: 2 Too many pods.' }
|
|
30
|
+
for (const node of [<WorkspaceProblemCallout problem={p} rootKind="Cluster" />, <ProblemList problems={[p]} rootKind="Cluster" />]) {
|
|
31
|
+
const html = renderToStaticMarkup(node)
|
|
32
|
+
expect(html).toContain(p.detail)
|
|
33
|
+
expect(html).toContain(p.rawDetail)
|
|
34
|
+
expect(html).toContain('Scheduler message')
|
|
35
|
+
expect(html).toContain('aria-expanded="false"')
|
|
36
|
+
}
|
|
37
|
+
expect(renderToStaticMarkup(<WorkspaceProblemCallout problem={problem('Pod')} rootKind="Cluster" />)).not.toContain('Scheduler message')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('uses a generic label for raw evidence from another controller', () => {
|
|
41
|
+
const p = { ...problem('Deployment'), rawDetail: 'Progress deadline exceeded.' }
|
|
42
|
+
expect(renderToStaticMarkup(<WorkspaceProblemCallout problem={p} rootKind="Application" />)).toContain('Original evidence')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('keeps source-specific provenance fields exclusive in the public type', () => {
|
|
46
|
+
type Details = ReturnType<typeof details>
|
|
47
|
+
expectTypeOf<Details & { source: 'issue'; measuredBy: string }>().not.toMatchTypeOf<WorkspaceProblem>()
|
|
48
|
+
expectTypeOf<Details & { source: 'measurement'; origin: { label: string } }>().not.toMatchTypeOf<WorkspaceProblem>()
|
|
49
|
+
expectTypeOf<Details & { source: 'audit'; origin: { label: string } }>().not.toMatchTypeOf<WorkspaceProblem>()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('preserves alert and unknown tones in callouts and compact lists', () => {
|
|
53
|
+
for (const tone of ['alert', 'unknown'] as const) {
|
|
54
|
+
const p = { ...problem('Pod'), tone }
|
|
55
|
+
const callout = renderToStaticMarkup(<WorkspaceProblemCallout problem={p} rootKind="Cluster" />)
|
|
56
|
+
expect(callout).toContain(`status-${tone}`)
|
|
57
|
+
const list = renderToStaticMarkup(<ProblemList problems={[p]} rootKind="Cluster" />)
|
|
58
|
+
expect(list).toContain(tone === 'alert' ? 'bg-orange-500' : 'bg-slate-400')
|
|
59
|
+
expect(list).toContain(tone === 'alert' ? 'text-orange-600' : 'text-theme-text-tertiary')
|
|
60
|
+
}
|
|
61
|
+
type HealthyProblem = Omit<ReturnType<typeof details>, 'tone'> & { source: 'issue'; tone: 'healthy' }
|
|
62
|
+
expectTypeOf<HealthyProblem>().not.toMatchTypeOf<WorkspaceProblem>()
|
|
63
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import type { HealthLevel } from '../resources/resource-utils'
|
|
4
|
+
import type { ResourceRef } from '../../types/core'
|
|
5
|
+
import { healthToSeverity } from '../../utils/badge-colors'
|
|
6
|
+
import { StatusDot, toneTextClass } from '../ui/status-tone'
|
|
7
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
8
|
+
import { AlertBanner } from '../ui/drawer-components'
|
|
9
|
+
import { FoldSection } from '../ui/FoldSection'
|
|
10
|
+
import { RefLink, type NavigateToRef } from '../ui/RefLink'
|
|
11
|
+
|
|
12
|
+
/** Where a problem's evidence comes from, in user terms. */
|
|
13
|
+
export interface ProblemOrigin {
|
|
14
|
+
label: string
|
|
15
|
+
/** The exact field or condition, shown on hover. */
|
|
16
|
+
detail?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Something about a workspace object that needs a look. `C` is the
|
|
21
|
+
* integration's own category set.
|
|
22
|
+
*/
|
|
23
|
+
export interface ProblemDetails<C extends string = string> {
|
|
24
|
+
/** Stable identity for keys. */
|
|
25
|
+
id: string
|
|
26
|
+
category: C
|
|
27
|
+
title: string
|
|
28
|
+
detail?: string
|
|
29
|
+
rawDetail?: string
|
|
30
|
+
rawDetailLabel?: string
|
|
31
|
+
/** The object the evidence is about: the workspace's root object or one of its children. */
|
|
32
|
+
subject: ResourceRef & { group: string }
|
|
33
|
+
/** A shorter headline for tight places (a fleet cell); `title` stays the precise one. */
|
|
34
|
+
shortTitle?: string
|
|
35
|
+
/** Other objects the same problem is about, e.g. earlier runs that failed the same way. */
|
|
36
|
+
alsoAbout?: Pick<ResourceRef, 'kind' | 'name'>[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ProblemProvenance =
|
|
40
|
+
| { source: 'issue'; origin?: ProblemOrigin; measuredBy?: never; sourceDetail?: never }
|
|
41
|
+
| { source: 'audit'; sourceDetail?: string; origin?: never; measuredBy?: never }
|
|
42
|
+
| { source: 'measurement'; measuredBy?: string; sourceDetail?: string; origin?: never }
|
|
43
|
+
|
|
44
|
+
export type WorkspaceProblem<C extends string = string> = ProblemDetails<C> & ProblemProvenance & { tone: Exclude<HealthLevel, 'healthy'> }
|
|
45
|
+
|
|
46
|
+
/** A problem's provenance label: where its evidence comes from, never a generic "Radar issue". */
|
|
47
|
+
export function problemOriginLabel(problem: WorkspaceProblem): ProblemOrigin {
|
|
48
|
+
switch (problem.source) {
|
|
49
|
+
case 'audit':
|
|
50
|
+
return { label: 'Best-practice check', detail: problem.sourceDetail }
|
|
51
|
+
case 'measurement':
|
|
52
|
+
return { label: problem.measuredBy ? `Measured by ${problem.measuredBy}` : 'Measured', detail: problem.sourceDetail }
|
|
53
|
+
}
|
|
54
|
+
return problem.origin ?? { label: 'Detected by Radar' }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* How a host opens a problem on its Issues page. Supplied by context so every
|
|
59
|
+
* summary and drawer gets the link without threading a prop through each.
|
|
60
|
+
*/
|
|
61
|
+
export const OpenIssueContext = createContext<((problem: WorkspaceProblem) => void) | undefined>(undefined)
|
|
62
|
+
|
|
63
|
+
export function ProblemMeta({
|
|
64
|
+
problem,
|
|
65
|
+
rootKind,
|
|
66
|
+
onNavigate,
|
|
67
|
+
subjectIsSelf,
|
|
68
|
+
children,
|
|
69
|
+
}: {
|
|
70
|
+
problem: WorkspaceProblem
|
|
71
|
+
/** The workspace's root kind: a problem about another kind names its subject. */
|
|
72
|
+
rootKind: string
|
|
73
|
+
onNavigate?: NavigateToRef
|
|
74
|
+
subjectIsSelf?: boolean
|
|
75
|
+
children?: ReactNode
|
|
76
|
+
}) {
|
|
77
|
+
const openIssue = useContext(OpenIssueContext)
|
|
78
|
+
const origin = problemOriginLabel(problem)
|
|
79
|
+
const aboutChild = !subjectIsSelf && problem.subject.kind !== rootKind
|
|
80
|
+
return (
|
|
81
|
+
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-theme-text-tertiary">
|
|
82
|
+
{aboutChild && (
|
|
83
|
+
<span>
|
|
84
|
+
{problem.subject.kind}{' '}
|
|
85
|
+
<RefLink refTo={problem.subject} onNavigate={onNavigate} mono />
|
|
86
|
+
{problem.alsoAbout && problem.alsoAbout.length > 0 && ' '}
|
|
87
|
+
{problem.alsoAbout && problem.alsoAbout.length > 0 && (
|
|
88
|
+
<Tooltip
|
|
89
|
+
content={
|
|
90
|
+
<ul>
|
|
91
|
+
{problem.alsoAbout.map((o) => (
|
|
92
|
+
<li key={`${o.kind}/${o.name}`} className="font-mono">
|
|
93
|
+
{o.kind} {o.name}
|
|
94
|
+
</li>
|
|
95
|
+
))}
|
|
96
|
+
</ul>
|
|
97
|
+
}
|
|
98
|
+
>
|
|
99
|
+
<span>and {problem.alsoAbout.length} more</span>
|
|
100
|
+
</Tooltip>
|
|
101
|
+
)}
|
|
102
|
+
</span>
|
|
103
|
+
)}
|
|
104
|
+
<Tooltip content={origin.detail} disabled={!origin.detail}>
|
|
105
|
+
<span>{origin.label}</span>
|
|
106
|
+
</Tooltip>
|
|
107
|
+
{openIssue && problem.source === 'issue' && (
|
|
108
|
+
<button type="button" onClick={() => openIssue(problem)} className="whitespace-nowrap text-accent-text hover:underline">
|
|
109
|
+
See in Issues →
|
|
110
|
+
</button>
|
|
111
|
+
)}
|
|
112
|
+
{children}
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function WorkspaceProblemCallout({
|
|
118
|
+
problem,
|
|
119
|
+
rootKind,
|
|
120
|
+
more,
|
|
121
|
+
onNavigate,
|
|
122
|
+
action,
|
|
123
|
+
subjectIsSelf,
|
|
124
|
+
}: {
|
|
125
|
+
problem: WorkspaceProblem
|
|
126
|
+
rootKind: string
|
|
127
|
+
more?: ReactNode
|
|
128
|
+
onNavigate?: NavigateToRef
|
|
129
|
+
action?: ReactNode
|
|
130
|
+
/** The callout sits on the subject's own page, so linking to it would loop. */
|
|
131
|
+
subjectIsSelf?: boolean
|
|
132
|
+
}) {
|
|
133
|
+
const severity = healthToSeverity(problem.tone)
|
|
134
|
+
const variant = severity === 'neutral' ? 'unknown' : severity
|
|
135
|
+
return (
|
|
136
|
+
<AlertBanner variant={variant} title={problem.title} message={problem.detail}>
|
|
137
|
+
{problem.rawDetail && <FoldSection title={problem.rawDetailLabel ?? "Original evidence"} summary="" attention={false}><div className="break-words text-xs text-theme-text-secondary">{problem.rawDetail}</div></FoldSection>}
|
|
138
|
+
<ProblemMeta problem={problem} rootKind={rootKind} onNavigate={onNavigate} subjectIsSelf={subjectIsSelf}>
|
|
139
|
+
{action}
|
|
140
|
+
{more}
|
|
141
|
+
</ProblemMeta>
|
|
142
|
+
</AlertBanner>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The problems a callout does not show, as a compact list with the callout's tone, title and source. */
|
|
147
|
+
export function ProblemList({ problems, rootKind, onNavigate }: { problems: WorkspaceProblem[]; rootKind: string; onNavigate?: NavigateToRef }) {
|
|
148
|
+
return (
|
|
149
|
+
<ul className="space-y-2">
|
|
150
|
+
{problems.map((p) => (
|
|
151
|
+
<li key={p.id} className="flex items-start gap-2 text-sm">
|
|
152
|
+
<span className="mt-1.5 shrink-0">
|
|
153
|
+
<StatusDot tone={p.tone} size="sm" />
|
|
154
|
+
</span>
|
|
155
|
+
<div className="min-w-0">
|
|
156
|
+
<div className={clsx('font-medium break-words', toneTextClass(p.tone))}>{p.title}</div>
|
|
157
|
+
{p.detail && <div className="text-xs text-theme-text-secondary break-words">{p.detail}</div>}
|
|
158
|
+
{p.rawDetail && <FoldSection title={p.rawDetailLabel ?? "Original evidence"} summary="" attention={false}><div className="break-words text-xs text-theme-text-secondary">{p.rawDetail}</div></FoldSection>}
|
|
159
|
+
<ProblemMeta problem={p} rootKind={rootKind} onNavigate={onNavigate} />
|
|
160
|
+
</div>
|
|
161
|
+
</li>
|
|
162
|
+
))}
|
|
163
|
+
</ul>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { ActiveFilterBar, type ActiveFilterBarProps } from './ActiveFilterBar'
|
|
6
|
+
|
|
7
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
8
|
+
|
|
9
|
+
let root: Root | null = null
|
|
10
|
+
let element: HTMLDivElement
|
|
11
|
+
|
|
12
|
+
async function render(overrides: Partial<ActiveFilterBarProps> = {}) {
|
|
13
|
+
const props: ActiveFilterBarProps = {
|
|
14
|
+
columnFilters: {},
|
|
15
|
+
columnFilterExcludes: {},
|
|
16
|
+
problemFilters: [],
|
|
17
|
+
labelSelector: '',
|
|
18
|
+
onClearColumn: vi.fn(),
|
|
19
|
+
onClearProblems: vi.fn(),
|
|
20
|
+
onClearLabels: vi.fn(),
|
|
21
|
+
...overrides,
|
|
22
|
+
}
|
|
23
|
+
element = document.createElement('div')
|
|
24
|
+
root = createRoot(element)
|
|
25
|
+
await act(async () => root!.render(<ActiveFilterBar {...props} />))
|
|
26
|
+
return props
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buttonWithText(text: string): HTMLButtonElement {
|
|
30
|
+
const button = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes(text))
|
|
31
|
+
if (!button) throw new Error(`no button containing "${text}"`)
|
|
32
|
+
return button
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await act(async () => root?.unmount())
|
|
37
|
+
root = null
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('ActiveFilterBar', () => {
|
|
41
|
+
it('renders nothing without active filters or a restore', async () => {
|
|
42
|
+
await render({ columnFilters: { status: [] } })
|
|
43
|
+
expect(element.innerHTML).toBe('')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('renders one chip per active filter, marking excluded columns', async () => {
|
|
47
|
+
await render({
|
|
48
|
+
columnFilters: { status: ['Running', 'Pending'], node: ['n1'], empty: [] },
|
|
49
|
+
columnFilterExcludes: { node: true },
|
|
50
|
+
problemFilters: ['crashloop'],
|
|
51
|
+
labelSelector: 'app=nginx',
|
|
52
|
+
})
|
|
53
|
+
expect(element.querySelectorAll('button')).toHaveLength(4)
|
|
54
|
+
expect(element.textContent).toContain('status: Running, Pending')
|
|
55
|
+
expect(element.textContent).toContain('node: not n1')
|
|
56
|
+
expect(element.textContent).toContain('Problems: crashloop')
|
|
57
|
+
expect(element.textContent).toContain('app=nginx')
|
|
58
|
+
expect(element.textContent).not.toContain('restored')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('calls the matching clear handler when a chip is clicked', async () => {
|
|
62
|
+
const props = await render({ columnFilters: { status: ['Running'] }, problemFilters: ['oom'], labelSelector: 'tier=web' })
|
|
63
|
+
await act(async () => buttonWithText('status:').click())
|
|
64
|
+
await act(async () => buttonWithText('Problems:').click())
|
|
65
|
+
await act(async () => buttonWithText('tier=web').click())
|
|
66
|
+
expect(props.onClearColumn).toHaveBeenCalledWith('status')
|
|
67
|
+
expect(props.onClearProblems).toHaveBeenCalledOnce()
|
|
68
|
+
expect(props.onClearLabels).toHaveBeenCalledOnce()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('shows the restored line only when restored, with Clear calling onClearAll', async () => {
|
|
72
|
+
const onClearAll = vi.fn()
|
|
73
|
+
await render({ restored: true, onClearAll, columnFilters: { status: ['Running'] } })
|
|
74
|
+
expect(element.textContent).toContain('Filters restored from your last visit')
|
|
75
|
+
await act(async () => buttonWithText('Clear').click())
|
|
76
|
+
expect(onClearAll).toHaveBeenCalledOnce()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { clsx } from 'clsx'
|
|
2
|
+
import { AlertTriangle, History, ListFilter, Tag, X } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
export interface ActiveFilterBarProps {
|
|
5
|
+
columnFilters: Record<string, string[]>
|
|
6
|
+
columnFilterExcludes: Record<string, boolean>
|
|
7
|
+
problemFilters: string[]
|
|
8
|
+
labelSelector: string
|
|
9
|
+
onClearColumn: (key: string) => void
|
|
10
|
+
onClearProblems: () => void
|
|
11
|
+
onClearLabels: () => void
|
|
12
|
+
restored?: boolean
|
|
13
|
+
onClearAll?: () => void
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ActiveFilterBar({
|
|
18
|
+
columnFilters,
|
|
19
|
+
columnFilterExcludes,
|
|
20
|
+
problemFilters,
|
|
21
|
+
labelSelector,
|
|
22
|
+
onClearColumn,
|
|
23
|
+
onClearProblems,
|
|
24
|
+
onClearLabels,
|
|
25
|
+
restored = false,
|
|
26
|
+
onClearAll,
|
|
27
|
+
className,
|
|
28
|
+
}: ActiveFilterBarProps) {
|
|
29
|
+
const activeColEntries = Object.entries(columnFilters).filter(([, vals]) => vals.length > 0)
|
|
30
|
+
const hasChips = activeColEntries.length > 0 || problemFilters.length > 0 || !!labelSelector
|
|
31
|
+
if (!hasChips && !restored) return null
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className={clsx('flex flex-wrap items-center gap-1.5', className)}>
|
|
35
|
+
{restored && (
|
|
36
|
+
<span className="flex items-center gap-1 text-xs text-theme-text-tertiary">
|
|
37
|
+
<History className="w-3 h-3" />
|
|
38
|
+
<span>Filters restored from your last visit.</span>
|
|
39
|
+
{onClearAll && (
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
onClick={onClearAll}
|
|
43
|
+
className="text-theme-text-secondary hover:text-theme-text-primary underline underline-offset-2"
|
|
44
|
+
>
|
|
45
|
+
Clear
|
|
46
|
+
</button>
|
|
47
|
+
)}
|
|
48
|
+
</span>
|
|
49
|
+
)}
|
|
50
|
+
{activeColEntries.map(([key, vals]) => (
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
key={key}
|
|
54
|
+
onClick={() => onClearColumn(key)}
|
|
55
|
+
className="flex items-center gap-1 px-2 py-1 text-xs selection selection-text rounded-md hover:selection-strong transition-colors"
|
|
56
|
+
>
|
|
57
|
+
<ListFilter className="w-3 h-3" />
|
|
58
|
+
<span>{key}: {columnFilterExcludes[key] ? 'not ' : ''}{vals.join(', ')}</span>
|
|
59
|
+
<X className="w-3 h-3" />
|
|
60
|
+
</button>
|
|
61
|
+
))}
|
|
62
|
+
{problemFilters.length > 0 && (
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={onClearProblems}
|
|
66
|
+
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/15 text-red-700 dark:text-red-300 rounded-md hover:bg-red-500/25 transition-colors"
|
|
67
|
+
>
|
|
68
|
+
<AlertTriangle className="w-3 h-3" />
|
|
69
|
+
<span>Problems: {problemFilters.join(', ')}</span>
|
|
70
|
+
<X className="w-3 h-3" />
|
|
71
|
+
</button>
|
|
72
|
+
)}
|
|
73
|
+
{labelSelector && (
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={onClearLabels}
|
|
77
|
+
className="flex items-center gap-1 px-2 py-1 text-xs bg-green-500/15 text-green-700 dark:text-green-300 rounded-md hover:bg-green-500/25 transition-colors"
|
|
78
|
+
>
|
|
79
|
+
<Tag className="w-3 h-3" />
|
|
80
|
+
<span>{labelSelector}</span>
|
|
81
|
+
<X className="w-3 h-3" />
|
|
82
|
+
</button>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import type React from 'react'
|
|
3
|
+
import { GripVertical, X } from 'lucide-react'
|
|
4
|
+
import { clsx } from 'clsx'
|
|
5
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
6
|
+
import { isMac } from '../../utils/platform'
|
|
7
|
+
import { moveColumn, pinnedColumnCount } from './column-order'
|
|
8
|
+
import type { Column } from './ResourcesView'
|
|
9
|
+
|
|
10
|
+
interface ColumnPickerListProps {
|
|
11
|
+
/** Every offered column, in display order. */
|
|
12
|
+
columns: Column[]
|
|
13
|
+
visibleColumns: Set<string>
|
|
14
|
+
customColumnKeys: Set<string>
|
|
15
|
+
onToggle: (key: string) => void
|
|
16
|
+
onRemoveCustom: (key: string) => void
|
|
17
|
+
/** Receives the movable column keys in their new order. */
|
|
18
|
+
onReorder: (order: string[]) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The Columns picker's rows: show/hide, remove a custom column, and reorder by
|
|
22
|
+
* dragging the grip or with Alt/Option+Up/Down on the row's checkbox. */
|
|
23
|
+
export function ColumnPickerList({
|
|
24
|
+
columns,
|
|
25
|
+
visibleColumns,
|
|
26
|
+
customColumnKeys,
|
|
27
|
+
onToggle,
|
|
28
|
+
onRemoveCustom,
|
|
29
|
+
onReorder,
|
|
30
|
+
}: ColumnPickerListProps) {
|
|
31
|
+
const pinned = pinnedColumnCount(columns)
|
|
32
|
+
const [dragKey, setDragKey] = useState<string | null>(null)
|
|
33
|
+
// Insertion point among the movable rows: 0 = before the first one.
|
|
34
|
+
const [dropGap, setDropGap] = useState<number | null>(null)
|
|
35
|
+
const [announcement, setAnnouncement] = useState('')
|
|
36
|
+
const hintId = useId()
|
|
37
|
+
const mac = isMac()
|
|
38
|
+
|
|
39
|
+
// React moves a keyed row by re-inserting DOM nodes, and re-inserting the
|
|
40
|
+
// focused checkbox drops focus. Put it back so repeated presses keep working.
|
|
41
|
+
const refocus = useRef<HTMLElement | null>(null)
|
|
42
|
+
useLayoutEffect(() => {
|
|
43
|
+
const el = refocus.current
|
|
44
|
+
if (!el) return
|
|
45
|
+
refocus.current = null
|
|
46
|
+
if (el.isConnected && document.activeElement !== el) el.focus()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const commitMove = (key: string, toIndex: number) => {
|
|
50
|
+
const next = moveColumn(columns, key, toIndex)
|
|
51
|
+
if (!next) return false
|
|
52
|
+
onReorder(next)
|
|
53
|
+
const col = columns.find(c => c.key === key)
|
|
54
|
+
setAnnouncement(`${col?.label ?? key} moved to position ${pinned + next.indexOf(key) + 1} of ${columns.length}.`)
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const handleKeyDown = (e: React.KeyboardEvent, key: string, movableIndex: number) => {
|
|
59
|
+
if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return
|
|
60
|
+
e.preventDefault()
|
|
61
|
+
e.stopPropagation()
|
|
62
|
+
const up = e.key === 'ArrowUp'
|
|
63
|
+
refocus.current = e.target instanceof HTMLElement ? e.target : null
|
|
64
|
+
if (!commitMove(key, movableIndex + (up ? -1 : 1))) {
|
|
65
|
+
refocus.current = null
|
|
66
|
+
const label = columns.find(c => c.key === key)?.label ?? key
|
|
67
|
+
setAnnouncement(up ? `${label} is already at the top.` : `${label} is already at the bottom.`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The gap above the row when the pointer is in its top half, else below it.
|
|
72
|
+
const gapAt = (e: React.DragEvent, movableIndex: number) => {
|
|
73
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
74
|
+
return e.clientY < rect.top + rect.height / 2 ? movableIndex : movableIndex + 1
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const endDrag = () => {
|
|
78
|
+
setDragKey(null)
|
|
79
|
+
setDropGap(null)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
<span id={hintId} className="sr-only">
|
|
85
|
+
{mac ? 'Press Option+Up Arrow or Option+Down Arrow to move this column.' : 'Press Alt+Up Arrow or Alt+Down Arrow to move this column.'}
|
|
86
|
+
</span>
|
|
87
|
+
<span className="sr-only" role="status" aria-live="polite">{announcement}</span>
|
|
88
|
+
{columns.map((col, i) => {
|
|
89
|
+
const isCustom = customColumnKeys.has(col.key)
|
|
90
|
+
const movableIndex = i - pinned
|
|
91
|
+
const movable = movableIndex >= 0
|
|
92
|
+
const fromIndex = dragKey ? columns.findIndex(c => c.key === dragKey) - pinned : -1
|
|
93
|
+
const showGap = (gap: number) => dropGap === gap && gap !== fromIndex && gap !== fromIndex + 1
|
|
94
|
+
return (
|
|
95
|
+
<div
|
|
96
|
+
key={col.key}
|
|
97
|
+
data-column-row
|
|
98
|
+
className={clsx(
|
|
99
|
+
'group relative flex items-center gap-1.5 pl-1.5 pr-3 py-1.5 hover:bg-theme-elevated',
|
|
100
|
+
dragKey === col.key && 'opacity-50',
|
|
101
|
+
)}
|
|
102
|
+
onKeyDown={movable ? e => handleKeyDown(e, col.key, movableIndex) : undefined}
|
|
103
|
+
onDragOver={movable && dragKey ? e => {
|
|
104
|
+
e.preventDefault()
|
|
105
|
+
e.dataTransfer.dropEffect = 'move'
|
|
106
|
+
setDropGap(gapAt(e, movableIndex))
|
|
107
|
+
} : undefined}
|
|
108
|
+
onDrop={movable && dragKey ? e => {
|
|
109
|
+
e.preventDefault()
|
|
110
|
+
const gap = gapAt(e, movableIndex)
|
|
111
|
+
commitMove(dragKey, gap > fromIndex ? gap - 1 : gap)
|
|
112
|
+
endDrag()
|
|
113
|
+
} : undefined}
|
|
114
|
+
>
|
|
115
|
+
{movable && showGap(movableIndex) && (
|
|
116
|
+
<span aria-hidden className="pointer-events-none absolute inset-x-2 -top-px h-0.5 rounded-full bg-accent" />
|
|
117
|
+
)}
|
|
118
|
+
{movable && showGap(movableIndex + 1) && i === columns.length - 1 && (
|
|
119
|
+
<span aria-hidden className="pointer-events-none absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
|
|
120
|
+
)}
|
|
121
|
+
{movable ? (
|
|
122
|
+
<Tooltip content={mac ? 'Drag to reorder, or press ⌥↑ / ⌥↓' : 'Drag to reorder, or press Alt+↑ / Alt+↓'} position="left">
|
|
123
|
+
<span
|
|
124
|
+
aria-hidden
|
|
125
|
+
draggable
|
|
126
|
+
onDragStart={e => {
|
|
127
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
128
|
+
// Firefox starts a drag only when data is set.
|
|
129
|
+
e.dataTransfer.setData('text/plain', col.key)
|
|
130
|
+
const row = e.currentTarget.closest('[data-column-row]')
|
|
131
|
+
if (row instanceof HTMLElement) e.dataTransfer.setDragImage(row, 12, row.offsetHeight / 2)
|
|
132
|
+
setDragKey(col.key)
|
|
133
|
+
}}
|
|
134
|
+
onDragEnd={endDrag}
|
|
135
|
+
className="flex shrink-0 cursor-grab items-center text-theme-text-tertiary opacity-60 group-hover:opacity-100 active:cursor-grabbing"
|
|
136
|
+
>
|
|
137
|
+
<GripVertical className="w-3.5 h-3.5" />
|
|
138
|
+
</span>
|
|
139
|
+
</Tooltip>
|
|
140
|
+
) : (
|
|
141
|
+
<span aria-hidden className="w-3.5 shrink-0" />
|
|
142
|
+
)}
|
|
143
|
+
<label className="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
|
|
144
|
+
<input
|
|
145
|
+
type="checkbox"
|
|
146
|
+
checked={visibleColumns.has(col.key)}
|
|
147
|
+
onChange={() => onToggle(col.key)}
|
|
148
|
+
disabled={col.key === 'name'}
|
|
149
|
+
aria-describedby={movable ? hintId : undefined}
|
|
150
|
+
className="rounded border-theme-border"
|
|
151
|
+
/>
|
|
152
|
+
<span className={clsx(
|
|
153
|
+
'text-sm truncate',
|
|
154
|
+
col.key === 'name' ? 'text-theme-text-tertiary' : 'text-theme-text-primary'
|
|
155
|
+
)} title={col.tooltip || col.label}>
|
|
156
|
+
{col.label}
|
|
157
|
+
</span>
|
|
158
|
+
</label>
|
|
159
|
+
{isCustom && (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={() => onRemoveCustom(col.key)}
|
|
163
|
+
className="shrink-0 p-0.5 text-theme-text-tertiary hover:text-red-500 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
|
164
|
+
title="Remove column"
|
|
165
|
+
>
|
|
166
|
+
<X className="w-3.5 h-3.5" />
|
|
167
|
+
</button>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
})}
|
|
172
|
+
</>
|
|
173
|
+
)
|
|
174
|
+
}
|