Skip to content

Commit 45937b3

Browse files
authored
Merge branch 'main' into scanner/fix-19300
2 parents 81878cd + 2163503 commit 45937b3

6 files changed

Lines changed: 224 additions & 52 deletions

File tree

web/src/components/cards/StorageOverview.test.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,27 @@ vi.mock('../../lib/cards/cardHooks', () => ({
6161
}))
6262

6363
vi.mock('../../lib/cards/CardComponents', () => ({
64-
CardClusterFilter: ({ availableClusters }: { availableClusters: Array<{ name: string }> }) => (
65-
<div data-testid="cluster-filter" data-count={availableClusters.length} />
64+
CardHeaderRow: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
65+
CardStatGrid: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
66+
CardStatHeader: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
67+
CardControlsRow: ({
68+
clusterFilter,
69+
clusterIndicator,
70+
}: {
71+
clusterFilter?: { availableClusters: Array<{ name: string }> }
72+
clusterIndicator?: { selectedCount: number; totalCount: number }
73+
}) => (
74+
<div data-testid="card-controls-row">
75+
{clusterFilter && (
76+
<div
77+
data-testid="cluster-filter"
78+
data-count={clusterFilter.availableClusters.length}
79+
/>
80+
)}
81+
{clusterIndicator && (
82+
<span>{clusterIndicator.selectedCount}/{clusterIndicator.totalCount}</span>
83+
)}
84+
</div>
6685
),
6786
}))
6887

web/src/components/cards/__tests__/ClusterHealth.test.tsx

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -131,16 +131,31 @@ vi.mock('../../clusters/ClusterDetailModal', () => ({
131131
),
132132
}))
133133

134-
vi.mock('../../../lib/cards/CardComponents', () => ({
135-
CardSearchInput: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
136-
<input data-testid="search-input" value={value} onChange={(e) => onChange(e.target.value)} />
137-
),
138-
CardControlsRow: () => <div data-testid="card-controls-row" />,
139-
CardPaginationFooter: ({ needsPagination }: { needsPagination: boolean }) =>
140-
needsPagination ? <div data-testid="pagination" /> : null,
141-
CardAIActions: () => <div data-testid="ai-actions" />,
142-
CardEmptyState: ({ title, message }: { title?: string; message?: string; icon?: unknown }) => <div data-testid="empty-state">{title}{message && <span>{message}</span>}</div>,
143-
}))
134+
vi.mock('../../../lib/cards/CardComponents', async (importOriginal) => {
135+
const actual = await importOriginal<typeof import('../../../lib/cards/CardComponents')>()
136+
137+
return {
138+
...actual,
139+
CardSearchInput: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
140+
<input data-testid="search-input" value={value} onChange={(e) => onChange(e.target.value)} />
141+
),
142+
CardControlsRow: ({
143+
clusterIndicator,
144+
}: {
145+
clusterIndicator?: { selectedCount: number; totalCount: number }
146+
}) => (
147+
<div data-testid="card-controls-row">
148+
{clusterIndicator && (
149+
<span>{clusterIndicator.selectedCount}/{clusterIndicator.totalCount}</span>
150+
)}
151+
</div>
152+
),
153+
CardPaginationFooter: ({ needsPagination }: { needsPagination: boolean }) =>
154+
needsPagination ? <div data-testid="pagination" /> : null,
155+
CardAIActions: () => <div data-testid="ai-actions" />,
156+
CardEmptyState: ({ title, message }: { title?: string; message?: string; icon?: unknown }) => <div data-testid="empty-state">{title}{message && <span>{message}</span>}</div>,
157+
}
158+
})
144159

145160
// ---------------------------------------------------------------------------
146161
// Helpers

web/src/components/cards/card-wrapper/CardActionMenu.tsx

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { cn } from '../../../lib/cn'
88
import { isCardExportable } from '../../../lib/widgets/widgetRegistry'
99
import { copyToClipboard } from '../../../lib/clipboard'
1010
import { useDashboardContextOptional } from '../../../hooks/useDashboardContext'
11+
import { useModalState } from '../../../lib/modals'
1112

