diff --git a/public/app/features/scopes/ScopesService.test.ts b/public/app/features/scopes/ScopesService.test.ts index 61f85257dcf..6b360f853e3 100644 --- a/public/app/features/scopes/ScopesService.test.ts +++ b/public/app/features/scopes/ScopesService.test.ts @@ -14,15 +14,22 @@ describe('ScopesService', () => { let selectorService: jest.Mocked; let dashboardsService: jest.Mocked; let locationService: jest.Mocked; - 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; 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', () => { diff --git a/public/app/features/scopes/ScopesService.ts b/public/app/features/scopes/ScopesService.ts index 2595ff66876..36edcc94b13 100644 --- a/public/app/features/scopes/ScopesService.ts +++ b/public/app/features/scopes/ScopesService.ts @@ -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, + }); + } + }) + ); } /** diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts b/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts index 62b3b71e09d..52368b4577d 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts +++ b/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts @@ -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; beforeEach(() => { + const fetchScopeNavigationsMock = jest.fn().mockResolvedValue([]); mockApiClient = { fetchDashboards: jest.fn(), - fetchScopeNavigations: jest.fn(), + fetchScopeNavigations: fetchScopeNavigationsMock, } as unknown as jest.Mocked; 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); + }); + }); }); diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts index be6f966623e..55ea5ab737a 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts +++ b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts @@ -23,6 +23,7 @@ interface ScopesDashboardsServiceState { forScopeNames: string[]; loading: boolean; searchQuery: string; + navigationScope?: string; } export class ScopesDashboardsService extends ScopesServiceBase { @@ -56,6 +57,18 @@ export class ScopesDashboardsService extends ScopesServiceBase { + 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) { diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx b/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx index b25a3bfc55b..7e7601f3b06 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx +++ b/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx @@ -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) => ( ({ + 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 => ({ + 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( + + ); + + 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( + + ); + + 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( + + ); + + // 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + // 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( + + ); + + // 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( + + ); + + 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( + + ); + + // 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( + + ); + + 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(); + }); +}); diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx b/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx index da1f315c5e2..9d572a4d54c 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx +++ b/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx @@ -26,7 +26,7 @@ export function ScopesDashboardsTreeFolderItem({ // get scopesselector service const scopesSelectorService = useScopesServices()?.scopesSelectorService ?? undefined; - + const scopesDashboardsService = useScopesServices()?.scopesDashboardsService ?? undefined; return (
@@ -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 && (
- +
)}
diff --git a/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.test.tsx b/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.test.tsx index 3aba827d4ef..8dafc80c612 100644 --- a/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.test.tsx +++ b/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.test.tsx @@ -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({ui}); }; 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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(); + + 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( + + ); + + 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'); + }); + }); }); diff --git a/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.tsx b/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.tsx index 859b6059059..b7722edcf8c 100644 --- a/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.tsx +++ b/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.tsx @@ -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) => { + 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 ( diff --git a/public/app/features/scopes/selector/ScopesSelectorService.test.ts b/public/app/features/scopes/selector/ScopesSelectorService.test.ts index eef594d2335..57ff735f019 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.test.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.test.ts @@ -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; @@ -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', () => { diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts index 742a77ad5d4..14a16be69ac 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.ts @@ -371,12 +371,15 @@ export class ScopesSelectorService extends ScopesServiceBase 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 this.applyScopes([], false); + public removeAllScopes = () => { + this.applyScopes([], false); + this.dashboardsService.setNavigationScope(undefined); + }; private addRecentScopes = (scopes: Scope[], parentNode?: ScopeNode) => { if (scopes.length === 0) {