diff --git a/backend/apps/github/api/internal/queries/organization.py b/backend/apps/github/api/internal/queries/organization.py index 996b287b62..902702bb2f 100644 --- a/backend/apps/github/api/internal/queries/organization.py +++ b/backend/apps/github/api/internal/queries/organization.py @@ -1,10 +1,14 @@ """GitHub organization GraphQL queries.""" import strawberry +import strawberry_django +from apps.common.utils import normalize_limit from apps.github.api.internal.nodes.organization import OrganizationNode from apps.github.models.organization import Organization +MAX_LIMIT = 100 + @strawberry.type class OrganizationQuery: @@ -29,3 +33,21 @@ def organization( return Organization.objects.get(is_owasp_related_organization=True, login=login) except Organization.DoesNotExist: return None + + @strawberry_django.field + def recent_organizations(self, limit: int = 5) -> list[OrganizationNode]: + """Resolve recent organizations. + + Args: + limit (int): Maximum number of organizations to return. + + Returns: + list: List of recent organizations. + + """ + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + + return Organization.objects.filter(is_owasp_related_organization=True).order_by( + "-created_at" + )[:normalized_limit] diff --git a/backend/tests/apps/github/api/internal/queries/organization_test.py b/backend/tests/apps/github/api/internal/queries/organization_test.py index 0b439bff17..5d15c9d0cb 100644 --- a/backend/tests/apps/github/api/internal/queries/organization_test.py +++ b/backend/tests/apps/github/api/internal/queries/organization_test.py @@ -48,3 +48,35 @@ def test_organization_with_different_login(self, mock_get, mock_organization): assert result == mock_organization mock_get.assert_called_once_with(is_owasp_related_organization=True, login="test-org") + + @patch("apps.github.models.organization.Organization.objects.filter") + def test_recent_organizations(self, mock_filter): + """Test fetching recent organizations.""" + mock_qs = Mock() + mock_ordered_qs = Mock() + mock_filter.return_value = mock_qs + mock_qs.order_by.return_value = mock_ordered_qs + mock_ordered_qs.__getitem__ = Mock(return_value=["org1", "org2"]) + + result = OrganizationQuery().recent_organizations(limit=2) + + assert result == ["org1", "org2"] + mock_filter.assert_called_once_with(is_owasp_related_organization=True) + mock_qs.order_by.assert_called_once_with("-created_at") + mock_ordered_qs.__getitem__.assert_called_once_with(slice(None, 2)) + + @patch("apps.github.models.organization.Organization.objects.filter") + def test_recent_organizations_with_zero_limit(self, mock_filter): + """Test fetching recent organizations with zero limit returns empty list.""" + result = OrganizationQuery().recent_organizations(limit=0) + + assert result == [] + mock_filter.assert_not_called() + + @patch("apps.github.models.organization.Organization.objects.filter") + def test_recent_organizations_with_negative_limit(self, mock_filter): + """Test fetching recent organizations with negative limit returns empty list.""" + result = OrganizationQuery().recent_organizations(limit=-1) + + assert result == [] + mock_filter.assert_not_called() diff --git a/frontend/__tests__/a11y/pages/Community.a11y.test.tsx b/frontend/__tests__/a11y/pages/Community.a11y.test.tsx new file mode 100644 index 0000000000..36d4862e8b --- /dev/null +++ b/frontend/__tests__/a11y/pages/Community.a11y.test.tsx @@ -0,0 +1,45 @@ +import { useQuery } from '@apollo/client/react' +import { mockCommunityGraphQLData } from '@mockData/mockCommunityData' +import { screen, waitFor } from '@testing-library/react' +import { axe } from 'jest-axe' +import { render } from 'wrappers/testUtil' +import CommunityPage from 'app/community/page' + +jest.mock('@apollo/client/react', () => ({ + ...jest.requireActual('@apollo/client/react'), + useQuery: jest.fn(), +})) + +jest.mock('@heroui/toast', () => ({ + addToast: jest.fn(), +})) + +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + useRouter: jest.fn(() => ({ + push: jest.fn(), + })), +})) + +describe('Community Page Accessibility', () => { + afterAll(() => { + jest.clearAllMocks() + }) + + it('should have no accessibility violations', async () => { + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: mockCommunityGraphQLData, + loading: false, + error: null, + }) + + const { container } = render() + + await waitFor(() => { + expect(screen.getByText('OWASP Community')).toBeInTheDocument() + }) + + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/e2e/data/mockCommunityData.ts b/frontend/__tests__/e2e/data/mockCommunityData.ts new file mode 100644 index 0000000000..46d3892002 --- /dev/null +++ b/frontend/__tests__/e2e/data/mockCommunityData.ts @@ -0,0 +1,79 @@ +export const mockCommunityData = { + data: { + recentChapters: [ + { + id: '1', + createdAt: '2025-03-18T01:03:09+00:00', + key: 'chapter_1', + leaders: ['Leader 1', 'Leader 3'], + name: 'Chapter 1', + suggestedLocation: 'Pune, Maharashtra, India', + }, + { + id: '2', + createdAt: '2025-03-13T00:01:01+00:00', + key: 'chapter_2', + leaders: ['Leader 1', 'Leader 2'], + name: 'Chapter 2', + suggestedLocation: 'Location 2', + }, + { + id: '3', + createdAt: '2025-02-25T02:04:57+00:00', + key: 'chapter_3', + leaders: ['Leader 1', 'Leader 2'], + name: 'Chapter 3', + suggestedLocation: 'Location 3', + }, + ], + recentOrganizations: [ + { + id: '1', + avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4', + login: 'org_1', + name: 'Organization 1', + }, + { + id: '2', + avatarUrl: 'https://avatars.githubusercontent.com/u/2?v=4', + login: 'org_2', + name: 'Organization 2', + }, + ], + snapshots: [ + { + id: '1', + key: 'snapshot_1', + title: 'Snapshot 1', + startAt: '2025-01-01', + endAt: '2025-01-31', + }, + { + id: '2', + key: 'snapshot_2', + title: 'Snapshot 2', + startAt: '2025-02-01', + endAt: '2025-02-28', + }, + ], + topContributors: [ + { + name: 'Contributor 1', + login: 'contributor_1', + avatarUrl: 'https://avatars.githubusercontent.com/u/3531020?v=4', + }, + { + name: 'Contributor 2', + login: 'contributor_2', + avatarUrl: 'https://avatars.githubusercontent.com/u/862914?v=4', + }, + ], + statsOverview: { + activeChaptersStats: 150, + activeProjectsStats: 50, + contributorsStats: 5000, + countriesStats: 100, + slackWorkspaceStats: 35000, + }, + }, +} diff --git a/frontend/__tests__/e2e/pages/Chapters.spec.ts b/frontend/__tests__/e2e/pages/Chapters.spec.ts index 5ffda82696..84544288e1 100644 --- a/frontend/__tests__/e2e/pages/Chapters.spec.ts +++ b/frontend/__tests__/e2e/pages/Chapters.spec.ts @@ -57,6 +57,6 @@ test.describe('Chapters Page', () => { }) test('breadcrumb renders correct segments on /chapters', async ({ page }) => { - await expectBreadCrumbsToBeVisible(page, ['Home', 'Chapters']) + await expectBreadCrumbsToBeVisible(page, ['Home', 'Community', 'Chapters']) }) }) diff --git a/frontend/__tests__/e2e/pages/Community.spec.ts b/frontend/__tests__/e2e/pages/Community.spec.ts new file mode 100644 index 0000000000..0f257596b6 --- /dev/null +++ b/frontend/__tests__/e2e/pages/Community.spec.ts @@ -0,0 +1,75 @@ +import { mockCommunityData } from '@e2e/data/mockCommunityData' +import { mockHomeData } from '@e2e/data/mockHomeData' +import { test, expect } from '@playwright/test' + +test.describe('Community Page', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/graphql/', async (route) => { + const combinedData = { + data: { + ...mockHomeData.data, + ...mockCommunityData.data, + }, + } + await route.fulfill({ + status: 200, + json: combinedData, + }) + }) + await page.context().addCookies([ + { + name: 'csrftoken', + value: 'abc123', + domain: 'localhost', + path: '/', + }, + ]) + await page.goto('/community') + }) + + test('should have a heading and intro text', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'OWASP Community' })).toBeVisible() + await expect( + page.getByText( + "Connect, collaborate, and contribute to the world's largest application security community." + ) + ).toBeVisible() + await expect(page.getByPlaceholder('Search the OWASP community')).toBeVisible() + }) + + test('should have navigation cards', async ({ page }) => { + const navSection = page.locator('.grid.grid-cols-2').first() + await expect(navSection.getByRole('link', { name: 'Chapters' })).toBeVisible() + await expect(navSection.getByRole('link', { name: 'Members' })).toBeVisible() + await expect(navSection.getByRole('link', { name: 'Organizations' })).toBeVisible() + }) + + test('should have new chapters', async ({ page }) => { + await expect(page.getByText('New Chapters', { exact: true })).toBeVisible() + await expect(page.getByText('Chapter 1')).toBeVisible() + await expect(page.getByText('Pune, Maharashtra, India')).toBeVisible() + }) + + test('should have new organizations', async ({ page }) => { + await expect(page.getByText('New Organizations', { exact: true })).toBeVisible() + await expect(page.getByText('Organization 1')).toBeVisible() + }) + + test('should have snapshots', async ({ page }) => { + await expect(page.getByText('Snapshot 1')).toBeVisible() + await expect(page.getByText('Jan 1, 2025 - Jan 31, 2025')).toBeVisible() + }) + + test('should have top contributors', async ({ page }) => { + await expect(page.getByText('Top Contributors', { exact: true })).toBeVisible() + await expect(page.getByText('Contributor 1')).toBeVisible() + }) + + test('should have stats', async ({ page }) => { + await expect(page.getByText('Active Chapters')).toBeVisible() + await expect(page.getByText('150+', { exact: true })).toBeVisible() + await expect(page.getByText('Active Projects')).toBeVisible() + await expect(page.getByText('50+', { exact: true })).toBeVisible() + await expect(page.getByText(/5k\+/i)).toBeVisible() + }) +}) diff --git a/frontend/__tests__/e2e/pages/Organizations.spec.ts b/frontend/__tests__/e2e/pages/Organizations.spec.ts index 5708c34ad4..5890d49a34 100644 --- a/frontend/__tests__/e2e/pages/Organizations.spec.ts +++ b/frontend/__tests__/e2e/pages/Organizations.spec.ts @@ -43,6 +43,6 @@ test.describe('Organization Page', () => { }) test('breadcrumb renders correct segments on /organizations', async ({ page }) => { - await expectBreadCrumbsToBeVisible(page, ['Home', 'Organizations']) + await expectBreadCrumbsToBeVisible(page, ['Home', 'Community', 'Organizations']) }) }) diff --git a/frontend/__tests__/e2e/pages/Users.spec.ts b/frontend/__tests__/e2e/pages/Users.spec.ts index ec058f46c5..de820905e7 100644 --- a/frontend/__tests__/e2e/pages/Users.spec.ts +++ b/frontend/__tests__/e2e/pages/Users.spec.ts @@ -61,6 +61,6 @@ test.describe('Users Page', () => { await expect(page.getByText('2k')).toBeVisible() }) test('breadcrumb renders correct segments on /members', async ({ page }) => { - await expectBreadCrumbsToBeVisible(page, ['Home', 'Members']) + await expectBreadCrumbsToBeVisible(page, ['Home', 'Community', 'Members']) }) }) diff --git a/frontend/__tests__/mockData/mockCommunityData.ts b/frontend/__tests__/mockData/mockCommunityData.ts new file mode 100644 index 0000000000..09d5a3f5c9 --- /dev/null +++ b/frontend/__tests__/mockData/mockCommunityData.ts @@ -0,0 +1,72 @@ +export const mockCommunityGraphQLData = { + recentChapters: [ + { + id: '1', + createdAt: '2025-01-01T10:00:00Z', + key: 'chapter-1', + leaders: ['Leader 1', 'Leader 2'], + name: 'OWASP Chapter 1', + suggestedLocation: 'Location 1', + }, + { + id: '2', + createdAt: '2025-01-02T10:00:00Z', + key: 'chapter-2', + leaders: ['Leader 3'], + name: 'OWASP Chapter 2', + suggestedLocation: 'Location 2', + }, + ], + recentOrganizations: [ + { + id: 'org1', + avatarUrl: 'https://example.com/org1.png', + login: 'org1', + name: 'Organization 1', + }, + { + id: 'org2', + avatarUrl: 'https://example.com/org2.png', + login: 'org2', + name: 'Organization 2', + }, + ], + snapshots: [ + { + id: 'snap1', + key: 'snapshot-1', + title: 'Snapshot 1', + startAt: '2025-01-01', + endAt: '2025-01-31', + }, + { + id: 'snap2', + key: 'snapshot-2', + title: 'Snapshot 2', + startAt: '2025-02-01', + endAt: '2025-02-28', + }, + ], + topContributors: [ + { + id: 'user1', + avatarUrl: 'https://example.com/user1.png', + login: 'user1', + name: 'User 1', + bio: 'Bio 1', + }, + { + id: 'user2', + avatarUrl: 'https://example.com/user2.png', + login: 'user2', + name: 'User 2', + bio: 'Bio 2', + }, + ], + statsOverview: { + activeChaptersStats: 150, + activeProjectsStats: 50, + countriesStats: 100, + contributorsStats: 5000, + }, +} diff --git a/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx b/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx index 4291b39030..6a6b90b0a0 100644 --- a/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx +++ b/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx @@ -34,6 +34,7 @@ describe('useBreadcrumbs', () => { expect(result.current).toEqual([ { title: 'Home', path: '/' }, + { title: 'Community', path: '/community' }, { title: 'Members', path: '/members' }, ]) }) @@ -78,6 +79,7 @@ describe('useBreadcrumbs', () => { // Note: 'repositories' segment is hidden (in HIDDEN_SEGMENTS) expect(result.current).toEqual([ { title: 'Home', path: '/' }, + { title: 'Community', path: '/community' }, { title: 'Organizations', path: '/organizations' }, { title: 'Test Organization', path: '/organizations/test-org' }, { title: 'Test Repository', path: '/organizations/test-org/repositories/test-repo' }, @@ -91,34 +93,6 @@ describe('useBreadcrumbs', () => { }) }) - describe('HIDDEN_SEGMENTS', () => { - test('does not show "community" in breadcrumbs when present in path', () => { - ;(usePathname as jest.Mock).mockReturnValue('/community/forum') - - const { result } = renderHook(() => useBreadcrumbs(), { wrapper }) - - const titles = result.current.map((item) => item.title) - expect(titles).not.toContain('Community') - expect(result.current).toEqual([ - { title: 'Home', path: '/' }, - { title: 'Forum', path: '/community/forum' }, - ]) - }) - test('hides multiple HIDDEN_SEGMENTS in the same path', () => { - ;(usePathname as jest.Mock).mockReturnValue('/community/mentees/profile') - - const { result } = renderHook(() => useBreadcrumbs(), { wrapper }) - - const titles = result.current.map((item) => item.title) - expect(titles).not.toContain('Community') - expect(titles).not.toContain('Mentees') - expect(result.current).toEqual([ - { title: 'Home', path: '/' }, - { title: 'Profile', path: '/community/mentees/profile' }, - ]) - }) - }) - describe('Edge cases', () => { test('handles null pathname', () => { ;(usePathname as jest.Mock).mockReturnValue(null) diff --git a/frontend/__tests__/unit/pages/Community.test.tsx b/frontend/__tests__/unit/pages/Community.test.tsx new file mode 100644 index 0000000000..2a915c658d --- /dev/null +++ b/frontend/__tests__/unit/pages/Community.test.tsx @@ -0,0 +1,147 @@ +import { useQuery } from '@apollo/client/react' +import { addToast } from '@heroui/toast' +import { mockCommunityGraphQLData } from '@mockData/mockCommunityData' +import { screen, waitFor } from '@testing-library/react' +import { render } from 'wrappers/testUtil' +import CommunityPage from 'app/community/page' + +jest.mock('@apollo/client/react', () => ({ + ...jest.requireActual('@apollo/client/react'), + useQuery: jest.fn(), +})) + +jest.mock('@heroui/toast', () => ({ + addToast: jest.fn(), +})) + +const mockRouter = { + push: jest.fn(), +} +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + useRouter: jest.fn(() => mockRouter), +})) + +describe('Community Page', () => { + beforeEach(() => { + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: mockCommunityGraphQLData, + loading: false, + error: null, + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + test('renders loading state', async () => { + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: null, + loading: true, + error: null, + }) + + render() + + await waitFor(() => { + const loadings = screen.getAllByAltText('Loading indicator') + expect(loadings.length).toBeGreaterThan(0) + }) + }) + + test('renders error state', async () => { + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: null, + loading: false, + error: { message: 'Failed to fetch' }, + }) + + render() + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'GraphQL Request Failed', + color: 'danger', + }) + ) + }) + }) + + test('renders hero section correctly', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('OWASP Community')).toBeInTheDocument() + expect(screen.getByText(/Connect, collaborate, and contribute/)).toBeInTheDocument() + expect(screen.getByPlaceholderText('Search the OWASP community')).toBeInTheDocument() + }) + }) + + test('renders navigation cards', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Chapters')).toBeInTheDocument() + expect(screen.getByText('Members')).toBeInTheDocument() + expect(screen.getByText('Organizations')).toBeInTheDocument() + expect(screen.getAllByText('Snapshots').length).toBeGreaterThan(0) + }) + }) + + test('renders new chapters section', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('New Chapters')).toBeInTheDocument() + expect(screen.getByText('OWASP Chapter 1')).toBeInTheDocument() + expect(screen.getByText('OWASP Chapter 2')).toBeInTheDocument() + expect(screen.getByText('Location 1')).toBeInTheDocument() + }) + }) + + test('renders recent organizations section', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('New Organizations')).toBeInTheDocument() + expect(screen.getByText('Organization 1')).toBeInTheDocument() + }) + }) + + test('renders snapshots section', async () => { + render() + + await waitFor(() => { + expect(screen.getAllByText('Snapshots').length).toBeGreaterThan(0) + expect(screen.getByText('Snapshot 1')).toBeInTheDocument() + expect(screen.getByText(/Jan 1, 2025 - Jan 31, 2025/)).toBeInTheDocument() + }) + }) + + test('renders top contributors section', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Top Contributors')).toBeInTheDocument() + expect(screen.getByText('User 1')).toBeInTheDocument() + expect(screen.getByText('User 2')).toBeInTheDocument() + }) + }) + + test('renders stats section', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Active Chapters')).toBeInTheDocument() + expect(screen.getByText('150+')).toBeInTheDocument() + expect(screen.getByText('Active Projects')).toBeInTheDocument() + expect(screen.getByText('50+')).toBeInTheDocument() + expect(screen.getByText('Countries')).toBeInTheDocument() + expect(screen.getByText('100+')).toBeInTheDocument() + expect(screen.getByText('Contributors')).toBeInTheDocument() + expect(screen.getByText(/5k\+/i)).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/app/community/layout.tsx b/frontend/src/app/community/layout.tsx new file mode 100644 index 0000000000..ab22a0c0fb --- /dev/null +++ b/frontend/src/app/community/layout.tsx @@ -0,0 +1,9 @@ +import { Metadata } from 'next' +import React from 'react' +import { getStaticMetadata } from 'utils/metaconfig' + +export const metadata: Metadata = getStaticMetadata('community', '/community') + +export default function CommunityLayout({ children }: { children: React.ReactNode }) { + return children +} diff --git a/frontend/src/app/community/page.tsx b/frontend/src/app/community/page.tsx new file mode 100644 index 0000000000..233b923b0b --- /dev/null +++ b/frontend/src/app/community/page.tsx @@ -0,0 +1,277 @@ +'use client' +import { useQuery } from '@apollo/client/react' +import { addToast } from '@heroui/toast' +import millify from 'millify' +import Image from 'next/image' +import Link from 'next/link' +import React, { useEffect } from 'react' +import { FaMapMarkerAlt, FaUsers } from 'react-icons/fa' +import { FaBuilding, FaCamera, FaLocationDot, FaPeopleGroup } from 'react-icons/fa6' +import { ErrorDisplay } from 'app/global-error' +import { GetCommunityPageDataDocument } from 'types/__generated__/communityQueries.generated' +import { formatDate } from 'utils/dateFormatter' +import { getMemberUrl } from 'utils/urlFormatter' +import AnchorTitle from 'components/AnchorTitle' +import ChapterCard from 'components/ChapterCard' +import LoadingSpinner from 'components/LoadingSpinner' +import MultiSearchBar from 'components/MultiSearch' +import SecondaryCard from 'components/SecondaryCard' +import { TruncatedText } from 'components/TruncatedText' + +interface NavCardProps { + href: string + icon: React.ReactNode + title: string + description: string +} + +const NavCard = ({ href, icon, title, description }: NavCardProps) => ( + +
{icon}
+

{title}

+

{description}

+ +) + +export default function CommunityPage() { + const { + data: graphQLData, + error: graphQLRequestError, + loading: isLoading, + } = useQuery(GetCommunityPageDataDocument) + + useEffect(() => { + if (graphQLRequestError) { + addToast({ + description: 'Unable to complete the requested operation.', + title: 'GraphQL Request Failed', + timeout: 3000, + shouldShowTimeoutProgress: true, + color: 'danger', + variant: 'solid', + }) + } + }, [graphQLRequestError]) + + if (isLoading) { + return + } + + if (graphQLRequestError || !graphQLData) { + return ( + + ) + } + + const data = graphQLData + + const statsOverview = data.statsOverview + const statsData = statsOverview + ? [ + { label: 'Active Chapters', value: statsOverview.activeChaptersStats }, + { label: 'Active Projects', value: statsOverview.activeProjectsStats }, + { label: 'Contributors', value: statsOverview.contributorsStats }, + { label: 'Countries', value: statsOverview.countriesStats }, + ] + : [] + + return ( +
+
+
+
+

+ OWASP Community +

+

+ Connect, collaborate, and contribute to the world's largest application security + community. +

+
+
+ +
+
+ +

Explore Community

+
+ } + title="Chapters" + description="Find local chapters near you" + /> + } + title="Members" + description="Connect with community members" + /> + } + title="Organizations" + description="Explore supporting organizations" + /> + } + title="Snapshots" + description="View community snapshots" + /> +
+ + + +
+ } + className="overflow-hidden" + > +
+ {data.recentChapters?.map((chapter) => ( + + ))} +
+ + +
+ + +
+ } + className="!mb-0 flex h-full flex-col overflow-hidden" + > +
+ {data.recentOrganizations?.map((org) => ( +
+
+ {org.avatarUrl && ( + {org.name + )} + + + +
+
+ ))} +
+ + + + +
+ } + className="!mb-0 flex h-full flex-col overflow-hidden" + > +
+ {data.snapshots?.map((snapshot) => ( +
+ + + +
+ {formatDate(snapshot.startAt)} - {formatDate(snapshot.endAt)} +
+
+ ))} +
+ + + + + + + } + > +
+ {data.topContributors?.map((contributor) => ( +
+
+ {contributor.avatarUrl && ( + {contributor.name + )} + + {contributor.name || contributor.login} + +
+
+ ))} +
+
+ +
+ {statsData.map((stat) => ( +
+ +
{millify(stat.value)}+
+
{stat.label}
+
+
+ ))} +
+ + + ) +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 859d1fb2b5..3d67a418b8 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -33,6 +33,7 @@ import { formatDate, formatDateRange } from 'utils/dateFormatter' import { getMemberUrl } from 'utils/urlFormatter' import AnchorTitle from 'components/AnchorTitle' import CalendarButton from 'components/CalendarButton' +import ChapterCard from 'components/ChapterCard' import ChapterMapWrapper from 'components/ChapterMapWrapper' import ContributorsList from 'components/ContributorsList' import LeadersList from 'components/LeadersList' @@ -227,38 +228,14 @@ export default function Home() { >
{data?.recentChapters?.map((chapter) => ( -
-

- - - -

-
-
- - {formatDate(chapter.createdAt)} -
- {chapter.suggestedLocation && ( -
- - -
- )} -
- - {chapter.leaders.length > 0 && ( -
- - -
- )} -
+ ))}
diff --git a/frontend/src/components/ChapterCard.tsx b/frontend/src/components/ChapterCard.tsx new file mode 100644 index 0000000000..43200d0863 --- /dev/null +++ b/frontend/src/components/ChapterCard.tsx @@ -0,0 +1,51 @@ +'use client' +import Link from 'next/link' +import { FaCalendar, FaMapMarkerAlt } from 'react-icons/fa' +import { HiUserGroup } from 'react-icons/hi' +import { formatDate } from 'utils/dateFormatter' +import LeadersList from 'components/LeadersList' +import { TruncatedText } from 'components/TruncatedText' + +interface ChapterCardProps { + chapterKey: string + name: string + createdAt: number + suggestedLocation: string | null + leaders: string[] +} + +const ChapterCard = ({ + chapterKey, + name, + createdAt, + suggestedLocation, + leaders, +}: ChapterCardProps) => ( +
+

+ + + +

+
+
+ + {formatDate(createdAt)} +
+ {suggestedLocation && ( +
+ + +
+ )} +
+ {leaders.length > 0 && ( +
+ + +
+ )} +
+) + +export default ChapterCard diff --git a/frontend/src/hooks/useBreadcrumbs.ts b/frontend/src/hooks/useBreadcrumbs.ts index 3ecf8b8b21..bacbc5e2b8 100644 --- a/frontend/src/hooks/useBreadcrumbs.ts +++ b/frontend/src/hooks/useBreadcrumbs.ts @@ -5,14 +5,15 @@ import { formatBreadcrumbTitle } from 'utils/breadcrumb' export type { BreadcrumbItem } from 'types/breadcrumb' -const HIDDEN_SEGMENTS = new Set(['community', 'mentees', 'modules', 'programs', 'repositories']) +const HIDDEN_SEGMENTS = new Set(['mentees', 'modules', 'programs', 'repositories']) +const COMMUNITY_RELATED_PATHS = ['/chapters', '/members', '/organizations'] function buildBreadcrumbItems( pathname: string | null, registeredItems: BreadcrumbItem[] ): BreadcrumbItem[] { const registeredMap = new Map() - for (const item of registeredItems) { + for (const item of registeredItems || []) { registeredMap.set(item.path, item) } @@ -22,6 +23,13 @@ function buildBreadcrumbItems( return items } + if (COMMUNITY_RELATED_PATHS.some((path) => pathname.startsWith(path))) { + items.push({ + title: 'Community', + path: '/community', + }) + } + const segments = pathname.split('/').filter(Boolean) let currentPath = '' diff --git a/frontend/src/server/queries/communityQueries.ts b/frontend/src/server/queries/communityQueries.ts new file mode 100644 index 0000000000..548d0e6b90 --- /dev/null +++ b/frontend/src/server/queries/communityQueries.ts @@ -0,0 +1,39 @@ +import { gql } from '@apollo/client' + +export const GET_COMMUNITY_PAGE_DATA = gql` + query GetCommunityPageData { + recentChapters(limit: 6) { + id + createdAt + key + leaders + name + suggestedLocation + } + recentOrganizations(limit: 5) { + id + avatarUrl + login + name + } + snapshots(limit: 5) { + id + key + title + startAt + endAt + } + topContributors(hasFullName: true, limit: 12) { + id + avatarUrl + login + name + } + statsOverview { + activeChaptersStats + activeProjectsStats + contributorsStats + countriesStats + } + } +` diff --git a/frontend/src/types/__generated__/communityQueries.generated.ts b/frontend/src/types/__generated__/communityQueries.generated.ts new file mode 100644 index 0000000000..db7c4ee1f9 --- /dev/null +++ b/frontend/src/types/__generated__/communityQueries.generated.ts @@ -0,0 +1,10 @@ +import * as Types from './graphql'; + +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +export type GetCommunityPageDataQueryVariables = Types.Exact<{ [key: string]: never; }>; + + +export type GetCommunityPageDataQuery = { recentChapters: Array<{ __typename: 'ChapterNode', id: string, createdAt: number, key: string, leaders: Array, name: string, suggestedLocation: string | null }>, recentOrganizations: Array<{ __typename: 'OrganizationNode', id: string, avatarUrl: string, login: string, name: string }>, snapshots: Array<{ __typename: 'SnapshotNode', id: string, key: string, title: string, startAt: any, endAt: any }>, topContributors: Array<{ __typename: 'RepositoryContributorNode', id: string, avatarUrl: string, login: string, name: string }>, statsOverview: { __typename: 'StatsNode', activeChaptersStats: number, activeProjectsStats: number, contributorsStats: number, countriesStats: number } }; + + +export const GetCommunityPageDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCommunityPageData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"recentChapters"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"6"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"leaders"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"suggestedLocation"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentOrganizations"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"snapshots"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"startAt"}},{"kind":"Field","name":{"kind":"Name","value":"endAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"topContributors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"hasFullName"},"value":{"kind":"BooleanValue","value":true}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"12"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"statsOverview"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeChaptersStats"}},{"kind":"Field","name":{"kind":"Name","value":"activeProjectsStats"}},{"kind":"Field","name":{"kind":"Name","value":"contributorsStats"}},{"kind":"Field","name":{"kind":"Name","value":"countriesStats"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/src/types/__generated__/graphql.ts b/frontend/src/types/__generated__/graphql.ts index c1eb07d419..5b3159915d 100644 --- a/frontend/src/types/__generated__/graphql.ts +++ b/frontend/src/types/__generated__/graphql.ts @@ -710,6 +710,7 @@ export type Query = { recentChapters: Array; recentIssues: Array; recentMilestones: Array; + recentOrganizations: Array; recentPosts: Array; recentProjects: Array; recentPullRequests: Array; @@ -858,6 +859,11 @@ export type QueryRecentMilestonesArgs = { }; +export type QueryRecentOrganizationsArgs = { + limit?: Scalars['Int']['input']; +}; + + export type QueryRecentPostsArgs = { limit?: Scalars['Int']['input']; }; diff --git a/frontend/src/utils/metadata.ts b/frontend/src/utils/metadata.ts index d174adf440..8e33f6e579 100644 --- a/frontend/src/utils/metadata.ts +++ b/frontend/src/utils/metadata.ts @@ -48,6 +48,13 @@ export const METADATA_CONFIG = { pageTitle: 'About', type: 'website', }, + community: { + description: + 'Connect, collaborate, and contribute to the OWASP community. Explore chapters, members, organizations, and community snapshots.', + keywords: ['OWASP community', 'chapters', 'members', 'organizations', 'snapshots'], + pageTitle: 'Community', + type: 'website', + }, organizations: { description: 'Explore OWASP organizations and their contributions to web security.', keywords: ['OWASP organizations', 'security organizations', 'web security'],