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 }) => ( + {alt} + ), +})) + // 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) => ( +
+
+ {contributor?.name + + {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)} - /> - ) : ( - - ))}
) }