diff --git a/backend/apps/owasp/api/internal/nodes/common.py b/backend/apps/owasp/api/internal/nodes/common.py
index 798e07b269..c70f14b0ff 100644
--- a/backend/apps/owasp/api/internal/nodes/common.py
+++ b/backend/apps/owasp/api/internal/nodes/common.py
@@ -9,6 +9,11 @@
class GenericEntityNode:
"""Base node class for OWASP entities with common fields and resolvers."""
+ @strawberry.field
+ def leaders_logins(self) -> list[str]:
+ """Resolve leaders logins."""
+ return [leader.login for leader in self.leaders.all()]
+
@strawberry.field
def leaders(self) -> list[str]:
"""Resolve leaders."""
diff --git a/frontend/__tests__/e2e/pages/ChapterDetails.spec.ts b/frontend/__tests__/e2e/pages/ChapterDetails.spec.ts
index e4bb465ea9..72a0dceffb 100644
--- a/frontend/__tests__/e2e/pages/ChapterDetails.spec.ts
+++ b/frontend/__tests__/e2e/pages/ChapterDetails.spec.ts
@@ -55,4 +55,8 @@ test.describe('Chapter Details Page', () => {
await page.getByRole('button', { name: 'Show less' }).click()
await expect(page.getByRole('button', { name: 'Show more' })).toBeVisible()
})
+
+ test('should have a leaders list block', async ({ page }) => {
+ await expect(page.getByRole('heading', { name: 'Leaders' })).toBeVisible()
+ })
})
diff --git a/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts b/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts
index af8de6b13d..52e6d7313c 100644
--- a/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts
+++ b/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts
@@ -123,4 +123,8 @@ test.describe('Project Details Page', () => {
await expect(page.getByText('Forks Trend')).toBeVisible()
await expect(page.getByText('Days Since Last Commit and Release')).toBeVisible()
})
+
+ test('should have a leaders list block', async ({ page }) => {
+ await expect(page.getByRole('heading', { name: 'Leaders' })).toBeVisible()
+ })
})
diff --git a/frontend/__tests__/unit/components/LeadersListBlock.test.tsx b/frontend/__tests__/unit/components/LeadersListBlock.test.tsx
new file mode 100644
index 0000000000..4cf0688476
--- /dev/null
+++ b/frontend/__tests__/unit/components/LeadersListBlock.test.tsx
@@ -0,0 +1,113 @@
+import { MockedProvider } from '@apollo/client/testing'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { useRouter } from 'next/navigation'
+import { GET_LEADER_DATA } from 'server/queries/userQueries'
+import LeadersListBlock from 'components/LeadersListBlock'
+
+jest.mock('next/navigation', () => ({
+ useRouter: jest.fn(),
+}))
+
+const mockLeaders = {
+ leader1: 'Role 1',
+ leader2: 'Role 2',
+}
+
+const mocks = [
+ {
+ request: {
+ query: GET_LEADER_DATA,
+ variables: { key: 'leader1' },
+ },
+ result: {
+ data: {
+ user: {
+ login: 'leader1',
+ avatarUrl: 'https://avatars.githubusercontent.com/u/1',
+ company: 'Company 1',
+ location: 'Location 1',
+ name: 'Leader One',
+ },
+ },
+ },
+ },
+ {
+ request: {
+ query: GET_LEADER_DATA,
+ variables: { key: 'leader2' },
+ },
+ result: {
+ data: {
+ user: {
+ login: 'leader2',
+ avatarUrl: 'https://avatars.githubusercontent.com/u/2',
+ company: 'Company 2',
+ location: 'Location 2',
+ name: 'Leader Two',
+ },
+ },
+ },
+ },
+]
+
+describe('LeadersListBlock', () => {
+ const push = jest.fn()
+ beforeEach(() => {
+ ;(useRouter as jest.Mock).mockReturnValue({ push })
+ })
+
+ test('renders loading state initially', () => {
+ render(
+
+
+
+ )
+
+ expect(screen.getByText('Loading leader1...')).toBeInTheDocument()
+ expect(screen.getByText('Loading leader2...')).toBeInTheDocument()
+ })
+
+ test('renders user cards on successful data fetch', async () => {
+ render(
+
+
+
+ )
+
+ expect(await screen.findByText('Leader One')).toBeInTheDocument()
+ expect(await screen.findByText('Leader Two')).toBeInTheDocument()
+ })
+
+ test('navigates to the correct user profile on button click', async () => {
+ render(
+
+
+
+ )
+
+ const viewProfileButton = await screen.findAllByText('View Profile')
+ fireEvent.click(viewProfileButton[0])
+
+ expect(push).toHaveBeenCalledWith('/members/leader1')
+ })
+
+ test('renders an error message if the query fails', async () => {
+ const errorMocks = [
+ {
+ request: {
+ query: GET_LEADER_DATA,
+ variables: { key: 'leader1' },
+ },
+ error: new Error('An error occurred'),
+ },
+ ]
+
+ render(
+
+
+
+ )
+
+ expect(await screen.findByText("Error loading leader1's data")).toBeInTheDocument()
+ })
+})
diff --git a/frontend/__tests__/unit/data/mockChapterDetailsData.ts b/frontend/__tests__/unit/data/mockChapterDetailsData.ts
index f5963686d3..6c97890b8b 100644
--- a/frontend/__tests__/unit/data/mockChapterDetailsData.ts
+++ b/frontend/__tests__/unit/data/mockChapterDetailsData.ts
@@ -21,6 +21,7 @@ export const mockChapterDetailsData = {
},
establishedYear: 2020,
key: 'test-chapter',
+ leadersLogins: ['leader1', 'leader2'],
},
topContributors: Array.from({ length: 15 }, (_, i) => ({
avatarUrl: `https://avatars.githubusercontent.com/avatar${i + 1}.jpg`,
diff --git a/frontend/__tests__/unit/data/mockProjectDetailsData.ts b/frontend/__tests__/unit/data/mockProjectDetailsData.ts
index 9d9c195022..6eea638302 100644
--- a/frontend/__tests__/unit/data/mockProjectDetailsData.ts
+++ b/frontend/__tests__/unit/data/mockProjectDetailsData.ts
@@ -20,6 +20,7 @@ export const mockProjectDetailsData = {
key: 'example-project',
languages: ['Python', 'GraphQL', 'JavaScript'],
leaders: ['alice', 'bob'],
+ leadersLogins: ['alice', 'bob'],
level: 'Lab',
name: 'Test Project',
recentIssues: [
diff --git a/frontend/src/app/about/page.tsx b/frontend/src/app/about/page.tsx
index c852ec6e37..3a197bd1cf 100644
--- a/frontend/src/app/about/page.tsx
+++ b/frontend/src/app/about/page.tsx
@@ -14,24 +14,20 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Tooltip } from '@heroui/tooltip'
import Image from 'next/image'
import Link from 'next/link'
-import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
-import FontAwesomeIconWrapper from 'wrappers/FontAwesomeIconWrapper'
import { ErrorDisplay, handleAppError } from 'app/global-error'
import { GET_PROJECT_METADATA, GET_TOP_CONTRIBUTORS } from 'server/queries/projectQueries'
-import { GET_LEADER_DATA } from 'server/queries/userQueries'
import type { Contributor } from 'types/contributor'
import type { Project } from 'types/project'
-import type { User } from 'types/user'
import { aboutText, technologies } from 'utils/aboutData'
import { capitalize } from 'utils/capitalize'
import AnchorTitle from 'components/AnchorTitle'
import AnimatedCounter from 'components/AnimatedCounter'
+import LeadersListBlock from 'components/LeadersListBlock'
import LoadingSpinner from 'components/LoadingSpinner'
import Markdown from 'components/MarkdownWrapper'
import SecondaryCard from 'components/SecondaryCard'
import TopContributorsList from 'components/TopContributorsList'
-import UserCard from 'components/UserCard'
const leaders = {
arkid15r: 'CCSP, CISSP, CSSLP',
@@ -112,15 +108,9 @@ const About = () => {
))}
- }>
-
- {Object.keys(leaders).map((username) => (
-
-
-
- ))}
-
-
+ {leaders && (
+
+ )}
{topContributors && (
{
)
}
-const LeaderData = ({ username }: { username: string }) => {
- const { data, loading, error } = useQuery(GET_LEADER_DATA, {
- variables: { key: username },
- })
- const router = useRouter()
-
- if (loading) return Loading {username}...
- if (error) return Error loading {username}'s data
-
- const user = data?.user
-
- if (!user) {
- return No data available for {username}
- }
-
- const handleButtonClick = (user: User) => {
- router.push(`/members/${user.login}`)
- }
-
- return (
- ,
- label: 'View Profile',
- onclick: () => handleButtonClick(user),
- }}
- className="h-64 w-40 bg-inherit"
- company={user.company}
- description={leaders[user.login]}
- location={user.location}
- name={user.name || username}
- />
- )
-}
-
export default About
diff --git a/frontend/src/app/chapters/[chapterKey]/page.tsx b/frontend/src/app/chapters/[chapterKey]/page.tsx
index d6a7f1a16c..893d9c495f 100644
--- a/frontend/src/app/chapters/[chapterKey]/page.tsx
+++ b/frontend/src/app/chapters/[chapterKey]/page.tsx
@@ -68,6 +68,7 @@ export default function ChapterDetailsPage() {
socialLinks={chapter.relatedUrls}
summary={chapter.summary}
title={chapter.name}
+ leadersLogins={chapter.leadersLogins}
topContributors={topContributors}
type="chapter"
/>
diff --git a/frontend/src/app/projects/[projectKey]/page.tsx b/frontend/src/app/projects/[projectKey]/page.tsx
index a563a1daca..5f08f5c166 100644
--- a/frontend/src/app/projects/[projectKey]/page.tsx
+++ b/frontend/src/app/projects/[projectKey]/page.tsx
@@ -103,6 +103,7 @@ const ProjectDetailsPage = () => {
stats={projectStats}
summary={project.summary}
title={project.name}
+ leadersLogins={project.leadersLogins}
topContributors={topContributors}
topics={project.topics}
type="project"
diff --git a/frontend/src/components/CardDetailsPage.tsx b/frontend/src/components/CardDetailsPage.tsx
index 4eb1e9f18f..644c903fe4 100644
--- a/frontend/src/components/CardDetailsPage.tsx
+++ b/frontend/src/components/CardDetailsPage.tsx
@@ -7,10 +7,12 @@ import {
faTags,
faUsers,
faRectangleList,
+ faArrowUpRightFromSquare,
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import Link from 'next/link'
import type { DetailsCardProps } from 'types/card'
+import { LeadersListBlockProps } from 'types/leaders'
import { capitalize } from 'utils/capitalize'
import { IS_PROJECT_HEALTH_ENABLED } from 'utils/credentials'
import { getSocialIcon } from 'utils/urlIconMappings'
@@ -19,6 +21,7 @@ import ChapterMapWrapper from 'components/ChapterMapWrapper'
import HealthMetrics from 'components/HealthMetrics'
import InfoBlock from 'components/InfoBlock'
import LeadersList from 'components/LeadersList'
+import LeadersListBlock from 'components/LeadersListBlock'
import MetricsScoreCircle from 'components/MetricsScoreCircle'
import Milestones from 'components/Milestones'
import RecentIssues from 'components/RecentIssues'
@@ -50,6 +53,7 @@ const DetailsCard = ({
stats,
summary,
title,
+ leadersLogins,
topContributors,
topics,
type,
@@ -171,6 +175,16 @@ const DetailsCard = ({
)}
)}
+ {leadersLogins && leadersLogins.length > 0 && (
+ {
+ acc[login] = ''
+ return acc
+ }, {} as LeadersListBlockProps)}
+ />
+ )}
{topContributors && (
{
+ return (
+ }>
+
+ {Object.keys(leaders).map((username) => (
+
+
+
+ ))}
+
+
+ )
+}
+
+const LeaderData = ({
+ username,
+ leaders,
+}: {
+ username: string
+ leaders: LeadersListBlockProps
+}) => {
+ const { data, loading, error } = useQuery(GET_LEADER_DATA, {
+ variables: { key: username },
+ })
+ const router = useRouter()
+
+ if (loading) return Loading {username}...
+ if (error) return Error loading {username}'s data
+
+ const user = data?.user
+
+ if (!user) {
+ return No data available for {username}
+ }
+
+ const handleButtonClick = (user: User) => {
+ router.push(`/members/${user.login}`)
+ }
+
+ return (
+ ,
+ label: 'View Profile',
+ onclick: () => handleButtonClick(user),
+ }}
+ className="w-42 h-64 bg-inherit"
+ company={user.company}
+ description={leaders[user.login]}
+ location={user.location}
+ name={user.name || username}
+ />
+ )
+}
+
+export default LeadersListBlock
diff --git a/frontend/src/server/queries/chapterQueries.ts b/frontend/src/server/queries/chapterQueries.ts
index 2482c06beb..3b4add0c93 100644
--- a/frontend/src/server/queries/chapterQueries.ts
+++ b/frontend/src/server/queries/chapterQueries.ts
@@ -16,6 +16,7 @@ export const GET_CHAPTER_DATA = gql`
summary
updatedAt
url
+ leadersLogins
}
topContributors(chapter: $key) {
avatarUrl
diff --git a/frontend/src/server/queries/projectQueries.ts b/frontend/src/server/queries/projectQueries.ts
index 30f7773b7a..06458b57e4 100644
--- a/frontend/src/server/queries/projectQueries.ts
+++ b/frontend/src/server/queries/projectQueries.ts
@@ -10,6 +10,7 @@ export const GET_PROJECT_DATA = gql`
key
languages
leaders
+ leadersLogins
level
name
healthMetricsList(limit: 30) {
diff --git a/frontend/src/types/card.ts b/frontend/src/types/card.ts
index e95150a892..a478687dbb 100644
--- a/frontend/src/types/card.ts
+++ b/frontend/src/types/card.ts
@@ -53,6 +53,7 @@ export interface DetailsCardProps {
stats?: Stats[]
summary?: string
title?: string
+ leadersLogins?: string[]
topContributors?: Contributor[]
topics?: string[]
type: string
diff --git a/frontend/src/types/chapter.ts b/frontend/src/types/chapter.ts
index c6ee7bbe70..fc77b68da2 100644
--- a/frontend/src/types/chapter.ts
+++ b/frontend/src/types/chapter.ts
@@ -13,6 +13,7 @@ export type Chapter = {
relatedUrls: string[]
suggestedLocation: string
summary: string
+ leadersLogins?: string[]
topContributors: Contributor[]
updatedAt: number
url: string
diff --git a/frontend/src/types/leaders.ts b/frontend/src/types/leaders.ts
index ca543e7220..c02d39686a 100644
--- a/frontend/src/types/leaders.ts
+++ b/frontend/src/types/leaders.ts
@@ -1,3 +1,8 @@
export type LeadersListProps = {
leaders: string
}
+
+// Maps username to certification strings (e.g., { arkid15r: 'CCSP, CISSP, CSSLP' })
+export type LeadersListBlockProps = {
+ [key: string]: string
+}
diff --git a/frontend/src/types/project.ts b/frontend/src/types/project.ts
index 93057d4af3..be617c2cc9 100644
--- a/frontend/src/types/project.ts
+++ b/frontend/src/types/project.ts
@@ -25,6 +25,7 @@ export type Project = {
key: string
languages: string[]
leaders: string[]
+ leadersLogins?: string[]
level: string
name: string
openIssuesCount?: number