chore(frontend): refactor the homepage component (#2574)

* chore(frontend): refactor the homepage component

* chore: resolve code review comments

* Fix schedule action buttons and add tooltip initialization tests

- Replace anchor elements with button elements for better semantics
- Use react-icons instead of Font Awesome for chevron/arrow icons
- Add FaFastBackward, FaFastForward, and FaPlus icons with proper alignment
- Add debouncing to MutationObserver callback to improve performance
- Add comprehensive test coverage for useTooltipInitialization hook

* chore: resolve code review comments

* chore: resolve code review comments
This commit is contained in:
Nico Miguelino
2025-12-12 15:35:40 -08:00
committed by GitHub
parent 65e854bbd9
commit 26abe6f0d8
9 changed files with 415 additions and 180 deletions

28
package-lock.json generated
View File

@@ -117,7 +117,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -2043,7 +2042,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -2067,7 +2065,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2097,7 +2094,6 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -3582,7 +3578,6 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
@@ -3656,7 +3651,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -3944,7 +3938,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3955,7 +3948,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4049,7 +4041,6 @@
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.49.0",
"@typescript-eslint/types": "8.49.0",
@@ -4773,7 +4764,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5338,7 +5328,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -6250,7 +6239,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7947,7 +7935,6 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@@ -8345,7 +8332,6 @@
"integrity": "sha512-zbBTiqr2Vl78pKp/laGBREYzbZx9ZtqPjOK4++lL4BNDhxRnahg51HtoDrk9/VjIy9IthNEWdKVd7H5bqBhiWQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/environment": "30.2.0",
"@jest/environment-jsdom-abstract": "30.2.0",
@@ -9130,7 +9116,6 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -10087,7 +10072,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
@@ -10283,7 +10267,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -10293,7 +10276,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -10320,7 +10302,6 @@
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -10418,8 +10399,7 @@
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"peer": true
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -10709,7 +10689,6 @@
"integrity": "sha512-8u4xqqUeugGNCYwr9ARNtQKTOj4KmYiJAVKXf2CTIivTCR51j96htbMKWDru8H5SaQWpyVgTfOF8Ylyf5pun1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -10810,7 +10789,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -11523,7 +11501,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11878,7 +11855,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12131,7 +12107,6 @@
"integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -12181,7 +12156,6 @@
"integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@discoveryjs/json-ext": "^0.6.1",
"@webpack-cli/configtest": "^3.0.1",

View File

@@ -0,0 +1,37 @@
import { AssetEditData } from '@/types'
import { ActiveAssetsTable } from '@/components/active-assets'
import { EmptyAssetMessage } from '@/components/empty-asset-message'
interface ActiveAssetsSectionProps {
activeAssetsCount: number
onEditAsset: (asset: AssetEditData) => void
onAddAssetClick: (event: React.MouseEvent) => void
}
export const ActiveAssetsSection = ({
activeAssetsCount,
onEditAsset,
onAddAssetClick,
}: ActiveAssetsSectionProps) => {
return (
<div className="container">
<div className="row content active-content px-2 pt-4">
<div className="col-12 mb-5">
<section id="active-assets-section">
<h5>
<b>Active assets</b>
</h5>
<ActiveAssetsTable onEditAsset={onEditAsset} />
{activeAssetsCount === 0 && (
<EmptyAssetMessage
onAddAssetClick={onAddAssetClick}
isActive={true}
/>
)}
</section>
</div>
</div>
</div>
)
}

View File

@@ -1,7 +1,6 @@
import classNames from 'classnames'
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import Tooltip from 'bootstrap/js/dist/tooltip'
import {
fetchAssets,
selectActiveAssets,
@@ -9,11 +8,12 @@ import {
} from '@/store/assets'
import { AssetEditData, AppDispatch } from '@/types'
import { EmptyAssetMessage } from '@/components/empty-asset-message'
import { InactiveAssetsTable } from '@/components/inactive-assets'
import { ActiveAssetsTable } from '@/components/active-assets'
import { ActiveAssetsSection } from '@/components/active-assets-section'
import { AddAssetModal } from '@/components/add-asset-modal'
import { EditAssetModal } from '@/components/edit-asset-modal'
import { InactiveAssetsSection } from '@/components/inactive-assets-section'
import { ScheduleHeader } from '@/components/schedule-header'
import { useTooltipInitialization } from '@/hooks/use-tooltip-initialization'
export const ScheduleOverview = () => {
const dispatch = useDispatch<AppDispatch>()
@@ -41,55 +41,7 @@ export const ScheduleOverview = () => {
fetchPlayerName()
}, [dispatch, playerName])
// Initialize tooltips
useEffect(() => {
const tooltipElements: Tooltip[] = []
const initializeTooltips = () => {
// Dispose existing tooltips
tooltipElements.forEach((tooltip) => tooltip.dispose())
tooltipElements.length = 0
// Initialize new tooltips
const tooltipNodes = document.querySelectorAll(
'[data-bs-toggle="tooltip"]',
)
tooltipNodes.forEach((element) => {
const tooltip = new Tooltip(element as HTMLElement, {
placement: 'top',
trigger: 'hover',
html: true,
delay: { show: 0, hide: 0 },
animation: true,
})
tooltipElements.push(tooltip)
})
}
// Initial tooltip initialization
initializeTooltips()
// Reinitialize tooltips when assets change
const observer = new MutationObserver(() => {
initializeTooltips()
})
// Observe changes in both active and inactive sections
const activeSection = document.getElementById('active-assets-section')
const inactiveSection = document.getElementById('inactive-assets-section')
if (activeSection) {
observer.observe(activeSection, { childList: true, subtree: true })
}
if (inactiveSection) {
observer.observe(inactiveSection, { childList: true, subtree: true })
}
return () => {
observer.disconnect()
tooltipElements.forEach((tooltip) => tooltip.dispose())
}
}, [activeAssets, inactiveAssets])
useTooltipInitialization(activeAssets.length, inactiveAssets.length)
const handleAddAsset = (event: React.MouseEvent) => {
event.preventDefault()
@@ -127,108 +79,25 @@ export const ScheduleOverview = () => {
return (
<>
<div className="container pt-3 pb-3">
<div className="row">
<div className="col-12">
<h4 className="mb-3">
<b className="text-white">Schedule Overview</b>
</h4>
{playerName && (
<span className="badge bg-primary px-3 py-2 rounded-pill mb-3">
<h6 className="my-0 text-center text-dark fw-bold">
{playerName}
</h6>
</span>
)}
<div className="d-flex flex-column flex-sm-row gap-2 mb-3 mt-4">
<a
id="previous-asset-button"
className={classNames(
'btn',
'btn-long',
'btn-light',
'fw-bold',
'text-dark',
)}
href="#"
onClick={handlePreviousAsset}
>
<i className="fas fa-chevron-left pe-2"></i>
Previous Asset
</a>
<a
id="next-asset-button"
className={classNames(
'btn',
'btn-long',
'btn-light',
'fw-bold',
'text-dark',
)}
href="#"
onClick={handleNextAsset}
>
Next Asset
<i className="fas fa-chevron-right ps-2"></i>
</a>
<a
id="add-asset-button"
className={classNames(
'add-asset-button',
'btn',
'btn-long',
'btn-primary',
)}
href="#"
onClick={handleAddAsset}
>
Add Asset
</a>
</div>
</div>
</div>
</div>
<ScheduleHeader
playerName={playerName}
onPreviousAsset={handlePreviousAsset}
onNextAsset={handleNextAsset}
onAddAsset={handleAddAsset}
/>
<span id="assets">
<div className="container">
<div className="row content active-content px-2 pt-4">
<div className="col-12 mb-5">
<section id="active-assets-section">
<h5>
<b>Active assets</b>
</h5>
<ActiveAssetsTable onEditAsset={handleEditAsset} />
{activeAssets.length === 0 && (
<EmptyAssetMessage
onAddAssetClick={handleAddAsset}
isActive={true}
/>
)}
</section>
</div>
</div>
</div>
<ActiveAssetsSection
activeAssetsCount={activeAssets.length}
onEditAsset={handleEditAsset}
onAddAssetClick={handleAddAsset}
/>
<div className="container mt-4">
<div className="row content inactive-content px-2 pt-4">
<div className="col-12 mb-5">
<section id="inactive-assets-section">
<h5>
<b>Inactive assets</b>
</h5>
<InactiveAssetsTable onEditAsset={handleEditAsset} />
{inactiveAssets.length === 0 && (
<EmptyAssetMessage
onAddAssetClick={handleAddAsset}
isActive={false}
/>
)}
</section>
</div>
</div>
</div>
<InactiveAssetsSection
inactiveAssetsCount={inactiveAssets.length}
onEditAsset={handleEditAsset}
onAddAssetClick={handleAddAsset}
/>
</span>
<AddAssetModal

View File

@@ -0,0 +1,37 @@
import { AssetEditData } from '@/types'
import { EmptyAssetMessage } from '@/components/empty-asset-message'
import { InactiveAssetsTable } from '@/components/inactive-assets'
interface InactiveAssetsSectionProps {
inactiveAssetsCount: number
onEditAsset: (asset: AssetEditData) => void
onAddAssetClick: (event: React.MouseEvent) => void
}
export const InactiveAssetsSection = ({
inactiveAssetsCount,
onEditAsset,
onAddAssetClick,
}: InactiveAssetsSectionProps) => {
return (
<div className="container mt-4">
<div className="row content inactive-content px-2 pt-4">
<div className="col-12 mb-5">
<section id="inactive-assets-section">
<h5>
<b>Inactive assets</b>
</h5>
<InactiveAssetsTable onEditAsset={onEditAsset} />
{inactiveAssetsCount === 0 && (
<EmptyAssetMessage
onAddAssetClick={onAddAssetClick}
isActive={false}
/>
)}
</section>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
interface PlayerNameBadgeProps {
playerName: string
}
export const PlayerNameBadge = ({ playerName }: PlayerNameBadgeProps) => {
if (!playerName) {
return null
}
return (
<span className="badge bg-primary px-3 py-2 rounded-pill mb-3">
<h6 className="my-0 text-center text-dark fw-bold">{playerName}</h6>
</span>
)
}

View File

@@ -0,0 +1,66 @@
import classNames from 'classnames'
import { FaFastBackward, FaFastForward, FaPlus } from 'react-icons/fa'
interface ScheduleActionButtonsProps {
onPreviousAsset: (event: React.MouseEvent) => void
onNextAsset: (event: React.MouseEvent) => void
onAddAsset: (event: React.MouseEvent) => void
}
export const ScheduleActionButtons = ({
onPreviousAsset,
onNextAsset,
onAddAsset,
}: ScheduleActionButtonsProps) => {
return (
<div className="d-flex flex-column flex-sm-row gap-2 mb-3 mt-4">
<button
id="previous-asset-button"
className={classNames(
'btn',
'btn-long',
'btn-light',
'fw-bold',
'text-dark',
)}
onClick={onPreviousAsset}
>
<span className="d-flex align-items-center justify-content-center">
<FaFastBackward className="pe-2 fs-4" />
Previous Asset
</span>
</button>
<button
id="next-asset-button"
className={classNames(
'btn',
'btn-long',
'btn-light',
'fw-bold',
'text-dark',
)}
onClick={onNextAsset}
>
<span className="d-flex align-items-center justify-content-center">
<FaFastForward className="pe-2 fs-4" />
Next Asset
</span>
</button>
<button
id="add-asset-button"
className={classNames(
'add-asset-button',
'btn',
'btn-long',
'btn-primary',
)}
onClick={onAddAsset}
>
<span className="d-flex align-items-center justify-content-center">
<FaPlus className="pe-2 fs-5" />
Add Asset
</span>
</button>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { PlayerNameBadge } from '@/components/player-name-badge'
import { ScheduleActionButtons } from '@/components/schedule-action-buttons'
interface ScheduleHeaderProps {
playerName: string
onPreviousAsset: (event: React.MouseEvent) => void
onNextAsset: (event: React.MouseEvent) => void
onAddAsset: (event: React.MouseEvent) => void
}
export const ScheduleHeader = ({
playerName,
onPreviousAsset,
onNextAsset,
onAddAsset,
}: ScheduleHeaderProps) => {
return (
<div className="container pt-3 pb-3">
<div className="row">
<div className="col-12">
<h4 className="mb-3">
<b className="text-white">Schedule Overview</b>
</h4>
<PlayerNameBadge playerName={playerName} />
<ScheduleActionButtons
onPreviousAsset={onPreviousAsset}
onNextAsset={onNextAsset}
onAddAsset={onAddAsset}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,139 @@
import { renderHook } from '@testing-library/react'
import { useTooltipInitialization } from './use-tooltip-initialization'
// Mock Bootstrap Tooltip
jest.mock('bootstrap/js/dist/tooltip', () => {
return jest.fn().mockImplementation(() => ({
dispose: jest.fn(),
}))
})
describe('useTooltipInitialization', () => {
let activeSection: HTMLElement
let inactiveSection: HTMLElement
beforeEach(() => {
// Create mock sections
activeSection = document.createElement('div')
activeSection.id = 'active-assets-section'
document.body.appendChild(activeSection)
inactiveSection = document.createElement('div')
inactiveSection.id = 'inactive-assets-section'
document.body.appendChild(inactiveSection)
// Mock document.querySelectorAll
jest
.spyOn(document, 'querySelectorAll')
.mockReturnValue([] as unknown as NodeListOf<Element>)
jest.spyOn(document, 'getElementById').mockImplementation((id) => {
if (id === 'active-assets-section') return activeSection
if (id === 'inactive-assets-section') return inactiveSection
return null
})
})
afterEach(() => {
activeSection.remove()
inactiveSection.remove()
jest.clearAllMocks()
})
it('should initialize tooltips on mount', () => {
renderHook(() => useTooltipInitialization(1, 0))
expect(document.querySelectorAll).toHaveBeenCalledWith(
'[data-bs-toggle="tooltip"]',
)
})
it('should set up MutationObserver for both sections', () => {
const observeSpy = jest.spyOn(MutationObserver.prototype, 'observe')
renderHook(() => useTooltipInitialization(1, 0))
expect(observeSpy).toHaveBeenCalledWith(activeSection, {
childList: true,
subtree: true,
})
expect(observeSpy).toHaveBeenCalledWith(inactiveSection, {
childList: true,
subtree: true,
})
})
it('should disconnect observer and dispose tooltips on unmount', () => {
const disconnectSpy = jest.spyOn(MutationObserver.prototype, 'disconnect')
const { unmount } = renderHook(() => useTooltipInitialization(1, 0))
unmount()
expect(disconnectSpy).toHaveBeenCalled()
})
it('should debounce MutationObserver callbacks', async () => {
jest.useFakeTimers()
const querySelectorSpy = jest
.spyOn(document, 'querySelectorAll')
.mockReturnValue([] as unknown as NodeListOf<Element>)
// Capture the callback passed to MutationObserver
let mutationCallback: ((mutations: MutationRecord[]) => void) | null = null
const mockMutationObserver = jest.fn(
(callback: (mutations: MutationRecord[]) => void) => {
mutationCallback = callback
return {
observe: jest.fn(),
disconnect: jest.fn(),
}
},
)
globalThis.MutationObserver =
mockMutationObserver as unknown as typeof MutationObserver
renderHook(() => useTooltipInitialization(1, 0))
// Initial call on mount
expect(querySelectorSpy).toHaveBeenCalledTimes(1)
querySelectorSpy.mockClear()
// Simulate multiple rapid mutations
const callback = mutationCallback as unknown as (
mutations: MutationRecord[],
) => void
callback([])
callback([])
callback([])
// Should not call querySelectorAll yet (debounced)
expect(querySelectorSpy).not.toHaveBeenCalled()
// Fast-forward time past debounce delay
jest.advanceTimersByTime(300)
// After debounce, it should be called once
expect(querySelectorSpy).toHaveBeenCalledTimes(1)
jest.useRealTimers()
})
it('should reinitialize tooltips when asset counts change', () => {
const querySelectorSpy = jest
.spyOn(document, 'querySelectorAll')
.mockReturnValue([] as unknown as NodeListOf<Element>)
const { rerender } = renderHook(
({ activeCount, inactiveCount }) =>
useTooltipInitialization(activeCount, inactiveCount),
{ initialProps: { activeCount: 1, inactiveCount: 0 } },
)
querySelectorSpy.mockClear()
rerender({ activeCount: 2, inactiveCount: 0 })
expect(querySelectorSpy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,62 @@
import { useEffect } from 'react'
import Tooltip from 'bootstrap/js/dist/tooltip'
export const useTooltipInitialization = (
activeAssetsCount: number,
inactiveAssetsCount: number,
) => {
useEffect(() => {
const tooltipElements: Tooltip[] = []
let debounceTimer: NodeJS.Timeout | null = null
const initializeTooltips = () => {
tooltipElements.forEach((tooltip) => tooltip.dispose())
tooltipElements.length = 0
const tooltipNodes = document.querySelectorAll(
'[data-bs-toggle="tooltip"]',
)
tooltipNodes.forEach((element) => {
const tooltip = new Tooltip(element as HTMLElement, {
placement: 'top',
trigger: 'hover',
html: true,
delay: { show: 0, hide: 0 },
animation: true,
})
tooltipElements.push(tooltip)
})
}
initializeTooltips()
const handleMutation = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(() => {
initializeTooltips()
}, 300)
}
const observer = new MutationObserver(handleMutation)
const activeSection = document.getElementById('active-assets-section')
const inactiveSection = document.getElementById('inactive-assets-section')
if (activeSection) {
observer.observe(activeSection, { childList: true, subtree: true })
}
if (inactiveSection) {
observer.observe(inactiveSection, { childList: true, subtree: true })
}
return () => {
observer.disconnect()
if (debounceTimer) {
clearTimeout(debounceTimer)
}
tooltipElements.forEach((tooltip) => tooltip.dispose())
}
}, [activeAssetsCount, inactiveAssetsCount])
}