Compare commits

...

18 Commits

Author SHA1 Message Date
Tobias Skarhed
5b2601aeda Update clearing internal nav path state 2025-11-28 12:30:33 +01:00
Tobias Skarhed
1769c222a0 Merge remote-tracking branch 'origin/main' into tskarhed/scopes/navigation-scope-sync-open-state 2025-11-26 14:50:50 +01:00
Tobias Skarhed
1d9be8a61c Merge remote-tracking branch 'refs/remotes/origin/tskarhed/scopes/navigation-scope-sync-open-state' into tskarhed/scopes/navigation-scope-sync-open-state 2025-11-26 14:23:52 +01:00
Tobias Skarhed
538bc93bd6 Fix folder epansion when clearing and switching scopes 2025-11-26 14:22:28 +01:00
Tobias Skarhed
2d1d7b469a Merge branch 'main' into tskarhed/scopes/navigation-scope-sync-open-state 2025-11-26 11:20:54 +01:00
Tobias Skarhed
d2d435dc10 Update for current path 2025-11-26 11:11:18 +01:00
Tobias Skarhed
44e80b007e Initial syncing of expanded folders 2025-11-25 10:46:19 +01:00
Tobias Skarhed
f5fbd3c732 Remove misplaced test 2025-11-21 16:14:10 +01:00
Tobias Skarhed
8ed9cc3bc8 Use ScopeNavgiations by default in the ScopesDashboardService unit tests 2025-11-21 15:04:59 +01:00
Tobias Skarhed
0950ce4280 Fix issues in test 2025-11-21 14:33:30 +01:00
Tobias Skarhed
7528057a30 Merge remote-tracking branch 'origin/main' into tskarhed/scopes/navigation-scope-sync 2025-11-21 14:23:35 +01:00
Tobias Skarhed
f30584dce1 Udpate test and remove errors 2025-11-21 14:15:05 +01:00
Tobias Skarhed
867228143e Add test for TreeFolderItem 2025-11-21 14:13:15 +01:00
Tobias Skarhed
e1e6e6b0db Update functionality to change scopes when clicking on icon 2025-11-21 11:52:08 +01:00
Tobias Skarhed
d5ea6244e0 Add a bunch of tests 2025-11-20 12:33:34 +01:00
Tobias Skarhed
6ac036efb8 Update unit test with dashboards service subscription handling 2025-11-20 12:03:34 +01:00
Tobias Skarhed
0b3672218b Proper URL sync 2025-11-19 11:32:18 +01:00
Tobias Skarhed
553f0f840a Set navigationScope if we have a subScope 2025-11-18 11:41:35 +01:00
8 changed files with 522 additions and 194 deletions

View File

