diff --git a/frontend/__tests__/a11y/components/CardDetailsPage.a11y.test.tsx b/frontend/__tests__/a11y/components/CardDetailsPage.a11y.test.tsx index dc23d3de6e..f79e721540 100644 --- a/frontend/__tests__/a11y/components/CardDetailsPage.a11y.test.tsx +++ b/frontend/__tests__/a11y/components/CardDetailsPage.a11y.test.tsx @@ -1,12 +1,13 @@ -import { mockChapterData } from '@mockData/mockChapterData' import { render } from '@testing-library/react' import { axe } from 'jest-axe' import { useTheme } from 'next-themes' import React from 'react' import { FaCode, FaTags } from 'react-icons/fa6' -import { ExperienceLevelEnum } from 'types/__generated__/graphql' -import { DetailsCardProps } from 'types/card' -import DetailsCard from 'components/CardDetailsPage' +import CardDetailsHeader from 'components/CardDetailsPage/CardDetailsHeader' +import CardDetailsMetadata from 'components/CardDetailsPage/CardDetailsMetadata' +import CardDetailsPageWrapper from 'components/CardDetailsPage/CardDetailsPageWrapper' +import CardDetailsSummary from 'components/CardDetailsPage/CardDetailsSummary' +import CardDetailsTags from 'components/CardDetailsPage/CardDetailsTags' jest.mock('next/link', () => { return function MockLink({ @@ -74,49 +75,17 @@ jest.mock('@apollo/client/react', () => ({ useMutation: jest.fn(() => [jest.fn()]), })) -const mockHealthMetricsData = [ - { - ageDays: 365, - ageDaysRequirement: 365, - id: 'test-id', - createdAt: '2023-01-01', - contributorsCount: 10, - forksCount: 5, - isFundingRequirementsCompliant: true, - isLeaderRequirementsCompliant: true, - lastCommitDays: 1, - lastCommitDaysRequirement: 30, - lastPullRequestDays: 2, - lastPullRequestDaysRequirement: 30, - lastReleaseDays: 10, - lastReleaseDaysRequirement: 90, - openIssuesCount: 5, - openPullRequestsCount: 3, - owaspPageLastUpdateDays: 30, - owaspPageLastUpdateDaysRequirement: 90, - projectName: 'Test Project', - projectKey: 'test-project', - recentReleasesCount: 2, - score: 85, - starsCount: 100, - totalIssuesCount: 20, - totalReleasesCount: 5, - unassignedIssuesCount: 2, - unansweredIssuesCount: 1, - }, -] - const mockStats = [ { icon: FaCode, pluralizedName: 'repositories', - unit: '', + unit: 'Repository', value: 10, }, { icon: FaTags, - pluralizedName: 'stars', - unit: '', + pluralizedName: 'Stars', + unit: 'Star', value: 100, }, ] @@ -127,26 +96,6 @@ const mockDetails = [ { label: 'Status', value: 'Active' }, ] -const defaultProps: DetailsCardProps = { - title: 'Test Project', - description: 'A test project for demonstration', - type: 'project', - details: mockDetails, - stats: mockStats, - isActive: true, - showAvatar: true, - languages: ['JavaScript', 'TypeScript'], - topics: ['web', 'frontend'], - repositories: [], - recentIssues: [], - recentMilestones: [], - recentReleases: [], - pullRequests: [], - topContributors: [], - healthMetricsData: mockHealthMetricsData, - socialLinks: [], -} - describe.each([ { theme: 'light', name: 'light' }, { theme: 'dark', name: 'dark' }, @@ -155,69 +104,56 @@ describe.each([ ;(useTheme as jest.Mock).mockReturnValue({ theme, setTheme: jest.fn() }) document.documentElement.classList.toggle('dark', theme === 'dark') }) - it('should have no accessibility violations', async () => { - const { container } = render() + + it('should have no accessibility violations in header', async () => { + const { container } = render( + + + + ) const results = await axe(container) expect(results).toHaveNoViolations() }) - it('should have no violations for chapter type', async () => { + it('should have no violations in summary', async () => { const { container } = render( - + + + ) - const results = await axe(container) - expect(results).toHaveNoViolations() }) - it('should have no violations for program type', async () => { + it('should have no violations in metadata', async () => { const { container } = render( - + + + ) const results = await axe(container) expect(results).toHaveNoViolations() }) - it('should have no violations in archived state', async () => { - let container: HTMLElement - - await React.act(async () => { - const renderResult = render( - - ) - container = renderResult.container - }) + it('should have no violations in tags', async () => { + const { container } = render( + + + + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) + it('should have no violations for complete page layout', async () => { + const { container } = render( + + + + + + + ) const results = await axe(container) expect(results).toHaveNoViolations() }) diff --git a/frontend/__tests__/e2e/pages/ChapterDetails.spec.ts b/frontend/__tests__/e2e/pages/ChapterDetails.spec.ts index 3b14824d2d..8b2c172d27 100644 --- a/frontend/__tests__/e2e/pages/ChapterDetails.spec.ts +++ b/frontend/__tests__/e2e/pages/ChapterDetails.spec.ts @@ -22,19 +22,17 @@ test.describe('Chapter Details Page', () => { test('should have map with geolocation', async ({ page }) => { const unlockButton = page.getByRole('button', { name: 'Unlock map' }) - await expect(unlockButton).toBeVisible() + await unlockButton.waitFor({ state: 'attached', timeout: 30000 }) - await unlockButton.click() + await unlockButton.dispatchEvent('click') - await expect(page.getByRole('button', { name: 'Zoom in' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Zoom out' })).toBeVisible() + await unlockButton.waitFor({ state: 'detached', timeout: 10000 }) - const marker = page.locator('.leaflet-marker-icon').first() - await marker.click() + await page.locator('.leaflet-control-zoom-in').waitFor({ state: 'attached', timeout: 10000 }) + await page.locator('.leaflet-control-zoom-out').waitFor({ state: 'attached', timeout: 10000 }) - // The popup typically matches the chapter name - const popupButton = page.getByRole('button', { name: 'OWASP Rosario' }) - await expect(popupButton).toBeVisible() + await page.locator('.leaflet-container').waitFor({ state: 'attached' }) + await page.locator('.leaflet-tile-pane').waitFor({ state: 'attached' }) }) test('should have top contributors', async ({ page }) => { diff --git a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx deleted file mode 100644 index 1e4230c66c..0000000000 --- a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx +++ /dev/null @@ -1,2936 +0,0 @@ -import { render, screen, cleanup, fireEvent } from '@testing-library/react' -import React from 'react' -import '@testing-library/jest-dom' -import { FaCode, FaTags } from 'react-icons/fa6' -import type { MenteeNode } from 'types/__generated__/graphql' -import type { DetailsCardProps } from 'types/card' -import type { PullRequest } from 'types/pullRequest' -import CardDetailsPage, { type CardType } from 'components/CardDetailsPage' - -jest.mock('@heroui/tooltip', () => ({ - Tooltip: ({ children, content }: { children: React.ReactNode; content: string }) => ( -
- {children} -
- ), -})) - -jest.mock('next/navigation', () => ({ - useRouter: () => ({ - push: jest.fn(), - replace: jest.fn(), - prefetch: jest.fn(), - }), -})) - -jest.mock('next/image', () => ({ - __esModule: true, - default: ({ - src, - alt, - fill, - objectFit, - ...props - }: { - src: string - alt: string - fill?: boolean - objectFit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down' - [key: string]: unknown - }) => ( - // eslint-disable-next-line @next/next/no-img-element - {alt} - ), -})) - -jest.mock('utils/env.client', () => ({ - IS_PROJECT_HEALTH_ENABLED: true, -})) - -jest.mock('next-auth/react', () => { - return { - useSession: jest.fn(() => ({ - data: null, - status: 'unauthenticated', - })), - SessionProvider: ({ children }: { children: React.ReactNode }) => children, - } -}) - -jest.mock('utils/scrollToAnchor', () => ({ - scrollToAnchor: jest.fn(), -})) - -jest.mock('utils/dateFormatter', () => ({ - formatDate: (date: string | number) => { - if (typeof date === 'string') return date - return new Date(date).toISOString().split('T')[0] - }, -})) - -jest.mock('utils/urlFormatter', () => ({ - getMemberUrl: (login: string) => `/members/${login}`, - getMenteeUrl: (programKey: string, entityKey: string, login: string) => - `/programs/${programKey}/mentees/${login}`, -})) - -jest.mock('utils/urlIconMappings', () => ({ - getSocialIcon: (url: string) => { - const safe = encodeURIComponent(url) - return function MockSocialIcon(props: { className?: string }) { - return - } - }, -})) - -jest.mock('components/AnchorTitle', () => ({ - __esModule: true, - default: ({ - title, - className, - ...props - }: { - title: string - className?: string - [key: string]: unknown - }) => ( - - {title} - - ), -})) - -jest.mock('components/ChapterMapWrapper', () => ({ - __esModule: true, - default: ({ - geoLocData: _geoLocData, - showLocal, - style, - showLocationSharing: _showLocationSharing, - ...otherProps - }: { - geoLocData?: unknown - showLocal: boolean - style: React.CSSProperties - showLocationSharing?: boolean - [key: string]: unknown - }) => { - return ( -
- Chapter Map {showLocal ? '(Local)' : ''} -
- ) - }, -})) - -jest.mock('components/HealthMetrics', () => ({ - __esModule: true, - default: ({ data, ...props }: { data: unknown[]; [key: string]: unknown }) => ( -
- Health Metrics ({data.length} items) -
- ), -})) - -jest.mock('components/ContributionHeatmap', () => ({ - __esModule: true, - default: ({ - contributionData, - startDate, - endDate, - ...props - }: { - contributionData: Record - startDate: string - endDate: string - [key: string]: unknown - }) => ( -
- Heatmap: {Object.keys(contributionData).length} days from {startDate} to {endDate} -
- ), -})) - -jest.mock('components/ContributionStats', () => ({ - __esModule: true, - default: ({ - title, - stats, - ...props - }: { - title: string - stats?: { commits: number; pullRequests: number; issues: number; total: number } - [key: string]: unknown - }) => ( -
-

{title}

- {stats && ( - <> -

{stats.commits}

-

{stats.pullRequests}

-

{stats.issues}

-

{stats.total}

- - )} -
- ), -})) - -jest.mock('components/InfoBlock', () => ({ - __esModule: true, - default: ({ - icon: _icon, - pluralizedName, - unit, - value, - className, - ...props - }: { - _icon: unknown - pluralizedName?: string - unit?: string - value: number - className?: string - [key: string]: unknown - }) => ( -
- {value} {unit} {pluralizedName} -
- ), -})) - -jest.mock('components/LeadersList', () => ({ - __esModule: true, - default: ({ - leaders, - entityKey: _entityKey, - ...props - }: { - leaders: string - entityKey: string - [key: string]: unknown - }) => ( - - {leaders} - - ), -})) - -jest.mock('components/MetricsScoreCircle', () => ({ - __esModule: true, - default: ({ - score, - clickable, - onClick: _onClick, - ...props - }: { - score: number - clickable?: boolean - onClick?: () => void - [key: string]: unknown - }) => - clickable ? ( - - ) : ( -
- Score: {score} -
- ), -})) - -jest.mock('components/Milestones', () => ({ - __esModule: true, - default: ({ - data, - showAvatar, - ...props - }: { - data: unknown[] - showAvatar: boolean - [key: string]: unknown - }) => ( -
- Milestones ({data?.length || 0} items) {showAvatar ? 'with avatars' : 'no avatars'} -
- ), -})) - -jest.mock('components/RecentIssues', () => ({ - __esModule: true, - default: ({ - data, - showAvatar, - ...props - }: { - data: unknown[] - showAvatar: boolean - [key: string]: unknown - }) => ( -
- Recent Issues ({data?.length || 0} items) {showAvatar ? 'with avatars' : 'no avatars'} -
- ), -})) - -jest.mock('components/RecentPullRequests', () => ({ - __esModule: true, - default: ({ - data, - showAvatar, - ...props - }: { - data: unknown[] - showAvatar: boolean - [key: string]: unknown - }) => ( -
- Recent Pull Requests ({data?.length || 0} items) {showAvatar ? 'with avatars' : 'no avatars'} -
- ), -})) - -jest.mock('components/MentorshipPullRequest', () => ({ - __esModule: true, - default: ({ pr, ...props }: { pr: PullRequest; [key: string]: unknown }) => ( -
- MentorshipPullRequest: {pr.title} -
- ), -})) - -jest.mock('components/RecentReleases', () => ({ - __esModule: true, - default: ({ - data, - showAvatar, - showSingleColumn, - ...props - }: { - data: unknown[] - showAvatar: boolean - showSingleColumn?: boolean - [key: string]: unknown - }) => ( -
- Recent Releases ({data?.length || 0} items) {showAvatar ? 'with avatars' : 'no avatars'} - {showSingleColumn ? ' (single column)' : ''} -
- ), -})) - -jest.mock('components/RepositoryCard', () => ({ - __esModule: true, - default: ({ - repositories, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - maxInitialDisplay, - ...props - }: { - repositories: unknown[] - maxInitialDisplay?: number - [key: string]: unknown - }) => ( -
- Repositories ({repositories.length} items) -
- ), -})) - -jest.mock('components/SecondaryCard', () => ({ - __esModule: true, - default: ({ - title, - children, - className, - icon: _icon, - ...props - }: { - _icon?: unknown - title: React.ReactNode - children: React.ReactNode - className?: string - [key: string]: unknown - }) => ( -
-

{title}

-
{children}
-
- ), -})) - -jest.mock('components/SponsorCard', () => ({ - __esModule: true, - default: ({ - target, - title, - type, - ...props - }: { - target: string - title: string - type: string - [key: string]: unknown - }) => ( -
- Sponsor Card - Target: {target}, Title: {title}, Type: {type} -
- ), -})) - -jest.mock('components/ToggleableList', () => ({ - __esModule: true, - default: ({ - items, - icon: _icon, - label, - entityKey: _entityKey, - isDisabled: _isDisabled, - ...props - }: { - items: string[] - _icon: unknown - label: React.ReactNode - entityKey: string - isDisabled?: boolean - [key: string]: unknown - }) => ( -
- {label}: {items.join(', ')} -
- ), -})) - -jest.mock('components/ContributorsList', () => ({ - __esModule: true, - default: ({ - contributors, - maxInitialDisplay, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - icon, - title = 'Contributors', - - getUrl, - ...props - }: { - contributors: (Partial & { tag?: string; login?: string; name?: string })[] - icon?: unknown - title?: string - maxInitialDisplay: number - getUrl: (login: string) => string - [key: string]: unknown - }) => ( -
- {title} ({contributors.length} items, max display: {maxInitialDisplay}) - {contributors.map((c) => ( - - {c.name || c.login || 'Unknown'} - - ))} -
- ), -})) - -jest.mock('components/EntityActions', () => ({ - __esModule: true, - default: ({ - type, - programKey, - moduleKey, - status: _status, - setStatus: _setStatus, - isAdmin, - isMentor: _isMentor, - ...props - }: { - type: string - programKey?: string - moduleKey?: string - status?: string - setStatus?: (status: string) => void - isAdmin?: boolean - isMentor?: boolean - [key: string]: unknown - }) => ( -
- EntityActions: type={type}, programKey={programKey}, moduleKey={moduleKey} -
- ), -})) - -jest.mock('components/Leaders', () => { - return { - __esModule: true, - default: ({ users, ...props }: { users: unknown[]; [key: string]: unknown }) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const usersList = users as any[] - return ( -
-

Leaders

- {Array.isArray(usersList) && - // eslint-disable-next-line @typescript-eslint/no-explicit-any - usersList.map((user: any, index: number) => { - const uniqueKey = `leader-${index}-${user.login || 'unknown'}` - return ( -
-
{user.member?.name || user.memberName || 'Unknown'}
-
{user.description || ''}
-
- ) - })} -
- ) - }, - } -}) - -jest.mock('components/StatusBadge', () => ({ - __esModule: true, - default: ({ - status, - _size, - ...props - }: { - status: string - _size?: string - [key: string]: unknown - }) => ( - - {status.charAt(0).toUpperCase() + status.slice(1)} - - ), -})) - -jest.mock('components/MarkdownWrapper', () => ({ - __esModule: true, - default: ({ content, ...props }: { content: string; [key: string]: unknown }) => ( -
- {content} -
- ), -})) - -jest.mock('components/ModuleCard', () => ({ - __esModule: true, - default: ({ - modules, - accessLevel: _accessLevel, - admins: _admins, - programKey: _programKey, - ...props - }: { - modules: unknown[] - accessLevel: string - admins?: unknown[] - programKey?: string - [key: string]: unknown - }) => ( -
- ModuleCard ({modules?.length || 0} modules) -
- ), -})) - -jest.mock('components/ShowMoreButton', () => { - function ShowMoreButtonMock({ - onToggle, - ...props - }: Readonly<{ - onToggle: () => void - [key: string]: unknown - }>) { - const [isExpanded, setIsExpanded] = React.useState(false) - return ( - - ) - } - return { - __esModule: true, - default: ShowMoreButtonMock, - } -}) - -jest.mock('components/TruncatedText', () => ({ - __esModule: true, - TruncatedText: ({ text }: { text: string }) => {text}, -})) - -describe('CardDetailsPage', () => { - const createMalformedData = >( - validData: T, - overrides: Record - ): T => { - return { ...validData, ...overrides } - } - - const createMalformedArray = >( - validArray: T[], - malformedItems: Record[] - ): T[] => { - return malformedItems.map((item, index) => - createMalformedData(validArray[index] || validArray[0], item) - ) - } - - const createInvalidValues = () => ({ - nullValue: null, - undefinedValue: undefined, - emptyString: '', - negativeNumber: -10, - invalidUrl: 'not-a-url', - }) - - const invalidValues = createInvalidValues() - - const mockHealthMetricsData = [ - { - ageDays: 365, - ageDaysRequirement: 365, - id: 'test-id', - createdAt: '2023-01-01', - contributorsCount: 10, - forksCount: 5, - isFundingRequirementsCompliant: true, - isLeaderRequirementsCompliant: true, - lastCommitDays: 1, - lastCommitDaysRequirement: 30, - lastPullRequestDays: 2, - lastPullRequestDaysRequirement: 30, - lastReleaseDays: 10, - lastReleaseDaysRequirement: 90, - openIssuesCount: 5, - openPullRequestsCount: 3, - owaspPageLastUpdateDays: 30, - owaspPageLastUpdateDaysRequirement: 90, - projectName: 'Test Project', - projectKey: 'test-project', - recentReleasesCount: 2, - score: 85, - starsCount: 100, - totalIssuesCount: 20, - totalReleasesCount: 5, - unassignedIssuesCount: 2, - unansweredIssuesCount: 1, - }, - ] - - const mockStats = [ - { - icon: FaCode, - pluralizedName: 'repositories', - unit: '', - value: 10, - }, - { - icon: FaTags, - pluralizedName: 'stars', - unit: '', - value: 100, - }, - ] - - const mockDetails = [ - { label: 'Created', value: '2023-01-01' }, - { label: 'Leaders', value: 'John Doe, Jane Smith' }, - { label: 'Status', value: 'Active' }, - ] - - const mockContributors = [ - { - id: 'contributor-1', - avatarUrl: 'https://example.com/avatar1.jpg', - login: 'john_doe', - name: 'John Doe', - projectKey: 'test-project', - contributionsCount: 50, - }, - { - id: 'contributor-2', - avatarUrl: 'https://example.com/avatar2.jpg', - login: 'jane_smith', - name: 'Jane Smith', - projectKey: 'test-project', - contributionsCount: 30, - }, - ] - - const mockRepositories = [ - { - contributorsCount: 5, - forksCount: 10, - name: 'test-repo-1', - openIssuesCount: 3, - starsCount: 50, - subscribersCount: 20, - url: 'https://github.com/test/repo1', - key: 'test-repo-1', - }, - { - contributorsCount: 8, - forksCount: 15, - name: 'test-repo-2', - openIssuesCount: 5, - starsCount: 80, - subscribersCount: 30, - url: 'https://github.com/test/repo2', - key: 'test-repo-2', - }, - ] - - const mockUser = { - avatarUrl: 'https://example.com/avatar.jpg', - contributionsCount: 100, - createdAt: Date.now() - 31536000000, - followersCount: 50, - followingCount: 25, - key: 'test-user', - login: 'test_user', - name: 'Test User', - publicRepositoriesCount: 10, - url: 'https://github.com/test_user', - } - - const mockRecentIssues = [ - { - author: mockUser, - createdAt: Date.now() - 86400000, - hint: 'Bug fix needed', - labels: ['bug', 'high-priority'], - number: '123', - organizationName: 'test-org', - projectName: 'Test Project', - projectUrl: 'https://github.com/test/project', - body: 'Issue summary', - title: 'Test Issue', - updatedAt: Date.now(), - url: 'https://github.com/test/project/issues/123', - objectID: 'issue-123', - }, - ] - - const mockRecentMilestones = [ - { - author: mockUser, - body: 'Milestone description', - closedIssuesCount: 5, - createdAt: '2023-01-01T00:00:00.000Z', - openIssuesCount: 2, - repositoryName: 'test-repo', - state: 'open', - title: 'v1.0.0 Release', - url: 'https://github.com/test/project/milestone/1', - }, - ] - - const mockPullRequests = [ - { - id: 'mock-pull-request-1', - author: mockUser, - createdAt: new Date(Date.now() - 172800000).toISOString(), - organizationName: 'test-org', - title: 'Add new feature', - url: 'https://github.com/test/project/pull/456', - state: 'merged', - mergedAt: new Date(Date.now() - 86400000).toISOString(), - }, - ] - - const mockRecentReleases = [ - { - id: 'release-1', - author: mockUser, - isPreRelease: false, - name: 'v1.0.0', - publishedAt: Date.now() - 604800000, - repositoryName: 'test-repo', - tagName: 'v1.0.0', - url: 'https://github.com/test/repo/releases/tag/v1.0.0', - }, - ] - - const mockChapterGeoData = [ - { - createdAt: Date.now() - 31536000000, - isActive: true, - key: 'test-chapter', - leaders: ['John Doe', 'Jane Smith'], - name: 'Test Chapter', - objectID: 'chapter-test', - region: 'North America', - relatedUrls: ['https://example.com'], - suggestedLocation: 'New York, NY', - summary: 'Test chapter summary', - topContributors: mockContributors, - updatedAt: Date.now(), - url: 'https://owasp.org/test-chapter', - _geoloc: { lat: 40.7128, lng: -74.006 }, - }, - ] - - const defaultProps: DetailsCardProps = { - title: 'Test Project', - description: 'A test project for demonstration', - type: 'project', - details: mockDetails, - stats: mockStats, - isActive: true, - showAvatar: true, - languages: ['JavaScript', 'TypeScript'], - topics: ['web', 'frontend'], - repositories: [], - recentIssues: [], - recentMilestones: [], - recentReleases: [], - pullRequests: [], - topContributors: [], - healthMetricsData: mockHealthMetricsData, - socialLinks: [], - } - - afterEach(() => { - cleanup() - jest.clearAllMocks() - }) - - describe('Essential Rendering Tests', () => { - it('renders successfully with minimal required props', () => { - const minimalProps: DetailsCardProps = { - title: 'Minimal Project', - type: 'project', - stats: [], - healthMetricsData: [], - languages: [], - topics: [], - } - - render() - - expect(screen.getByText('Minimal Project')).toBeInTheDocument() - expect(screen.getByText('Project Details')).toBeInTheDocument() - }) - - it('renders with all props provided', () => { - render() - - expect(screen.getByText('Test Project')).toBeInTheDocument() - expect(screen.getByText('A test project for demonstration')).toBeInTheDocument() - expect(screen.getByText('Project Details')).toBeInTheDocument() - expect(screen.getByText('Statistics')).toBeInTheDocument() - }) - }) - - describe('Conditional Rendering Logic', () => { - it('renders inactive badge when isActive is false', () => { - render() - - expect(screen.getByText('Inactive')).toBeInTheDocument() - // Updated classes for consistent badge styling. - expect(screen.getByText('Inactive')).toHaveClass('bg-red-50', 'text-red-800') - }) - - it('does not render inactive badge when isActive is true', () => { - render() - - expect(screen.queryByText('Inactive')).not.toBeInTheDocument() - }) - - it('renders summary section when summary prop is provided', () => { - render() - - expect(screen.getByText('Summary')).toBeInTheDocument() - expect(screen.getByText('This is a project summary')).toBeInTheDocument() - }) - - it('renders userSummary section when userSummary prop is provided', () => { - const userSummary =
Custom user summary content
- render() - - const userSummaryContent = screen.getByText('Custom user summary content') - expect(userSummaryContent).toBeInTheDocument() - }) - - it('renders health metrics when type is project and health data is available', () => { - render( - - ) - - expect(screen.getByTestId('health-metrics')).toBeInTheDocument() - expect(screen.getByTestId('metrics-score-circle')).toBeInTheDocument() - }) - - it('does not render health metrics when type is not project', () => { - render( - - ) - - expect(screen.queryByTestId('health-metrics')).not.toBeInTheDocument() - }) - - it('renders chapter map when type is chapter and geolocation data is provided', () => { - render( - - ) - - expect(screen.getByTestId('chapter-map-wrapper')).toBeInTheDocument() - }) - - it('renders social links for chapter and committee types', () => { - const socialLinks = ['https://github.com/test', 'https://twitter.com/test'] - render() - - expect(screen.getByText('Social Links')).toBeInTheDocument() - expect(screen.getAllByRole('link')).toHaveLength(2) - }) - }) - - describe('Prop-based Behavior', () => { - it('renders different grid layout for chapter type', () => { - render() - - const detailsCard = screen.getByTestId('secondary-card') - expect(detailsCard).toHaveClass('md:col-span-3') - }) - - it('renders different grid layout for non-chapter types', () => { - render() - - const detailsCards = screen.getAllByTestId('secondary-card') - const detailsCard = detailsCards.find((card) => card.textContent?.includes('Project Details')) - expect(detailsCard).toHaveClass('md:col-span-5') - }) - - const supportedTypes: CardType[] = [ - 'project', - 'repository', - 'committee', - 'user', - 'organization', - ] - - test.each(supportedTypes)('renders statistics section for %s type', (entityType) => { - render() - expect(screen.getByText('Statistics')).toBeInTheDocument() - }) - - it('renders languages and topics for project and repository types', () => { - render() - - expect(screen.getAllByTestId('toggleable-list')).toHaveLength(2) - expect( - screen.getByText( - (content, element) => element?.textContent === 'Languages: JavaScript, TypeScript' - ) - ).toBeInTheDocument() - expect( - screen.getByText((content, element) => element?.textContent === 'Topics: web, frontend') - ).toBeInTheDocument() - }) - - it('renders repositories section when repositories are provided', () => { - render() - - expect(screen.getByText('Repositories')).toBeInTheDocument() - expect(screen.getByTestId('repositories-card')).toBeInTheDocument() - }) - - it('renders MentorshipPullRequest when type is module and PRs are provided', () => { - render( - - ) - - expect(screen.getByText('Recent Pull Requests')).toBeInTheDocument() - expect(screen.getAllByTestId('pull-request-item').length).toBeGreaterThan(0) - }) - }) - - describe('Event Handling', () => { - it('renders clickable health metrics button', () => { - render( - - ) - - const healthButton = screen.getByRole('button') - expect(healthButton).toBeInTheDocument() - expect(screen.getByTestId('metrics-score-circle')).toBeInTheDocument() - }) - - it('renders Show More button when onLoadMorePullRequests is provided', () => { - render( - - ) - expect(screen.getByRole('button', { name: /Show more/i })).toBeInTheDocument() - }) - - it('renders Show Less button when onResetPullRequests is provided', () => { - render( - - ) - expect(screen.getByRole('button', { name: /Show less/i })).toBeInTheDocument() - }) - - it('shows Loading on Show more and disables both controls when isFetchingMore is true', () => { - render( - - ) - expect(screen.getByRole('button', { name: /Loading/i })).toBeDisabled() - expect(screen.getByRole('button', { name: /Show less/i })).toBeDisabled() - }) - - it('calls scrollToAnchor when MetricsScoreCircle is clicked', () => { - const { scrollToAnchor } = jest.requireMock('utils/scrollToAnchor') - - render( - - ) - - const healthButton = screen.getByRole('button') - fireEvent.click(healthButton) - - expect(scrollToAnchor).toHaveBeenCalledWith('issues-trend') - }) - - it('renders social links with correct hrefs and target attributes', () => { - const socialLinks = ['https://github.com/test', 'https://twitter.com/test'] - render() - - const links = screen.getAllByRole('link') - for (const link of links) { - expect(link).toHaveAttribute('target', '_blank') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') - } - }) - }) - - describe('Text and Content Rendering', () => { - it('renders title correctly', () => { - render() - - expect(screen.getByText('Custom Project Title')).toBeInTheDocument() - expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Custom Project Title') - }) - - it('renders description correctly', () => { - render() - - expect(screen.getByText('Custom project description')).toBeInTheDocument() - }) - - it('renders details with proper formatting', () => { - render() - - expect(screen.getByText('Created:')).toBeInTheDocument() - expect(screen.getByText('2023-01-01')).toBeInTheDocument() - expect(screen.getByText('Status:')).toBeInTheDocument() - expect(screen.getByText('Active')).toBeInTheDocument() - }) - - it('renders leaders with special formatting', () => { - render() - - expect(screen.getByText('Leaders:')).toBeInTheDocument() - expect(screen.getByTestId('leaders-list')).toBeInTheDocument() - }) - - it('renders Leaders component when entityLeaders are provided', () => { - const entityLeaders = [ - { - description: 'Project Leader', - memberName: 'Alice', - member: { - id: '1', - login: 'alice', - name: 'Alice', - avatarUrl: 'https://avatars.githubusercontent.com/u/12345?v=4', - }, - }, - ] - render() - expect(screen.getByText('Leaders')).toBeInTheDocument() - expect(screen.getByText('Alice')).toBeInTheDocument() - expect(screen.getByText('Project Leader')).toBeInTheDocument() - }) - - it('capitalizes entity type in details title', () => { - render() - - expect(screen.getByText('Project Details')).toBeInTheDocument() - }) - }) - - describe('Edge Cases and Invalid Inputs', () => { - it('handles missing title gracefully', () => { - render() - - expect(screen.queryByRole('heading', { level: 1 })).toBeInTheDocument() - }) - - it('handles empty details array', () => { - render() - - expect(screen.getByText('Project Details')).toBeInTheDocument() - }) - - it('handles empty stats array', () => { - render() - - expect(screen.getByText('Statistics')).toBeInTheDocument() - }) - - it('handles missing detail values with fallback', () => { - const detailsWithMissingValues = [ - { label: 'Missing Value', value: invalidValues.emptyString }, - { label: 'Null Value', value: invalidValues.nullValue }, - { label: 'Undefined Value', value: invalidValues.undefinedValue }, - ] - - render() - - expect(screen.getAllByText('Unknown')).toHaveLength(3) - }) - - it('handles empty languages and topics arrays', () => { - render() - - expect(screen.queryByText('Languages:')).not.toBeInTheDocument() - expect(screen.queryByText('Topics:')).not.toBeInTheDocument() - }) - - it('handles empty social links array', () => { - render() - - expect(screen.queryByText('Social Links')).not.toBeInTheDocument() - }) - }) - - describe('Default Values and Fallbacks', () => { - it('uses default isActive value when not provided', () => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { isActive, ...propsWithoutIsActive } = defaultProps - - render() - - expect(screen.queryByText('Inactive')).not.toBeInTheDocument() - }) - - it('uses default showAvatar value when not provided', () => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { showAvatar, ...propsWithoutShowAvatar } = defaultProps - - render() - - expect(screen.getByText('Test Project')).toBeInTheDocument() - }) - - it('uses default geolocationData value when not provided', () => { - render() - - expect(screen.queryByTestId('chapter-map-wrapper')).not.toBeInTheDocument() - }) - }) - - describe('DOM Structure and Styling', () => { - it('applies correct CSS classes to main container', () => { - render() - - const mainContainer = document.querySelector('.min-h-screen') - expect(mainContainer).toHaveClass( - 'min-h-screen', - 'bg-white', - 'p-8', - 'text-gray-600', - 'dark:bg-[#212529]', - 'dark:text-gray-300' - ) - }) - - it('applies correct CSS classes to title', () => { - render() - - const title = screen.getByRole('heading', { level: 1 }) - expect(title).toHaveClass('text-4xl', 'font-bold') - }) - - it('applies correct CSS classes to description', () => { - render() - - const description = screen.getByText('A test project for demonstration') - expect(description).toHaveClass('mb-6', 'text-xl') - }) - - it('applies correct grid classes based on content', () => { - render() - - const gridContainer = screen.getByText('Project Details').closest('div')?.parentElement - expect(gridContainer).toHaveClass('grid', 'grid-cols-1', 'gap-6', 'md:grid-cols-7') - }) - }) - - describe('Component Integration', () => { - it('passes correct props to child components', () => { - render() - - expect(screen.getByTestId('contributors-list')).toHaveTextContent( - 'Top Contributors (2 items, max display: 12)' - ) - }) - - it('renders sponsor card with correct props', () => { - render( - - ) - - expect(screen.getByTestId('sponsor-card')).toHaveTextContent( - 'Sponsor Card - Target: test-key, Title: Test Project Name, Type: project' - ) - }) - - it('renders recent components for supported types', () => { - render( - - ) - - expect(screen.getByTestId('recent-issues')).toBeInTheDocument() - expect(screen.getByTestId('milestones')).toBeInTheDocument() - expect(screen.getByTestId('recent-pull-requests')).toBeInTheDocument() - expect(screen.getByTestId('recent-releases')).toBeInTheDocument() - }) - - const entityTypes: CardType[] = [ - 'project', - 'repository', - 'user', - 'organization', - 'committee', - 'chapter', - ] - - test.each(entityTypes)('renders all expected sections for %s type', (entityType) => { - render( - - ) - - expect( - screen.getByText(`${entityType.charAt(0).toUpperCase() + entityType.slice(1)} Details`) - ).toBeInTheDocument() - }) - - const supportedTypes: CardType[] = [ - 'project', - 'repository', - 'committee', - 'user', - 'organization', - ] - - test.each(supportedTypes)('renders statistics section for supported %s type', (entityType) => { - render() - expect(screen.getByText('Statistics')).toBeInTheDocument() - }) - - it('renders chapter map for chapter type only', () => { - render( - - ) - - expect(screen.getByTestId('chapter-map-wrapper')).toBeInTheDocument() - }) - - it('properly handles arrays vs single items in props', () => { - render( - - ) - - expect(screen.getByTestId('health-metrics')).toHaveTextContent('Health Metrics (1 items)') - expect(screen.getByTestId('contributors-list')).toHaveTextContent('Top Contributors (2 items') - expect(screen.getByTestId('repositories-card')).toHaveTextContent('Repositories (2 items)') - }) - - it('handles conditional rendering based on array lengths', () => { - render() - - expect(screen.getByTestId('toggleable-list')).toHaveTextContent('Languages: JavaScript') - expect(screen.queryByText('Topics:')).not.toBeInTheDocument() - }) - - it('handles conditional rendering when languages empty but topics present', () => { - render() - - expect(screen.getByTestId('toggleable-list')).toHaveTextContent('Topics: web') - expect(screen.queryByText('Languages:')).not.toBeInTheDocument() - }) - }) - - describe('Accessibility and Semantic HTML', () => { - it('uses proper heading hierarchy', () => { - render() - - const mainHeading = screen.getByRole('heading', { level: 1 }) - expect(mainHeading).toHaveTextContent('Test Project') - - const sectionHeadings = screen.getAllByRole('heading', { level: 3 }) - expect(sectionHeadings.length).toBeGreaterThan(0) - }) - - it('provides proper link attributes for external links', () => { - const socialLinks = ['https://github.com/test', 'https://twitter.com/test'] - render() - - const links = screen.getAllByRole('link') - const externalLinks = links.filter((link) => link.getAttribute('href')?.startsWith('http')) - - for (const link of externalLinks) { - expect(link).toHaveAttribute('target', '_blank') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') - } - }) - - it('renders with proper document structure', () => { - render() - - const mainContainer = document.querySelector('.min-h-screen') - expect(mainContainer).toBeInTheDocument() - - const contentWrapper = document.querySelector('.mx-auto.max-w-6xl') - expect(contentWrapper).toBeInTheDocument() - }) - }) - - describe('Responsive Design Classes', () => { - it('applies responsive grid classes correctly', () => { - render() - - const gridContainer = screen.getByText('Project Details').closest('div')?.parentElement - expect(gridContainer).toHaveClass('grid', 'grid-cols-1', 'gap-6', 'md:grid-cols-7') - }) - - it('applies correct column spans for different layouts', () => { - render() - const chapterDetailsCard = screen.getByTestId('secondary-card') - expect(chapterDetailsCard).toHaveClass('md:col-span-3') - - cleanup() - - render() - const detailsCards = screen.getAllByTestId('secondary-card') - const detailsCard = detailsCards.find((card) => card.textContent?.includes('Project Details')) - expect(detailsCard).toHaveClass('md:col-span-5') - }) - - it('applies responsive classes to languages and topics section', () => { - render() - - const languagesTopicsContainer = screen - .getAllByTestId('toggleable-list')[0] - .closest('div')?.parentElement - expect(languagesTopicsContainer).toHaveClass( - 'mb-8', - 'grid', - 'grid-cols-1', - 'gap-6', - 'md:grid-cols-2' - ) - }) - }) - - describe('Performance and Optimization', () => { - it('does not render expensive components when not needed', () => { - render( - - ) - - expect(screen.queryByTestId('health-metrics')).not.toBeInTheDocument() - expect(screen.queryByTestId('repositories-card')).not.toBeInTheDocument() - }) - - it('handles large arrays efficiently', () => { - const largeContributorsList = Array.from({ length: 50 }, (_, i) => ({ - ...mockContributors[0], - login: `contributor-${i}`, - name: `Contributor ${i}`, - })) - - render() - - expect(screen.getByTestId('contributors-list')).toHaveTextContent( - 'Top Contributors (50 items, max display: 12)' - ) - }) - }) - - describe('Data Validation and Error Handling', () => { - it('handles malformed health metrics data gracefully', () => { - const malformedHealthData = [ - createMalformedData(mockHealthMetricsData[0], { score: invalidValues.nullValue }), - ] - - render( - - ) - - expect(screen.getByTestId('health-metrics')).toBeInTheDocument() - expect(screen.getByTestId('metrics-score-circle')).toBeInTheDocument() - }) - - it('handles invalid health metrics score gracefully', () => { - const invalidHealthData = createMalformedArray(mockHealthMetricsData, [ - { score: invalidValues.negativeNumber }, - { score: invalidValues.undefinedValue }, - ]) - - render( - - ) - - expect(screen.getByTestId('health-metrics')).toBeInTheDocument() - }) - - it('handles invalid social link URLs gracefully', () => { - const invalidSocialLinks = ['', 'not-a-url', 'invalid://url'] - - expect(() => - render( - - ) - ).not.toThrow() - - expect(screen.getByText('Social Links')).toBeInTheDocument() - }) - - it('handles unsupported entity types gracefully', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - render() - expect(screen.getByText('Unsupported-type Details')).toBeInTheDocument() - }) - - it('handles extremely large contributor arrays', () => { - const largeContributors = Array.from({ length: 1000 }, (_, i) => ({ - ...mockContributors[0], - login: `user-${i}`, - name: `User ${i}`, - })) - - render() - - expect(screen.getByTestId('contributors-list')).toHaveTextContent( - 'Top Contributors (1000 items, max display: 12)' - ) - }) - - it('handles contributors with missing required fields', () => { - const incompleteContributors = createMalformedArray(mockContributors, [ - { - avatarUrl: invalidValues.emptyString, - login: invalidValues.emptyString, - name: invalidValues.emptyString, - projectKey: 'project1', - }, - { - avatarUrl: 'https://example.com/avatar.jpg', - login: 'user2', - name: invalidValues.nullValue, - projectKey: 'project1', - }, - ]) - - expect(() => - render() - ).not.toThrow() - }) - - it('validates required vs optional props correctly', () => { - const minimalValidProps: DetailsCardProps = { - type: 'project' as const, - stats: [], - healthMetricsData: [], - languages: [], - topics: [], - } - - expect(() => render()).not.toThrow() - }) - - it('handles undefined and null values in arrays', () => { - const propsWithUndefinedArrays = { - ...defaultProps, - recentIssues: undefined, - recentMilestones: null, - topContributors: undefined, - } - - expect(() => render()).not.toThrow() - }) - - it('handles malformed repository data', () => { - const malformedRepositories = createMalformedArray(mockRepositories, [ - { - name: invalidValues.nullValue, - contributorsCount: invalidValues.negativeNumber, - }, - { - url: invalidValues.emptyString, - starsCount: invalidValues.undefinedValue, - }, - ]) - - expect(() => - render() - ).not.toThrow() - }) - - it('handles empty string values in details', () => { - const detailsWithEmptyStrings = [ - { label: invalidValues.emptyString, value: 'Some Value' }, - { label: 'Some Label', value: invalidValues.emptyString }, - { label: invalidValues.nullValue, value: invalidValues.nullValue }, - ] - - expect(() => - render() - ).not.toThrow() - }) - }) - - describe('Advanced Integration Tests', () => { - it('handles multiple rapid prop changes', () => { - const { rerender } = render() - - rerender() - expect(screen.getByText('Chapter Details')).toBeInTheDocument() - - rerender() - expect(screen.getByText('User Details')).toBeInTheDocument() - - rerender() - expect(screen.getByText('Organization Details')).toBeInTheDocument() - }) - - it('handles complex nested data structures', () => { - const complexProps = { - ...defaultProps, - details: [ - { - label: 'Complex Detail', - value: ( -
- Nested Content -
- ), - }, - ], - userSummary: ( -
-

Complex user summary

-
    -
  • Item 1
  • -
  • Item 2
  • -
-
- ), - } - - render() - - expect(screen.getByText('Nested')).toBeInTheDocument() - expect(screen.getByText('Content')).toBeInTheDocument() - expect(screen.getByText('Complex user summary')).toBeInTheDocument() - expect(screen.getByText('Item 1')).toBeInTheDocument() - expect(screen.getByText('Item 2')).toBeInTheDocument() - }) - - it('renders correctly with all optional sections enabled', () => { - const fullPropsAllSections: DetailsCardProps = { - ...defaultProps, - type: 'project' as const, - summary: 'Project summary text', - userSummary:
User summary content
, - socialLinks: ['https://github.com/test', 'https://twitter.com/test'], - entityKey: 'test-entity', - projectName: 'Test Project Name', - geolocationData: mockChapterGeoData, - healthMetricsData: mockHealthMetricsData, - topContributors: mockContributors, - repositories: mockRepositories, - recentIssues: mockRecentIssues, - recentMilestones: mockRecentMilestones, - recentReleases: mockRecentReleases, - pullRequests: mockPullRequests, - } - - render() - - expect(screen.getByText('Project summary text')).toBeInTheDocument() - expect(screen.getByText('User summary content')).toBeInTheDocument() - expect(screen.getByTestId('health-metrics')).toBeInTheDocument() - expect(screen.getByTestId('contributors-list')).toBeInTheDocument() - expect(screen.getByTestId('repositories-card')).toBeInTheDocument() - expect(screen.getByTestId('sponsor-card')).toBeInTheDocument() - }) - - it('handles zero and negative values in stats', () => { - const statsWithZeroValues = [ - { icon: FaCode, value: 0, unit: 'Star' }, - { icon: FaTags, value: -10, unit: 'Issue' }, - { icon: FaCode, value: null, unit: 'Fork' }, - ] - - expect(() => - render() - ).not.toThrow() - - expect(screen.getByText('Statistics')).toBeInTheDocument() - }) - - it('handles undefined topics/languages in grid layout', () => { - render() - expect(screen.getByTestId('toggleable-list')).toHaveTextContent('Languages: JavaScript') - cleanup() - render() - expect(screen.getByTestId('toggleable-list')).toHaveTextContent('Topics: web') - }) - - it('renders Leaders with Unknown when value is null', () => { - const propsWithNullLeader = { - ...defaultProps, - type: 'chapter', - details: [{ label: 'Leaders', value: null }], - } - render() - expect(screen.getByText('Leaders:')).toBeInTheDocument() - expect(screen.getByText('Unknown')).toBeInTheDocument() - }) - - it('handles mixed valid and invalid data in arrays', () => { - const mixedValidInvalidData = { - ...defaultProps, - recentIssues: [ - mockRecentIssues[0], // Valid - createMalformedData(mockRecentIssues[0], { title: invalidValues.nullValue }), - createMalformedData(mockRecentIssues[0], { author: invalidValues.nullValue }), - ], - languages: ['JavaScript', invalidValues.emptyString, invalidValues.nullValue, 'TypeScript'], - topics: ['web', invalidValues.undefinedValue, 'frontend', invalidValues.emptyString], - } - - expect(() => render()).not.toThrow() - }) - }) - - describe('Accessibility Edge Cases', () => { - it('maintains accessibility with missing aria labels', () => { - render() - - const h1 = screen.getByRole('heading', { level: 1 }) - expect(h1).toBeInTheDocument() - - const h3s = screen.getAllByRole('heading', { level: 3 }) - expect(h3s.length).toBeGreaterThan(0) - }) - - it('handles very long text content gracefully', () => { - const longTextProps = { - ...defaultProps, - title: 'A'.repeat(500), - description: 'B'.repeat(1000), - summary: 'C'.repeat(2000), - } - - render() - - expect(screen.getByText('A'.repeat(500))).toBeInTheDocument() - expect(screen.getByText('B'.repeat(1000))).toBeInTheDocument() - expect(screen.getByText('C'.repeat(2000))).toBeInTheDocument() - }) - - it('handles special characters in text content', () => { - const specialCharProps = { - ...defaultProps, - title: 'Project with "quotes" & ', - description: 'Description with symbols 🚀 and special characters', - details: [{ label: 'Special & Label', value: 'Value with ' }], - } - - render() - - expect(screen.getByText('Project with "quotes" & ')).toBeInTheDocument() - expect( - screen.getByText('Description with symbols 🚀 and special characters') - ).toBeInTheDocument() - }) - }) - - describe('Archived Badge Functionality', () => { - it('displays archived badge for archived repository', () => { - const archivedProps: DetailsCardProps = { - ...defaultProps, - type: 'repository' as const, - isArchived: true, - } - - render() - - expect(screen.getByText('Archived')).toBeInTheDocument() - }) - - it('does not display archived badge for non-archived repository', () => { - const activeProps: DetailsCardProps = { - ...defaultProps, - type: 'repository' as const, - isArchived: false, - } - - render() - - expect(screen.queryByText('Archived')).not.toBeInTheDocument() - }) - - it('does not display archived badge when isArchived is undefined', () => { - const undefinedProps: DetailsCardProps = { - ...defaultProps, - type: 'repository' as const, - } - - render() - - expect(screen.queryByText('Archived')).not.toBeInTheDocument() - }) - - it('does not display archived badge for non-repository types', () => { - const projectProps: DetailsCardProps = { - ...defaultProps, - type: 'project' as const, - isArchived: true, - } - - render() - - expect(screen.queryByText('Archived')).not.toBeInTheDocument() - }) - - it('displays archived badge alongside inactive badge', () => { - const bothBadgesProps: DetailsCardProps = { - ...defaultProps, - type: 'repository' as const, - isArchived: true, - isActive: false, - } - - render() - - expect(screen.getByText('Archived')).toBeInTheDocument() - expect(screen.getByText('Inactive')).toBeInTheDocument() - }) - - it('displays archived badge independently of active status', () => { - const archivedAndActiveProps: DetailsCardProps = { - ...defaultProps, - type: 'repository' as const, - isArchived: true, - isActive: true, - } - - render() - - expect(screen.getByText('Archived')).toBeInTheDocument() - expect(screen.queryByText('Inactive')).not.toBeInTheDocument() - }) - - it('archived badge has correct positioning with flex container', () => { - const archivedProps: DetailsCardProps = { - ...defaultProps, - type: 'repository' as const, - isArchived: true, - } - - const { container } = render() - - // New structure: badges are in a flex container with items-center and gap-3 - const badgeContainer = container.querySelector('.flex.items-center.gap-3') - expect(badgeContainer).toBeInTheDocument() - }) - - it('archived badge renders with medium size', () => { - const archivedProps: DetailsCardProps = { - ...defaultProps, - type: 'repository' as const, - isArchived: true, - } - - render() - - const badge = screen.getByText('Archived') - expect(badge).toHaveClass('px-3', 'py-1', 'text-sm') - }) - - it('handles null isArchived gracefully', () => { - const nullArchivedProps: DetailsCardProps = { - ...defaultProps, - type: 'repository' as const, - isArchived: null, - } - - render() - - expect(screen.queryByText('Archived')).not.toBeInTheDocument() - }) - }) - - describe('Contribution Stats and Heatmap', () => { - const contributionData = { - '2024-01-01': 5, - '2024-01-02': 10, - '2024-01-03': 3, - } - - const contributionStats = { - commits: 100, - pullRequests: 50, - issues: 25, - total: 175, - } - - it('renders contribution stats and heatmap when data is provided', () => { - const propsWithContributions: DetailsCardProps = { - ...defaultProps, - type: 'project' as const, - contributionData, - contributionStats, - startDate: '2024-01-01', - endDate: '2024-12-31', - } - - render() - - expect(screen.getByText('Project Contribution Activity')).toBeInTheDocument() - expect(screen.getByText('100')).toBeInTheDocument() - expect(screen.getByText('50')).toBeInTheDocument() - expect(screen.getByText('25')).toBeInTheDocument() - expect(screen.getByText('175')).toBeInTheDocument() - }) - - it('uses correct title for chapter type', () => { - const chapterPropsWithContributions: DetailsCardProps = { - ...defaultProps, - type: 'chapter' as const, - contributionStats, - } - - render() - - expect(screen.getByText('Chapter Contribution Activity')).toBeInTheDocument() - }) - - it('does not render contribution section when no data is provided', () => { - render() - - expect(screen.queryByText('Project Contribution Activity')).not.toBeInTheDocument() - expect(screen.queryByText('Chapter Contribution Activity')).not.toBeInTheDocument() - }) - - it('renders only stats when contributionData is missing', () => { - const statsOnlyProps: DetailsCardProps = { - ...defaultProps, - type: 'project' as const, - contributionStats, - } - - render() - - expect(screen.getByText('Project Contribution Activity')).toBeInTheDocument() - expect(screen.getByText('100')).toBeInTheDocument() - }) - - it('renders heatmap when contributionData and dates are provided', () => { - const heatmapProps: DetailsCardProps = { - ...defaultProps, - type: 'project' as const, - contributionData, - startDate: '2024-01-01', - endDate: '2024-12-31', - } - - render() - - // Heatmap should be rendered (mocked in jest setup) - expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() - }) - - it('does not render heatmap when dates are missing', () => { - const noDateProps: DetailsCardProps = { - ...defaultProps, - type: 'project' as const, - contributionData, - } - - render() - - expect(screen.queryByTestId('mock-heatmap-chart')).not.toBeInTheDocument() - }) - - it('does not render heatmap when contributionData is empty', () => { - const emptyDataProps: DetailsCardProps = { - ...defaultProps, - type: 'project' as const, - contributionData: {}, - startDate: '2024-01-01', - endDate: '2024-12-31', - } - - render() - - expect(screen.queryByTestId('mock-heatmap-chart')).not.toBeInTheDocument() - }) - - it('renders contribution section before top contributors', () => { - const fullProps: DetailsCardProps = { - ...defaultProps, - type: 'project' as const, - contributionStats, - topContributors: [ - { - id: 'contributor-user1', - login: 'user1', - name: 'User One', - avatarUrl: 'https://example.com/avatar1.png', - }, - ], - } - - render() - - const contributionSection = screen.getByText('Project Contribution Activity') - const contributorsSection = screen.getByText(/Top Contributors/i) - - // Check that contribution section appears before contributors - expect(contributionSection.compareDocumentPosition(contributorsSection)).toBe( - Node.DOCUMENT_POSITION_FOLLOWING - ) - }) - }) - - describe('Program Milestones Display', () => { - const createMilestones = (count: number) => { - const milestones = [] - for (let i = 0; i < count; i++) { - milestones.push({ - author: mockUser, - body: `Milestone description ${i + 1}`, - closedIssuesCount: 5, - createdAt: Math.floor((Date.now() - 10000000) / 1000), - openIssuesCount: 2, - repositoryName: `test-repo-${i}`, - organizationName: 'test-org', - state: 'open', - title: `Milestone ${i + 1}`, - url: `https://github.com/test/project/milestone/${i + 1}`, - }) - } - return milestones - } - - it('renders only first 4 milestones initially for program type', () => { - const manyMilestones = createMilestones(6) - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - recentMilestones: manyMilestones, - modules: [], - } - - render() - - expect(screen.getByText('Recent Milestones')).toBeInTheDocument() - - expect(screen.getByText('Milestone 1')).toBeInTheDocument() - expect(screen.getByText('Milestone 4')).toBeInTheDocument() - - expect(screen.queryByText('Milestone 5')).not.toBeInTheDocument() - expect(screen.queryByText('Milestone 6')).not.toBeInTheDocument() - - expect(screen.getByText(/Show more/i)).toBeInTheDocument() - }) - - it('expands to show all milestones when "Show more" is clicked', () => { - const manyMilestones = createMilestones(6) - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - recentMilestones: manyMilestones, - modules: [], - } - - render() - - const showMoreBtn = screen.getByText(/Show more/i) - fireEvent.click(showMoreBtn) - - expect(screen.getByText('Milestone 5')).toBeInTheDocument() - expect(screen.getByText('Milestone 6')).toBeInTheDocument() - - expect(screen.getByText(/Show less/i)).toBeInTheDocument() - }) - - it('collapses back to 4 milestones when "Show less" is clicked', () => { - const manyMilestones = createMilestones(6) - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - recentMilestones: manyMilestones, - modules: [], - } - - render() - - fireEvent.click(screen.getByText(/Show more/i)) - expect(screen.getByText('Milestone 5')).toBeInTheDocument() - - fireEvent.click(screen.getByText(/Show less/i)) - - expect(screen.queryByText('Milestone 5')).not.toBeInTheDocument() - expect(screen.getByText(/Show more/i)).toBeInTheDocument() - }) - - it('does not show toggle button if milestones <= 4', () => { - const fewMilestones = createMilestones(4) - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - recentMilestones: fewMilestones, - modules: [], - } - - render() - - expect(screen.getByText('Milestone 1')).toBeInTheDocument() - expect(screen.getByText('Milestone 4')).toBeInTheDocument() - expect(screen.queryByText(/Show more/i)).not.toBeInTheDocument() - }) - - it('renders milestone author avatar when showAvatar is true and author data is complete', () => { - const milestonesWithAuthor = [ - { - author: { - login: 'author-user', - name: 'Author User', - avatarUrl: 'https://example.com/author-avatar.jpg', - }, - body: 'Milestone with author', - closedIssuesCount: 3, - createdAt: new Date(Date.now() - 10000000).toISOString(), - openIssuesCount: 1, - repositoryName: 'test-repo', - organizationName: 'test-org', - state: 'open', - title: 'Milestone With Author', - url: 'https://github.com/test/project/milestone/1', - }, - ] - - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - recentMilestones: milestonesWithAuthor, - showAvatar: true, - modules: [], - } - - render() - - expect(screen.getByText('Milestone With Author')).toBeInTheDocument() - // The avatar image should be rendered - const avatarImg = screen.getByAltText("Author User's avatar") - expect(avatarImg).toBeInTheDocument() - expect(avatarImg).toHaveAttribute('src', 'https://example.com/author-avatar.jpg') - }) - - it('renders milestone without author avatar when author data is missing', () => { - const milestonesWithoutAuthor = [ - { - author: null, - body: 'Milestone without author', - closedIssuesCount: 3, - createdAt: new Date(Date.now() - 10000000).toISOString(), - openIssuesCount: 1, - repositoryName: 'test-repo', - organizationName: 'test-org', - state: 'open', - title: 'Milestone No Author', - url: 'https://github.com/test/project/milestone/1', - }, - ] - - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - recentMilestones: milestonesWithoutAuthor, - showAvatar: true, - modules: [], - } - - render() - - expect(screen.getByText('Milestone No Author')).toBeInTheDocument() - }) - - it('renders milestone author tooltip using login when name is missing', () => { - const milestonesWithAuthorNoName = [ - { - author: { - login: 'author-login-only', - name: undefined, - avatarUrl: 'https://example.com/author-avatar.jpg', - }, - body: 'Milestone with author no name', - closedIssuesCount: 3, - createdAt: new Date(Date.now() - 10000000).toISOString(), - openIssuesCount: 1, - repositoryName: 'test-repo', - organizationName: 'test-org', - state: 'open', - title: 'Milestone Author No Name', - url: 'https://github.com/test/project/milestone/1', - }, - ] - - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - recentMilestones: milestonesWithAuthorNoName, - showAvatar: true, - modules: [], - } - - render() - expect(screen.getByTestId('mock-tooltip')).toHaveAttribute('title', 'author-login-only') - expect(screen.getByAltText("author-login-only's avatar")).toBeInTheDocument() - }) - - it('renders milestone title without link when URL is missing', () => { - const milestonesWithoutUrl = [ - { - author: mockUser, - body: 'Milestone without URL', - closedIssuesCount: 3, - createdAt: new Date(Date.now() - 10000000).toISOString(), - openIssuesCount: 1, - repositoryName: 'test-repo', - organizationName: 'test-org', - state: 'open', - title: 'Milestone No URL', - url: null, - }, - ] - - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - recentMilestones: milestonesWithoutUrl, - modules: [], - } - - render() - - expect(screen.getByText('Milestone No URL')).toBeInTheDocument() - // The title should not be a link - const title = screen.getByText('Milestone No URL') - expect(title.closest('a')).toBeNull() - }) - - it('renders milestone without repository link when repositoryName or organizationName is missing', () => { - const milestonesWithoutRepo = [ - { - author: mockUser, - body: 'Milestone without repo', - closedIssuesCount: 3, - createdAt: new Date(Date.now() - 10000000).toISOString(), - openIssuesCount: 1, - repositoryName: null, - organizationName: null, - state: 'open', - title: 'Milestone No Repo', - url: 'https://github.com/test/project/milestone/1', - }, - ] - - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - recentMilestones: milestonesWithoutRepo, - modules: [], - } - - render() - - expect(screen.getByText('Milestone No Repo')).toBeInTheDocument() - }) - }) - - describe('Module Pull Requests Display', () => { - const createPullRequests = (count: number) => { - const pullRequests = [] - for (let i = 0; i < count; i++) { - pullRequests.push({ - id: `pr-${i}`, - author: mockUser, - createdAt: new Date().toISOString(), - organizationName: 'test-org', - title: `Pull Request ${i + 1}`, - url: `https://github.com/test/project/pull/${i + 1}`, - state: 'OPEN', - number: i + 1, - mergedAt: null, - repositoryName: 'test-repo', - }) - } - return pullRequests - } - - it('renders only first 4 PRs initially for module type', () => { - const manyPRs = createPullRequests(6) - const moduleProps: DetailsCardProps = { - ...defaultProps, - type: 'module' as const, - pullRequests: manyPRs as unknown as PullRequest[], - } - - render() - - expect(screen.getByText('Recent Pull Requests')).toBeInTheDocument() - - expect(screen.getByText(/Pull Request 1/)).toBeInTheDocument() - expect(screen.getByText(/Pull Request 4/)).toBeInTheDocument() - - expect(screen.queryByText(/Pull Request 5/)).not.toBeInTheDocument() - expect(screen.queryByText(/Pull Request 6/)).not.toBeInTheDocument() - - expect(screen.getByText(/Show more/i)).toBeInTheDocument() - }) - - it('expands to show all PRs when "Show more" is clicked', () => { - const manyPRs = createPullRequests(6) - const moduleProps: DetailsCardProps = { - ...defaultProps, - type: 'module' as const, - pullRequests: manyPRs as unknown as PullRequest[], - } - - render() - - const showMoreBtn = screen.getByText(/Show more/i) - fireEvent.click(showMoreBtn) - - expect(screen.getByText(/Pull Request 5/)).toBeInTheDocument() - expect(screen.getByText(/Pull Request 6/)).toBeInTheDocument() - - expect(screen.getByText(/Show less/i)).toBeInTheDocument() - }) - - it('collapses back to 4 PRs when "Show less" is clicked', () => { - const manyPRs = createPullRequests(6) - const moduleProps: DetailsCardProps = { - ...defaultProps, - type: 'module' as const, - pullRequests: manyPRs as unknown as PullRequest[], - } - - render() - - fireEvent.click(screen.getByText(/Show more/i)) - expect(screen.getByText(/Pull Request 5/)).toBeInTheDocument() - - fireEvent.click(screen.getByText(/Show less/i)) - - expect(screen.queryByText(/Pull Request 5/)).not.toBeInTheDocument() - expect(screen.getByText(/Show more/i)).toBeInTheDocument() - }) - - it('does not show toggle button if PRs <= 4', () => { - const fewPRs = createPullRequests(4) - const moduleProps: DetailsCardProps = { - ...defaultProps, - type: 'module' as const, - pullRequests: fewPRs as unknown as PullRequest[], - } - - render() - - expect(screen.getByText(/Pull Request 1/)).toBeInTheDocument() - expect(screen.getByText(/Pull Request 4/)).toBeInTheDocument() - expect(screen.queryByText(/Show more/i)).not.toBeInTheDocument() - }) - }) - - describe('Module Admin EntityActions and Mentees', () => { - it('renders EntityActions for module type when user is an admin', () => { - const { useSession } = jest.requireMock('next-auth/react') - useSession.mockReturnValue({ - data: { - user: { - login: 'admin-user', - name: 'Admin User', - email: 'admin@example.com', - }, - }, - }) - - const adminUser = { - id: 'admin-id', - login: 'admin-user', - name: 'Admin User', - avatarUrl: 'https://example.com/admin-avatar.jpg', - } - - const moduleProps: DetailsCardProps = { - ...defaultProps, - type: 'module' as const, - accessLevel: 'admin', - admins: [adminUser], - programKey: 'test-program', - entityKey: 'test-module', - modules: [], - } - - render() - - expect(screen.getByTestId('entity-actions')).toBeInTheDocument() - expect(screen.getByTestId('entity-actions')).toHaveTextContent('type=module') - }) - - it('renders EntityActions for module type when user is a mentor but not admin', () => { - const { useSession } = jest.requireMock('next-auth/react') - useSession.mockReturnValue({ - data: { - user: { - login: 'mentor-user', - name: 'Mentor User', - email: 'mentor@example.com', - }, - }, - }) - - const adminUser = { - id: 'admin-id', - login: 'admin-user', - name: 'Admin User', - avatarUrl: 'https://example.com/admin-avatar.jpg', - } - - const mentorUser = { - id: 'mentor-id', - login: 'mentor-user', - name: 'Mentor User', - avatarUrl: 'https://example.com/mentor-avatar.jpg', - } - - const moduleProps: DetailsCardProps = { - ...defaultProps, - type: 'module' as const, - accessLevel: 'admin', - admins: [adminUser], - mentors: [mentorUser], - programKey: 'test-program', - entityKey: 'test-module', - modules: [], - } - - render() - - const entityActions = screen.getByTestId('entity-actions') - expect(entityActions).toBeInTheDocument() - expect(entityActions).toHaveTextContent('type=module') - // isAdmin should be undefined (user is not in admins list) - expect(entityActions).not.toHaveAttribute('data-isadmin', 'true') - }) - - it('does not render EntityActions for module type when user is not an admin', () => { - const { useSession } = jest.requireMock('next-auth/react') - useSession.mockReturnValue({ - data: { - user: { - login: 'regular-user', - name: 'Regular User', - email: 'user@example.com', - }, - }, - }) - - const adminUser = { - id: 'admin-id', - login: 'admin-user', - name: 'Admin User', - avatarUrl: 'https://example.com/admin-avatar.jpg', - } - - const moduleProps: DetailsCardProps = { - ...defaultProps, - type: 'module' as const, - accessLevel: 'admin', - admins: [adminUser], - programKey: 'test-program', - entityKey: 'test-module', - modules: [], - } - - render() - - expect(screen.queryByTestId('entity-actions')).not.toBeInTheDocument() - }) - - it('renders mentees section when mentees are provided', () => { - const mentees = [ - { - id: 'mentee-1', - login: 'mentee_user1', - name: 'Mentee User 1', - avatarUrl: 'https://example.com/mentee1.jpg', - }, - { - id: 'mentee-2', - login: 'mentee_user2', - name: 'Mentee User 2', - avatarUrl: 'https://example.com/mentee2.jpg', - }, - ] - - const propsWithMentees: DetailsCardProps = { - ...defaultProps, - mentees, - programKey: 'test-program', - entityKey: 'test-entity', - } - - render() - - const allContributorsLists = screen.getAllByTestId('contributors-list') - const menteesSection = allContributorsLists.find((el) => el.textContent?.includes('Mentees')) - expect(menteesSection).toHaveTextContent('Mentees (2 items, max display: 6)') - }) - - it('does not render mentees section when no mentees are provided', () => { - const propsWithoutMentees: DetailsCardProps = { - ...defaultProps, - mentees: [], - } - render() - // Make sure mentees section is not rendered - const allContributorsLists = screen.queryAllByTestId('contributors-list') - const menteesList = allContributorsLists.find((el) => el.textContent?.includes('Mentees')) - expect(menteesList).toBeUndefined() - }) - - it('renders mentees with custom URL formatter', () => { - const mentees = [ - { - id: 'mentee-1', - login: 'test_mentee', - name: 'Test Mentee', - avatarUrl: 'https://example.com/mentee.jpg', - }, - ] - - const propsWithMentees: DetailsCardProps = { - ...defaultProps, - mentees, - programKey: 'program-key-123', - entityKey: 'entity-key-456', - } - - render() - - const menteeLink = screen.getByText('Test Mentee') - expect(menteeLink).toBeInTheDocument() - expect(menteeLink).toHaveAttribute('href', '/programs/program-key-123/mentees/test_mentee') - }) - - it('renders mentee links with empty program key segment when programKey is undefined', () => { - const mentees = [ - { - id: 'mentee-1', - login: 'test_mentee', - name: 'Test Mentee', - avatarUrl: 'https://example.com/mentee.jpg', - }, - ] - - const propsWithMentees: DetailsCardProps = { - ...defaultProps, - mentees, - programKey: undefined, - entityKey: undefined, - } - - render() - - const menteeLink = screen.getByText('Test Mentee') - expect(menteeLink).toBeInTheDocument() - expect(menteeLink).toHaveAttribute('href', '/programs//mentees/test_mentee') - }) - - it('handles null/undefined mentees array gracefully', () => { - const propsWithNullMentees: DetailsCardProps = { - ...defaultProps, - mentees: null, - } - - expect(() => render()).not.toThrow() - }) - - it('renders program EntityActions when type is program with appropriate access', () => { - const { useSession } = jest.requireMock('next-auth/react') - useSession.mockReturnValue({ - data: { - user: { - login: 'program-admin', - name: 'Program Admin', - email: 'admin@example.com', - }, - }, - }) - - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - accessLevel: 'admin', - canUpdateStatus: true, - status: 'active', - setStatus: jest.fn(), - programKey: 'test-program', - modules: [], - } - - render() - - expect(screen.getByTestId('entity-actions')).toBeInTheDocument() - expect(screen.getByTestId('entity-actions')).toHaveTextContent('type=program') - }) - - it('does not render program EntityActions when canUpdateStatus is false', () => { - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - accessLevel: 'admin', - canUpdateStatus: false, - status: 'active', - setStatus: jest.fn(), - programKey: 'test-program', - modules: [], - } - - render() - - expect(screen.queryByTestId('entity-actions')).not.toBeInTheDocument() - }) - - it('does not render program EntityActions when accessLevel is not admin', () => { - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - accessLevel: 'user', - canUpdateStatus: true, - status: 'active', - setStatus: jest.fn(), - programKey: 'test-program', - modules: [], - } - - render() - - expect(screen.queryByTestId('entity-actions')).not.toBeInTheDocument() - }) - }) - - describe('Program and Module Tags, Domains, and Labels', () => { - it('renders tags for program type', () => { - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - tags: ['tag1', 'tag2', 'tag3'], - modules: [], - } - - render() - - expect(screen.getByText(/Tags/)).toBeInTheDocument() - expect(screen.getByText(/tag1, tag2, tag3/)).toBeInTheDocument() - }) - - it('renders domains for program type', () => { - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - domains: ['domain1', 'domain2'], - modules: [], - } - - render() - - expect(screen.getByText(/Domains/)).toBeInTheDocument() - expect(screen.getByText(/domain1, domain2/)).toBeInTheDocument() - }) - - it('renders labels for program type', () => { - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - labels: ['label1', 'label2'], - modules: [], - } - - render() - - expect(screen.getByText(/Labels/)).toBeInTheDocument() - expect(screen.getByText(/label1, label2/)).toBeInTheDocument() - }) - - it('renders tags and domains in same row for module type', () => { - const moduleProps: DetailsCardProps = { - ...defaultProps, - type: 'module' as const, - tags: ['moduleTag1'], - domains: ['moduleDomain1'], - modules: [], - } - - render() - - expect(screen.getByText(/Tags/)).toBeInTheDocument() - expect(screen.getByText(/Domains/)).toBeInTheDocument() - }) - - it('does not render tags section when tags array is empty', () => { - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - tags: [], - domains: ['domain1'], - modules: [], - } - - render() - - expect(screen.queryByText(/Tags:/)).not.toBeInTheDocument() - expect(screen.getByText(/Domains/)).toBeInTheDocument() - }) - - it('does not render domains section when domains array is empty', () => { - const programProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - tags: ['tag1'], - domains: [], - modules: [], - } - - render() - - expect(screen.getByText(/Tags/)).toBeInTheDocument() - expect(screen.queryByText(/Domains:/)).not.toBeInTheDocument() - }) - }) - - describe('Program Module Rendering', () => { - const mockModules = [ - { - id: 'module-1-id', - key: 'module-1', - name: 'Module 1', - description: 'First module', - endedAt: new Date(Date.now() + 86400000).toISOString(), - startedAt: new Date(Date.now() - 86400000).toISOString(), - experienceLevel: 'BEGINNER', - mentors: [], - }, - { - id: 'module-2-id', - key: 'module-2', - name: 'Module 2', - description: 'Second module', - endedAt: new Date(Date.now() + 86400000).toISOString(), - startedAt: new Date(Date.now() - 86400000).toISOString(), - experienceLevel: 'INTERMEDIATE', - mentors: [], - }, - ] as DetailsCardProps['modules'] - - it('renders single module without SecondaryCard wrapper', () => { - const singleModuleProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - modules: [mockModules![0]], - } - - render() - - expect(screen.getByTestId('module-card')).toBeInTheDocument() - // Single module should not have "Modules" title - expect(screen.queryByText('Modules')).not.toBeInTheDocument() - }) - - it('renders multiple modules with SecondaryCard wrapper and title', () => { - const multiModuleProps: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - modules: mockModules, - } - - render() - - expect(screen.getByTestId('module-card')).toBeInTheDocument() - expect(screen.getByText('Modules')).toBeInTheDocument() - }) - }) - - describe('Mentors and Admins Lists', () => { - const mockMentors = [ - { - id: 'mentor-1', - login: 'mentor_user1', - name: 'Mentor User 1', - avatarUrl: 'https://example.com/mentor1.jpg', - }, - { - id: 'mentor-2', - login: 'mentor_user2', - name: 'Mentor User 2', - avatarUrl: 'https://example.com/mentor2.jpg', - }, - ] - - const mockAdmins = [ - { - id: 'admin-1', - login: 'admin_user1', - name: 'Admin User 1', - avatarUrl: 'https://example.com/admin1.jpg', - }, - ] - - it('renders mentors section when mentors are provided', () => { - const propsWithMentors: DetailsCardProps = { - ...defaultProps, - mentors: mockMentors, - } - - render() - - const allContributorsLists = screen.getAllByTestId('contributors-list') - const mentorsSection = allContributorsLists.find((el) => el.textContent?.includes('Mentors')) - expect(mentorsSection).toHaveTextContent('Mentors (2 items, max display: 6)') - }) - - it('does not render mentors section when mentors array is empty', () => { - const propsWithoutMentors: DetailsCardProps = { - ...defaultProps, - mentors: [], - } - - render() - - // Mentors section should not be rendered - const allContributorsLists = screen.queryAllByTestId('contributors-list') - const mentorsSection = allContributorsLists.find((el) => el.textContent?.includes('Mentors')) - expect(mentorsSection).toBeUndefined() - }) - - it('renders admins section when type is program and admins are provided', () => { - const propsWithAdmins: DetailsCardProps = { - ...defaultProps, - type: 'program' as const, - admins: mockAdmins, - modules: [], - } - - render() - - const allContributorsLists = screen.getAllByTestId('contributors-list') - const adminsSection = allContributorsLists.find((el) => el.textContent?.includes('Admins')) - expect(adminsSection).toHaveTextContent('Admins (1 items, max display: 6)') - }) - - it('does not render admins section for non-program types', () => { - const propsWithAdmins: DetailsCardProps = { - ...defaultProps, - type: 'project' as const, - admins: mockAdmins, - } - - render() - - const allContributorsLists = screen.queryAllByTestId('contributors-list') - const adminsSection = allContributorsLists.find((el) => el.textContent?.includes('Admins')) - expect(adminsSection).toBeUndefined() - }) - }) - - describe('Repository Rendering for Different Types', () => { - it('renders repositories for user type', () => { - const userProps: DetailsCardProps = { - ...defaultProps, - type: 'user' as const, - repositories: mockRepositories, - } - - render() - - expect(screen.getByText('Repositories')).toBeInTheDocument() - expect(screen.getByTestId('repositories-card')).toBeInTheDocument() - }) - - it('renders repositories for organization type', () => { - const orgProps: DetailsCardProps = { - ...defaultProps, - type: 'organization' as const, - repositories: mockRepositories, - } - - render() - - expect(screen.getByText('Repositories')).toBeInTheDocument() - expect(screen.getByTestId('repositories-card')).toBeInTheDocument() - }) - - it('does not render repositories for chapter type', () => { - const chapterProps: DetailsCardProps = { - ...defaultProps, - type: 'chapter' as const, - repositories: mockRepositories, - } - - render() - - expect(screen.queryByText('Repositories')).not.toBeInTheDocument() - }) - }) - - describe('Sponsor Card Rendering', () => { - it('renders sponsor card for chapter type', () => { - const chapterProps: DetailsCardProps = { - ...defaultProps, - type: 'chapter' as const, - entityKey: 'test-chapter', - } - - render() - - expect(screen.getByTestId('sponsor-card')).toBeInTheDocument() - expect(screen.getByTestId('sponsor-card')).toHaveTextContent('Type: chapter') - }) - - it('renders sponsor card for repository type', () => { - const repoProps: DetailsCardProps = { - ...defaultProps, - type: 'repository' as const, - entityKey: 'test-repo', - } - - render() - - expect(screen.getByTestId('sponsor-card')).toBeInTheDocument() - expect(screen.getByTestId('sponsor-card')).toHaveTextContent('Type: project') - }) - - it('uses projectName as title when provided', () => { - const projectProps: DetailsCardProps = { - ...defaultProps, - type: 'project' as const, - entityKey: 'test-project', - projectName: 'Custom Project Name', - } - - render() - - expect(screen.getByTestId('sponsor-card')).toHaveTextContent('Title: Custom Project Name') - }) - - it('does not render sponsor card when entityKey is missing', () => { - const propsWithoutKey: DetailsCardProps = { - ...defaultProps, - type: 'project' as const, - entityKey: undefined, - } - - render() - - expect(screen.queryByTestId('sponsor-card')).not.toBeInTheDocument() - }) - }) -}) diff --git a/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsContributions.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsContributions.test.tsx new file mode 100644 index 0000000000..4ae697c248 --- /dev/null +++ b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsContributions.test.tsx @@ -0,0 +1,129 @@ +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import CardDetailsContributions from 'components/CardDetailsPage/CardDetailsContributions' + +jest.mock('components/ContributionHeatmap', () => ({ + __esModule: true, + default: ({ contributionData }: { contributionData: object }) => ( +
Heatmap: {JSON.stringify(contributionData)}
+ ), +})) + +jest.mock('components/ContributionStats', () => ({ + __esModule: true, + default: ({ stats }: { stats: object }) => ( +
Stats: {JSON.stringify(stats)}
+ ), +})) + +describe('CardDetailsContributions', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('renders nothing when hasContributions is false', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders contribution stats when stats provided and hasContributions is true', () => { + const stats = { + total: 150, + average: 10, + } + + render() + + expect(screen.getByTestId('contribution-stats')).toBeInTheDocument() + }) + + it('renders contribution heatmap with required props', () => { + render( + + ) + + expect(screen.getByTestId('contribution-heatmap')).toBeInTheDocument() + }) + + it('renders both stats and heatmap components together', () => { + const stats = { + total: 100, + average: 50, + } + + render( + + ) + + expect(screen.getByTestId('contribution-stats')).toBeInTheDocument() + expect(screen.getByTestId('contribution-heatmap')).toBeInTheDocument() + }) + + it('does not render heatmap when contributionData is empty', () => { + const stats = { + total: 100, + } + + const { queryByTestId } = render( + + ) + + expect(screen.getByTestId('contribution-stats')).toBeInTheDocument() + expect(queryByTestId('contribution-heatmap')).not.toBeInTheDocument() + }) + + it('does not render heatmap without startDate and endDate', () => { + const stats = { + total: 100, + } + + const { queryByTestId } = render( + + ) + + expect(screen.getByTestId('contribution-stats')).toBeInTheDocument() + expect(queryByTestId('contribution-heatmap')).not.toBeInTheDocument() + }) + + it('renders with custom title', () => { + const stats = { + total: 50, + } + + render( + + ) + + expect(screen.getByTestId('contribution-stats')).toBeInTheDocument() + }) + + it('renders nothing when hasContributions is false even with stats', () => { + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) +}) diff --git a/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsContributors.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsContributors.test.tsx new file mode 100644 index 0000000000..51f2bd5243 --- /dev/null +++ b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsContributors.test.tsx @@ -0,0 +1,293 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import '@testing-library/jest-dom' +import { Contributor } from 'types/contributor' +import CardDetailsContributors from 'components/CardDetailsPage/CardDetailsContributors' + +jest.mock('components/ContributorsList', () => ({ + __esModule: true, + default: ({ + title, + contributors, + getUrl, + }: { + title: string + contributors: Contributor[] + getUrl?: (login: string) => string + }) => ( +
+

{title}

+
    + {contributors.map((c) => ( +
  • {getUrl ? {c.name} : c.name}
  • + ))} +
+
+ ), +})) + +jest.mock('next/link', () => { + return ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ) +}) + +jest.mock('utils/urlFormatter', () => ({ + getMemberUrl: (login: string) => `/members/${login}`, + getMenteeUrl: (programKey: string, entityKey: string, login: string) => + `/programs/${programKey}/mentees/${login}`, +})) + +describe('CardDetailsContributors', () => { + const mockContributor: Contributor = { + id: '1', + name: 'John Doe', + avatarUrl: 'https://example.com/avatar.jpg', + login: 'john_doe', + contributionsCount: 42, + } + + const mockContributor2: Contributor = { + id: '2', + name: 'Jane Smith', + avatarUrl: 'https://example.com/avatar2.jpg', + login: 'jane_smith', + contributionsCount: 35, + } + + it('renders nothing when no contributors provided', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders top contributors when provided', () => { + render() + + expect(screen.getByText('John Doe')).toBeInTheDocument() + expect(screen.getByText('Jane Smith')).toBeInTheDocument() + }) + + it('renders admins list when provided', () => { + render() + + expect(screen.getByText('Admins')).toBeInTheDocument() + expect(screen.getByText('John Doe')).toBeInTheDocument() + }) + + it('does not render admins list when empty', () => { + render() + + expect(screen.queryByText('Admins')).not.toBeInTheDocument() + }) + + it('renders mentors list when provided', () => { + render() + + expect(screen.getByText('Mentors')).toBeInTheDocument() + expect(screen.getByText('John Doe')).toBeInTheDocument() + }) + + it('renders mentees list when provided', () => { + render() + + expect(screen.getByText('Mentees')).toBeInTheDocument() + expect(screen.getByText('John Doe')).toBeInTheDocument() + }) + + it('renders multiple contributor sections simultaneously', () => { + render( + + ) + + expect(screen.getByText('Top Contributors')).toBeInTheDocument() + expect(screen.getByText('Mentors')).toBeInTheDocument() + expect(screen.getByText('Mentees')).toBeInTheDocument() + }) + + it('renders program admins and top contributors', () => { + render( + + ) + + expect(screen.getByText('Admins')).toBeInTheDocument() + expect(screen.getByText('Top Contributors')).toBeInTheDocument() + }) + + it('handles multiple contributor types correctly', () => { + render() + + expect(screen.getByText('Top Contributors')).toBeInTheDocument() + }) + + it('renders empty when all arrays are empty', () => { + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) + + it('handles all contributor types', () => { + render() + + expect(screen.getByText('Top Contributors')).toBeInTheDocument() + }) + + it('does not render mentors when array is empty', () => { + render() + expect(screen.queryByText('Mentors')).not.toBeInTheDocument() + }) + + it('does not render mentees when array is empty', () => { + render() + expect(screen.queryByText('Mentees')).not.toBeInTheDocument() + }) + + it('does not render admins when array is empty', () => { + render() + expect(screen.queryByText('Admins')).not.toBeInTheDocument() + }) + + it('renders all contributor types together', () => { + render( + + ) + + expect(screen.getByText('Top Contributors')).toBeInTheDocument() + expect(screen.getByText('Admins')).toBeInTheDocument() + expect(screen.getByText('Mentors')).toBeInTheDocument() + expect(screen.getByText('Mentees')).toBeInTheDocument() + }) + + it('does not render topContributors when undefined', () => { + render() + expect(screen.queryByText('Top Contributors')).not.toBeInTheDocument() + }) + + it('does not render mentors when undefined', () => { + render() + expect(screen.queryByText('Mentors')).not.toBeInTheDocument() + }) + + it('does not render mentees when undefined', () => { + render() + expect(screen.queryByText('Mentees')).not.toBeInTheDocument() + }) + + it('does not render admins with empty array', () => { + render() + expect(screen.queryByText('Admins')).not.toBeInTheDocument() + }) + + it('renders only topContributors when other arrays are undefined', () => { + render( + + ) + + expect(screen.getByText('Top Contributors')).toBeInTheDocument() + expect(screen.queryByText('Admins')).not.toBeInTheDocument() + expect(screen.queryByText('Mentors')).not.toBeInTheDocument() + expect(screen.queryByText('Mentees')).not.toBeInTheDocument() + }) + + it('handles different entity types with topContributors', () => { + render() + + expect(screen.getByText('Top Contributors')).toBeInTheDocument() + }) + + it('renders mentees with programKey and entityKey', () => { + render( + + ) + + expect(screen.getByText('Mentees')).toBeInTheDocument() + const menteeLink = screen.getByText('John Doe').closest('li')?.querySelector('a') + expect(menteeLink?.href).toContain('/programs/test-program/mentees/') + }) + + it('renders mentees with empty programKey and entityKey', () => { + render() + + expect(screen.getByText('Mentees')).toBeInTheDocument() + const menteeLink = screen.getByText('John Doe').closest('li')?.querySelector('a') + expect(menteeLink?.href).toContain('/programs//mentees/') + }) + + it('renders mentees without programKey and entityKey props', () => { + render() + + expect(screen.getByText('Mentees')).toBeInTheDocument() + const menteeLink = screen.getByText('John Doe').closest('li')?.querySelector('a') + expect(menteeLink?.href).toContain('/programs//mentees/') + }) + + it('renders multiple admins', () => { + const mockAdmins = [mockContributor, mockContributor2] + render() + + expect(screen.getByText('Admins')).toBeInTheDocument() + expect(screen.getByText('John Doe')).toBeInTheDocument() + expect(screen.getByText('Jane Smith')).toBeInTheDocument() + }) + + it('renders multiple mentors', () => { + const mockMentors = [mockContributor, mockContributor2] + render() + + expect(screen.getByText('Mentors')).toBeInTheDocument() + expect(screen.getByText('John Doe')).toBeInTheDocument() + expect(screen.getByText('Jane Smith')).toBeInTheDocument() + }) + + it('renders multiple mentees', () => { + const mockMentees = [mockContributor, mockContributor2] + render() + + expect(screen.getByText('Mentees')).toBeInTheDocument() + expect(screen.getByText('John Doe')).toBeInTheDocument() + expect(screen.getByText('Jane Smith')).toBeInTheDocument() + }) +}) diff --git a/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsHeader.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsHeader.test.tsx new file mode 100644 index 0000000000..053a77b566 --- /dev/null +++ b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsHeader.test.tsx @@ -0,0 +1,414 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import type { Session } from 'next-auth' +import '@testing-library/jest-dom' +import CardDetailsHeader from 'components/CardDetailsPage/CardDetailsHeader' +import type { CardDetailsHeaderProps } from 'components/CardDetailsPage/CardDetailsHeader' + +jest.mock('next-auth/react', () => ({ + useSession: jest.fn(() => ({ + data: null, + status: 'unauthenticated', + })), +})) + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const mockUseSession = jest.mocked(require('next-auth/react').useSession) + +jest.mock('utils/scrollToAnchor', () => ({ + scrollToAnchor: jest.fn(), +})) + +jest.mock('utils/env.client', () => ({ + IS_PROJECT_HEALTH_ENABLED: true, +})) + +jest.mock('components/EntityActions', () => ({ + __esModule: true, + default: ({ + type, + programKey, + moduleKey, + }: { + type: string + programKey?: string + moduleKey?: string + }) => ( +
+ EntityActions: type={type}, programKey={programKey}, moduleKey={moduleKey} +
+ ), +})) + +jest.mock('components/StatusBadge', () => ({ + __esModule: true, + default: ({ status }: { status: string }) => ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ), +})) + +jest.mock('components/MetricsScoreCircle', () => ({ + __esModule: true, + default: ({ + score, + clickable, + onClick, + }: { + score: number + clickable?: boolean + onClick?: () => void + }) => + clickable ? ( + + ) : ( +
Score: {score}
+ ), +})) + +describe('CardDetailsHeader', () => { + const defaultProps: CardDetailsHeaderProps = { + title: 'Test Title', + } + + it('renders title correctly', () => { + render() + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) + + it('renders description when provided', () => { + render() + expect(screen.getByText('Test description')).toBeInTheDocument() + }) + + it('renders inactive badge when isActive is false', () => { + render() + expect(screen.getByTestId('status-badge-inactive')).toBeInTheDocument() + }) + + it('renders archived badge when isArchived is true', () => { + render() + expect(screen.getByTestId('status-badge-archived')).toBeInTheDocument() + }) + + it('does not render archived badge when showArchivedBadge is false', () => { + render() + expect(screen.queryByTestId('status-badge-archived')).not.toBeInTheDocument() + }) + + it('renders health metrics when conditions are met', () => { + const healthMetricsData = [{ score: 85 }] + render( + + ) + expect(screen.getByTestId('metrics-score-circle')).toBeInTheDocument() + }) + + it('does not render health metrics when score is undefined', () => { + const healthMetricsData = [{ score: undefined }] + render( + + ) + expect(screen.queryByTestId('metrics-score-circle')).not.toBeInTheDocument() + }) + + it('calls scrollToAnchor when health metrics button is clicked', () => { + const { scrollToAnchor } = jest.requireMock('utils/scrollToAnchor') + const healthMetricsData = [{ score: 85 }] + + render( + + ) + + const button = screen.getByTestId('metrics-score-circle') + fireEvent.click(button) + + expect(scrollToAnchor).toHaveBeenCalledWith('issues-trend') + }) + + describe('Module actions with admin and mentor logic', () => { + afterEach(() => { + mockUseSession.mockReturnValue({ + data: null, + status: 'unauthenticated', + } as { data: Session | null; status: string }) + }) + + it('renders entity actions when showModuleActions is true and user is admin', () => { + mockUseSession.mockReturnValueOnce({ + data: { + user: { + login: 'admin_user', + }, + }, + status: 'authenticated', + } as { data: Record; status: string }) + + const admins = [{ login: 'admin_user' }] + + render( + + ) + + expect(screen.getByTestId('entity-actions')).toBeInTheDocument() + expect(screen.getByText(/EntityActions.*type=module/)).toBeInTheDocument() + }) + + it('renders entity actions when showModuleActions is true and user is mentor', () => { + mockUseSession.mockReturnValueOnce({ + data: { + user: { + login: 'mentor_user', + }, + }, + status: 'authenticated', + } as { data: Record; status: string }) + + const mentors = [{ login: 'mentor_user' }] + + render( + + ) + + expect(screen.getByTestId('entity-actions')).toBeInTheDocument() + }) + + it('does not render entity actions when showModuleActions is true but user is neither admin nor mentor', () => { + mockUseSession.mockReturnValueOnce({ + data: { + user: { + login: 'regular_user', + }, + }, + status: 'authenticated', + } as { data: Record; status: string }) + + const admins = [{ login: 'admin_user' }] + const mentors = [{ login: 'mentor_user' }] + + render( + + ) + + expect(screen.queryByTestId('entity-actions')).not.toBeInTheDocument() + }) + + it('does not render entity actions when showModuleActions is true but programKey is missing', () => { + mockUseSession.mockReturnValueOnce({ + data: { + user: { + login: 'admin_user', + }, + }, + status: 'authenticated', + } as { data: Record; status: string }) + + const admins = [{ login: 'admin_user' }] + + render( + + ) + + expect(screen.queryByTestId('entity-actions')).not.toBeInTheDocument() + }) + + it('does not render entity actions when showModuleActions is true but moduleKey is missing', () => { + mockUseSession.mockReturnValueOnce({ + data: { + user: { + login: 'admin_user', + }, + }, + status: 'authenticated', + } as { data: Record; status: string }) + + const admins = [{ login: 'admin_user' }] + + render( + + ) + + expect(screen.queryByTestId('entity-actions')).not.toBeInTheDocument() + }) + + it('renders entity actions when user is both admin and mentor', () => { + mockUseSession.mockReturnValueOnce({ + data: { + user: { + login: 'admin_mentor_user', + }, + }, + status: 'authenticated', + } as { data: Record; status: string }) + + const admins = [{ login: 'admin_mentor_user' }] + const mentors = [{ login: 'admin_mentor_user' }] + + render( + + ) + + expect(screen.getByTestId('entity-actions')).toBeInTheDocument() + }) + }) + + describe('Program actions', () => { + it('renders program EntityActions when showProgramActions, canUpdateStatus, and programKey are all set', () => { + render( + + ) + const actions = screen.getByTestId('entity-actions') + expect(actions).toBeInTheDocument() + expect(actions).toHaveTextContent('type=program') + expect(actions).toHaveTextContent('programKey=my-program') + }) + + it('does not render program EntityActions when canUpdateStatus is false', () => { + render( + + ) + expect(screen.queryByTestId('entity-actions')).not.toBeInTheDocument() + }) + + it('does not render program EntityActions when programKey is missing', () => { + render( + + ) + expect(screen.queryByTestId('entity-actions')).not.toBeInTheDocument() + }) + + it('does not render program EntityActions when showProgramActions is false', () => { + render( + + ) + expect(screen.queryByTestId('entity-actions')).not.toBeInTheDocument() + }) + }) + + describe('Edge cases', () => { + it('renders empty title when title prop is not provided', () => { + render() + expect(document.querySelector('h1')).toBeInTheDocument() + expect(document.querySelector('h1')?.textContent).toBe('') + }) + + it('does not render description paragraph when description is not provided', () => { + render() + expect(screen.queryByText('Test description')).not.toBeInTheDocument() + }) + + it('does not render health metrics when healthMetricsData is an empty array', () => { + render( + + ) + expect(screen.queryByTestId('metrics-score-circle')).not.toBeInTheDocument() + }) + + it('does not render health metrics when showHealthMetrics is false', () => { + render( + + ) + expect(screen.queryByTestId('metrics-score-circle')).not.toBeInTheDocument() + }) + + it('does not render module EntityActions when showModuleActions is false', () => { + render( + + ) + expect(screen.queryByTestId('entity-actions')).not.toBeInTheDocument() + }) + + it('renders active component without inactive badge when isActive is true (default)', () => { + render() + expect(screen.queryByTestId('status-badge-inactive')).not.toBeInTheDocument() + }) + + it('does not render archived badge when isArchived is false (default)', () => { + render() + expect(screen.queryByTestId('status-badge-archived')).not.toBeInTheDocument() + }) + }) +}) diff --git a/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsIssuesMilestones.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsIssuesMilestones.test.tsx new file mode 100644 index 0000000000..ce73670fb2 --- /dev/null +++ b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsIssuesMilestones.test.tsx @@ -0,0 +1,953 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' +import '@testing-library/jest-dom' +import { Issue, Milestone, PullRequest, Release } from 'types' +import CardDetailsIssuesMilestones from 'components/CardDetailsPage/CardDetailsIssuesMilestones' + +jest.mock('components/RecentIssues', () => ({ + __esModule: true, + default: ({ data }: { data: Issue[] }) => ( +
Issues: {data.map((i) => i.title).join(', ')}
+ ), +})) + +jest.mock('components/Milestones', () => ({ + __esModule: true, + default: ({ data }: { data: Milestone[] }) => ( +
Milestones: {data.map((m) => m.title).join(', ')}
+ ), +})) + +jest.mock('components/RecentPullRequests', () => ({ + __esModule: true, + default: ({ data }: { data: PullRequest[] }) => ( +
PRs: {data.map((pr) => pr.title).join(', ')}
+ ), +})) + +jest.mock('components/RecentReleases', () => ({ + __esModule: true, + default: ({ data }: { data: Release[] }) => ( +
Releases: {data.map((r) => r.name).join(', ')}
+ ), +})) + +jest.mock('next/link', () => { + return ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ) +}) + +jest.mock('components/MentorshipPullRequest', () => ({ + __esModule: true, + default: ({ pr }: { pr: PullRequest }) => ( +
{pr.title}
+ ), +})) + +jest.mock('components/SecondaryCard', () => ({ + __esModule: true, + default: ({ title, children }: { title: React.ReactNode; children: React.ReactNode }) => ( +
+
{title}
+ {children} +
+ ), +})) + +jest.mock('components/ShowMoreButton', () => ({ + __esModule: true, + default: ({ onToggle }: { onToggle: () => void }) => ( + + ), +})) + +jest.mock('components/AnchorTitle', () => ({ + __esModule: true, + default: ({ title }: { title: string }) => {title}, +})) + +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ alt, ...props }: Record) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt)} /> + ), +})) + +jest.mock('components/TruncatedText', () => ({ + __esModule: true, + TruncatedText: ({ text }: { text: string }) => {text}, +})) + +jest.mock('utils/dateFormatter', () => ({ + formatDate: (date: string) => new Date(date).toLocaleDateString(), +})) + +jest.mock('@heroui/tooltip', () => ({ + Tooltip: ({ children, content }: { children: React.ReactNode; content: string }) => ( +
{children}
+ ), +})) + +describe('CardDetailsIssuesMilestones', () => { + const mockIssue: Issue = { + id: '1', + title: 'Fix bug in parser', + url: 'https://github.com/issue/1', + state: 'open', + createdAt: '2024-01-01', + } + + const mockMilestone: Milestone = { + id: '1', + title: 'v1.0.0', + url: 'https://github.com/milestone/1', + dueOn: '2024-02-01', + } + + const mockPR: PullRequest = { + id: '1', + title: 'Add new feature', + url: 'https://github.com/pr/1', + state: 'open', + createdAt: '2024-01-05', + } + + const mockRelease: Release = { + id: '1', + name: 'v1.0.0', + url: 'https://github.com/releases/v1.0.0', + publishedAt: '2024-01-15', + } + + it('renders all sections together when provided', () => { + render( + + ) + + expect(screen.getByTestId('recent-issues')).toBeInTheDocument() + expect(screen.getByTestId('milestones')).toBeInTheDocument() + expect(screen.getByTestId('recent-pull-requests')).toBeInTheDocument() + expect(screen.getByTestId('recent-releases')).toBeInTheDocument() + }) + + it('renders nothing when no data provided', () => { + const { container } = render() + expect(container.querySelector('div')).toBeEmptyDOMElement() + }) + + it('renders milestones in secondary card', () => { + render() + + expect(screen.getByTestId('milestones')).toBeInTheDocument() + }) + + it('renders pull requests when provided', () => { + render() + + expect(screen.getByTestId('recent-pull-requests')).toBeInTheDocument() + }) + + it('renders milestones with show more button when 5+ items', () => { + const milestones = [ + { id: '1', title: 'v1.0', createdAt: '2024-01-01', closedIssuesCount: 5, openIssuesCount: 1 }, + { id: '2', title: 'v1.1', createdAt: '2024-01-02', closedIssuesCount: 3, openIssuesCount: 2 }, + { id: '3', title: 'v1.2', createdAt: '2024-01-03', closedIssuesCount: 4, openIssuesCount: 1 }, + { id: '4', title: 'v1.3', createdAt: '2024-01-04', closedIssuesCount: 2, openIssuesCount: 3 }, + { id: '5', title: 'v1.4', createdAt: '2024-01-05', closedIssuesCount: 1, openIssuesCount: 1 }, + ] + + render() + + expect(screen.getByTestId('milestones')).toBeInTheDocument() + expect(screen.getByText(/v1\./)).toBeInTheDocument() + }) + + it('toggles milestones visibility when show more button clicked', () => { + const milestones = [ + { id: '1', title: 'm1', createdAt: '2024-01-01', closedIssuesCount: 1, openIssuesCount: 1 }, + { id: '2', title: 'm2', createdAt: '2024-01-02', closedIssuesCount: 2, openIssuesCount: 1 }, + { id: '3', title: 'm3', createdAt: '2024-01-03', closedIssuesCount: 3, openIssuesCount: 1 }, + { id: '4', title: 'm4', createdAt: '2024-01-04', closedIssuesCount: 4, openIssuesCount: 1 }, + { id: '5', title: 'm5', createdAt: '2024-01-05', closedIssuesCount: 5, openIssuesCount: 1 }, + ] + + render() + + expect(screen.getByText('m1')).toBeInTheDocument() + expect(screen.getByText('m4')).toBeInTheDocument() + expect(screen.queryByText('m5')).not.toBeInTheDocument() + + const showMoreButton = screen.getByTestId('show-more-button') + fireEvent.click(showMoreButton) + + expect(screen.getByText('m5')).toBeInTheDocument() + }) + + it('renders milestones with all optional properties', () => { + const milestoneWithAllProps: Milestone = { + id: '1', + title: 'Full Milestone', + url: 'https://github.com/milestone/1', + createdAt: '2024-01-01', + closedIssuesCount: 10, + openIssuesCount: 5, + dueOn: '2024-02-01', + author: { + login: 'dev_user', + name: 'Dev User', + avatarUrl: 'https://example.com/dev-avatar.jpg', + }, + repositoryName: 'main-repo', + organizationName: 'main-org', + } + + render( + + ) + + expect(screen.getByTestId('secondary-card')).toBeInTheDocument() + expect(screen.getByTestId('secondary-card-title')).toHaveTextContent('Recent Milestones') + expect(screen.getByText('Full Milestone')).toBeInTheDocument() + expect(screen.getByText(/10 closed/)).toBeInTheDocument() + expect(screen.getByText(/5 open/)).toBeInTheDocument() + }) + + it('renders pull requests with load more functionality', () => { + const prs = Array.from({ length: 5 }, (_, i) => ({ + id: String(i + 1), + title: `PR ${i + 1}`, + url: `https://github.com/pr/${i + 1}`, + state: 'open' as const, + createdAt: `2024-01-0${i + 1}`, + })) + + const onLoadMore = jest.fn() + + render( + + ) + + expect(screen.getByTestId('secondary-card')).toBeInTheDocument() + expect(screen.getByText('PR 1')).toBeInTheDocument() + expect(screen.getByText('PR 4')).toBeInTheDocument() + + const showMoreButton = screen.getByRole('button', { name: /show more/i }) + fireEvent.click(showMoreButton) + expect(onLoadMore).toHaveBeenCalledTimes(1) + }) + + it('renders pull requests with toggle functionality', () => { + const prs = Array.from({ length: 5 }, (_, i) => ({ + id: String(i + 1), + title: `PR ${i + 1}`, + url: `https://github.com/pr/${i + 1}`, + state: 'open' as const, + createdAt: `2024-01-0${i + 1}`, + })) + + render() + + expect(screen.getByTestId('secondary-card')).toBeInTheDocument() + const showMoreButton = screen.getByTestId('show-more-button') + expect(showMoreButton).toBeInTheDocument() + + fireEvent.click(showMoreButton) + expect(showMoreButton).toBeInTheDocument() + }) + + it('renders issues and milestones in grid layout', () => { + render( + + ) + + expect(screen.getByTestId('recent-issues')).toBeInTheDocument() + expect(screen.getByTestId('milestones')).toBeInTheDocument() + }) + + it('renders releases in secondary grid', () => { + render() + + expect(screen.getByTestId('recent-pull-requests')).toBeInTheDocument() + expect(screen.getByTestId('recent-releases')).toBeInTheDocument() + }) + + it('renders empty milestone card without author', () => { + const milestoneNoAuthor: Milestone = { + id: '1', + title: 'No Author Milestone', + createdAt: '2024-01-01', + closedIssuesCount: 0, + openIssuesCount: 0, + } + + render( + + ) + + expect(screen.getByText('No Author Milestone')).toBeInTheDocument() + }) + + it('renders milestone without repository info', () => { + const milestoneNoRepo: Milestone = { + id: '1', + title: 'No Repo Milestone', + createdAt: '2024-01-01', + closedIssuesCount: 1, + openIssuesCount: 1, + author: { + login: 'user', + name: 'User', + avatarUrl: 'https://example.com/avatar.jpg', + }, + } + + render( + + ) + + expect(screen.getByText('No Repo Milestone')).toBeInTheDocument() + expect(screen.getByText(/1 closed/)).toBeInTheDocument() + expect(screen.getByText(/1 open/)).toBeInTheDocument() + }) + + it('renders only milestones section when isMilestoneOnly is true', () => { + const milestones = Array.from({ length: 3 }, (_, i) => ({ + id: String(i + 1), + title: `m${i + 1}`, + createdAt: `2024-01-0${i + 1}`, + closedIssuesCount: i, + openIssuesCount: i + 1, + })) + + render( + + ) + + expect(screen.getByTestId('secondary-card')).toBeInTheDocument() + expect(screen.queryByTestId('recent-issues')).not.toBeInTheDocument() + }) + + it('renders only pull requests section when isPullRequestOnly is true', () => { + const prs = [ + { + id: '1', + title: 'PR Only', + url: 'https://github.com/pr/1', + state: 'open' as const, + createdAt: '2024-01-01', + }, + ] + + render( + + ) + + expect(screen.getByTestId('secondary-card')).toBeInTheDocument() + expect(screen.getByTestId('secondary-card-title')).toHaveTextContent('Recent Pull Requests') + }) + + it('handles pull requests with fetch loading state', () => { + const prs = Array.from({ length: 3 }, (_, i) => ({ + id: String(i + 1), + title: `PR ${i + 1}`, + url: `https://github.com/pr/${i + 1}`, + state: 'open' as const, + createdAt: `2024-01-0${i + 1}`, + })) + + const onLoadMore = jest.fn() + + const { rerender } = render( + + ) + + rerender( + + ) + + expect(screen.getByTestId('secondary-card')).toBeInTheDocument() + }) + + it('renders nothing when isPullRequestOnly but no pull requests', () => { + render() + + expect(screen.queryByTestId('secondary-card')).not.toBeInTheDocument() + }) + + it('renders nothing when isMilestoneOnly but no milestones', () => { + render() + + expect(screen.queryByTestId('secondary-card')).not.toBeInTheDocument() + }) + + it('renders both load more and reset buttons for pull requests', () => { + const prs = Array.from({ length: 5 }, (_, i) => ({ + id: String(i + 1), + title: `PR ${i + 1}`, + url: `https://github.com/pr/${i + 1}`, + state: 'open' as const, + createdAt: `2024-01-0${i + 1}`, + })) + + const onLoadMore = jest.fn() + const onReset = jest.fn() + + render( + + ) + + expect(screen.getByTestId('secondary-card')).toBeInTheDocument() + + const showMoreButton = screen.getByRole('button', { name: /show more/i }) + const showLessButton = screen.getByRole('button', { name: /show less/i }) + expect(showMoreButton).toBeInTheDocument() + expect(showLessButton).toBeInTheDocument() + + fireEvent.click(showMoreButton) + expect(onLoadMore).toHaveBeenCalledTimes(1) + + fireEvent.click(showLessButton) + expect(onReset).toHaveBeenCalledTimes(1) + }) + + it('renders milestone with url as clickable link', () => { + const milestoneWithUrl: Milestone = { + id: '1', + title: 'v2.0.0', + url: 'https://github.com/milestone/2', + createdAt: '2024-02-01', + closedIssuesCount: 8, + openIssuesCount: 2, + } + + render( + + ) + + const link = screen.getByRole('link', { hidden: true }) + expect(link).toHaveAttribute('href', 'https://github.com/milestone/2') + }) + + it('renders milestone without url as plain text', () => { + const milestoneNoUrl: Milestone = { + id: '1', + title: 'v3.0.0', + createdAt: '2024-03-01', + closedIssuesCount: 12, + openIssuesCount: 0, + } + + render( + + ) + + expect(screen.getByText('v3.0.0')).toBeInTheDocument() + }) + + it('renders milestone with repository link', () => { + const milestoneWithRepo: Milestone = { + id: '1', + title: 'v1.5.0', + createdAt: '2024-01-15', + closedIssuesCount: 3, + openIssuesCount: 1, + repositoryName: 'awesome-repo', + organizationName: 'awesome-org', + } + + render( + + ) + + const repoText = screen.getByText('awesome-repo') + const repoLink = repoText.closest('a') + expect(repoLink).toBeInTheDocument() + expect(repoLink).toHaveAttribute('href', '/organizations/awesome-org/repositories/awesome-repo') + }) + + it('renders milestone with author avatar and clickable author link', () => { + const milestoneWithAuthorAndAvatar: Milestone = { + id: '1', + title: 'Release v1.0', + createdAt: '2024-01-10', + closedIssuesCount: 5, + openIssuesCount: 2, + author: { + login: 'contributor-a', + name: 'Contributor A', + avatarUrl: 'https://example.com/contrib-a.jpg', + }, + } + + render( + + ) + + const authorLinks = screen.getAllByRole('link', { hidden: true }) + const authorLink = authorLinks.find( + (link) => link.getAttribute('href') === '/members/contributor-a' + ) + expect(authorLink).toBeInTheDocument() + }) + + it('renders milestone dueOn date when available', () => { + const milestoneWithDueDate: Milestone = { + id: '1', + title: 'Sprint End', + createdAt: '2024-01-01', + dueOn: '2024-03-31', + closedIssuesCount: 10, + openIssuesCount: 5, + } + + render( + + ) + + expect(screen.getByText('Sprint End')).toBeInTheDocument() + }) + + it('renders milestone without createdAt', () => { + const milestoneNoCreatedAt: Milestone = { + id: '1', + title: 'Future Release', + dueOn: '2024-12-31', + closedIssuesCount: 0, + openIssuesCount: 0, + } + + render( + + ) + + expect(screen.getByText('Future Release')).toBeInTheDocument() + }) + + it('renders milestone without both repository and organization', () => { + const milestoneOnlyTitle: Milestone = { + id: '1', + title: 'Standalone Milestone', + createdAt: '2024-01-01', + closedIssuesCount: 1, + openIssuesCount: 1, + } + + render( + + ) + + expect(screen.getByText('Standalone Milestone')).toBeInTheDocument() + expect(screen.getByText(/1 closed/)).toBeInTheDocument() + }) + + it('does not render show more button when pull requests <= 4 and no callbacks', () => { + const prs = [ + { + id: '1', + title: 'PR 1', + url: 'https://github.com/pr/1', + state: 'open' as const, + createdAt: '2024-01-01', + }, + { + id: '2', + title: 'PR 2', + url: 'https://github.com/pr/2', + state: 'open' as const, + createdAt: '2024-01-02', + }, + ] + + render() + + expect(screen.queryByTestId('show-more-button')).not.toBeInTheDocument() + }) + + it('renders show more button when pull requests > 4 and no callbacks', () => { + const prs = Array.from({ length: 5 }, (_, i) => ({ + id: String(i + 1), + title: `PR ${i + 1}`, + url: `https://github.com/pr/${i + 1}`, + state: 'open' as const, + createdAt: `2024-01-0${i + 1}`, + })) + + render() + + const showMoreButton = screen.getByTestId('show-more-button') + expect(showMoreButton).toBeInTheDocument() + expect(showMoreButton).toHaveTextContent('Show More') + }) + + it('handles onResetPullRequests callback click', () => { + const prs = Array.from({ length: 3 }, (_, i) => ({ + id: String(i + 1), + title: `PR ${i + 1}`, + url: `https://github.com/pr/${i + 1}`, + state: 'open' as const, + createdAt: `2024-01-0${i + 1}`, + })) + + const onReset = jest.fn() + + render( + + ) + + expect(screen.getByTestId('secondary-card')).toBeInTheDocument() + + const showLessButton = screen.getByRole('button', { name: /show less/i }) + fireEvent.click(showLessButton) + expect(onReset).toHaveBeenCalledTimes(1) + }) + + it('renders multiple milestones and toggles show more correctly', () => { + const milestones = Array.from({ length: 6 }, (_, i) => ({ + id: String(i + 1), + title: `v${i + 1}.0`, + createdAt: `2024-01-${String(i + 1).padStart(2, '0')}`, + closedIssuesCount: i, + openIssuesCount: i + 1, + })) + + render() + + const showMoreButton = screen.getByTestId('show-more-button') + expect(showMoreButton).toBeInTheDocument() + + expect(screen.getByText('v1.0')).toBeInTheDocument() + expect(screen.queryByText('v5.0')).not.toBeInTheDocument() + }) + + it('renders load more button with correct disabled state', () => { + const prs = Array.from({ length: 3 }, (_, i) => ({ + id: String(i + 1), + title: `PR ${i + 1}`, + url: `https://github.com/pr/${i + 1}`, + state: 'open' as const, + createdAt: `2024-01-0${i + 1}`, + })) + + const onLoadMore = jest.fn() + + const { rerender } = render( + + ) + + const loadMoreBtn = screen.getByRole('button', { name: /Show more/ }) + expect(loadMoreBtn).not.toBeDisabled() + + rerender( + + ) + + expect(screen.getByText('Loading...')).toBeInTheDocument() + }) + + it('renders reset button with correct disabled state', () => { + const prs = Array.from({ length: 3 }, (_, i) => ({ + id: String(i + 1), + title: `PR ${i + 1}`, + url: `https://github.com/pr/${i + 1}`, + state: 'open' as const, + createdAt: `2024-01-0${i + 1}`, + })) + + const onReset = jest.fn() + + const { rerender } = render( + + ) + + const resetBtn = screen.getByText('Show less') + expect(resetBtn).not.toBeDisabled() + + rerender( + + ) + + const disabledBtn = screen.getByText('Show less') + expect(disabledBtn).toBeDisabled() + }) + + it('applies correct cursor styles when fetching more', () => { + const prs = Array.from({ length: 3 }, (_, i) => ({ + id: String(i + 1), + title: `PR ${i + 1}`, + url: `https://github.com/pr/${i + 1}`, + state: 'open' as const, + createdAt: `2024-01-0${i + 1}`, + })) + + const onLoadMore = jest.fn() + + const { rerender } = render( + + ) + + let loadMoreBtn = screen.getByRole('button', { name: /Show more/ }) + expect(loadMoreBtn.className).not.toContain('cursor-not-allowed') + + rerender( + + ) + + loadMoreBtn = screen.getByText('Loading...') + expect(loadMoreBtn.className).toContain('cursor-not-allowed') + }) + + it('renders milestone title as link when url provided', () => { + const milestoneWithExtUrl: Milestone = { + id: '1', + title: 'External Release', + url: 'https://external-release.com', + createdAt: '2024-01-01', + closedIssuesCount: 5, + openIssuesCount: 1, + } + + render( + + ) + + const links = screen.getAllByRole('link', { hidden: true }) + const externalLink = links.find( + (link) => link.getAttribute('href') === 'https://external-release.com' + ) + expect(externalLink).toBeInTheDocument() + expect(externalLink).toHaveAttribute('href', 'https://external-release.com') + }) + + it('renders milestone with author using only login when name not provided', () => { + const milestoneAuthorNoName: Milestone = { + id: '1', + title: 'Release v1.2', + createdAt: '2024-01-20', + closedIssuesCount: 3, + openIssuesCount: 1, + author: { + login: 'author_login', + avatarUrl: 'https://example.com/author.jpg', + }, + } + + render( + + ) + + expect(screen.getByText('Release v1.2')).toBeInTheDocument() + }) + + it('does not render milestone author avatar when showAvatar is false', () => { + const milestoneWithAuthor: Milestone = { + id: '1', + title: 'Release v2.0', + createdAt: '2024-02-01', + closedIssuesCount: 10, + openIssuesCount: 2, + author: { + login: 'dev_author', + name: 'Developer', + avatarUrl: 'https://example.com/dev.jpg', + }, + } + + render( + + ) + + expect(screen.queryByAltText(/avatar/)).not.toBeInTheDocument() + expect(screen.getByText('Release v2.0')).toBeInTheDocument() + }) + + it('does not render author avatar when author login is missing', () => { + const milestoneNoAuthorLogin: Milestone = { + id: '1', + title: 'Release v1.5', + createdAt: '2024-01-30', + closedIssuesCount: 7, + openIssuesCount: 1, + author: { + name: 'Some Developer', + avatarUrl: 'https://example.com/dev.jpg', + }, + } + + render( + + ) + + expect(screen.getByText('Release v1.5')).toBeInTheDocument() + }) + + it('does not render author avatar when avatar url is missing', () => { + const milestoneNoAvatarUrl: Milestone = { + id: '1', + title: 'Release v1.8', + createdAt: '2024-01-25', + closedIssuesCount: 4, + openIssuesCount: 2, + author: { + login: 'author_name', + name: 'Author Full Name', + }, + } + + render( + + ) + + expect(screen.getByText('Release v1.8')).toBeInTheDocument() + }) + + it('renders repository link with correct href structure', () => { + const milestoneRepo: Milestone = { + id: '1', + title: 'Release v2.0.0', + createdAt: '2024-02-15', + closedIssuesCount: 20, + openIssuesCount: 5, + repositoryName: 'production-repo', + organizationName: 'prod-org', + } + + render( + + ) + + const repoLink = screen.getByText('production-repo').closest('a') + expect(repoLink).toHaveAttribute('href', '/organizations/prod-org/repositories/production-repo') + }) + + it('only renders repository section when both name and org present', () => { + const milestonePartialRepo2: Milestone = { + id: '1', + title: 'Release v1.9', + createdAt: '2024-02-10', + closedIssuesCount: 8, + openIssuesCount: 2, + organizationName: 'only-org', + } + + render( + + ) + + expect(screen.getByText('Release v1.9')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /only-org/ })).not.toBeInTheDocument() + }) +}) diff --git a/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsLeaders.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsLeaders.test.tsx new file mode 100644 index 0000000000..832603d176 --- /dev/null +++ b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsLeaders.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { Leader } from 'types/leader' +import CardDetailsLeaders from 'components/CardDetailsPage/CardDetailsLeaders' + +jest.mock('components/Leaders', () => ({ + __esModule: true, + default: ({ users }: { users: Leader[] }) => ( +
Leaders: {users.map((u) => u.login).join(', ')}
+ ), +})) + +describe('CardDetailsLeaders', () => { + const mockLeader: Leader = { + id: '1', + login: 'user1', + name: 'User One', + avatarUrl: 'https://example.com/avatar1.jpg', + } + + it('renders nothing when entityLeaders is undefined', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders nothing when entityLeaders is null', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders nothing when entityLeaders is empty array', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders Leaders component when entityLeaders has data', () => { + render() + expect(screen.getByTestId('leaders-component')).toBeInTheDocument() + expect(screen.getByText(/user1/)).toBeInTheDocument() + }) + + it('renders div with mb-8 class when entityLeaders has data', () => { + const { container } = render() + const div = container.querySelector('div.mb-8') + expect(div).toBeInTheDocument() + }) + + it('passes all leaders to Leaders component', () => { + const leaders: Leader[] = [ + { + id: '1', + login: 'leader1', + name: 'Leader One', + avatarUrl: 'https://example.com/avatar1.jpg', + }, + { + id: '2', + login: 'leader2', + name: 'Leader Two', + avatarUrl: 'https://example.com/avatar2.jpg', + }, + { + id: '3', + login: 'leader3', + name: 'Leader Three', + avatarUrl: 'https://example.com/avatar3.jpg', + }, + ] + + render() + expect(screen.getByTestId('leaders-component')).toBeInTheDocument() + expect(screen.getByText(/leader1.*leader2.*leader3/)).toBeInTheDocument() + }) + + it('renders with single leader', () => { + render() + expect(screen.getByTestId('leaders-component')).toBeInTheDocument() + expect(screen.getByText('Leaders: user1')).toBeInTheDocument() + }) + + it('renders Leaders component nested inside mb-8 div', () => { + const { container } = render() + const mbDiv = container.querySelector('div.mb-8') + const leadersComponent = screen.getByTestId('leaders-component') + expect(mbDiv?.contains(leadersComponent)).toBe(true) + }) +}) diff --git a/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsMetadata.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsMetadata.test.tsx new file mode 100644 index 0000000000..4c2184c8d6 --- /dev/null +++ b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsMetadata.test.tsx @@ -0,0 +1,179 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import '@testing-library/jest-dom' +import type { Stats } from 'types/card' +import type { Chapter } from 'types/chapter' +import CardDetailsMetadata from 'components/CardDetailsPage/CardDetailsMetadata' + +jest.mock('react-icons/fa6', () => ({ + FaChartPie: () => ChartIcon, + FaRectangleList: () => ListIcon, +})) + +jest.mock('components/AnchorTitle', () => ({ + __esModule: true, + default: ({ title }: { title: string }) => {title}, +})) + +jest.mock('components/ChapterMapWrapper', () => ({ + __esModule: true, + default: ({ geoLocData }: { geoLocData: Chapter[] }) => ( +
{geoLocData.length} locations
+ ), +})) + +jest.mock('components/InfoBlock', () => ({ + __esModule: true, + default: ({ + value, + unit, + pluralizedName, + }: { + value: number + unit: string + pluralizedName: string + }) => ( +
+ {value} {unit} {pluralizedName} +
+ ), +})) + +jest.mock('components/LeadersList', () => ({ + __esModule: true, + default: ({ leaders, entityKey }: { leaders: string; entityKey: string }) => ( +
+ {leaders} (key: {entityKey}) +
+ ), +})) + +jest.mock('components/SecondaryCard', () => ({ + __esModule: true, + default: ({ + title, + children, + className, + }: { + title: React.ReactNode + children: React.ReactNode + className?: string + }) => ( +
+ {title} + {children} +
+ ), +})) + +const getSocialPlatformName = (url: string): string => { + if (url.includes('github')) { + return 'GitHub' + } + if (url.includes('twitter')) { + return 'Twitter' + } + return 'Link' +} + +jest.mock('utils/urlIconMappings', () => ({ + getSocialIcon: (url: string) => { + return () => {getSocialPlatformName(url)} + }, +})) + +describe('CardDetailsMetadata', () => { + const mockStats: Stats[] = [ + { + value: 100, + unit: 'repositories', + pluralizedName: 'Repositories', + icon: 'FaGit', + }, + { + value: 50, + unit: 'members', + pluralizedName: 'Members', + icon: 'FaUsers', + }, + ] + + const mockGeoData: Chapter[] = [ + { + id: '1', + name: 'Chapter 1', + location: 'City 1', + }, + { + id: '2', + name: 'Chapter 2', + location: 'City 2', + }, + ] + + it('renders Details card when details prop is provided', () => { + const details = [ + { label: 'Name', value: 'Test Name' }, + { label: 'Email', value: 'test@example.com' }, + ] + + render() + const secondaryCard = screen.getByTestId('secondary-card') + expect(secondaryCard).toBeInTheDocument() + expect(secondaryCard).toHaveTextContent('Name') + expect(secondaryCard).toHaveTextContent('Test Name') + }) + + it('renders details with default "Unknown" when value is missing', () => { + const details = [ + { label: 'Name', value: '' }, + { label: 'Empty Field', value: undefined as string | undefined }, + ] + + render() + const secondaryCard = screen.getByTestId('secondary-card') + expect(secondaryCard).toHaveTextContent('Unknown') + }) + + it('renders LeadersList component for Leaders detail', () => { + const details = [{ label: 'Leaders', value: 'leader1, leader2' }] + + render() + expect(screen.getByTestId('leaders-list')).toBeInTheDocument() + expect(screen.getByText(/leader1, leader2/)).toBeInTheDocument() + }) + + it('renders Statistics card when showStatistics is true and stats provided', () => { + render() + expect(screen.getAllByTestId('secondary-card').length).toBeGreaterThanOrEqual(1) + expect(screen.getByText('Statistics')).toBeInTheDocument() + expect(screen.getAllByTestId('info-block').length).toBe(2) + }) + + it('renders geolocation map when showGeolocation is true and geolocationData provided', () => { + render() + expect(screen.getByTestId('chapter-map')).toBeInTheDocument() + expect(screen.getByText('2 locations')).toBeInTheDocument() + }) + + it('renders social links when showSocialLinks is true', () => { + const details = [{ label: 'Organization', value: 'Org Name' }] + const socialLinks = ['https://github.com/test', 'https://twitter.com/test'] + + render( + + ) + + expect(screen.getByText('Social Links')).toBeInTheDocument() + expect(screen.getByTestId('social-icon-https://github.com/test')).toBeInTheDocument() + expect(screen.getByTestId('social-icon-https://twitter.com/test')).toBeInTheDocument() + }) + + it('does not render social links when socialLinks is empty array', () => { + const details = [{ label: 'Organization', value: 'Org Name' }] + + render() + + expect(screen.queryByText('Social Links')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsRepositoriesModules.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsRepositoriesModules.test.tsx new file mode 100644 index 0000000000..ecf9e9be8c --- /dev/null +++ b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsRepositoriesModules.test.tsx @@ -0,0 +1,149 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import '@testing-library/jest-dom' +import { Module } from 'types/mentorship' +import { RepositoryCardProps } from 'types/project' +import CardDetailsRepositoriesModules from 'components/CardDetailsPage/CardDetailsRepositoriesModules' + +jest.mock('components/ModuleCard', () => ({ + __esModule: true, + default: ({ modules }: { modules: Module[] }) => ( +
{modules.map((m) => m.name).join(', ')}
+ ), +})) + +jest.mock('components/RepositoryCard', () => ({ + __esModule: true, + default: ({ repositories }: { repositories: RepositoryCardProps[] }) => ( +
{repositories.map((r) => r.name).join(', ')}
+ ), +})) + +jest.mock('components/SecondaryCard', () => ({ + __esModule: true, + default: ({ + title, + children, + additionalAction, + }: { + title?: string + children: React.ReactNode + additionalAction?: React.ReactNode + }) => ( +
+ {title &&

{title}

} + {additionalAction &&
{additionalAction}
} +
{children}
+
+ ), +})) + +jest.mock('next-auth/react', () => ({ + useSession: jest.fn(() => ({ + data: { + user: { role: 'admin' }, + }, + })), +})) + +jest.mock('next/link', () => { + return ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ) +}) + +describe('CardDetailsRepositoriesModules', () => { + const mockRepository: RepositoryCardProps = { + id: '1', + name: 'test-repo', + url: 'https://github.com/test-repo', + language: 'TypeScript', + stars: 100, + forks: 50, + } + + const mockModule: Module = { + id: '1', + name: 'auth-module', + description: 'Authentication module', + programId: 'program-1', + } + + it('renders nothing when no repositories or modules provided', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders repositories when provided', () => { + render() + + expect(screen.getByTestId('repository-card')).toBeInTheDocument() + expect(screen.getByText('test-repo')).toBeInTheDocument() + }) + + it('renders modules when provided', () => { + render() + + expect(screen.getByTestId('module-card')).toBeInTheDocument() + expect(screen.getByText('auth-module')).toBeInTheDocument() + }) + + it('renders both repositories and modules when provided', () => { + render( + + ) + + expect(screen.getByTestId('repository-card')).toBeInTheDocument() + expect(screen.getByText('test-repo')).toBeInTheDocument() + expect(screen.getByTestId('module-card')).toBeInTheDocument() + expect(screen.getByText('auth-module')).toBeInTheDocument() + }) + + it('renders multiple repositories in one card', () => { + const repos = [ + mockRepository, + { ...mockRepository, id: '2', name: 'another-repo' }, + { ...mockRepository, id: '3', name: 'third-repo' }, + ] + + render() + + expect(screen.getByText('test-repo, another-repo, third-repo')).toBeInTheDocument() + }) + + it('renders multiple modules', () => { + const modules = [ + mockModule, + { ...mockModule, id: '2', name: 'payment-module' }, + { ...mockModule, id: '3', name: 'logging-module' }, + ] + + render() + + expect(screen.getByText('auth-module, payment-module, logging-module')).toBeInTheDocument() + }) + + it('does not render repositories when empty', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('does not render modules when empty', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders a single repository when provided', () => { + render() + expect(screen.getByTestId('repository-card')).toBeInTheDocument() + }) + + it('renders a single module when provided', () => { + render() + expect(screen.getByTestId('module-card')).toBeInTheDocument() + }) +}) diff --git a/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsSummary.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsSummary.test.tsx new file mode 100644 index 0000000000..f3a7159662 --- /dev/null +++ b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsSummary.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import '@testing-library/jest-dom' +import CardDetailsSummary from 'components/CardDetailsPage/CardDetailsSummary' + +jest.mock('components/MarkdownWrapper', () => ({ + __esModule: true, + default: ({ content }: { content: string }) => ( +
{content}
+ ), +})) + +jest.mock('components/SecondaryCard', () => ({ + __esModule: true, + default: ({ title, children }: { title?: React.ReactNode; children: React.ReactNode }) => ( +
+ {title &&

{title}

} +
{children}
+
+ ), +})) + +jest.mock('components/AnchorTitle', () => ({ + __esModule: true, + default: ({ title }: { title: string }) => {title}, +})) + +jest.mock('react-icons/fa6', () => ({ + FaCircleInfo: () => , +})) + +describe('CardDetailsSummary', () => { + it('renders nothing when no summary or userSummary provided', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders summary section when summary prop is provided', () => { + render() + + expect(screen.getByText('Summary')).toBeInTheDocument() + expect(screen.getByText('This is a project summary')).toBeInTheDocument() + }) + + it('renders userSummary section when userSummary prop is provided', () => { + const userSummary =
Custom user summary content
+ render() + + expect(screen.getByText('Custom user summary content')).toBeInTheDocument() + }) + + it('renders both summary and userSummary when both provided', () => { + const userSummary =
User custom content
+ render() + + expect(screen.getByText('Summary')).toBeInTheDocument() + expect(screen.getByText('Project summary text')).toBeInTheDocument() + expect(screen.getByText('User custom content')).toBeInTheDocument() + }) + + it('renders nothing when summary is empty string', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders markdown wrapper with correct content', () => { + const summaryText = 'This is **bold** text' + render() + + expect(screen.getByTestId('markdown-wrapper')).toHaveTextContent(summaryText) + }) +}) diff --git a/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsTags.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsTags.test.tsx new file mode 100644 index 0000000000..034f50816b --- /dev/null +++ b/frontend/__tests__/unit/components/CardDetailsPage/CardDetailsTags.test.tsx @@ -0,0 +1,147 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import CardDetailsTags from 'components/CardDetailsPage/CardDetailsTags' + +// Mock the child components +jest.mock('components/AnchorTitle', () => { + return function MockAnchorTitle({ title }: { title: string }) { + return {title} + } +}) + +jest.mock('components/ToggleableList', () => { + return function MockToggleableList({ + entityKey, + label, + items, + isDisabled, + }: { + entityKey: string + label: React.ReactNode + items: string[] + isDisabled?: boolean + }) { + return ( +
+ {label} +
    + {items.map((item) => ( +
  • {item}
  • + ))} +
+
+ ) + } +}) + +describe('CardDetailsTags', () => { + it('should render null when no props provided', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + describe('Languages and Topics', () => { + it('should render both languages and topics with md:grid-cols-2', () => { + const { container } = render( + + ) + + expect(screen.getByTestId('toggleable-list-test-languages')).toBeInTheDocument() + expect(screen.getByTestId('toggleable-list-test-topics')).toBeInTheDocument() + expect(container.querySelector(String.raw`.md\:grid-cols-2`)).toBeInTheDocument() + }) + + it('should render languages only with md:col-span-1', () => { + const { container } = render() + + expect(screen.getByTestId('toggleable-list-test-languages')).toBeInTheDocument() + expect(container.querySelector(String.raw`.md\:col-span-1`)).toBeInTheDocument() + }) + + it('should render topics only with md:col-span-1', () => { + const { container } = render() + + expect(screen.getByTestId('toggleable-list-test-topics')).toBeInTheDocument() + expect(container.querySelector(String.raw`.md\:col-span-1`)).toBeInTheDocument() + }) + }) + + describe('Tags, Domains, and Labels', () => { + it('should render tags and domains with md:grid-cols-2', () => { + const { container } = render( + + ) + + expect(screen.getByTestId('toggleable-list-test-tags')).toBeInTheDocument() + expect(screen.getByTestId('toggleable-list-test-domains')).toBeInTheDocument() + expect(container.querySelector(String.raw`.md\:grid-cols-2`)).toBeInTheDocument() + }) + + it('should render tags only with md:col-span-1', () => { + const { container } = render() + + expect(screen.getByTestId('toggleable-list-test-tags')).toBeInTheDocument() + expect(container.querySelector(String.raw`.md\:col-span-1`)).toBeInTheDocument() + }) + + it('should render domains only with md:col-span-1', () => { + const { container } = render() + + expect(screen.getByTestId('toggleable-list-test-domains')).toBeInTheDocument() + expect(container.querySelector(String.raw`.md\:col-span-1`)).toBeInTheDocument() + }) + + it('should render labels with isDisabled={true}', () => { + render() + + const labelsList = screen.getByTestId('toggleable-list-test-labels') + expect(labelsList).toHaveAttribute('data-disabled', 'true') + }) + + it('should render tags and domains with isDisabled={true}', () => { + render() + + const tagsList = screen.getByTestId('toggleable-list-test-tags') + const domainsList = screen.getByTestId('toggleable-list-test-domains') + expect(tagsList).toHaveAttribute('data-disabled', 'true') + expect(domainsList).toHaveAttribute('data-disabled', 'true') + }) + + it('should render all three sections: tags/domains and labels', () => { + render( + + ) + + expect(screen.getByTestId('toggleable-list-test-tags')).toBeInTheDocument() + expect(screen.getByTestId('toggleable-list-test-domains')).toBeInTheDocument() + expect(screen.getByTestId('toggleable-list-test-labels')).toBeInTheDocument() + }) + + it('should render labels only', () => { + render() + expect(screen.getByTestId('toggleable-list-test-labels')).toBeInTheDocument() + }) + }) + + it('should prioritize languages/topics over tags/domains/labels', () => { + render( + + ) + + expect(screen.getByTestId('toggleable-list-test-languages')).toBeInTheDocument() + expect(screen.getByTestId('toggleable-list-test-topics')).toBeInTheDocument() + expect(screen.queryByTestId('toggleable-list-test-tags')).not.toBeInTheDocument() + expect(screen.queryByTestId('toggleable-list-test-labels')).not.toBeInTheDocument() + }) + + it('should use entityKey correctly in toggleable list identifiers', () => { + render() + expect(screen.getByTestId('toggleable-list-custom-key-tags')).toBeInTheDocument() + }) +}) diff --git a/frontend/__tests__/unit/pages/ChapterDetails.test.tsx b/frontend/__tests__/unit/pages/ChapterDetails.test.tsx index efc7f66115..9b0f1dcdee 100644 --- a/frontend/__tests__/unit/pages/ChapterDetails.test.tsx +++ b/frontend/__tests__/unit/pages/ChapterDetails.test.tsx @@ -203,4 +203,82 @@ describe('chapterDetailsPage Component', () => { expect(screen.getByText('OWASP Test Chapter')).toBeInTheDocument() }) }) + + test('renders contributions section when contributionStats total > 0', async () => { + const chapterDataWithStats = { + ...mockChapterDetailsData, + chapter: { + ...mockChapterDetailsData.chapter, + contributionStats: { + commits: 5, + pullRequests: 3, + issues: 2, + releases: 1, + total: 11, + }, + contributionData: undefined, + }, + } + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: chapterDataWithStats, + error: null, + }) + render() + + await waitFor(() => { + expect(screen.getByText('OWASP Test Chapter')).toBeInTheDocument() + }) + expect(screen.getByText('Chapter Contribution Activity')).toBeInTheDocument() + }) + + test('does not render contributions section when contributionStats total is 0 and contributionData is empty', async () => { + const chapterDataWithZeroStats = { + ...mockChapterDetailsData, + chapter: { + ...mockChapterDetailsData.chapter, + contributionStats: { + commits: 0, + pullRequests: 0, + issues: 0, + releases: 0, + total: 0, + }, + contributionData: {}, + }, + } + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: chapterDataWithZeroStats, + error: null, + }) + render() + + await waitFor(() => { + expect(screen.getByText('OWASP Test Chapter')).toBeInTheDocument() + }) + expect(screen.queryByText('Chapter Contribution Activity')).not.toBeInTheDocument() + }) + + test('renders contributions section when contributionData is non-empty', async () => { + const chapterDataWithContributionData = { + ...mockChapterDetailsData, + chapter: { + ...mockChapterDetailsData.chapter, + contributionStats: undefined, + contributionData: { + '2024-01': 10, + '2024-02': 20, + }, + }, + } + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: chapterDataWithContributionData, + error: null, + }) + render() + + await waitFor(() => { + expect(screen.getByText('OWASP Test Chapter')).toBeInTheDocument() + }) + expect(screen.getByText('Chapter Contribution Activity')).toBeInTheDocument() + }) }) diff --git a/frontend/__tests__/unit/pages/ModuleDetails.test.tsx b/frontend/__tests__/unit/pages/ModuleDetails.test.tsx index db4eea7b29..b295bc7ce5 100644 --- a/frontend/__tests__/unit/pages/ModuleDetails.test.tsx +++ b/frontend/__tests__/unit/pages/ModuleDetails.test.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@apollo/client/react' import { mockModuleData } from '@mockData/mockModuleData' import { screen, waitFor } from '@testing-library/react' import { useParams } from 'next/navigation' +import React from 'react' import { render } from 'wrappers/testUtil' import { handleAppError } from 'app/global-error' import ModuleDetailsPage from 'app/mentorship/programs/[programKey]/modules/[moduleKey]/page' @@ -22,12 +23,47 @@ jest.mock('app/global-error', () => ({ jest.mock('components/LoadingSpinner', () => () =>
LoadingSpinner
) -jest.mock('components/CardDetailsPage', () => (props) => ( -
-
{props.title}
-
{props.summary}
-
-)) +jest.mock('components/CardDetailsPage/CardDetailsHeader', () => { + return function MockHeader(props: { title: string }) { + return
{props.title}
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsSummary', () => { + return function MockSummary(props: { summary: string }) { + return
{props.summary}
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsPageWrapper', () => { + return function MockWrapper({ children }: { children: React.ReactNode }) { + return <>{children} + } +}) + +jest.mock('components/CardDetailsPage/CardDetailsMetadata', () => { + return function MockMetadata() { + return
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsTags', () => { + return function MockTags() { + return
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsContributors', () => { + return function MockContributors() { + return
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsIssuesMilestones', () => { + return function MockIssuesMilestones() { + return
+ } +}) describe('ModuleDetailsPage', () => { const mockUseParams = useParams as jest.Mock @@ -114,8 +150,8 @@ describe('ModuleDetailsPage', () => { render() - expect(await screen.findByTestId('details-card')).toHaveTextContent('Intro to Web') - expect(screen.getByTestId('details-card')).toHaveTextContent('A beginner friendly module.') + expect(await screen.findByTestId('header')).toHaveTextContent('Intro to Web') + expect(screen.getByTestId('summary')).toHaveTextContent('A beginner friendly module.') }) it('renders module without admins (uses undefined fallback)', async () => { @@ -131,7 +167,7 @@ describe('ModuleDetailsPage', () => { render() - expect(await screen.findByTestId('details-card')).toHaveTextContent('Intro to Web') + expect(await screen.findByTestId('header')).toHaveTextContent('Intro to Web') }) it('renders module without domains (uses undefined fallback)', async () => { @@ -147,7 +183,7 @@ describe('ModuleDetailsPage', () => { render() - expect(await screen.findByTestId('details-card')).toHaveTextContent('Intro to Web') + expect(await screen.findByTestId('header')).toHaveTextContent('Intro to Web') }) it('renders module without tags (uses undefined fallback)', async () => { @@ -163,6 +199,6 @@ describe('ModuleDetailsPage', () => { render() - expect(await screen.findByTestId('details-card')).toHaveTextContent('Intro to Web') + expect(await screen.findByTestId('header')).toHaveTextContent('Intro to Web') }) }) diff --git a/frontend/__tests__/unit/pages/ModuleDetailsPage.test.tsx b/frontend/__tests__/unit/pages/ModuleDetailsPage.test.tsx index e639d01953..4ae1d85acb 100644 --- a/frontend/__tests__/unit/pages/ModuleDetailsPage.test.tsx +++ b/frontend/__tests__/unit/pages/ModuleDetailsPage.test.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@apollo/client/react' import { mockModuleData } from '@mockData/mockModuleData' import { screen, waitFor } from '@testing-library/react' import { useParams } from 'next/navigation' +import React from 'react' import { render } from 'wrappers/testUtil' import { handleAppError } from 'app/global-error' import ModuleDetailsPage from 'app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page' @@ -22,15 +23,53 @@ jest.mock('app/global-error', () => ({ jest.mock('components/LoadingSpinner', () => () =>
LoadingSpinner
) -jest.mock('components/CardDetailsPage', () => (props) => ( -
-
{props.title}
-
{props.summary}
- {props.onLoadMorePullRequests && ( - - )} -
-)) +jest.mock('components/CardDetailsPage/CardDetailsHeader', () => { + return function MockHeader(props: { title: string }) { + return
{props.title}
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsSummary', () => { + return function MockSummary(props: { summary: string }) { + return
{props.summary}
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsPageWrapper', () => { + return function MockWrapper({ children }: { children: React.ReactNode }) { + return <>{children} + } +}) + +jest.mock('components/CardDetailsPage/CardDetailsMetadata', () => { + return function MockMetadata() { + return
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsTags', () => { + return function MockTags() { + return
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsContributors', () => { + return function MockContributors() { + return
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsIssuesMilestones', () => { + return function MockIssuesMilestones(props: { onLoadMorePullRequests?: () => void }) { + return ( +
+ {props.onLoadMorePullRequests && ( + + )} +
+ ) + } +}) describe('ModuleDetailsPage', () => { const mockUseParams = useParams as jest.Mock @@ -80,8 +119,8 @@ describe('ModuleDetailsPage', () => { render() - expect(await screen.findByTestId('details-card')).toHaveTextContent('Intro to Web') - expect(screen.getByTestId('details-card')).toHaveTextContent('A beginner friendly module.') + expect(await screen.findByTestId('header')).toHaveTextContent('Intro to Web') + expect(screen.getByTestId('summary')).toHaveTextContent('A beginner friendly module.') }) it('renders module without admins (uses undefined fallback)', async () => { @@ -97,7 +136,7 @@ describe('ModuleDetailsPage', () => { render() - expect(await screen.findByTestId('details-card')).toHaveTextContent('Intro to Web') + expect(await screen.findByTestId('header')).toHaveTextContent('Intro to Web') }) it('renders module without domains (uses undefined fallback)', async () => { @@ -113,7 +152,7 @@ describe('ModuleDetailsPage', () => { render() - expect(await screen.findByTestId('details-card')).toHaveTextContent('Intro to Web') + expect(await screen.findByTestId('header')).toHaveTextContent('Intro to Web') }) it('renders module without tags (uses undefined fallback)', async () => { @@ -129,6 +168,95 @@ describe('ModuleDetailsPage', () => { render() - expect(await screen.findByTestId('details-card')).toHaveTextContent('Intro to Web') + expect(await screen.findByTestId('header')).toHaveTextContent('Intro to Web') + }) + + it('renders module without mentors (uses undefined fallback for lines 79 & 98)', async () => { + mockUseQuery.mockReturnValue({ + loading: false, + data: { + getModule: { ...mockModuleData, mentors: null }, + getProgram: { admins }, + }, + }) + + render() + + expect(await screen.findByTestId('header')).toHaveTextContent('Intro to Web') + }) + + it('renders module without mentees (uses undefined fallback for line 99)', async () => { + mockUseQuery.mockReturnValue({ + loading: false, + data: { + getModule: { ...mockModuleData, mentees: null }, + getProgram: { admins }, + }, + }) + + render() + + expect(await screen.findByTestId('header')).toHaveTextContent('Intro to Web') + }) + + it('renders module without labels (uses undefined fallback)', async () => { + mockUseQuery.mockReturnValue({ + loading: false, + data: { + getModule: { ...mockModuleData, labels: null }, + getProgram: { admins }, + }, + }) + + render() + + expect(await screen.findByTestId('header')).toHaveTextContent('Intro to Web') + }) + + it('shows "Module Not Found" when getModule returns null', async () => { + mockUseQuery.mockReturnValue({ + loading: false, + data: { getModule: null, getProgram: { admins } }, + }) + + render() + + await waitFor(() => { + expect(screen.getByText('Module Not Found')).toBeInTheDocument() + }) + }) + + it('shows "Module Not Found" when data is undefined', async () => { + mockUseQuery.mockReturnValue({ + loading: false, + data: undefined, + }) + + render() + + await waitFor(() => { + expect(screen.getByText('Module Not Found')).toBeInTheDocument() + }) + }) + + it('shows loading spinner when isLoading true and no module cached', () => { + mockUseQuery.mockReturnValue({ loading: true, data: undefined }) + + const { container } = render() + expect(container.innerHTML).toContain('LoadingSpinner') + }) + + it('does not show loading spinner when isLoading true but module is already cached', async () => { + mockUseQuery.mockReturnValue({ + loading: true, + data: { + getModule: mockModuleData, + getProgram: { admins }, + }, + }) + + render() + + expect(await screen.findByTestId('header')).toHaveTextContent('Intro to Web') }) }) diff --git a/frontend/__tests__/unit/pages/ProgramDetailsMentorship.test.tsx b/frontend/__tests__/unit/pages/ProgramDetailsMentorship.test.tsx index 50802a8214..7b2cc8fb53 100644 --- a/frontend/__tests__/unit/pages/ProgramDetailsMentorship.test.tsx +++ b/frontend/__tests__/unit/pages/ProgramDetailsMentorship.test.tsx @@ -3,6 +3,7 @@ import { addToast } from '@heroui/toast' import mockProgramDetailsData from '@mockData/mockProgramData' import { screen, waitFor, fireEvent } from '@testing-library/react' import { useSession } from 'next-auth/react' +import React from 'react' import { render } from 'wrappers/testUtil' import { handleAppError } from 'app/global-error' import ProgramDetailsPage from 'app/my/mentorship/programs/[programKey]/page' @@ -11,21 +12,16 @@ import { ProgramStatusEnum } from 'types/__generated__/graphql' let capturedSetStatus: ((status: string) => void) | null = null -jest.mock('components/CardDetailsPage', () => { - return jest.fn((props) => { - capturedSetStatus = props.setStatus +jest.mock('components/CardDetailsPage/CardDetailsHeader', () => { + return function MockHeader(props: { + title: string + canUpdateStatus?: boolean + setStatus?: (status: string) => void + }) { + capturedSetStatus = props.setStatus || null return (

{props.title}

-

{props.summary}

-
- {props.details?.map((detail: { label: string; value: string }) => ( -
- {detail.label} - {detail.value} -
- ))} -
{props.canUpdateStatus && (
@@ -36,7 +32,46 @@ jest.mock('components/CardDetailsPage', () => { )}
) - }) + } +}) + +jest.mock('components/CardDetailsPage/CardDetailsSummary', () => { + return function MockSummary(props: { summary: string }) { + return

{props.summary}

+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsMetadata', () => { + return function MockMetadata(props: { details?: Array<{ label: string; value: string }> }) { + return ( +
+ {props.details?.map((detail: { label: string; value: string }) => ( +
+ {detail.label} + {detail.value} +
+ ))} +
+ ) + } +}) + +jest.mock('components/CardDetailsPage/CardDetailsPageWrapper', () => { + return function MockWrapper({ children }: { children: React.ReactNode }) { + return
{children}
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsRepositoriesModules', () => { + return function MockReposModules() { + return
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsTags', () => { + return function MockTags() { + return
+ } }) jest.mock('@heroui/toast', () => ({ diff --git a/frontend/__tests__/unit/pages/ProjectDetails.test.tsx b/frontend/__tests__/unit/pages/ProjectDetails.test.tsx index 7d0d18fe07..4afd72e511 100644 --- a/frontend/__tests__/unit/pages/ProjectDetails.test.tsx +++ b/frontend/__tests__/unit/pages/ProjectDetails.test.tsx @@ -302,4 +302,172 @@ describe('ProjectDetailsPage', () => { expect(screen.getByText('Project Leader')).toBeInTheDocument() }) }) + + test('renders contribution activity when contributionStats with total > 0 is provided', async () => { + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: { + ...mockProjectDetailsData, + project: { + ...mockProjectDetailsData.project, + contributionStats: { + commits: 50, + pullRequests: 20, + issues: 10, + releases: 5, + total: 85, + }, + contributionData: undefined, + }, + }, + error: null, + loading: false, + }) + + render() + + await waitFor(() => { + expect(screen.getByText('Test Project')).toBeInTheDocument() + }) + }) + + test('renders contribution activity when only contributionData is provided', async () => { + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: { + ...mockProjectDetailsData, + project: { + ...mockProjectDetailsData.project, + contributionStats: undefined, + contributionData: { + '2024-01': 10, + '2024-02': 20, + }, + }, + }, + error: null, + loading: false, + }) + + render() + + await waitFor(() => { + expect(screen.getByText('Test Project')).toBeInTheDocument() + }) + }) + + test('does not render contribution activity when contributionStats total is 0 and no contributionData', async () => { + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: { + ...mockProjectDetailsData, + project: { + ...mockProjectDetailsData.project, + contributionStats: { + commits: 0, + pullRequests: 0, + issues: 0, + releases: 0, + total: 0, + }, + contributionData: {}, + }, + }, + error: null, + loading: false, + }) + + render() + + await waitFor(() => { + expect(screen.getByText('Test Project')).toBeInTheDocument() + }) + }) + + test('does not render health metrics when healthMetricsList is empty', async () => { + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: { + ...mockProjectDetailsData, + project: { + ...mockProjectDetailsData.project, + healthMetricsList: [], + }, + }, + error: null, + loading: false, + }) + + render() + + await waitFor(() => { + expect(screen.getByText('Test Project')).toBeInTheDocument() + expect(screen.queryByText(/Issues Trend/)).not.toBeInTheDocument() + }) + }) + + test('does not render health metrics when healthMetricsList is null', async () => { + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: { + ...mockProjectDetailsData, + project: { + ...mockProjectDetailsData.project, + healthMetricsList: null, + }, + }, + error: null, + loading: false, + }) + + render() + + await waitFor(() => { + expect(screen.getByText('Test Project')).toBeInTheDocument() + expect(screen.queryByText(/Issues Trend/)).not.toBeInTheDocument() + }) + }) + + test('renders project URL as a link', async () => { + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: mockProjectDetailsData, + error: null, + loading: false, + }) + + render() + + await waitFor(() => { + const urlLink = screen.getByRole('link', { name: mockProjectDetailsData.project.url }) + expect(urlLink).toBeInTheDocument() + expect(urlLink).toHaveAttribute('href', mockProjectDetailsData.project.url) + }) + }) + + test('renders tags section with languages and topics', async () => { + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: mockProjectDetailsData, + error: null, + loading: false, + }) + + render() + + await waitFor(() => { + expect(screen.getByText('Python')).toBeInTheDocument() + expect(screen.getByText('graphql')).toBeInTheDocument() + }) + }) + + test('renders repositories section correctly', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Repo One')).toBeInTheDocument() + expect(screen.getByText('Repo Two')).toBeInTheDocument() + }) + }) + + test('renders recent releases section correctly', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('v1.2.0')).toBeInTheDocument() + }) + }) }) diff --git a/frontend/__tests__/unit/pages/PublicMentorshipModulePage.test.tsx b/frontend/__tests__/unit/pages/PublicMentorshipModulePage.test.tsx index 8fe7518732..b836c53318 100644 --- a/frontend/__tests__/unit/pages/PublicMentorshipModulePage.test.tsx +++ b/frontend/__tests__/unit/pages/PublicMentorshipModulePage.test.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@apollo/client/react' import { fireEvent, screen, waitFor } from '@testing-library/react' import { useParams } from 'next/navigation' +import React from 'react' import { render } from 'wrappers/testUtil' import { handleAppError } from 'app/global-error' import PublicMentorshipModulePage from 'app/mentorship/programs/[programKey]/modules/[moduleKey]/page' @@ -26,19 +27,49 @@ jest.mock('app/global-error', () => ({ jest.mock('components/LoadingSpinner', () => () =>
LoadingSpinner
) -jest.mock('components/CardDetailsPage', () => ({ - __esModule: true, - default: function MockDetailsCard(props: { - title?: string - summary?: string +jest.mock('components/CardDetailsPage/CardDetailsHeader', () => { + return function MockHeader(props: { title: string }) { + return
{props.title}
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsSummary', () => { + return function MockSummary(props: { summary: string }) { + return
{props.summary}
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsPageWrapper', () => { + return function MockWrapper({ children }: { children: React.ReactNode }) { + return
{children}
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsMetadata', () => { + return function MockMetadata() { + return
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsTags', () => { + return function MockTags() { + return
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsContributors', () => { + return function MockContributors() { + return
+ } +}) + +jest.mock('components/CardDetailsPage/CardDetailsIssuesMilestones', () => { + return function MockIssuesMilestones(props: { onLoadMorePullRequests?: () => void onResetPullRequests?: () => void - isFetchingMore?: boolean }) { return ( -
- {props.title} - {props.summary} +
{props.onLoadMorePullRequests && (
) - }, -})) + } +}) const createPullRequest = (index: number) => ({ id: `pr-${index}`, @@ -151,8 +182,8 @@ describe('PublicMentorshipModulePage', () => { }) render() - expect(screen.getByTestId('details-card')).toHaveTextContent('Intro to Web') - expect(screen.getByTestId('details-card')).toHaveTextContent('A public module.') + expect(screen.getByTestId('wrapper')).toHaveTextContent('Intro to Web') + expect(screen.getByTestId('wrapper')).toHaveTextContent('A public module.') }) it('omits load-more handler when there are no extra pull requests to page', async () => { @@ -441,4 +472,215 @@ describe('PublicMentorshipModulePage', () => { expect(mockFetchMore).toHaveBeenCalled() }) }) + + it('renders with null mentors, mentees, tags, and domains (covers ?? undefined fallbacks)', () => { + mockUseQuery.mockReturnValue({ + loading: false, + data: { + getModule: { + ...buildModuleData(2), + mentors: null, + mentees: null, + tags: null, + domains: null, + }, + getProgram: { admins: null }, + }, + error: undefined, + fetchMore: mockFetchMore, + }) + + render() + expect(screen.getByTestId('wrapper')).toBeInTheDocument() + }) + + it('shows load-more button when local PRs exceed visibleCount even when hasMorePRs is false', async () => { + mockUseQuery.mockReturnValue({ + loading: false, + data: { + getModule: buildModuleData(4), + getProgram: { admins: [] }, + }, + error: undefined, + fetchMore: mockFetchMore, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('pr-show-more')).toBeInTheDocument() + }) + }) + + it('only increments visibleCount (no fetchMore) when hasMorePRs is false but local PRs exceed visibleCount', async () => { + const initial = { ...buildModuleData(5) } + mockFetchMore.mockImplementation( + ({ updateQuery }: { updateQuery: (p: unknown, o: unknown) => unknown }) => { + updateQuery( + { getModule: initial, getProgram: { admins: [] } }, + { fetchMoreResult: { getModule: { recentPullRequests: [] } } } + ) + return Promise.resolve() + } + ) + + mockUseQuery.mockReturnValue({ + loading: false, + data: { getModule: initial, getProgram: { admins: [] } }, + error: undefined, + fetchMore: mockFetchMore, + }) + + render() + fireEvent.click(screen.getByTestId('pr-show-more')) + + await waitFor(() => expect(mockFetchMore).toHaveBeenCalledTimes(1)) + await waitFor(() => { + expect(screen.queryByTestId('pr-show-more')).not.toBeInTheDocument() + }) + }) + + it('merges fetchMore results when prevResult has no existing pull requests', async () => { + const initial = buildModuleData(5) + mockFetchMore.mockImplementation( + ({ updateQuery }: { updateQuery: (p: unknown, o: unknown) => unknown }) => { + const merged = updateQuery( + { + getModule: { ...initial, recentPullRequests: null }, + getProgram: { admins: [] }, + }, + { + fetchMoreResult: { + getModule: { + recentPullRequests: [createPullRequest(10), createPullRequest(11)], + }, + }, + } + ) + return Promise.resolve(merged) + } + ) + + mockUseQuery.mockReturnValue({ + loading: false, + data: { getModule: initial, getProgram: { admins: [] } }, + error: undefined, + fetchMore: mockFetchMore, + }) + + render() + fireEvent.click(screen.getByTestId('pr-show-more')) + + await waitFor(() => { + expect(mockFetchMore).toHaveBeenCalled() + }) + }) + + it('shows no load-more button when hasMorePRs is false and local PRs do not exceed visibleCount', async () => { + mockUseQuery.mockReturnValue({ + loading: false, + data: { + getModule: buildModuleData(2), + getProgram: { admins: [] }, + }, + error: undefined, + fetchMore: mockFetchMore, + }) + + render() + + await waitFor(() => { + expect(screen.queryByTestId('pr-show-more')).not.toBeInTheDocument() + }) + }) + + it('uses || [] fallback on lines 120 and 163 when recentPullRequests is null', async () => { + mockFetchMore.mockResolvedValue({}) + mockUseQuery.mockReturnValue({ + loading: false, + data: { + getModule: { ...buildModuleData(0), recentPullRequests: null }, + getProgram: { admins: [] }, + }, + error: undefined, + fetchMore: mockFetchMore, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('pr-show-more')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('pr-show-more')) + + await waitFor(() => { + expect(mockFetchMore).toHaveBeenCalled() + }) + }) + + it('increments visibleCount without calling fetchMore when hasMorePRs is false and handler is clicked', async () => { + const initial = buildModuleData(8) + + mockFetchMore.mockImplementation( + ({ updateQuery }: { updateQuery: (p: unknown, o: unknown) => unknown }) => { + updateQuery( + { getModule: initial, getProgram: { admins: [] } }, + { fetchMoreResult: { getModule: { recentPullRequests: [] } } } + ) + return Promise.resolve() + } + ) + + mockUseQuery.mockReturnValue({ + loading: false, + data: { getModule: initial, getProgram: { admins: [] } }, + error: undefined, + fetchMore: mockFetchMore, + }) + + render() + + fireEvent.click(screen.getByTestId('pr-show-more')) + + await waitFor(() => { + expect(mockFetchMore).not.toHaveBeenCalled() + }) + }) + + it('sets hasMorePRs to false when newPRs returned is less than limit (line 141)', async () => { + const initial = buildModuleData(5) + mockFetchMore.mockImplementation( + ({ updateQuery }: { updateQuery: (p: unknown, o: unknown) => unknown }) => { + updateQuery( + { getModule: initial, getProgram: { admins: [] } }, + { + fetchMoreResult: { + getModule: { + recentPullRequests: [createPullRequest(20), createPullRequest(21)], + }, + }, + } + ) + return Promise.resolve() + } + ) + + mockUseQuery.mockReturnValue({ + loading: false, + data: { getModule: initial, getProgram: { admins: [] } }, + error: undefined, + fetchMore: mockFetchMore, + }) + + render() + fireEvent.click(screen.getByTestId('pr-show-more')) + + await waitFor(() => { + expect(mockFetchMore).toHaveBeenCalled() + }) + await waitFor(() => { + expect(screen.queryByTestId('pr-show-more')).not.toBeInTheDocument() + }) + }) }) diff --git a/frontend/src/app/chapters/[chapterKey]/page.tsx b/frontend/src/app/chapters/[chapterKey]/page.tsx index 663ea1311a..51a47a537a 100644 --- a/frontend/src/app/chapters/[chapterKey]/page.tsx +++ b/frontend/src/app/chapters/[chapterKey]/page.tsx @@ -8,8 +8,16 @@ import { GetChapterDataDocument } from 'types/__generated__/chapterQueries.gener import type { Chapter } from 'types/chapter' import { getContributionStats } from 'utils/contributionDataUtils' import { formatDate, getDateRange } from 'utils/dateFormatter' -import DetailsCard from 'components/CardDetailsPage' +import CardDetailsContributions from 'components/CardDetailsPage/CardDetailsContributions' +import CardDetailsContributors from 'components/CardDetailsPage/CardDetailsContributors' +import CardDetailsHeader from 'components/CardDetailsPage/CardDetailsHeader' +import CardDetailsLeaders from 'components/CardDetailsPage/CardDetailsLeaders' +import CardDetailsMetadata from 'components/CardDetailsPage/CardDetailsMetadata' +import CardDetailsPageWrapper from 'components/CardDetailsPage/CardDetailsPageWrapper' +import CardDetailsSummary from 'components/CardDetailsPage/CardDetailsSummary' +import CardDetailsTags from 'components/CardDetailsPage/CardDetailsTags' import LoadingSpinner from 'components/LoadingSpinner' +import SponsorCard from 'components/SponsorCard' export default function ChapterDetailsPage() { const { chapterKey } = useParams<{ chapterKey: string }>() @@ -77,21 +85,43 @@ export default function ChapterDetailsPage() { ) return ( - + + + + + + + + + + + + 0) || + (chapter.contributionData && Object.keys(chapter.contributionData).length > 0) + ) + } + contributionStats={contributionStats} + contributionData={chapter.contributionData} + startDate={startDate} + endDate={endDate} + title="Chapter Contribution Activity" + /> + + + + {chapter.key && chapter.name && ( + + )} + ) } diff --git a/frontend/src/app/committees/[committeeKey]/page.tsx b/frontend/src/app/committees/[committeeKey]/page.tsx index ec937f14ef..dc44c49d9e 100644 --- a/frontend/src/app/committees/[committeeKey]/page.tsx +++ b/frontend/src/app/committees/[committeeKey]/page.tsx @@ -9,7 +9,11 @@ import { HiUserGroup } from 'react-icons/hi' import { ErrorDisplay, handleAppError } from 'app/global-error' import { GetCommitteeDataDocument } from 'types/__generated__/committeeQueries.generated' import { formatDate } from 'utils/dateFormatter' -import DetailsCard from 'components/CardDetailsPage' +import CardDetailsContributors from 'components/CardDetailsPage/CardDetailsContributors' +import CardDetailsHeader from 'components/CardDetailsPage/CardDetailsHeader' +import CardDetailsMetadata from 'components/CardDetailsPage/CardDetailsMetadata' +import CardDetailsPageWrapper from 'components/CardDetailsPage/CardDetailsPageWrapper' +import CardDetailsSummary from 'components/CardDetailsPage/CardDetailsSummary' import LoadingSpinner from 'components/LoadingSpinner' export default function CommitteeDetailsPage() { @@ -83,14 +87,20 @@ export default function CommitteeDetailsPage() { ] return ( - + + + + + + + + + ) } diff --git a/frontend/src/app/members/[memberKey]/page.tsx b/frontend/src/app/members/[memberKey]/page.tsx index dabcbeca37..178274cd99 100644 --- a/frontend/src/app/members/[memberKey]/page.tsx +++ b/frontend/src/app/members/[memberKey]/page.tsx @@ -12,7 +12,14 @@ import { Badge } from 'types/badge' import { User } from 'types/user' import { formatDate } from 'utils/dateFormatter' import Badges from 'components/Badges' -import DetailsCard from 'components/CardDetailsPage' +import CardDetailsContributions from 'components/CardDetailsPage/CardDetailsContributions' +import CardDetailsContributors from 'components/CardDetailsPage/CardDetailsContributors' +import CardDetailsHeader from 'components/CardDetailsPage/CardDetailsHeader' +import CardDetailsIssuesMilestones from 'components/CardDetailsPage/CardDetailsIssuesMilestones' +import CardDetailsMetadata from 'components/CardDetailsPage/CardDetailsMetadata' +import CardDetailsPageWrapper from 'components/CardDetailsPage/CardDetailsPageWrapper' +import CardDetailsRepositoriesModules from 'components/CardDetailsPage/CardDetailsRepositoriesModules' +import CardDetailsSummary from 'components/CardDetailsPage/CardDetailsSummary' import ContributionHeatmap from 'components/ContributionHeatmap' import MemberDetailsPageSkeleton from 'components/skeletons/MemberDetailsPageSkeleton' @@ -194,27 +201,37 @@ const UserDetailsPage: React.FC = () => { ] return ( - - } - /> + + + + + } + /> + + + + + + + + + + + ) } diff --git a/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx b/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx index bb61608ba6..ac960340a7 100644 --- a/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx +++ b/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx @@ -10,7 +10,13 @@ import { GetProgramAdminsAndModulesDocument } from 'types/__generated__/moduleQu import { Module } from 'types/mentorship' import type { PullRequest } from 'types/pullRequest' import { formatDate } from 'utils/dateFormatter' -import DetailsCard from 'components/CardDetailsPage' +import CardDetailsContributors from 'components/CardDetailsPage/CardDetailsContributors' +import CardDetailsHeader from 'components/CardDetailsPage/CardDetailsHeader' +import CardDetailsIssuesMilestones from 'components/CardDetailsPage/CardDetailsIssuesMilestones' +import CardDetailsMetadata from 'components/CardDetailsPage/CardDetailsMetadata' +import CardDetailsPageWrapper from 'components/CardDetailsPage/CardDetailsPageWrapper' +import CardDetailsSummary from 'components/CardDetailsPage/CardDetailsSummary' +import CardDetailsTags from 'components/CardDetailsPage/CardDetailsTags' import LoadingSpinner from 'components/LoadingSpinner' import { getSimpleDuration } from 'components/ModuleCard' @@ -87,64 +93,83 @@ const ModuleDetailsPage = () => { return ( - visibleCount - ? () => { - if (isFetchingMore) return - const currentLength = programModule.recentPullRequests?.length || 0 - if (hasMorePRs && currentLength < visibleCount + limit) { - setIsFetchingMore(true) - fetchMore({ - variables: { - programKey, - moduleKey, - offset: currentLength, - limit, - }, - updateQuery: (prevResult, { fetchMoreResult }) => { - if (!fetchMoreResult) return prevResult - const newPRs = fetchMoreResult.getModule?.recentPullRequests || [] - if (newPRs.length < limit) setHasMorePRs(false) - if (newPRs.length === 0) return prevResult - return { - ...prevResult, - getModule: { - ...prevResult.getModule!, - recentPullRequests: [ - ...(prevResult.getModule?.recentPullRequests || []), - ...newPRs, - ], - }, - } - }, - }) - .catch((error) => handleAppError(error)) - .finally(() => setIsFetchingMore(false)) + + + + + + + + + + + + visibleCount + ? () => { + if (isFetchingMore) return + const currentLength = programModule.recentPullRequests?.length || 0 + if (hasMorePRs && currentLength < visibleCount + limit) { + setIsFetchingMore(true) + fetchMore({ + variables: { + programKey, + moduleKey, + offset: currentLength, + limit, + }, + updateQuery: (prevResult, { fetchMoreResult }) => { + if (!fetchMoreResult) return prevResult + const newPRs = fetchMoreResult.getModule?.recentPullRequests || [] + if (newPRs.length < limit) setHasMorePRs(false) + if (newPRs.length === 0) return prevResult + return { + ...prevResult, + getModule: { + ...prevResult.getModule!, + recentPullRequests: [ + ...(prevResult.getModule?.recentPullRequests || []), + ...newPRs, + ], + }, + } + }, + }) + .catch((error) => handleAppError(error)) + .finally(() => setIsFetchingMore(false)) + } + setVisibleCount((prev) => prev + limit) } - setVisibleCount((prev) => prev + limit) - } - : undefined - } - onResetPullRequests={ - visibleCount > limit && (programModule.recentPullRequests || []).length > limit - ? () => setVisibleCount(limit) - : undefined - } - /> + : undefined + } + onResetPullRequests={ + visibleCount > limit && (programModule.recentPullRequests || []).length > limit + ? () => setVisibleCount(limit) + : undefined + } + isFetchingMore={isFetchingMore} + /> + ) } diff --git a/frontend/src/app/mentorship/programs/[programKey]/page.tsx b/frontend/src/app/mentorship/programs/[programKey]/page.tsx index 113192332b..ede38a73a0 100644 --- a/frontend/src/app/mentorship/programs/[programKey]/page.tsx +++ b/frontend/src/app/mentorship/programs/[programKey]/page.tsx @@ -9,7 +9,12 @@ import { GetProgramAndModulesDocument } from 'types/__generated__/programsQuerie import { titleCaseWord } from 'utils/capitalize' import { formatDate } from 'utils/dateFormatter' -import DetailsCard from 'components/CardDetailsPage' +import CardDetailsHeader from 'components/CardDetailsPage/CardDetailsHeader' +import CardDetailsMetadata from 'components/CardDetailsPage/CardDetailsMetadata' +import CardDetailsPageWrapper from 'components/CardDetailsPage/CardDetailsPageWrapper' +import CardDetailsRepositoriesModules from 'components/CardDetailsPage/CardDetailsRepositoriesModules' +import CardDetailsSummary from 'components/CardDetailsPage/CardDetailsSummary' +import CardDetailsTags from 'components/CardDetailsPage/CardDetailsTags' import LoadingSpinner from 'components/LoadingSpinner' const ProgramDetailsPage = () => { @@ -68,20 +73,23 @@ const ProgramDetailsPage = () => { return ( - + + + + + + + + + + + ) } diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx index c8c513195e..eb390450e6 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx @@ -8,7 +8,12 @@ import { ErrorDisplay, handleAppError } from 'app/global-error' import { GetProgramAdminsAndModulesDocument } from 'types/__generated__/moduleQueries.generated' import { Module } from 'types/mentorship' import { formatDate } from 'utils/dateFormatter' -import DetailsCard from 'components/CardDetailsPage' +import CardDetailsContributors from 'components/CardDetailsPage/CardDetailsContributors' +import CardDetailsHeader from 'components/CardDetailsPage/CardDetailsHeader' +import CardDetailsMetadata from 'components/CardDetailsPage/CardDetailsMetadata' +import CardDetailsPageWrapper from 'components/CardDetailsPage/CardDetailsPageWrapper' +import CardDetailsSummary from 'components/CardDetailsPage/CardDetailsSummary' +import CardDetailsTags from 'components/CardDetailsPage/CardDetailsTags' import LoadingSpinner from 'components/LoadingSpinner' import { getSimpleDuration } from 'components/ModuleCard' @@ -63,21 +68,39 @@ const ModuleDetailsPage = () => { return ( - + + + + + + + + + + + ) } diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx index bd2330609a..b19eb4c1d6 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx @@ -13,7 +13,12 @@ import { GetProgramAndModulesDocument } from 'types/__generated__/programsQuerie import type { ExtendedSession } from 'types/auth' import { titleCaseWord } from 'utils/capitalize' import { formatDate } from 'utils/dateFormatter' -import DetailsCard from 'components/CardDetailsPage' +import CardDetailsHeader from 'components/CardDetailsPage/CardDetailsHeader' +import CardDetailsMetadata from 'components/CardDetailsPage/CardDetailsMetadata' +import CardDetailsPageWrapper from 'components/CardDetailsPage/CardDetailsPageWrapper' +import CardDetailsRepositoriesModules from 'components/CardDetailsPage/CardDetailsRepositoriesModules' +import CardDetailsSummary from 'components/CardDetailsPage/CardDetailsSummary' +import CardDetailsTags from 'components/CardDetailsPage/CardDetailsTags' import LoadingSpinner from 'components/LoadingSpinner' const ProgramDetailsPage = () => { @@ -118,21 +123,35 @@ const ProgramDetailsPage = () => { return ( - + + + + + + + + + + + ) } diff --git a/frontend/src/app/organizations/[organizationKey]/page.tsx b/frontend/src/app/organizations/[organizationKey]/page.tsx index 382314f9e0..a9dc324254 100644 --- a/frontend/src/app/organizations/[organizationKey]/page.tsx +++ b/frontend/src/app/organizations/[organizationKey]/page.tsx @@ -15,7 +15,13 @@ import type { RepositoryCardProps } from 'types/project' import type { PullRequest } from 'types/pullRequest' import type { Release } from 'types/release' import { formatDate } from 'utils/dateFormatter' -import DetailsCard from 'components/CardDetailsPage' +import CardDetailsContributors from 'components/CardDetailsPage/CardDetailsContributors' +import CardDetailsHeader from 'components/CardDetailsPage/CardDetailsHeader' +import CardDetailsIssuesMilestones from 'components/CardDetailsPage/CardDetailsIssuesMilestones' +import CardDetailsMetadata from 'components/CardDetailsPage/CardDetailsMetadata' +import CardDetailsPageWrapper from 'components/CardDetailsPage/CardDetailsPageWrapper' +import CardDetailsRepositoriesModules from 'components/CardDetailsPage/CardDetailsRepositoriesModules' +import CardDetailsSummary from 'components/CardDetailsPage/CardDetailsSummary' import OrganizationDetailsPageSkeleton from 'components/skeletons/OrganizationDetailsPageSkeleton' const OrganizationDetailsPage = () => { const { organizationKey } = useParams<{ organizationKey: string }>() @@ -114,29 +120,41 @@ const OrganizationDetailsPage = () => { ] return ( - ({ - ...release, - publishedAt: release.publishedAt as string, - })) as Release[] - } - recentMilestones={recentMilestones as Milestone[]} - repositories={ - repositories?.map((repo) => ({ - ...repo, - organization: repo.organization ? { login: repo.organization.login } : undefined, - })) as RepositoryCardProps[] - } - stats={organizationStats} - summary={organization.description} - title={organization.name} - topContributors={topContributors} - type="organization" - /> + + + + + + + + + + ({ + ...release, + publishedAt: release.publishedAt as string, + })) as Release[] + } + showAvatar={true} + /> + + ({ + ...repo, + organization: repo.organization ? { login: repo.organization.login } : undefined, + })) as RepositoryCardProps[] + } + /> + ) } diff --git a/frontend/src/app/organizations/[organizationKey]/repositories/[repositoryKey]/page.tsx b/frontend/src/app/organizations/[organizationKey]/repositories/[repositoryKey]/page.tsx index f1abaf8936..c3e179b211 100644 --- a/frontend/src/app/organizations/[organizationKey]/repositories/[repositoryKey]/page.tsx +++ b/frontend/src/app/organizations/[organizationKey]/repositories/[repositoryKey]/page.tsx @@ -9,8 +9,15 @@ import { HiUserGroup } from 'react-icons/hi' import { handleAppError, ErrorDisplay } from 'app/global-error' import { GetRepositoryDataDocument } from 'types/__generated__/repositoryQueries.generated' import { formatDate } from 'utils/dateFormatter' -import DetailsCard from 'components/CardDetailsPage' +import CardDetailsContributors from 'components/CardDetailsPage/CardDetailsContributors' +import CardDetailsHeader from 'components/CardDetailsPage/CardDetailsHeader' +import CardDetailsIssuesMilestones from 'components/CardDetailsPage/CardDetailsIssuesMilestones' +import CardDetailsMetadata from 'components/CardDetailsPage/CardDetailsMetadata' +import CardDetailsPageWrapper from 'components/CardDetailsPage/CardDetailsPageWrapper' +import CardDetailsSummary from 'components/CardDetailsPage/CardDetailsSummary' +import CardDetailsTags from 'components/CardDetailsPage/CardDetailsTags' import LoadingSpinner from 'components/LoadingSpinner' +import SponsorCard from 'components/SponsorCard' const RepositoryDetailsPage = () => { const { repositoryKey, organizationKey } = useParams<{ @@ -104,23 +111,42 @@ const RepositoryDetailsPage = () => { }, ] return ( - + + + + + + + + + + + + + + {repository.project?.key && repository.project?.name && ( + + )} + ) } export default RepositoryDetailsPage diff --git a/frontend/src/app/projects/[projectKey]/page.tsx b/frontend/src/app/projects/[projectKey]/page.tsx index 456b060f16..36cadb03b0 100644 --- a/frontend/src/app/projects/[projectKey]/page.tsx +++ b/frontend/src/app/projects/[projectKey]/page.tsx @@ -17,8 +17,20 @@ import type { PullRequest } from 'types/pullRequest' import type { Release } from 'types/release' import { getContributionStats } from 'utils/contributionDataUtils' import { formatDate, getDateRange } from 'utils/dateFormatter' -import DetailsCard from 'components/CardDetailsPage' +import { IS_PROJECT_HEALTH_ENABLED } from 'utils/env.client' +import CardDetailsContributions from 'components/CardDetailsPage/CardDetailsContributions' +import CardDetailsContributors from 'components/CardDetailsPage/CardDetailsContributors' +import CardDetailsHeader from 'components/CardDetailsPage/CardDetailsHeader' +import CardDetailsIssuesMilestones from 'components/CardDetailsPage/CardDetailsIssuesMilestones' +import CardDetailsLeaders from 'components/CardDetailsPage/CardDetailsLeaders' +import CardDetailsMetadata from 'components/CardDetailsPage/CardDetailsMetadata' +import CardDetailsPageWrapper from 'components/CardDetailsPage/CardDetailsPageWrapper' +import CardDetailsRepositoriesModules from 'components/CardDetailsPage/CardDetailsRepositoriesModules' +import CardDetailsSummary from 'components/CardDetailsPage/CardDetailsSummary' +import CardDetailsTags from 'components/CardDetailsPage/CardDetailsTags' +import HealthMetrics from 'components/HealthMetrics' import LoadingSpinner from 'components/LoadingSpinner' +import SponsorCard from 'components/SponsorCard' const ProjectDetailsPage = () => { const { projectKey } = useParams<{ projectKey: string }>() @@ -108,29 +120,64 @@ const ProjectDetailsPage = () => { ) return ( - + + + + + + + + + + + + 0) || + (project.contributionData && Object.keys(project.contributionData).length > 0) + ) + } + contributionStats={contributionStats} + contributionData={project.contributionData} + startDate={startDate} + endDate={endDate} + title="Project Contribution Activity" + /> + + + + + + + + {IS_PROJECT_HEALTH_ENABLED && + project.healthMetricsList && + project.healthMetricsList.length > 0 && ( + + )} + + {project.key && project.name && ( + + )} + ) } diff --git a/frontend/src/components/CardDetailsPage.tsx b/frontend/src/components/CardDetailsPage.tsx deleted file mode 100644 index 24a1d5d70c..0000000000 --- a/frontend/src/components/CardDetailsPage.tsx +++ /dev/null @@ -1,617 +0,0 @@ -import { Tooltip } from '@heroui/tooltip' -import upperFirst from 'lodash/upperFirst' -import Image from 'next/image' -import Link from 'next/link' -import { useSession } from 'next-auth/react' -import { useState } from 'react' -import { - FaCircleInfo, - FaChartPie, - FaFolderOpen, - FaCode, - FaTags, - FaRectangleList, - FaCalendar, - FaCircleCheck, - FaCircleExclamation, - FaSignsPost, - FaCodeBranch, - FaChevronDown, - FaChevronUp, -} from 'react-icons/fa6' -import { HiUserGroup } from 'react-icons/hi' -import type { ExtendedSession } from 'types/auth' -import type { DetailsCardProps } from 'types/card' -import { formatDate } from 'utils/dateFormatter' -import { IS_PROJECT_HEALTH_ENABLED } from 'utils/env.client' -import { scrollToAnchor } from 'utils/scrollToAnchor' -import { getMemberUrl, getMenteeUrl } from 'utils/urlFormatter' -import { getSocialIcon } from 'utils/urlIconMappings' -import AnchorTitle from 'components/AnchorTitle' -import ChapterMapWrapper from 'components/ChapterMapWrapper' -import ContributionHeatmap from 'components/ContributionHeatmap' -import ContributionStats from 'components/ContributionStats' -import ContributorsList from 'components/ContributorsList' -import EntityActions from 'components/EntityActions' -import HealthMetrics from 'components/HealthMetrics' -import InfoBlock from 'components/InfoBlock' -import Leaders from 'components/Leaders' -import LeadersList from 'components/LeadersList' -import Markdown from 'components/MarkdownWrapper' -import MentorshipPullRequest from 'components/MentorshipPullRequest' -import MetricsScoreCircle from 'components/MetricsScoreCircle' -import Milestones from 'components/Milestones' -import ModuleCard from 'components/ModuleCard' -import RecentIssues from 'components/RecentIssues' -import RecentPullRequests from 'components/RecentPullRequests' -import RecentReleases from 'components/RecentReleases' -import RepositoryCard from 'components/RepositoryCard' -import SecondaryCard from 'components/SecondaryCard' -import ShowMoreButton from 'components/ShowMoreButton' -import SponsorCard from 'components/SponsorCard' -import StatusBadge from 'components/StatusBadge' -import ToggleableList from 'components/ToggleableList' - -import { TruncatedText } from 'components/TruncatedText' - -export type CardType = - | 'chapter' - | 'committee' - | 'module' - | 'organization' - | 'program' - | 'project' - | 'repository' - | 'user' - -const showStatistics = (type: CardType): boolean => - ['committee', 'organization', 'project', 'repository', 'user'].includes(type) - -const showIssuesAndMilestones = (type: CardType): boolean => - ['organization', 'project', 'repository', 'user'].includes(type) - -const showPullRequestsAndReleases = (type: CardType): boolean => - ['organization', 'project', 'repository', 'user'].includes(type) - -const MILESTONE_LIMIT = 4 - -const DetailsCard = ({ - description, - details, - accessLevel, - contributionData, - contributionStats, - endDate, - startDate, - status, - setStatus, - canUpdateStatus, - tags, - domains, - entityLeaders, - labels, - modules, - mentors, - mentees, - admins, - entityKey, - geolocationData = [], - healthMetricsData, - isActive = true, - isArchived = false, - languages, - onLoadMorePullRequests, - onResetPullRequests, - isFetchingMore, - programKey, - projectName, - pullRequests, - recentIssues, - recentMilestones, - recentReleases, - repositories = [], - showAvatar = true, - socialLinks, - stats, - summary, - title, - topContributors, - topics, - type, - userSummary, -}: DetailsCardProps) => { - const { data: session } = useSession() as { data: ExtendedSession | null } - const [showAllPRs, setShowAllPRs] = useState(false) - const [showAllMilestones, setShowAllMilestones] = useState(false) - - // compute styles based on type prop - const typeStylesMap: Record = { - chapter: 'gap-2 md:col-span-3', - committee: 'gap-2 md:col-span-5', - module: 'gap-2 md:col-span-7', - organization: 'gap-2 md:col-span-5', - program: 'gap-2 md:col-span-7', - project: 'gap-2 md:col-span-5', - repository: 'gap-2 md:col-span-5', - user: 'gap-2 md:col-span-5', - } - - const hasContributions = - (contributionStats && contributionStats.total > 0) || - (contributionData && Object.keys(contributionData).length > 0) - - const secondaryCardStyles = typeStylesMap[type] ?? 'gap-2 md:col-span-5' - - const prDisplayLimit = onLoadMorePullRequests || onResetPullRequests || showAllPRs ? undefined : 4 - - return ( -
-
-
-
-

{title}

-
- {type === 'program' && accessLevel === 'admin' && canUpdateStatus && programKey && ( - - )} - {type === 'module' && - (() => { - if (!programKey || !entityKey) return null - const currentUserLogin = session?.user?.login - const isAdmin = - accessLevel === 'admin' && - admins?.some((admin) => admin.login === currentUserLogin) - const isMentor = mentors?.some((mentor) => mentor.login === currentUserLogin) - return isAdmin || isMentor ? ( - - ) : null - })()} - {!isActive && } - {isArchived && type === 'repository' && } - {IS_PROJECT_HEALTH_ENABLED && - type === 'project' && - healthMetricsData && - healthMetricsData.length > 0 && - healthMetricsData[0].score !== undefined && ( - scrollToAnchor('issues-trend')} - /> - )} -
-
-
-

