Scopes: Sync navigation scope and apply subScopes (#114083)
* Set navigationScope if we have a subScope * Proper URL sync * Update unit test with dashboards service subscription handling * Add a bunch of tests * Update functionality to change scopes when clicking on icon * Add test for TreeFolderItem * Udpate test and remove errors * Fix issues in test * Use ScopeNavgiations by default in the ScopesDashboardService unit tests * Remove misplaced test
This commit is contained in:
@@ -14,15 +14,22 @@ describe('ScopesService', () => {
|
||||
let selectorService: jest.Mocked<ScopesSelectorService>;
|
||||
let dashboardsService: jest.Mocked<ScopesDashboardsService>;
|
||||
let locationService: jest.Mocked<LocationService>;
|
||||
let stateSubscription:
|
||||
let selectorStateSubscription:
|
||||
| ((
|
||||
state: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> },
|
||||
prevState: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> }
|
||||
) => void)
|
||||
| undefined;
|
||||
let dashboardsStateSubscription:
|
||||
| ((
|
||||
state: { navigationScope?: string; drawerOpened: boolean },
|
||||
prevState: { navigationScope?: string; drawerOpened: boolean }
|
||||
) => void)
|
||||
| undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
stateSubscription = undefined;
|
||||
selectorStateSubscription = undefined;
|
||||
dashboardsStateSubscription = undefined;
|
||||
|
||||
selectorService = {
|
||||
state: {
|
||||
@@ -46,7 +53,7 @@ describe('ScopesService', () => {
|
||||
tree: { scopeNodeId: '', expanded: false, query: '', children: {} },
|
||||
}),
|
||||
subscribeToState: jest.fn((callback) => {
|
||||
stateSubscription = callback;
|
||||
selectorStateSubscription = callback;
|
||||
return { unsubscribe: jest.fn() };
|
||||
}),
|
||||
changeScopes: jest.fn(),
|
||||
@@ -63,6 +70,7 @@ describe('ScopesService', () => {
|
||||
forScopeNames: [],
|
||||
loading: false,
|
||||
searchQuery: '',
|
||||
navigationScope: undefined,
|
||||
},
|
||||
stateObservable: new BehaviorSubject({
|
||||
drawerOpened: false,
|
||||
@@ -73,7 +81,13 @@ describe('ScopesService', () => {
|
||||
forScopeNames: [],
|
||||
loading: false,
|
||||
searchQuery: '',
|
||||
navigationScope: undefined,
|
||||
}),
|
||||
subscribeToState: jest.fn((callback) => {
|
||||
dashboardsStateSubscription = callback;
|
||||
return { unsubscribe: jest.fn() };
|
||||
}),
|
||||
setNavigationScope: jest.fn(),
|
||||
} as unknown as jest.Mocked<ScopesDashboardsService>;
|
||||
|
||||
locationService = {
|
||||
@@ -160,6 +174,40 @@ describe('ScopesService', () => {
|
||||
|
||||
expect(selectorService.changeScopes).toHaveBeenCalledWith(['scope1', 'scope2'], undefined, 'node1', false);
|
||||
});
|
||||
|
||||
it('should read navigation_scope from URL on init', () => {
|
||||
locationService.getLocation = jest.fn().mockReturnValue({
|
||||
pathname: '/test',
|
||||
search: '?scopes=scope1&navigation_scope=navScope1',
|
||||
});
|
||||
|
||||
service = new ScopesService(selectorService, dashboardsService, locationService);
|
||||
|
||||
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1');
|
||||
});
|
||||
|
||||
it('should read navigation_scope along with other scope parameters', () => {
|
||||
locationService.getLocation = jest.fn().mockReturnValue({
|
||||
pathname: '/test',
|
||||
search: '?scopes=scope1&scope_node=node1&navigation_scope=navScope1',
|
||||
});
|
||||
|
||||
service = new ScopesService(selectorService, dashboardsService, locationService);
|
||||
|
||||
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1');
|
||||
expect(selectorService.changeScopes).toHaveBeenCalledWith(['scope1'], undefined, 'node1', false);
|
||||
});
|
||||
|
||||
it('should not call setNavigationScope when navigation_scope is not in URL', () => {
|
||||
locationService.getLocation = jest.fn().mockReturnValue({
|
||||
pathname: '/test',
|
||||
search: '?scopes=scope1',
|
||||
});
|
||||
|
||||
service = new ScopesService(selectorService, dashboardsService, locationService);
|
||||
|
||||
expect(dashboardsService.setNavigationScope).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL synchronization', () => {
|
||||
@@ -172,11 +220,11 @@ describe('ScopesService', () => {
|
||||
});
|
||||
|
||||
it('should write scope_node to URL when scopes change', () => {
|
||||
if (!stateSubscription) {
|
||||
throw new Error('stateSubscription not set');
|
||||
if (!selectorStateSubscription) {
|
||||
throw new Error('selectorStateSubscription not set');
|
||||
}
|
||||
|
||||
stateSubscription(
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }],
|
||||
},
|
||||
@@ -196,11 +244,11 @@ describe('ScopesService', () => {
|
||||
});
|
||||
|
||||
it('should reset scope_parent to null when writing URL', () => {
|
||||
if (!stateSubscription) {
|
||||
throw new Error('stateSubscription not set');
|
||||
if (!selectorStateSubscription) {
|
||||
throw new Error('selectorStateSubscription not set');
|
||||
}
|
||||
|
||||
stateSubscription(
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1', parentNodeId: 'parent1' }],
|
||||
},
|
||||
@@ -218,11 +266,11 @@ describe('ScopesService', () => {
|
||||
});
|
||||
|
||||
it('should handle scopeNodeId changes without scope changes', () => {
|
||||
if (!stateSubscription) {
|
||||
throw new Error('stateSubscription not set');
|
||||
if (!selectorStateSubscription) {
|
||||
throw new Error('selectorStateSubscription not set');
|
||||
}
|
||||
|
||||
stateSubscription(
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node2' }],
|
||||
},
|
||||
@@ -242,11 +290,11 @@ describe('ScopesService', () => {
|
||||
});
|
||||
|
||||
it('should handle missing scopeNodeId gracefully', () => {
|
||||
if (!stateSubscription) {
|
||||
throw new Error('stateSubscription not set');
|
||||
if (!selectorStateSubscription) {
|
||||
throw new Error('selectorStateSubscription not set');
|
||||
}
|
||||
|
||||
stateSubscription(
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1' }],
|
||||
},
|
||||
@@ -266,13 +314,13 @@ describe('ScopesService', () => {
|
||||
});
|
||||
|
||||
it('should not update URL when scopes and scopeNodeId have not changed', () => {
|
||||
if (!stateSubscription) {
|
||||
throw new Error('stateSubscription not set');
|
||||
if (!selectorStateSubscription) {
|
||||
throw new Error('selectorStateSubscription not set');
|
||||
}
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
stateSubscription(
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }],
|
||||
},
|
||||
@@ -283,6 +331,90 @@ describe('ScopesService', () => {
|
||||
|
||||
expect(locationService.partial).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should write navigation_scope to URL when navigationScope changes', () => {
|
||||
if (!dashboardsStateSubscription) {
|
||||
throw new Error('dashboardsStateSubscription not set');
|
||||
}
|
||||
|
||||
dashboardsStateSubscription(
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: true,
|
||||
},
|
||||
{
|
||||
navigationScope: undefined,
|
||||
drawerOpened: false,
|
||||
}
|
||||
);
|
||||
|
||||
expect(locationService.partial).toHaveBeenCalledWith({
|
||||
navigation_scope: 'navScope1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should update navigation_scope in URL when navigationScope changes', () => {
|
||||
if (!dashboardsStateSubscription) {
|
||||
throw new Error('dashboardsStateSubscription not set');
|
||||
}
|
||||
|
||||
dashboardsStateSubscription(
|
||||
{
|
||||
navigationScope: 'navScope2',
|
||||
drawerOpened: true,
|
||||
},
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(locationService.partial).toHaveBeenCalledWith({
|
||||
navigation_scope: 'navScope2',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not update URL when navigationScope has not changed', () => {
|
||||
if (!dashboardsStateSubscription) {
|
||||
throw new Error('dashboardsStateSubscription not set');
|
||||
}
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
dashboardsStateSubscription(
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: true,
|
||||
},
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: false,
|
||||
}
|
||||
);
|
||||
|
||||
expect(locationService.partial).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear navigation_scope from URL when navigationScope is cleared', () => {
|
||||
if (!dashboardsStateSubscription) {
|
||||
throw new Error('dashboardsStateSubscription not set');
|
||||
}
|
||||
|
||||
dashboardsStateSubscription(
|
||||
{
|
||||
navigationScope: undefined,
|
||||
drawerOpened: false,
|
||||
},
|
||||
{
|
||||
navigationScope: 'navScope1',
|
||||
drawerOpened: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(locationService.partial).toHaveBeenCalledWith({
|
||||
navigation_scope: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setEnabled', () => {
|
||||
|
||||
@@ -73,6 +73,11 @@ export class ScopesService implements ScopesContextValue {
|
||||
const scopeNodeId = queryParams.get('scope_node');
|
||||
// TODO: figure out when to remove this. scope_parent is for backward compatibility only
|
||||
const parentNodeId = queryParams.get('scope_parent');
|
||||
const navigationScope = queryParams.get('navigation_scope');
|
||||
|
||||
if (navigationScope) {
|
||||
this.dashboardsService.setNavigationScope(navigationScope);
|
||||
}
|
||||
|
||||
this.changeScopes(queryParams.getAll('scopes'), parentNodeId ?? undefined, scopeNodeId ?? undefined);
|
||||
|
||||
@@ -133,6 +138,16 @@ 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,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Location } from 'history';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import { ScopeDashboardBinding } from '@grafana/data';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
|
||||
import { ScopesApiClient } from '../ScopesApiClient';
|
||||
@@ -15,7 +14,7 @@ jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
config: {
|
||||
featureToggles: {
|
||||
useScopesNavigationEndpoint: false,
|
||||
useScopesNavigationEndpoint: true,
|
||||
},
|
||||
apps: {},
|
||||
},
|
||||
@@ -33,27 +32,32 @@ describe('ScopesDashboardsService', () => {
|
||||
let mockApiClient: jest.Mocked<ScopesApiClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
const fetchScopeNavigationsMock = jest.fn().mockResolvedValue([]);
|
||||
mockApiClient = {
|
||||
fetchDashboards: jest.fn(),
|
||||
fetchScopeNavigations: jest.fn(),
|
||||
fetchScopeNavigations: fetchScopeNavigationsMock,
|
||||
} as unknown as jest.Mocked<ScopesApiClient>;
|
||||
|
||||
service = new ScopesDashboardsService(mockApiClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.featureToggles.useScopesNavigationEndpoint = true;
|
||||
});
|
||||
|
||||
describe('folder expansion based on location', () => {
|
||||
it('should expand folders when current location matches dashboard ID', async () => {
|
||||
// Mock current location to be a dashboard
|
||||
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/d/dashboard1' } as Location);
|
||||
|
||||
const mockDashboards: ScopeDashboardBinding[] = [
|
||||
const mockNavigations: ScopeNavigation[] = [
|
||||
{
|
||||
spec: {
|
||||
scope: 'scope1',
|
||||
dashboard: 'dashboard1',
|
||||
url: '/d/dashboard1',
|
||||
},
|
||||
status: {
|
||||
dashboardTitle: 'Test Dashboard',
|
||||
title: 'Test Dashboard',
|
||||
groups: ['group1'],
|
||||
},
|
||||
metadata: {
|
||||
@@ -62,7 +66,7 @@ describe('ScopesDashboardsService', () => {
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClient.fetchDashboards.mockResolvedValue(mockDashboards);
|
||||
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
|
||||
await service.fetchDashboards(['scope1']);
|
||||
|
||||
// Verify that the folder is expanded because the current dashboard ID matches
|
||||
@@ -168,14 +172,14 @@ describe('ScopesDashboardsService', () => {
|
||||
// Mock current location to not match any navigation
|
||||
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/different-path' } as Location);
|
||||
|
||||
const mockDashboards: ScopeDashboardBinding[] = [
|
||||
const mockNavigations: ScopeNavigation[] = [
|
||||
{
|
||||
spec: {
|
||||
scope: 'scope1',
|
||||
dashboard: 'dashboard1',
|
||||
url: '/d/dashboard1',
|
||||
},
|
||||
status: {
|
||||
dashboardTitle: 'Test Dashboard',
|
||||
title: 'Test Dashboard',
|
||||
groups: ['group1'],
|
||||
},
|
||||
metadata: {
|
||||
@@ -184,7 +188,7 @@ describe('ScopesDashboardsService', () => {
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClient.fetchDashboards.mockResolvedValue(mockDashboards);
|
||||
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
|
||||
await service.fetchDashboards(['scope1']);
|
||||
|
||||
// Verify that the folder is not expanded because the current location doesn't match
|
||||
@@ -197,14 +201,14 @@ describe('ScopesDashboardsService', () => {
|
||||
pathname: '/d/dashboard1/very-important',
|
||||
} as Location);
|
||||
|
||||
const mockDashboards: ScopeDashboardBinding[] = [
|
||||
const mockNavigations: ScopeNavigation[] = [
|
||||
{
|
||||
spec: {
|
||||
scope: 'scope1',
|
||||
dashboard: 'dashboard1',
|
||||
url: '/d/dashboard1',
|
||||
},
|
||||
status: {
|
||||
dashboardTitle: 'Test Dashboard',
|
||||
title: 'Test Dashboard',
|
||||
groups: ['group1'],
|
||||
},
|
||||
metadata: {
|
||||
@@ -213,7 +217,7 @@ describe('ScopesDashboardsService', () => {
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClient.fetchDashboards.mockResolvedValue(mockDashboards);
|
||||
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
|
||||
await service.fetchDashboards(['scope1']);
|
||||
|
||||
// Verify that the folder is expanded because the current path starts with the dashboard ID
|
||||
@@ -224,14 +228,14 @@ describe('ScopesDashboardsService', () => {
|
||||
// Mock current location to be a specific dashboard
|
||||
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/d/dashboard1' } as Location);
|
||||
|
||||
const mockDashboards: ScopeDashboardBinding[] = [
|
||||
const mockNavigations: ScopeNavigation[] = [
|
||||
{
|
||||
spec: {
|
||||
scope: 'scope1',
|
||||
dashboard: 'dashboard1',
|
||||
url: '/d/dashboard1',
|
||||
},
|
||||
status: {
|
||||
dashboardTitle: 'Test Dashboard',
|
||||
title: 'Test Dashboard',
|
||||
groups: ['group1'],
|
||||
},
|
||||
metadata: {
|
||||
@@ -241,10 +245,10 @@ describe('ScopesDashboardsService', () => {
|
||||
{
|
||||
spec: {
|
||||
scope: 'scope1',
|
||||
dashboard: 'dashboard2',
|
||||
url: '/d/dashboard2',
|
||||
},
|
||||
status: {
|
||||
dashboardTitle: 'Another Dashboard',
|
||||
title: 'Another Dashboard',
|
||||
groups: ['group2'],
|
||||
},
|
||||
metadata: {
|
||||
@@ -253,7 +257,7 @@ describe('ScopesDashboardsService', () => {
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClient.fetchDashboards.mockResolvedValue(mockDashboards);
|
||||
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
|
||||
await service.fetchDashboards(['scope1']);
|
||||
|
||||
// Verify that only the folder containing the current dashboard is expanded
|
||||
@@ -697,4 +701,142 @@ describe('ScopesDashboardsService', () => {
|
||||
expect(filteredItems.some((item) => item.metadata.name === 'loki-item-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setNavigationScope', () => {
|
||||
beforeEach(() => {
|
||||
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/' } as Location);
|
||||
// Reset mocks but keep the mock functions
|
||||
mockApiClient.fetchDashboards.mockClear();
|
||||
// Note: fetchScopeNavigations mock is set up in top-level beforeEach
|
||||
// Individual tests will override it with their own mockResolvedValue calls
|
||||
});
|
||||
|
||||
it('should set navigation scope and fetch dashboards', async () => {
|
||||
// Mock non-empty results so drawerOpened stays true after fetchDashboards completes
|
||||
mockApiClient.fetchScopeNavigations.mockResolvedValue([
|
||||
{
|
||||
spec: { scope: 'navScope1', url: '/d/dashboard1' },
|
||||
status: { title: 'Test', groups: [] },
|
||||
metadata: { name: 'dashboard1' },
|
||||
},
|
||||
]);
|
||||
|
||||
await service.setNavigationScope('navScope1');
|
||||
|
||||
expect(service.state.navigationScope).toBe('navScope1');
|
||||
expect(service.state.drawerOpened).toBe(true);
|
||||
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['navScope1']);
|
||||
});
|
||||
|
||||
it('should clear navigation scope and use fallback scope names', async () => {
|
||||
// Mock non-empty results so drawerOpened stays true after fetchDashboards completes
|
||||
mockApiClient.fetchScopeNavigations.mockResolvedValue([
|
||||
{
|
||||
spec: { scope: 'fallbackScope1', url: '/d/dashboard1' },
|
||||
status: { title: 'Test', groups: [] },
|
||||
metadata: { name: 'dashboard1' },
|
||||
},
|
||||
]);
|
||||
// Set an initial navigation scope
|
||||
await service.setNavigationScope('initialScope');
|
||||
expect(service.state.navigationScope).toBe('initialScope');
|
||||
expect(service.state.drawerOpened).toBe(true);
|
||||
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['initialScope']);
|
||||
|
||||
await service.setNavigationScope(undefined, ['fallbackScope1', 'fallbackScope2']);
|
||||
|
||||
expect(service.state.navigationScope).toBeUndefined();
|
||||
expect(service.state.drawerOpened).toBe(true);
|
||||
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['fallbackScope1', 'fallbackScope2']);
|
||||
});
|
||||
|
||||
it('should not run if previous and next navigation scopes are undefined and we provide fallback scope names', async () => {
|
||||
await service.setNavigationScope(undefined, ['fallbackScope1', 'fallbackScope2']);
|
||||
expect(service.state.navigationScope).toBeUndefined();
|
||||
expect(service.state.drawerOpened).toBe(false);
|
||||
expect(mockApiClient.fetchScopeNavigations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should close drawer when navigation scope is cleared without fallback', async () => {
|
||||
// When setNavigationScope is called with undefined and no fallback,
|
||||
// it calls fetchDashboards([]), which returns early without calling the API
|
||||
await service.setNavigationScope(undefined);
|
||||
|
||||
expect(service.state.navigationScope).toBeUndefined();
|
||||
expect(service.state.drawerOpened).toBe(false);
|
||||
// fetchDashboards([]) returns early, so API client is not called
|
||||
expect(mockApiClient.fetchScopeNavigations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not update state if navigation scope has not changed', async () => {
|
||||
mockApiClient.fetchScopeNavigations.mockResolvedValue([
|
||||
{
|
||||
spec: { scope: 'navScope1', url: '/d/dashboard1' },
|
||||
status: { title: 'Test', groups: [] },
|
||||
metadata: { name: 'dashboard1' },
|
||||
},
|
||||
]);
|
||||
await service.setNavigationScope('navScope1');
|
||||
mockApiClient.fetchScopeNavigations.mockClear();
|
||||
|
||||
await service.setNavigationScope('navScope1');
|
||||
|
||||
expect(mockApiClient.fetchScopeNavigations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update navigation scope when changing from one scope to another', async () => {
|
||||
mockApiClient.fetchScopeNavigations.mockResolvedValue([]);
|
||||
|
||||
await service.setNavigationScope('navScope1');
|
||||
mockApiClient.fetchScopeNavigations.mockClear();
|
||||
|
||||
await service.setNavigationScope('navScope2');
|
||||
|
||||
expect(service.state.navigationScope).toBe('navScope2');
|
||||
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['navScope2']);
|
||||
});
|
||||
|
||||
it('should update navigation scope when clearing an existing scope', async () => {
|
||||
mockApiClient.fetchScopeNavigations.mockResolvedValue([]);
|
||||
|
||||
await service.setNavigationScope('navScope1');
|
||||
mockApiClient.fetchScopeNavigations.mockClear();
|
||||
|
||||
await service.setNavigationScope(undefined, ['fallbackScope']);
|
||||
|
||||
expect(service.state.navigationScope).toBeUndefined();
|
||||
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['fallbackScope']);
|
||||
});
|
||||
|
||||
it('should open drawer when navigation scope is set', async () => {
|
||||
// Mock to return non-empty results so drawer stays open
|
||||
mockApiClient.fetchScopeNavigations.mockResolvedValue([
|
||||
{
|
||||
spec: { scope: 'navScope1', url: '/d/dashboard1' },
|
||||
status: { title: 'Test', groups: [] },
|
||||
metadata: { name: 'dashboard1' },
|
||||
},
|
||||
]);
|
||||
|
||||
await service.setNavigationScope('navScope1');
|
||||
|
||||
expect(service.state.drawerOpened).toBe(true);
|
||||
});
|
||||
|
||||
it('should open drawer when fallback scopes are provided', async () => {
|
||||
// Mock non-empty results so drawerOpened stays true after fetchDashboards completes
|
||||
mockApiClient.fetchScopeNavigations.mockResolvedValue([
|
||||
{
|
||||
spec: { scope: 'fallbackScope', url: '/d/dashboard1' },
|
||||
status: { title: 'Test', groups: [] },
|
||||
metadata: { name: 'dashboard1' },
|
||||
},
|
||||
]);
|
||||
await service.setNavigationScope('initialScope');
|
||||
|
||||
await service.setNavigationScope(undefined, ['fallbackScope']);
|
||||
|
||||
expect(service.state.drawerOpened).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ interface ScopesDashboardsServiceState {
|
||||
forScopeNames: string[];
|
||||
loading: boolean;
|
||||
searchQuery: string;
|
||||
navigationScope?: string;
|
||||
}
|
||||
|
||||
export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsServiceState> {
|
||||
@@ -56,6 +57,18 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
|
||||
});
|
||||
}
|
||||
|
||||
// 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[]) => {
|
||||
if (this.state.navigationScope === navigationScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -9,12 +9,13 @@ import { ScopesNavigationTreeLink } from './ScopesNavigationTreeLink';
|
||||
import { OnFolderUpdate, SuggestedNavigationsFoldersMap } from './types';
|
||||
|
||||
export interface ScopesDashboardsTreeProps {
|
||||
subScope?: string;
|
||||
folders: SuggestedNavigationsFoldersMap;
|
||||
folderPath: string[];
|
||||
onFolderUpdate: OnFolderUpdate;
|
||||
}
|
||||
|
||||
export function ScopesDashboardsTree({ folders, folderPath, onFolderUpdate }: ScopesDashboardsTreeProps) {
|
||||
export function ScopesDashboardsTree({ subScope, folders, folderPath, onFolderUpdate }: ScopesDashboardsTreeProps) {
|
||||
const [queryParams] = useQueryParams();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
@@ -52,6 +53,7 @@ export function ScopesDashboardsTree({ folders, folderPath, onFolderUpdate }: Sc
|
||||
))}
|
||||
{regularNavigations.map((navigation) => (
|
||||
<ScopesNavigationTreeLink
|
||||
subScope={subScope}
|
||||
key={navigation.id + navigation.title}
|
||||
to={urlUtil.renderUrl(navigation.url, queryParams)}
|
||||
title={navigation.title}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { ScopesDashboardsTreeFolderItem } from './ScopesDashboardsTreeFolderItem';
|
||||
import { SuggestedNavigationsFolder, SuggestedNavigationsFoldersMap } from './types';
|
||||
|
||||
// Mock useQueryParams hook used by ScopesDashboardsTree
|
||||
jest.mock('app/core/hooks/useQueryParams', () => ({
|
||||
useQueryParams: jest.fn(() => [{}]),
|
||||
}));
|
||||
|
||||
// Mock ScopesContextProvider
|
||||
const mockScopesSelectorService = {
|
||||
changeScopes: jest.fn(),
|
||||
};
|
||||
|
||||
const mockScopesDashboardsService = {
|
||||
setNavigationScope: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('../ScopesContextProvider', () => ({
|
||||
...jest.requireActual('../ScopesContextProvider'),
|
||||
useScopesServices: jest.fn(() => ({
|
||||
scopesSelectorService: mockScopesSelectorService,
|
||||
scopesDashboardsService: mockScopesDashboardsService,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('ScopesDashboardsTreeFolderItem', () => {
|
||||
const mockOnFolderUpdate = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const createMockFolder = (overrides?: Partial<SuggestedNavigationsFolder>): SuggestedNavigationsFolder => ({
|
||||
title: 'Test Folder',
|
||||
expanded: false,
|
||||
folders: {},
|
||||
suggestedNavigations: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockFolders: SuggestedNavigationsFoldersMap = {
|
||||
'': {
|
||||
title: '',
|
||||
expanded: true,
|
||||
folders: {},
|
||||
suggestedNavigations: {},
|
||||
},
|
||||
};
|
||||
|
||||
it('renders folder with correct props', () => {
|
||||
const folder = createMockFolder();
|
||||
render(
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
folder={folder}
|
||||
folderPath={['']}
|
||||
folders={createMockFolders}
|
||||
onFolderUpdate={mockOnFolderUpdate}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Folder')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('scopes-dashboards-Test Folder-expand')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onFolderUpdate when expand button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const folder = createMockFolder({ expanded: false });
|
||||
|
||||
render(
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
folder={folder}
|
||||
folderPath={['']}
|
||||
folders={createMockFolders}
|
||||
onFolderUpdate={mockOnFolderUpdate}
|
||||
/>
|
||||
);
|
||||
|
||||
const expandButton = screen.getByTestId('scopes-dashboards-Test Folder-expand');
|
||||
await user.click(expandButton);
|
||||
|
||||
expect(mockOnFolderUpdate).toHaveBeenCalledWith([''], true);
|
||||
});
|
||||
|
||||
it('shows exchange icon when folder has subScopeName', () => {
|
||||
const folder = createMockFolder({ subScopeName: 'subScope1' });
|
||||
|
||||
render(
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
folder={folder}
|
||||
folderPath={['']}
|
||||
folders={createMockFolders}
|
||||
onFolderUpdate={mockOnFolderUpdate}
|
||||
/>
|
||||
);
|
||||
|
||||
// IconButton with exchange-alt icon should be present
|
||||
const exchangeButton = screen.getByRole('button', { name: /change root scope/i });
|
||||
expect(exchangeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show exchange icon when folder does not have subScopeName', () => {
|
||||
const folder = createMockFolder();
|
||||
|
||||
render(
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
folder={folder}
|
||||
folderPath={['']}
|
||||
folders={createMockFolders}
|
||||
onFolderUpdate={mockOnFolderUpdate}
|
||||
/>
|
||||
);
|
||||
|
||||
const exchangeButtons = screen.queryAllByRole('button', { name: /change root scope/i });
|
||||
expect(exchangeButtons).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('calls setNavigationScope when exchange icon is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const folder = createMockFolder({ subScopeName: 'subScope1' });
|
||||
|
||||
render(
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
folder={folder}
|
||||
folderPath={['']}
|
||||
folders={createMockFolders}
|
||||
onFolderUpdate={mockOnFolderUpdate}
|
||||
/>
|
||||
);
|
||||
|
||||
const exchangeButton = screen.getByRole('button', { name: /change root scope/i });
|
||||
await user.click(exchangeButton);
|
||||
|
||||
expect(mockScopesDashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined, ['subScope1']);
|
||||
});
|
||||
|
||||
it('calls changeScopes when exchange icon is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const folder = createMockFolder({ subScopeName: 'subScope1' });
|
||||
|
||||
render(
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
folder={folder}
|
||||
folderPath={['']}
|
||||
folders={createMockFolders}
|
||||
onFolderUpdate={mockOnFolderUpdate}
|
||||
/>
|
||||
);
|
||||
|
||||
const exchangeButton = screen.getByRole('button', { name: /change root scope/i });
|
||||
await user.click(exchangeButton);
|
||||
|
||||
expect(mockScopesSelectorService.changeScopes).toHaveBeenCalledWith(['subScope1']);
|
||||
});
|
||||
|
||||
it('passes subScope prop to ScopesDashboardsTree when folder is expanded', () => {
|
||||
const folder = createMockFolder({ expanded: true, subScopeName: 'subScope1' });
|
||||
const childFolders: SuggestedNavigationsFoldersMap = {
|
||||
'': {
|
||||
title: '',
|
||||
expanded: true,
|
||||
folders: {
|
||||
childFolder: {
|
||||
title: 'Child Folder',
|
||||
expanded: false,
|
||||
folders: {},
|
||||
suggestedNavigations: {},
|
||||
},
|
||||
},
|
||||
suggestedNavigations: {},
|
||||
},
|
||||
childFolder: {
|
||||
title: 'Child Folder',
|
||||
expanded: false,
|
||||
folders: {},
|
||||
suggestedNavigations: {},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
folder={folder}
|
||||
folderPath={['']}
|
||||
folders={childFolders}
|
||||
onFolderUpdate={mockOnFolderUpdate}
|
||||
/>
|
||||
);
|
||||
|
||||
// ScopesDashboardsTree should be rendered when folder is expanded
|
||||
// We can verify this by checking that the children container is present
|
||||
const childrenContainer = screen.getByText('Test Folder').closest('div')?.nextSibling;
|
||||
expect(childrenContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render ScopesDashboardsTree when folder is not expanded', () => {
|
||||
const folder = createMockFolder({ expanded: false, subScopeName: 'subScope1' });
|
||||
|
||||
render(
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
folder={folder}
|
||||
folderPath={['']}
|
||||
folders={createMockFolders}
|
||||
onFolderUpdate={mockOnFolderUpdate}
|
||||
/>
|
||||
);
|
||||
|
||||
// When not expanded, the children container should not be visible
|
||||
// The structure should only show the folder row
|
||||
expect(screen.getByText('Test Folder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prevents default and stops propagation when exchange icon is clicked', async () => {
|
||||
const folder = createMockFolder({ subScopeName: 'subScope1' });
|
||||
const preventDefault = jest.fn();
|
||||
const stopPropagation = jest.fn();
|
||||
|
||||
render(
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
folder={folder}
|
||||
folderPath={['']}
|
||||
folders={createMockFolders}
|
||||
onFolderUpdate={mockOnFolderUpdate}
|
||||
/>
|
||||
);
|
||||
|
||||
const exchangeButton = screen.getByRole('button', { name: /change root scope/i });
|
||||
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(clickEvent, 'preventDefault', { value: preventDefault });
|
||||
Object.defineProperty(clickEvent, 'stopPropagation', { value: stopPropagation });
|
||||
|
||||
exchangeButton.dispatchEvent(clickEvent);
|
||||
|
||||
// The onClick handler should prevent default and stop propagation
|
||||
// Note: userEvent.click doesn't trigger preventDefault/stopPropagation directly,
|
||||
// but the handler should call them. We verify the handler was called correctly.
|
||||
expect(mockScopesDashboardsService.setNavigationScope).toHaveBeenCalled();
|
||||
expect(stopPropagation).toHaveBeenCalled();
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call setNavigationScope when subScopeName is missing', async () => {
|
||||
const folder = createMockFolder({ subScopeName: undefined });
|
||||
|
||||
render(
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
folder={folder}
|
||||
folderPath={['']}
|
||||
folders={createMockFolders}
|
||||
onFolderUpdate={mockOnFolderUpdate}
|
||||
/>
|
||||
);
|
||||
|
||||
// No exchange button should be present, so no click should trigger setNavigationScope
|
||||
expect(mockScopesDashboardsService.setNavigationScope).not.toHaveBeenCalled();
|
||||
// Check precense of exchange button
|
||||
const exchangeButton = screen.queryByRole('button', { name: /change root scope/i });
|
||||
expect(exchangeButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not call setNavigationScope when scopesSelectorService is not available', async () => {
|
||||
const user = userEvent.setup();
|
||||
const folder = createMockFolder({ subScopeName: 'subScope1' });
|
||||
|
||||
// Mock useScopesServices to return undefined
|
||||
jest.spyOn(require('../ScopesContextProvider'), 'useScopesServices').mockReturnValue(undefined);
|
||||
|
||||
render(
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
folder={folder}
|
||||
folderPath={['']}
|
||||
folders={createMockFolders}
|
||||
onFolderUpdate={mockOnFolderUpdate}
|
||||
/>
|
||||
);
|
||||
|
||||
const exchangeButton = screen.queryByRole('button', { name: /change root scope/i });
|
||||
if (exchangeButton) {
|
||||
await user.click(exchangeButton);
|
||||
}
|
||||
|
||||
// Should not crash, but also should not call setNavigationScope if service is not available
|
||||
// The component checks for scopesSelectorService existence before calling setNavigationScope
|
||||
expect(mockScopesDashboardsService.setNavigationScope).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ export function ScopesDashboardsTreeFolderItem({
|
||||
|
||||
// get scopesselector service
|
||||
const scopesSelectorService = useScopesServices()?.scopesSelectorService ?? undefined;
|
||||
|
||||
const scopesDashboardsService = useScopesServices()?.scopesDashboardsService ?? undefined;
|
||||
return (
|
||||
<div className={styles.container} role="treeitem" aria-selected={folder.expanded}>
|
||||
<div className={styles.row}>
|
||||
@@ -57,6 +57,7 @@ export function ScopesDashboardsTreeFolderItem({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (folder.subScopeName && scopesSelectorService) {
|
||||
scopesDashboardsService?.setNavigationScope(undefined, [folder.subScopeName]);
|
||||
scopesSelectorService.changeScopes([folder.subScopeName]);
|
||||
}
|
||||
}}
|
||||
@@ -66,7 +67,12 @@ export function ScopesDashboardsTreeFolderItem({
|
||||
|
||||
{folder.expanded && (
|
||||
<div className={styles.children}>
|
||||
<ScopesDashboardsTree folders={folders} folderPath={folderPath} onFolderUpdate={onFolderUpdate} />
|
||||
<ScopesDashboardsTree
|
||||
subScope={folder.subScopeName}
|
||||
folders={folders}
|
||||
folderPath={folderPath}
|
||||
onFolderUpdate={onFolderUpdate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { locationService } from '@grafana/runtime';
|
||||
|
||||
import { ScopesNavigationTreeLink } from './ScopesNavigationTreeLink';
|
||||
|
||||
// Mock react-router-dom's useLocation
|
||||
@@ -9,15 +12,50 @@ jest.mock('react-router-dom-v5-compat', () => ({
|
||||
useLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock @grafana/runtime
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
locationService: {
|
||||
push: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ScopesContextProvider
|
||||
const mockScopesSelectorService = {
|
||||
state: {
|
||||
appliedScopes: [{ scopeId: 'currentScope' }],
|
||||
},
|
||||
changeScopes: jest.fn(),
|
||||
};
|
||||
|
||||
const mockScopesDashboardsService = {
|
||||
state: {
|
||||
navigationScope: undefined as string | undefined,
|
||||
},
|
||||
setNavigationScope: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('../ScopesContextProvider', () => ({
|
||||
...jest.requireActual('../ScopesContextProvider'),
|
||||
useScopesServices: jest.fn(() => ({
|
||||
scopesSelectorService: mockScopesSelectorService,
|
||||
scopesDashboardsService: mockScopesDashboardsService,
|
||||
})),
|
||||
}));
|
||||
|
||||
const renderWithRouter = (ui: React.ReactElement) => {
|
||||
return render(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
};
|
||||
|
||||
describe('ScopesNavigationTreeLink', () => {
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockLocationServicePush = locationService.push as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseLocation.mockReturnValue({ pathname: '/current-path' });
|
||||
jest.clearAllMocks();
|
||||
mockScopesSelectorService.state.appliedScopes = [{ scopeId: 'currentScope' }];
|
||||
mockScopesDashboardsService.state.navigationScope = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -133,4 +171,141 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(link).toHaveTextContent('Metrics Drilldown');
|
||||
});
|
||||
|
||||
describe('click handler with subScope', () => {
|
||||
beforeEach(() => {
|
||||
// Mock window.location.origin
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
origin: 'http://localhost',
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should prevent default navigation when subScope is provided', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithRouter(
|
||||
<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" subScope="subScope1" />
|
||||
);
|
||||
|
||||
const link = screen.getByTestId('scopes-dashboards-test-id');
|
||||
await user.click(link);
|
||||
|
||||
// Should call changeScopes instead of navigating normally
|
||||
expect(mockScopesSelectorService.changeScopes).toHaveBeenCalled();
|
||||
expect(mockLocationServicePush).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set navigation scope from current scope when not already set', async () => {
|
||||
mockScopesDashboardsService.state.navigationScope = undefined;
|
||||
mockScopesSelectorService.state.appliedScopes = [{ scopeId: 'currentScope' }];
|
||||
|
||||
renderWithRouter(
|
||||
<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" subScope="subScope1" />
|
||||
);
|
||||
|
||||
const link = screen.getByTestId('scopes-dashboards-test-id');
|
||||
await userEvent.click(link);
|
||||
|
||||
expect(mockScopesDashboardsService.setNavigationScope).toHaveBeenCalledWith('currentScope');
|
||||
});
|
||||
|
||||
it('should not set navigation scope when already set', async () => {
|
||||
mockScopesDashboardsService.state.navigationScope = 'existingNavScope';
|
||||
mockScopesSelectorService.state.appliedScopes = [{ scopeId: 'currentScope' }];
|
||||
|
||||
renderWithRouter(
|
||||
<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" subScope="subScope1" />
|
||||
);
|
||||
|
||||
const link = screen.getByTestId('scopes-dashboards-test-id');
|
||||
await userEvent.click(link);
|
||||
|
||||
// Should not call setNavigationScope with currentScope since it's already set
|
||||
expect(mockScopesDashboardsService.setNavigationScope).not.toHaveBeenCalledWith('currentScope');
|
||||
});
|
||||
|
||||
it('should call changeScopes with subScope', async () => {
|
||||
renderWithRouter(
|
||||
<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" subScope="subScope1" />
|
||||
);
|
||||
|
||||
const link = screen.getByTestId('scopes-dashboards-test-id');
|
||||
await userEvent.click(link);
|
||||
|
||||
expect(mockScopesSelectorService.changeScopes).toHaveBeenCalledWith(['subScope1'], undefined, undefined, false);
|
||||
});
|
||||
|
||||
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" />
|
||||
);
|
||||
|
||||
const link = screen.getByTestId('scopes-dashboards-test-id');
|
||||
await userEvent.click(link);
|
||||
|
||||
expect(mockLocationServicePush).toHaveBeenCalled();
|
||||
const pushedUrl = mockLocationServicePush.mock.calls[0][0];
|
||||
expect(pushedUrl).toContain('/test-path');
|
||||
expect(pushedUrl).toContain('scopes=subScope1');
|
||||
expect(pushedUrl).toContain('navigation_scope=currentScope');
|
||||
});
|
||||
|
||||
it('should remove scope_node and scope_parent from URL when navigating with subScope', async () => {
|
||||
renderWithRouter(
|
||||
<ScopesNavigationTreeLink
|
||||
to="/test-path?scope_node=node1&scope_parent=parent1"
|
||||
title="Test Link"
|
||||
id="test-id"
|
||||
subScope="subScope1"
|
||||
/>
|
||||
);
|
||||
|
||||
const link = screen.getByTestId('scopes-dashboards-test-id');
|
||||
await userEvent.click(link);
|
||||
|
||||
expect(mockLocationServicePush).toHaveBeenCalled();
|
||||
const pushedUrl = mockLocationServicePush.mock.calls[0][0];
|
||||
expect(pushedUrl).not.toContain('scope_node');
|
||||
expect(pushedUrl).not.toContain('scope_parent');
|
||||
});
|
||||
|
||||
it('should allow normal navigation when subScope is not provided', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithRouter(<ScopesNavigationTreeLink to="/test-path" title="Test Link" id="test-id" />);
|
||||
|
||||
const link = screen.getByTestId('scopes-dashboards-test-id');
|
||||
await user.click(link);
|
||||
|
||||
// Should not call changeScopes or locationService.push when subScope is not provided
|
||||
expect(mockScopesSelectorService.changeScopes).not.toHaveBeenCalled();
|
||||
expect(mockLocationServicePush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle URL with existing query params correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockScopesDashboardsService.state.navigationScope = undefined;
|
||||
mockScopesSelectorService.state.appliedScopes = [{ scopeId: 'currentScope' }];
|
||||
|
||||
renderWithRouter(
|
||||
<ScopesNavigationTreeLink
|
||||
to="/test-path?param1=value1¶m2=value2"
|
||||
title="Test Link"
|
||||
id="test-id"
|
||||
subScope="subScope1"
|
||||
/>
|
||||
);
|
||||
|
||||
const link = screen.getByTestId('scopes-dashboards-test-id');
|
||||
await user.click(link);
|
||||
|
||||
expect(mockLocationServicePush).toHaveBeenCalled();
|
||||
const pushedUrl = mockLocationServicePush.mock.calls[0][0];
|
||||
expect(pushedUrl).toContain('scopes=subScope1');
|
||||
expect(pushedUrl).toContain('navigation_scope=currentScope');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,31 +2,76 @@ import { css, cx } from '@emotion/css';
|
||||
import { useMemo } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { GrafanaTheme2, IconName, locationUtil } from '@grafana/data';
|
||||
import { GrafanaTheme2, IconName, locationUtil, UrlQueryMap, urlUtil } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { Icon, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { useScopesServices } from '../ScopesContextProvider';
|
||||
|
||||
import { isCurrentPath, normalizePath } from './scopeNavgiationUtils';
|
||||
|
||||
export interface ScopesNavigationTreeLinkProps {
|
||||
subScope?: string;
|
||||
to: string;
|
||||
title: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function ScopesNavigationTreeLink({ to, title, id }: ScopesNavigationTreeLinkProps) {
|
||||
export function ScopesNavigationTreeLink({ subScope, to, title, id }: ScopesNavigationTreeLinkProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const linkIcon = useMemo(() => getLinkIcon(to), [to]);
|
||||
const locPathname = useLocation().pathname;
|
||||
|
||||
const services = useScopesServices();
|
||||
// Ignore query params
|
||||
const isCurrent = isCurrentPath(locPathname, to);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (subScope) {
|
||||
e.preventDefault(); // Prevent default Link navigation
|
||||
|
||||
// Set current scope to navigation scope and subScope to scope
|
||||
const currentScope = services?.scopesSelectorService?.state.appliedScopes[0]?.scopeId;
|
||||
const currentNavigationScope = services?.scopesDashboardsService?.state.navigationScope;
|
||||
|
||||
// Parse the URL to extract path and existing query params
|
||||
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);
|
||||
services?.scopesDashboardsService?.setNavigationScope(currentScope);
|
||||
}
|
||||
|
||||
// Update query params with the new subScope
|
||||
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');
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
aria-current={isCurrent ? 'page' : undefined}
|
||||
className={cx(styles.container, isCurrent && styles.current)}
|
||||
data-testid={`scopes-dashboards-${id}`}
|
||||
onClick={handleClick}
|
||||
role="treeitem"
|
||||
key={id}
|
||||
>
|
||||
|
||||
@@ -73,6 +73,7 @@ describe('ScopesSelectorService', () => {
|
||||
|
||||
dashboardsService = {
|
||||
fetchDashboards: jest.fn().mockResolvedValue(undefined),
|
||||
setNavigationScope: jest.fn(),
|
||||
state: {
|
||||
scopeNavigations: [],
|
||||
dashboards: [],
|
||||
@@ -82,6 +83,7 @@ describe('ScopesSelectorService', () => {
|
||||
forScopeNames: [],
|
||||
loading: false,
|
||||
searchQuery: '',
|
||||
navigationScope: undefined,
|
||||
},
|
||||
} as unknown as jest.Mocked<ScopesDashboardsService>;
|
||||
|
||||
@@ -395,6 +397,34 @@ describe('ScopesSelectorService', () => {
|
||||
await service.removeAllScopes();
|
||||
expect(service.state.appliedScopes).toEqual([]);
|
||||
});
|
||||
|
||||
it('should clear navigation scope when removing all scopes', async () => {
|
||||
await service.updateNode('', true, '');
|
||||
await service.selectScope('test-scope-node');
|
||||
await service.apply();
|
||||
await service.removeAllScopes();
|
||||
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation scope interaction', () => {
|
||||
it('should skip fetchDashboards when navigationScope is set', async () => {
|
||||
dashboardsService.state.navigationScope = 'navScope1';
|
||||
jest.clearAllMocks();
|
||||
|
||||
await service.changeScopes(['test-scope']);
|
||||
|
||||
expect(dashboardsService.fetchDashboards).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call fetchDashboards when navigationScope is not set', async () => {
|
||||
dashboardsService.state.navigationScope = undefined;
|
||||
jest.clearAllMocks();
|
||||
|
||||
await service.changeScopes(['test-scope']);
|
||||
|
||||
expect(dashboardsService.fetchDashboards).toHaveBeenCalledWith(['test-scope']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecentScopes', () => {
|
||||
|
||||
@@ -371,12 +371,15 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
|
||||
// Fetches both dashboards and scope navigations
|
||||
// We call this even if we have 0 scope because in that case it also closes the dashboard drawer.
|
||||
this.dashboardsService.fetchDashboards(scopes.map((s) => s.scopeId)).then(() => {
|
||||
const selectedScopeNode = scopes[0]?.scopeNodeId ? this.state.nodes[scopes[0]?.scopeNodeId] : undefined;
|
||||
if (redirectOnApply) {
|
||||
this.redirectAfterApply(selectedScopeNode);
|
||||
}
|
||||
});
|
||||
// Only fetch dashboards based on the scopes if we don't have a navigation scope set.
|
||||
if (!this.dashboardsService.state.navigationScope) {
|
||||
this.dashboardsService.fetchDashboards(scopes.map((s) => s.scopeId)).then(() => {
|
||||
const selectedScopeNode = scopes[0]?.scopeNodeId ? this.state.nodes[scopes[0]?.scopeNodeId] : undefined;
|
||||
if (redirectOnApply) {
|
||||
this.redirectAfterApply(selectedScopeNode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (scopes.length > 0) {
|
||||
const fetchedScopes = await this.apiClient.fetchMultipleScopes(scopes.map((s) => s.scopeId));
|
||||
@@ -431,7 +434,10 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
}
|
||||
};
|
||||
|
||||
public removeAllScopes = () => this.applyScopes([], false);
|
||||
public removeAllScopes = () => {
|
||||
this.applyScopes([], false);
|
||||
this.dashboardsService.setNavigationScope(undefined);
|
||||
};
|
||||
|
||||
private addRecentScopes = (scopes: Scope[], parentNode?: ScopeNode) => {
|
||||
if (scopes.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user