@@ -4,7 +4,7 @@ import { config, locationService, ScopesContext } from '@grafana/runtime';
import { ScopesApiClient } from './ScopesApiClient'; import { ScopesApiClient } from './ScopesApiClient';
import { ScopesService } from './ScopesService'; import { ScopesService } from './ScopesService';
import { ScopesDashboardsService } from './dashboards/ScopesDashboardsService'; import { deserializeFolderPath, ScopesDashboardsService } from './dashboards/ScopesDashboardsService';
import { ScopesSelectorService } from './selector/ScopesSelectorService'; import { ScopesSelectorService } from './selector/ScopesSelectorService';
type Services = { type Services = {
@@ -33,7 +33,11 @@ interface ScopesContextProviderProps {
export function defaultScopesServices() { export function defaultScopesServices() {
const client = new ScopesApiClient(); const client = new ScopesApiClient();
const dashboardService = new ScopesDashboardsService(client); // Read initial expanded path from URL
const queryParams = new URLSearchParams(locationService.getLocation().search);
const initialExpandedPath = deserializeFolderPath(queryParams.get('nav_scope_path'));
const dashboardService = new ScopesDashboardsService(client, initialExpandedPath);
const selectorService = new ScopesSelectorService(client, dashboardService); const selectorService = new ScopesSelectorService(client, dashboardService);
return { return {
scopesService: new ScopesService(selectorService, dashboardService, locationService), scopesService: new ScopesService(selectorService, dashboardService, locationService),

View File

@@ -1,5 +1,4 @@
import { Location } from 'history'; import { Location } from 'history';
import { Subject } from 'rxjs';
import { config, locationService } from '@grafana/runtime'; import { config, locationService } from '@grafana/runtime';
@@ -7,7 +6,12 @@ import { ScopesApiClient } from '../ScopesApiClient';
// Import mock data for subScope tests // Import mock data for subScope tests
import { navigationWithSubScope, navigationWithSubScope2, navigationWithSubScopeAndGroups } from '../tests/utils/mocks'; import { navigationWithSubScope, navigationWithSubScope2, navigationWithSubScopeAndGroups } from '../tests/utils/mocks';
import { ScopesDashboardsService, filterItemsWithSubScopesInPath } from './ScopesDashboardsService'; import {
ScopesDashboardsService,
filterItemsWithSubScopesInPath,
serializeFolderPath,
deserializeFolderPath,
} from './ScopesDashboardsService';
import { ScopeNavigation } from './types'; import { ScopeNavigation } from './types';
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
@@ -20,10 +24,7 @@ jest.mock('@grafana/runtime', () => ({
}, },
locationService: { locationService: {
getLocation: jest.fn(), getLocation: jest.fn(),
// Mock getLocationObservable to return a mock observable partial: jest.fn(),
getLocationObservable: jest.fn().mockReturnValue({
subscribe: jest.fn(),
}),
}, },
})); }));
@@ -73,101 +74,6 @@ describe('ScopesDashboardsService', () => {
expect(service.state.folders[''].folders['group1'].expanded).toBe(true); expect(service.state.folders[''].folders['group1'].expanded).toBe(true);
}); });
it('should expand folder when location changes and matches a navigation URL', 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'],
},
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);
// 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;
});
it('should not expand folders when current location does not match any navigation', async () => { it('should not expand folders when current location does not match any navigation', async () => {
// Mock current location to not match any navigation // Mock current location to not match any navigation
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/different-path' } as Location); (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/different-path' } as Location);
@@ -796,6 +702,113 @@ describe('ScopesDashboardsService', () => {
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['navScope2']); expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['navScope2']);
}); });
it('should clear nav_scope_path when navigation scope changes', async () => {
mockApiClient.fetchScopeNavigations.mockResolvedValue([]);
await service.setNavigationScope('navScope1');
expect(locationService.partial).toHaveBeenCalledWith({ nav_scope_path: null });
});
it('should clear nav_scope_path when clearing navigation scope', async () => {
mockApiClient.fetchScopeNavigations.mockResolvedValue([]);
await service.setNavigationScope('navScope1');
(locationService.partial as jest.Mock).mockClear();
await service.setNavigationScope(undefined);
expect(locationService.partial).toHaveBeenCalledWith({ nav_scope_path: null });
});
it('should clear expandedFolderPath in state when navigation scope changes', async () => {
mockApiClient.fetchScopeNavigations.mockResolvedValue([]);
// Set an initial path (simulating a previous navigation with nested folders)
const testService = new ScopesDashboardsService(mockApiClient, ['', 'group1', 'subfolder']);
expect(testService.state.expandedFolderPath).toEqual(['', 'group1', 'subfolder']);
// Set navigation scope for the first time (initial load from URL)
await testService.setNavigationScope('navScope1');
// expandedFolderPath should be PRESERVED on initial load (from URL)
expect(testService.state.expandedFolderPath).toEqual(['', 'group1', 'subfolder']);
// Now change to a different navigation scope
await testService.setNavigationScope('navScope2');
// expandedFolderPath should NOW be cleared when scope changes
expect(testService.state.expandedFolderPath).toEqual([]);
});
it('should auto-expand folders after clearing navigation scope and selecting new scope', async () => {
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/d/dashboard1' } as Location);
// Step 1: Set a navigation scope with nested folders (initial load)
const testService = new ScopesDashboardsService(mockApiClient, ['', 'group1', 'subfolder']);
mockApiClient.fetchScopeNavigations.mockResolvedValue([]);
await testService.setNavigationScope('navScope1');
// expandedFolderPath should be preserved on initial load
expect(testService.state.expandedFolderPath).toEqual(['', 'group1', 'subfolder']);
// Step 2: Clear navigation scope (scope is changing)
await testService.setNavigationScope(undefined);
// expandedFolderPath should NOW be cleared when scope changes
expect(testService.state.expandedFolderPath).toEqual([]);
// Step 3: Select a new scope (without navigation scope) with a dashboard that matches current URL
const mockNavigations: ScopeNavigation[] = [
{
spec: { scope: 'scope1', url: '/d/dashboard1' },
status: { title: 'Test Dashboard', groups: ['group2'] },
metadata: { name: 'dashboard1' },
},
];
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
await testService.fetchDashboards(['scope1']);
// group2 should be auto-expanded because it contains the active dashboard
expect(testService.state.folders[''].folders['group2'].expanded).toBe(true);
});
it('should clear expandedFolderPath when scopes change via fetchDashboards', async () => {
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/d/dashboard2' } as Location);
// Start with an initial expanded path from URL
const testService = new ScopesDashboardsService(mockApiClient, ['', 'group1', 'subfolder']);
// Step 1: Fetch dashboards for scope1
mockApiClient.fetchScopeNavigations.mockResolvedValue([
{
spec: { scope: 'scope1', url: '/d/dashboard1' },
status: { title: 'Test Dashboard', groups: ['group1'] },
metadata: { name: 'dashboard1' },
},
]);
await testService.fetchDashboards(['scope1']);
// Path from URL should still be set after first fetch (initial load)
expect(testService.state.expandedFolderPath).toEqual(['', 'group1', 'subfolder']);
// Step 2: Fetch dashboards for a different scope (scope2) - simulating scope change
mockApiClient.fetchScopeNavigations.mockResolvedValue([
{
spec: { scope: 'scope2', url: '/d/dashboard2' },
status: { title: 'Another Dashboard', groups: ['group2'] },
metadata: { name: 'dashboard2' },
},
]);
await testService.fetchDashboards(['scope2']);
// expandedFolderPath should be cleared because scopes changed
expect(testService.state.expandedFolderPath).toEqual([]);
// group2 should be auto-expanded because path was cleared and it contains active item
expect(testService.state.folders[''].folders['group2'].expanded).toBe(true);
});
it('should update navigation scope when clearing an existing scope', async () => { it('should update navigation scope when clearing an existing scope', async () => {
mockApiClient.fetchScopeNavigations.mockResolvedValue([]); mockApiClient.fetchScopeNavigations.mockResolvedValue([]);
@@ -839,4 +852,117 @@ describe('ScopesDashboardsService', () => {
expect(service.state.drawerOpened).toBe(true); expect(service.state.drawerOpened).toBe(true);
}); });
}); });
describe('expandedFolderPath URL sync', () => {
it('should initialize with provided expandedFolderPath', () => {
const initialPath = ['', 'group1', 'subfolder'];
const testService = new ScopesDashboardsService(mockApiClient, initialPath);
expect(testService.state.expandedFolderPath).toEqual(initialPath);
});
it('should apply expandedFolderPath on initial load from URL', async () => {
const initialPath = ['', 'group1'];
const testService = new ScopesDashboardsService(mockApiClient, initialPath);
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/d/dashboard1' } as Location);
const mockNavigations: ScopeNavigation[] = [
{
spec: { scope: 'scope1', url: '/d/dashboard1' },
status: { title: 'Test Dashboard', groups: ['group1'] },
metadata: { name: 'dashboard1' },
},
];
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
await testService.fetchDashboards(['scope1']);
// Folder should be expanded based on initialPath
expect(testService.state.folders[''].folders['group1'].expanded).toBe(true);
});
it('should fetch items for subScope folders when applying expandedPath on initial load', async () => {
const subScopeNavigation = navigationWithSubScope;
const initialPath = ['', `${subScopeNavigation.spec.subScope}-${subScopeNavigation.metadata.name}`];
const testService = new ScopesDashboardsService(mockApiClient, initialPath);
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/' } as Location);
// Mock the initial scope navigations (includes subScope folder)
mockApiClient.fetchScopeNavigations.mockResolvedValueOnce([subScopeNavigation]);
// Mock the subScope items fetch
const subScopeItems: ScopeNavigation[] = [
{
spec: { scope: subScopeNavigation.spec.subScope!, url: '/d/subscope-dashboard' },
status: { title: 'SubScope Dashboard', groups: [] },
metadata: { name: 'subscope-dashboard' },
},
];
mockApiClient.fetchScopeNavigations.mockResolvedValueOnce(subScopeItems);
await testService.fetchDashboards(['scope1']);
// Wait for async subScope fetch to complete
await new Promise((resolve) => setTimeout(resolve, 0));
const folderKey = `${subScopeNavigation.spec.subScope}-${subScopeNavigation.metadata.name}`;
const subScopeFolder = testService.state.folders[''].folders[folderKey];
// Folder should be expanded
expect(subScopeFolder.expanded).toBe(true);
// SubScope items should have been fetched and added
expect(Object.keys(subScopeFolder.suggestedNavigations).length).toBeGreaterThan(0);
});
});
describe('serializeFolderPath and deserializeFolderPath', () => {
it('should serialize folder path correctly (excluding root)', () => {
const path = ['', 'group1', 'subfolder'];
const serialized = serializeFolderPath(path);
expect(serialized).toBe('group1,subfolder');
});
it('should serialize folder path with special characters (excluding root)', () => {
const path = ['', 'group/1', 'sub folder'];
const serialized = serializeFolderPath(path);
expect(serialized).toBe('group%2F1,sub%20folder');
});
it('should serialize root-only path as empty string', () => {
const path = [''];
const serialized = serializeFolderPath(path);
expect(serialized).toBe('');
});
it('should deserialize folder path correctly (prepending root)', () => {
const serialized = 'group1,subfolder';
const path = deserializeFolderPath(serialized);
expect(path).toEqual(['', 'group1', 'subfolder']);
});
it('should deserialize folder path with special characters (prepending root)', () => {
const serialized = 'group%2F1,sub%20folder';
const path = deserializeFolderPath(serialized);
expect(path).toEqual(['', 'group/1', 'sub folder']);
});
it('should return empty array for null path string', () => {
const path = deserializeFolderPath(null);
expect(path).toEqual([]);
});
it('should return empty array for empty path string', () => {
const path = deserializeFolderPath('');
expect(path).toEqual([]);
});
});
}); });