{description}

- {summary && ( - }> - - - )} - - {userSummary && {userSummary}} -
- } - className={secondaryCardStyles} - > - {details?.map((detail) => - detail?.label === 'Leaders' ? ( -
- {detail.label}:{' '} - -
- ) : ( -
- {detail.label}: {detail?.value || 'Unknown'} -
- ) - )} - {socialLinks && (type === 'chapter' || type === 'committee') && ( - - )} -
- {showStatistics(type) && stats && ( - } - className="md:col-span-2" - > - {stats.map((stat) => ( -
- -
- ))} -
- )} - {type === 'chapter' && geolocationData && geolocationData.length > 0 && ( -
- -
- )} -
- {(type === 'project' || type === 'repository') && (languages || topics) && ( -
- {languages && languages.length !== 0 && ( - } - /> - )} - {topics && topics.length !== 0 && ( - } - /> - )} -
- )} - {(type === 'program' || type === 'module') && ( - <> - {((tags?.length || 0) > 0 || (domains?.length || 0) > 0) && ( -
- {tags && tags.length > 0 && ( - } - isDisabled={true} - /> - )} - {domains && domains.length > 0 && ( - } - isDisabled={true} - /> - )} -
- )} - {labels && labels.length > 0 && ( -
- } - isDisabled={true} - /> -
- )} - - )} - {entityLeaders && entityLeaders.length > 0 && } - {(type === 'project' || type === 'chapter') && hasContributions && ( -
-
- {contributionStats && ( - - )} - {contributionData && - Object.keys(contributionData).length > 0 && - startDate && - endDate && ( -
-
- -
-
- )} -
-
- )} - {topContributors && ( - - )} - {admins && admins.length > 0 && type === 'program' && ( - - )} - {mentors && mentors.length > 0 && ( - - )} - {mentees && mentees.length > 0 && ( - getMenteeUrl(programKey || '', entityKey || '', login)} - /> - )} - {showIssuesAndMilestones(type) && ( -
- {recentIssues && } - {recentMilestones && } -
- )} - {showPullRequestsAndReleases(type) && ( -
- {pullRequests && } - {recentReleases && ( - - )} -
- )} - - {type === 'module' && pullRequests && pullRequests.length > 0 && ( - }> -
- {pullRequests.slice(0, prDisplayLimit).map((pr) => ( - - ))} - - {(onLoadMorePullRequests || onResetPullRequests) && ( -
- {onLoadMorePullRequests && ( - - )} - - {onResetPullRequests && ( - - )} -
- )} - - {!onLoadMorePullRequests && !onResetPullRequests && pullRequests.length > 4 && ( - setShowAllPRs(!showAllPRs)} /> - )} -
-
- )} - {(type === 'project' || type === 'user' || type === 'organization') && - repositories.length > 0 && ( - }> - - - )} - {type === 'program' && - modules && - modules.length > 0 && - (() => { - const modulesList = modules - return ( - <> - {modulesList.length === 1 ? ( -
- -
- ) : ( - }> - - - )} - - ) - })()} - {type === 'program' && recentMilestones && recentMilestones.length > 0 && ( - }> -
- {recentMilestones - .slice(0, showAllMilestones ? recentMilestones.length : MILESTONE_LIMIT) - .map((milestone, index) => ( -
-
-
- {showAvatar && milestone?.author?.login && milestone?.author?.avatarUrl && ( - - - {`${milestone.author?.name - - - )} -

- {milestone?.url ? ( - - - - ) : ( - - )} -

-
-
-
-
- - {milestone.createdAt && {formatDate(milestone.createdAt)}} -
-
- - {milestone.closedIssuesCount} closed -
-
- - {milestone.openIssuesCount} open -
- {milestone?.repositoryName && milestone?.organizationName && ( -
- - - - -
- )} -
-
-
-
- ))} -
- {recentMilestones.length > MILESTONE_LIMIT && ( - setShowAllMilestones(!showAllMilestones)} /> - )} -
- )} - {IS_PROJECT_HEALTH_ENABLED && - type === 'project' && - healthMetricsData && - healthMetricsData.length > 0 && } - {entityKey && - ['chapter', 'project', 'repository'].includes(type) && - (projectName || title) && - (() => { - return ( - - ) - })()} -
-
- ) -} - -export default DetailsCard - -const SocialLinks = ({ urls }: { urls: string[] }) => { - if (!urls || urls.length === 0) return null - return ( -
- Social Links -
- {urls.map((url: string) => { - const SocialIcon = getSocialIcon(url) - return ( - - - - ) - })} -
-
- ) -} diff --git a/frontend/src/components/CardDetailsPage/CardDetailsContributions.tsx b/frontend/src/components/CardDetailsPage/CardDetailsContributions.tsx new file mode 100644 index 0000000000..5706cdc83b --- /dev/null +++ b/frontend/src/components/CardDetailsPage/CardDetailsContributions.tsx @@ -0,0 +1,47 @@ +import type { ContributionStats as ContributionStatsType } from 'utils/contributionDataUtils' +import ContributionHeatmap from 'components/ContributionHeatmap' +import ContributionStats from 'components/ContributionStats' + +interface CardDetailsContributionsProps { + hasContributions: boolean + contributionStats?: ContributionStatsType + contributionData?: Record + startDate?: string + endDate?: string + title?: string +} + +const CardDetailsContributions = ({ + hasContributions, + contributionStats, + contributionData, + startDate, + endDate, + title = 'Contribution Activity', +}: CardDetailsContributionsProps) => { + if (!hasContributions) { + return null + } + + return ( +
+
+ {contributionStats && } + {contributionData && Object.keys(contributionData).length > 0 && startDate && endDate && ( +
+
+ +
+
+ )} +
+
+ ) +} + +export default CardDetailsContributions diff --git a/frontend/src/components/CardDetailsPage/CardDetailsContributors.tsx b/frontend/src/components/CardDetailsPage/CardDetailsContributors.tsx new file mode 100644 index 0000000000..c6bdbad54b --- /dev/null +++ b/frontend/src/components/CardDetailsPage/CardDetailsContributors.tsx @@ -0,0 +1,65 @@ +import { HiUserGroup } from 'react-icons/hi' +import type { Contributor } from 'types/contributor' +import { getMemberUrl, getMenteeUrl } from 'utils/urlFormatter' +import ContributorsList from 'components/ContributorsList' + +interface CardDetailsContributorsProps { + entityKey?: string + programKey?: string + topContributors?: Contributor[] + admins?: Contributor[] + mentors?: Contributor[] + mentees?: Contributor[] +} + +const CardDetailsContributors = ({ + entityKey, + programKey, + topContributors, + admins, + mentors, + mentees, +}: CardDetailsContributorsProps) => { + return ( + <> + {topContributors && ( + + )} + {admins && admins.length > 0 && ( + + )} + {mentors && mentors.length > 0 && ( + + )} + {mentees && mentees.length > 0 && ( + getMenteeUrl(programKey || '', entityKey || '', login)} + /> + )} + + ) +} + +export default CardDetailsContributors diff --git a/frontend/src/components/CardDetailsPage/CardDetailsHeader.tsx b/frontend/src/components/CardDetailsPage/CardDetailsHeader.tsx new file mode 100644 index 0000000000..c22b5a411d --- /dev/null +++ b/frontend/src/components/CardDetailsPage/CardDetailsHeader.tsx @@ -0,0 +1,106 @@ +import { useSession } from 'next-auth/react' +import type { ComponentProps } from 'react' +import type { ExtendedSession } from 'types/auth' +import { IS_PROJECT_HEALTH_ENABLED } from 'utils/env.client' +import { scrollToAnchor } from 'utils/scrollToAnchor' +import EntityActions from 'components/EntityActions' +import MetricsScoreCircle from 'components/MetricsScoreCircle' +import StatusBadge from 'components/StatusBadge' + +export interface CardDetailsHeaderProps { + title?: string + description?: string + status?: string + setStatus?: ComponentProps['setStatus'] + canUpdateStatus?: boolean + programKey?: string + moduleKey?: string + entityKey?: string + accessLevel?: string + admins?: Array<{ login: string }> + mentors?: Array<{ login: string }> + isActive?: boolean + isArchived?: boolean + healthMetricsData?: Array<{ score?: number }> + showProgramActions?: boolean + showModuleActions?: boolean + showArchivedBadge?: boolean + showHealthMetrics?: boolean +} + +const CardDetailsHeader = ({ + title, + description, + status, + setStatus, + canUpdateStatus, + programKey, + moduleKey, + entityKey: _entityKey, + accessLevel, + admins, + mentors, + isActive = true, + isArchived = false, + healthMetricsData, + showProgramActions, + showModuleActions, + showArchivedBadge, + showHealthMetrics, +}: CardDetailsHeaderProps) => { + const { data: session } = useSession() as { data: ExtendedSession | null } + + return ( + <> +
+
+

{title || ''}

+
+ {showProgramActions && canUpdateStatus && programKey && ( + + )} + {showModuleActions && + (() => { + if (!programKey || !moduleKey) return null + const currentUserLogin = session?.user?.login + const isAdmin = + accessLevel === 'admin' && + admins?.some((admin) => admin.login === currentUserLogin) + const isMentor = mentors?.some((mentor) => mentor.login === currentUserLogin) + return isAdmin || isMentor ? ( + + ) : null + })()} + {!isActive && } + {showArchivedBadge && isArchived && } + {showHealthMetrics && + IS_PROJECT_HEALTH_ENABLED && + healthMetricsData && + healthMetricsData.length > 0 && + healthMetricsData[0].score !== undefined && ( + scrollToAnchor('issues-trend')} + /> + )} +
+
+
+ {description &&

{description}

} + + ) +} + +export default CardDetailsHeader diff --git a/frontend/src/components/CardDetailsPage/CardDetailsIssuesMilestones.tsx b/frontend/src/components/CardDetailsPage/CardDetailsIssuesMilestones.tsx new file mode 100644 index 0000000000..8a906878ad --- /dev/null +++ b/frontend/src/components/CardDetailsPage/CardDetailsIssuesMilestones.tsx @@ -0,0 +1,208 @@ +import { Tooltip } from '@heroui/tooltip' +import Image from 'next/image' +import Link from 'next/link' +import { useState } from 'react' +import { + FaCalendar, + FaCircleCheck, + FaCircleExclamation, + FaFolderOpen, + FaSignsPost, + FaCodeBranch, + FaChevronDown, + FaChevronUp, +} from 'react-icons/fa6' +import type { Issue } from 'types/issue' +import type { Milestone } from 'types/milestone' +import type { PullRequest } from 'types/pullRequest' +import type { Release } from 'types/release' +import { formatDate } from 'utils/dateFormatter' +import AnchorTitle from 'components/AnchorTitle' +import MentorshipPullRequest from 'components/MentorshipPullRequest' +import Milestones from 'components/Milestones' +import RecentIssues from 'components/RecentIssues' +import RecentPullRequests from 'components/RecentPullRequests' +import RecentReleases from 'components/RecentReleases' +import SecondaryCard from 'components/SecondaryCard' +import ShowMoreButton from 'components/ShowMoreButton' +import { TruncatedText } from 'components/TruncatedText' + +interface CardDetailsIssuesMilestonesProps { + recentIssues?: Issue[] + recentMilestones?: Milestone[] + pullRequests?: PullRequest[] + recentReleases?: Release[] + showAvatar?: boolean + onLoadMorePullRequests?: () => void + onResetPullRequests?: () => void + isFetchingMore?: boolean + isMilestoneOnly?: boolean + isPullRequestOnly?: boolean +} + +const MILESTONE_LIMIT = 4 + +const CardDetailsIssuesMilestones = ({ + recentIssues, + recentMilestones, + pullRequests, + recentReleases, + showAvatar = true, + onLoadMorePullRequests, + onResetPullRequests, + isFetchingMore = false, + isMilestoneOnly = false, + isPullRequestOnly = false, +}: CardDetailsIssuesMilestonesProps) => { + const [showAllMilestones, setShowAllMilestones] = useState(false) + const [showAllPRs, setShowAllPRs] = useState(false) + + const prDisplayLimit = onLoadMorePullRequests || onResetPullRequests || showAllPRs ? undefined : 4 + + return ( + <> + {!isMilestoneOnly && !isPullRequestOnly && ( +
+ {recentIssues && } + {recentMilestones && } +
+ )} + {!isMilestoneOnly && !isPullRequestOnly && ( +
+ {pullRequests && } + {recentReleases && ( + + )} +
+ )} + {isPullRequestOnly && pullRequests && pullRequests.length > 0 && ( + }> +
+ {pullRequests.slice(0, prDisplayLimit).map((pr) => ( + + ))} + + {(onLoadMorePullRequests || onResetPullRequests) && ( +
+ {onLoadMorePullRequests && ( + + )} + + {onResetPullRequests && ( + + )} +
+ )} + + {!onLoadMorePullRequests && !onResetPullRequests && pullRequests.length > 4 && ( + setShowAllPRs(!showAllPRs)} /> + )} +
+
+ )} + {isMilestoneOnly && recentMilestones && recentMilestones.length > 0 && ( + }> +
+ {recentMilestones + .slice(0, showAllMilestones ? recentMilestones.length : MILESTONE_LIMIT) + .map((milestone, index) => ( +
+
+
+ {showAvatar && milestone?.author?.login && milestone?.author?.avatarUrl && ( + + + {`${milestone.author?.name + + + )} +

+ {milestone?.url ? ( + + + + ) : ( + + )} +

+
+
+
+
+ + {milestone.createdAt && {formatDate(milestone.createdAt)}} +
+
+ + {milestone.closedIssuesCount ?? 0} closed +
+
+ + {milestone.openIssuesCount ?? 0} open +
+ {milestone?.repositoryName && milestone?.organizationName && ( +
+ + + + +
+ )} +
+
+
+
+ ))} +
+ {recentMilestones.length > MILESTONE_LIMIT && ( + setShowAllMilestones(!showAllMilestones)} /> + )} +
+ )} + + ) +} + +export default CardDetailsIssuesMilestones diff --git a/frontend/src/components/CardDetailsPage/CardDetailsLeaders.tsx b/frontend/src/components/CardDetailsPage/CardDetailsLeaders.tsx new file mode 100644 index 0000000000..76ce5726ac --- /dev/null +++ b/frontend/src/components/CardDetailsPage/CardDetailsLeaders.tsx @@ -0,0 +1,18 @@ +import type { Leader } from 'types/leader' +import Leaders from 'components/Leaders' + +interface CardDetailsLeadersProps { + entityLeaders?: Leader[] | null +} + +export default function CardDetailsLeaders({ entityLeaders }: Readonly) { + if (!entityLeaders || entityLeaders.length === 0) { + return null + } + + return ( +
+ +
+ ) +} diff --git a/frontend/src/components/CardDetailsPage/CardDetailsMetadata.tsx b/frontend/src/components/CardDetailsPage/CardDetailsMetadata.tsx new file mode 100644 index 0000000000..24e0ac4803 --- /dev/null +++ b/frontend/src/components/CardDetailsPage/CardDetailsMetadata.tsx @@ -0,0 +1,124 @@ +import type { JSX } from 'react' +import { FaChartPie, FaRectangleList } from 'react-icons/fa6' +import type { Stats } from 'types/card' +import type { Chapter } from 'types/chapter' +import { getSocialIcon } from 'utils/urlIconMappings' +import AnchorTitle from 'components/AnchorTitle' +import ChapterMapWrapper from 'components/ChapterMapWrapper' +import InfoBlock from 'components/InfoBlock' +import LeadersList from 'components/LeadersList' +import SecondaryCard from 'components/SecondaryCard' + +interface CardDetailsMetadataProps { + details?: Array<{ label: string; value: string | JSX.Element }> + entityKey?: string + stats?: Stats[] + geolocationData?: Chapter[] + socialLinks?: string[] + showStatistics?: boolean + showGeolocation?: boolean + showSocialLinks?: boolean + detailsTitle?: string +} + +const CardDetailsMetadata = ({ + details, + entityKey, + stats, + geolocationData = [], + socialLinks, + showStatistics = !!stats, + showGeolocation = false, + showSocialLinks = false, + detailsTitle = 'Details', +}: CardDetailsMetadataProps) => { + return ( +
+ } + className="gap-2 md:col-span-5" + > + {details?.map((detail) => + detail?.label === 'Leaders' ? ( +
+ {detail.label}:{' '} + +
+ ) : ( +
+ {detail.label}: {detail?.value || 'Unknown'} +
+ ) + )} + {showSocialLinks && socialLinks && } +
+ {showStatistics && stats && ( + } + className="md:col-span-2" + > + {stats.map((stat) => ( +
+ +
+ ))} +
+ )} + {showGeolocation && geolocationData && geolocationData.length > 0 && ( +
+ +
+ )} +
+ ) +} + +const SocialLinks = ({ urls }: { urls: string[] }) => { + if (!urls || urls.length === 0) return null + return ( +
+ Social Links +
+ {urls.map((url: string) => { + const SocialIcon = getSocialIcon(url) + return ( + + + + ) + })} +
+
+ ) +} + +export default CardDetailsMetadata diff --git a/frontend/src/components/CardDetailsPage/CardDetailsPageWrapper.tsx b/frontend/src/components/CardDetailsPage/CardDetailsPageWrapper.tsx new file mode 100644 index 0000000000..ff345a25e2 --- /dev/null +++ b/frontend/src/components/CardDetailsPage/CardDetailsPageWrapper.tsx @@ -0,0 +1,15 @@ +import React from 'react' + +interface CardDetailsPageWrapperProps { + children: React.ReactNode +} + +const CardDetailsPageWrapper: React.FC = ({ children }) => { + return ( +
+
{children}
+
+ ) +} + +export default CardDetailsPageWrapper diff --git a/frontend/src/components/CardDetailsPage/CardDetailsRepositoriesModules.tsx b/frontend/src/components/CardDetailsPage/CardDetailsRepositoriesModules.tsx new file mode 100644 index 0000000000..353d03be20 --- /dev/null +++ b/frontend/src/components/CardDetailsPage/CardDetailsRepositoriesModules.tsx @@ -0,0 +1,63 @@ +import { FaFolderOpen } from 'react-icons/fa6' +import type { Module } from 'types/mentorship' +import type { RepositoryCardProps } from 'types/project' +import AnchorTitle from 'components/AnchorTitle' +import ModuleCard from 'components/ModuleCard' +import RepositoryCard from 'components/RepositoryCard' +import SecondaryCard from 'components/SecondaryCard' + +interface CardDetailsRepositoriesModulesProps { + programKey?: string + accessLevel?: string + repositories?: RepositoryCardProps[] + modules?: Module[] + admins?: Array<{ login: string }> +} + +const CardDetailsRepositoriesModules = ({ + programKey, + accessLevel, + repositories = [], + modules, + admins, +}: CardDetailsRepositoriesModulesProps) => { + return ( + <> + {repositories.length > 0 && ( + }> + + + )} + {modules && + modules.length > 0 && + (() => { + const modulesList = modules + return ( + <> + {modulesList.length === 1 ? ( +
+ +
+ ) : ( + }> + + + )} + + ) + })()} + + ) +} + +export default CardDetailsRepositoriesModules diff --git a/frontend/src/components/CardDetailsPage/CardDetailsSummary.tsx b/frontend/src/components/CardDetailsPage/CardDetailsSummary.tsx new file mode 100644 index 0000000000..d368394a2a --- /dev/null +++ b/frontend/src/components/CardDetailsPage/CardDetailsSummary.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react' +import { FaCircleInfo } from 'react-icons/fa6' +import AnchorTitle from 'components/AnchorTitle' +import Markdown from 'components/MarkdownWrapper' +import SecondaryCard from 'components/SecondaryCard' + +interface CardDetailsSummaryProps { + summary?: string + userSummary?: ReactNode +} + +const CardDetailsSummary = ({ summary, userSummary }: CardDetailsSummaryProps) => { + return ( + <> + {summary && ( + }> + + + )} + + {userSummary && {userSummary}} + + ) +} + +export default CardDetailsSummary diff --git a/frontend/src/components/CardDetailsPage/CardDetailsTags.tsx b/frontend/src/components/CardDetailsPage/CardDetailsTags.tsx new file mode 100644 index 0000000000..5260e7cdb6 --- /dev/null +++ b/frontend/src/components/CardDetailsPage/CardDetailsTags.tsx @@ -0,0 +1,98 @@ +import { FaCode, FaTags, FaChartPie } from 'react-icons/fa6' +import AnchorTitle from 'components/AnchorTitle' +import ToggleableList from 'components/ToggleableList' + +interface CardDetailsTagsProps { + entityKey?: string + languages?: string[] + topics?: string[] + tags?: string[] + domains?: string[] + labels?: string[] +} + +const CardDetailsTags = ({ + entityKey, + languages, + topics, + tags, + domains, + labels, +}: CardDetailsTagsProps) => { + const hasLanguagesOrTopics = (languages?.length || 0) > 0 || (topics?.length || 0) > 0 + const hasTagsDomainsOrLabels = + (tags?.length || 0) > 0 || (domains?.length || 0) > 0 || (labels?.length || 0) > 0 + + // Languages and Topics section + if (hasLanguagesOrTopics) { + return ( +
+ {languages && languages.length !== 0 && ( + } + /> + )} + {topics && topics.length !== 0 && ( + } + /> + )} +
+ ) + } + + // Tags, Domains, and Labels section + if (hasTagsDomainsOrLabels) { + return ( + <> + {((tags?.length || 0) > 0 || (domains?.length || 0) > 0) && ( +
+ {tags && tags.length > 0 && ( + } + isDisabled={true} + /> + )} + {domains && domains.length > 0 && ( + } + isDisabled={true} + /> + )} +
+ )} + {labels && labels.length > 0 && ( +
+ } + isDisabled={true} + /> +
+ )} + + ) + } + + return null +} + +export default CardDetailsTags diff --git a/frontend/src/types/card.ts b/frontend/src/types/card.ts index 4615a5f89d..7682167194 100644 --- a/frontend/src/types/card.ts +++ b/frontend/src/types/card.ts @@ -15,7 +15,6 @@ import type { RepositoryCardProps } from 'types/project' import type { PullRequest } from 'types/pullRequest' import type { Release } from 'types/release' import type { ContributionStats } from 'utils/contributionDataUtils' -import type { CardType } from 'components/CardDetailsPage' export type CardProps = { button: Button @@ -38,7 +37,7 @@ export type CardProps = { url: string } -type Stats = { +export type Stats = { icon: IconType pluralizedName?: string unit?: string @@ -87,7 +86,6 @@ export interface DetailsCardProps { topContributors?: Contributor[] topics?: string[] tags?: string[] - type: CardType userSummary?: JSX.Element }