Scopes: Sync nested scopes navigation open folders to URL (#114786)
* Sync nav_scope_path with url * Let the current active scope remain if it is a child of the selected subscope * Remove location updates based on nav_scope_path to maintain expanded folders * Fix folder tests * Remove console logs * Better mock for changeScopes * Update test to support the new calls * Update test with function inputs * Fix failinging test * Add tests and add isEqual check for fetching new subscopes
This commit is contained in:
@@ -22,8 +22,8 @@ describe('ScopesService', () => {
|
||||
| undefined;
|
||||
let dashboardsStateSubscription:
|
||||
| ((
|
||||
state: { navigationScope?: string; drawerOpened: boolean },
|
||||
prevState: { navigationScope?: string; drawerOpened: boolean }
|
||||
state: { navigationScope?: string; drawerOpened: boolean; navScopePath?: string[] },
|
||||
prevState: { navigationScope?: string; drawerOpened: boolean; navScopePath?: string[] }
|
||||
) => void)
|
||||
| undefined;
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('ScopesService', () => {
|
||||
selectorStateSubscription = callback;
|
||||
return { unsubscribe: jest.fn() };
|
||||
}),
|
||||
changeScopes: jest.fn(),
|
||||
changeScopes: jest.fn().mockResolvedValue(undefined),
|
||||
resolvePathToRoot: jest.fn().mockResolvedValue({ path: [], tree: {} }),
|
||||
} as unknown as jest.Mocked<ScopesSelectorService>;
|
||||
|
||||
@@ -71,6 +71,7 @@ describe('ScopesService', () => {
|
||||
loading: false,
|
||||
searchQuery: '',
|
||||
navigationScope: undefined,
|
||||
navScopePath: undefined,
|
||||
},
|
||||
stateObservable: new BehaviorSubject({
|
||||
drawerOpened: false,
|
||||
@@ -82,12 +83,14 @@ describe('ScopesService', () => {
|
||||
loading: false,
|
||||
searchQuery: '',
|
||||
navigationScope: undefined,
|
||||
navScopePath: undefined,
|
||||
}),
|
||||
subscribeToState: jest.fn((callback) => {
|
||||
dashboardsStateSubscription = callback;
|
||||
return { unsubscribe: jest.fn() };
|
||||
}),
|
||||
setNavigationScope: jest.fn(),
|
||||
setNavScopePath: jest.fn(),
|
||||
} as unknown as jest.Mocked<ScopesDashboardsService>;
|
||||
|
||||
locationService = {
|
||||
@@ -188,7 +191,7 @@ describe('ScopesService', () => {
|
||||
|
||||
service = new ScopesService(selectorService, dashboardsService, locationService);
|
||||
|
||||
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1');
|
||||
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1', undefined, undefined);
|
||||
});
|
||||
|
||||
it('should read navigation_scope along with other scope parameters', () => {
|
||||
@@ -199,7 +202,7 @@ describe('ScopesService', () => {
|
||||
|
||||
service = new ScopesService(selectorService, dashboardsService, locationService);
|
||||
|
||||
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1');
|
||||
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1', undefined, undefined);
|
||||
expect(selectorService.changeScopes).toHaveBeenCalledWith(['scope1'], undefined, 'node1', false);
|
||||
});
|
||||
|
||||
@@ -213,6 +216,45 @@ describe('ScopesService', () => {
|
||||
|
||||
expect(dashboardsService.setNavigationScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should read nav_scope_path along with navigation_scope from URL on init', () => {
|
||||
locationService.getLocation = jest.fn().mockReturnValue({
|
||||
pathname: '/test',
|
||||
search: '?navigation_scope=navScope1&nav_scope_path=mimir%2Cloki',
|
||||
});
|
||||
|
||||
service = new ScopesService(selectorService, dashboardsService, locationService);
|
||||
|
||||
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1', undefined, ['mimir', 'loki']);
|
||||
});
|
||||
|
||||
it('should handle nav_scope_path without navigation_scope by calling setNavScopePath after changeScopes', async () => {
|
||||
locationService.getLocation = jest.fn().mockReturnValue({
|
||||
pathname: '/test',
|
||||
search: '?scopes=scope1&nav_scope_path=mimir',
|
||||
});
|
||||
|
||||
service = new ScopesService(selectorService, dashboardsService, locationService);
|
||||
|
||||
// Wait for the changeScopes promise to resolve
|
||||
await Promise.resolve();
|
||||
|
||||
expect(dashboardsService.setNavScopePath).toHaveBeenCalledWith(['mimir']);
|
||||
});
|
||||
|
||||
it('should handle URL-encoded nav_scope_path values', () => {
|
||||
locationService.getLocation = jest.fn().mockReturnValue({
|
||||
pathname: '/test',
|
||||
search: '?navigation_scope=navScope1&nav_scope_path=' + encodeURIComponent('folder one,folder two'),
|
||||
});
|
||||
|
||||
service = new ScopesService(selectorService, dashboardsService, locationService);
|
||||
|
||||
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1', undefined, [
|
||||
'folder one',
|
||||
'folder two',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL synchronization', () => {
|
||||
@@ -346,16 +388,22 @@ describe('ScopesService', () => {
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: true,
|
||||
navScopePath: undefined,
|
||||
},
|
||||
{
|
||||
navigationScope: undefined,
|
||||
drawerOpened: false,
|
||||
navScopePath: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
expect(locationService.partial).toHaveBeenCalledWith({
|
||||
navigation_scope: 'navScope1',
|
||||
});
|
||||
expect(locationService.partial).toHaveBeenCalledWith(
|
||||
{
|
||||
navigation_scope: 'navScope1',
|
||||
nav_scope_path: null,
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should update navigation_scope in URL when navigationScope changes', () => {
|
||||
@@ -367,16 +415,22 @@ describe('ScopesService', () => {
|
||||
{
|
||||
navigationScope: 'navScope2',
|
||||
drawerOpened: true,
|
||||
navScopePath: undefined,
|
||||
},
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: true,
|
||||
navScopePath: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
expect(locationService.partial).toHaveBeenCalledWith({
|
||||
navigation_scope: 'navScope2',
|
||||
});
|
||||
expect(locationService.partial).toHaveBeenCalledWith(
|
||||
{
|
||||
navigation_scope: 'navScope2',
|
||||
nav_scope_path: null,
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should not update URL when navigationScope has not changed', () => {
|
||||
@@ -390,10 +444,12 @@ describe('ScopesService', () => {
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: true,
|
||||
navScopePath: undefined,
|
||||
},
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: false,
|
||||
navScopePath: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -409,16 +465,126 @@ describe('ScopesService', () => {
|
||||
{
|
||||
navigationScope: undefined,
|
||||
drawerOpened: false,
|
||||
navScopePath: undefined,
|
||||
},
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: true,
|
||||
navScopePath: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
expect(locationService.partial).toHaveBeenCalledWith({
|
||||
navigation_scope: undefined,
|
||||
});
|
||||
expect(locationService.partial).toHaveBeenCalledWith(
|
||||
{
|
||||
navigation_scope: null,
|
||||
nav_scope_path: null,
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should write nav_scope_path to URL when navScopePath changes', () => {
|
||||
if (!dashboardsStateSubscription) {
|
||||
throw new Error('dashboardsStateSubscription not set');
|
||||
}
|
||||
|
||||
dashboardsStateSubscription(
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: true,
|
||||
navScopePath: ['mimir', 'loki'],
|
||||
},
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: true,
|
||||
navScopePath: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
expect(locationService.partial).toHaveBeenCalledWith(
|
||||
{
|
||||
navigation_scope: 'navScope1',
|
||||
nav_scope_path: encodeURIComponent('mimir,loki'),
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should update nav_scope_path in URL when navScopePath changes', () => {
|
||||
if (!dashboardsStateSubscription) {
|
||||
throw new Error('dashboardsStateSubscription not set');
|
||||
}
|
||||
|
||||
dashboardsStateSubscription(
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: true,
|
||||
navScopePath: ['mimir', 'loki', 'tempo'],
|
||||
},
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: true,
|
||||
navScopePath: ['mimir', 'loki'],
|
||||
}
|
||||
);
|
||||
|
||||
expect(locationService.partial).toHaveBeenCalledWith(
|
||||
{
|
||||
navigation_scope: 'navScope1',
|
||||
nav_scope_path: encodeURIComponent('mimir,loki,tempo'),
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear nav_scope_path from URL when navScopePath becomes empty', () => {
|
||||
if (!dashboardsStateSubscription) {
|
||||
throw new Error('dashboardsStateSubscription not set');
|
||||
}
|
||||
|
||||
dashboardsStateSubscription(
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: true,
|
||||
navScopePath: [],
|
||||
},
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: true,
|
||||
navScopePath: ['mimir'],
|
||||
}
|
||||
);
|
||||
|
||||
expect(locationService.partial).toHaveBeenCalledWith(
|
||||
{
|
||||
navigation_scope: 'navScope1',
|
||||
nav_scope_path: null,
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should not update URL when only drawerOpened changes but navigationScope and navScopePath remain the same', () => {
|
||||
if (!dashboardsStateSubscription) {
|
||||
throw new Error('dashboardsStateSubscription not set');
|
||||
}
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
dashboardsStateSubscription(
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: false,
|
||||
navScopePath: ['mimir'],
|
||||
},
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: true,
|
||||
navScopePath: ['mimir'],
|
||||
}
|
||||
);
|
||||
|
||||
expect(locationService.partial).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -457,4 +623,113 @@ describe('ScopesService', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('back/forward navigation handling', () => {
|
||||
let locationSubject: BehaviorSubject<{ pathname: string; search: string }>;
|
||||
|
||||
beforeEach(() => {
|
||||
locationSubject = new BehaviorSubject({
|
||||
pathname: '/test',
|
||||
search: '',
|
||||
});
|
||||
|
||||
locationService.getLocation = jest.fn().mockReturnValue({
|
||||
pathname: '/test',
|
||||
search: '',
|
||||
});
|
||||
locationService.getLocationObservable = jest.fn().mockReturnValue(locationSubject);
|
||||
|
||||
// Set initial state for dashboards service
|
||||
dashboardsService.state.navigationScope = undefined;
|
||||
dashboardsService.state.navScopePath = undefined;
|
||||
|
||||
service = new ScopesService(selectorService, dashboardsService, locationService);
|
||||
service.setEnabled(true);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should update navigation scope when URL changes via back/forward', () => {
|
||||
// Simulate URL change (e.g., browser back button)
|
||||
locationSubject.next({
|
||||
pathname: '/test',
|
||||
search: '?navigation_scope=navScope1',
|
||||
});
|
||||
|
||||
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1', undefined, undefined);
|
||||
});
|
||||
|
||||
it('should update nav_scope_path when URL changes via back/forward', () => {
|
||||
// Set current state
|
||||
dashboardsService.state.navigationScope = 'navScope1';
|
||||
dashboardsService.state.navScopePath = undefined;
|
||||
|
||||
// Simulate URL change with nav_scope_path
|
||||
locationSubject.next({
|
||||
pathname: '/test',
|
||||
search: '?navigation_scope=navScope1&nav_scope_path=' + encodeURIComponent('mimir,loki'),
|
||||
});
|
||||
|
||||
expect(dashboardsService.setNavScopePath).toHaveBeenCalledWith(['mimir', 'loki']);
|
||||
});
|
||||
|
||||
it('should clear navigation scope when removed from URL via back/forward', () => {
|
||||
// Set current state
|
||||
dashboardsService.state.navigationScope = 'navScope1';
|
||||
dashboardsService.state.navScopePath = ['mimir'];
|
||||
|
||||
// Simulate URL change (navigation scope removed)
|
||||
locationSubject.next({
|
||||
pathname: '/test',
|
||||
search: '',
|
||||
});
|
||||
|
||||
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('should handle navigation scope change along with nav_scope_path', () => {
|
||||
// Set current state
|
||||
dashboardsService.state.navigationScope = 'navScope1';
|
||||
dashboardsService.state.navScopePath = ['mimir'];
|
||||
|
||||
// Simulate URL change to different navigation scope with new path
|
||||
locationSubject.next({
|
||||
pathname: '/test',
|
||||
search: '?navigation_scope=navScope2&nav_scope_path=' + encodeURIComponent('loki,tempo'),
|
||||
});
|
||||
|
||||
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope2', undefined, ['loki', 'tempo']);
|
||||
});
|
||||
|
||||
it('should handle URL-encoded navigation_scope from back/forward', () => {
|
||||
// Set current state
|
||||
dashboardsService.state.navigationScope = undefined;
|
||||
|
||||
// Simulate URL change with encoded navigation scope
|
||||
locationSubject.next({
|
||||
pathname: '/test',
|
||||
search: '?navigation_scope=' + encodeURIComponent('scope with spaces'),
|
||||
});
|
||||
|
||||
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('scope with spaces', undefined, undefined);
|
||||
});
|
||||
|
||||
it('should handle nav_scope_path change without navigation_scope', async () => {
|
||||
// Set current state - no navigation scope but has nav scope path
|
||||
dashboardsService.state.navigationScope = undefined;
|
||||
dashboardsService.state.navScopePath = undefined;
|
||||
selectorService.state.appliedScopes = [{ scopeId: 'scope1' }];
|
||||
|
||||
// Simulate URL change with only nav_scope_path
|
||||
locationSubject.next({
|
||||
pathname: '/test',
|
||||
search: '?scopes=scope1&nav_scope_path=mimir',
|
||||
});
|
||||
|
||||
// Wait for changeScopes promise
|
||||
await Promise.resolve();
|
||||
|
||||
expect(dashboardsService.setNavScopePath).toHaveBeenCalledWith(['mimir']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { map, distinctUntilChanged } from 'rxjs/operators';
|
||||
import { LocationService, ScopesContextValue, ScopesContextValueState } from '@grafana/runtime';
|
||||
|
||||
import { ScopesDashboardsService } from './dashboards/ScopesDashboardsService';
|
||||
import { deserializeFolderPath, serializeFolderPath } from './dashboards/scopeNavgiationUtils';
|
||||
import { ScopesSelectorService } from './selector/ScopesSelectorService';
|
||||
|
||||
export interface State {
|
||||
@@ -72,12 +73,21 @@ export class ScopesService implements ScopesContextValue {
|
||||
const queryParams = new URLSearchParams(locationService.getLocation().search);
|
||||
const scopeNodeId = queryParams.get('scope_node');
|
||||
const navigationScope = queryParams.get('navigation_scope');
|
||||
const navScopePath = queryParams.get('nav_scope_path');
|
||||
|
||||
if (navigationScope) {
|
||||
this.dashboardsService.setNavigationScope(navigationScope);
|
||||
this.dashboardsService.setNavigationScope(
|
||||
navigationScope,
|
||||
undefined,
|
||||
navScopePath ? deserializeFolderPath(navScopePath) : undefined
|
||||
);
|
||||
}
|
||||
|
||||
this.changeScopes(queryParams.getAll('scopes'), undefined, scopeNodeId ?? undefined);
|
||||
this.changeScopes(queryParams.getAll('scopes'), undefined, scopeNodeId ?? undefined).then(() => {
|
||||
if (navScopePath && !navigationScope) {
|
||||
this.dashboardsService.setNavScopePath(deserializeFolderPath(navScopePath));
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-load scope node (which loads parent too)
|
||||
const nodeToPreload = scopeNodeId;
|
||||
@@ -99,6 +109,9 @@ export class ScopesService implements ScopesContextValue {
|
||||
const scopes = queryParams.getAll('scopes');
|
||||
const scopeNodeId = queryParams.get('scope_node');
|
||||
|
||||
const navigationScope = queryParams.get('navigation_scope');
|
||||
const navScopePath = queryParams.get('nav_scope_path');
|
||||
|
||||
// Check if new scopes are different from the old scopes
|
||||
const currentScopes = this.selectorService.state.appliedScopes.map((scope) => scope.scopeId);
|
||||
if (scopes.length && !isEqual(scopes, currentScopes)) {
|
||||
@@ -107,6 +120,31 @@ export class ScopesService implements ScopesContextValue {
|
||||
// changes the URL directly, it would trigger a reload so scopes would still be reset.
|
||||
this.changeScopes(scopes, undefined, scopeNodeId ?? undefined);
|
||||
}
|
||||
|
||||
// Handle navigation_scope and nav_scope_path changes from back/forward navigation
|
||||
const currentNavigationScope = this.dashboardsService.state.navigationScope;
|
||||
const currentNavScopePath = this.dashboardsService.state.navScopePath;
|
||||
const newNavScopePath = navScopePath ? deserializeFolderPath(navScopePath) : undefined;
|
||||
const decodedNavigationScope = navigationScope ? decodeURIComponent(navigationScope) : undefined;
|
||||
|
||||
const navigationScopeChanged = decodedNavigationScope !== currentNavigationScope;
|
||||
const navScopePathChanged = !isEqual(newNavScopePath, currentNavScopePath);
|
||||
|
||||
if (navigationScopeChanged) {
|
||||
// Navigation scope changed - do full update
|
||||
if (decodedNavigationScope) {
|
||||
this.dashboardsService.setNavigationScope(decodedNavigationScope, undefined, newNavScopePath);
|
||||
} else if (newNavScopePath?.length) {
|
||||
this.changeScopes(scopes, undefined, scopeNodeId ?? undefined).then(() => {
|
||||
this.dashboardsService.setNavScopePath(newNavScopePath);
|
||||
});
|
||||
} else {
|
||||
this.dashboardsService.setNavigationScope(undefined);
|
||||
}
|
||||
} else if (navScopePathChanged) {
|
||||
// Navigation scope unchanged but path changed
|
||||
this.dashboardsService.setNavScopePath(newNavScopePath);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -137,10 +175,17 @@ export class ScopesService implements ScopesContextValue {
|
||||
// Update the URL based on change in the navigation scope
|
||||
this.subscriptions.push(
|
||||
this.dashboardsService.subscribeToState((state, prevState) => {
|
||||
if (state.navigationScope !== prevState.navigationScope) {
|
||||
this.locationService.partial({
|
||||
navigation_scope: state.navigationScope,
|
||||
});
|
||||
if (
|
||||
state.navigationScope !== prevState.navigationScope ||
|
||||
!isEqual(state.navScopePath, prevState.navScopePath)
|
||||
) {
|
||||
this.locationService.partial(
|
||||
{
|
||||
navigation_scope: state.navigationScope ? encodeURIComponent(state.navigationScope) : null,
|
||||
nav_scope_path: state.navScopePath?.length ? serializeFolderPath(state.navScopePath) : null,
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -67,7 +67,12 @@ export function ScopesDashboards() {
|
||||
/>
|
||||
) : filteredFolders[''] ? (
|
||||
<ScrollContainer>
|
||||
<ScopesDashboardsTree folders={filteredFolders} folderPath={['']} onFolderUpdate={updateFolder} />
|
||||
<ScopesDashboardsTree
|
||||
folders={filteredFolders}
|
||||
folderPath={['']}
|
||||
subScopePath={[]}
|
||||
onFolderUpdate={updateFolder}
|
||||
/>
|
||||
</ScrollContainer>
|
||||
) : (
|
||||
<p className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundForFilter">
|
||||
|
||||
@@ -839,4 +839,52 @@ describe('ScopesDashboardsService', () => {
|
||||
expect(service.state.drawerOpened).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setNavScopePath', () => {
|
||||
beforeEach(() => {
|
||||
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/' } as Location);
|
||||
});
|
||||
|
||||
it('should set nav scope path', async () => {
|
||||
await service.setNavScopePath(['mimir']);
|
||||
expect(service.state.navScopePath).toEqual(['mimir']);
|
||||
});
|
||||
|
||||
it('should replace existing path with new path', async () => {
|
||||
await service.setNavScopePath(['mimir']);
|
||||
expect(service.state.navScopePath).toEqual(['mimir']);
|
||||
|
||||
await service.setNavScopePath(['loki']);
|
||||
expect(service.state.navScopePath).toEqual(['loki']);
|
||||
});
|
||||
|
||||
it('should handle multiple scopes in path', async () => {
|
||||
await service.setNavScopePath(['mimir', 'loki']);
|
||||
expect(service.state.navScopePath).toEqual(['mimir', 'loki']);
|
||||
});
|
||||
|
||||
it('should clear path with empty array', async () => {
|
||||
await service.setNavScopePath(['mimir', 'loki']);
|
||||
expect(service.state.navScopePath).toEqual(['mimir', 'loki']);
|
||||
|
||||
await service.setNavScopePath([]);
|
||||
expect(service.state.navScopePath).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle undefined path as empty array', async () => {
|
||||
await service.setNavScopePath(['mimir']);
|
||||
expect(service.state.navScopePath).toEqual(['mimir']);
|
||||
|
||||
await service.setNavScopePath(undefined);
|
||||
expect(service.state.navScopePath).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not update state if path is unchanged', async () => {
|
||||
await service.setNavScopePath(['mimir']);
|
||||
|
||||
await service.setNavScopePath(['mimir']);
|
||||
// Path should remain the same
|
||||
expect(service.state.navScopePath).toEqual(['mimir']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,13 @@ import { config, locationService } from '@grafana/runtime';
|
||||
import { ScopesApiClient } from '../ScopesApiClient';
|
||||
import { ScopesServiceBase } from '../ScopesServiceBase';
|
||||
|
||||
import { isCurrentPath } from './scopeNavgiationUtils';
|
||||
import { ScopeNavigation, SuggestedNavigationsFoldersMap, SuggestedNavigationsMap } from './types';
|
||||
import { buildSubScopePath, isCurrentPath } from './scopeNavgiationUtils';
|
||||
import {
|
||||
ScopeNavigation,
|
||||
SuggestedNavigationsFolder,
|
||||
SuggestedNavigationsFoldersMap,
|
||||
SuggestedNavigationsMap,
|
||||
} from './types';
|
||||
|
||||
interface ScopesDashboardsServiceState {
|
||||
// State of the drawer showing related dashboards
|
||||
@@ -24,6 +29,8 @@ interface ScopesDashboardsServiceState {
|
||||
loading: boolean;
|
||||
searchQuery: string;
|
||||
navigationScope?: string;
|
||||
// Path of subScopes which should be expanded
|
||||
navScopePath?: string[];
|
||||
}
|
||||
|
||||
export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsServiceState> {
|
||||
@@ -38,6 +45,7 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
|
||||
forScopeNames: [],
|
||||
loading: false,
|
||||
searchQuery: '',
|
||||
navScopePath: undefined,
|
||||
});
|
||||
|
||||
// Add/ remove location subscribtion based on the drawer opened state
|
||||
@@ -57,9 +65,40 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
|
||||
});
|
||||
}
|
||||
|
||||
private openSubScopeFolder = (subScopePath: string[]) => {
|
||||
const subScope = subScopePath[subScopePath.length - 1];
|
||||
const path = buildSubScopePath(subScope, this.state.folders);
|
||||
|
||||
// Get path to the folder - path can now be undefined
|
||||
if (path && path.length > 0) {
|
||||
this.updateFolder(path, true);
|
||||
}
|
||||
};
|
||||
|
||||
public setNavScopePath = async (navScopePath?: string[]) => {
|
||||
const navScopePathArray = navScopePath ?? [];
|
||||
|
||||
if (!isEqual(navScopePathArray, this.state.navScopePath)) {
|
||||
this.updateState({ navScopePath: navScopePathArray });
|
||||
|
||||
for (const subScope of navScopePathArray) {
|
||||
// Find the actual path to the folder with this subScopeName
|
||||
const folderPath = buildSubScopePath(subScope, this.state.folders);
|
||||
if (folderPath && folderPath.length > 0) {
|
||||
await this.fetchSubScopeItems(folderPath, subScope);
|
||||
this.openSubScopeFolder([subScope]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The fallbackScopeNames is used to fetch the ScopeNavigations for the current dashboard when the navigationScope is not set.
|
||||
// You only need to awaut this function if you need to wait for the dashboards to be fetched before doing something else.
|
||||
public setNavigationScope = async (navigationScope?: string, fallbackScopeNames?: string[]) => {
|
||||
public setNavigationScope = async (
|
||||
navigationScope?: string,
|
||||
fallbackScopeNames?: string[],
|
||||
navScopePath?: string[]
|
||||
) => {
|
||||
if (this.state.navigationScope === navigationScope) {
|
||||
return;
|
||||
}
|
||||
@@ -67,6 +106,7 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
|
||||
const forScopeNames = navigationScope ? [navigationScope] : (fallbackScopeNames ?? []);
|
||||
this.updateState({ navigationScope, drawerOpened: forScopeNames.length > 0 });
|
||||
await this.fetchDashboards(forScopeNames);
|
||||
await this.setNavScopePath(navScopePath);
|
||||
};
|
||||
|
||||
// Expand the group that matches the current path, if it is not already expanded
|
||||
@@ -148,6 +188,15 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
|
||||
};
|
||||
|
||||
private fetchSubScopeItems = async (path: string[], subScopeName: string) => {
|
||||
// Check if folder already has content - skip fetching to preserve existing state
|
||||
const targetFolder = this.getFolder(path);
|
||||
if (
|
||||
targetFolder &&
|
||||
(Object.keys(targetFolder.folders).length > 0 || Object.keys(targetFolder.suggestedNavigations).length > 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let subScopeFolders: SuggestedNavigationsFoldersMap | undefined;
|
||||
|
||||
try {
|
||||
@@ -208,6 +257,15 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
|
||||
this.updateState({ folders, filteredFolders });
|
||||
};
|
||||
|
||||
// Helper to get a folder at a given path
|
||||
private getFolder = (path: string[]): SuggestedNavigationsFolder | undefined => {
|
||||
let folder: SuggestedNavigationsFoldersMap = this.state.folders;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
folder = folder[path[i]]?.folders ?? {};
|
||||
}
|
||||
return folder[path[path.length - 1]];
|
||||
};
|
||||
|
||||
public changeSearchQuery = (searchQuery: string) => {
|
||||
searchQuery = searchQuery ?? '';
|
||||
|
||||
|
||||
@@ -12,10 +12,17 @@ export interface ScopesDashboardsTreeProps {
|
||||
subScope?: string;
|
||||
folders: SuggestedNavigationsFoldersMap;
|
||||
folderPath: string[];
|
||||
subScopePath?: string[];
|
||||
onFolderUpdate: OnFolderUpdate;
|
||||
}
|
||||
|
||||
export function ScopesDashboardsTree({ subScope, folders, folderPath, onFolderUpdate }: ScopesDashboardsTreeProps) {
|
||||
export function ScopesDashboardsTree({
|
||||
subScopePath,
|
||||
subScope,
|
||||
folders,
|
||||
folderPath,
|
||||
onFolderUpdate,
|
||||
}: ScopesDashboardsTreeProps) {
|
||||
const [queryParams] = useQueryParams();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
@@ -54,6 +61,7 @@ export function ScopesDashboardsTree({ subScope, folders, folderPath, onFolderUp
|
||||
{regularNavigations.map((navigation) => (
|
||||
<ScopesNavigationTreeLink
|
||||
subScope={subScope}
|
||||
subScopePath={subScopePath}
|
||||
key={navigation.id + navigation.title}
|
||||
to={urlUtil.renderUrl(navigation.url, queryParams)}
|
||||
title={navigation.title}
|
||||
@@ -68,6 +76,7 @@ export function ScopesDashboardsTree({ subScope, folders, folderPath, onFolderUp
|
||||
{subScopeFolders.map(([subFolderId, subFolder]) => (
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
key={subFolderId}
|
||||
subScopePath={[...(subScopePath ?? []), subFolder.subScopeName ?? '']}
|
||||
folder={subFolder}
|
||||
folders={folder.folders}
|
||||
folderPath={[...folderPath, subFolderId]}
|
||||
|
||||
@@ -16,6 +16,9 @@ const mockScopesSelectorService = {
|
||||
|
||||
const mockScopesDashboardsService = {
|
||||
setNavigationScope: jest.fn(),
|
||||
state: {
|
||||
navScopePath: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('../ScopesContextProvider', () => ({
|
||||
@@ -133,7 +136,7 @@ describe('ScopesDashboardsTreeFolderItem', () => {
|
||||
const exchangeButton = screen.getByRole('button', { name: /change root scope/i });
|
||||
await user.click(exchangeButton);
|
||||
|
||||
expect(mockScopesDashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined, ['subScope1']);
|
||||
expect(mockScopesDashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined, undefined, []);
|
||||
});
|
||||
|
||||
it('calls changeScopes when exchange icon is clicked', async () => {
|
||||
@@ -152,7 +155,7 @@ describe('ScopesDashboardsTreeFolderItem', () => {
|
||||
const exchangeButton = screen.getByRole('button', { name: /change root scope/i });
|
||||
await user.click(exchangeButton);
|
||||
|
||||
expect(mockScopesSelectorService.changeScopes).toHaveBeenCalledWith(['subScope1']);
|
||||
expect(mockScopesSelectorService.changeScopes).toHaveBeenCalledWith(['subScope1'], undefined, undefined, false);
|
||||
});
|
||||
|
||||
it('passes subScope prop to ScopesDashboardsTree when folder is expanded', () => {
|
||||
|
||||
@@ -14,9 +14,11 @@ export interface ScopesDashboardsTreeFolderItemProps {
|
||||
folderPath: string[];
|
||||
folders: SuggestedNavigationsFoldersMap;
|
||||
onFolderUpdate: OnFolderUpdate;
|
||||
subScopePath?: string[];
|
||||
}
|
||||
|
||||
export function ScopesDashboardsTreeFolderItem({
|
||||
subScopePath,
|
||||
folder,
|
||||
folderPath,
|
||||
folders,
|
||||
@@ -53,12 +55,27 @@ export function ScopesDashboardsTreeFolderItem({
|
||||
scope: folder.subScopeName || '',
|
||||
})}
|
||||
name="exchange-alt"
|
||||
onClick={(e) => {
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (folder.subScopeName && scopesSelectorService) {
|
||||
scopesDashboardsService?.setNavigationScope(undefined, [folder.subScopeName]);
|
||||
scopesSelectorService.changeScopes([folder.subScopeName]);
|
||||
const activeSubScopePath = scopesDashboardsService?.state.navScopePath;
|
||||
// Check if the active scope is a child of the current folder's scope
|
||||
const activeScope = activeSubScopePath?.[activeSubScopePath.length - 1];
|
||||
const folderLocationInActivePath = activeSubScopePath?.indexOf(folder.subScopeName) ?? -1;
|
||||
|
||||
await scopesDashboardsService?.setNavigationScope(
|
||||
folderLocationInActivePath >= 0 ? folder.subScopeName : undefined,
|
||||
undefined,
|
||||
activeSubScopePath?.slice(folderLocationInActivePath + 1) ?? []
|
||||
);
|
||||
// Now changeScopes will skip fetchDashboards because navigationScope is set
|
||||
scopesSelectorService.changeScopes(
|
||||
folderLocationInActivePath >= 0 && activeScope ? [activeScope] : [folder.subScopeName],
|
||||
undefined,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -68,6 +85,7 @@ export function ScopesDashboardsTreeFolderItem({
|
||||
{folder.expanded && (
|
||||
<div className={styles.children}>
|
||||
<ScopesDashboardsTree
|
||||
subScopePath={subScopePath}
|
||||
subScope={folder.subScopeName}
|
||||
folders={folders}
|
||||
folderPath={folderPath}
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
const link = screen.getByTestId('scopes-dashboards-test-id');
|
||||
await userEvent.click(link);
|
||||
|
||||
expect(mockScopesDashboardsService.setNavigationScope).toHaveBeenCalledWith('currentScope');
|
||||
expect(mockScopesDashboardsService.setNavigationScope).toHaveBeenCalledWith('currentScope', undefined, undefined);
|
||||
});
|
||||
|
||||
it('should not set navigation scope when already set', async () => {
|
||||
|
||||
@@ -8,16 +8,17 @@ import { Icon, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { useScopesServices } from '../ScopesContextProvider';
|
||||
|
||||
import { isCurrentPath, normalizePath } from './scopeNavgiationUtils';
|
||||
import { isCurrentPath, normalizePath, serializeFolderPath } from './scopeNavgiationUtils';
|
||||
|
||||
export interface ScopesNavigationTreeLinkProps {
|
||||
subScope?: string;
|
||||
to: string;
|
||||
title: string;
|
||||
id: string;
|
||||
subScopePath?: string[];
|
||||
}
|
||||
|
||||
export function ScopesNavigationTreeLink({ subScope, to, title, id }: ScopesNavigationTreeLinkProps) {
|
||||
export function ScopesNavigationTreeLink({ subScope, to, title, id, subScopePath }: ScopesNavigationTreeLinkProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const linkIcon = useMemo(() => getLinkIcon(to), [to]);
|
||||
const locPathname = useLocation().pathname;
|
||||
@@ -25,7 +26,7 @@ export function ScopesNavigationTreeLink({ subScope, to, title, id }: ScopesNavi
|
||||
// Ignore query params
|
||||
const isCurrent = isCurrentPath(locPathname, to);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
const handleClick = async (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (subScope) {
|
||||
e.preventDefault(); // Prevent default Link navigation
|
||||
|
||||
@@ -39,11 +40,18 @@ export function ScopesNavigationTreeLink({ subScope, to, title, id }: ScopesNavi
|
||||
const searchParams = new URLSearchParams(url.search);
|
||||
if (!currentNavigationScope && currentScope) {
|
||||
searchParams.set('navigation_scope', currentScope);
|
||||
services?.scopesDashboardsService?.setNavigationScope(currentScope);
|
||||
await services?.scopesDashboardsService?.setNavigationScope(
|
||||
currentScope,
|
||||
undefined,
|
||||
subScopePath && subScopePath.length > 0 ? subScopePath : undefined
|
||||
);
|
||||
}
|
||||
|
||||
// Update query params with the new subScope
|
||||
searchParams.set('scopes', subScope);
|
||||
|
||||
// Set nav_scope_path to the subScopePath
|
||||
searchParams.set('nav_scope_path', subScopePath ? serializeFolderPath(subScopePath) : '');
|
||||
// Remove scope_node and scope_parent since we're changing to a subScope
|
||||
searchParams.delete('scope_node');
|
||||
searchParams.delete('scope_parent');
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { getDashboardPathForComparison, isCurrentPath } from './scopeNavgiationUtils';
|
||||
import {
|
||||
buildSubScopePath,
|
||||
deserializeFolderPath,
|
||||
getDashboardPathForComparison,
|
||||
isCurrentPath,
|
||||
serializeFolderPath,
|
||||
} from './scopeNavgiationUtils';
|
||||
import { SuggestedNavigationsFoldersMap } from './types';
|
||||
|
||||
describe('scopeNavgiationUtils', () => {
|
||||
it('should return the correct path for a dashboard', () => {
|
||||
@@ -28,4 +35,194 @@ describe('scopeNavgiationUtils', () => {
|
||||
expect(isCurrentPath('/d/dashboardId/slug', '/d/dashboardId#hash')).toBe(true);
|
||||
expect(isCurrentPath('/d/dashboardId', '/d/dashboardId#hash')).toBe(true);
|
||||
});
|
||||
|
||||
describe('deserializeFolderPath', () => {
|
||||
it('should return empty array for empty string', () => {
|
||||
expect(deserializeFolderPath('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse a simple comma-separated string', () => {
|
||||
expect(deserializeFolderPath('mimir,loki')).toEqual(['mimir', 'loki']);
|
||||
});
|
||||
|
||||
it('should handle single value', () => {
|
||||
expect(deserializeFolderPath('mimir')).toEqual(['mimir']);
|
||||
});
|
||||
|
||||
it('should trim whitespace around values', () => {
|
||||
expect(deserializeFolderPath(' mimir , loki ')).toEqual(['mimir', 'loki']);
|
||||
});
|
||||
|
||||
it('should handle URL-encoded strings', () => {
|
||||
expect(deserializeFolderPath(encodeURIComponent('mimir,loki'))).toEqual(['mimir', 'loki']);
|
||||
});
|
||||
|
||||
it('should handle URL-encoded strings with special characters', () => {
|
||||
expect(deserializeFolderPath(encodeURIComponent('folder one,folder two'))).toEqual(['folder one', 'folder two']);
|
||||
});
|
||||
|
||||
it('should fallback to split without decoding if decodeURIComponent fails', () => {
|
||||
// Invalid URI sequence that would cause decodeURIComponent to throw
|
||||
const invalidUri = '%E0%A4%A';
|
||||
expect(deserializeFolderPath(invalidUri)).toEqual(['%E0%A4%A']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeFolderPath', () => {
|
||||
it('should return empty string for empty array', () => {
|
||||
expect(serializeFolderPath([])).toBe('');
|
||||
});
|
||||
|
||||
it('should serialize a simple array', () => {
|
||||
expect(serializeFolderPath(['mimir', 'loki'])).toBe(encodeURIComponent('mimir,loki'));
|
||||
});
|
||||
|
||||
it('should handle single value', () => {
|
||||
expect(serializeFolderPath(['mimir'])).toBe('mimir');
|
||||
});
|
||||
|
||||
it('should handle values with spaces', () => {
|
||||
expect(serializeFolderPath(['folder one', 'folder two'])).toBe(encodeURIComponent('folder one,folder two'));
|
||||
});
|
||||
|
||||
it('should return empty string for null/undefined input', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(serializeFolderPath(null as any)).toBe('');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(serializeFolderPath(undefined as any)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeFolderPath and deserializeFolderPath round-trip', () => {
|
||||
it('should round-trip simple paths', () => {
|
||||
const original = ['mimir', 'loki'];
|
||||
const serialized = serializeFolderPath(original);
|
||||
const deserialized = deserializeFolderPath(serialized);
|
||||
expect(deserialized).toEqual(original);
|
||||
});
|
||||
|
||||
it('should round-trip paths with spaces', () => {
|
||||
const original = ['folder one', 'folder two'];
|
||||
const serialized = serializeFolderPath(original);
|
||||
const deserialized = deserializeFolderPath(serialized);
|
||||
expect(deserialized).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSubScopePath', () => {
|
||||
it('should return undefined when folders is empty', () => {
|
||||
const folders: SuggestedNavigationsFoldersMap = {};
|
||||
expect(buildSubScopePath('mimir', folders)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should find subScope at root level', () => {
|
||||
const folders: SuggestedNavigationsFoldersMap = {
|
||||
'Mimir Dashboards': {
|
||||
title: 'Mimir Dashboards',
|
||||
expanded: false,
|
||||
folders: {},
|
||||
suggestedNavigations: {},
|
||||
subScopeName: 'mimir',
|
||||
},
|
||||
};
|
||||
expect(buildSubScopePath('mimir', folders)).toEqual(['Mimir Dashboards']);
|
||||
});
|
||||
|
||||
it('should find subScope in nested folders', () => {
|
||||
const folders: SuggestedNavigationsFoldersMap = {
|
||||
'': {
|
||||
title: '',
|
||||
expanded: true,
|
||||
folders: {
|
||||
'Parent Folder': {
|
||||
title: 'Parent Folder',
|
||||
expanded: false,
|
||||
folders: {
|
||||
'Mimir Dashboards': {
|
||||
title: 'Mimir Dashboards',
|
||||
expanded: false,
|
||||
folders: {},
|
||||
suggestedNavigations: {},
|
||||
subScopeName: 'mimir',
|
||||
},
|
||||
},
|
||||
suggestedNavigations: {},
|
||||
},
|
||||
},
|
||||
suggestedNavigations: {},
|
||||
},
|
||||
};
|
||||
expect(buildSubScopePath('mimir', folders)).toEqual(['', 'Parent Folder', 'Mimir Dashboards']);
|
||||
});
|
||||
|
||||
it('should return undefined when subScope is not found', () => {
|
||||
const folders: SuggestedNavigationsFoldersMap = {
|
||||
'': {
|
||||
title: '',
|
||||
expanded: true,
|
||||
folders: {
|
||||
'Loki Dashboards': {
|
||||
title: 'Loki Dashboards',
|
||||
expanded: false,
|
||||
folders: {},
|
||||
suggestedNavigations: {},
|
||||
subScopeName: 'loki',
|
||||
},
|
||||
},
|
||||
suggestedNavigations: {},
|
||||
},
|
||||
};
|
||||
expect(buildSubScopePath('mimir', folders)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return first match when multiple folders have the same subScope', () => {
|
||||
const folders: SuggestedNavigationsFoldersMap = {
|
||||
'Mimir Dashboards': {
|
||||
title: 'Mimir Dashboards',
|
||||
expanded: false,
|
||||
folders: {},
|
||||
suggestedNavigations: {},
|
||||
subScopeName: 'mimir',
|
||||
},
|
||||
'Mimir Overview': {
|
||||
title: 'Mimir Overview',
|
||||
expanded: false,
|
||||
folders: {},
|
||||
suggestedNavigations: {},
|
||||
subScopeName: 'mimir',
|
||||
},
|
||||
};
|
||||
// Should return the first one found (order depends on Object.entries)
|
||||
const result = buildSubScopePath('mimir', folders);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should find deeply nested subScope', () => {
|
||||
const folders: SuggestedNavigationsFoldersMap = {
|
||||
level1: {
|
||||
title: 'Level 1',
|
||||
expanded: true,
|
||||
folders: {
|
||||
level2: {
|
||||
title: 'Level 2',
|
||||
expanded: true,
|
||||
folders: {
|
||||
level3: {
|
||||
title: 'Level 3',
|
||||
expanded: false,
|
||||
folders: {},
|
||||
suggestedNavigations: {},
|
||||
subScopeName: 'deep-scope',
|
||||
},
|
||||
},
|
||||
suggestedNavigations: {},
|
||||
},
|
||||
},
|
||||
suggestedNavigations: {},
|
||||
},
|
||||
};
|
||||
expect(buildSubScopePath('deep-scope', folders)).toEqual(['level1', 'level2', 'level3']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SuggestedNavigationsFoldersMap } from './types';
|
||||
|
||||
// Helper function to get the base path for a dashboard URL for comparison purposes.
|
||||
// e.g., /d/dashboardId/slug -> /d/dashboardId
|
||||
// /d/dashboardId -> /d/dashboardId
|
||||
@@ -5,12 +7,63 @@ export function getDashboardPathForComparison(pathname: string): string {
|
||||
return pathname.split('/').slice(0, 3).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the path to a folder with the given subScopeName by searching recursively.
|
||||
* @param subScope - The subScope name to find
|
||||
* @param folders - The root folder structure to search
|
||||
* @returns Array representing the path to the folder, or undefined if not found
|
||||
*/
|
||||
export function buildSubScopePath(subScope: string, folders: SuggestedNavigationsFoldersMap): string[] | undefined {
|
||||
function findPath(currentFolders: SuggestedNavigationsFoldersMap, currentPath: string[]): string[] | undefined {
|
||||
for (const [key, folder] of Object.entries(currentFolders)) {
|
||||
const newPath = [...currentPath, key];
|
||||
if (folder.subScopeName === subScope) {
|
||||
return newPath;
|
||||
}
|
||||
// Search in nested folders
|
||||
const nestedPath = findPath(folder.folders, newPath);
|
||||
if (nestedPath) {
|
||||
return nestedPath;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return findPath(folders, []);
|
||||
}
|
||||
|
||||
export function normalizePath(path: string): string {
|
||||
// Remove query + hash + trailing slash (except root)
|
||||
const noQuery = path.split('?')[0].split('#')[0];
|
||||
return noQuery !== '/' && noQuery.endsWith('/') ? noQuery.slice(0, -1) : noQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes a comma-separated folder path string into an array.
|
||||
* Handles URL-encoded strings.
|
||||
*/
|
||||
export function deserializeFolderPath(navScopePath: string): string[] {
|
||||
if (!navScopePath) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const decoded = decodeURIComponent(navScopePath);
|
||||
return decoded.split(',').map((s) => s.trim());
|
||||
} catch {
|
||||
return navScopePath.split(',').map((s) => s.trim());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a folder path array into a comma-separated string.
|
||||
*/
|
||||
export function serializeFolderPath(path: string[]): string {
|
||||
if (!path) {
|
||||
return '';
|
||||
}
|
||||
return encodeURIComponent(path.join(','));
|
||||
}
|
||||
|
||||
// Pathname comes from location.pathname
|
||||
export function isCurrentPath(pathname: string, to: string): boolean {
|
||||
const isDashboard = to.startsWith('/d/');
|
||||
|
||||
@@ -444,7 +444,7 @@ describe('ScopesSelectorService', () => {
|
||||
await service.selectScope('test-scope-node');
|
||||
await service.apply();
|
||||
await service.removeAllScopes();
|
||||
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined);
|
||||
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined, undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -405,7 +405,7 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
|
||||
public removeAllScopes = () => {
|
||||
this.applyScopes([], false);
|
||||
this.dashboardsService.setNavigationScope(undefined);
|
||||
this.dashboardsService.setNavigationScope(undefined, undefined, undefined);
|
||||
};
|
||||
|
||||
private addRecentScopes = (scopes: Scope[], parentNode?: ScopeNode, scopeNodeId?: string) => {
|
||||
|
||||
Reference in New Issue
Block a user