@skyhook-io/k8s-ui 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,8 @@
1
- import { useEffect, useRef, ReactNode } from 'react'
2
- import { createPortal } from 'react-dom'
1
+ import { ReactNode } from 'react'
3
2
  import { AlertTriangle, X } from 'lucide-react'
4
3
  import { clsx } from 'clsx'
5
4
  import { SEVERITY_TEXT, SEVERITY_BADGE_BORDERED } from '../../utils/badge-colors'
6
- import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
7
- import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
5
+ import { DialogPortal } from './DialogPortal'
8
6
 
9
7
 
10
8
  interface ConfirmDialogProps {
@@ -18,6 +16,7 @@ interface ConfirmDialogProps {
18
16
  cancelLabel?: string
19
17
  variant?: 'danger' | 'warning'
20
18
  isLoading?: boolean
19
+ isClosable?: boolean // Allow closing even when isLoading (e.g., for long-running ops the user can dismiss)
21
20
  children?: ReactNode // Optional custom content (e.g., checkboxes)
22
21
  }
23
22
 
@@ -32,99 +31,56 @@ export function ConfirmDialog({
32
31
  cancelLabel = 'Cancel',
33
32
  variant = 'danger',
34
33
  isLoading = false,
34
+ isClosable = false,
35
35
  children,
36
36
  }: ConfirmDialogProps) {
37
- const dialogRef = useRef<HTMLDivElement>(null)
38
- const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
39
-
40
- // Handle ESC key
41
- useEffect(() => {
42
- if (!open) return
43
-
44
- const handleKeyDown = (e: KeyboardEvent) => {
45
- if (e.key === 'Escape' && !isLoading) {
46
- e.stopPropagation()
47
- onClose()
48
- }
49
- }
50
- document.addEventListener('keydown', handleKeyDown, true)
51
- return () => document.removeEventListener('keydown', handleKeyDown, true)
52
- }, [open, onClose, isLoading])
53
-
54
- // Focus trap
55
- useEffect(() => {
56
- if (open && dialogRef.current) {
57
- dialogRef.current.focus()
58
- }
59
- }, [open])
60
-
61
- if (!shouldRender) return null
62
-
37
+ const canClose = !isLoading || isClosable
63
38
  const isDanger = variant === 'danger'
64
39
  const severity = isDanger ? 'error' : 'warning'
65
40
 
66
- return createPortal(
67
- <div className="fixed inset-0 z-50 flex items-center justify-center">
68
- {/* Backdrop */}
69
- <div
70
- className={clsx(
71
- 'absolute inset-0 bg-black/60 backdrop-blur-sm',
72
- TRANSITION_BACKDROP,
73
- isOpen ? 'opacity-100' : 'opacity-0'
74
- )}
75
- onClick={isLoading ? undefined : onClose}
76
- />
77
-
78
- {/* Dialog */}
79
- <div
80
- ref={dialogRef}
81
- tabIndex={-1}
82
- className={clsx(
83
- 'relative bg-theme-surface border border-theme-border rounded-lg shadow-2xl max-w-md w-full mx-4 outline-none',
84
- TRANSITION_PANEL,
85
- isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
86
- )}
87
- >
88
- {/* Header */}
89
- <div className="flex items-start gap-3 p-4 border-b border-theme-border">
90
- <div
91
- className={clsx(
92
- 'flex items-center justify-center w-10 h-10 rounded-full shrink-0',
93
- isDanger ? 'bg-red-500/20' : 'bg-amber-500/20'
94
- )}
95
- >
96
- <AlertTriangle className={clsx('w-5 h-5', SEVERITY_TEXT[severity])} />
97
- </div>
98
- <div className="flex-1 min-w-0">
99
- <h3 className="text-lg font-semibold text-theme-text-primary">{title}</h3>
100
- <p className="text-sm text-theme-text-secondary mt-1">{message}</p>
101
- </div>
102
- <button
103
- onClick={onClose}
104
- disabled={isLoading}
105
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50"
106
- >
107
- <X className="w-5 h-5" />
108
- </button>
41
+ return (
42
+ <DialogPortal open={open} onClose={onClose} closable={canClose} className="max-w-md w-full">
43
+ {/* Header */}
44
+ <div className="flex items-start gap-3 p-4 border-b border-theme-border">
45
+ <div
46
+ className={clsx(
47
+ 'flex items-center justify-center w-10 h-10 rounded-full shrink-0',
48
+ isDanger ? 'bg-red-500/20' : 'bg-amber-500/20'
49
+ )}
50
+ >
51
+ <AlertTriangle className={clsx('w-5 h-5', SEVERITY_TEXT[severity])} />
109
52
  </div>
53
+ <div className="flex-1 min-w-0">
54
+ <h3 className="text-lg font-semibold text-theme-text-primary">{title}</h3>
55
+ <p className="text-sm text-theme-text-secondary mt-1">{message}</p>
56
+ </div>
57
+ <button
58
+ onClick={onClose}
59
+ disabled={!canClose}
60
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50"
61
+ >
62
+ <X className="w-5 h-5" />
63
+ </button>
64
+ </div>
110
65
 
111
- {/* Details */}
112
- {details && (
113
- <div className="p-4 border-b border-theme-border">
114
- <pre className="text-xs text-theme-text-secondary bg-theme-base/50 rounded p-3 overflow-auto max-h-32 whitespace-pre-wrap font-mono">
115
- {details}
116
- </pre>
117
- </div>
118
- )}
66
+ {/* Details */}
67
+ {details && (
68
+ <div className="p-4 border-b border-theme-border">
69
+ <pre className="text-xs text-theme-text-secondary bg-theme-base/50 rounded p-3 overflow-auto max-h-32 whitespace-pre-wrap font-mono">
70
+ {details}
71
+ </pre>
72
+ </div>
73
+ )}
119
74
 
120
- {/* Custom content */}
121
- {children && (
122
- <div className="px-4 pt-4">
123
- {children}
124
- </div>
125
- )}
75
+ {/* Custom content */}
76
+ {children && (
77
+ <div className="px-4 pt-4">
78
+ {children}
79
+ </div>
80
+ )}
126
81
 
127
- {/* Warning message */}
82
+ {/* Warning message — hidden once the action is in progress */}
83
+ {!isLoading && !children && (
128
84
  <div className="p-4">
129
85
  <div
130
86
  className={clsx(
@@ -140,49 +96,48 @@ export function ConfirmDialog({
140
96
  </span>
141
97
  </div>
142
98
  </div>
99
+ )}
143
100
 
144
- {/* Actions */}
145
- <div className="flex items-center justify-end gap-3 p-4 border-t border-theme-border">
146
- <button
147
- onClick={onClose}
148
- disabled={isLoading}
149
- className="px-4 py-2 text-sm font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors disabled:opacity-50"
150
- >
151
- {cancelLabel}
152
- </button>
153
- <button
154
- onClick={onConfirm}
155
- disabled={isLoading}
156
- className={clsx(
157
- 'px-4 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2',
158
- isDanger
159
- ? 'bg-red-600 hover:bg-red-700 text-theme-text-primary'
160
- : 'bg-amber-600 hover:bg-amber-700 text-theme-text-primary'
161
- )}
162
- >
163
- {isLoading && (
164
- <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
165
- <circle
166
- className="opacity-25"
167
- cx="12"
168
- cy="12"
169
- r="10"
170
- stroke="currentColor"
171
- strokeWidth="4"
172
- fill="none"
173
- />
174
- <path
175
- className="opacity-75"
176
- fill="currentColor"
177
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
178
- />
179
- </svg>
180
- )}
181
- {confirmLabel}
182
- </button>
183
- </div>
101
+ {/* Actions */}
102
+ <div className="flex items-center justify-end gap-3 p-4 border-t border-theme-border">
103
+ <button
104
+ onClick={onClose}
105
+ disabled={!canClose}
106
+ className="px-4 py-2 text-sm font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors disabled:opacity-50"
107
+ >
108
+ {cancelLabel}
109
+ </button>
110
+ <button
111
+ onClick={onConfirm}
112
+ disabled={isLoading}
113
+ className={clsx(
114
+ 'px-4 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2',
115
+ isDanger
116
+ ? 'bg-red-600 hover:bg-red-700 text-theme-text-primary'
117
+ : 'bg-amber-600 hover:bg-amber-700 text-theme-text-primary'
118
+ )}
119
+ >
120
+ {isLoading && (
121
+ <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
122
+ <circle
123
+ className="opacity-25"
124
+ cx="12"
125
+ cy="12"
126
+ r="10"
127
+ stroke="currentColor"
128
+ strokeWidth="4"
129
+ fill="none"
130
+ />
131
+ <path
132
+ className="opacity-75"
133
+ fill="currentColor"
134
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
135
+ />
136
+ </svg>
137
+ )}
138
+ {confirmLabel}
139
+ </button>
184
140
  </div>
185
- </div>,
186
- document.body
141
+ </DialogPortal>
187
142
  )
188
143
  }
@@ -0,0 +1,82 @@
1
+ import { useEffect, useRef, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { clsx } from 'clsx'
4
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
5
+ import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
6
+
7
+ interface DialogPortalProps {
8
+ open: boolean
9
+ onClose: () => void
10
+ children: ReactNode
11
+ /** Extra classes on the panel container (width, max-height, etc.) */
12
+ className?: string
13
+ /** Prevent closing via Escape / backdrop click (e.g. during async operation) */
14
+ closable?: boolean
15
+ }
16
+
17
+ /**
18
+ * Minimal dialog primitive — handles portal, backdrop, escape, focus, animation.
19
+ * Renders children inside a centered panel portaled to document.body, so it works
20
+ * correctly even inside CSS-transformed containers (drawers, slide panels).
21
+ *
22
+ * Usage:
23
+ * <DialogPortal open={showDialog} onClose={() => setShowDialog(false)} className="w-80">
24
+ * <h3>Title</h3>
25
+ * <p>Content</p>
26
+ * </DialogPortal>
27
+ */
28
+ export function DialogPortal({ open, onClose, children, className, closable = true }: DialogPortalProps) {
29
+ const dialogRef = useRef<HTMLDivElement>(null)
30
+ const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
31
+
32
+ // Capture-phase ESC handler — stops event before it reaches document listeners (e.g. drawer shortcuts)
33
+ useEffect(() => {
34
+ if (!open) return
35
+ const handleKeyDown = (e: KeyboardEvent) => {
36
+ if (e.key === 'Escape' && closable) {
37
+ e.stopPropagation()
38
+ e.preventDefault()
39
+ onClose()
40
+ }
41
+ }
42
+ document.addEventListener('keydown', handleKeyDown, true)
43
+ return () => document.removeEventListener('keydown', handleKeyDown, true)
44
+ }, [open, onClose, closable])
45
+
46
+ // Move focus into the dialog for accessibility and tab navigation
47
+ useEffect(() => {
48
+ if (open && dialogRef.current) {
49
+ dialogRef.current.focus()
50
+ }
51
+ }, [open])
52
+
53
+ if (!shouldRender) return null
54
+
55
+ return createPortal(
56
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
57
+ <div
58
+ className={clsx(
59
+ 'absolute inset-0 bg-black/60 backdrop-blur-sm',
60
+ TRANSITION_BACKDROP,
61
+ isOpen ? 'opacity-100' : 'opacity-0',
62
+ )}
63
+ onClick={closable ? onClose : undefined}
64
+ />
65
+ <div
66
+ ref={dialogRef}
67
+ role="dialog"
68
+ aria-modal="true"
69
+ tabIndex={-1}
70
+ className={clsx(
71
+ 'relative bg-theme-surface border border-theme-border rounded-lg shadow-2xl mx-4 outline-none',
72
+ TRANSITION_PANEL,
73
+ isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
74
+ className,
75
+ )}
76
+ >
77
+ {children}
78
+ </div>
79
+ </div>,
80
+ document.body,
81
+ )
82
+ }
@@ -1,4 +1,5 @@
1
1
  export { Tooltip } from './Tooltip'
2
+ export { DialogPortal } from './DialogPortal'
2
3
  export { ConfirmDialog } from './ConfirmDialog'
3
4
  export { HealthRing } from './HealthRing'
4
5
  export { MetricsChart, MetricsSparkline } from './MetricsChart'
@@ -38,7 +38,7 @@ import {
38
38
  } from '../timeline/shared'
39
39
  import { ResourceActionsBar } from '../shared/ResourceActionsBar'
40
40
  import { EditableYamlView, SaveSuccessAnimation } from '../shared/EditableYamlView'
41
- import { ResourceRendererDispatch, getResourceStatus } from '../shared/ResourceRendererDispatch'
41
+ import { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from '../shared/ResourceRendererDispatch'
42
42
  import { getKindColor, formatKindName } from '../ui/drawer-components'
43
43
 
44
44
  type TabType = 'overview' | 'timeline' | 'logs' | 'metrics' | 'yaml'
@@ -94,6 +94,8 @@ interface WorkloadViewProps {
94
94
  onUpdateResource?: (params: { kind: string; namespace: string; name: string; yaml: string }) => Promise<void>
95
95
  /** Whether the resource is being updated */
96
96
  isUpdatingResource?: boolean
97
+ /** Error message from the last update attempt */
98
+ updateResourceError?: string | null
97
99
 
98
100
  // ── Tab state (optional URL sync) ────────────────────────────────────────
99
101
  /** Controlled active tab. If not provided, managed internally. */
@@ -123,6 +125,10 @@ interface WorkloadViewProps {
123
125
  // ── ResourceActionsBar props (passed through) ────────────────────────────
124
126
  /** All props for the actions bar (forwarded as-is) */
125
127
  actionsBarProps?: Record<string, any>
128
+
129
+ // ── Renderer overrides ──────────────────────────────────────────────────
130
+ /** Platform-specific renderer overrides (e.g. with hooks for metrics, exec, port-forward) */
131
+ rendererOverrides?: RendererOverrides
126
132
  }
127
133
 
128
134
  export function WorkloadView({
@@ -152,6 +158,7 @@ export function WorkloadView({
152
158
  // Mutations
153
159
  onUpdateResource,
154
160
  isUpdatingResource,
161
+ updateResourceError,
155
162
  // Tab state
156
163
  activeTab: controlledTab,
157
164
  onTabChange,
@@ -161,6 +168,8 @@ export function WorkloadView({
161
168
  isMetricsAvailable,
162
169
  // Actions bar
163
170
  actionsBarProps,
171
+ // Renderer overrides
172
+ rendererOverrides,
164
173
  }: WorkloadViewProps) {
165
174
  // Normalize kind: URL has plural lowercase, internal logic uses singular PascalCase
166
175
  const kind = pluralToKind(kindProp)
@@ -416,6 +425,9 @@ export function WorkloadView({
416
425
  onCopy={(text) => copyToClipboard(text, 'yaml')}
417
426
  copied={copied === 'yaml'}
418
427
  onSaved={handleSaved}
428
+ onSave={onUpdateResource}
429
+ isSaving={isUpdatingResource}
430
+ saveError={updateResourceError}
419
431
  />
420
432
  ) : (
421
433
  <ResourceRendererDispatch
@@ -428,6 +440,7 @@ export function WorkloadView({
428
440
  onNavigate={onNavigateToResource ? (ref) => onNavigateToResource(refToSelectedResource(ref)) : undefined}
429
441
  onSaveSecretValue={canUpdateSecrets ? handleSaveSecretValue : undefined}
430
442
  isSavingSecret={isUpdatingResource}
443
+ rendererOverrides={rendererOverrides}
431
444
  />
432
445
  )}
433
446
  </div>
@@ -566,6 +579,7 @@ export function WorkloadView({
566
579
  isSavingSecret={isUpdatingResource}
567
580
  onOpenLogs={handleOpenLogs}
568
581
  onSwitchToTimeline={() => handleSetTab('timeline')}
582
+ rendererOverrides={rendererOverrides}
569
583
  />
570
584
  )}
571
585
  {activeTab === 'timeline' && (
@@ -613,6 +627,9 @@ export function WorkloadView({
613
627
  onCopy={(text) => copyToClipboard(text, 'yaml')}
614
628
  copied={copied === 'yaml'}
615
629
  onSaved={handleSaved}
630
+ onSave={onUpdateResource}
631
+ isSaving={isUpdatingResource}
632
+ saveError={updateResourceError}
616
633
  />
617
634
  )}
618
635
  </div>
@@ -1044,6 +1061,7 @@ function InfoTab({
1044
1061
  isSavingSecret,
1045
1062
  onOpenLogs,
1046
1063
  onSwitchToTimeline,
1064
+ rendererOverrides,
1047
1065
  }: {
1048
1066
  resource: any
1049
1067
  selectedResource: SelectedResource
@@ -1056,6 +1074,7 @@ function InfoTab({
1056
1074
  isSavingSecret?: boolean
1057
1075
  onOpenLogs?: (podName: string, containerName: string) => void
1058
1076
  onSwitchToTimeline?: () => void
1077
+ rendererOverrides?: RendererOverrides
1059
1078
  }) {
1060
1079
  if (isLoading) {
1061
1080
  return (
@@ -1088,6 +1107,7 @@ function InfoTab({
1088
1107
  showCommonSections={true}
1089
1108
  showMetrics={false}
1090
1109
  onOpenLogs={onOpenLogs}
1110
+ rendererOverrides={rendererOverrides}
1091
1111
  eventsHint={onSwitchToTimeline && (
1092
1112
  <button
1093
1113
  onClick={onSwitchToTimeline}
package/src/types/core.ts CHANGED
@@ -146,10 +146,9 @@ export interface Topology {
146
146
  nodes: TopologyNode[]
147
147
  edges: TopologyEdge[]
148
148
  warnings?: string[] // Warnings about resources that failed to load
149
- truncated?: boolean // True if topology was truncated due to size limit
150
- totalNodes?: number // Total nodes before truncation (only set if truncated)
151
149
  largeCluster?: boolean // True if cluster exceeds large cluster threshold
152
150
  hiddenKinds?: string[] // Resource kinds auto-hidden for performance
151
+ requiresNamespaceFilter?: boolean // True if cluster is too large for all-namespace topology
153
152
  crdDiscoveryStatus?: 'idle' | 'discovering' | 'ready' // CRD discovery status
154
153
  }
155
154
 
@@ -0,0 +1,142 @@
1
+ import { describe, test, expect } from 'vitest'
2
+ import { kindToPlural, pluralToKind, refToSelectedResource } from './navigation'
3
+
4
+ describe('kindToPlural', () => {
5
+ test('singular PascalCase to plural lowercase', () => {
6
+ expect(kindToPlural('Secret')).toBe('secrets')
7
+ expect(kindToPlural('Deployment')).toBe('deployments')
8
+ expect(kindToPlural('Pod')).toBe('pods')
9
+ expect(kindToPlural('Service')).toBe('services')
10
+ expect(kindToPlural('ConfigMap')).toBe('configmaps')
11
+ expect(kindToPlural('Node')).toBe('nodes')
12
+ expect(kindToPlural('Job')).toBe('jobs')
13
+ expect(kindToPlural('CronJob')).toBe('cronjobs')
14
+ })
15
+
16
+ test('handles kinds ending in s/x/ch/sh (adds -es)', () => {
17
+ expect(kindToPlural('Ingress')).toBe('ingresses')
18
+ })
19
+
20
+ test('handles kinds ending in consonant+y (changes to -ies)', () => {
21
+ expect(kindToPlural('NetworkPolicy')).toBe('networkpolicies')
22
+ })
23
+
24
+ test('handles kinds ending in ss (Class-suffix)', () => {
25
+ expect(kindToPlural('StorageClass')).toBe('storageclasses')
26
+ expect(kindToPlural('IngressClass')).toBe('ingressclasses')
27
+ expect(kindToPlural('PriorityClass')).toBe('priorityclasses')
28
+ expect(kindToPlural('RuntimeClass')).toBe('runtimeclasses')
29
+ expect(kindToPlural('GatewayClass')).toBe('gatewayclasses')
30
+ expect(kindToPlural('EC2NodeClass')).toBe('ec2nodeclasses')
31
+ })
32
+
33
+ test('idempotent on known plurals (prevents double-pluralization)', () => {
34
+ // This was the original bug: "secrets" → "secretses"
35
+ expect(kindToPlural('secrets')).toBe('secrets')
36
+ expect(kindToPlural('services')).toBe('services')
37
+ expect(kindToPlural('ingresses')).toBe('ingresses')
38
+ expect(kindToPlural('deployments')).toBe('deployments')
39
+ expect(kindToPlural('pods')).toBe('pods')
40
+ expect(kindToPlural('configmaps')).toBe('configmaps')
41
+ expect(kindToPlural('nodes')).toBe('nodes')
42
+ expect(kindToPlural('storageclasses')).toBe('storageclasses')
43
+ expect(kindToPlural('networkpolicies')).toBe('networkpolicies')
44
+ expect(kindToPlural('horizontalpodautoscalers')).toBe('horizontalpodautoscalers')
45
+ })
46
+
47
+ test('handles aliases', () => {
48
+ expect(kindToPlural('HorizontalPodAutoscaler')).toBe('horizontalpodautoscalers')
49
+ expect(kindToPlural('pvc')).toBe('persistentvolumeclaims')
50
+ expect(kindToPlural('PodGroup')).toBe('pods')
51
+ })
52
+ })
53
+
54
+ // Demonstrate that the naive .toLowerCase() + 's' pattern used by renderers is broken.
55
+ // These tests prove WHY renderers must use kindToPlural() instead of ad-hoc pluralization.
56
+ describe('naive pluralization (renderer bug demonstration)', () => {
57
+ const naivePlural = (kind: string) => kind.toLowerCase() + 's'
58
+
59
+ test('breaks for Class-suffix kinds (triple-s)', () => {
60
+ // What HPARenderer, KarpenterNodePoolRenderer, etc. actually produce
61
+ expect(naivePlural('EC2NodeClass')).toBe('ec2nodeclasss') // WRONG
62
+ expect(kindToPlural('EC2NodeClass')).toBe('ec2nodeclasses') // CORRECT
63
+ })
64
+
65
+ test('breaks for Policy-suffix kinds', () => {
66
+ expect(naivePlural('NetworkPolicy')).toBe('networkpolicys') // WRONG
67
+ expect(kindToPlural('NetworkPolicy')).toBe('networkpolicies') // CORRECT
68
+ })
69
+
70
+ test('breaks for Ingress-like kinds (ending in s)', () => {
71
+ expect(naivePlural('Ingress')).toBe('ingresss') // WRONG
72
+ expect(kindToPlural('Ingress')).toBe('ingresses') // CORRECT
73
+ })
74
+
75
+ test('breaks for Repository-suffix kinds', () => {
76
+ expect(naivePlural('GitRepository')).toBe('gitrepositorys') // WRONG
77
+ expect(kindToPlural('GitRepository')).toBe('gitrepositories') // CORRECT
78
+ })
79
+ })
80
+
81
+ describe('pluralToKind', () => {
82
+ test('reverse mapping for known plurals', () => {
83
+ expect(pluralToKind('secrets')).toBe('Secret')
84
+ expect(pluralToKind('deployments')).toBe('Deployment')
85
+ expect(pluralToKind('horizontalpodautoscalers')).toBe('HorizontalPodAutoscaler')
86
+ expect(pluralToKind('ingresses')).toBe('Ingress')
87
+ expect(pluralToKind('configmaps')).toBe('ConfigMap')
88
+ expect(pluralToKind('networkpolicies')).toBe('NetworkPolicy')
89
+ expect(pluralToKind('storageclasses')).toBe('StorageClass')
90
+ })
91
+
92
+ test('PascalCase input returned as-is', () => {
93
+ expect(pluralToKind('Deployment')).toBe('Deployment')
94
+ expect(pluralToKind('Secret')).toBe('Secret')
95
+ })
96
+
97
+ test('fallback de-pluralization for unknown kinds', () => {
98
+ expect(pluralToKind('widgets')).toBe('Widget')
99
+ })
100
+
101
+ test('fallback handles -ies suffix', () => {
102
+ // Unknown kind not in the map
103
+ expect(pluralToKind('batteries')).toBe('Battery')
104
+ })
105
+
106
+ test('fallback handles -ses suffix', () => {
107
+ // "databases" triggers the -ses rule (strips 2 chars) — a known limitation
108
+ // of the heuristic fallback. Known kinds use the PLURAL_TO_KIND map instead.
109
+ expect(pluralToKind('databases')).toBe('Databas')
110
+ })
111
+ })
112
+
113
+ describe('refToSelectedResource', () => {
114
+ test('converts singular kind to plural for navigation', () => {
115
+ const result = refToSelectedResource({
116
+ kind: 'Secret',
117
+ name: 'test-tls',
118
+ namespace: 'platform',
119
+ })
120
+ expect(result).toEqual({
121
+ kind: 'secrets',
122
+ name: 'test-tls',
123
+ namespace: 'platform',
124
+ group: undefined,
125
+ })
126
+ })
127
+
128
+ test('preserves group field', () => {
129
+ const result = refToSelectedResource({
130
+ kind: 'Certificate',
131
+ name: 'my-cert',
132
+ namespace: 'default',
133
+ group: 'cert-manager.io',
134
+ })
135
+ expect(result).toEqual({
136
+ kind: 'certificates',
137
+ name: 'my-cert',
138
+ namespace: 'default',
139
+ group: 'cert-manager.io',
140
+ })
141
+ })
142
+ })