Scopes: Redirect to first available dashboard on select (#112257)

* Redirect to first available dashboard on select

* Check currently active url before redirecting

* Expand currently selected group on selection

* Add unit test

* Test group expansion on URL change

* Don't expand any group if the active item is already in an expanded one

* Mock dashboardsService better

* Fix linitng issue

* Add and remove subscrioption based on open state

* Extract scope navigation utils

* Fix import order

* Fix import path

* Add redirection tests to ScopesSelectorService

* Fix import order
This commit is contained in:
Tobias Skarhed
2025-10-21 10:56:37 +02:00
committed by GitHub
parent d2462a80f6
commit 5c97059d5c
8 changed files with 419 additions and 23 deletions
@@ -1,4 +1,5 @@
import { Location } from 'history';
import { Subject } from 'rxjs';
import { ScopeDashboardBinding } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
@@ -17,6 +18,10 @@ jest.mock('@grafana/runtime', () => ({
},
locationService: {
getLocation: jest.fn(),
// Mock getLocationObservable to return a mock observable
getLocationObservable: jest.fn().mockReturnValue({
subscribe: jest.fn(),
}),
},
}));
@@ -61,12 +66,11 @@ describe('ScopesDashboardsService', () => {
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
it('should expand folder when location changes and matches a navigation URL', async () => {
config.featureToggles.useScopesNavigationEndpoint = true;
// Mock current location to match a URL-based navigation
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/test-url' } as Location);
// Mock initial location
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/' } as Location);
const mockNavigations: ScopeNavigation[] = [
{
@@ -85,10 +89,73 @@ describe('ScopesDashboardsService', () => {
];
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);
// Set up mock observable to emit location changes
const locationSubject = new Subject<Location>();
(locationService.getLocationObservable as jest.Mock).mockReturnValue(locationSubject);
// Create a new service instance that will subscribe to our mocked observable
const testService = new ScopesDashboardsService(mockApiClient);
await testService.fetchDashboards(['scope1']);
// Initially, folder should not be expanded since we're at '/'
expect(testService.state.folders[''].folders['group1'].expanded).toBe(false);
// Simulate location change to a URL that matches a navigation
locationSubject.next({ pathname: '/test-url' } as Location);
// Now the folder should be expanded because the location matches a navigation URL
expect(testService.state.folders[''].folders['group1'].expanded).toBe(true);
// Reset the feature toggle
config.featureToggles.useScopesNavigationEndpoint = false;
});
it('should not expand folder when location changes, matches a navigation URL in a folder which is already expanded', async () => {
config.featureToggles.useScopesNavigationEndpoint = true;
// Mock initial location
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/' } as Location);
const mockNavigations: ScopeNavigation[] = [
{
spec: {
scope: 'scope1',
url: '/test-url',
},
status: {
title: 'Test URL',
groups: ['group1', 'group2'],
},
metadata: {
name: 'url1',
},
},
];
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
// Set up mock observable to emit location changes
const locationSubject = new Subject<Location>();
(locationService.getLocationObservable as jest.Mock).mockReturnValue(locationSubject);
// Create a new service instance that will subscribe to our mocked observable
const testService = new ScopesDashboardsService(mockApiClient);
await testService.fetchDashboards(['scope1']);
// Initially, folder should not be expanded since we're at '/'
expect(testService.state.folders[''].folders['group1'].expanded).toBe(false);
// Manually expand group1 to simulate it being already expanded
testService.updateFolder(['', 'group2'], true);
expect(testService.state.folders[''].folders['group2'].expanded).toBe(true);
// Simulate location change to a URL that matches a navigation
locationSubject.next({ pathname: '/test-url' } as Location);
// The folder should still be expanded (no change since it was already expanded)
expect(testService.state.folders[''].folders['group1'].expanded).toBe(false);
expect(testService.state.folders[''].folders['group2'].expanded).toBe(true);
// Reset the feature toggle
config.featureToggles.useScopesNavigationEndpoint = false;
@@ -1,4 +1,5 @@
import { isEqual } from 'lodash';
import { Subscription } from 'rxjs';
import { ScopeDashboardBinding } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
@@ -6,6 +7,7 @@ import { config, locationService } from '@grafana/runtime';
import { ScopesApiClient } from '../ScopesApiClient';
import { ScopesServiceBase } from '../ScopesServiceBase';
import { isCurrentPath } from './scopeNavgiationUtils';
import { ScopeNavigation, SuggestedNavigationsFoldersMap } from './types';
interface ScopesDashboardsServiceState {
@@ -24,6 +26,7 @@ interface ScopesDashboardsServiceState {
}
export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsServiceState> {
private locationSubscription: Subscription | undefined;
constructor(private apiClient: ScopesApiClient) {
super({
drawerOpened: false,
@@ -35,8 +38,56 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
loading: false,
searchQuery: '',
});
// Add/ remove location subscribtion based on the drawer opened state
this.subscribeToState((state, prevState) => {
if (state.drawerOpened === prevState.drawerOpened) {
return;
}
if (state.drawerOpened && !prevState.drawerOpened) {
// Before creating a new subscription, ensure any existing subscription is disposed to avoid multiple active subscriptions and potential memory leaks.
this.locationSubscription?.unsubscribe();
this.locationSubscription = locationService.getLocationObservable().subscribe((location) => {
this.onLocationChange(location.pathname);
});
} else if (!state.drawerOpened && prevState.drawerOpened) {
this.locationSubscription?.unsubscribe();
}
});
}
// Expand the group that matches the current path, if it is not already expanded
private onLocationChange = (pathname: string) => {
if (!this.state.drawerOpened) {
return;
}
const currentPath = pathname;
const activeScopeNavigation = this.state.scopeNavigations.find((s) => {
if (!('url' in s.spec) || typeof s.spec.url !== 'string') {
return false;
}
return isCurrentPath(currentPath, s.spec.url);
});
if (!activeScopeNavigation) {
return;
}
// Check if the activeScopeNavigation is in a folder that is already expanded
if (activeScopeNavigation.status.groups) {
for (const group of activeScopeNavigation.status.groups) {
if (this.state.folders[''].folders[group].expanded) {
return;
}
}
}
// Expand the first group, as we don't know which one to prioritize
if (activeScopeNavigation.status.groups) {
this.updateFolder(['', activeScopeNavigation.status.groups[0]], true);
}
};
public updateFolder = (path: string[], expanded: boolean) => {
let folders = { ...this.state.folders };
let filteredFolders = { ...this.state.filteredFolders };
@@ -5,30 +5,21 @@ import { Link, useLocation } from 'react-router-dom-v5-compat';
import { GrafanaTheme2, IconName, locationUtil } from '@grafana/data';
import { Icon, useStyles2 } from '@grafana/ui';
import { isCurrentPath } from './scopeNavgiationUtils';
export interface ScopesNavigationTreeLinkProps {
to: string;
title: string;
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 when we navigate to just the dashboard id, hence we need to disregard this
const currentPath = isDashboard ? getDashboardPathForComparison(locPathname) : locPathname;
// Ignore query params
const isCurrent = to.split('?')[0] === currentPath;
const isCurrent = isCurrentPath(locPathname, to);
return (
<Link
@@ -0,0 +1,31 @@
import { getDashboardPathForComparison, isCurrentPath } from './scopeNavgiationUtils';
describe('scopeNavgiationUtils', () => {
it('should return the correct path for a dashboard', () => {
expect(getDashboardPathForComparison('/d/dashboardId/slug')).toBe('/d/dashboardId');
expect(getDashboardPathForComparison('/d/dashboardId')).toBe('/d/dashboardId');
expect(getDashboardPathForComparison('/d/dashboardId/slug?query=param')).toBe('/d/dashboardId');
});
it('should return the correct path for a navigation', () => {
expect(isCurrentPath('/d/dashboardId/slug', '/d/dashboardId')).toBe(true);
expect(isCurrentPath('/d/dashboardId', '/d/dashboardId')).toBe(true);
});
it('shoudl handle non-dashboard paths', () => {
expect(isCurrentPath('/other/path', '/other/path')).toBe(true);
expect(isCurrentPath('/other/path', '/other/path?query=param')).toBe(true);
expect(isCurrentPath('/other/path', '/other/path#hash')).toBe(true);
expect(isCurrentPath('/other/path', '/other/path?query=param#hash')).toBe(true);
});
it('should return the correct path for a navigation with query params', () => {
expect(isCurrentPath('/d/dashboardId/slug', '/d/dashboardId?query=param')).toBe(true);
expect(isCurrentPath('/d/dashboardId', '/d/dashboardId?query=param')).toBe(true);
});
it('should return the correct path for a navigation with hash', () => {
expect(isCurrentPath('/d/dashboardId/slug', '/d/dashboardId#hash')).toBe(true);
expect(isCurrentPath('/d/dashboardId', '/d/dashboardId#hash')).toBe(true);
});
});
@@ -0,0 +1,24 @@
// 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
export function getDashboardPathForComparison(pathname: string): string {
return pathname.split('/').slice(0, 3).join('/');
}
export function normalizePath(path: string): string {
// Remove query + hash + trailing slash (except root)
const noQuery = path.split('?')[0].split('#')[0];
return noQuery !== '/' && noQuery.endsWith('/') ? noQuery.slice(0, -1) : noQuery;
}
// Pathname comes from location.pathname
export function isCurrentPath(pathname: string, to: string): boolean {
const isDashboard = to.startsWith('/d/');
if (isDashboard) {
// For dashboards, the title is appended to the path when we navigate to just the dashboard id, hence we need to disregard this
return getDashboardPathForComparison(pathname) === normalizePath(to);
}
//Ignore query params
return pathname === normalizePath(to);
}
@@ -1,11 +1,22 @@
import { Scope, ScopeNode, Store } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { ScopesApiClient } from '../ScopesApiClient';
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
import { ScopeNavigation } from '../dashboards/types';
import { RECENT_SCOPES_KEY, ScopesSelectorService } from './ScopesSelectorService';
import { RecentScope } from './types';
// Mock locationService
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
locationService: {
push: jest.fn(),
getLocation: jest.fn(),
},
}));
describe('ScopesSelectorService', () => {
let service: ScopesSelectorService;
let apiClient: jest.Mocked<ScopesApiClient>;
@@ -40,6 +51,12 @@ describe('ScopesSelectorService', () => {
let store: Store;
beforeEach(() => {
// Clear all mocks
jest.clearAllMocks();
// Mock locationService to return a default location
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-page' });
apiClient = {
fetchScope: jest.fn().mockResolvedValue(mockScope),
fetchMultipleScopes: jest.fn().mockResolvedValue([mockScope]),
@@ -55,7 +72,17 @@ describe('ScopesSelectorService', () => {
} as unknown as jest.Mocked<ScopesApiClient>;
dashboardsService = {
fetchDashboards: jest.fn(),
fetchDashboards: jest.fn().mockResolvedValue(undefined),
state: {
scopeNavigations: [],
dashboards: [],
drawerOpened: false,
filteredFolders: {},
folders: {},
forScopeNames: [],
loading: false,
searchQuery: '',
},
} as unknown as jest.Mocked<ScopesDashboardsService>;
storeValue = {};
@@ -532,4 +559,155 @@ describe('ScopesSelectorService', () => {
expect(recentScopes[2][0].parentNode).toBeUndefined(); // invalid parent node should be removed
});
});
describe('redirect on scope selection', () => {
it('should redirect to the first scopeNavigation with /d/ URL when current URL is not a scopeNavigation', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
scope: 'test-scope',
url: '/d/dashboard1',
},
status: {
title: 'Dashboard 1',
groups: [],
},
metadata: {
name: 'dashboard1',
},
},
];
dashboardsService.state.scopeNavigations = mockNavigations;
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
await service.changeScopes(['test-scope']);
expect(locationService.push).toHaveBeenCalledWith('/d/dashboard1');
});
it('should NOT redirect when the first scopeNavigation does not contain /d/ (e.g., logs drilldown)', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
scope: 'test-scope',
url: '/explore',
},
status: {
title: 'Explore',
groups: [],
},
metadata: {
name: 'explore1',
},
},
];
dashboardsService.state.scopeNavigations = mockNavigations;
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
await service.changeScopes(['test-scope']);
expect(locationService.push).not.toHaveBeenCalled();
});
it('should NOT redirect when current URL matches a scopeNavigation', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
scope: 'test-scope',
url: '/d/dashboard1',
},
status: {
title: 'Dashboard 1',
groups: [],
},
metadata: {
name: 'dashboard1',
},
},
];
dashboardsService.state.scopeNavigations = mockNavigations;
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/d/dashboard1' });
await service.changeScopes(['test-scope']);
expect(locationService.push).not.toHaveBeenCalled();
});
it('should NOT redirect when there are no scopeNavigations', async () => {
dashboardsService.state.scopeNavigations = [];
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
await service.changeScopes(['test-scope']);
expect(locationService.push).not.toHaveBeenCalled();
});
it('should NOT redirect when scopeNavigation does not have a url property', async () => {
const mockNavigations = [
{
spec: {
scope: 'test-scope',
// Missing url property
},
status: {
title: 'Dashboard 1',
groups: [],
},
metadata: {
name: 'dashboard1',
},
},
] as unknown as ScopeNavigation[];
dashboardsService.state.scopeNavigations = mockNavigations;
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
await service.changeScopes(['test-scope']);
expect(locationService.push).not.toHaveBeenCalled();
});
it('should handle multiple scopeNavigations and redirect to the first dashboard one', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
scope: 'test-scope',
url: '/d/first-dashboard',
},
status: {
title: 'First Dashboard',
groups: [],
},
metadata: {
name: 'first-dashboard',
},
},
{
spec: {
scope: 'test-scope',
url: '/d/second-dashboard',
},
status: {
title: 'Second Dashboard',
groups: [],
},
metadata: {
name: 'second-dashboard',
},
},
];
dashboardsService.state.scopeNavigations = mockNavigations;
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
await service.changeScopes(['test-scope']);
// Should redirect to the first one
expect(locationService.push).toHaveBeenCalledWith('/d/first-dashboard');
expect(locationService.push).toHaveBeenCalledTimes(1);
});
});
});
@@ -1,11 +1,12 @@
import { Scope, ScopeNode, store as storeImpl } from '@grafana/data';
import { config } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import { SceneRenderProfiler } from '@grafana/scenes';
import { getDashboardSceneProfiler } from 'app/features/dashboard/services/DashboardProfiler';
import { ScopesApiClient } from '../ScopesApiClient';
import { ScopesServiceBase } from '../ScopesServiceBase';
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
import { isCurrentPath } from '../dashboards/scopeNavgiationUtils';
import {
closeNodes,
@@ -18,7 +19,6 @@ import {
treeNodeAtPath,
} from './scopesTreeUtils';
import { NodesMap, RecentScope, RecentScopeSchema, ScopeSchema, ScopesMap, SelectedScope, TreeNode } from './types';
export const RECENT_SCOPES_KEY = 'grafana.scopes.recent';
export interface ScopesSelectorServiceState {
@@ -362,7 +362,31 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
// Fetches both dashboards and scope navigations
// We call this even if we have 0 scope because in that case it also closes the dashboard drawer.
this.dashboardsService.fetchDashboards(scopes.map((s) => s.scopeId));
this.dashboardsService.fetchDashboards(scopes.map((s) => s.scopeId)).then(() => {
// Redirect to first scopeNavigation if current URL isn't a scopeNavigation
const currentPath = locationService.getLocation().pathname;
const activeScopeNavigation = this.dashboardsService.state.scopeNavigations.find((s) => {
if (!('url' in s.spec) || typeof s.spec.url !== 'string') {
return false;
}
return isCurrentPath(currentPath, s.spec.url);
});
if (!activeScopeNavigation && this.dashboardsService.state.scopeNavigations.length > 0) {
// Redirect to the first available scopeNavigation
const firstScopeNavigation = this.dashboardsService.state.scopeNavigations[0];
if (
firstScopeNavigation &&
'url' in firstScopeNavigation.spec &&
typeof firstScopeNavigation.spec.url === 'string' &&
// Only redirect to dashboards TODO: Remove this once Logs Drilldown has Scopes support
firstScopeNavigation.spec.url.includes('/d/')
) {
locationService.push(firstScopeNavigation.spec.url);
}
}
});
if (scopes.length > 0) {
const fetchedScopes = await this.apiClient.fetchMultipleScopes(scopes.map((s) => s.scopeId));
@@ -4,6 +4,7 @@ import { config, locationService } from '@grafana/runtime';
import { ScopesService } from '../ScopesService';
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
import { ScopeNavigation } from '../dashboards/types';
import {
clearNotFound,
@@ -261,6 +262,35 @@ describe('Dashboards list', () => {
expectDashboardLength('billing-usage', 1);
});
it('redirects to the first scope navigation if your current dashboard is not a scope navigation', async () => {
// Render another dashboard, which is not a scope navigation
const mockNavigations: ScopeNavigation[] = [
{
spec: {
scope: 'grafana',
url: '/d/dashboard1',
},
status: {
title: 'Dashboard 1',
groups: ['group1'],
},
metadata: {
name: 'dashboard1',
},
},
];
fetchDashboardsSpy.mockResolvedValue(mockNavigations);
await renderDashboard();
expect(locationService.getLocation().pathname).toBe('/');
await updateScopes(scopesService, ['grafana']);
expect(locationService.getLocation().pathname).toBe('/d/dashboard1');
// renderDashboard defaults to home dashboard
expect(locationService.getLocation().pathname).not.toBe('/');
expect(fetchDashboardsSpy).toHaveBeenCalled();
});
it('Shows a proper message when no scopes are selected', async () => {
await toggleDashboards();
expectNoDashboardsNoScopes();