mirror of
https://github.com/Screenly/Anthias.git
synced 2025-12-23 22:38:05 -05:00
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:
28
package-lock.json
generated
28
package-lock.json
generated
@@ -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",
|
||||
|
||||
37
static/src/components/active-assets-section.tsx
Normal file
37
static/src/components/active-assets-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
37
static/src/components/inactive-assets-section.tsx
Normal file
37
static/src/components/inactive-assets-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
static/src/components/player-name-badge.tsx
Normal file
15
static/src/components/player-name-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
static/src/components/schedule-action-buttons.tsx
Normal file
66
static/src/components/schedule-action-buttons.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
static/src/components/schedule-header.tsx
Normal file
36
static/src/components/schedule-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
static/src/hooks/use-tooltip-initialization.test.ts
Normal file
139
static/src/hooks/use-tooltip-initialization.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
62
static/src/hooks/use-tooltip-initialization.ts
Normal file
62
static/src/hooks/use-tooltip-initialization.ts
Normal 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])
|
||||
}
|
||||
Reference in New Issue
Block a user