-
-
Notifications
You must be signed in to change notification settings - Fork 313
refactor: update breadcrumbs component to support dynamic title rende… #2573
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 10 commits
7a1353d
5b00579
ef1cfb8
5f70589
0e96f26
cd1417d
bafe0e1
b431914
536119e
2f14e42
e529474
05f171a
6e9666b
0058b28
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(<BreadCrumbRenderer items={mockItems} />) | ||
|
|
||
| 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(<BreadCrumbRenderer items={mockItems} />) | ||
|
|
||
| render(<BreadCrumbs />) | ||
| 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(<BreadCrumbRenderer items={mockItems} />) | ||
|
|
||
| render(<BreadCrumbs />) | ||
| 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(<BreadCrumbRenderer items={mockItems} />) | ||
|
|
||
| const lastItem = screen.getByText('OWASP ZAP') | ||
| expect(lastItem).not.toHaveAttribute('href') | ||
| expect(lastItem.tagName).toBe('SPAN') | ||
| }) | ||
|
|
||
| render(<BreadCrumbs />) | ||
| test('applies hover styles to clickable links', () => { | ||
| render(<BreadCrumbRenderer items={mockItems} />) | ||
|
|
||
| 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(<BreadCrumbRenderer items={mockItems} />) | ||
|
|
||
| render(<BreadCrumbs />) | ||
| 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(<BreadCrumbRenderer items={mockItems} />) | ||
|
|
||
| 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(<BreadCrumbRenderer items={singleItem} />) | ||
|
|
||
| render(<BreadCrumbs />) | ||
| expect(screen.getByText('Home')).toBeInTheDocument() | ||
| const separators = screen.queryByRole('separator') | ||
| expect(separators).not.toBeInTheDocument() | ||
| }) | ||
|
|
||
| test('handles empty items array', () => { | ||
| const { container } = render(<BreadCrumbRenderer items={[]} />) | ||
|
|
||
| const breadcrumbList = container.querySelector('[data-slot="list"]') | ||
| expect(breadcrumbList?.children).toHaveLength(0) | ||
| }) | ||
|
|
||
| test('applies correct wrapper styling', () => { | ||
| const { container } = render(<BreadCrumbRenderer items={mockItems} />) | ||
|
|
||
| const wrapper = container.querySelector('.mt-16') | ||
| expect(wrapper).toHaveClass('w-full', 'pt-4') | ||
| }) | ||
|
|
||
| test('links have correct href attributes', () => { | ||
| render(<BreadCrumbRenderer items={mockItems} />) | ||
|
|
||
| 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') | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }) => ( | ||
| <BreadcrumbRoot>{children}</BreadcrumbRoot> | ||
| ) | ||
|
|
||
| // Helper component to register a breadcrumb before rendering BreadCrumbsWrapper | ||
| function BreadCrumbsWrapperWithTitle({ path, title }: { path: string; title: string }) { | ||
|
Check warning on line 18 in frontend/__tests__/unit/components/BreadCrumbsWrapper.test.tsx
|
||
| useEffect(() => { | ||
| const unregister = registerBreadcrumb({ path, title }) | ||
| return unregister | ||
| }, [path, title]) | ||
| return <BreadCrumbsWrapper /> | ||
| } | ||
|
|
||
| describe('BreadCrumbsWrapper', () => { | ||
| afterEach(() => { | ||
| jest.clearAllMocks() | ||
| }) | ||
|
|
||
| describe('Route Detection - Should Hide', () => { | ||
| test('returns null on home page', () => { | ||
| ;(usePathname as jest.Mock).mockReturnValue('/') | ||
|
|
||
| const { container } = render(<BreadCrumbsWrapper />) | ||
| expect(container.firstChild).toBeNull() | ||
| }) | ||
| }) | ||
|
|
||
| describe('Route Detection - Should Render', () => { | ||
| test('renders for routes with registered breadcrumbs', () => { | ||
| ;(usePathname as jest.Mock).mockReturnValue('/projects') | ||
|
|
||
| render(<BreadCrumbsWrapperWithTitle path="/projects" title="Projects" />, { 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( | ||
| <BreadCrumbsWrapperWithTitle | ||
| path="/projects/test-project-1" | ||
| title="Security Scanner Tool" | ||
| />, | ||
| { wrapper } | ||
| ) | ||
| // Should show registered title instead of URL slug | ||
| expect(screen.getByText('Security Scanner Tool')).toBeInTheDocument() | ||
| }) | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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( | ||
| <BreadcrumbRoot> | ||
| <BreadCrumbsWrapper /> | ||
| {ui} | ||
| </BreadcrumbRoot> | ||
| ) | ||
| } | ||
|
|
||
| describe('PageLayout', () => { | ||
| afterEach(() => { | ||
| jest.clearAllMocks() | ||
| }) | ||
|
|
||
| test('renders children components', () => { | ||
| ;(usePathname as jest.Mock).mockReturnValue('/projects/zap') | ||
|
|
||
| renderWithProvider( | ||
| <PageLayout title="OWASP ZAP"> | ||
| <div>Child Content</div> | ||
| </PageLayout> | ||
| ) | ||
|
|
||
| expect(screen.getByText('Child Content')).toBeInTheDocument() | ||
| }) | ||
|
|
||
| describe('Title Prop Handling', () => { | ||
| test('displays title in breadcrumbs', async () => { | ||
| ;(usePathname as jest.Mock).mockReturnValue('/projects/zap') | ||
|
|
||
| renderWithProvider( | ||
| <PageLayout title="OWASP ZAP"> | ||
| <div>Content</div> | ||
| </PageLayout> | ||
| ) | ||
|
|
||
| // 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( | ||
| <PageLayout title="OWASP ZAP" path="/projects/zap"> | ||
| <div>Content</div> | ||
| </PageLayout> | ||
| ) | ||
|
|
||
| await screen.findByText('OWASP ZAP') | ||
| expect(screen.getByText('OWASP ZAP')).toBeInTheDocument() | ||
| }) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }) => ( | ||
| <BreadcrumbRoot>{children}</BreadcrumbRoot> | ||
| ) | ||
|
|
||
| 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' }) | ||
| ) | ||
| unregisterFns.push( | ||
|
Check warning on line 72 in frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx
|
||
| 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(() => { | ||
| unregisterFns.forEach((fn) => fn()) | ||
|
Check failure on line 89 in frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx
|
||
| }) | ||
| }) | ||
| }) | ||
|
|
||
| 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' }, | ||
| ]) | ||
| }) | ||
| }) | ||
| }) | ||
Uh oh!
There was an error while loading. Please reload this page.