Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions frontend/__tests__/unit/components/AnchorTitle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -372,7 +372,7 @@ describe('AnchorTitle Component', () => {
render(<AnchorTitle title="Heading Test" />)

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')
Expand Down Expand Up @@ -469,7 +469,7 @@ describe('AnchorTitle Component', () => {
fireEvent.click(link)

expect(mockScrollTo).toHaveBeenCalledWith({
top: 520,
top: 20,
behavior: 'smooth',
})

Expand Down Expand Up @@ -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()
Expand Down
170 changes: 167 additions & 3 deletions frontend/__tests__/unit/components/MetricsScoreCircle.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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(<MetricsScoreCircle score={75} />)
it('has hover effect classes applied when clickable', () => {
const { container } = render(<MetricsScoreCircle score={75} clickable={true} />)

// 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(<MetricsScoreCircle score={75} clickable={false} />)

// 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(<MetricsScoreCircle score={15} />)
Expand Down Expand Up @@ -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(<MetricsScoreCircle score={75} clickable={true} onClick={mockOnClick} />)

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(<MetricsScoreCircle score={75} clickable={false} onClick={mockOnClick} />)

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(<MetricsScoreCircle score={75} clickable={true} />)

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(<MetricsScoreCircle score={75} clickable={true} />)

const clickableElement = container.querySelector('[class*="cursor-pointer"]')
expect(clickableElement).toBeInTheDocument()
})

it('does not have cursor pointer when not clickable', () => {
const { container } = render(<MetricsScoreCircle score={75} clickable={false} />)

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(<MetricsScoreCircle score={75} clickable={true} />)

const buttonElement = screen.getByRole('button')
expect(buttonElement).toBeInTheDocument()
})

it('does not have button role when not clickable', () => {
render(<MetricsScoreCircle score={75} clickable={false} />)

const buttonElement = screen.queryByRole('button')
expect(buttonElement).not.toBeInTheDocument()
})

it('has tabIndex when clickable', () => {
const { container } = render(<MetricsScoreCircle score={75} clickable={true} />)

const clickableElement = container.querySelector('[tabindex="0"]')
expect(clickableElement).toBeInTheDocument()
})

it('does not have tabIndex when not clickable', () => {
const { container } = render(<MetricsScoreCircle score={75} clickable={false} />)

const clickableElement = container.querySelector('[tabindex]')
expect(clickableElement).not.toBeInTheDocument()
})

it('handles keyboard navigation when clickable', () => {
const mockOnClick = jest.fn()
render(<MetricsScoreCircle score={75} clickable={true} onClick={mockOnClick} />)

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(<MetricsScoreCircle score={75} clickable={false} onClick={mockOnClick} />)

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(<MetricsScoreCircle score={25} clickable={true} />)

// 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(<MetricsScoreCircle score={25} clickable={false} />)

// 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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const ProjectHealthMetricsDetails: FC = () => {
/>
</div>
<div className="flex items-center gap-2">
<MetricsScoreCircle score={metricsLatest.score} />
<MetricsScoreCircle score={metricsLatest.score} clickable={false} />
<GeneralCompliantComponent
title={
metricsLatest.isFundingRequirementsCompliant
Expand Down
15 changes: 4 additions & 11 deletions frontend/src/components/AnchorTitle.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { faLink } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import React, { useEffect, useCallback } from 'react'
import { scrollToAnchor, scrollToAnchorWithHistory } from 'utils/scrollToAnchor'
import slugify from 'utils/slugify'

interface AnchorTitleProps {
Expand All @@ -13,20 +14,12 @@ const AnchorTitle: React.FC<AnchorTitleProps> = ({ 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<HTMLAnchorElement, MouseEvent>) => {
event.preventDefault()
scrollToElement()
window.history.pushState(null, '', href)
scrollToAnchorWithHistory(id)
}

useEffect(() => {
Expand All @@ -50,7 +43,7 @@ const AnchorTitle: React.FC<AnchorTitleProps> = ({ title }) => {
return (
<div id={id} className="relative">
<div className="group relative flex items-center">
<div className="flex items-center text-2xl font-semibold" id="anchor-title">
<div className="flex items-center text-2xl font-semibold" data-anchor-title="true">
{title}
</div>
<a
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/components/CardDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import {
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import upperFirst from 'lodash/upperFirst'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import type { ExtendedSession } from 'types/auth'
import type { DetailsCardProps } from 'types/card'
import { IS_PROJECT_HEALTH_ENABLED } from 'utils/credentials'
import { scrollToAnchor } from 'utils/scrollToAnchor'
import { getSocialIcon } from 'utils/urlIconMappings'
import AnchorTitle from 'components/AnchorTitle'
import ChapterMapWrapper from 'components/ChapterMapWrapper'
Expand Down Expand Up @@ -96,9 +96,11 @@ const DetailsCard = ({
</button>
)}
{IS_PROJECT_HEALTH_ENABLED && type === 'project' && healthMetricsData.length > 0 && (
<Link href="#issues-trend">
<MetricsScoreCircle score={healthMetricsData[0].score} />
</Link>
<MetricsScoreCircle
score={healthMetricsData[0].score}
clickable={true}
onClick={() => scrollToAnchor('issues-trend')}
/>
)}
</div>
{!isActive && (
Expand Down
46 changes: 41 additions & 5 deletions frontend/src/components/MetricsScoreCircle.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>) => void
clickable?: boolean
}

const MetricsScoreCircle: FC<MetricsScoreCircleProps> = ({ 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<HTMLDivElement>) => {
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 (
<Tooltip content={'Current Project Health Score'} placement="top">
<div
className={`group relative flex h-14 w-14 flex-col items-center justify-center rounded-full shadow-md transition-all duration-300 hover:scale-105 hover:shadow-lg ${scoreStyle}`}
className={finalClasses}
onClick={handleClick}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onKeyDown={
clickable
? (e: React.KeyboardEvent<HTMLDivElement>) => {
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<HTMLDivElement>)
}
}
}
: undefined
}
>
<div className="absolute inset-0 rounded-full bg-gradient-to-br from-white/20 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"></div>
{clickable && (
<div className="absolute inset-0 rounded-full bg-gradient-to-br from-white/20 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"></div>
)}
<div className="relative z-10 flex flex-col items-center text-center">
<span className="text-[0.5rem] font-medium uppercase tracking-wide opacity-60">
Health
Expand All @@ -31,4 +66,5 @@ const MetricsScoreCircle: FC<{ score: number }> = ({ score }) => {
</Tooltip>
)
}

export default MetricsScoreCircle
Loading