diff --git a/frontend/__tests__/unit/components/SingleModuleCard.test.tsx b/frontend/__tests__/unit/components/SingleModuleCard.test.tsx
index 9eaba76be3..474e0e918a 100644
--- a/frontend/__tests__/unit/components/SingleModuleCard.test.tsx
+++ b/frontend/__tests__/unit/components/SingleModuleCard.test.tsx
@@ -1,4 +1,5 @@
-import { screen } from '@testing-library/react'
+/* eslint-disable @next/next/no-img-element */
+import { screen, fireEvent } from '@testing-library/react'
import { usePathname, useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import React from 'react'
@@ -12,31 +13,27 @@ jest.mock('next/link', () => ({
default: ({
children,
href,
- target,
- rel,
className,
...props
}: {
children: React.ReactNode
href: string
- target?: string
- rel?: string
className?: string
[key: string]: unknown
}) => (
-
+
{children}
),
}))
+jest.mock('next/image', () => ({
+ __esModule: true,
+ default: ({ src, alt, ...props }: { src: string; alt: string; [key: string]: unknown }) => (
+
+ ),
+}))
+
// Mock dependencies
jest.mock('next/navigation', () => ({
useRouter: jest.fn(),
@@ -57,6 +54,10 @@ jest.mock('utils/dateFormatter', () => ({
formatDate: jest.fn((date: string) => new Date(date).toLocaleDateString()),
}))
+jest.mock('utils/urlFormatter', () => ({
+ getMemberUrl: jest.fn((login: string) => `/members/${login}`),
+}))
+
jest.mock('components/ModuleCard', () => ({
getSimpleDuration: jest.fn((start: string, end: string) => {
const startDate = new Date(start)
@@ -67,21 +68,12 @@ jest.mock('components/ModuleCard', () => ({
}),
}))
-jest.mock('components/ContributorsList', () => ({
+jest.mock('components/ShowMoreButton', () => ({
__esModule: true,
- default: ({
- contributors,
- label,
- }: {
- contributors: unknown[]
- label: string
- getUrl: (login: string) => string
- }) => (
-
-
- {label}: {contributors.length} contributors
-
-
+ default: ({ onToggle }: { onToggle: () => void }) => (
+
),
}))
@@ -119,6 +111,16 @@ const mockModule: Module = {
labels: ['good first issue', 'bug'],
}
+const mockModuleWithManyMentors: Module = {
+ ...mockModule,
+ mentors: Array.from({ length: 10 }, (_, i) => ({
+ id: `mentor-${i + 1}`,
+ name: `mentor${i + 1}`,
+ login: `mentor${i + 1}`,
+ avatarUrl: `https://example.com/avatar${i + 1}.jpg`,
+ })),
+}
+
const mockAdmins = [{ login: 'admin1' }, { login: 'admin2' }]
describe('SingleModuleCard', () => {
@@ -146,7 +148,7 @@ describe('SingleModuleCard', () => {
expect(screen.getByText('Test Module')).toBeInTheDocument()
expect(screen.getByText('This is a test module description')).toBeInTheDocument()
- expect(screen.getByTestId('icon-users')).toBeInTheDocument()
+ expect(screen.getAllByTestId('icon-users').length).toBeGreaterThan(0)
})
it('renders module details correctly', () => {
@@ -159,49 +161,118 @@ describe('SingleModuleCard', () => {
expect(screen.getByText('Duration:')).toBeInTheDocument()
})
- it('renders mentors list when mentors exist', () => {
+ it('renders mentors section with inline contributors when mentors exist', () => {
render()
- expect(screen.getByTestId('contributors-list')).toBeInTheDocument()
- expect(screen.getByText('Mentors: 2 contributors')).toBeInTheDocument()
+ expect(screen.getByText('Mentors')).toBeInTheDocument()
+ expect(screen.getByText('User1')).toBeInTheDocument()
+ expect(screen.getByText('User2')).toBeInTheDocument()
+ expect(screen.getAllByTestId('contributor-avatar')).toHaveLength(2)
})
- it('does not render mentors list when no mentors', () => {
+ it('does not render mentors section when no mentors', () => {
const moduleWithoutMentors = { ...mockModule, mentors: [] }
render()
- expect(screen.queryByTestId('contributors-list')).not.toBeInTheDocument()
+ expect(screen.queryByText('Mentors')).not.toBeInTheDocument()
})
it('renders module link with correct href', () => {
render()
- const moduleLink = screen.getByTestId('module-link')
- expect(moduleLink).toHaveAttribute(
+ const moduleLinks = screen.getAllByTestId('module-link')
+ const titleLink = moduleLinks.find((link) =>
+ link.getAttribute('href')?.includes('/modules/test-module')
+ )
+ expect(titleLink).toHaveAttribute(
'href',
'/my/mentorship/programs/test-program/modules/test-module'
)
- expect(moduleLink).toHaveAttribute('target', '_blank')
- expect(moduleLink).toHaveAttribute('rel', 'noopener noreferrer')
})
})
- describe('Simplified Interface', () => {
- it('focuses on content display only', () => {
+ describe('Inline Contributors Rendering', () => {
+ it('renders contributor avatars inline without nested shadows', () => {
render()
- // Should display core content
- expect(screen.getByText('Test Module')).toBeInTheDocument()
- expect(screen.getByText('This is a test module description')).toBeInTheDocument()
- expect(screen.getByText('Experience Level:')).toBeInTheDocument()
+ const avatars = screen.getAllByTestId('contributor-avatar')
+ expect(avatars.length).toBeGreaterThan(0)
+ })
+
+ it('renders show more button when more than 6 mentors', () => {
+ render()
+
+ expect(screen.getByTestId('show-more-button')).toBeInTheDocument()
+ })
+
+ it('does not render show more button when 6 or fewer mentors', () => {
+ render()
- // Should have clickable title for navigation
- const moduleLink = screen.getByTestId('module-link')
- expect(moduleLink).toHaveAttribute(
+ expect(screen.queryByTestId('show-more-button')).not.toBeInTheDocument()
+ })
+
+ it('toggles show all mentors when show more button is clicked', () => {
+ render()
+
+ // Initially shows only 6
+ expect(screen.getAllByTestId('contributor-avatar')).toHaveLength(6)
+
+ // Click show more
+ fireEvent.click(screen.getByTestId('show-more-button'))
+
+ // Should now show all 10
+ expect(screen.getAllByTestId('contributor-avatar')).toHaveLength(10)
+ })
+ })
+
+ describe('Mentee URL Handling', () => {
+ it('uses private mentee URL in private view', () => {
+ const moduleWithMentees: Module = {
+ ...mockModule,
+ mentees: [
+ {
+ id: 'mentee-1',
+ name: 'mentee1',
+ login: 'mentee1',
+ avatarUrl: 'https://example.com/mentee1.jpg',
+ },
+ ],
+ }
+ mockUsePathname.mockReturnValue('/my/mentorship/programs/test-program')
+
+ render()
+
+ const menteeLinks = screen.getAllByTestId('module-link')
+ const menteeLink = menteeLinks.find((link) =>
+ link.getAttribute('href')?.includes('/mentees/')
+ )
+ expect(menteeLink).toHaveAttribute(
'href',
- '/my/mentorship/programs/test-program/modules/test-module'
+ '/my/mentorship/programs/test-program/modules/test-module/mentees/mentee1'
)
})
+
+ it('uses public member URL in public view', () => {
+ const moduleWithMentees: Module = {
+ ...mockModule,
+ mentors: [],
+ mentees: [
+ {
+ id: 'mentee-1',
+ name: 'mentee1',
+ login: 'mentee1',
+ avatarUrl: 'https://example.com/mentee1.jpg',
+ },
+ ],
+ }
+ mockUsePathname.mockReturnValue('/programs/test-program')
+
+ render()
+
+ const allLinks = screen.getAllByRole('link')
+ const menteeLink = allLinks.find((link) => link.getAttribute('href') === '/members/mentee1')
+ expect(menteeLink).toBeInTheDocument()
+ })
})
describe('Props Handling', () => {
@@ -212,8 +283,7 @@ describe('SingleModuleCard', () => {
expect(screen.getByText('This is a test module description')).toBeInTheDocument()
})
- it('ignores admin-related props since menu is removed', () => {
- // These props are now ignored but should not cause errors
+ it('handles admin props correctly', () => {
render()
expect(screen.getByText('Test Module')).toBeInTheDocument()
@@ -231,7 +301,7 @@ describe('SingleModuleCard', () => {
render()
expect(screen.getByText('Test Module')).toBeInTheDocument()
- expect(screen.queryByTestId('contributors-list')).not.toBeInTheDocument()
+ expect(screen.queryByText('Mentors')).not.toBeInTheDocument()
})
it('handles undefined admins array gracefully', () => {
@@ -240,37 +310,61 @@ describe('SingleModuleCard', () => {
// Should render without errors even with admin props
expect(screen.getByText('Test Module')).toBeInTheDocument()
})
+
+ it('renders no description available when description is empty', () => {
+ const moduleWithoutDescription = { ...mockModule, description: '' }
+ render()
+
+ expect(screen.getByText('No description available.')).toBeInTheDocument()
+ })
})
describe('Accessibility', () => {
it('has accessible link for module navigation', () => {
render()
- const moduleLink = screen.getByTestId('module-link')
- expect(moduleLink).toBeInTheDocument()
- expect(moduleLink).toHaveAttribute(
+ const moduleLinks = screen.getAllByTestId('module-link')
+ const titleLink = moduleLinks.find((link) =>
+ link.getAttribute('href')?.includes('/modules/test-module')
+ )
+ expect(titleLink).toBeInTheDocument()
+ expect(titleLink).toHaveAttribute(
'href',
'/my/mentorship/programs/test-program/modules/test-module'
)
- expect(moduleLink).toHaveAttribute('target', '_blank')
- expect(moduleLink).toHaveAttribute('rel', 'noopener noreferrer')
})
- it('has proper heading structure', () => {
+ it('has proper heading structure with h2', () => {
render()
- const moduleTitle = screen.getByRole('heading', { level: 1 })
+ const moduleTitle = screen.getByRole('heading', { level: 2 })
expect(moduleTitle).toBeInTheDocument()
expect(moduleTitle).toHaveTextContent('Test Module')
})
+
+ it('has proper heading structure with h3 for contributors sections', () => {
+ render()
+
+ const mentorsHeading = screen.getByRole('heading', { level: 3, name: 'Mentors' })
+ expect(mentorsHeading).toBeInTheDocument()
+ })
})
- describe('Responsive Design', () => {
- it('applies responsive classes correctly', () => {
+ describe('Styling', () => {
+ it('renders without shadow or border classes in module wrapper', () => {
+ render()
+
+ // The component should render successfully with the section styling
+ expect(screen.getByText('Test Module')).toBeInTheDocument()
+ expect(screen.getByText('Mentors')).toBeInTheDocument()
+ })
+
+ it('renders contributor items with proper styling', () => {
render()
- const moduleTitle = screen.getByText('Test Module')
- expect(moduleTitle).toHaveClass('sm:break-normal', 'sm:text-lg', 'lg:text-2xl')
+ // Contributors should be rendered inline
+ const avatars = screen.getAllByTestId('contributor-avatar')
+ expect(avatars.length).toBeGreaterThan(0)
})
})
})
diff --git a/frontend/src/components/CardDetailsPage.tsx b/frontend/src/components/CardDetailsPage.tsx
index c3bdabdba5..0763ea1af5 100644
--- a/frontend/src/components/CardDetailsPage.tsx
+++ b/frontend/src/components/CardDetailsPage.tsx
@@ -359,12 +359,15 @@ const DetailsCard = ({
)}
{type === 'program' && modules.length > 0 && (
- }
- >
-
-
+ <>
+ {modules.length === 1 ? (
+
+ ) : (
+ }>
+
+
+ )}
+ >
)}
{IS_PROJECT_HEALTH_ENABLED && type === 'project' && healthMetricsData.length > 0 && (
diff --git a/frontend/src/components/SingleModuleCard.tsx b/frontend/src/components/SingleModuleCard.tsx
index 68e5f7ee8c..5c84628384 100644
--- a/frontend/src/components/SingleModuleCard.tsx
+++ b/frontend/src/components/SingleModuleCard.tsx
@@ -1,19 +1,23 @@
'use client'
import { capitalize } from 'lodash'
+import upperFirst from 'lodash/upperFirst'
+import Image from 'next/image'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useSession } from 'next-auth/react'
-import React from 'react'
+import React, { useState } from 'react'
+import { FaFolderOpen } from 'react-icons/fa'
import { HiUserGroup } from 'react-icons/hi'
import { ExtendedSession } from 'types/auth'
+import type { Contributor } from 'types/contributor'
import type { Module } from 'types/mentorship'
import { formatDate } from 'utils/dateFormatter'
-import { getMemberUrl, getMenteeUrl } from 'utils/urlFormatter'
-import ContributorsList from 'components/ContributorsList'
+import { getMemberUrl } from 'utils/urlFormatter'
import EntityActions from 'components/EntityActions'
import Markdown from 'components/MarkdownWrapper'
import { getSimpleDuration } from 'components/ModuleCard'
+import ShowMoreButton from 'components/ShowMoreButton'
interface SingleModuleCardProps {
module: Module
@@ -26,6 +30,8 @@ interface SingleModuleCardProps {
const SingleModuleCard: React.FC = ({ module, accessLevel, admins }) => {
const { data } = useSession()
const pathname = usePathname()
+ const [showAllMentors, setShowAllMentors] = useState(false)
+ const [showAllMentees, setShowAllMentees] = useState(false)
const isAdmin =
accessLevel === 'admin' &&
@@ -41,25 +47,76 @@ const SingleModuleCard: React.FC = ({ module, accessLevel
{ label: 'Duration', value: getSimpleDuration(module.startedAt, module.endedAt) },
]
+ const maxInitialDisplay = 6
+ const displayMentors = showAllMentors
+ ? module.mentors
+ : module.mentors?.slice(0, maxInitialDisplay)
+ const displayMentees = showAllMentees
+ ? module.mentees
+ : module.mentees?.slice(0, maxInitialDisplay)
+
+ const isPrivateView = pathname?.startsWith('/my/mentorship')
+
+ const renderContributors = (
+ contributors: Contributor[] | undefined,
+ displayContributors: Contributor[] | undefined,
+ label: string,
+ showAll: boolean,
+ toggleShowAll: () => void,
+ isMentee = false
+ ) => {
+ if (!contributors || contributors.length === 0) return null
+
+ return (
+
+
+
+ {label}
+
+
+ {displayContributors?.map((contributor) => (
+
+
+
+
+ {upperFirst(contributor.name) || upperFirst(contributor.login)}
+
+
+
+ ))}
+
+ {contributors.length > maxInitialDisplay &&
}
+
+ )
+ }
+
return (
-
+
-
-
-
+
+
+
{module.name}
-
+
@@ -81,33 +138,19 @@ const SingleModuleCard: React.FC
= ({ module, accessLevel
{/* Mentors */}
- {module.mentors?.length > 0 && (
-
+ {renderContributors(module.mentors, displayMentors, 'Mentors', showAllMentors, () =>
+ setShowAllMentors(!showAllMentors)
+ )}
+
+ {/* Mentees */}
+ {renderContributors(
+ module.mentees,
+ displayMentees,
+ 'Mentees',
+ showAllMentees,
+ () => setShowAllMentees(!showAllMentees),
+ true
)}
- {module.mentees?.length > 0 &&
- (pathname?.startsWith('/my/mentorship') ? (
-
getMenteeUrl(programKey, module.key, login)}
- />
- ) : (
-
- ))}
)
}