From 9a98dfc82627eaf90ddd627d4ec4061a3fd36175 Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Wed, 14 May 2025 14:18:24 +0200 Subject: [PATCH] Scopes: Fix highlighting of current active dashboard. (#105333) * Do not match current link based on startsWith * Add test and take query params into account * Extract dashboard path handling * Expand comment --- .../ScopesNavigationTreeLink.test.tsx | 108 ++++++++++++++++++ .../dashboards/ScopesNavigationTreeLink.tsx | 16 ++- 2 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 public/app/features/scopes/dashboards/ScopesNavigationTreeLink.test.tsx diff --git a/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.test.tsx b/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.test.tsx new file mode 100644 index 00000000000..b8775a3c21b --- /dev/null +++ b/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.test.tsx @@ -0,0 +1,108 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom-v5-compat'; + +import { ScopesNavigationTreeLink } from './ScopesNavigationTreeLink'; + +// Mock react-router-dom's useLocation +jest.mock('react-router-dom-v5-compat', () => ({ + ...jest.requireActual('react-router-dom-v5-compat'), + useLocation: jest.fn(), +})); + +const renderWithRouter = (ui: React.ReactElement) => { + return render({ui}); +}; + +describe('ScopesNavigationTreeLink', () => { + const mockUseLocation = useLocation as jest.Mock; + + beforeEach(() => { + mockUseLocation.mockReturnValue({ pathname: '/current-path' }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders link with correct props', () => { + renderWithRouter(); + + const link = screen.getByTestId('scopes-dashboards-test-id'); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', '/test-path'); + expect(link).toHaveAttribute('role', 'treeitem'); + expect(link).toHaveTextContent('Test Link'); + }); + + it('sets aria-current when path matches', () => { + mockUseLocation.mockReturnValue({ pathname: '/test-path' }); + + renderWithRouter(); + + const link = screen.getByTestId('scopes-dashboards-test-id'); + expect(link).toHaveAttribute('aria-current', 'page'); + }); + + it('does not set aria-current when path does not match', () => { + mockUseLocation.mockReturnValue({ pathname: '/different-path' }); + + renderWithRouter(); + + const link = screen.getByTestId('scopes-dashboards-test-id'); + expect(link).not.toHaveAttribute('aria-current'); + }); + + it('handles dashboard paths correctly', () => { + mockUseLocation.mockReturnValue({ pathname: '/d/dashboard1/some-details' }); + + renderWithRouter(); + + const link = screen.getByTestId('scopes-dashboards-dashboard-id'); + expect(link).toHaveAttribute('aria-current', 'page'); + }); + + it('does not match when path is just the start of another path', () => { + mockUseLocation.mockReturnValue({ pathname: '/test-path/extra' }); + + renderWithRouter(); + + const link = screen.getByTestId('scopes-dashboards-test-id'); + expect(link).not.toHaveAttribute('aria-current'); + }); + + it('only highlights the matching link when multiple links are present', () => { + mockUseLocation.mockReturnValue({ pathname: '/test-path' }); + + renderWithRouter( + <> + + + + ); + + const matchingLink = screen.getByTestId('scopes-dashboards-matching-id'); + const otherLink = screen.getByTestId('scopes-dashboards-other-id'); + + expect(matchingLink).toHaveAttribute('aria-current', 'page'); + expect(otherLink).not.toHaveAttribute('aria-current'); + }); + + it('matches path correctly when current location has query parameters', () => { + mockUseLocation.mockReturnValue({ + pathname: '/test-path', + }); + + renderWithRouter( + <> + + + + ); + + const matchingLink = screen.getByTestId('scopes-dashboards-matching-id'); + const otherLink = screen.getByTestId('scopes-dashboards-other-id'); + + expect(matchingLink).toHaveAttribute('aria-current', 'page'); + expect(otherLink).not.toHaveAttribute('aria-current'); + }); +}); diff --git a/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.tsx b/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.tsx index a1876b22741..589cbe9c743 100644 --- a/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.tsx +++ b/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.tsx @@ -11,19 +11,29 @@ export interface ScopesNavigationTreeLinkProps { id: string; } +// Helper function to get the base path for a dashboard URL for comparison purposes. +// e.g., /d/dashboardId/slug -> /d/dashboardId +// /d/dashboardId -> /d/dashboardId +function getDashboardPathForComparison(pathname: string): string { + return pathname.split('/').slice(0, 3).join('/'); +} + export function ScopesNavigationTreeLink({ to, title, id }: ScopesNavigationTreeLinkProps) { const styles = useStyles2(getStyles); const linkIcon = useMemo(() => getLinkIcon(to), [to]); const isDashboard = to.startsWith('/d/'); + const locPathname = useLocation().pathname; - // For dashboards, the title is appended to the path. We need to diregard this - const currentPath = isDashboard ? useLocation().pathname.split('/').slice(0, 3).join('/') : useLocation().pathname; + // For dashboards, the title is appended to the path when we navigate to just the dashboard id, hence we need to disregard this + const currentPath = isDashboard ? getDashboardPathForComparison(locPathname) : locPathname; - const isCurrent = to.startsWith(currentPath); + // Ignore query params + const isCurrent = to.split('?')[0] === currentPath; return (