mirror of
https://github.com/grafana/grafana.git
synced 2025-12-20 19:44:55 +08:00
Compare commits
18 Commits
docs/updat
...
tskarhed/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b2601aeda | ||
|
|
1769c222a0 | ||
|
|
1d9be8a61c | ||
|
|
538bc93bd6 | ||
|
|
2d1d7b469a | ||
|
|
d2d435dc10 | ||
|
|
44e80b007e | ||
|
|
f5fbd3c732 | ||
|
|
8ed9cc3bc8 | ||
|
|
0950ce4280 | ||
|
|
7528057a30 | ||
|
|
f30584dce1 | ||
|
|
867228143e | ||
|
|
e1e6e6b0db | ||
|
|
d5ea6244e0 | ||
|
|
6ac036efb8 | ||
|
|
0b3672218b | ||
|
|
553f0f840a |
@@ -4,7 +4,7 @@ import { config, locationService, ScopesContext } from '@grafana/runtime';
|
||||
|
||||
import { ScopesApiClient } from './ScopesApiClient';
|
||||
import { ScopesService } from './ScopesService';
|
||||
import { ScopesDashboardsService } from './dashboards/ScopesDashboardsService';
|
||||
import { deserializeFolderPath, ScopesDashboardsService } from './dashboards/ScopesDashboardsService';
|
||||
import { ScopesSelectorService } from './selector/ScopesSelectorService';
|
||||
|
||||
type Services = {
|
||||
@@ -33,7 +33,11 @@ interface ScopesContextProviderProps {
|
||||
|
||||
export function defaultScopesServices() {
|
||||
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);
|
||||
return {
|
||||
scopesService: new ScopesService(selectorService, dashboardService, locationService),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Location } from 'history';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
|
||||
@@ -7,7 +6,12 @@ import { ScopesApiClient } from '../ScopesApiClient';
|
||||
// Import mock data for subScope tests
|
||||
import { navigationWithSubScope, navigationWithSubScope2, navigationWithSubScopeAndGroups } from '../tests/utils/mocks';
|
||||
|
||||
import { ScopesDashboardsService, filterItemsWithSubScopesInPath } from './ScopesDashboardsService';
|
||||
import {
|
||||
ScopesDashboardsService,
|
||||
filterItemsWithSubScopesInPath,
|
||||
serializeFolderPath,
|
||||
deserializeFolderPath,
|
||||
} from './ScopesDashboardsService';
|
||||
import { ScopeNavigation } from './types';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
@@ -20,10 +24,7 @@ jest.mock('@grafana/runtime', () => ({
|
||||
},
|
||||
locationService: {
|
||||
getLocation: jest.fn(),
|
||||
// Mock getLocationObservable to return a mock observable
|
||||
getLocationObservable: jest.fn().mockReturnValue({
|
||||
subscribe: jest.fn(),
|
||||
}),
|
||||
partial: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -73,101 +74,6 @@ describe('ScopesDashboardsService', () => {
|
||||
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 () => {
|
||||
// Mock current location to not match any navigation
|
||||
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/different-path' } as Location);
|
||||
@@ -796,6 +702,113 @@ describe('ScopesDashboardsService', () => {
|
||||
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 () => {
|
||||
mockApiClient.fetchScopeNavigations.mockResolvedValue([]);
|
||||
|
||||
@@ -839,4 +852,117 @@ describe('ScopesDashboardsService', () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { isEqual } from 'lodash';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
import { ScopeDashboardBinding } from '@grafana/data';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
@@ -7,7 +6,6 @@ import { config, locationService } from '@grafana/runtime';
|
||||
import { ScopesApiClient } from '../ScopesApiClient';
|
||||
import { ScopesServiceBase } from '../ScopesServiceBase';
|
||||
|
||||
import { isCurrentPath } from './scopeNavgiationUtils';
|
||||
import { ScopeNavigation, SuggestedNavigationsFoldersMap, SuggestedNavigationsMap } from './types';
|
||||
|
||||
interface ScopesDashboardsServiceState {
|
||||
@@ -24,11 +22,15 @@ interface ScopesDashboardsServiceState {
|
||||
loading: boolean;
|
||||
searchQuery: string;
|
||||
navigationScope?: string;
|
||||
// The path to the currently expanded folder (breadcrumb trail to active item)
|
||||
expandedFolderPath: string[];
|
||||
}
|
||||
|
||||
export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsServiceState> {
|
||||
private locationSubscription: Subscription | undefined;
|
||||
constructor(private apiClient: ScopesApiClient) {
|
||||
constructor(
|
||||
private apiClient: ScopesApiClient,
|
||||
initialExpandedPath: string[] = []
|
||||
) {
|
||||
super({
|
||||
drawerOpened: false,
|
||||
dashboards: [],
|
||||
@@ -38,22 +40,7 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
|
||||
forScopeNames: [],
|
||||
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();
|
||||
}
|
||||
expandedFolderPath: initialExpandedPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,40 +52,25 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
|
||||
}
|
||||
|
||||
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
|
||||
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);
|
||||
// Only clear expanded path if navigation scope was already set (scope is changing, not initial load)
|
||||
// On initial load, preserve the expandedFolderPath from URL
|
||||
const shouldClearExpandedPath = this.state.navigationScope !== undefined;
|
||||
|
||||
this.updateState({
|
||||
navigationScope,
|
||||
drawerOpened: forScopeNames.length > 0,
|
||||
// Clear the expanded folder path only when scope changes (not on initial load)
|
||||
...(shouldClearExpandedPath && { expandedFolderPath: [] }),
|
||||
});
|
||||
|
||||
if (!activeScopeNavigation) {
|
||||
return;
|
||||
// Clear the navigation folder path from URL when the navigation scope changes
|
||||
// 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
|
||||
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);
|
||||
}
|
||||
await this.fetchDashboards(forScopeNames);
|
||||
};
|
||||
|
||||
public updateFolder = (path: string[], expanded: boolean) => {
|
||||
@@ -203,6 +175,13 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
|
||||
...currentFilteredFolder.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 });
|
||||
@@ -233,12 +212,21 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
|
||||
forScopeNames: [],
|
||||
loading: false,
|
||||
drawerOpened: false,
|
||||
expandedFolderPath: [],
|
||||
});
|
||||
|
||||
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
|
||||
? this.apiClient.fetchScopeNavigations
|
||||
@@ -248,7 +236,14 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
|
||||
|
||||
if (isEqual(this.state.forScopeNames, forScopeNames)) {
|
||||
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);
|
||||
// Also apply the expanded path to filtered folders to keep them in sync
|
||||
this.applyExpandedPath(filteredFolders, this.state.expandedFolderPath);
|
||||
|
||||
this.updateState({
|
||||
scopeNavigations: res,
|
||||
@@ -282,15 +277,23 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
|
||||
const subScope = 'subScope' in navigation.spec ? navigation.spec.subScope : undefined;
|
||||
|
||||
// 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;
|
||||
|
||||
if (isCurrentDashboard && 'dashboard' in navigation.spec) {
|
||||
const dashboardId = currentPath.split('/')[2];
|
||||
expanded = navigation.spec.dashboard === dashboardId;
|
||||
}
|
||||
if (this.state.expandedFolderPath.length === 0) {
|
||||
if (isCurrentDashboard && 'dashboard' in navigation.spec) {
|
||||
const dashboardId = currentPath.split('/')[2];
|
||||
expanded = navigation.spec.dashboard === dashboardId;
|
||||
}
|
||||
|
||||
if ('url' in navigation.spec) {
|
||||
expanded = currentPath.startsWith(navigation.spec.url);
|
||||
if ('url' in navigation.spec && typeof navigation.spec.url === 'string') {
|
||||
// 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
|
||||
@@ -376,6 +379,42 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
|
||||
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 => {
|
||||
query = (query ?? '').toLowerCase();
|
||||
|
||||
@@ -412,6 +451,34 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
|
||||
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.
|
||||
* This prevents infinite loops when a subScope returns items with the same subScope.
|
||||
|
||||
@@ -58,6 +58,7 @@ export function ScopesDashboardsTree({ subScope, folders, folderPath, onFolderUp
|
||||
to={urlUtil.renderUrl(navigation.url, queryParams)}
|
||||
title={navigation.title}
|
||||
id={navigation.id}
|
||||
folderPath={folderPath}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
locationService: {
|
||||
push: jest.fn(),
|
||||
partial: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -63,7 +64,7 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
expect(link).toBeInTheDocument();
|
||||
@@ -75,7 +76,7 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
it('sets aria-current when path matches', () => {
|
||||
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');
|
||||
expect(link).toHaveAttribute('aria-current', 'page');
|
||||
@@ -84,7 +85,7 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
it('does not set aria-current when path does not match', () => {
|
||||
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');
|
||||
expect(link).not.toHaveAttribute('aria-current');
|
||||
@@ -93,7 +94,9 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
it('handles dashboard paths correctly', () => {
|
||||
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');
|
||||
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', () => {
|
||||
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');
|
||||
expect(link).not.toHaveAttribute('aria-current');
|
||||
@@ -113,8 +116,8 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
|
||||
renderWithRouter(
|
||||
<>
|
||||
<ScopesNavigationTreeLink to="/test-path" title="Matching Link" id="matching-id" />
|
||||
<ScopesNavigationTreeLink to="/test-path-extra" title="Other Link" id="other-id" />
|
||||
<ScopesNavigationTreeLink to="/test-path" title="Matching Link" id="matching-id" folderPath={['']} />
|
||||
<ScopesNavigationTreeLink to="/test-path-extra" title="Other Link" id="other-id" folderPath={['']} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -132,8 +135,13 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
|
||||
renderWithRouter(
|
||||
<>
|
||||
<ScopesNavigationTreeLink to="/test-path?param1=value1¶m2=value2" title="Matching Link" id="matching-id" />
|
||||
<ScopesNavigationTreeLink to="/test-path-other" title="Other Link" id="other-id" />
|
||||
<ScopesNavigationTreeLink
|
||||
to="/test-path?param1=value1¶m2=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"
|
||||
title="Metrics Drilldown"
|
||||
id="metrics-drilldown"
|
||||
folderPath={['']}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -163,7 +172,12 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
|
||||
it('shows correct icon for grafana-metricsdrilldown-app without trailing slash', () => {
|
||||
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');
|
||||
@@ -187,7 +201,13 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
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');
|
||||
@@ -203,7 +223,13 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
mockScopesSelectorService.state.appliedScopes = [{ scopeId: 'currentScope' }];
|
||||
|
||||
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');
|
||||
@@ -217,7 +243,13 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
mockScopesSelectorService.state.appliedScopes = [{ scopeId: 'currentScope' }];
|
||||
|
||||
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');
|
||||
@@ -229,7 +261,13 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
|
||||
it('should call changeScopes with subScope', async () => {
|
||||
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');
|
||||
@@ -240,7 +278,13 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
|
||||
it('should navigate to URL with updated query params', async () => {
|
||||
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');
|
||||
@@ -260,6 +304,7 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
title="Test Link"
|
||||
id="test-id"
|
||||
subScope="subScope1"
|
||||
folderPath={['', 'group1']}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -272,17 +317,42 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
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();
|
||||
|
||||
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');
|
||||
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(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 () => {
|
||||
@@ -296,6 +366,7 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
title="Test Link"
|
||||
id="test-id"
|
||||
subScope="subScope1"
|
||||
folderPath={['', 'group1']}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@ import { css, cx } from '@emotion/css';
|
||||
import { useMemo } from 'react';
|
||||
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 { Icon, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { useScopesServices } from '../ScopesContextProvider';
|
||||
|
||||
import { serializeFolderPath } from './ScopesDashboardsService';
|
||||
import { isCurrentPath, normalizePath } from './scopeNavgiationUtils';
|
||||
|
||||
export interface ScopesNavigationTreeLinkProps {
|
||||
@@ -15,9 +16,10 @@ export interface ScopesNavigationTreeLinkProps {
|
||||
to: string;
|
||||
title: 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 linkIcon = useMemo(() => getLinkIcon(to), [to]);
|
||||
const locPathname = useLocation().pathname;
|
||||
@@ -26,6 +28,9 @@ export function ScopesNavigationTreeLink({ subScope, to, title, id }: ScopesNavi
|
||||
const isCurrent = isCurrentPath(locPathname, to);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
// Set the expanded folder path in the URL
|
||||
const pathString = serializeFolderPath(folderPath);
|
||||
|
||||
if (subScope) {
|
||||
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 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 pathname = url.pathname;
|
||||
const searchParams = new URLSearchParams(url.search);
|
||||
|
||||
if (!currentNavigationScope && currentScope) {
|
||||
searchParams.set('navigation_scope', currentScope);
|
||||
url.searchParams.set('navigation_scope', currentScope);
|
||||
services?.scopesDashboardsService?.setNavigationScope(currentScope);
|
||||
}
|
||||
|
||||
// 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
|
||||
searchParams.delete('scope_node');
|
||||
searchParams.delete('scope_parent');
|
||||
url.searchParams.delete('scope_node');
|
||||
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
|
||||
const queryMap: UrlQueryMap = {};
|
||||
searchParams.forEach((value, key) => {
|
||||
queryMap[key] = value;
|
||||
});
|
||||
|
||||
// Build the new URL safely using urlUtil.renderUrl
|
||||
const newUrl = urlUtil.renderUrl(pathname, queryMap);
|
||||
// Build the new URL safely using the URL API (pathname + search)
|
||||
const newUrl = url.pathname + url.search;
|
||||
|
||||
// Change scopes first (this updates the state)
|
||||
services?.scopesSelectorService?.changeScopes([subScope], undefined, undefined, false);
|
||||
|
||||
// Then navigate to the URL with updated query params
|
||||
locationService.push(newUrl);
|
||||
} else {
|
||||
// For regular navigation (no subScope), set the folder path in the URL
|
||||
locationService.partial({ nav_scope_path: pathString });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ jest.mock('@grafana/runtime', () => ({
|
||||
locationService: {
|
||||
push: jest.fn(),
|
||||
getLocation: jest.fn(),
|
||||
partial: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -74,6 +75,7 @@ describe('ScopesSelectorService', () => {
|
||||
dashboardsService = {
|
||||
fetchDashboards: jest.fn().mockResolvedValue(undefined),
|
||||
setNavigationScope: jest.fn(),
|
||||
updateFolder: jest.fn(),
|
||||
state: {
|
||||
scopeNavigations: [],
|
||||
dashboards: [],
|
||||
@@ -425,6 +427,43 @@ describe('ScopesSelectorService', () => {
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ScopesApiClient } from '../ScopesApiClient';
|
||||
import { ScopesServiceBase } from '../ScopesServiceBase';
|
||||
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
|
||||
import { isCurrentPath } from '../dashboards/scopeNavgiationUtils';
|
||||
import { ScopeNavigation } from '../dashboards/types';
|
||||
|
||||
import {
|
||||
closeNodes,
|
||||
@@ -429,11 +430,27 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
// Only redirect to dashboards TODO: Remove this once Logs Drilldown has Scopes support
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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 = () => {
|
||||
this.applyScopes([], false);
|
||||
this.dashboardsService.setNavigationScope(undefined);
|
||||
|
||||
Reference in New Issue
Block a user