diff --git a/backend/apps/github/graphql/nodes/milestone.py b/backend/apps/github/graphql/nodes/milestone.py
index 7a9d032c06..cc1eb67ae8 100644
--- a/backend/apps/github/graphql/nodes/milestone.py
+++ b/backend/apps/github/graphql/nodes/milestone.py
@@ -10,6 +10,7 @@ class MilestoneNode(BaseNode):
"""Github Milestone Node."""
organization_name = graphene.String()
+ progress = graphene.Float()
repository_name = graphene.String()
class Meta:
@@ -17,6 +18,7 @@ class Meta:
fields = (
"author",
+ "body",
"created_at",
"title",
"open_issues_count",
@@ -24,10 +26,17 @@ class Meta:
"url",
)
- def resolve_repository_name(self, info):
- """Resolve repository name."""
- return self.repository.name
-
def resolve_organization_name(self, info):
"""Return organization name."""
return self.repository.organization.login if self.repository.organization else None
+
+ def resolve_progress(self, info):
+ """Return milestone progress."""
+ total_issues_count = self.closed_issues_count + self.open_issues_count
+ if not total_issues_count:
+ return 0.0
+ return round((self.closed_issues_count / total_issues_count) * 100, 2)
+
+ def resolve_repository_name(self, info):
+ """Resolve repository name."""
+ return self.repository.name
diff --git a/backend/tests/apps/github/graphql/nodes/milestone_test.py b/backend/tests/apps/github/graphql/nodes/milestone_test.py
index 36492aebc6..45f629c14c 100644
--- a/backend/tests/apps/github/graphql/nodes/milestone_test.py
+++ b/backend/tests/apps/github/graphql/nodes/milestone_test.py
@@ -17,10 +17,12 @@ def test_meta_configuration(self):
assert MilestoneNode._meta.model == Milestone
expected_fields = {
"author",
+ "body",
"closed_issues_count",
"created_at",
"open_issues_count",
"organization_name",
+ "progress",
"repository_name",
"title",
"url",
diff --git a/frontend/__tests__/e2e/pages/About.spec.ts b/frontend/__tests__/e2e/pages/About.spec.ts
index 7af886b87f..5729ce0765 100644
--- a/frontend/__tests__/e2e/pages/About.spec.ts
+++ b/frontend/__tests__/e2e/pages/About.spec.ts
@@ -70,7 +70,10 @@ test.describe('About Page', () => {
test('loads roadmap items correctly', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Roadmap' })).toBeVisible()
- expect(await page.locator('li').count()).toBeGreaterThan(0)
+ for (const milestone of mockAboutData.project.recentMilestones) {
+ await expect(page.getByText(milestone.title)).toBeVisible()
+ await expect(page.getByText(milestone.body)).toBeVisible()
+ }
})
test('displays animated counters with correct values', async ({ page }) => {
diff --git a/frontend/__tests__/unit/data/mockAboutData.ts b/frontend/__tests__/unit/data/mockAboutData.ts
index c8ccefdffb..3a578ff732 100644
--- a/frontend/__tests__/unit/data/mockAboutData.ts
+++ b/frontend/__tests__/unit/data/mockAboutData.ts
@@ -4,6 +4,26 @@ export const mockAboutData = {
issuesCount: 40,
forksCount: 60,
starsCount: 890,
+ recentMilestones: [
+ {
+ title: 'NestBot title',
+ body: 'NestBot Idea',
+ url: 'http/github.com/milestones/5',
+ progress: 58,
+ },
+ {
+ title: 'Contribution Hub title',
+ body: 'Contribution Hub Idea',
+ url: 'http/github.com/milestones/8',
+ progress: 75,
+ },
+ {
+ title: 'Project Dashboard title',
+ body: 'Project Dashboard Idea',
+ url: 'http/github.com/milestones/10',
+ progress: 80,
+ },
+ ],
},
topContributors: Array.from({ length: 15 }, (_, i) => ({
avatarUrl: `https://avatars.githubusercontent.com/avatar${i + 1}.jpg`,
diff --git a/frontend/__tests__/unit/pages/About.test.tsx b/frontend/__tests__/unit/pages/About.test.tsx
index 4da6578229..679af79e64 100644
--- a/frontend/__tests__/unit/pages/About.test.tsx
+++ b/frontend/__tests__/unit/pages/About.test.tsx
@@ -3,6 +3,7 @@ import { addToast } from '@heroui/toast'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { mockAboutData } from '@unit/data/mockAboutData'
import { useRouter } from 'next/navigation'
+import { act } from 'react'
import { render } from 'wrappers/testUtil'
import About from 'app/about/page'
import { GET_PROJECT_METADATA, GET_TOP_CONTRIBUTORS } from 'server/queries/projectQueries'
@@ -34,11 +35,6 @@ jest.mock('utils/aboutData', () => ({
'This is a test paragraph about the project.',
'This is another paragraph about the project history.',
],
- roadmap: [
- { title: 'Feature 1', issueLink: 'https://github.com/owasp/test/issues/1' },
- { title: 'Feature 2', issueLink: 'https://github.com/owasp/test/issues/2' },
- { title: 'Feature 3', issueLink: 'https://github.com/owasp/test/issues/3' },
- ],
technologies: [
{
section: 'Backend',
@@ -129,13 +125,7 @@ describe('About Component', () => {
return mockTopContributorsData
}
} else if (query === GET_LEADER_DATA) {
- if (key === 'arkid15r') {
- return mockUserData('arkid15r')
- } else if (key === 'kasya') {
- return mockUserData('kasya')
- } else if (key === 'mamicidal') {
- return mockUserData('mamicidal')
- }
+ return mockUserData(key)
}
return { loading: true }
@@ -149,7 +139,9 @@ describe('About Component', () => {
})
test('renders project history correctly', async () => {
- render()
+ await act(async () => {
+ render()
+ })
const historySection = screen.getByText('History').closest('div')
expect(historySection).toBeInTheDocument()
@@ -163,7 +155,9 @@ describe('About Component', () => {
})
test('renders leaders section with three leaders', async () => {
- render()
+ await act(async () => {
+ render()
+ })
const leadersSection = screen.getByText('Leaders').closest('div')
expect(leadersSection).toBeInTheDocument()
@@ -183,15 +177,15 @@ describe('About Component', () => {
return mockProjectData
} else if (options?.variables?.key === 'arkid15r') {
return { data: null, loading: false, error: mockError }
- } else if (options?.variables?.key === 'kasya') {
- return mockUserData('kasya')
- } else if (options?.variables?.key === 'mamicidal') {
- return mockUserData('mamicidal')
+ } else if (options?.variables?.key === 'kasya' || options?.variables?.key === 'mamicidal') {
+ return mockUserData(options?.variables?.key)
}
return { loading: true }
})
- render()
+ await act(async () => {
+ render()
+ })
await waitFor(() => {
expect(screen.getByText("Error loading arkid15r's data")).toBeInTheDocument()
@@ -201,7 +195,9 @@ describe('About Component', () => {
})
test('renders top contributors section correctly', async () => {
- render()
+ await act(async () => {
+ render()
+ })
await waitFor(() => {
expect(screen.getByText('Top Contributors')).toBeInTheDocument()
@@ -212,7 +208,9 @@ describe('About Component', () => {
})
test('toggles contributors list when show more/less is clicked', async () => {
- render()
+ await act(async () => {
+ render()
+ })
await waitFor(() => {
expect(screen.getByText('Contributor 6')).toBeInTheDocument()
expect(screen.queryByText('Contributor 10')).not.toBeInTheDocument()
@@ -235,7 +233,9 @@ describe('About Component', () => {
})
test('renders technologies section correctly', async () => {
- render()
+ await act(async () => {
+ render()
+ })
const technologiesSection = screen.getByText('Technologies & Tools').closest('div')
expect(technologiesSection).toBeInTheDocument()
@@ -269,26 +269,29 @@ describe('About Component', () => {
})
test('renders roadmap correctly', async () => {
- render()
+ await act(async () => {
+ render()
+ })
const roadmapSection = screen.getByRole('heading', { name: 'Roadmap' }).closest('div')
expect(roadmapSection).toBeInTheDocument()
-
- const roadmapItems = within(roadmapSection).getAllByRole('listitem')
- expect(roadmapItems).toHaveLength(3)
-
- expect(screen.getByText('Feature 1')).toBeInTheDocument()
- expect(screen.getByText('Feature 2')).toBeInTheDocument()
- expect(screen.getByText('Feature 3')).toBeInTheDocument()
-
+ const roadmapData = mockAboutData.project.recentMilestones
const links = within(roadmapSection)
.getAllByRole('link')
.filter((link) => link.getAttribute('href') !== '#roadmap')
- expect(links[0].getAttribute('href')).toBe('https://github.com/owasp/test/issues/1')
+
+ for (let i = 0; i < roadmapData.length; i++) {
+ const milestone = [...roadmapData].sort((a, b) => (a.title > b.title ? 1 : -1))[i]
+ expect(screen.getByText(milestone.title)).toBeInTheDocument()
+ expect(screen.getByText(milestone.body)).toBeInTheDocument()
+ expect(links[i].getAttribute('href')).toBe(milestone.url)
+ }
})
test('renders project stats cards correctly', async () => {
- render()
+ await act(async () => {
+ render()
+ })
await waitFor(() => {
expect(screen.getByText('Contributors')).toBeInTheDocument()
@@ -312,7 +315,9 @@ describe('About Component', () => {
return { loading: true }
})
- render()
+ await act(async () => {
+ render()
+ })
await waitFor(() => {
expect(screen.getByText('Loading arkid15r...')).toBeInTheDocument()
@@ -341,7 +346,9 @@ describe('About Component', () => {
return { loading: true }
})
- render()
+ await act(async () => {
+ render()
+ })
await waitFor(() => {
expect(screen.getByText('No data available for arkid15r')).toBeInTheDocument()
@@ -366,7 +373,9 @@ describe('About Component', () => {
return { loading: true }
})
- render()
+ await act(async () => {
+ render()
+ })
await waitFor(() => {
expect(screen.getByText('Data not found')).toBeInTheDocument()
@@ -388,7 +397,9 @@ describe('About Component', () => {
return { loading: true }
})
- render()
+ await act(async () => {
+ render()
+ })
await waitFor(() => {
expect(screen.getByText('No data available for arkid15r')).toBeInTheDocument()
@@ -398,7 +409,9 @@ describe('About Component', () => {
})
test('navigates to user details on View Profile button click', async () => {
- render()
+ await act(async () => {
+ render()
+ })
await waitFor(() => {
const viewDetailsButtons = screen.getAllByText('View Profile')
@@ -434,7 +447,9 @@ describe('About Component', () => {
return { loading: true }
})
- render()
+ await act(async () => {
+ render()
+ })
await waitFor(() => {
expect(screen.getByText('arkid15r')).toBeInTheDocument()
@@ -457,7 +472,9 @@ describe('About Component', () => {
return { loading: true }
})
- render()
+ await act(async () => {
+ render()
+ })
await waitFor(() => {
expect(screen.getByText(/No data available for arkid15r/i)).toBeInTheDocument()
@@ -476,7 +493,9 @@ describe('About Component', () => {
}
})
- render()
+ await act(async () => {
+ render()
+ })
await waitFor(() => {
// Look for the element with alt text "Loading indicator"
const spinner = screen.getAllByAltText('Loading indicator')
@@ -495,7 +514,9 @@ describe('About Component', () => {
error: null,
}
})
- render()
+ await act(async () => {
+ render()
+ })
await waitFor(() => {
expect(screen.getByText(/Data not found/)).toBeInTheDocument()
expect(
@@ -515,7 +536,9 @@ describe('About Component', () => {
error: null,
}
})
- render()
+ await act(async () => {
+ render()
+ })
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith({
color: 'danger',
@@ -539,7 +562,9 @@ describe('About Component', () => {
error: null,
}
})
- render()
+ await act(async () => {
+ render()
+ })
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith({
color: 'danger',
diff --git a/frontend/src/app/about/page.tsx b/frontend/src/app/about/page.tsx
index b3cb5bc19b..7f105f3067 100644
--- a/frontend/src/app/about/page.tsx
+++ b/frontend/src/app/about/page.tsx
@@ -1,12 +1,17 @@
'use client'
import { useQuery } from '@apollo/client'
import {
+ faCircleCheck,
+ faClock,
+ faUserGear,
faMapSigns,
faScroll,
faUsers,
faTools,
faArrowUpRightFromSquare,
} from '@fortawesome/free-solid-svg-icons'
+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'
@@ -18,7 +23,7 @@ import { GET_LEADER_DATA } from 'server/queries/userQueries'
import { TopContributorsTypeGraphql } from 'types/contributor'
import { ProjectTypeGraphql } from 'types/project'
import { User } from 'types/user'
-import { aboutText, roadmap, technologies } from 'utils/aboutData'
+import { aboutText, technologies } from 'utils/aboutData'
import AnchorTitle from 'components/AnchorTitle'
import AnimatedCounter from 'components/AnimatedCounter'
import LoadingSpinner from 'components/LoadingSpinner'
@@ -159,20 +164,56 @@ const About = () => {
}>
-
- {roadmap.map((item) => (
- -
-
-
+ {[...projectMetadata.recentMilestones]
+ .sort((a, b) => (a.title > b.title ? 1 : -1))
+ .map((milestone, index) => (
+
- {item.title}
-
-
- ))}
-
+
+
+
+ {milestone.title}
+ 0
+ ? 'In Progress'
+ : 'Not Started'
+ }
+ id={`tooltip-state-${index}`}
+ delay={100}
+ placement="top"
+ showArrow
+ >
+
+ 0
+ ? faUserGear
+ : faClock
+ }
+ />
+
+
+
+
+
{milestone.body}
+
+
+ ))}
+
diff --git a/frontend/src/app/members/[memberKey]/page.tsx b/frontend/src/app/members/[memberKey]/page.tsx
index c4395fef0c..f69ad3edfd 100644
--- a/frontend/src/app/members/[memberKey]/page.tsx
+++ b/frontend/src/app/members/[memberKey]/page.tsx
@@ -9,7 +9,7 @@ import {
import Image from 'next/image'
import Link from 'next/link'
import { useParams } from 'next/navigation'
-import React, { useState, useEffect, useRef, useMemo } from 'react'
+import React, { useState, useEffect, useRef } from 'react'
import { handleAppError, ErrorDisplay } from 'app/global-error'
import { GET_USER_DATA } from 'server/queries/userQueries'
import type {
@@ -18,7 +18,7 @@ import type {
ProjectReleaseType,
RepositoryCardProps,
} from 'types/project'
-import type { ItemCardPullRequests, PullRequestsType, UserDetailsProps } from 'types/user'
+import type { ItemCardPullRequests, UserDetailsProps } from 'types/user'
import { formatDate } from 'utils/dateFormatter'
import { drawContributions, fetchHeatmapData, HeatmapData } from 'utils/helpers/githubHeatmap'
import DetailsCard from 'components/CardDetailsPage'
@@ -30,7 +30,7 @@ const UserDetailsPage: React.FC = () => {
const [issues, setIssues] = useState
([])
const [topRepositories, setTopRepositories] = useState([])
const [milestones, setMilestones] = useState([])
- const [pullRequests, setPullRequests] = useState([])
+ const [pullRequests, setPullRequests] = useState([])
const [releases, setReleases] = useState([])
const [data, setData] = useState({} as HeatmapData)
const [isLoading, setIsLoading] = useState(true)
@@ -107,82 +107,6 @@ const UserDetailsPage: React.FC = () => {
return {word}
})
- const formattedIssues: ProjectIssuesType[] = useMemo(() => {
- return (
- issues?.map((issue) => ({
- author: {
- avatarUrl: user?.avatarUrl || '',
- key: user?.login || '',
- login: user?.login || '',
- name: user?.name || user?.login || '',
- },
- createdAt: issue.createdAt,
- organizationName: issue.organizationName,
- repositoryName: issue.repositoryName,
- title: issue.title,
- url: issue.url,
- })) || []
- )
- }, [user, issues])
-
- const formattedPullRequest: ItemCardPullRequests[] = useMemo(() => {
- return (
- pullRequests?.map((pullRequest) => ({
- author: {
- avatarUrl: user?.avatarUrl || '',
- key: user?.login || '',
- login: user?.login || '',
- name: user?.name || user?.login || '',
- },
- createdAt: pullRequest.createdAt,
- organizationName: pullRequest.organizationName,
- repositoryName: pullRequest.repositoryName,
- title: pullRequest.title,
- url: pullRequest.url,
- })) || []
- )
- }, [pullRequests, user])
-
- const formattedReleases: ProjectReleaseType[] = useMemo(() => {
- return (
- releases?.map((release) => ({
- author: {
- avatarUrl: user?.avatarUrl || '',
- key: user?.login || '',
- login: user?.login || '',
- name: user?.name || user?.login || '',
- },
- isPreRelease: release.isPreRelease,
- name: release.name,
- organizationName: release.organizationName,
- publishedAt: release.publishedAt,
- repositoryName: release.repositoryName,
- tagName: release.tagName,
- url: release.url,
- })) || []
- )
- }, [releases, user])
-
- const formattedMilestones: ProjectMilestonesType[] = useMemo(() => {
- return (
- milestones?.map((milestone) => ({
- author: {
- avatarUrl: user?.avatarUrl || '',
- key: user?.login || '',
- login: user?.login || '',
- name: user?.name || user?.login || '',
- },
- createdAt: milestone.createdAt,
- openIssuesCount: milestone.openIssuesCount,
- closedIssuesCount: milestone.closedIssuesCount,
- organizationName: milestone.organizationName,
- repositoryName: milestone.repositoryName,
- title: milestone.title,
- url: milestone.url,
- })) || []
- )
- }, [milestones, user])
-
if (isLoading) {
return
}
@@ -270,10 +194,10 @@ const UserDetailsPage: React.FC = () => {
}
- pullRequests={formattedPullRequest}
- recentIssues={formattedIssues}
- recentMilestones={formattedMilestones}
- recentReleases={formattedReleases}
+ pullRequests={pullRequests}
+ recentIssues={issues}
+ recentMilestones={milestones}
+ recentReleases={releases}
repositories={topRepositories}
showAvatar={false}
stats={userStats}
diff --git a/frontend/src/server/queries/projectQueries.ts b/frontend/src/server/queries/projectQueries.ts
index 63249309e0..69b1fb1055 100644
--- a/frontend/src/server/queries/projectQueries.ts
+++ b/frontend/src/server/queries/projectQueries.ts
@@ -103,6 +103,12 @@ export const GET_PROJECT_METADATA = gql`
name
starsCount
summary
+ recentMilestones {
+ title
+ url
+ body
+ progress
+ }
}
}
`
diff --git a/frontend/src/types/project.ts b/frontend/src/types/project.ts
index 45cfdaaade..92580440ec 100644
--- a/frontend/src/types/project.ts
+++ b/frontend/src/types/project.ts
@@ -36,13 +36,15 @@ export interface ProjectMilestonesType {
name: string
login: string
}
+ body: string
title: string
openIssuesCount: number
closedIssuesCount: number
+ progress?: number
repositoryName: string
organizationName?: string
createdAt: string
- url: string
+ url?: string
}
export interface ProjectStatsType {
diff --git a/frontend/src/utils/aboutData.ts b/frontend/src/utils/aboutData.ts
index ce747eefbc..1c51cb0c95 100644
--- a/frontend/src/utils/aboutData.ts
+++ b/frontend/src/utils/aboutData.ts
@@ -4,35 +4,6 @@ export const aboutText = [
'The code is licensed under the MIT License, encouraging contributions while protecting the authors from legal claims. All OWASP Nest leaders are certified ISC2 professionals and OWASP members who adhere to the OWASP Code of Conduct.',
]
-export const roadmap = [
- {
- title: 'Create OWASP Contribution Hub to centralize collaboration opportunities',
- issueLink: 'https://github.com/OWASP/Nest/issues/710',
- },
- {
- title:
- 'Design and launch the OWASP API for chapters, projects, committees, and other OWASP entities',
- issueLink: 'https://github.com/OWASP/Nest/issues/707',
- },
- {
- title:
- 'Develop OWASP Schema to standardize metadata for chapters, projects, and other entities',
- issueLink: 'https://github.com/OWASP/Nest/issues/709',
- },
- {
- title: 'Extend OWASP NestBot with AI agent/assistant capabilities',
- issueLink: 'https://github.com/OWASP/Nest/issues/908',
- },
- {
- title: 'Implement OWASP Project Health Dashboard',
- issueLink: 'https://github.com/OWASP/Nest/issues/711',
- },
- {
- title: 'Migrate OWASP Nest to Kubernetes',
- issueLink: 'https://github.com/OWASP/Nest/issues/706',
- },
-]
-
export const technologies = [
{
section: 'Backend',