mirror of
https://github.com/grafana/grafana.git
synced 2025-12-22 12:44:34 +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 { 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),
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -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¶m2=value2" title="Matching Link" id="matching-id" />
|
<ScopesNavigationTreeLink
|
||||||
<ScopesNavigationTreeLink to="/test-path-other" title="Other Link" id="other-id" />
|
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"
|
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']}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user