Compare commits

...

18 Commits

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

View File

@@ -4,7 +4,7 @@ import { config, locationService, ScopesContext } from '@grafana/runtime';
import { ScopesApiClient } from './ScopesApiClient';
import { 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),

View File

@@ -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([]);
});
});
});

View File

@@ -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.

View File

@@ -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}
/>
))}

View File

@@ -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&param2=value2" title="Matching Link" id="matching-id" />
<ScopesNavigationTreeLink to="/test-path-other" title="Other Link" id="other-id" />
<ScopesNavigationTreeLink
to="/test-path?param1=value1&param2=value2"
title="Matching Link"
id="matching-id"
folderPath={['']}
/>
<ScopesNavigationTreeLink to="/test-path-other" title="Other Link" id="other-id" folderPath={['']} />
</>
);
@@ -150,6 +158,7 @@ describe('ScopesNavigationTreeLink', () => {
to="/a/grafana-metricsdrilldown-app?from=now-1h&to=now"
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']}
/>
);

View File

@@ -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 });
}
};

View File

@@ -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', () => {

View File

@@ -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);