diff --git a/frontend/__tests__/unit/components/BreadCrumbs.test.tsx b/frontend/__tests__/unit/components/BreadCrumbs.test.tsx index 17511e18ad..13fdddbd8e 100644 --- a/frontend/__tests__/unit/components/BreadCrumbs.test.tsx +++ b/frontend/__tests__/unit/components/BreadCrumbs.test.tsx @@ -1,68 +1,98 @@ import { render, screen } from '@testing-library/react' -import { usePathname } from 'next/navigation' -import BreadCrumbs from 'components/BreadCrumbs' +import BreadCrumbRenderer from 'components/BreadCrumbs' import '@testing-library/jest-dom' -jest.mock('next/navigation', () => ({ - usePathname: jest.fn(), -})) +describe('BreadCrumbRenderer', () => { + const mockItems = [ + { title: 'Home', path: '/' }, + { title: 'Projects', path: '/projects' }, + { title: 'OWASP ZAP', path: '/projects/zap' }, + ] -describe('BreadCrumb', () => { - afterEach(() => { - jest.clearAllMocks() + test('renders all breadcrumb items', () => { + render() + + expect(screen.getByText('Home')).toBeInTheDocument() + expect(screen.getByText('Projects')).toBeInTheDocument() + expect(screen.getByText('OWASP ZAP')).toBeInTheDocument() }) - test('does not render on root path "/"', () => { - ;(usePathname as jest.Mock).mockReturnValue('/') + test('renders navigation element with correct aria-label', () => { + render() - render() - expect(screen.queryByText('Home')).not.toBeInTheDocument() + const nav = screen.getByRole('navigation') + expect(nav).toHaveAttribute('aria-label', 'breadcrumb') }) - test('renders breadcrumb with multiple segments', () => { - ;(usePathname as jest.Mock).mockReturnValue('/dashboard/users/profile') + test('renders clickable links for non-last items', () => { + render() - render() + const homeLink = screen.getByText('Home').closest('a') + const projectsLink = screen.getByText('Projects').closest('a') - expect(screen.getByText('Home')).toBeInTheDocument() - expect(screen.getByText('Dashboard')).toBeInTheDocument() - expect(screen.getByText('Users')).toBeInTheDocument() - expect(screen.getByText('Profile')).toBeInTheDocument() + expect(homeLink).toHaveAttribute('href', '/') + expect(projectsLink).toHaveAttribute('href', '/projects') }) - test('disables the last segment (non-clickable)', () => { - ;(usePathname as jest.Mock).mockReturnValue('/settings/account') + test('disables the last item (non-clickable)', () => { + render() + + const lastItem = screen.getByText('OWASP ZAP') + expect(lastItem).not.toHaveAttribute('href') + expect(lastItem.tagName).toBe('SPAN') + }) - render() + test('applies hover styles to clickable links', () => { + render() - const lastSegment = screen.getByText('Account') - expect(lastSegment).toBeInTheDocument() - expect(lastSegment).not.toHaveAttribute('href') + const homeLink = screen.getByText('Home').closest('a') + expect(homeLink).toHaveClass('hover:text-blue-700', 'hover:underline') }) - test('links have correct href attributes', () => { - ;(usePathname as jest.Mock).mockReturnValue('/dashboard/users/profile') + test('applies disabled styling to last breadcrumb', () => { + render() - render() + const lastItem = screen.getByText('OWASP ZAP') + expect(lastItem).toHaveClass('cursor-default', 'font-semibold') + }) - const homeLink = screen.getByText('Home').closest('a') - const dashboardLink = screen.getByText('Dashboard').closest('a') - const usersLink = screen.getByText('Users').closest('a') + test('renders chevron separators between items', () => { + const { container } = render() - expect(homeLink).toHaveAttribute('href', '/') - expect(dashboardLink).toHaveAttribute('href', '/dashboard') - expect(usersLink).toHaveAttribute('href', '/dashboard/users') + const separators = container.querySelectorAll('[data-slot="separator"]') + expect(separators).toHaveLength(2) }) - test('links have hover styles', () => { - ;(usePathname as jest.Mock).mockReturnValue('/dashboard/users') + test('handles single item (home only)', () => { + const singleItem = [{ title: 'Home', path: '/' }] + render() - render() + expect(screen.getByText('Home')).toBeInTheDocument() + const separators = screen.queryByRole('separator') + expect(separators).not.toBeInTheDocument() + }) + + test('handles empty items array', () => { + const { container } = render() + + const breadcrumbList = container.querySelector('[data-slot="list"]') + expect(breadcrumbList?.children).toHaveLength(0) + }) + + test('applies correct wrapper styling', () => { + const { container } = render() + + const wrapper = container.querySelector('.mt-16') + expect(wrapper).toHaveClass('w-full', 'pt-4') + }) + + test('links have correct href attributes', () => { + render() const homeLink = screen.getByText('Home').closest('a') - const dashboardLink = screen.getByText('Dashboard').closest('a') + const projectsLink = screen.getByText('Projects').closest('a') - expect(homeLink).toHaveClass('hover:text-blue-700', 'hover:underline') - expect(dashboardLink).toHaveClass('hover:text-blue-700', 'hover:underline') + expect(homeLink).toHaveAttribute('href', '/') + expect(projectsLink).toHaveAttribute('href', '/projects') }) }) diff --git a/frontend/__tests__/unit/components/BreadCrumbsWrapper.test.tsx b/frontend/__tests__/unit/components/BreadCrumbsWrapper.test.tsx new file mode 100644 index 0000000000..8327b4a86a --- /dev/null +++ b/frontend/__tests__/unit/components/BreadCrumbsWrapper.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { BreadcrumbRoot, registerBreadcrumb } from 'contexts/BreadcrumbContext' +import { usePathname } from 'next/navigation' +import React, { useEffect } from 'react' +import BreadCrumbsWrapper from 'components/BreadCrumbsWrapper' + +jest.mock('next/navigation', () => ({ + usePathname: jest.fn(), +})) + +// Wrapper with BreadcrumbRoot for context tests +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +// Helper component to register a breadcrumb before rendering BreadCrumbsWrapper +function BreadCrumbsWrapperWithTitle({ path, title }: Readonly<{ path: string; title: string }>) { + useEffect(() => { + const unregister = registerBreadcrumb({ path, title }) + return unregister + }, [path, title]) + return +} + +describe('BreadCrumbsWrapper', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + describe('Route Detection - Should Hide', () => { + test('returns null on home page', () => { + ;(usePathname as jest.Mock).mockReturnValue('/') + + const { container } = render() + expect(container.firstChild).toBeNull() + }) + }) + + describe('Route Detection - Should Render', () => { + test('renders for routes with registered breadcrumbs', () => { + ;(usePathname as jest.Mock).mockReturnValue('/projects') + + render(, { wrapper }) + expect(screen.getByText('Home')).toBeInTheDocument() + expect(screen.getByText('Projects')).toBeInTheDocument() + }) + + test('uses registered title', () => { + ;(usePathname as jest.Mock).mockReturnValue('/projects/test-project-1') + + render( + , + { wrapper } + ) + // Should show registered title instead of URL slug + expect(screen.getByText('Security Scanner Tool')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/__tests__/unit/components/PageLayout.test.tsx b/frontend/__tests__/unit/components/PageLayout.test.tsx new file mode 100644 index 0000000000..5a12bc6400 --- /dev/null +++ b/frontend/__tests__/unit/components/PageLayout.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { BreadcrumbRoot } from 'contexts/BreadcrumbContext' +import { usePathname } from 'next/navigation' +import React from 'react' +import BreadCrumbsWrapper from 'components/BreadCrumbsWrapper' +import PageLayout from 'components/PageLayout' + +jest.mock('next/navigation', () => ({ + usePathname: jest.fn(), +})) + +// Helper to wrap components with BreadcrumbRoot and render with BreadCrumbsWrapper +const renderWithProvider = (ui: React.ReactElement) => { + return render( + + + {ui} + + ) +} + +describe('PageLayout', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + test('renders children components', () => { + ;(usePathname as jest.Mock).mockReturnValue('/projects/zap') + + renderWithProvider( + +
Child Content
+
+ ) + + expect(screen.getByText('Child Content')).toBeInTheDocument() + }) + + describe('Title Prop Handling', () => { + test('displays title in breadcrumbs', async () => { + ;(usePathname as jest.Mock).mockReturnValue('/projects/zap') + + renderWithProvider( + +
Content
+
+ ) + + // Wait for effect to run + await screen.findByText('OWASP ZAP') + expect(screen.getByText('OWASP ZAP')).toBeInTheDocument() + }) + + test('handles explicit path prop', async () => { + ;(usePathname as jest.Mock).mockReturnValue('/projects/zap/details') + + renderWithProvider( + +
Content
+
+ ) + + await screen.findByText('OWASP ZAP') + expect(screen.getByText('OWASP ZAP')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx b/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx new file mode 100644 index 0000000000..e24e0bb50b --- /dev/null +++ b/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx @@ -0,0 +1,115 @@ +import { renderHook, act } from '@testing-library/react' +import { BreadcrumbRoot, registerBreadcrumb } from 'contexts/BreadcrumbContext' +import { useBreadcrumbs } from 'hooks/useBreadcrumbs' +import { usePathname } from 'next/navigation' +import React from 'react' + +jest.mock('next/navigation', () => ({ + usePathname: jest.fn(), +})) + +// Helper wrapper with BreadcrumbRoot +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +describe('useBreadcrumbs', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Hybrid Pattern (registered + auto-generated)', () => { + test('returns Home for root path', () => { + ;(usePathname as jest.Mock).mockReturnValue('/') + + const { result } = renderHook(() => useBreadcrumbs(), { wrapper }) + + expect(result.current).toEqual([{ title: 'Home', path: '/' }]) + }) + + test('auto-generates breadcrumbs from URL when no items registered', () => { + ;(usePathname as jest.Mock).mockReturnValue('/members') + + const { result } = renderHook(() => useBreadcrumbs(), { wrapper }) + + expect(result.current).toEqual([ + { title: 'Home', path: '/' }, + { title: 'Members', path: '/members' }, + ]) + }) + + test('uses registered title when available', () => { + ;(usePathname as jest.Mock).mockReturnValue('/projects/test-project') + + const { result } = renderHook(() => useBreadcrumbs(), { wrapper }) + + let unregister: () => void + act(() => { + unregister = registerBreadcrumb({ title: 'Test Project', path: '/projects/test-project' }) + }) + + expect(result.current).toEqual([ + { title: 'Home', path: '/' }, + { title: 'Projects', path: '/projects' }, + { title: 'Test Project', path: '/projects/test-project' }, + ]) + + act(() => { + unregister() + }) + }) + + test('merges registered and auto-generated items', () => { + ;(usePathname as jest.Mock).mockReturnValue('/organizations/test-org/repositories/test-repo') + + const { result } = renderHook(() => useBreadcrumbs(), { wrapper }) + + const unregisterFns: (() => void)[] = [] + act(() => { + unregisterFns.push( + registerBreadcrumb({ title: 'Test Organization', path: '/organizations/test-org' }), + registerBreadcrumb({ + title: 'Test Repository', + path: '/organizations/test-org/repositories/test-repo', + }) + ) + }) + + // Note: 'repositories' segment is hidden (in HIDDEN_SEGMENTS) + expect(result.current).toEqual([ + { title: 'Home', path: '/' }, + { title: 'Organizations', path: '/organizations' }, + { title: 'Test Organization', path: '/organizations/test-org' }, + { title: 'Test Repository', path: '/organizations/test-org/repositories/test-repo' }, + ]) + + act(() => { + for (const fn of unregisterFns) { + fn() + } + }) + }) + }) + + describe('Edge cases', () => { + test('handles null pathname', () => { + ;(usePathname as jest.Mock).mockReturnValue(null) + + const { result } = renderHook(() => useBreadcrumbs(), { wrapper }) + + expect(result.current).toEqual([{ title: 'Home', path: '/' }]) + }) + + test('formats hyphenated URL segments', () => { + ;(usePathname as jest.Mock).mockReturnValue('/test-page/test-section') + + const { result } = renderHook(() => useBreadcrumbs(), { wrapper }) + + expect(result.current).toEqual([ + { title: 'Home', path: '/' }, + { title: 'Test Page', path: '/test-page' }, + { title: 'Test Section', path: '/test-page/test-section' }, + ]) + }) + }) +}) diff --git a/frontend/__tests__/unit/pages/ChapterDetails.test.tsx b/frontend/__tests__/unit/pages/ChapterDetails.test.tsx index 978f55026a..96da372687 100644 --- a/frontend/__tests__/unit/pages/ChapterDetails.test.tsx +++ b/frontend/__tests__/unit/pages/ChapterDetails.test.tsx @@ -28,6 +28,7 @@ jest.mock('next/navigation', () => ({ ...jest.requireActual('next/navigation'), useRouter: jest.fn(() => mockRouter), useParams: () => ({ chapterKey: 'test-chapter' }), + usePathname: jest.fn(() => '/chapters/test-chapter'), })) describe('chapterDetailsPage Component', () => { diff --git a/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx b/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx index e27fff0286..a9c776505f 100644 --- a/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx +++ b/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx @@ -22,6 +22,7 @@ jest.mock('next/navigation', () => ({ ...jest.requireActual('next/navigation'), useRouter: jest.fn(() => mockRouter), useParams: () => ({ committeeKey: 'test-committee' }), + usePathname: jest.fn(() => '/committees/test-committee'), })) describe('CommitteeDetailsPage Component', () => { @@ -52,7 +53,7 @@ describe('CommitteeDetailsPage Component', () => { test('renders committee data correctly', async () => { render() await waitFor(() => { - expect(screen.getByText('Test Committee')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Test Committee' })).toBeInTheDocument() }) expect(screen.getByText('This is a test committee summary.')).toBeInTheDocument() expect(screen.getByText('Leader 1')).toBeInTheDocument() diff --git a/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx b/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx index 1b1281fe65..1a4e27d98b 100644 --- a/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx +++ b/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx @@ -28,6 +28,7 @@ jest.mock('next/navigation', () => ({ ...jest.requireActual('next/navigation'), useRouter: jest.fn(() => mockRouter), useParams: () => ({ repositoryKey: 'test-org' }), + usePathname: jest.fn(() => '/organizations/test-org'), })) const mockError = { @@ -70,7 +71,7 @@ describe('OrganizationDetailsPage', () => { render() await waitFor(() => { - expect(screen.getByText('Test Organization')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Test Organization' })).toBeInTheDocument() }) expect(screen.getByText('@test-org')).toBeInTheDocument() diff --git a/frontend/__tests__/unit/utils/breadcrumb.test.ts b/frontend/__tests__/unit/utils/breadcrumb.test.ts new file mode 100644 index 0000000000..f944ee43f7 --- /dev/null +++ b/frontend/__tests__/unit/utils/breadcrumb.test.ts @@ -0,0 +1,37 @@ +import { formatBreadcrumbTitle } from 'utils/breadcrumb' + +describe('formatBreadcrumbTitle', () => { + it('capitalizes single word', () => { + expect(formatBreadcrumbTitle('projects')).toBe('Projects') + }) + + it('splits hyphenated slug and capitalizes each word', () => { + expect(formatBreadcrumbTitle('test-org-repo')).toBe('Test Org Repo') + }) + + it('handles multiple hyphens', () => { + expect(formatBreadcrumbTitle('test-multi-word-slug-example')).toBe( + 'Test Multi Word Slug Example' + ) + }) + + it('preserves uppercase words', () => { + expect(formatBreadcrumbTitle('OWASP-ZAP')).toBe('OWASP ZAP') + }) + + it('returns empty string for empty input', () => { + expect(formatBreadcrumbTitle('')).toBe('') + }) + + it('handles single character segments', () => { + expect(formatBreadcrumbTitle('a-b-c')).toBe('A B C') + }) + + it('handles numbers in slug', () => { + expect(formatBreadcrumbTitle('top-10-2021')).toBe('Top 10 2021') + }) + + it('handles trailing/leading hyphens', () => { + expect(formatBreadcrumbTitle('-test-')).toBe(' Test ') + }) +}) diff --git a/frontend/src/app/chapters/[chapterKey]/layout.tsx b/frontend/src/app/chapters/[chapterKey]/layout.tsx index ce8da734d6..194c749d19 100644 --- a/frontend/src/app/chapters/[chapterKey]/layout.tsx +++ b/frontend/src/app/chapters/[chapterKey]/layout.tsx @@ -1,8 +1,21 @@ import { Metadata } from 'next' -import React from 'react' +import React, { cache } from 'react' import { apolloClient } from 'server/apolloClient' import { GetChapterMetadataDocument } from 'types/__generated__/chapterQueries.generated' import { generateSeoMetadata } from 'utils/metaconfig' +import PageLayout from 'components/PageLayout' + +const getChapterMetadata = cache(async (chapterKey: string) => { + try { + const { data } = await apolloClient.query({ + query: GetChapterMetadataDocument, + variables: { key: chapterKey }, + }) + return data + } catch { + return null + } +}) export async function generateMetadata({ params, @@ -10,12 +23,7 @@ export async function generateMetadata({ params: Promise<{ chapterKey: string }> }): Promise { const { chapterKey } = await params - const { data } = await apolloClient.query({ - query: GetChapterMetadataDocument, - variables: { - key: chapterKey, - }, - }) + const data = await getChapterMetadata(chapterKey) const chapter = data?.chapter return chapter @@ -28,8 +36,19 @@ export async function generateMetadata({ : null } -export default function ChapterDetailsLayout({ +export default async function ChapterDetailsLayout({ children, -}: Readonly<{ children: React.ReactNode }>) { - return children + params, +}: Readonly<{ + children: React.ReactNode + params: Promise<{ chapterKey: string }> +}>) { + const { chapterKey } = await params + const data = await getChapterMetadata(chapterKey) + + if (!data?.chapter) { + return children + } + + return {children} } diff --git a/frontend/src/app/committees/[committeeKey]/layout.tsx b/frontend/src/app/committees/[committeeKey]/layout.tsx index 9ae7237dc9..00977a817a 100644 --- a/frontend/src/app/committees/[committeeKey]/layout.tsx +++ b/frontend/src/app/committees/[committeeKey]/layout.tsx @@ -1,8 +1,21 @@ import { Metadata } from 'next' -import React from 'react' +import React, { cache } from 'react' import { apolloClient } from 'server/apolloClient' import { GetCommitteeMetadataDocument } from 'types/__generated__/committeeQueries.generated' import { generateSeoMetadata } from 'utils/metaconfig' +import PageLayout from 'components/PageLayout' + +const getCommitteeMetadata = cache(async (committeeKey: string) => { + try { + const { data } = await apolloClient.query({ + query: GetCommitteeMetadataDocument, + variables: { key: committeeKey }, + }) + return data + } catch { + return null + } +}) export async function generateMetadata({ params, @@ -10,12 +23,7 @@ export async function generateMetadata({ params: Promise<{ committeeKey: string }> }): Promise { const { committeeKey } = await params - const { data } = await apolloClient.query({ - query: GetCommitteeMetadataDocument, - variables: { - key: committeeKey, - }, - }) + const data = await getCommitteeMetadata(committeeKey) const committee = data?.committee return committee @@ -28,10 +36,19 @@ export async function generateMetadata({ : null } -export default function CommitteeDetailsLayout({ +export default async function CommitteeDetailsLayout({ children, + params, }: Readonly<{ children: React.ReactNode + params: Promise<{ committeeKey: string }> }>) { - return children + const { committeeKey } = await params + const data = await getCommitteeMetadata(committeeKey) + + if (!data?.committee) { + return children + } + + return {children} } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 62b643fa3f..40654da428 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,4 +1,5 @@ import { GoogleAnalytics } from '@next/third-parties/google' +import { BreadcrumbRoot } from 'contexts/BreadcrumbContext' import type { Metadata } from 'next' import { Geist, Geist_Mono } from 'next/font/google' import React from 'react' @@ -6,7 +7,7 @@ import { Providers } from 'wrappers/provider' import { GTM_ID } from 'utils/env.client' import { IS_GITHUB_AUTH_ENABLED } from 'utils/env.server' import AutoScrollToTop from 'components/AutoScrollToTop' -import BreadCrumbs from 'components/BreadCrumbs' +import BreadCrumbsWrapper from 'components/BreadCrumbsWrapper' import Footer from 'components/Footer' import Header from 'components/Header' import ScrollToTop from 'components/ScrollToTop' @@ -71,14 +72,16 @@ export default function RootLayout({ style={{ minHeight: '100vh' }} > -
- -
- -
{children}
-
- -
+ +
+ +
+ +
{children}
+
+ +
+
diff --git a/frontend/src/app/members/[memberKey]/layout.tsx b/frontend/src/app/members/[memberKey]/layout.tsx index d65eb3d121..f71eb14531 100644 --- a/frontend/src/app/members/[memberKey]/layout.tsx +++ b/frontend/src/app/members/[memberKey]/layout.tsx @@ -7,6 +7,7 @@ import { } from 'types/__generated__/userQueries.generated' import { generateSeoMetadata } from 'utils/metaconfig' import { generateProfilePageStructuredData } from 'utils/structuredData' +import PageLayout from 'components/PageLayout' import StructuredDataScript from 'components/StructuredDataScript' export async function generateMetadata({ @@ -55,9 +56,9 @@ export default async function UserDetailsLayout({ } return ( - <> + {children} - + ) } diff --git a/frontend/src/app/organizations/[organizationKey]/layout.tsx b/frontend/src/app/organizations/[organizationKey]/layout.tsx index 3784ee5157..e35abb8273 100644 --- a/frontend/src/app/organizations/[organizationKey]/layout.tsx +++ b/frontend/src/app/organizations/[organizationKey]/layout.tsx @@ -7,6 +7,7 @@ import { GetOrganizationMetadataDocument, } from 'types/__generated__/organizationQueries.generated' import { generateSeoMetadata } from 'utils/metaconfig' +import PageLayout from 'components/PageLayout' export async function generateMetadata({ params, @@ -99,8 +100,15 @@ export default async function OrganizationDetailsLayout({ const { organizationKey } = await params const structuredData = await generateOrganizationStructuredData(organizationKey) + // Fetch organization name for breadcrumb + const { data } = await apolloClient.query({ + query: GetOrganizationMetadataDocument, + variables: { login: organizationKey }, + }) + const orgName = data?.organization?.name || data?.organization?.login || organizationKey + return ( - <> + {structuredData && (