1213
// Card width options (in grid columns out of 12)
1314
const WIDTH_OPTIONS = [
@@ -86,9 +87,9 @@ export const CardActionMenu = memo(function CardActionMenu({
8687
const { t } = useTranslation(['cards', 'common'])
8788
const studioContext = useDashboardContextOptional()
8889

89-
const [showMenu, setShowMenu] = useState(false)
90-
const [showResizeMenu, setShowResizeMenu] = useState(false)
91-
const [showHeightMenu, setShowHeightMenu] = useState(false)
90+
const { isOpen: showMenu, open: openMenu, close: closeMenu } = useModalState()
91+
const { isOpen: showResizeMenu, open: openResizeMenu, close: closeResizeMenu } = useModalState()
92+
const { isOpen: showHeightMenu, open: openHeightMenu, close: closeHeightMenu } = useModalState()
9293
const [resizeMenuOnLeft, setResizeMenuOnLeft] = useState(false)
9394
const [heightMenuOnLeft, setHeightMenuOnLeft] = useState(false)
9495
const [menuPosition, setMenuPosition] = useState<{ top: number; right: number } | null>(null)
@@ -107,15 +108,15 @@ export const CardActionMenu = memo(function CardActionMenu({
107108
// Close resize/height submenus when main menu closes (#7869)
108109
useEffect(() => {
109110
if (!showMenu) {
110-
setShowResizeMenu(false)
111-
setShowHeightMenu(false)
111+
closeResizeMenu()
112+
closeHeightMenu()
112113
setMenuPosition(null)
113114
if (restoreFocusRef.current) {
114115
menuButtonRef.current?.focus()
115116
restoreFocusRef.current = false
116117
}
117118
}
118-
}, [showMenu])
119+
}, [showMenu, closeResizeMenu, closeHeightMenu])
119120

120121
useEffect(() => {
121122
if (!showMenu) return
@@ -140,12 +141,12 @@ export const CardActionMenu = memo(function CardActionMenu({
140141
function handleOtherMenuOpen(e: Event) {
141142
const detail = (e as CustomEvent).detail
142143
if (detail !== cardId && showMenu) {
143-
setShowMenu(false)
144+
closeMenu()
144145
}
145146
}
146147
window.addEventListener('card-menu-open', handleOtherMenuOpen)
147148
return () => window.removeEventListener('card-menu-open', handleOtherMenuOpen)
148-
}, [showMenu, cardId])
149+
}, [showMenu, cardId, closeMenu])
149150

150151
// Keep menu anchored to button on scroll/resize (#5253).
151152
useEffect(() => {
@@ -184,13 +185,13 @@ export const CardActionMenu = memo(function CardActionMenu({
184185
const handleClickOutside = (e: MouseEvent) => {
185186
const target = e.target as HTMLElement
186187
if (!target.closest('[data-tour="card-menu"]') && !target.closest('[data-card-action-menu]')) {
187-
setShowMenu(false)
188+
closeMenu()
188189
}
189190
}
190191

191192
document.addEventListener('mousedown', handleClickOutside)
192193
return () => document.removeEventListener('mousedown', handleClickOutside)
193-
}, [showMenu])
194+
}, [showMenu, closeMenu])
194195

195196
// Flip resize submenu to left when near viewport edge
196197
useEffect(() => {
@@ -212,15 +213,15 @@ export const CardActionMenu = memo(function CardActionMenu({
212213
if (e.key === 'Escape') {
213214
e.preventDefault()
214215
restoreFocusRef.current = true
215-
setShowResizeMenu(false)
216-
setShowHeightMenu(false)
217-
setShowMenu(false)
216+
closeResizeMenu()
217+
closeHeightMenu()
218+
closeMenu()
218219
return
219220
}
220221
if (e.key === 'ArrowLeft' && (showResizeMenu || showHeightMenu)) {
221222
e.preventDefault()
222-
setShowResizeMenu(false)
223-
setShowHeightMenu(false)
223+
closeResizeMenu()
224+
closeHeightMenu()
224225
menuRef.current?.querySelector<HTMLElement>(MENU_ITEM_SELECTOR)?.focus()
225226
return
226227
}
@@ -251,8 +252,10 @@ export const CardActionMenu = memo(function CardActionMenu({
251252
const opening = !showMenu
252253
if (opening) {
253254
window.dispatchEvent(new CustomEvent('card-menu-open', { detail: cardId }))
255+
openMenu()
256+
} else {
257+
closeMenu()
254258
}
255-
setShowMenu(opening)
256259
}}
257260
className="p-1.5 rounded-lg hover:bg-secondary/50 text-muted-foreground hover:text-foreground transition-colors"
258261
aria-label={t('cardWrapper.cardMenuTooltip')}
@@ -275,7 +278,7 @@ export const CardActionMenu = memo(function CardActionMenu({
275278
onKeyDown={handleMenuKeyDown}
276279
>
277280
<button
278-
onClick={() => { setShowMenu(false); onConfigure?.() }}
281+
onClick={() => { closeMenu(); onConfigure?.() }}
279282
className="w-full px-4 py-2 text-left text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/50 flex items-center gap-2"
280283
role="menuitem"
281284
title={t('cardWrapper.configureTooltip')}
@@ -285,7 +288,7 @@ export const CardActionMenu = memo(function CardActionMenu({
285288
</button>
286289
<button
287290
onClick={() => {
288-
setShowMenu(false)
291+
closeMenu()
289292
const url = `${window.location.origin}${window.location.pathname}?card=${cardType}`
290293
copyToClipboard(url)
291294
}}
@@ -301,7 +304,7 @@ export const CardActionMenu = memo(function CardActionMenu({
301304
{onWidthChange && (
302305
<div className="relative" ref={menuContainerRef}>
303306
<button
304-
onClick={() => { setShowResizeMenu(!showResizeMenu); setShowHeightMenu(false) }}
307+
onClick={() => { if (showResizeMenu) { closeResizeMenu() } else { openResizeMenu() } closeHeightMenu() }}
305308
className="w-full px-4 py-2 text-left text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/50 flex flex-wrap items-center justify-between gap-y-2"
306309
role="menuitem"
307310
aria-haspopup="menu"
@@ -327,7 +330,7 @@ export const CardActionMenu = memo(function CardActionMenu({
327330
{WIDTH_OPTIONS.map((option) => (
328331
<button
329332
key={option.value}
330-
onClick={() => { onWidthChange(option.value); setShowResizeMenu(false); setShowMenu(false) }}
333+
onClick={() => { onWidthChange(option.value); closeResizeMenu(); closeMenu() }}
331334
className={cn(
332335
'w-full px-3 py-2 text-left text-sm flex flex-wrap items-center justify-between gap-y-2',
333336
cardWidth === option.value
@@ -349,7 +352,7 @@ export const CardActionMenu = memo(function CardActionMenu({
349352
{onHeightChange && (
350353
<div className="relative" ref={heightMenuContainerRef}>
351354
<button
352-
onClick={() => { setShowHeightMenu(!showHeightMenu); setShowResizeMenu(false) }}
355+
onClick={() => { if (showHeightMenu) { closeHeightMenu() } else { openHeightMenu() } closeResizeMenu() }}
353356
className="w-full px-4 py-2 text-left text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/50 flex flex-wrap items-center justify-between gap-y-2"
354357
role="menuitem"
355358
aria-haspopup="menu"
@@ -375,7 +378,7 @@ export const CardActionMenu = memo(function CardActionMenu({
375378
{HEIGHT_OPTIONS.map((option) => (
376379
<button
377380
key={option.value}
378-
onClick={() => { onHeightChange(option.value); setShowHeightMenu(false); setShowMenu(false) }}
381+
onClick={() => { onHeightChange(option.value); closeHeightMenu(); closeMenu() }}
379382
className={cn(
380383
'w-full px-3 py-2 text-left text-sm flex flex-wrap items-center justify-between gap-y-2',
381384
cardHeight === option.value
@@ -396,7 +399,7 @@ export const CardActionMenu = memo(function CardActionMenu({
396399
{isCardExportable(cardType) && (
397400
<button
398401
onClick={() => {
399-
setShowMenu(false)
402+
closeMenu()
400403
if (studioContext?.openAddCardModal) {
401404
studioContext.openAddCardModal('widgets', cardType)
402405
} else {
@@ -413,7 +416,7 @@ export const CardActionMenu = memo(function CardActionMenu({
413416
)}
414417

415418
<button
416-
onClick={() => { setShowMenu(false); onRemove?.() }}
419+
onClick={() => { closeMenu(); onRemove?.() }}
417420
className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-red-500/10 flex items-center gap-2"
418421
role="menuitem"
419422
title={t('cardWrapper.removeTooltip')}

web/src/components/cards/card-wrapper/InfoTooltip.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
22
import { createPortal } from 'react-dom'
33
import { Info } from 'lucide-react'
44
import { useTranslation } from 'react-i18next'
5+
import { useModalState } from '../../../lib/modals'
56

67
// #6227: shared Escape-key coordinator. Multiple InfoTooltips (one per
78
// CardWrapper) used to each register their own document-level keydown
@@ -50,7 +51,7 @@ const TOOLTIP_EDGE_MARGIN_PX = 8
5051
*/
5152
export function InfoTooltip({ text }: { text: string }) {
5253
const { t } = useTranslation('cards')
53-
const [isVisible, setIsVisible] = useState(false)
54+
const { isOpen: isVisible, open: show, close: hide, toggle } = useModalState()
5455
const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
5556
const triggerRef = useRef<HTMLButtonElement>(null)
5657
const tooltipRef = useRef<HTMLDivElement>(null)
@@ -109,27 +110,27 @@ export function InfoTooltip({ text }: { text: string }) {
109110
const handleClickOutside = (e: MouseEvent) => {
110111
const target = e.target as HTMLElement
111112
if (!triggerRef.current?.contains(target) && !tooltipRef.current?.contains(target)) {
112-
setIsVisible(false)
113+
hide()
113114
}
114115
}
115116

116117
document.addEventListener('mousedown', handleClickOutside)
117-
const popEscape = pushEscapeHandler(() => setIsVisible(false))
118+
const popEscape = pushEscapeHandler(hide)
118119
return () => {
119120
document.removeEventListener('mousedown', handleClickOutside)
120121
popEscape()
121122
}
122-
}, [isVisible])
123+
}, [isVisible, hide])
123124

124125
return (
125126
<>
126127
<button
127128
ref={triggerRef}
128-
onClick={() => setIsVisible(!isVisible)}
129-
onMouseEnter={() => setIsVisible(true)}
130-
onMouseLeave={() => setIsVisible(false)}
131-
onFocus={() => setIsVisible(true)}
132-
onBlur={() => setIsVisible(false)}
129+
onClick={toggle}
130+
onMouseEnter={show}
131+
onMouseLeave={hide}
132+
onFocus={show}
133+
onBlur={hide}
133134
className="p-0.5 rounded text-muted-foreground/50 hover:text-muted-foreground transition-colors"
134135
aria-label={t('cardWrapper.cardInfo')}
135136
aria-describedby={isVisible ? tooltipId : undefined}
@@ -143,8 +144,8 @@ export function InfoTooltip({ text }: { text: string }) {
143144
role="tooltip"
144145
className="fixed z-dropdown max-w-xs px-3 py-2.5 text-xs leading-relaxed rounded-lg bg-background border border-border text-foreground shadow-xl animate-fade-in"
145146
style={{ top: position.top, left: position.left }}
146-
onMouseEnter={() => setIsVisible(true)}
147-
onMouseLeave={() => setIsVisible(false)}
147+
onMouseEnter={show}
148+
onMouseLeave={hide}
148149
>
149150
{text}
150151
</div>,

web/src/components/cards/card-wrapper/InstallCTAFlow.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ClusterSelectionDialog } from '../../missions/ClusterSelectionDialog'
77
import { ConfirmMissionPromptDialog } from '../../missions/ConfirmMissionPromptDialog'
88
import { useMissions } from '../../../hooks/useMissions'
99
import { useLocalAgent } from '../../../hooks/useLocalAgent'
10+
import { useModalState } from '../../../lib/modals'
1011

1112
/** Timeout for fetching KB guide data (ms) */
1213
const KB_FETCH_TIMEOUT_MS = 10_000
@@ -31,7 +32,7 @@ export function InstallCTAFlow({ cardType, title }: InstallCTAFlowProps) {
3132

3233
const installInfo = CARD_INSTALL_MAP[cardType]
3334

34-
const [showClusterSelect, setShowClusterSelect] = useState(false)
35+
const { isOpen: showClusterSelect, open: openClusterSelect, close: closeClusterSelect } = useModalState()
3536
const [showInstallGuide, setShowInstallGuide] = useState<{
3637
mission: { mission?: { title?: string; description?: string; steps?: { title?: string; description?: string }[] } }
3738
} | null>(null)
@@ -51,7 +52,7 @@ export function InstallCTAFlow({ cardType, title }: InstallCTAFlowProps) {
5152
if (isPreparingInstall) return
5253
setInstallError(null)
5354
if (isAgentConnected && installInfo) {
54-
setShowClusterSelect(true)
55+
openClusterSelect()
5556
} else if (installInfo) {
5657
setIsPreparingInstall(true)
5758
try {
@@ -93,9 +94,9 @@ export function InstallCTAFlow({ cardType, title }: InstallCTAFlowProps) {
9394
{showClusterSelect && installInfo && (
9495
<ClusterSelectionDialog
9596
open={showClusterSelect}
96-
onCancel={() => setShowClusterSelect(false)}
97+
onCancel={closeClusterSelect}
9798
onSelect={async (clusters) => {
98-
setShowClusterSelect(false)
99+
closeClusterSelect()
99100
setInstallError(null)
100101
setIsPreparingInstall(true)
101102
try {

0 commit comments

Comments
 (0)