diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index c8b49ab0520..6497973d7ae 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -5,7 +5,7 @@ import { PropsWithChildren, useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { locationSearchToObject, locationService, useScopes } from '@grafana/runtime'; -import { getDragStyles, LinkButton, useStyles2 } from '@grafana/ui'; +import { ErrorBoundaryAlert, getDragStyles, LinkButton, useStyles2 } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { useMediaQueryMinWidth } from 'app/core/hooks/useMediaQueryMinWidth'; import { Trans } from 'app/core/internationalization'; @@ -122,7 +122,9 @@ export function AppChrome({ children }: Props) { [styles.scopesDashboardsContainerDocked]: menuDockedAndOpen, })} > - + + + )}
({ + ...jest.requireActual('@grafana/runtime'), + config: { + featureToggles: { + useScopesNavigationEndpoint: false, + }, + }, + locationService: { + getLocation: jest.fn(), + }, +})); + +describe('ScopesDashboardsService', () => { + let service: ScopesDashboardsService; + let mockApiClient: jest.Mocked; + + beforeEach(() => { + mockApiClient = { + fetchDashboards: jest.fn(), + fetchScopeNavigations: jest.fn(), + } as unknown as jest.Mocked; + + service = new ScopesDashboardsService(mockApiClient); + }); + + describe('folder expansion based on location', () => { + it('should expand folders when current location matches dashboard ID', async () => { + // Mock current location to be a dashboard + (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/d/dashboard1' } as Location); + + const mockDashboards: ScopeDashboardBinding[] = [ + { + spec: { + scope: 'scope1', + dashboard: 'dashboard1', + }, + status: { + dashboardTitle: 'Test Dashboard', + groups: ['group1'], + }, + metadata: { + name: 'dashboard1', + }, + }, + ]; + + mockApiClient.fetchDashboards.mockResolvedValue(mockDashboards); + await service.fetchDashboards(['scope1']); + + // Verify that the folder is expanded because the current dashboard ID matches + expect(service.state.folders[''].folders['group1'].expanded).toBe(true); + }); + + it('should expand folders when current location matches URL path and navigation endpoint is enabled', async () => { + // Enable the navigation endpoint feature toggle + config.featureToggles.useScopesNavigationEndpoint = true; + + // Mock current location to match a URL-based navigation + (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/test-url' } as Location); + + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + scope: 'scope1', + url: '/test-url', + }, + status: { + title: 'Test URL', + groups: ['group1'], + }, + metadata: { + name: 'url1', + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); + await service.fetchDashboards(['scope1']); + + // Verify that the folder is expanded because the current URL path matches + expect(service.state.folders[''].folders['group1'].expanded).toBe(true); + + // Reset the feature toggle + config.featureToggles.useScopesNavigationEndpoint = false; + }); + + it('should not expand folders when current location does not match any navigation', async () => { + // Mock current location to not match any navigation + (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/different-path' } as Location); + + const mockDashboards: ScopeDashboardBinding[] = [ + { + spec: { + scope: 'scope1', + dashboard: 'dashboard1', + }, + status: { + dashboardTitle: 'Test Dashboard', + groups: ['group1'], + }, + metadata: { + name: 'dashboard1', + }, + }, + ]; + + mockApiClient.fetchDashboards.mockResolvedValue(mockDashboards); + await service.fetchDashboards(['scope1']); + + // Verify that the folder is not expanded because the current location doesn't match + expect(service.state.folders[''].folders['group1'].expanded).toBe(false); + }); + + it('should expand folders when current location matches nested dashboard path', async () => { + // Mock current location to be a nested dashboard path + (locationService.getLocation as jest.Mock).mockReturnValue({ + pathname: '/d/dashboard1/very-important', + } as Location); + + const mockDashboards: ScopeDashboardBinding[] = [ + { + spec: { + scope: 'scope1', + dashboard: 'dashboard1', + }, + status: { + dashboardTitle: 'Test Dashboard', + groups: ['group1'], + }, + metadata: { + name: 'dashboard1', + }, + }, + ]; + + mockApiClient.fetchDashboards.mockResolvedValue(mockDashboards); + await service.fetchDashboards(['scope1']); + + // Verify that the folder is expanded because the current path starts with the dashboard ID + expect(service.state.folders[''].folders['group1'].expanded).toBe(true); + }); + + it('should not expand folders containing different dashboards', async () => { + // Mock current location to be a specific dashboard + (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/d/dashboard1' } as Location); + + const mockDashboards: ScopeDashboardBinding[] = [ + { + spec: { + scope: 'scope1', + dashboard: 'dashboard1', + }, + status: { + dashboardTitle: 'Test Dashboard', + groups: ['group1'], + }, + metadata: { + name: 'dashboard1', + }, + }, + { + spec: { + scope: 'scope1', + dashboard: 'dashboard2', + }, + status: { + dashboardTitle: 'Another Dashboard', + groups: ['group2'], + }, + metadata: { + name: 'dashboard2', + }, + }, + ]; + + mockApiClient.fetchDashboards.mockResolvedValue(mockDashboards); + await service.fetchDashboards(['scope1']); + + // Verify that only the folder containing the current dashboard is expanded + expect(service.state.folders[''].folders['group1'].expanded).toBe(true); + expect(service.state.folders[''].folders['group2'].expanded).toBe(false); + }); + + describe('with useScopesNavigationEndpoint enabled', () => { + beforeEach(() => { + config.featureToggles.useScopesNavigationEndpoint = true; + }); + + afterEach(() => { + config.featureToggles.useScopesNavigationEndpoint = false; + }); + + it('should expand folders when current location matches a navigation URL', async () => { + // Mock current location to match a navigation URL + (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/custom-page' } as Location); + + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + scope: 'scope1', + url: '/custom-page', + }, + status: { + title: 'Custom Page', + groups: ['group1'], + }, + metadata: { + name: 'nav1', + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); + await service.fetchDashboards(['scope1']); + + // Verify that the folder is expanded because the current URL matches a navigation + expect(service.state.folders[''].folders['group1'].expanded).toBe(true); + }); + + it('should expand folders when current location matches a nested navigation URL', async () => { + // Mock current location to match a nested navigation URL + (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/custom-page/details' } as Location); + + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + scope: 'scope1', + url: '/custom-page', + }, + status: { + title: 'Custom Page', + groups: ['group1'], + }, + metadata: { + name: 'nav1', + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); + await service.fetchDashboards(['scope1']); + + // Verify that the folder is expanded because the current URL starts with a navigation URL + expect(service.state.folders[''].folders['group1'].expanded).toBe(true); + }); + + it('should not expand folders when current location does not match any navigation', async () => { + // Mock current location to not match any navigation + (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/unrelated-page' } as Location); + + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + scope: 'scope1', + url: '/custom-page', + }, + status: { + title: 'Custom Page', + groups: ['group1'], + }, + metadata: { + name: 'nav1', + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); + await service.fetchDashboards(['scope1']); + + // Verify that the folder is not expanded because the current URL doesn't match any navigation + expect(service.state.folders[''].folders['group1'].expanded).toBe(false); + }); + + it('should not expand folders containing different navigations', async () => { + // Mock current location to match a specific navigation + (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/custom-page' } as Location); + + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + scope: 'scope1', + url: '/custom-page', + }, + status: { + title: 'Custom Page', + groups: ['group1'], + }, + metadata: { + name: 'nav1', + }, + }, + { + spec: { + scope: 'scope1', + url: '/other-page', + }, + status: { + title: 'Other Page', + groups: ['group2'], + }, + metadata: { + name: 'nav2', + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); + await service.fetchDashboards(['scope1']); + + // Verify that only the folder containing the current navigation is expanded + expect(service.state.folders[''].folders['group1'].expanded).toBe(true); + expect(service.state.folders[''].folders['group2'].expanded).toBe(false); + }); + }); + }); +}); diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts index 5044867eab6..8ab1e7bff0c 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts +++ b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts @@ -1,7 +1,7 @@ import { isEqual } from 'lodash'; import { ScopeDashboardBinding } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { ScopesApiClient } from '../ScopesApiClient'; import { ScopesServiceBase } from '../ScopesServiceBase'; @@ -113,6 +113,9 @@ export class ScopesDashboardsService extends ScopesServiceBase ): SuggestedNavigationsFoldersMap => { + const currentPath = locationService.getLocation().pathname; + const isCurrentDashboard = currentPath.startsWith('/d/'); + const folders: SuggestedNavigationsFoldersMap = { '': { title: '', @@ -127,15 +130,33 @@ export class ScopesDashboardsService extends ScopesServiceBase { - if (group && !rootNode.folders[group]) { + const groupExists = !!rootNode.folders[group]; + const groupCurrentlyExpanded = groupExists && rootNode.folders[group].expanded; + + if (group && !groupExists) { rootNode.folders[group] = { title: group, - expanded: false, + expanded, folders: {}, suggestedNavigations: {}, }; } + if (group && expanded && !groupCurrentlyExpanded) { + rootNode.folders[group].expanded = true; + } }); const targets = diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx b/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx index 5e276837d2e..0f19594dcfc 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx +++ b/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx @@ -30,7 +30,7 @@ export function ScopesDashboardsTree({ folders, folderPath, onFolderUpdate }: Sc ))} {Object.values(folder.suggestedNavigations).map((navigation) => ( { marginTop: theme.spacing(0.25), }), children: css({ - paddingLeft: theme.spacing(4), + paddingLeft: theme.spacing(3), }), }; }; diff --git a/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.tsx b/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.tsx index 03c25d4f8c7..a1876b22741 100644 --- a/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.tsx +++ b/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.tsx @@ -1,6 +1,6 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import { useMemo } from 'react'; -import { Link } from 'react-router-dom-v5-compat'; +import { Link, useLocation } from 'react-router-dom-v5-compat'; import { GrafanaTheme2, IconName, locationUtil } from '@grafana/data'; import { Icon, useStyles2 } from '@grafana/ui'; @@ -14,10 +14,22 @@ export interface ScopesNavigationTreeLinkProps { export function ScopesNavigationTreeLink({ to, title, id }: ScopesNavigationTreeLinkProps) { const styles = useStyles2(getStyles); const linkIcon = useMemo(() => getLinkIcon(to), [to]); + const isDashboard = to.startsWith('/d/'); + + // 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; + + const isCurrent = to.startsWith(currentPath); return ( - - {title} + + {title} ); } @@ -48,21 +60,33 @@ const getStyles = (theme: GrafanaTheme2) => { return { container: css({ display: 'flex', - alignItems: 'flex-start', + alignItems: 'center', gap: theme.spacing(1), - padding: theme.spacing(0.5, 0), + padding: theme.spacing(0.75, 0), textAlign: 'left', + paddingLeft: theme.spacing(1), + wordBreak: 'break-word', - '&:last-child': css({ - paddingBottom: 0, - }), '&:hover, &:focus': css({ textDecoration: 'underline', }), }), - icon: css({ - marginTop: theme.spacing(0.25), + current: css({ + position: 'relative', + background: theme.colors.action.selected, + borderRadius: `0 ${theme.shape.radius.default} ${theme.shape.radius.default} 0`, + '&::before': { + backgroundImage: theme.colors.gradients.brandVertical, + borderRadius: theme.shape.radius.default, + content: '" "', + display: 'block', + height: '100%', + position: 'absolute', + width: theme.spacing(0.5), + top: 0, + left: 0, + }, }), }; };