diff --git a/frontend/__tests__/src/data/mockChapterDetailsData.ts b/frontend/__tests__/src/data/mockChapterDetailsData.ts
new file mode 100644
index 000000000..6f724add9
--- /dev/null
+++ b/frontend/__tests__/src/data/mockChapterDetailsData.ts
@@ -0,0 +1,55 @@
+export const mockChaterDetailsData = {
+ name: 'OWASP Test Chapter',
+ suggested_location: 'Test City, Test Country',
+ region: 'Test Region',
+ is_active: true,
+ tags: 'test-tag',
+ updated_at: 1652129718,
+ url: 'https://owasp.org/test-chapter',
+ related_urls: [
+ 'https://discord.com/test',
+ 'https://www.instagram.com/test',
+ 'https://www.linkedin.com/test',
+ 'https://www.youtube.com/test',
+ 'https://twitter.com/test',
+ 'https://meetup.com/test',
+ ],
+ summary: 'This is a test chapter summary.',
+ top_contributors: [
+ {
+ avatar_url: 'https://example.com/avatar1.jpg',
+ name: 'Contributor 1',
+ contributions_count: 10,
+ },
+ {
+ avatar_url: 'https://example.com/avatar2.jpg',
+ name: 'Contributor 2',
+ contributions_count: 8,
+ },
+ {
+ avatar_url: 'https://example.com/avatar3.jpg',
+ name: 'Contributor 3',
+ contributions_count: 6,
+ },
+ {
+ avatar_url: 'https://example.com/avatar4.jpg',
+ name: 'Contributor 4',
+ contributions_count: 4,
+ },
+ {
+ avatar_url: 'https://example.com/avatar5.jpg',
+ name: 'Contributor 5',
+ contributions_count: 2,
+ },
+ {
+ avatar_url: 'https://example.com/avatar6.jpg',
+ name: 'Contributor 6',
+ contributions_count: 1,
+ },
+ {
+ avatar_url: 'https://example.com/avatar7.jpg',
+ name: 'Contributor 7',
+ contributions_count: 1,
+ },
+ ],
+}
diff --git a/frontend/__tests__/src/pages/ChapterDetails.test.tsx b/frontend/__tests__/src/pages/ChapterDetails.test.tsx
index a1fef7521..a30dbe018 100644
--- a/frontend/__tests__/src/pages/ChapterDetails.test.tsx
+++ b/frontend/__tests__/src/pages/ChapterDetails.test.tsx
@@ -1,25 +1,24 @@
import { screen, waitFor } from '@testing-library/react'
-
import { fetchAlgoliaData } from 'api/fetchAlgoliaData'
import { ChapterDetailsPage } from 'pages'
import { render } from 'wrappers/testUtil'
+import { mockChaterDetailsData } from '@tests/data/mockChapterDetailsData'
+jest.mock('api/fetchAlgoliaData')
-import { mockChapterData } from '@tests/data/mockChapterData'
-
-jest.mock('api/fetchAlgoliaData', () => ({
- fetchAlgoliaData: jest.fn(),
-}))
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
- useNavigate: jest.fn(),
+ useParams: () => ({
+ chapterKey: 'test-chapter',
+ }),
}))
-describe('ChapterDetailsPage Component', () => {
+describe('chapterDetailsPage Component', () => {
beforeEach(() => {
- ;(fetchAlgoliaData as jest.Mock).mockResolvedValue({
- hits: mockChapterData.chapters,
- totalPages: 2,
- })
+ ;(fetchAlgoliaData as jest.Mock).mockImplementation(() =>
+ Promise.resolve({
+ hits: [mockChaterDetailsData],
+ })
+ )
})
afterEach(() => {
@@ -37,18 +36,62 @@ describe('ChapterDetailsPage Component', () => {
test('renders chapter data correctly', async () => {
render()
await waitFor(() => {
- expect(screen.getByText('Chapter 1')).toBeInTheDocument()
+ expect(screen.getByText('Test City, Test Country')).toBeInTheDocument()
})
- expect(screen.getByText('This is a summary of Chapter 1.')).toBeInTheDocument()
- const viewButton = screen.getByText('Join')
- expect(viewButton).toBeInTheDocument()
+ expect(screen.getByText('Test Region')).toBeInTheDocument()
+ expect(screen.getByText('Test-tag')).toBeInTheDocument()
+ expect(screen.getByText('https://owasp.org/test-chapter')).toBeInTheDocument()
+ expect(screen.getByText('This is a test chapter summary.')).toBeInTheDocument()
})
- test('displays "Chapter not found" when there are no chapters', async () => {
+ test('displays "No chapters found" when there are no chapters', async () => {
;(fetchAlgoliaData as jest.Mock).mockResolvedValue({ hits: [], totalPages: 0 })
render()
await waitFor(() => {
expect(screen.getByText('Chapter not found')).toBeInTheDocument()
})
})
+
+ test('contributors visibility check', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.getByText('Contributor 1')).toBeInTheDocument()
+ })
+ expect(screen.queryByText('Contributor 7')).not.toBeInTheDocument()
+ })
+
+ test('renders chapter URL as clickable link', async () => {
+ render()
+
+ await waitFor(() => {
+ const link = screen.getByText('https://owasp.org/test-chapter')
+ expect(link.tagName).toBe('A')
+ expect(link).toHaveAttribute('href', 'https://owasp.org/test-chapter')
+ })
+ })
+
+ test('handles contributors with missing names gracefully', async () => {
+ const chapterDataWithIncompleteContributors = {
+ ...mockChaterDetailsData,
+ top_contributors: [
+ {
+ name: 'user1',
+ avatar_url: 'https://example.com/avatar1.jpg',
+ contributions_count: 30,
+ },
+ ],
+ }
+
+ ;(fetchAlgoliaData as jest.Mock).mockImplementation(() =>
+ Promise.resolve({
+ hits: [chapterDataWithIncompleteContributors],
+ })
+ )
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('user1')).toBeInTheDocument()
+ })
+ })
})
diff --git a/frontend/__tests__/src/pages/ProjectDetails.test.tsx b/frontend/__tests__/src/pages/ProjectDetails.test.tsx
index ffb156e9e..836e75269 100644
--- a/frontend/__tests__/src/pages/ProjectDetails.test.tsx
+++ b/frontend/__tests__/src/pages/ProjectDetails.test.tsx
@@ -2,8 +2,9 @@ import { within } from '@testing-library/dom'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { fetchAlgoliaData } from 'api/fetchAlgoliaData'
import { useNavigate } from 'react-router-dom'
+import { formatDate } from 'utils/dateFormatter'
import { render } from 'wrappers/testUtil'
-import ProjectDetailsPage, { formatDate } from 'pages/ProjectDetails'
+import ProjectDetailsPage from 'pages/ProjectDetails'
import { mockProjectDetailsData } from '@tests/data/mockProjectDetailsData'
jest.mock('api/fetchAlgoliaData')
@@ -27,6 +28,24 @@ describe('ProjectDetailsPage', () => {
afterEach(() => {
jest.clearAllMocks()
})
+ test('renders loading spinner initially', async () => {
+ render()
+ const loadingSpinner = screen.getAllByAltText('Loading indicator')
+ await waitFor(() => {
+ expect(loadingSpinner.length).toBeGreaterThan(0)
+ })
+ })
+
+ test('renders project data correctly', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.getByText('Test Project')).toBeInTheDocument()
+ })
+ expect(screen.getByText('This is a test project description')).toBeInTheDocument()
+ expect(screen.getByText('Tool')).toBeInTheDocument()
+ expect(screen.getByText('Flagship')).toBeInTheDocument()
+ expect(screen.getByText('OWASP')).toBeInTheDocument()
+ })
test('displays error when project is not found', async () => {
;(fetchAlgoliaData as jest.Mock).mockImplementationOnce(() => Promise.resolve({ hits: [] }))
diff --git a/frontend/src/pages/ChapterDetails.tsx b/frontend/src/pages/ChapterDetails.tsx
index 555aeb54e..beed272f9 100644
--- a/frontend/src/pages/ChapterDetails.tsx
+++ b/frontend/src/pages/ChapterDetails.tsx
@@ -1,17 +1,50 @@
+import {
+ faDiscord,
+ faInstagram,
+ faLinkedin,
+ faYoutube,
+ faMeetup,
+ faXTwitter,
+} from '@fortawesome/free-brands-svg-icons'
+import {
+ faGlobe,
+ faCalendarAlt,
+ faMapMarkerAlt,
+ faChevronDown,
+ faChevronUp,
+ faLink,
+ faTags,
+} from '@fortawesome/free-solid-svg-icons'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { fetchAlgoliaData } from 'api/fetchAlgoliaData'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
-import { getFilteredIcons, handleSocialUrls } from 'utils/utility'
+import { formatDate } from 'utils/dateFormatter'
import { ErrorDisplay } from 'wrappers/ErrorWrapper'
-import FontAwesomeIconWrapper from 'wrappers/FontAwesomeIconWrapper'
-
-import Card from 'components/Card'
import LoadingSpinner from 'components/LoadingSpinner'
-const ChapterDetailsPage = () => {
+const getSocialIcon = (url: string) => {
+ if (!/^https?:\/\//i.test(url)) {
+ url = 'http://' + url
+ }
+
+ const hostname = new URL(url).hostname.toLowerCase()
+
+ if (hostname === 'discord.com' || hostname.endsWith('.discord.com')) return faDiscord
+ if (hostname === 'instagram.com' || hostname.endsWith('.instagram.com')) return faInstagram
+ if (hostname === 'linkedin.com' || hostname.endsWith('.linkedin.com')) return faLinkedin
+ if (hostname === 'youtube.com' || hostname.endsWith('.youtube.com')) return faYoutube
+ if (hostname === 'twitter.com' || hostname === 'x.com' || hostname.endsWith('.x.com'))
+ return faXTwitter
+ if (hostname === 'meetup.com' || hostname.endsWith('.meetup.com')) return faMeetup
+ return faGlobe
+}
+
+export default function ChapterDetailsPage() {
const { chapterKey } = useParams()
const [chapter, setchapter] = useState(null)
const [isLoading, setIsLoading] = useState(true)
+ const [showAllContributors, setShowAllContributors] = useState(false)
useEffect(() => {
const fetchchapterData = async () => {
@@ -42,32 +75,128 @@ const ChapterDetailsPage = () => {
/>
)
- const params = ['updated_at']
- const filteredIcons = getFilteredIcons(chapter, params)
- const formattedUrls = handleSocialUrls(chapter.related_urls)
+ return (
+
+
+
{chapter.name}
- const SubmitButton = {
- label: 'Join',
- icon:
,
- url: chapter.url,
- }
+
+
+
Chapter Details
+
+
+
+
+
Location
+
{chapter.suggested_location}
+
+
- return (
-
-
-
+
+
+
+
Region
+
{chapter.region}
+
+
+
+
+
+
+
Tags
+
+ {chapter.tags[0].toUpperCase() + chapter.tags.slice(1)}
+
+
+
+
+
+
+
+
Last Updated
+
{formatDate(chapter.updated_at)}
+
+
+
+
+
+
+
Social Links
+
+ {chapter.related_urls.map((url, index) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
Summary
+
{chapter.summary}
+
+
+
+
+
Top Contributors
+
+ {chapter.top_contributors.length > 6 && (
+
+ )}
+
)
}
-export default ChapterDetailsPage
diff --git a/frontend/src/pages/ProjectDetails.tsx b/frontend/src/pages/ProjectDetails.tsx
index 1c3e0bec5..9a5595dcc 100644
--- a/frontend/src/pages/ProjectDetails.tsx
+++ b/frontend/src/pages/ProjectDetails.tsx
@@ -14,17 +14,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { fetchAlgoliaData } from 'api/fetchAlgoliaData'
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
+import { formatDate } from 'utils/dateFormatter'
import { ErrorDisplay } from 'wrappers/ErrorWrapper'
import LoadingSpinner from 'components/LoadingSpinner'
-export const formatDate = (timestamp: number) => {
- return new Date(timestamp * 1000).toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- })
-}
-
const ProjectDetailsPage = () => {
const { projectKey } = useParams()
const [project, setProject] = useState(null)
diff --git a/frontend/src/utils/dateFormatter.ts b/frontend/src/utils/dateFormatter.ts
new file mode 100644
index 000000000..6a3233607
--- /dev/null
+++ b/frontend/src/utils/dateFormatter.ts
@@ -0,0 +1,7 @@
+export const formatDate = (timestamp: number) => {
+ return new Date(timestamp * 1000).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ })
+}
diff --git a/frontend/src/utils/paramsMapping.ts b/frontend/src/utils/paramsMapping.ts
index 3fb427ccb..d219fcefe 100644
--- a/frontend/src/utils/paramsMapping.ts
+++ b/frontend/src/utils/paramsMapping.ts
@@ -26,8 +26,11 @@ export const getParamsForIndexName = (indexName: string, distinct = false) => {
'idx_key',
'idx_leaders',
'idx_name',
+ 'idx_region',
'idx_related_urls',
+ 'idx_suggested_location',
'idx_summary',
+ 'idx_tags',
'idx_top_contributors',
'idx_updated_at',
'idx_url',