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:
Tobias Skarhed
2025-11-26 11:15:58 +01:00
committed by GitHub
parent 136a0eb4d6
commit 513b81a531
11 changed files with 905 additions and 52 deletions
+150 -18
View File
@@ -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&param2=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) {