View File

@@ -1,5 +1,4 @@
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { Subscription } from 'rxjs';
import { ScopeDashboardBinding } from '@grafana/data'; import { ScopeDashboardBinding } from '@grafana/data';
import { config, locationService } from '@grafana/runtime'; import { config, locationService } from '@grafana/runtime';
@@ -7,7 +6,6 @@ import { config, locationService } from '@grafana/runtime';
import { ScopesApiClient } from '../ScopesApiClient'; import { ScopesApiClient } from '../ScopesApiClient';
import { ScopesServiceBase } from '../ScopesServiceBase'; import { ScopesServiceBase } from '../ScopesServiceBase';
import { isCurrentPath } from './scopeNavgiationUtils';
import { ScopeNavigation, SuggestedNavigationsFoldersMap, SuggestedNavigationsMap } from './types'; import { ScopeNavigation, SuggestedNavigationsFoldersMap, SuggestedNavigationsMap } from './types';
interface ScopesDashboardsServiceState { interface ScopesDashboardsServiceState {
@@ -24,11 +22,15 @@ interface ScopesDashboardsServiceState {
loading: boolean; loading: boolean;
searchQuery: string; searchQuery: string;
navigationScope?: string; navigationScope?: string;
// The path to the currently expanded folder (breadcrumb trail to active item)
expandedFolderPath: string[];
} }
export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsServiceState> { export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsServiceState> {
private locationSubscription: Subscription | undefined; constructor(
constructor(private apiClient: ScopesApiClient) { private apiClient: ScopesApiClient,
initialExpandedPath: string[] = []
) {
super({ super({
drawerOpened: false, drawerOpened: false,
dashboards: [], dashboards: [],
@@ -38,22 +40,7 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
forScopeNames: [], forScopeNames: [],
loading: false, loading: false,
searchQuery: '', searchQuery: '',
}); expandedFolderPath: initialExpandedPath,
// 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();
}
}); });
} }
@@ -65,40 +52,25 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
} }
const forScopeNames = navigationScope ? [navigationScope] : (fallbackScopeNames ?? []); const forScopeNames = navigationScope ? [navigationScope] : (fallbackScopeNames ?? []);
this.updateState({ navigationScope, drawerOpened: forScopeNames.length > 0 });
await this.fetchDashboards(forScopeNames);
};
// Expand the group that matches the current path, if it is not already expanded // Only clear expanded path if navigation scope was already set (scope is changing, not initial load)
private onLocationChange = (pathname: string) => { // On initial load, preserve the expandedFolderPath from URL
if (!this.state.drawerOpened) { const shouldClearExpandedPath = this.state.navigationScope !== undefined;
return;
} this.updateState({
const currentPath = pathname; navigationScope,
const activeScopeNavigation = this.state.scopeNavigations.find((s) => { drawerOpened: forScopeNames.length > 0,
if (!('url' in s.spec) || typeof s.spec.url !== 'string') { // Clear the expanded folder path only when scope changes (not on initial load)
return false; ...(shouldClearExpandedPath && { expandedFolderPath: [] }),
}
return isCurrentPath(currentPath, s.spec.url);
}); });
if (!activeScopeNavigation) { // Clear the navigation folder path from URL when the navigation scope changes
return; // since the folder structure will be different
if (shouldClearExpandedPath) {
locationService.partial({ nav_scope_path: null });
} }
// Check if the activeScopeNavigation is in a folder that is already expanded await this.fetchDashboards(forScopeNames);
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) => { public updateFolder = (path: string[], expanded: boolean) => {
@@ -203,6 +175,13 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
...currentFilteredFolder.suggestedNavigations, ...currentFilteredFolder.suggestedNavigations,
...rootSubScopeFolder.suggestedNavigations, ...rootSubScopeFolder.suggestedNavigations,
}; };
// Re-apply the expanded path to handle any nested folders within this subScope
// This is important when loading from URL with nested subScope folders
if (this.state.expandedFolderPath.length > path.length) {
this.applyExpandedPath(folders, this.state.expandedFolderPath);
this.applyExpandedPath(filteredFolders, this.state.expandedFolderPath);
}
} }
this.updateState({ folders, filteredFolders }); this.updateState({ folders, filteredFolders });
@@ -233,12 +212,21 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
forScopeNames: [], forScopeNames: [],
loading: false, loading: false,
drawerOpened: false, drawerOpened: false,
expandedFolderPath: [],
}); });
return; return;
} }
this.updateState({ forScopeNames, loading: true }); // Clear the expanded folder path when scopes change (unless this is initial load with path from URL)
// Only clear if we already had scopes loaded (not on initial load from empty state)
const shouldClearPath = this.state.forScopeNames.length > 0;
this.updateState({
forScopeNames,
loading: true,
...(shouldClearPath && { expandedFolderPath: [] }),
});
const fetchNavigations = config.featureToggles.useScopesNavigationEndpoint const fetchNavigations = config.featureToggles.useScopesNavigationEndpoint
? this.apiClient.fetchScopeNavigations ? this.apiClient.fetchScopeNavigations
@@ -248,7 +236,14 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
if (isEqual(this.state.forScopeNames, forScopeNames)) { if (isEqual(this.state.forScopeNames, forScopeNames)) {
const folders = this.groupSuggestedItems(res); const folders = this.groupSuggestedItems(res);
// Apply the expanded folder path from state (e.g., from URL on initial load)
// This will also trigger fetches for any subScope folders in the path
this.applyExpandedPath(folders, this.state.expandedFolderPath);
const filteredFolders = this.filterFolders(folders, this.state.searchQuery); const filteredFolders = this.filterFolders(folders, this.state.searchQuery);
// Also apply the expanded path to filtered folders to keep them in sync
this.applyExpandedPath(filteredFolders, this.state.expandedFolderPath);
this.updateState({ this.updateState({
scopeNavigations: res, scopeNavigations: res,
@@ -282,15 +277,23 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
const subScope = 'subScope' in navigation.spec ? navigation.spec.subScope : undefined; const subScope = 'subScope' in navigation.spec ? navigation.spec.subScope : undefined;
// If the current URL matches an item, expand the parent folders. // If the current URL matches an item, expand the parent folders.
// Only do this if we don't have an explicit expandedFolderPath from the URL
let expanded = false; let expanded = false;
if (this.state.expandedFolderPath.length === 0) {
if (isCurrentDashboard && 'dashboard' in navigation.spec) { if (isCurrentDashboard && 'dashboard' in navigation.spec) {
const dashboardId = currentPath.split('/')[2]; const dashboardId = currentPath.split('/')[2];
expanded = navigation.spec.dashboard === dashboardId; expanded = navigation.spec.dashboard === dashboardId;
} }
if ('url' in navigation.spec) { if ('url' in navigation.spec && typeof navigation.spec.url === 'string') {
expanded = currentPath.startsWith(navigation.spec.url); // For auto-expansion, support prefix matching (not just exact matching)
// This allows folders to be expanded when viewing nested pages
const normalizedNavUrl = navigation.spec.url.split('?')[0].split('#')[0];
// Match if exact match OR if current path starts with nav URL followed by '/'
// This prevents false matches like /custom matching /custom-other
expanded = currentPath === normalizedNavUrl || currentPath.startsWith(normalizedNavUrl + '/');
}
} }
// Helper function to add navigation item to a target // Helper function to add navigation item to a target
@@ -376,6 +379,42 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
return folders; return folders;
}; };
private applyExpandedPath = (folders: SuggestedNavigationsFoldersMap, path: string[]) => {
if (path.length === 0) {
return;
}
let currentLevelFolders: SuggestedNavigationsFoldersMap = folders;
const pathSoFar: string[] = [];
// Traverse the path and expand folders along the way
for (const folderKey of path) {
pathSoFar.push(folderKey);
const folder = currentLevelFolders[folderKey];
if (folder) {
folder.expanded = true;
// If this is a subScope folder and it's empty, trigger fetch
if (folder.subScopeName) {
const isEmpty =
Object.keys(folder.folders).length === 0 && Object.keys(folder.suggestedNavigations).length === 0;
if (isEmpty) {
// Trigger fetch for this subScope folder
folder.loading = true;
this.fetchSubScopeItems([...pathSoFar], folder.subScopeName);
}
}
currentLevelFolders = folder.folders;
} else {
// If folder doesn't exist in the path, stop traversing
break;
}
}
};
public filterFolders = (folders: SuggestedNavigationsFoldersMap, query: string): SuggestedNavigationsFoldersMap => { public filterFolders = (folders: SuggestedNavigationsFoldersMap, query: string): SuggestedNavigationsFoldersMap => {
query = (query ?? '').toLowerCase(); query = (query ?? '').toLowerCase();
@@ -412,6 +451,34 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
public toggleDrawer = () => this.updateState({ drawerOpened: !this.state.drawerOpened }); public toggleDrawer = () => this.updateState({ drawerOpened: !this.state.drawerOpened });
} }
/**
* Serializes a folder path array into a URL-safe string.
* Excludes the root folder ('') from the serialized path.
* @param path - The folder path array
* @returns Comma-separated, URL-encoded path string
*/
export function serializeFolderPath(path: string[]): string {
// Filter out empty strings (root folder) before serializing
return path
.filter((segment) => segment !== '')
.map((segment) => encodeURIComponent(segment))
.join(',');
}
/**
* Deserializes a URL path string back into a folder path array.
* Always prepends the root folder ('') to the path.
* @param pathString - The URL-encoded path string
* @returns Decoded folder path array with root prepended
*/
export function deserializeFolderPath(pathString: string | null): string[] {
if (!pathString || pathString === '') {
return [];
}
// Always prepend root folder to the decoded path
return ['', ...pathString.split(',').map((segment) => decodeURIComponent(segment))];
}
/** /**
* Filters out navigation items that have a subScope matching any subScope already in the path. * Filters out navigation items that have a subScope matching any subScope already in the path.
* This prevents infinite loops when a subScope returns items with the same subScope. * This prevents infinite loops when a subScope returns items with the same subScope.

View File

@@ -58,6 +58,7 @@ export function ScopesDashboardsTree({ subScope, folders, folderPath, onFolderUp
to={urlUtil.renderUrl(navigation.url, queryParams)} to={urlUtil.renderUrl(navigation.url, queryParams)}
title={navigation.title} title={navigation.title}
id={navigation.id} id={navigation.id}
folderPath={folderPath}
/> />
))} ))}

View File

@@ -17,6 +17,7 @@ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
locationService: { locationService: {
push: jest.fn(), push: jest.fn(),
partial: jest.fn(),
}, },
})); }));
@@ -63,7 +64,7 @@ describe('ScopesNavigationTreeLink', () => {
}); });
it('renders link with correct props', () => { it('renders link with correct props', () => {
renderWithRouter(<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" />); renderWithRouter(<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" folderPath={['']} />);
const link = screen.getByTestId('scopes-dashboards-test-id'); const link = screen.getByTestId('scopes-dashboards-test-id');
expect(link).toBeInTheDocument(); expect(link).toBeInTheDocument();
@@ -75,7 +76,7 @@ describe('ScopesNavigationTreeLink', () => {
it('sets aria-current when path matches', () => { it('sets aria-current when path matches', () => {
mockUseLocation.mockReturnValue({ pathname: '/test-path' }); mockUseLocation.mockReturnValue({ pathname: '/test-path' });
renderWithRouter(<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" />); renderWithRouter(<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" folderPath={['']} />);
const link = screen.getByTestId('scopes-dashboards-test-id'); const link = screen.getByTestId('scopes-dashboards-test-id');
expect(link).toHaveAttribute('aria-current', 'page'); expect(link).toHaveAttribute('aria-current', 'page');
@@ -84,7 +85,7 @@ describe('ScopesNavigationTreeLink', () => {
it('does not set aria-current when path does not match', () => { it('does not set aria-current when path does not match', () => {
mockUseLocation.mockReturnValue({ pathname: '/different-path' }); mockUseLocation.mockReturnValue({ pathname: '/different-path' });
renderWithRouter(<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" />); renderWithRouter(<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" folderPath={['']} />);
const link = screen.getByTestId('scopes-dashboards-test-id'); const link = screen.getByTestId('scopes-dashboards-test-id');
expect(link).not.toHaveAttribute('aria-current'); expect(link).not.toHaveAttribute('aria-current');
@@ -93,7 +94,9 @@ describe('ScopesNavigationTreeLink', () => {
it('handles dashboard paths correctly', () => { it('handles dashboard paths correctly', () => {
mockUseLocation.mockReturnValue({ pathname: '/d/dashboard1/some-details' }); mockUseLocation.mockReturnValue({ pathname: '/d/dashboard1/some-details' });
renderWithRouter(<ScopesNavigationTreeLink to="/d/dashboard1" title="Dashboard Link" id="dashboard-id" />); renderWithRouter(
<ScopesNavigationTreeLink to="/d/dashboard1" title="Dashboard Link" id="dashboard-id" folderPath={['']} />
);
const link = screen.getByTestId('scopes-dashboards-dashboard-id'); const link = screen.getByTestId('scopes-dashboards-dashboard-id');
expect(link).toHaveAttribute('aria-current', 'page'); expect(link).toHaveAttribute('aria-current', 'page');
@@ -102,7 +105,7 @@ describe('ScopesNavigationTreeLink', () => {
it('does not match when path is just the start of another path', () => { it('does not match when path is just the start of another path', () => {
mockUseLocation.mockReturnValue({ pathname: '/test-path/extra' }); mockUseLocation.mockReturnValue({ pathname: '/test-path/extra' });
renderWithRouter(<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" />); renderWithRouter(<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" folderPath={['']} />);
const link = screen.getByTestId('scopes-dashboards-test-id'); const link = screen.getByTestId('scopes-dashboards-test-id');
expect(link).not.toHaveAttribute('aria-current'); expect(link).not.toHaveAttribute('aria-current');
@@ -113,8 +116,8 @@ describe('ScopesNavigationTreeLink', () => {
renderWithRouter( renderWithRouter(
<> <>
<ScopesNavigationTreeLink to="/test-path" title="Matching Link" id="matching-id" /> <ScopesNavigationTreeLink to="/test-path" title="Matching Link" id="matching-id" folderPath={['']} />
<ScopesNavigationTreeLink to="/test-path-extra" title="Other Link" id="other-id" /> <ScopesNavigationTreeLink to="/test-path-extra" title="Other Link" id="other-id" folderPath={['']} />
</> </>
); );
@@ -132,8 +135,13 @@ describe('ScopesNavigationTreeLink', () => {
renderWithRouter( renderWithRouter(
<> <>
<ScopesNavigationTreeLink to="/test-path?param1=value1&param2=value2" title="Matching Link" id="matching-id" /> <ScopesNavigationTreeLink
<ScopesNavigationTreeLink to="/test-path-other" title="Other Link" id="other-id" /> to="/test-path?param1=value1&param2=value2"
title="Matching Link"
id="matching-id"
folderPath={['']}
/>
<ScopesNavigationTreeLink to="/test-path-other" title="Other Link" id="other-id" folderPath={['']} />
</> </>
); );
@@ -150,6 +158,7 @@ describe('ScopesNavigationTreeLink', () => {
to="/a/grafana-metricsdrilldown-app?from=now-1h&to=now" to="/a/grafana-metricsdrilldown-app?from=now-1h&to=now"
title="Metrics Drilldown" title="Metrics Drilldown"
id="metrics-drilldown" id="metrics-drilldown"
folderPath={['']}
/> />
); );
@@ -163,7 +172,12 @@ describe('ScopesNavigationTreeLink', () => {
it('shows correct icon for grafana-metricsdrilldown-app without trailing slash', () => { it('shows correct icon for grafana-metricsdrilldown-app without trailing slash', () => {
renderWithRouter( renderWithRouter(
<ScopesNavigationTreeLink to="/a/grafana-metricsdrilldown-app" title="Metrics Drilldown" id="metrics-drilldown" /> <ScopesNavigationTreeLink
to="/a/grafana-metricsdrilldown-app"
title="Metrics Drilldown"
id="metrics-drilldown"
folderPath={['']}
/>
); );
const link = screen.getByTestId('scopes-dashboards-metrics-drilldown'); const link = screen.getByTestId('scopes-dashboards-metrics-drilldown');
@@ -187,7 +201,13 @@ describe('ScopesNavigationTreeLink', () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithRouter( renderWithRouter(
<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" subScope="subScope1" /> <ScopesNavigationTreeLink
to="/test-path"
title="Test Link"
id="test-id"
subScope="subScope1"
folderPath={['', 'group1']}
/>
); );
const link = screen.getByTestId('scopes-dashboards-test-id'); const link = screen.getByTestId('scopes-dashboards-test-id');
@@ -203,7 +223,13 @@ describe('ScopesNavigationTreeLink', () => {
mockScopesSelectorService.state.appliedScopes = [{ scopeId: 'currentScope' }]; mockScopesSelectorService.state.appliedScopes = [{ scopeId: 'currentScope' }];
renderWithRouter( renderWithRouter(
<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" subScope="subScope1" /> <ScopesNavigationTreeLink
to="/test-path"
title="Test Link"
id="test-id"
subScope="subScope1"
folderPath={['', 'group1']}
/>
); );
const link = screen.getByTestId('scopes-dashboards-test-id'); const link = screen.getByTestId('scopes-dashboards-test-id');
@@ -217,7 +243,13 @@ describe('ScopesNavigationTreeLink', () => {
mockScopesSelectorService.state.appliedScopes = [{ scopeId: 'currentScope' }]; mockScopesSelectorService.state.appliedScopes = [{ scopeId: 'currentScope' }];
renderWithRouter( renderWithRouter(
<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" subScope="subScope1" /> <ScopesNavigationTreeLink
to="/test-path"
title="Test Link"
id="test-id"
subScope="subScope1"
folderPath={['', 'group1']}
/>
); );
const link = screen.getByTestId('scopes-dashboards-test-id'); const link = screen.getByTestId('scopes-dashboards-test-id');
@@ -229,7 +261,13 @@ describe('ScopesNavigationTreeLink', () => {
it('should call changeScopes with subScope', async () => { it('should call changeScopes with subScope', async () => {
renderWithRouter( renderWithRouter(
<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" subScope="subScope1" /> <ScopesNavigationTreeLink
to="/test-path"
title="Test Link"
id="test-id"
subScope="subScope1"
folderPath={['', 'group1']}
/>
); );
const link = screen.getByTestId('scopes-dashboards-test-id'); const link = screen.getByTestId('scopes-dashboards-test-id');
@@ -240,7 +278,13 @@ describe('ScopesNavigationTreeLink', () => {
it('should navigate to URL with updated query params', async () => { it('should navigate to URL with updated query params', async () => {
renderWithRouter( renderWithRouter(
<ScopesNavigationTreeLink to="/test-path?existing=param" title="Test Link" id="test-id" subScope="subScope1" /> <ScopesNavigationTreeLink
to="/test-path?existing=param"
title="Test Link"
id="test-id"
subScope="subScope1"
folderPath={['', 'group1']}
/>
); );
const link = screen.getByTestId('scopes-dashboards-test-id'); const link = screen.getByTestId('scopes-dashboards-test-id');
@@ -260,6 +304,7 @@ describe('ScopesNavigationTreeLink', () => {
title="Test Link" title="Test Link"
id="test-id" id="test-id"
subScope="subScope1" subScope="subScope1"
folderPath={['', 'group1']}
/> />
); );
@@ -272,17 +317,42 @@ describe('ScopesNavigationTreeLink', () => {
expect(pushedUrl).not.toContain('scope_parent'); expect(pushedUrl).not.toContain('scope_parent');
}); });
it('should allow normal navigation when subScope is not provided', async () => { it('should set nav_scope_path in URL when navigating with subScope', async () => {
renderWithRouter(
<ScopesNavigationTreeLink
to="/test-path"
title="Test Link"
id="test-id"
subScope="subScope1"
folderPath={['', 'group1', 'subfolder']}
/>
);
const link = screen.getByTestId('scopes-dashboards-test-id');
await userEvent.click(link);
expect(mockLocationServicePush).toHaveBeenCalled();
const pushedUrl = mockLocationServicePush.mock.calls[0][0];
// Root folder ('') should be excluded from the path
expect(pushedUrl).toContain('nav_scope_path=group1%2Csubfolder');
});
it('should set nav_scope_path in URL for regular navigation', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithRouter(<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" />); renderWithRouter(
<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" folderPath={['', 'group1']} />
);
const link = screen.getByTestId('scopes-dashboards-test-id'); const link = screen.getByTestId('scopes-dashboards-test-id');
await user.click(link); await user.click(link);
// Should not call changeScopes or locationService.push when subScope is not provided // Should not call changeScopes or push when subScope is not provided
expect(mockScopesSelectorService.changeScopes).not.toHaveBeenCalled(); expect(mockScopesSelectorService.changeScopes).not.toHaveBeenCalled();
expect(mockLocationServicePush).not.toHaveBeenCalled(); expect(mockLocationServicePush).not.toHaveBeenCalled();
// Should call partial to set nav_scope_path
expect(locationService.partial).toHaveBeenCalledWith({ nav_scope_path: 'group1' });
}); });
it('should handle URL with existing query params correctly', async () => { it('should handle URL with existing query params correctly', async () => {
@@ -296,6 +366,7 @@ describe('ScopesNavigationTreeLink', () => {
title="Test Link" title="Test Link"
id="test-id" id="test-id"
subScope="subScope1" subScope="subScope1"
folderPath={['', 'group1']}
/> />
); );

View File

@@ -2,12 +2,13 @@ import { css, cx } from '@emotion/css';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom-v5-compat'; import { Link, useLocation } from 'react-router-dom-v5-compat';
import { GrafanaTheme2, IconName, locationUtil, UrlQueryMap, urlUtil } from '@grafana/data'; import { GrafanaTheme2, IconName, locationUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime'; import { locationService } from '@grafana/runtime';
import { Icon, useStyles2 } from '@grafana/ui'; import { Icon, useStyles2 } from '@grafana/ui';
import { useScopesServices } from '../ScopesContextProvider'; import { useScopesServices } from '../ScopesContextProvider';
import { serializeFolderPath } from './ScopesDashboardsService';
import { isCurrentPath, normalizePath } from './scopeNavgiationUtils'; import { isCurrentPath, normalizePath } from './scopeNavgiationUtils';
export interface ScopesNavigationTreeLinkProps { export interface ScopesNavigationTreeLinkProps {
@@ -15,9 +16,10 @@ export interface ScopesNavigationTreeLinkProps {
to: string; to: string;
title: string; title: string;
id: string; id: string;
folderPath: string[];
} }
export function ScopesNavigationTreeLink({ subScope, to, title, id }: ScopesNavigationTreeLinkProps) { export function ScopesNavigationTreeLink({ subScope, to, title, id, folderPath }: ScopesNavigationTreeLinkProps) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const linkIcon = useMemo(() => getLinkIcon(to), [to]); const linkIcon = useMemo(() => getLinkIcon(to), [to]);
const locPathname = useLocation().pathname; const locPathname = useLocation().pathname;
@@ -26,6 +28,9 @@ export function ScopesNavigationTreeLink({ subScope, to, title, id }: ScopesNavi
const isCurrent = isCurrentPath(locPathname, to); const isCurrent = isCurrentPath(locPathname, to);
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => { const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
// Set the expanded folder path in the URL
const pathString = serializeFolderPath(folderPath);
if (subScope) { if (subScope) {
e.preventDefault(); // Prevent default Link navigation e.preventDefault(); // Prevent default Link navigation
@@ -33,35 +38,33 @@ export function ScopesNavigationTreeLink({ subScope, to, title, id }: ScopesNavi
const currentScope = services?.scopesSelectorService?.state.appliedScopes[0]?.scopeId; const currentScope = services?.scopesSelectorService?.state.appliedScopes[0]?.scopeId;
const currentNavigationScope = services?.scopesDashboardsService?.state.navigationScope; const currentNavigationScope = services?.scopesDashboardsService?.state.navigationScope;
// Parse the URL to extract path and existing query params // Parse the URL to extract path and existing query params using URL API
const url = new URL(to, window.location.origin); const url = new URL(to, window.location.origin);
const pathname = url.pathname;
const searchParams = new URLSearchParams(url.search);
if (!currentNavigationScope && currentScope) { if (!currentNavigationScope && currentScope) {
searchParams.set('navigation_scope', currentScope); url.searchParams.set('navigation_scope', currentScope);
services?.scopesDashboardsService?.setNavigationScope(currentScope); services?.scopesDashboardsService?.setNavigationScope(currentScope);
} }
// Update query params with the new subScope // Update query params with the new subScope
searchParams.set('scopes', subScope); url.searchParams.set('scopes', subScope);
// Remove scope_node and scope_parent since we're changing to a subScope // Remove scope_node and scope_parent since we're changing to a subScope
searchParams.delete('scope_node'); url.searchParams.delete('scope_node');
searchParams.delete('scope_parent'); url.searchParams.delete('scope_parent');
// Set the expanded folder path
url.searchParams.set('nav_scope_path', pathString);
// Convert URLSearchParams to query map object for urlUtil.renderUrl // Build the new URL safely using the URL API (pathname + search)
const queryMap: UrlQueryMap = {}; const newUrl = url.pathname + url.search;
searchParams.forEach((value, key) => {
queryMap[key] = value;
});
// Build the new URL safely using urlUtil.renderUrl
const newUrl = urlUtil.renderUrl(pathname, queryMap);
// Change scopes first (this updates the state) // Change scopes first (this updates the state)
services?.scopesSelectorService?.changeScopes([subScope], undefined, undefined, false); services?.scopesSelectorService?.changeScopes([subScope], undefined, undefined, false);
// Then navigate to the URL with updated query params // Then navigate to the URL with updated query params
locationService.push(newUrl); locationService.push(newUrl);
} else {
// For regular navigation (no subScope), set the folder path in the URL
locationService.partial({ nav_scope_path: pathString });
} }
}; };

View File

@@ -14,6 +14,7 @@ jest.mock('@grafana/runtime', () => ({
locationService: { locationService: {
push: jest.fn(), push: jest.fn(),
getLocation: jest.fn(), getLocation: jest.fn(),
partial: jest.fn(),
}, },
})); }));
@@ -74,6 +75,7 @@ describe('ScopesSelectorService', () => {
dashboardsService = { dashboardsService = {
fetchDashboards: jest.fn().mockResolvedValue(undefined), fetchDashboards: jest.fn().mockResolvedValue(undefined),
setNavigationScope: jest.fn(), setNavigationScope: jest.fn(),
updateFolder: jest.fn(),
state: { state: {
scopeNavigations: [], scopeNavigations: [],
dashboards: [], dashboards: [],
@@ -425,6 +427,43 @@ describe('ScopesSelectorService', () => {
expect(dashboardsService.fetchDashboards).toHaveBeenCalledWith(['test-scope']); expect(dashboardsService.fetchDashboards).toHaveBeenCalledWith(['test-scope']);
}); });
it('should expand folder when redirecting to first scope navigation', async () => {
// Mock location to be on a page that's NOT in the scope navigations
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' } as Location);
// Mock scope navigations with items in groups
const mockScopeNavigations: ScopeNavigation[] = [
{
spec: { scope: 'scope1', url: '/d/dashboard1' },
status: { title: 'First Dashboard', groups: ['group1'] },
metadata: { name: 'dashboard1' },
},
];
dashboardsService.state.scopeNavigations = mockScopeNavigations;
dashboardsService.state.folders = {
'': {
title: '',
expanded: true,
folders: {
group1: {
title: 'group1',
expanded: false,
folders: {},
suggestedNavigations: {},
},
},
suggestedNavigations: {},
},
};
await service.changeScopes(['test-scope']);
// Should expand the folder containing the first navigation
expect(dashboardsService.updateFolder).toHaveBeenCalledWith(['', 'group1'], true);
// Should redirect to the first navigation
expect(locationService.push).toHaveBeenCalledWith('/d/dashboard1');
});
}); });
describe('getRecentScopes', () => { describe('getRecentScopes', () => {

View File

@@ -7,6 +7,7 @@ import { ScopesApiClient } from '../ScopesApiClient';
import { ScopesServiceBase } from '../ScopesServiceBase'; import { ScopesServiceBase } from '../ScopesServiceBase';
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService'; import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
import { isCurrentPath } from '../dashboards/scopeNavgiationUtils'; import { isCurrentPath } from '../dashboards/scopeNavgiationUtils';
import { ScopeNavigation } from '../dashboards/types';
import { import {
closeNodes, closeNodes,
@@ -429,11 +430,27 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
// Only redirect to dashboards TODO: Remove this once Logs Drilldown has Scopes support // Only redirect to dashboards TODO: Remove this once Logs Drilldown has Scopes support
firstScopeNavigation.spec.url.includes('/d/') firstScopeNavigation.spec.url.includes('/d/')
) { ) {
// Before redirecting, expand the folder containing this navigation
// This ensures the correct folder is expanded after the redirect
this.expandFolderForNavigation(firstScopeNavigation);
locationService.push(firstScopeNavigation.spec.url); locationService.push(firstScopeNavigation.spec.url);
} }
} }
}; };
// Helper to expand the folder containing a specific navigation item
private expandFolderForNavigation = (navigation: ScopeNavigation | { status: { groups?: string[] } }) => {
const groups = navigation.status.groups ?? [];
if (groups.length > 0) {
// Find the first group that contains this navigation
const groupName = groups[0];
if (groupName && this.dashboardsService.state.folders['']?.folders[groupName]) {
// Update the folder to be expanded
this.dashboardsService.updateFolder(['', groupName], true);
}
}
};
public removeAllScopes = () => { public removeAllScopes = () => {
this.applyScopes([], false); this.applyScopes([], false);
this.dashboardsService.setNavigationScope(undefined); this.dashboardsService.setNavigationScope(undefined);