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,
+ },
}),
};
};