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

+
+ {(showAllContributors + ? chapter.top_contributors + : chapter.top_contributors.slice(0, 6) + ).map((contributor, index) => ( + + {contributor.name +
+

+ {contributor.name || contributor.login} +

+

+ {contributor.contributions_count} contributions +

+
+
+ ))} +
+ {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',