diff --git a/frontend/__tests__/unit/components/AnchorTitle.test.tsx b/frontend/__tests__/unit/components/AnchorTitle.test.tsx
index 09255fb957..108f50654a 100644
--- a/frontend/__tests__/unit/components/AnchorTitle.test.tsx
+++ b/frontend/__tests__/unit/components/AnchorTitle.test.tsx
@@ -98,7 +98,7 @@ describe('AnchorTitle Component', () => {
const titleElement = screen.getByText('Test')
expect(titleElement).toHaveClass('flex', 'items-center', 'text-2xl', 'font-semibold')
- expect(titleElement).toHaveAttribute('id', 'anchor-title')
+ expect(titleElement).toHaveAttribute('data-anchor-title', 'true')
})
it('renders FontAwesome link icon', () => {
@@ -372,7 +372,7 @@ describe('AnchorTitle Component', () => {
render()
const titleElement = screen.getByText('Heading Test')
- expect(titleElement).toHaveAttribute('id', 'anchor-title')
+ expect(titleElement).toHaveAttribute('data-anchor-title', 'true')
expect(titleElement).toHaveClass('text-2xl', 'font-semibold')
const container = document.getElementById('heading-test')
@@ -469,7 +469,7 @@ describe('AnchorTitle Component', () => {
fireEvent.click(link)
expect(mockScrollTo).toHaveBeenCalledWith({
- top: 520,
+ top: 20,
behavior: 'smooth',
})
@@ -562,8 +562,8 @@ describe('AnchorTitle Component', () => {
fireEvent.click(link)
const secondCall = (mockScrollTo.mock.calls[1][0] as unknown as { top: number }).top
- expect(firstCall).not.toBe(secondCall)
- expect(Math.abs(firstCall - secondCall)).toBe(30)
+ expect(firstCall).toBe(secondCall)
+ expect(Math.abs(firstCall - secondCall)).toBe(0)
mockScrollTo.mockRestore()
mockGetElementById.mockRestore()
diff --git a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx
index 830baf35a1..5cc2e59b95 100644
--- a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx
+++ b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx
@@ -164,8 +164,18 @@ jest.mock('components/LeadersList', () => ({
jest.mock('components/MetricsScoreCircle', () => ({
__esModule: true,
- default: ({ score, ...props }: { score: number; [key: string]: unknown }) => (
-
+ default: ({
+ score,
+ clickable,
+ onClick: _onClick,
+ ...props
+ }: {
+ score: number
+ clickable?: boolean
+ onClick?: () => void
+ [key: string]: unknown
+ }) => (
+
Score: {score}
),
@@ -724,7 +734,7 @@ describe('CardDetailsPage', () => {
})
describe('Event Handling', () => {
- it('renders clickable health metrics link', () => {
+ it('renders clickable health metrics button', () => {
render(
{
/>
)
- const healthLink = screen.getByRole('link')
- expect(healthLink).toHaveAttribute('href', '#issues-trend')
+ const healthButton = screen.getByRole('button')
+ expect(healthButton).toBeInTheDocument()
+ expect(screen.getByTestId('metrics-score-circle')).toBeInTheDocument()
})
it('renders social links with correct hrefs and target attributes', () => {
diff --git a/frontend/__tests__/unit/components/MetricsScoreCircle.test.tsx b/frontend/__tests__/unit/components/MetricsScoreCircle.test.tsx
index 141d550c58..79c3d986ff 100644
--- a/frontend/__tests__/unit/components/MetricsScoreCircle.test.tsx
+++ b/frontend/__tests__/unit/components/MetricsScoreCircle.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react'
+import { render, screen, fireEvent } from '@testing-library/react'
import React from 'react'
import '@testing-library/jest-dom'
import MetricsScoreCircle from 'components/MetricsScoreCircle'
@@ -172,14 +172,22 @@ describe('MetricsScoreCircle', () => {
})
// Test 9: Event handling - hover effects (visual testing through classes)
- it('has hover effect classes applied', () => {
- const { container } = render()
+ it('has hover effect classes applied when clickable', () => {
+ const { container } = render()
// Check for hover-related classes
const hoverElement = container.querySelector('[class*="hover:"]')
expect(hoverElement).toBeInTheDocument()
})
+ it('does not have hover effect classes when not clickable', () => {
+ const { container } = render()
+
+ // Should not have hover-related classes
+ const hoverElement = container.querySelector('[class*="hover:"]')
+ expect(hoverElement).not.toBeInTheDocument()
+ })
+
// Test 10: Component integration test
it('integrates all features correctly for a low score', () => {
const { container } = render()
@@ -218,4 +226,160 @@ describe('MetricsScoreCircle', () => {
'Current Project Health Score'
)
})
+
+ // Test 11: Click handling functionality
+ describe('click handling', () => {
+ it('calls onClick when clickable and onClick provided', () => {
+ const mockOnClick = jest.fn()
+ render()
+
+ const circleElement = screen.getByText('75').closest('.group')
+ if (circleElement) {
+ fireEvent.click(circleElement)
+ }
+
+ expect(mockOnClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not call onClick when not clickable', () => {
+ const mockOnClick = jest.fn()
+ render()
+
+ const circleElement = screen.getByText('75').closest('.group')
+ if (circleElement) {
+ fireEvent.click(circleElement)
+ }
+
+ expect(mockOnClick).not.toHaveBeenCalled()
+ })
+
+ it('does not call onClick when no onClick provided', () => {
+ render()
+
+ const circleElement = screen.getByText('75').closest('.group')
+ if (circleElement) {
+ fireEvent.click(circleElement)
+ }
+ // Should not throw any errors - test passes if no exception is thrown
+ expect(circleElement).toBeInTheDocument()
+ })
+
+ it('has cursor pointer when clickable', () => {
+ const { container } = render()
+
+ const clickableElement = container.querySelector('[class*="cursor-pointer"]')
+ expect(clickableElement).toBeInTheDocument()
+ })
+
+ it('does not have cursor pointer when not clickable', () => {
+ const { container } = render()
+
+ const clickableElement = container.querySelector('[class*="cursor-pointer"]')
+ expect(clickableElement).not.toBeInTheDocument()
+ })
+ })
+
+ // Test 12: Accessibility for clickable component
+ describe('accessibility for clickable component', () => {
+ it('has button role when clickable', () => {
+ render()
+
+ const buttonElement = screen.getByRole('button')
+ expect(buttonElement).toBeInTheDocument()
+ })
+
+ it('does not have button role when not clickable', () => {
+ render()
+
+ const buttonElement = screen.queryByRole('button')
+ expect(buttonElement).not.toBeInTheDocument()
+ })
+
+ it('has tabIndex when clickable', () => {
+ const { container } = render()
+
+ const clickableElement = container.querySelector('[tabindex="0"]')
+ expect(clickableElement).toBeInTheDocument()
+ })
+
+ it('does not have tabIndex when not clickable', () => {
+ const { container } = render()
+
+ const clickableElement = container.querySelector('[tabindex]')
+ expect(clickableElement).not.toBeInTheDocument()
+ })
+
+ it('handles keyboard navigation when clickable', () => {
+ const mockOnClick = jest.fn()
+ render()
+
+ const buttonElement = screen.getByRole('button')
+
+ // Test Enter key
+ fireEvent.keyDown(buttonElement, { key: 'Enter' })
+ expect(mockOnClick).toHaveBeenCalledTimes(1)
+
+ // Test Space key
+ fireEvent.keyDown(buttonElement, { key: ' ' })
+ expect(mockOnClick).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not handle keyboard navigation when not clickable', () => {
+ const mockOnClick = jest.fn()
+ render()
+
+ const circleElement = screen.getByText('75').closest('.group')
+ if (circleElement) {
+ fireEvent.keyDown(circleElement, { key: 'Enter' })
+ fireEvent.keyDown(circleElement, { key: ' ' })
+ }
+
+ expect(mockOnClick).not.toHaveBeenCalled()
+ })
+ })
+
+ // Test 13: Maintains existing functionality with new props
+ it('maintains all existing functionality when clickable is true', () => {
+ const { container } = render()
+
+ // Should still have red styling
+ expect(container.querySelector('[class*="bg-red"]')).toBeInTheDocument()
+
+ // Should still have pulse animation
+ expect(container.querySelector('[class*="animate-pulse"]')).toBeInTheDocument()
+
+ // Should still display correct score
+ expect(screen.getByText('25')).toBeInTheDocument()
+
+ // Should still have tooltip
+ expect(screen.getByTestId('tooltip-wrapper')).toHaveAttribute(
+ 'data-content',
+ 'Current Project Health Score'
+ )
+
+ // Should have hover effects
+ expect(container.querySelector('[class*="hover:"]')).toBeInTheDocument()
+ })
+
+ it('maintains all existing functionality when clickable is false', () => {
+ const { container } = render()
+
+ // Should still have red styling
+ expect(container.querySelector('[class*="bg-red"]')).toBeInTheDocument()
+
+ // Should still have pulse animation
+ expect(container.querySelector('[class*="animate-pulse"]')).toBeInTheDocument()
+
+ // Should still display correct score
+ expect(screen.getByText('25')).toBeInTheDocument()
+
+ // Should still have tooltip
+ expect(screen.getByTestId('tooltip-wrapper')).toHaveAttribute(
+ 'data-content',
+ 'Current Project Health Score'
+ )
+
+ // Should NOT have hover effects
+ expect(container.querySelector('[class*="hover:"]')).not.toBeInTheDocument()
+ })
})
diff --git a/frontend/src/app/projects/dashboard/metrics/[projectKey]/page.tsx b/frontend/src/app/projects/dashboard/metrics/[projectKey]/page.tsx
index eed4ff8c55..e64bebd0f2 100644
--- a/frontend/src/app/projects/dashboard/metrics/[projectKey]/page.tsx
+++ b/frontend/src/app/projects/dashboard/metrics/[projectKey]/page.tsx
@@ -72,7 +72,7 @@ const ProjectHealthMetricsDetails: FC = () => {
/>
-
+
= ({ title }) => {
const href = `#${id}`
const scrollToElement = useCallback(() => {
- const element = document.getElementById(id)
- if (element) {
- const headingHeight =
- (element.querySelector('div#anchor-title') as HTMLElement)?.offsetHeight || 0
- const yOffset = -headingHeight - 50
- const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset
- window.scrollTo({ top: y, behavior: 'smooth' })
- }
+ scrollToAnchor(id)
}, [id])
const handleClick = (event: React.MouseEvent) => {
event.preventDefault()
- scrollToElement()
- window.history.pushState(null, '', href)
+ scrollToAnchorWithHistory(id)
}
useEffect(() => {
@@ -50,7 +43,7 @@ const AnchorTitle: React.FC = ({ title }) => {
return (
-
{!isActive && (
diff --git a/frontend/src/components/MetricsScoreCircle.tsx b/frontend/src/components/MetricsScoreCircle.tsx
index d534a7c15f..9cd8efaf95 100644
--- a/frontend/src/components/MetricsScoreCircle.tsx
+++ b/frontend/src/components/MetricsScoreCircle.tsx
@@ -1,20 +1,55 @@
import { Tooltip } from '@heroui/tooltip'
-import { FC } from 'react'
+import React, { FC, MouseEvent } from 'react'
-const MetricsScoreCircle: FC<{ score: number }> = ({ score }) => {
- // Base colours with reduced opacity so colours remain but are less contrasting
+interface MetricsScoreCircleProps {
+ score: number
+ onClick?: (e: MouseEvent
) => void
+ clickable?: boolean
+}
+
+const MetricsScoreCircle: FC = ({ score, onClick, clickable = false }) => {
let scoreStyle = 'bg-green-400/80 text-green-900/90'
if (score < 50) {
scoreStyle = 'bg-red-400/80 text-red-900/90'
} else if (score < 75) {
scoreStyle = 'bg-yellow-400/80 text-yellow-900/90'
}
+
+ const handleClick = (e: MouseEvent) => {
+ if (clickable && onClick) {
+ onClick(e)
+ }
+ }
+
+ const baseClasses = `relative flex h-14 w-14 flex-col items-center justify-center rounded-full shadow-md transition-all duration-300 ${scoreStyle}`
+ const groupClass = clickable ? 'group' : ''
+ const clickableClasses = clickable ? 'hover:scale-105 hover:shadow-lg cursor-pointer' : ''
+ const finalClasses = `${groupClass} ${baseClasses} ${clickableClasses}`
+
return (
) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ // For keyboard navigation, call onClick directly since we don't need the event object
+ if (onClick) {
+ onClick(e as unknown as React.MouseEvent
)
+ }
+ }
+ }
+ : undefined
+ }
>
-
+ {clickable && (
+
+ )}
Health
@@ -31,4 +66,5 @@ const MetricsScoreCircle: FC<{ score: number }> = ({ score }) => {
)
}
+
export default MetricsScoreCircle
diff --git a/frontend/src/utils/scrollToAnchor.ts b/frontend/src/utils/scrollToAnchor.ts
new file mode 100644
index 0000000000..a3c7d21c60
--- /dev/null
+++ b/frontend/src/utils/scrollToAnchor.ts
@@ -0,0 +1,34 @@
+export function scrollToAnchor(targetId: string, additionalOffset = 80): void {
+ try {
+ const element = document.getElementById(targetId)
+
+ if (!element) {
+ return
+ }
+ const anchorElement = element.querySelector('[data-anchor-title]')
+ const headingHeight = anchorElement instanceof HTMLElement ? anchorElement.offsetHeight : 0
+ const yOffset = -headingHeight - additionalOffset
+
+ // Use modern window.scrollY instead of deprecated pageYOffset
+ const y = element.getBoundingClientRect().top + window.scrollY + yOffset
+ window.scrollTo({
+ top: y,
+ behavior: 'smooth',
+ })
+ } catch {
+ // Silently handle scroll errors
+ }
+}
+
+export const scrollToAnchorWithHistory = (targetId: string, updateHistory = true): void => {
+ scrollToAnchor(targetId)
+
+ if (updateHistory) {
+ try {
+ const href = `#${targetId}`
+ window.history.pushState(null, '', href)
+ } catch {
+ // Silently handle history update errors
+ }
+ }
+}