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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/components/InfoItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const TextInfoItem = ({
value: string
}) => {
return (
<div className="flex items-center gap-2 text-sm text-gray-400">
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<IconWrapper icon={icon} className="text-xs" />
<span className="font-medium">{label}:</span> {value}
</div>
Expand Down
113 changes: 101 additions & 12 deletions frontend/src/components/ModuleCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import upperFirst from 'lodash/upperFirst'
import { capitalize } from 'lodash'
import Image from 'next/image'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import type React from 'react'
Expand All @@ -7,7 +8,6 @@ import { FaChevronDown, FaChevronUp, FaTurnUp, FaCalendar, FaHourglassHalf } fro
import type { Module } from 'types/mentorship'
import { formatDate } from 'utils/dateFormatter'
import { TextInfoItem } from 'components/InfoItem'
import { LabelList } from 'components/LabelList'
import SingleModuleCard from 'components/SingleModuleCard'
import { TruncatedText } from 'components/TruncatedText'

Expand All @@ -25,7 +25,6 @@ const ModuleCard = ({ modules, accessLevel, admins }: ModuleCardProps) => {
}

const displayedModule = showAllModule ? modules : modules.slice(0, 4)
const isAdmin = accessLevel === 'admin'

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
Expand All @@ -36,9 +35,9 @@ const ModuleCard = ({ modules, accessLevel, admins }: ModuleCardProps) => {

return (
<div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-3">
{displayedModule.map((module) => {
return <ModuleItem key={module.key || module.id} module={module} isAdmin={isAdmin} />
return <ModuleItem key={module.key || module.id} module={module} />
})}
</div>
{modules.length > 4 && (
Expand All @@ -65,26 +64,116 @@ const ModuleCard = ({ modules, accessLevel, admins }: ModuleCardProps) => {
)
}

const ModuleItem = ({ module, isAdmin }: { module: Module; isAdmin: boolean }) => {
const ModuleItem = ({ module }: { module: Module }) => {
const pathname = usePathname()

const mentors = module.mentors || []
const mentees = module.mentees || []

const mentorsWithAvatars = mentors.filter((m) => m?.avatarUrl)
const menteesWithAvatars = mentees.filter((m) => m?.avatarUrl)

const programKey = pathname?.split('/programs/')[1]?.split('/')[0] || ''
const moduleKey = module.key || module.id

const getMenteeUrl = (login: string) => {
if (pathname?.startsWith('/my/mentorship')) {
return `/my/mentorship/programs/${programKey}/modules/${moduleKey}/mentees/${login}`
}
return `/members/${login}`
}

const getAvatarUrlWithSize = (avatarUrl: string): string => {
try {
const url = new URL(avatarUrl)
url.searchParams.set('s', '60')
return url.toString()
} catch {
const separator = avatarUrl.includes('?') ? '&' : '?'
return `${avatarUrl}${separator}s=60`
}
}

return (
<div className="flex h-46 w-full flex-col gap-3 rounded-lg border-1 border-gray-200 p-4 shadow-xs ease-in-out hover:shadow-md dark:border-gray-700 dark:bg-gray-800">
<div className="flex h-auto min-h-[12rem] w-full flex-col gap-3 rounded-lg border-1 border-gray-200 p-4 text-gray-600 shadow-xs ease-in-out hover:shadow-md dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
<Link
href={`${pathname}/modules/${module.key}`}
className="text-start font-semibold text-blue-400 hover:underline"
className="text-start font-semibold text-gray-600 hover:underline dark:text-gray-300"
>
<TruncatedText text={module?.name} />
</Link>
<TextInfoItem icon={FaTurnUp} label="Level" value={upperFirst(module.experienceLevel)} />
<TextInfoItem icon={FaTurnUp} label="Level" value={capitalize(module.experienceLevel)} />
<TextInfoItem icon={FaCalendar} label="Start" value={formatDate(module.startedAt)} />
<TextInfoItem
icon={FaHourglassHalf}
label="Duration"
value={getSimpleDuration(module.startedAt, module.endedAt)}
/>
{isAdmin && module.labels && module.labels.length > 0 && (
<div className="mt-2">
<LabelList labels={module.labels} maxVisible={3} />

{(mentorsWithAvatars.length > 0 || menteesWithAvatars.length > 0) && (
<div className="mt-auto flex w-full gap-4">
{mentorsWithAvatars.length > 0 && (
<div className="flex flex-1 flex-col gap-2">
<span className="text-xs font-bold tracking-wider text-gray-600 uppercase dark:text-gray-300">
Mentors
</span>
<div className="flex flex-wrap gap-1">
{mentorsWithAvatars.slice(0, 4).map((contributor) => (
<Link
key={contributor.login}
href={`/members/${contributor.login}`}
className="transition-opacity hover:opacity-80"
>
<Image
alt={contributor.name || contributor.login}
className="rounded-full border-1 border-gray-200 dark:border-gray-700"
height={24}
src={getAvatarUrlWithSize(contributor.avatarUrl)}
title={contributor.name || contributor.login}
width={24}
/>
</Link>
))}
{mentorsWithAvatars.length > 4 && (
<span className="self-center text-xs font-medium text-gray-600 dark:text-gray-300">
+{mentorsWithAvatars.length - 4}
</span>
)}
</div>
</div>
)}
{menteesWithAvatars.length > 0 && (
<div
className={`flex flex-1 flex-col gap-2 ${mentorsWithAvatars.length > 0 ? 'border-l-1 border-gray-100 pl-4 dark:border-gray-700' : ''}`}
>
<span className="text-xs font-bold tracking-wider text-gray-600 uppercase dark:text-gray-300">
Mentees
</span>
<div className="flex flex-wrap gap-1">
{menteesWithAvatars.slice(0, 4).map((contributor) => (
<Link
key={contributor.login}
href={getMenteeUrl(contributor.login)}
className="transition-opacity hover:opacity-80"
>
<Image
alt={contributor.name || contributor.login}
className="rounded-full border-1 border-gray-200 dark:border-gray-700"
height={24}
src={getAvatarUrlWithSize(contributor.avatarUrl)}
title={contributor.name || contributor.login}
width={24}
/>
</Link>
))}
{menteesWithAvatars.length > 4 && (
<span className="self-center text-xs font-medium text-gray-600 dark:text-gray-300">
+{menteesWithAvatars.length - 4}
</span>
)}
</div>
</div>
)}
</div>
)}
</div>
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/components/SingleModuleCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { Module } from 'types/mentorship'
import { formatDate } from 'utils/dateFormatter'
import EntityActions from 'components/EntityActions'
import Markdown from 'components/MarkdownWrapper'
import MenteeContributorsList from 'components/MenteeContributorsList'
import { getSimpleDuration } from 'components/ModuleCard'
import TopContributorsList from 'components/TopContributorsList'

Expand Down Expand Up @@ -88,6 +89,24 @@ const SingleModuleCard: React.FC<SingleModuleCardProps> = ({ module, accessLevel
label="Mentors"
/>
)}
{module.mentees?.length > 0 &&
(pathname?.startsWith('/my/mentorship') ? (
<MenteeContributorsList
icon={HiUserGroup}
contributors={module.mentees}
maxInitialDisplay={6}
label="Mentees"
programKey={programKey}
moduleKey={module.key}
/>
) : (
<TopContributorsList
icon={HiUserGroup}
contributors={module.mentees}
maxInitialDisplay={6}
label="Mentees"
/>
))}
Comment on lines +92 to +109
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the SingleModuleCard.tsx file structure
cd frontend/src/components && head -120 SingleModuleCard.tsx | tail -50

Repository: OWASP/Nest

Length of output: 1395


🏁 Script executed:

# Also get the full context of the component to understand prop types
cat -n frontend/src/components/SingleModuleCard.tsx | head -30

Repository: OWASP/Nest

Length of output: 1238


🏁 Script executed:

# Find where SingleModuleCard is used
rg "SingleModuleCard" frontend/src --type tsx -A 2

Repository: OWASP/Nest

Length of output: 81


🏁 Script executed:

# Check routes that contain '/my/mentorship'
find frontend/src -type f \( -name "*.tsx" -o -name "*.ts" \) -exec grep -l "/my/mentorship" {} \;

Repository: OWASP/Nest

Length of output: 804


🏁 Script executed:

# Check complete SingleModuleCard implementation to see where programKey is used
cat -n frontend/src/components/SingleModuleCard.tsx

Repository: OWASP/Nest

Length of output: 4605


🏁 Script executed:

# Look for how SingleModuleCard is used in pages
rg "SingleModuleCard" frontend/src/app -B 5 -A 5 --type ts

Repository: OWASP/Nest

Length of output: 36


🏁 Script executed:

# Check MenteeContributorsList implementation
cat -n frontend/src/components/MenteeContributorsList.tsx

Repository: OWASP/Nest

Length of output: 3163


Guard admin mentee rendering when programKey is missing.

When accessing /my/mentorship without a selected program, programKey becomes an empty string, causing MenteeContributorsList to generate malformed links like /my/mentorship/programs//modules/.... Add a guard to fall back to the public list instead.

🛠️ Suggested fix
-      {module.mentees?.length > 0 &&
-        (pathname?.startsWith('/my/mentorship') ? (
+      {module.mentees?.length > 0 &&
+        (pathname?.startsWith('/my/mentorship') && programKey ? (
           <MenteeContributorsList
             icon={HiUserGroup}
             contributors={module.mentees}
             maxInitialDisplay={6}
             label="Mentees"
             programKey={programKey}
             moduleKey={module.key}
           />
         ) : (
           <TopContributorsList
             icon={HiUserGroup}
             contributors={module.mentees}
             maxInitialDisplay={6}
             label="Mentees"
           />
         ))}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{module.mentees?.length > 0 &&
(pathname?.startsWith('/my/mentorship') ? (
<MenteeContributorsList
icon={HiUserGroup}
contributors={module.mentees}
maxInitialDisplay={6}
label="Mentees"
programKey={programKey}
moduleKey={module.key}
/>
) : (
<TopContributorsList
icon={HiUserGroup}
contributors={module.mentees}
maxInitialDisplay={6}
label="Mentees"
/>
))}
{module.mentees?.length > 0 &&
(pathname?.startsWith('/my/mentorship') && programKey ? (
<MenteeContributorsList
icon={HiUserGroup}
contributors={module.mentees}
maxInitialDisplay={6}
label="Mentees"
programKey={programKey}
moduleKey={module.key}
/>
) : (
<TopContributorsList
icon={HiUserGroup}
contributors={module.mentees}
maxInitialDisplay={6}
label="Mentees"
/>
))}
🤖 Prompt for AI Agents
In `@frontend/src/components/SingleModuleCard.tsx` around lines 92 - 109, The
admin-only branch renders MenteeContributorsList even when programKey is empty,
producing malformed links; change the conditional so that the
MenteeContributorsList is used only when pathname?.startsWith('/my/mentorship')
&& programKey (non-empty) is truthy, and fall back to TopContributorsList when
programKey is falsy; update the ternary around
MenteeContributorsList/TopContributorsList (which currently checks pathname) to
include the programKey guard and keep props (module.mentees, maxInitialDisplay,
label) consistent.

</div>
)
}
Expand Down