diff --git a/devenv/scopes/scopes-config.yaml b/devenv/scopes/scopes-config.yaml index ef800415547..66ba7b6988c 100644 --- a/devenv/scopes/scopes-config.yaml +++ b/devenv/scopes/scopes-config.yaml @@ -48,6 +48,23 @@ scopes: operator: equals value: kids + # This scope appears in multiple places in the tree. + # The defaultPath determines which path is shown when this scope is selected + # (e.g., from a URL or programmatically), even if another path also links to it. + shared-service: + title: Shared Service + # Path from the root node down to the direct scopeNode. + # Node names are hierarchical (parent-child), so use the full names. + # This points to: gdev-scopes > production > shared-service-prod + defaultPath: + - gdev-scopes + - gdev-scopes-production + - gdev-scopes-production-shared-service-prod + filters: + - key: service + operator: equals + value: shared + tree: gdev-scopes: title: gdev-scopes @@ -68,6 +85,13 @@ tree: nodeType: leaf linkId: app2 linkType: scope + # This node links to 'shared-service' scope. + # The scope's defaultPath points here (production > gdev-scopes). + shared-service-prod: + title: Shared Service + nodeType: leaf + linkId: shared-service + linkType: scope test-cases: title: Test cases nodeType: container @@ -83,6 +107,15 @@ tree: nodeType: leaf linkId: test-case-2 linkType: scope + # This node also links to the same 'shared-service' scope. + # However, the scope's defaultPath points to the production path, + # so selecting this scope will expand the tree to production > shared-service-prod. + shared-service-test: + title: Shared Service (also in Production) + subTitle: defaultPath points to Production + nodeType: leaf + linkId: shared-service + linkType: scope test-case-redirect: title: Test case with redirect nodeType: leaf diff --git a/devenv/scopes/scopes.go b/devenv/scopes/scopes.go index e3f4de80d21..4487c5b69ef 100644 --- a/devenv/scopes/scopes.go +++ b/devenv/scopes/scopes.go @@ -51,8 +51,9 @@ type Config struct { // ScopeConfig is used for YAML parsing - converts to v0alpha1.ScopeSpec type ScopeConfig struct { - Title string `yaml:"title"` - Filters []ScopeFilterConfig `yaml:"filters"` + Title string `yaml:"title"` + DefaultPath []string `yaml:"defaultPath,omitempty"` + Filters []ScopeFilterConfig `yaml:"filters"` } // ScopeFilterConfig is used for YAML parsing - converts to v0alpha1.ScopeFilter @@ -116,9 +117,20 @@ func convertScopeSpec(cfg ScopeConfig) v0alpha1.ScopeSpec { for i, f := range cfg.Filters { filters[i] = convertFilter(f) } + + // Prefix defaultPath elements with the gdev prefix + var defaultPath []string + if len(cfg.DefaultPath) > 0 { + defaultPath = make([]string, len(cfg.DefaultPath)) + for i, p := range cfg.DefaultPath { + defaultPath[i] = prefix + "-" + p + } + } + return v0alpha1.ScopeSpec{ - Title: cfg.Title, - Filters: filters, + Title: cfg.Title, + DefaultPath: defaultPath, + Filters: filters, } } diff --git a/public/app/features/scopes/ScopesApiClient.test.ts b/public/app/features/scopes/ScopesApiClient.test.ts new file mode 100644 index 00000000000..979da40b6ce --- /dev/null +++ b/public/app/features/scopes/ScopesApiClient.test.ts @@ -0,0 +1,362 @@ +import { getBackendSrv, config } from '@grafana/runtime'; + +import { ScopesApiClient } from './ScopesApiClient'; + +// Mock the runtime dependencies +jest.mock('@grafana/runtime', () => ({ + getBackendSrv: jest.fn(), + config: { + featureToggles: { + useMultipleScopeNodesEndpoint: true, + useScopeSingleNodeEndpoint: true, + }, + }, +})); + +jest.mock('@grafana/api-clients', () => ({ + getAPIBaseURL: jest.fn().mockReturnValue('/apis/scope.grafana.app/v0alpha1'), +})); + +describe('ScopesApiClient', () => { + let apiClient: ScopesApiClient; + let mockBackendSrv: jest.Mocked<{ get: jest.Mock }>; + + beforeEach(() => { + mockBackendSrv = { + get: jest.fn(), + }; + (getBackendSrv as jest.Mock).mockReturnValue(mockBackendSrv); + apiClient = new ScopesApiClient(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('fetchMultipleScopeNodes', () => { + it('should fetch multiple nodes by names', async () => { + const mockNodes = [ + { + metadata: { name: 'node-1' }, + spec: { nodeType: 'container', title: 'Node 1', parentName: '' }, + }, + { + metadata: { name: 'node-2' }, + spec: { nodeType: 'leaf', title: 'Node 2', parentName: 'node-1' }, + }, + ]; + + mockBackendSrv.get.mockResolvedValue({ items: mockNodes }); + + const result = await apiClient.fetchMultipleScopeNodes(['node-1', 'node-2']); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + names: ['node-1', 'node-2'], + }); + expect(result).toEqual(mockNodes); + }); + + it('should return empty array when names array is empty', async () => { + const result = await apiClient.fetchMultipleScopeNodes([]); + + expect(mockBackendSrv.get).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it('should return empty array when feature toggle is disabled', async () => { + config.featureToggles.useMultipleScopeNodesEndpoint = false; + + const result = await apiClient.fetchMultipleScopeNodes(['node-1']); + + expect(mockBackendSrv.get).not.toHaveBeenCalled(); + expect(result).toEqual([]); + + // Restore feature toggle + config.featureToggles.useMultipleScopeNodesEndpoint = true; + }); + + it('should handle API errors gracefully', async () => { + mockBackendSrv.get.mockRejectedValue(new Error('Network error')); + + const result = await apiClient.fetchMultipleScopeNodes(['node-1']); + + expect(result).toEqual([]); + }); + + it('should handle response with no items field', async () => { + mockBackendSrv.get.mockResolvedValue({}); + + const result = await apiClient.fetchMultipleScopeNodes(['node-1']); + + expect(result).toEqual([]); + }); + + it('should handle response with null items', async () => { + mockBackendSrv.get.mockResolvedValue({ items: null }); + + const result = await apiClient.fetchMultipleScopeNodes(['node-1']); + + expect(result).toEqual([]); + }); + + it('should handle large arrays of node names', async () => { + const names = Array.from({ length: 100 }, (_, i) => `node-${i}`); + const mockNodes = names.map((name) => ({ + metadata: { name }, + spec: { nodeType: 'leaf', title: name, parentName: '' }, + })); + + mockBackendSrv.get.mockResolvedValue({ items: mockNodes }); + + const result = await apiClient.fetchMultipleScopeNodes(names); + + expect(result).toEqual(mockNodes); + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + names, + }); + }); + + it('should pass through node names exactly as provided', async () => { + const names = ['node-with-special-chars_123', 'node.with.dots', 'node-with-dashes']; + mockBackendSrv.get.mockResolvedValue({ items: [] }); + + await apiClient.fetchMultipleScopeNodes(names); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + names, + }); + }); + }); + + describe('fetchScopeNode', () => { + it('should fetch a single scope node by ID', async () => { + const mockNode = { + metadata: { name: 'test-node' }, + spec: { nodeType: 'leaf', title: 'Test Node', parentName: 'parent' }, + }; + + mockBackendSrv.get.mockResolvedValue(mockNode); + + const result = await apiClient.fetchScopeNode('test-node'); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/scopenodes/test-node'); + expect(result).toEqual(mockNode); + }); + + it('should return undefined when feature toggle is disabled', async () => { + config.featureToggles.useScopeSingleNodeEndpoint = false; + + const result = await apiClient.fetchScopeNode('test-node'); + + expect(mockBackendSrv.get).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + + // Restore feature toggle + config.featureToggles.useScopeSingleNodeEndpoint = true; + }); + + it('should return undefined on API error', async () => { + mockBackendSrv.get.mockRejectedValue(new Error('Not found')); + + const result = await apiClient.fetchScopeNode('non-existent'); + + expect(result).toBeUndefined(); + }); + }); + + describe('fetchNodes', () => { + it('should fetch nodes with parent filter', async () => { + const mockNodes = [ + { + metadata: { name: 'child-1' }, + spec: { nodeType: 'leaf', title: 'Child 1', parentName: 'parent' }, + }, + ]; + + mockBackendSrv.get.mockResolvedValue({ items: mockNodes }); + + const result = await apiClient.fetchNodes({ parent: 'parent' }); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + parent: 'parent', + query: undefined, + limit: 1000, + }); + expect(result).toEqual(mockNodes); + }); + + it('should fetch nodes with query filter', async () => { + const mockNodes = [ + { + metadata: { name: 'matching-node' }, + spec: { nodeType: 'leaf', title: 'Matching Node', parentName: '' }, + }, + ]; + + mockBackendSrv.get.mockResolvedValue({ items: mockNodes }); + + const result = await apiClient.fetchNodes({ query: 'matching' }); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + parent: undefined, + query: 'matching', + limit: 1000, + }); + expect(result).toEqual(mockNodes); + }); + + it('should respect custom limit', async () => { + mockBackendSrv.get.mockResolvedValue({ items: [] }); + + await apiClient.fetchNodes({ limit: 50 }); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + parent: undefined, + query: undefined, + limit: 50, + }); + }); + + it('should throw error for invalid limit (too small)', async () => { + await expect(apiClient.fetchNodes({ limit: 0 })).rejects.toThrow('Limit must be between 1 and 10000'); + }); + + it('should throw error for invalid limit (too large)', async () => { + await expect(apiClient.fetchNodes({ limit: 10001 })).rejects.toThrow('Limit must be between 1 and 10000'); + }); + + it('should use default limit of 1000 when not specified', async () => { + mockBackendSrv.get.mockResolvedValue({ items: [] }); + + await apiClient.fetchNodes({}); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + parent: undefined, + query: undefined, + limit: 1000, + }); + }); + + it('should return empty array on API error', async () => { + mockBackendSrv.get.mockRejectedValue(new Error('API Error')); + + const result = await apiClient.fetchNodes({ parent: 'test' }); + + expect(result).toEqual([]); + }); + }); + + describe('fetchScope', () => { + it('should fetch a scope by name', async () => { + const mockScope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + filters: [], + }, + }; + + mockBackendSrv.get.mockResolvedValue(mockScope); + + const result = await apiClient.fetchScope('test-scope'); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/scopes/test-scope'); + expect(result).toEqual(mockScope); + }); + + it('should return undefined on error', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mockBackendSrv.get.mockRejectedValue(new Error('Not found')); + + const result = await apiClient.fetchScope('non-existent'); + + expect(result).toBeUndefined(); + consoleErrorSpy.mockRestore(); + }); + + it('should log error to console', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const error = new Error('Not found'); + mockBackendSrv.get.mockRejectedValue(error); + + await apiClient.fetchScope('non-existent'); + + expect(consoleErrorSpy).toHaveBeenCalledWith(error); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('fetchMultipleScopes', () => { + it('should fetch multiple scopes in parallel', async () => { + const mockScopes = [ + { + metadata: { name: 'scope-1' }, + spec: { title: 'Scope 1', filters: [] }, + }, + { + metadata: { name: 'scope-2' }, + spec: { title: 'Scope 2', filters: [] }, + }, + ]; + + mockBackendSrv.get.mockResolvedValueOnce(mockScopes[0]).mockResolvedValueOnce(mockScopes[1]); + + const result = await apiClient.fetchMultipleScopes(['scope-1', 'scope-2']); + + expect(mockBackendSrv.get).toHaveBeenCalledTimes(2); + expect(result).toEqual(mockScopes); + }); + + it('should filter out undefined scopes', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const mockScope = { + metadata: { name: 'scope-1' }, + spec: { title: 'Scope 1', filters: [] }, + }; + + mockBackendSrv.get.mockResolvedValueOnce(mockScope).mockRejectedValueOnce(new Error('Not found')); + + const result = await apiClient.fetchMultipleScopes(['scope-1', 'non-existent']); + + expect(result).toEqual([mockScope]); + consoleErrorSpy.mockRestore(); + }); + + it('should return empty array when no scopes provided', async () => { + const result = await apiClient.fetchMultipleScopes([]); + + expect(result).toEqual([]); + expect(mockBackendSrv.get).not.toHaveBeenCalled(); + }); + }); + + describe('performance considerations', () => { + it('should make single batched request with fetchMultipleScopeNodes', async () => { + mockBackendSrv.get.mockResolvedValue({ items: [] }); + + await apiClient.fetchMultipleScopeNodes(['node-1', 'node-2', 'node-3', 'node-4', 'node-5']); + + // Should make exactly 1 API call + expect(mockBackendSrv.get).toHaveBeenCalledTimes(1); + }); + + it('should make N sequential requests with fetchScopeNode (old pattern)', async () => { + mockBackendSrv.get.mockResolvedValue({ + metadata: { name: 'test' }, + spec: { nodeType: 'leaf', title: 'Test', parentName: '' }, + }); + + // Simulate old pattern of fetching nodes one by one + await Promise.all([ + apiClient.fetchScopeNode('node-1'), + apiClient.fetchScopeNode('node-2'), + apiClient.fetchScopeNode('node-3'), + apiClient.fetchScopeNode('node-4'), + apiClient.fetchScopeNode('node-5'), + ]); + + // Should make 5 separate API calls + expect(mockBackendSrv.get).toHaveBeenCalledTimes(5); + }); + }); +}); diff --git a/public/app/features/scopes/ScopesService.test.ts b/public/app/features/scopes/ScopesService.test.ts index fdc0c8bd598..a90de0ef95b 100644 --- a/public/app/features/scopes/ScopesService.test.ts +++ b/public/app/features/scopes/ScopesService.test.ts @@ -1,5 +1,6 @@ import { BehaviorSubject } from 'rxjs'; +import { ScopeSpecFilter } from '@grafana/data'; import { LocationService } from '@grafana/runtime'; import { ScopesService } from './ScopesService'; @@ -16,8 +17,20 @@ describe('ScopesService', () => { let locationService: jest.Mocked; let selectorStateSubscription: | (( - state: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> }, - prevState: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> } + state: { + appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }>; + scopes?: Record< + string, + { metadata: { name: string }; spec: { title: string; defaultPath?: string[]; filters: ScopeSpecFilter[] } } + >; + }, + prevState: { + appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }>; + scopes?: Record< + string, + { metadata: { name: string }; spec: { title: string; defaultPath?: string[]; filters: ScopeSpecFilter[] } } + >; + } ) => void) | undefined; let dashboardsStateSubscription: @@ -274,9 +287,11 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], + scopes: {}, }, { appliedScopes: [], + scopes: {}, } ); @@ -298,9 +313,11 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1', parentNodeId: 'parent1' }], + scopes: {}, }, { appliedScopes: [], + scopes: {}, } ); @@ -320,9 +337,11 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node2' }], + scopes: {}, }, { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], + scopes: {}, } ); @@ -344,9 +363,11 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1' }], + scopes: {}, }, { appliedScopes: [], + scopes: {}, } ); @@ -370,15 +391,171 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], + scopes: {}, }, { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], + scopes: {}, } ); expect(locationService.partial).not.toHaveBeenCalled(); }); + describe('defaultPath support', () => { + it('should extract scope_node from defaultPath when available', () => { + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); + } + + selectorStateSubscription( + { + appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'old-node' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: ['', 'parent-node', 'correct-node'], + filters: [], + }, + }, + }, + }, + { + appliedScopes: [], + scopes: {}, + } + ); + + // Should use 'correct-node' from defaultPath, not 'old-node' from appliedScopes + expect(locationService.partial).toHaveBeenCalledWith( + { + scopes: ['scope1'], + scope_node: 'correct-node', + scope_parent: null, + }, + true + ); + }); + + it('should fallback to scopeNodeId when defaultPath is not available', () => { + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); + } + + selectorStateSubscription( + { + appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'fallback-node' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + filters: [], + }, + }, + }, + }, + { + appliedScopes: [], + scopes: {}, + } + ); + + // Should fallback to scopeNodeId from appliedScopes + expect(locationService.partial).toHaveBeenCalledWith( + { + scopes: ['scope1'], + scope_node: 'fallback-node', + scope_parent: null, + }, + true + ); + }); + + it('should handle empty defaultPath gracefully', () => { + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); + } + + selectorStateSubscription( + { + appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'fallback-node' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: [], + filters: [], + }, + }, + }, + }, + { + appliedScopes: [], + scopes: {}, + } + ); + + // Should fallback to scopeNodeId when defaultPath is empty + expect(locationService.partial).toHaveBeenCalledWith( + { + scopes: ['scope1'], + scope_node: 'fallback-node', + scope_parent: null, + }, + true + ); + }); + + it('should detect changes in defaultPath-derived scopeNodeId', () => { + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); + } + + selectorStateSubscription( + { + appliedScopes: [{ scopeId: 'scope1' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: ['', 'parent', 'new-node'], + filters: [], + }, + }, + }, + }, + { + appliedScopes: [{ scopeId: 'scope1' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: ['', 'parent', 'old-node'], + filters: [], + }, + }, + }, + } + ); + + // Should detect the change in defaultPath-derived scopeNodeId + expect(locationService.partial).toHaveBeenCalledWith( + { + scopes: ['scope1'], + scope_node: 'new-node', + scope_parent: null, + }, + true + ); + }); + }); + it('should write navigation_scope to URL when navigationScope changes', () => { if (!dashboardsStateSubscription) { throw new Error('dashboardsStateSubscription not set'); @@ -622,6 +799,30 @@ describe('ScopesService', () => { true ); }); + + it('should use defaultPath for scope_node when enabling scopes', () => { + selectorService.state.appliedScopes = [{ scopeId: 'scope1', scopeNodeId: 'old-node' }]; + selectorService.state.scopes = { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: ['', 'parent', 'correct-node-from-defaultPath'], + filters: [], + }, + }, + }; + + service.setEnabled(true); + + // Should use defaultPath instead of scopeNodeId from appliedScopes + expect(locationService.partial).toHaveBeenCalledWith( + expect.objectContaining({ + scope_node: 'correct-node-from-defaultPath', + }), + true + ); + }); }); describe('back/forward navigation handling', () => { diff --git a/public/app/features/scopes/ScopesService.ts b/public/app/features/scopes/ScopesService.ts index c92d6328749..8dd40fd953f 100644 --- a/public/app/features/scopes/ScopesService.ts +++ b/public/app/features/scopes/ScopesService.ts @@ -151,12 +151,26 @@ export class ScopesService implements ScopesContextValue { // Update the URL based on change in the scopes state this.subscriptions.push( selectorService.subscribeToState((state, prevState) => { - const oldScopeNodeId = prevState.appliedScopes[0]?.scopeNodeId; - const newScopeNodeId = state.appliedScopes[0]?.scopeNodeId; - const oldScopeNames = prevState.appliedScopes.map((scope) => scope.scopeId); const newScopeNames = state.appliedScopes.map((scope) => scope.scopeId); + // Extract scopeNodeId from defaultPath when available + const getScopeNodeId = (appliedScopes: typeof state.appliedScopes, scopes: typeof state.scopes) => { + const firstScope = appliedScopes[0]; + if (!firstScope) { + return undefined; + } + const scope = scopes[firstScope.scopeId]; + // Prefer defaultPath when available + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + return scope.spec.defaultPath[scope.spec.defaultPath.length - 1]; + } + return firstScope.scopeNodeId; + }; + + const oldScopeNodeId = getScopeNodeId(prevState.appliedScopes, prevState.scopes); + const newScopeNodeId = getScopeNodeId(state.appliedScopes, state.scopes); + const scopesChanged = !isEqual(oldScopeNames, newScopeNames); const scopeNodeChanged = oldScopeNodeId !== newScopeNodeId; @@ -230,7 +244,7 @@ export class ScopesService implements ScopesContextValue { if (this.state.enabled !== enabled) { this.updateState({ enabled }); if (enabled) { - const scopeNodeId = this.selectorService.state.appliedScopes[0]?.scopeNodeId; + const scopeNodeId = this.getScopeNodeIdForUrl(); this.locationService.partial( { scopes: this.selectorService.state.appliedScopes.map((s) => s.scopeId), @@ -243,6 +257,29 @@ export class ScopesService implements ScopesContextValue { } }; + /** + * Extracts the scopeNodeId for URL syncing, preferring defaultPath when available. + * When a scope has defaultPath, that is the source of truth for the node ID. + * @private + */ + private getScopeNodeIdForUrl(): string | undefined { + const firstScope = this.selectorService.state.appliedScopes[0]; + if (!firstScope) { + return undefined; + } + + const scope = this.selectorService.state.scopes[firstScope.scopeId]; + + // Prefer scopeNodeId from defaultPath if available (most reliable source) + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + // Extract scopeNodeId from the last element of defaultPath + return scope.spec.defaultPath[scope.spec.defaultPath.length - 1]; + } + + // Fallback to next in priority order: scopeNodeId from appliedScopes + return firstScope.scopeNodeId; + } + /** * Returns observable that emits when relevant parts of the selectorService state change. * @private diff --git a/public/app/features/scopes/selector/ScopesInput.tsx b/public/app/features/scopes/selector/ScopesInput.tsx index a180c523e6b..205c3b0356f 100644 --- a/public/app/features/scopes/selector/ScopesInput.tsx +++ b/public/app/features/scopes/selector/ScopesInput.tsx @@ -31,13 +31,33 @@ export function ScopesInput({ onInputClick, onRemoveAllClick, }: ScopesInputProps) { - const scopeNodeId = appliedScopes[0]?.scopeNodeId; + const firstScope = appliedScopes[0]; + const scope = scopes[firstScope?.scopeId]; const styles = useStyles2(getStyles); - const parentNodeIdFromRecentScopes = appliedScopes[0]?.parentNodeId; // This is only set from recent scopes TODO: remove after recent scopes refactor + + // Prefer scopeNodeId from defaultPath if available (most reliable source) + let scopeNodeId: string | undefined; + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + // Extract scopeNodeId from the last element of defaultPath + scopeNodeId = scope.spec.defaultPath[scope.spec.defaultPath.length - 1]; + } else { + // Fallback to next in priority order: scopeNodeId from appliedScopes + scopeNodeId = firstScope?.scopeNodeId; + } + const { node: scopeNode, isLoading: scopeNodeLoading } = useScopeNode(scopeNodeId); - // Get parent from scope node if available, otherwise fallback to parent - const parentNodeId = scopeNode?.spec.parentName ?? parentNodeIdFromRecentScopes; + // Prefer parentNodeId from defaultPath if available + let parentNodeId: string | undefined; + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 1) { + // Extract parentNodeId from the second-to-last element of defaultPath + parentNodeId = scope.spec.defaultPath[scope.spec.defaultPath.length - 2]; + } else { + // Fallback to parent from scope node or recent scopes + const parentNodeIdFromRecentScopes = firstScope?.parentNodeId; + parentNodeId = scopeNode?.spec.parentName ?? parentNodeIdFromRecentScopes; + } + const { node: parentNode, isLoading: parentNodeLoading } = useScopeNode(parentNodeId); // Prioritize scope node subtitle over parent node title @@ -99,16 +119,31 @@ export function ScopesInput({ ); } -const getScopesPath = (appliedScopes: SelectedScope[], nodes: NodesMap) => { +const getScopesPath = ( + appliedScopes: SelectedScope[], + nodes: NodesMap, + defaultPath?: string[] +): string[] | undefined => { let nicePath: string[] | undefined; - if (appliedScopes.length > 0 && appliedScopes[0].scopeNodeId) { - let path = getPathOfNode(appliedScopes[0].scopeNodeId, nodes); - // Get reed of empty root section and the actual scope node - path = path.slice(1, -1); + if (appliedScopes.length > 0) { + const firstScope = appliedScopes[0]; - // We may not have all the nodes in path loaded - nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p); + // Prefer defaultPath from scope metadata + if (defaultPath && defaultPath.length > 1) { + // Get all nodes except the last one (which is the scope itself) + const pathNodeIds = defaultPath.slice(0, -1); + nicePath = pathNodeIds.map((nodeId) => nodes[nodeId]?.spec.title).filter((title) => title); + } + // Fallback to walking the node tree + else if (firstScope.scopeNodeId) { + let path = getPathOfNode(firstScope.scopeNodeId, nodes); + // Get rid of empty root section and the actual scope node + path = path.slice(1, -1); + + // We may not have all the nodes in path loaded + nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p); + } } return nicePath; @@ -127,7 +162,9 @@ function ScopesTooltip({ nodes, scopes, appliedScopes, onRemoveAllClick, disable return t('scopes.selector.input.tooltip', 'Select scope'); } - const nicePath = getScopesPath(appliedScopes, nodes); + const firstScope = appliedScopes[0]; + const scope = scopes[firstScope?.scopeId]; + const nicePath = getScopesPath(appliedScopes, nodes, scope?.spec.defaultPath); const scopeNames = appliedScopes.map((s) => { if (s.scopeNodeId) { return nodes[s.scopeNodeId]?.spec.title || s.scopeNodeId; diff --git a/public/app/features/scopes/selector/ScopesSelectorService.test.ts b/public/app/features/scopes/selector/ScopesSelectorService.test.ts index 58ddb31a92f..e2102db5db7 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.test.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.test.ts @@ -77,7 +77,14 @@ describe('ScopesSelectorService', () => { }), fetchDashboards: jest.fn().mockResolvedValue([]), fetchScopeNavigations: jest.fn().mockResolvedValue([]), - fetchScopeNode: jest.fn().mockResolvedValue(mockNode), + fetchScopeNode: jest.fn().mockImplementation((id: string) => { + // Return undefined for empty string (root node) + if (id === '') { + return Promise.resolve(undefined); + } + return Promise.resolve(mockNode); + }), + fetchMultipleScopeNodes: jest.fn().mockResolvedValue([]), } as unknown as jest.Mocked; dashboardsService = { @@ -435,7 +442,7 @@ describe('ScopesSelectorService', () => { await service.filterNode('', ''); await service.selectScope('test-scope-node'); await service.apply(); - await service.removeAllScopes(); + service.removeAllScopes(); expect(service.state.appliedScopes).toEqual([]); }); @@ -443,7 +450,7 @@ describe('ScopesSelectorService', () => { await service.filterNode('', ''); await service.selectScope('test-scope-node'); await service.apply(); - await service.removeAllScopes(); + service.removeAllScopes(); expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined, undefined, undefined); }); }); @@ -1236,4 +1243,1076 @@ describe('ScopesSelectorService', () => { expect(locationService.push).toHaveBeenCalledWith('/d/dashboard1'); }); }); + + // Mock data for defaultPath and path helper tests + const regionNode: ScopeNode = { + metadata: { name: 'region-us-west' }, + spec: { + nodeType: 'container', + title: 'US West', + parentName: '', + linkType: undefined, + linkId: undefined, + }, + }; + + const countryNode: ScopeNode = { + metadata: { name: 'country-usa' }, + spec: { + nodeType: 'container', + title: 'USA', + parentName: 'region-us-west', + linkType: undefined, + linkId: undefined, + }, + }; + + const cityNode: ScopeNode = { + metadata: { name: 'city-seattle' }, + spec: { + nodeType: 'container', + title: 'Seattle', + parentName: 'country-usa', + linkType: undefined, + linkId: undefined, + }, + }; + + const datacenterNode: ScopeNode = { + metadata: { name: 'datacenter-sea-1' }, + spec: { + nodeType: 'leaf', + title: 'SEA-1', + parentName: 'city-seattle', + linkType: 'scope', + linkId: 'scope-sea-1', + }, + }; + + const scopeWithDefaultPath: Scope = { + metadata: { name: 'scope-sea-1' }, + spec: { + title: 'Seattle Datacenter 1', + defaultPath: ['region-us-west', 'country-usa', 'city-seattle', 'datacenter-sea-1'], + filters: [], + }, + }; + + const scopeWithoutDefaultPath: Scope = { + metadata: { name: 'scope-no-path' }, + spec: { + title: 'No Path Scope', + filters: [], + }, + }; + + const parentNode: ScopeNode = { + metadata: { name: 'parent' }, + spec: { + nodeType: 'container', + title: 'Parent', + parentName: '', + linkType: undefined, + linkId: undefined, + }, + }; + + const childNode: ScopeNode = { + metadata: { name: 'child' }, + spec: { + nodeType: 'leaf', + title: 'Child', + parentName: 'parent', + linkType: 'scope', + linkId: 'test-scope', + }, + }; + + const grandchildNode: ScopeNode = { + metadata: { name: 'grandchild' }, + spec: { + nodeType: 'leaf', + title: 'Grandchild', + parentName: 'child', + linkType: 'scope', + linkId: 'test-scope-2', + }, + }; + + /* eslint-disable @typescript-eslint/no-explicit-any */ + // Tests for defaultPath functionality + // Note: Tests access protected updateState method via (service as any) casting to set up test state + describe('getScopeNodes', () => { + it('should return cached nodes when available', async () => { + // Pre-populate cache + (service as any).updateState({ + nodes: { + 'region-us-west': regionNode, + 'country-usa': countryNode, + }, + }); + + const result = await service.getScopeNodes(['region-us-west', 'country-usa']); + + expect(result).toEqual([regionNode, countryNode]); + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + + it('should fetch only non-cached nodes', async () => { + // Pre-populate cache with one node + (service as any).updateState({ + nodes: { + 'region-us-west': regionNode, + }, + }); + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([countryNode]); + + const result = await service.getScopeNodes(['region-us-west', 'country-usa']); + + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith(['country-usa']); + expect(result).toEqual([regionNode, countryNode]); + }); + + it('should maintain order of requested nodes', async () => { + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([cityNode, countryNode, regionNode]); + + const result = await service.getScopeNodes(['region-us-west', 'country-usa', 'city-seattle']); + + expect(result).toEqual([regionNode, countryNode, cityNode]); + }); + + it('should update state with fetched nodes', async () => { + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([regionNode, countryNode]); + + await service.getScopeNodes(['region-us-west', 'country-usa']); + + expect(service.state.nodes).toEqual({ + 'region-us-west': regionNode, + 'country-usa': countryNode, + }); + }); + + it('should handle empty array input', async () => { + const result = await service.getScopeNodes([]); + + expect(result).toEqual([]); + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + + it('should filter out undefined nodes', async () => { + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([]); + + const result = await service.getScopeNodes(['non-existent-node']); + + expect(result).toEqual([]); + }); + }); + + describe('resolvePathToRoot with defaultPath', () => { + beforeEach(() => { + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should use defaultPath when scope has it defined', async () => { + // Pre-populate scope cache + (service as any).updateState({ + scopes: { + 'scope-sea-1': scopeWithDefaultPath, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-sea-1'); + + // Should fetch all nodes in defaultPath at once + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith([ + 'region-us-west', + 'country-usa', + 'city-seattle', + 'datacenter-sea-1', + ]); + expect(result.path).toEqual([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should fall back to recursive path walking when no scopeId provided', async () => { + // Setup nodes in cache for recursive walking + (service as any).updateState({ + nodes: { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree); + + expect(result.path).toEqual([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should fall back when scope has no defaultPath', async () => { + (service as any).updateState({ + scopes: { + 'scope-no-path': scopeWithoutDefaultPath, + }, + nodes: { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-no-path'); + + expect(result.path).toEqual([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should insert path nodes into tree', async () => { + (service as any).updateState({ + scopes: { + 'scope-sea-1': scopeWithDefaultPath, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-sea-1'); + + expect(result.tree.children?.['region-us-west']).toBeDefined(); + expect(result.tree.children?.['region-us-west']?.children?.['country-usa']).toBeDefined(); + expect( + result.tree.children?.['region-us-west']?.children?.['country-usa']?.children?.['city-seattle'] + ).toBeDefined(); + }); + }); + + describe('applyScopes with defaultPath pre-fetching', () => { + it('should pre-fetch all nodes from defaultPath when applying scopes', async () => { + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithDefaultPath]); + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode]); + + await service.changeScopes(['scope-sea-1']); + + // Should batch fetch all nodes in defaultPath + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith([ + 'region-us-west', + 'country-usa', + 'city-seattle', + 'datacenter-sea-1', + ]); + + // All nodes should be in cache + expect(service.state.nodes['region-us-west']).toEqual(regionNode); + expect(service.state.nodes['country-usa']).toEqual(countryNode); + expect(service.state.nodes['city-seattle']).toEqual(cityNode); + expect(service.state.nodes['datacenter-sea-1']).toEqual(datacenterNode); + }); + + it("should only pre-fetch the first scope's defaultPath", async () => { + const scope2: Scope = { + metadata: { name: 'scope-2' }, + spec: { + title: 'Scope 2', + defaultPath: ['region-us-west', 'country-usa', 'city-portland', 'datacenter-pdx-1'], + filters: [], + }, + }; + + const portlandNode: ScopeNode = { + metadata: { name: 'city-portland' }, + spec: { + nodeType: 'container', + title: 'Portland', + parentName: 'country-usa', + linkType: undefined, + linkId: undefined, + }, + }; + + const pdxDatacenterNode: ScopeNode = { + metadata: { name: 'datacenter-pdx-1' }, + spec: { + nodeType: 'leaf', + title: 'PDX-1', + parentName: 'city-portland', + linkType: 'scope', + linkId: 'scope-2', + }, + }; + + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithDefaultPath, scope2]); + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode, portlandNode, pdxDatacenterNode]); + + await service.changeScopes(['scope-sea-1', 'scope-2']); + + // Should only fetch the first scope's defaultPath (not the second scope's) + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith([ + 'region-us-west', + 'country-usa', + 'city-seattle', + 'datacenter-sea-1', + ]); + }); + + it('should not fetch when scopes have no defaultPath', async () => { + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithoutDefaultPath]); + + await service.changeScopes(['scope-no-path']); + + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + + it('should handle empty defaultPath array', async () => { + const scopeWithEmptyPath: Scope = { + metadata: { name: 'scope-empty' }, + spec: { + title: 'Scope Empty', + defaultPath: [], + filters: [], + }, + }; + + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithEmptyPath]); + + await service.changeScopes(['scope-empty']); + + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + }); + + describe('open selector with defaultPath expansion', () => { + beforeEach(() => { + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + // Return children based on parent + if (options.parent === '') { + return Promise.resolve([regionNode]); + } else if (options.parent === 'region-us-west') { + return Promise.resolve([countryNode]); + } else if (options.parent === 'country-usa') { + return Promise.resolve([cityNode]); + } else if (options.parent === 'city-seattle') { + return Promise.resolve([datacenterNode]); + } + return Promise.resolve([]); + }); + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should expand to defaultPath when opening selector with applied scope', async () => { + // Apply a scope with defaultPath + (service as any).updateState({ + scopes: { 'scope-sea-1': scopeWithDefaultPath }, + appliedScopes: [{ scopeId: 'scope-sea-1', scopeNodeId: 'datacenter-sea-1' }], + selectedScopes: [{ scopeId: 'scope-sea-1', scopeNodeId: 'datacenter-sea-1' }], + }); + + await service.open(); + + // Should fetch all nodes in the path + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalled(); + + // Tree should be expanded to show the path + expect(service.state.tree.children?.['region-us-west']?.expanded).toBe(true); + expect(service.state.tree.children?.['region-us-west']?.children?.['country-usa']?.expanded).toBe(true); + expect( + service.state.tree.children?.['region-us-west']?.children?.['country-usa']?.children?.['city-seattle']?.expanded + ).toBe(true); + }); + + it('should fall back to parentNodeId when scope has no defaultPath', async () => { + // Pre-populate nodes for fallback behavior + (service as any).updateState({ + scopes: { 'scope-no-path': scopeWithoutDefaultPath }, + nodes: { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }, + appliedScopes: [{ scopeId: 'scope-no-path', scopeNodeId: 'datacenter-sea-1', parentNodeId: 'city-seattle' }], + selectedScopes: [{ scopeId: 'scope-no-path', scopeNodeId: 'datacenter-sea-1', parentNodeId: 'city-seattle' }], + }); + + await service.open(); + + // Should still expand, but using parentNodeId logic + expect(service.state.opened).toBe(true); + }); + + it('should handle opening selector when scope is not yet loaded', async () => { + (service as any).updateState({ + appliedScopes: [{ scopeId: 'scope-sea-1' }], + selectedScopes: [{ scopeId: 'scope-sea-1' }], + }); + + await service.open(); + + // Should not crash, just open with root nodes + expect(service.state.opened).toBe(true); + }); + }); + + describe('performance improvements', () => { + it('should make only 1 API call for deep hierarchy with defaultPath', async () => { + (service as any).updateState({ + scopes: { 'scope-sea-1': scopeWithDefaultPath }, + }); + + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode]); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-sea-1'); + + // Should make exactly 1 API call + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledTimes(1); + }); + + it('should make N API calls for deep hierarchy without defaultPath (old behavior)', async () => { + // This test documents the old recursive behavior for comparison + apiClient.fetchScopeNode = jest.fn().mockImplementation((id: string) => { + const nodeMap: Record = { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }; + return Promise.resolve(nodeMap[id]); + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + await service.resolvePathToRoot('datacenter-sea-1', tree); + + // Would make 4 sequential calls in the old implementation + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('datacenter-sea-1'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('city-seattle'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('country-usa'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('region-us-west'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledTimes(4); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle defaultPath with missing nodes gracefully', async () => { + (service as any).updateState({ + scopes: { 'scope-sea-1': scopeWithDefaultPath }, + }); + + // API returns fewer nodes than requested + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([regionNode, countryNode]); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-sea-1'); + + // Should handle partial path gracefully + expect(result.path).toEqual([regionNode, countryNode]); + }); + + it('should handle API errors during batch fetch', async () => { + (service as any).updateState({ + scopes: { 'scope-sea-1': scopeWithDefaultPath }, + }); + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([]); + + await service.changeScopes(['scope-sea-1']); + + // Should not crash, state should be consistent + expect(service.state.appliedScopes).toEqual([{ scopeId: 'scope-sea-1' }]); + }); + + it('should deduplicate node IDs in defaultPath', async () => { + const scopeWithDuplicates: Scope = { + metadata: { name: 'scope-dupe' }, + spec: { + title: 'Scope with Duplicates', + defaultPath: ['region-us-west', 'country-usa', 'region-us-west', 'country-usa'], + filters: [], + }, + }; + + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithDuplicates]); + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([regionNode, countryNode]); + + await service.changeScopes(['scope-dupe']); + + // Should only fetch unique nodes + const calledWith = apiClient.fetchMultipleScopeNodes.mock.calls[0][0]; + const uniqueNodes = [...new Set(calledWith)]; + expect(calledWith.length).toBe(uniqueNodes.length); + }); + + it('should handle defaultPath with only root node', async () => { + const scopeWithRootOnly: Scope = { + metadata: { name: 'scope-root' }, + spec: { + title: 'Scope Root Only', + defaultPath: ['region-us-west'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'scope-root': scopeWithRootOnly }, + }); + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([regionNode]); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('region-us-west', tree, 'scope-root'); + + expect(result.path).toEqual([regionNode]); + }); + }); + + describe('backwards compatibility', () => { + it('should work with existing code that does not provide scopeId to resolvePathToRoot', async () => { + (service as any).updateState({ + nodes: { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree); + + expect(result.path).toEqual([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should not break when scope metadata is loaded after applying', async () => { + // This simulates the async nature of scope loading + apiClient.fetchMultipleScopes = jest.fn().mockImplementation(async () => { + // Simulate delay + await new Promise((resolve) => setTimeout(resolve, 10)); + return [scopeWithDefaultPath]; + }); + + await service.changeScopes(['scope-sea-1']); + + // Scope should eventually be in state + expect(service.state.scopes['scope-sea-1']).toEqual(scopeWithDefaultPath); + }); + }); + + // Tests for path helper methods + describe('getPathForScope (new helper method)', () => { + it('should prefer defaultPath from scope metadata', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + }); + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([parentNode, childNode]); + + // This tests the new getPathForScope method that should be created + // For now, this is testing the expected behavior through resolvePathToRoot + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('child', tree, 'test-scope'); + + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith(['parent', 'child']); + expect(result.path).toEqual([parentNode, childNode]); + }); + + it('should fall back to scopeNodeId when no defaultPath', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + nodes: { + parent: parentNode, + child: childNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('child', tree, 'test-scope'); + + expect(result.path).toEqual([parentNode, childNode]); + }); + + it('should return empty array when both scopeId and scopeNodeId are undefined', async () => { + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('', tree); + + expect(result.path).toEqual([]); + }); + + it('should handle scope not being in cache yet', async () => { + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + (service as any).updateState({ + nodes: { + parent: parentNode, + child: childNode, + }, + }); + + // Scope not in cache, but scopeNodeId is provided + const result = await service.resolvePathToRoot('child', tree, 'unknown-scope'); + + // Should fall back to node-based path + expect(result.path).toEqual([parentNode, childNode]); + }); + }); + + describe('getNodePath - optimized implementation', () => { + it('should build path from cached nodes without API calls', async () => { + (service as any).updateState({ + nodes: { + parent: parentNode, + child: childNode, + grandchild: grandchildNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('grandchild', tree); + + // Should not make any API calls since all nodes are cached + expect(apiClient.fetchScopeNode).not.toHaveBeenCalled(); + expect(result.path).toEqual([parentNode, childNode, grandchildNode]); + }); + + it('should fetch missing nodes in the path', async () => { + // Only grandchild is cached + (service as any).updateState({ + nodes: { + grandchild: grandchildNode, + }, + }); + + apiClient.fetchScopeNode = jest.fn().mockImplementation((id: string) => { + if (id === 'child') { + return Promise.resolve(childNode); + } + if (id === 'parent') { + return Promise.resolve(parentNode); + } + return Promise.resolve(undefined); + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + await service.resolvePathToRoot('grandchild', tree); + + // Should fetch missing parent nodes + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('child'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('parent'); + }); + + it('should handle circular references gracefully', async () => { + const circularNode1: ScopeNode = { + metadata: { name: 'node1' }, + spec: { + nodeType: 'container', + title: 'Node 1', + parentName: 'node2', + }, + }; + + const circularNode2: ScopeNode = { + metadata: { name: 'node2' }, + spec: { + nodeType: 'container', + title: 'Node 2', + parentName: 'node1', + }, + }; + + (service as any).updateState({ + nodes: { + node1: circularNode1, + node2: circularNode2, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + // This should not hang or crash + // Implementation should detect circular references and stop recursion + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const result = await service.resolvePathToRoot('node1', tree); + + expect(result).toBeDefined(); + // When circular reference is detected, it returns partial path (up to the circular point) + expect(result.path.length).toBeGreaterThan(0); + expect(consoleErrorSpy).toHaveBeenCalledWith('Circular reference detected in node path', expect.any(String)); + consoleErrorSpy.mockRestore(); + }); + + it('should stop at root node (empty parentName)', async () => { + (service as any).updateState({ + nodes: { + parent: parentNode, + child: childNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('child', tree); + + expect(result.path).toEqual([parentNode, childNode]); + expect(result.path[0].spec.parentName).toBe(''); + }); + }); + + describe('expandToSelectedScope (new helper method)', () => { + beforeEach(() => { + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + // Return children based on parent + if (options.parent === '') { + return Promise.resolve([parentNode]); + } else if (options.parent === 'parent') { + return Promise.resolve([childNode]); + } + return Promise.resolve([]); + }); + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([parentNode, childNode]); + }); + + it('should expand tree to show selected scope path', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + }); + + await service.open(); + + // Tree should be expanded to show the path + expect(service.state.tree.children?.['parent']?.expanded).toBe(true); + expect(service.state.tree.children?.['parent']?.children?.['child']).toBeDefined(); + }); + + it('should not expand when no scopes are selected', async () => { + (service as any).updateState({ + selectedScopes: [], + appliedScopes: [], + }); + + await service.open(); + + // Root should have children loaded but not expanded beyond that + expect(service.state.tree.children).toBeDefined(); + }); + + it('should load children of the last node in the path', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child', parentNodeId: 'parent' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child', parentNodeId: 'parent' }], + }); + + // Mock API to return path nodes + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([parentNode, childNode]); + + // Mock fetchNodes to return children of the last node + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + if (options.parent === '') { + return Promise.resolve([parentNode]); + } else if (options.parent === 'parent') { + return Promise.resolve([childNode]); + } + return Promise.resolve([]); + }); + + await service.open(); + + // Should have loaded root children (called once during tree initialization) + expect(apiClient.fetchNodes).toHaveBeenCalled(); + // Verify the path nodes were fetched (parent already in cache from fetchNodes, so only child is fetched) + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith(['child']); + }); + + it('should handle errors gracefully when expanding', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + }); + + // Mock API to fail + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + apiClient.fetchMultipleScopeNodes = jest.fn().mockRejectedValue(new Error('API Error')); + + // Should not crash + await expect(service.open()).resolves.not.toThrow(); + expect(service.state.opened).toBe(true); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('integration - full path resolution flow', () => { + it('should resolve path from defaultPath, insert into tree, and expand', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child', 'grandchild'], + filters: [], + }, + }; + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([parentNode, childNode, grandchildNode]); + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + // Return children based on parent + if (options.parent === '') { + return Promise.resolve([parentNode]); + } else if (options.parent === 'parent') { + return Promise.resolve([childNode]); + } else if (options.parent === 'child') { + return Promise.resolve([grandchildNode]); + } + return Promise.resolve([]); + }); + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'grandchild' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'grandchild' }], + }); + + await service.open(); + + // Path should be resolved (parent already in cache from fetchNodes, so only child and grandchild are fetched) + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith(['child', 'grandchild']); + + // Nodes should be in cache + expect(service.state.nodes['parent']).toEqual(parentNode); + expect(service.state.nodes['child']).toEqual(childNode); + expect(service.state.nodes['grandchild']).toEqual(grandchildNode); + + // Tree should be expanded + expect(service.state.tree.children?.['parent']?.expanded).toBe(true); + expect(service.state.tree.children?.['parent']?.children?.['child']?.expanded).toBe(true); + }); + + it('should use cached nodes and avoid unnecessary API calls', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + // Pre-populate cache + (service as any).updateState({ + scopes: { 'test-scope': scope }, + nodes: { + parent: parentNode, + child: childNode, + }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + }); + + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + // Return children based on parent + if (options.parent === '') { + return Promise.resolve([parentNode]); + } else if (options.parent === 'parent') { + return Promise.resolve([childNode]); + } + return Promise.resolve([]); + }); + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([]); + + await service.open(); + + // Should not fetch nodes that are already cached + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + }); + + describe('getScopeNode - caching behavior', () => { + it('should return cached node without API call', async () => { + (service as any).updateState({ + nodes: { + 'test-node': childNode, + }, + }); + + const result = await service.getScopeNode('test-node'); + + expect(result).toEqual(childNode); + expect(apiClient.fetchScopeNode).not.toHaveBeenCalled(); + }); + + it('should fetch and cache node when not in cache', async () => { + apiClient.fetchScopeNode = jest.fn().mockResolvedValue(childNode); + + const result = await service.getScopeNode('test-node'); + + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('test-node'); + expect(result).toEqual(childNode); + // Node is cached using its metadata.name, not the requested ID + expect(service.state.nodes['child']).toEqual(childNode); + }); + + it('should handle API errors gracefully', async () => { + apiClient.fetchScopeNode = jest.fn().mockResolvedValue(undefined); + + const result = await service.getScopeNode('non-existent'); + + expect(result).toBeUndefined(); + expect(service.state.nodes['non-existent']).toBeUndefined(); + }); + }); }); diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts index abd837c7fb7..b7abee2a2cc 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.ts @@ -19,6 +19,7 @@ import { treeNodeAtPath, } from './scopesTreeUtils'; import { NodesMap, RecentScope, RecentScopeSchema, ScopeSchema, ScopesMap, SelectedScope, TreeNode } from './types'; + export const RECENT_SCOPES_KEY = 'grafana.scopes.recent'; export interface ScopesSelectorServiceState { @@ -101,22 +102,74 @@ export class ScopesSelectorService extends ScopesServiceBase => { + private getNodePath = async (scopeNodeId: string, visited: Set = new Set()): Promise => { + // Protect against circular references + if (visited.has(scopeNodeId)) { + console.error('Circular reference detected in node path', scopeNodeId); + return []; + } + const node = await this.getScopeNode(scopeNodeId); if (!node) { return []; } + + // Add current node to visited set + const newVisited = new Set(visited); + newVisited.add(scopeNodeId); + const parentPath = - node.spec.parentName && node.spec.parentName !== '' ? await this.getNodePath(node.spec.parentName) : []; + node.spec.parentName && node.spec.parentName !== '' + ? await this.getNodePath(node.spec.parentName, newVisited) + : []; return [...parentPath, node]; }; + /** + * Determines the path to a scope node, preferring defaultPath from scope metadata. + * This is the single source of truth for path resolution. + * + * TODO: Consider making this public and exposing via a hook to avoid duplication + * with getScopesPath in ScopesInput.tsx + * + * @param scopeId - The scope ID to get the path for + * @param scopeNodeId - Optional scope node ID to fall back to if no defaultPath + * @returns Promise resolving to array of ScopeNode objects representing the path + */ + private async getPathForScope(scopeId: string, scopeNodeId?: string): Promise { + // 1. Check if scope has defaultPath (preferred method) + const scope = this.state.scopes[scopeId]; + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + // Batch fetch all nodes in defaultPath + return await this.getScopeNodes(scope.spec.defaultPath); + } + + // 2. Fall back to calculating path from scopeNodeId + if (scopeNodeId) { + return await this.getNodePath(scopeNodeId); + } + + return []; + } + public resolvePathToRoot = async ( scopeNodeId: string, - tree: TreeNode + tree: TreeNode, + scopeId?: string ): Promise<{ path: ScopeNode[]; tree: TreeNode }> => { - const nodePath = await this.getNodePath(scopeNodeId); + let nodePath: ScopeNode[]; + + // Check if scope has defaultPath for optimized resolution + const scope = scopeId ? this.state.scopes[scopeId] : undefined; + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + // Use batch-fetched defaultPath (most efficient) + nodePath = await this.getPathForScope(scopeId!, scopeNodeId); + } else { + // Fall back to node-based path resolution + nodePath = await this.getNodePath(scopeNodeId); + } + const newTree = insertPathNodesIntoTree(tree, nodePath); this.updateState({ tree: newTree }); @@ -207,16 +260,39 @@ export class ScopesSelectorService extends ScopesServiceBase { - // Set parent query only when filtering within existing children - treeNode.children = {}; + // Preserve existing children that have nested structure (from insertPathNodesIntoTree) + const existingChildren = treeNode.children || {}; + const childrenToPreserve: Record = {}; + + // Keep children that have a children property (object, not undefined) + // This includes both empty objects {} (from path insertion) and populated ones + for (const [key, child] of Object.entries(existingChildren)) { + // Preserve if children is an object (not undefined) + if (child.children !== undefined && typeof child.children === 'object') { + childrenToPreserve[key] = child; + } + } + + // Start with preserved children, then add/update with fetched children + treeNode.children = { ...childrenToPreserve }; + for (const node of childNodes) { - treeNode.children[node.metadata.name] = { - expanded: false, - scopeNodeId: node.metadata.name, - // Only set query on tree nodes if parent already has children (filtering vs first expansion). This is used for saerch highlighting. - query: query || '', - children: undefined, - }; + // If this child was preserved, merge with fetched data + if (childrenToPreserve[node.metadata.name]) { + treeNode.children[node.metadata.name] = { + ...childrenToPreserve[node.metadata.name], + // Update query but keep nested children + query: query || '', + }; + } else { + // New child from API + treeNode.children[node.metadata.name] = { + expanded: false, + scopeNodeId: node.metadata.name, + query: query || '', + children: undefined, + }; + } } // Set loaded to true if node is a container treeNode.childrenLoaded = true; @@ -356,16 +432,54 @@ export class ScopesSelectorService extends ScopesServiceBase 0) { + // Deduplicate and filter out already cached nodes + const uniqueNodeIds = [...new Set(firstScope.spec.defaultPath)]; + const nodesToFetch = uniqueNodeIds.filter((nodeId) => !this.state.nodes[nodeId]); - this.addRecentScopes(fetchedScopes, parentNode, scopes[0]?.scopeNodeId); + if (nodesToFetch.length > 0) { + await this.getScopeNodes(nodesToFetch); + } + } + + // Get scopeNode and parentNode, preferring defaultPath as the source of truth + let parentNode: ScopeNode | undefined; + let scopeNodeId: string | undefined; + + if (firstScope?.spec.defaultPath && firstScope.spec.defaultPath.length > 1) { + // Extract from defaultPath (most reliable source) + // defaultPath format: ['', 'parent-id', 'scope-node-id', ...] + scopeNodeId = firstScope.spec.defaultPath[firstScope.spec.defaultPath.length - 1]; + const parentNodeId = firstScope.spec.defaultPath[firstScope.spec.defaultPath.length - 2]; + + scopeNode = scopeNodeId ? this.state.nodes[scopeNodeId] : undefined; + parentNode = parentNodeId && parentNodeId !== '' ? this.state.nodes[parentNodeId] : undefined; + } else { + // Fallback to next in priority order + scopeNodeId = scopes[0]?.scopeNodeId; + scopeNode = scopeNodeId ? this.state.nodes[scopeNodeId] : undefined; + + const parentNodeId = scopes[0]?.parentNodeId ?? scopeNode?.spec.parentName; + parentNode = parentNodeId ? this.state.nodes[parentNodeId] : undefined; + } + + this.addRecentScopes(fetchedScopes, parentNode, scopeNodeId); this.updateState({ scopes: newScopesState, loading: false }); } }; @@ -375,7 +489,7 @@ export class ScopesSelectorService extends ScopesServiceBase { - if (!('url' in s.spec) || typeof s.spec.url !== 'string') { + if (!('url' in s.spec)) { return false; } return isCurrentPath(currentPath, s.spec.url); @@ -386,7 +500,6 @@ export class ScopesSelectorService extends ScopesServiceBase [scopes[0]?.parentNode?.metadata?.name, scopes[0]?.parentNode]) .filter(([key, parentNode]) => parentNode !== undefined && key !== undefined) ); - - return parentNodes; }; /** @@ -499,40 +609,42 @@ export class ScopesSelectorService extends ScopesServiceBase n.metadata.name); - path.unshift(''); - nodeAtPath = treeNodeAtPath(newTree, path); - } catch (error) { - console.error('Failed to resolve path to root', error); - } - } - - // We have resolved to root, which means the parent node should be available - let parentPath = path.slice(0, -1); - let parentNodeAtPath = treeNodeAtPath(newTree, parentPath); - - if (parentNodeAtPath && !parentNodeAtPath.childrenLoaded) { - // This will update the tree with the children - const { newTree: newTreeWithChildren } = await this.loadNodeChildren(parentPath, parentNodeAtPath, ''); - newTree = newTreeWithChildren; - } - - // Expand the nodes to the selected scope - must be done after loading children try { - newTree = expandNodes(newTree, parentPath); + // Get the path for the selected scope, preferring defaultPath from scope metadata + const pathNodes = await this.getPathForScope( + this.state.selectedScopes[0].scopeId, + this.state.selectedScopes[0].scopeNodeId + ); + + if (pathNodes.length > 0) { + // Convert to string path + const stringPath = pathNodes.map((n) => n.metadata.name); + stringPath.unshift(''); // Add root segment + + // Check if nodes are in tree + let nodeAtPath = treeNodeAtPath(newTree, stringPath); + + // If nodes aren't in tree yet, insert them + if (!nodeAtPath) { + newTree = insertPathNodesIntoTree(newTree, pathNodes); + // Update state so loadNodeChildren can see the inserted nodes + this.updateState({ tree: newTree }); + } + + // Load children of the parent node if needed to show all siblings + const parentPath = stringPath.slice(0, -1); + const parentNodeAtPath = treeNodeAtPath(newTree, parentPath); + + if (parentNodeAtPath && !parentNodeAtPath.childrenLoaded) { + const { newTree: newTreeWithChildren } = await this.loadNodeChildren(parentPath, parentNodeAtPath, ''); + newTree = newTreeWithChildren; + } + + // Expand the nodes to show the selected scope + newTree = expandNodes(newTree, parentPath); + } } catch (error) { - console.error('Failed to expand nodes', error); + console.error('Failed to expand to selected scope', error); } } @@ -580,9 +692,14 @@ export class ScopesSelectorService extends ScopesServiceBase !nodesMap[name]); - const nodes = await this.apiClient.fetchMultipleScopeNodes(nodesToFetch); - for (const node of nodes) { - nodesMap[node.metadata.name] = node; + if (nodesToFetch.length > 0) { + const nodes = await this.apiClient.fetchMultipleScopeNodes(nodesToFetch); + // Handle case where API returns undefined or non-array + if (Array.isArray(nodes)) { + for (const node of nodes) { + nodesMap[node.metadata.name] = node; + } + } } const newNodes = { ...this.state.nodes, ...nodesMap }; diff --git a/public/app/features/scopes/selector/scopesTreeUtils.ts b/public/app/features/scopes/selector/scopesTreeUtils.ts index 8824742505d..d4001db68c1 100644 --- a/public/app/features/scopes/selector/scopesTreeUtils.ts +++ b/public/app/features/scopes/selector/scopesTreeUtils.ts @@ -127,17 +127,27 @@ export const insertPathNodesIntoTree = (tree: TreeNode, path: ScopeNode[]) => { if (!childNodeName) { console.warn('Failed to insert full path into tree. Did not find child to' + stringPath[index]); treeNode.childrenLoaded = treeNode.childrenLoaded ?? false; - return treeNode; + return; + } + // Create node if it doesn't exist + if (!treeNode.children[childNodeName]) { + treeNode.children[childNodeName] = { + expanded: false, + scopeNodeId: childNodeName, + query: '', + children: {}, + childrenLoaded: false, + }; + } else { + // Node exists, ensure it has children object for nested insertion + if (treeNode.children[childNodeName].children === undefined) { + treeNode.children[childNodeName] = { + ...treeNode.children[childNodeName], + children: {}, + }; + } } - treeNode.children[childNodeName] = { - expanded: false, - scopeNodeId: childNodeName, - query: '', - children: undefined, - childrenLoaded: false, - }; treeNode.childrenLoaded = treeNode.childrenLoaded ?? false; - return treeNode; }); } return newTree; diff --git a/public/app/features/scopes/tests/selector.test.ts b/public/app/features/scopes/tests/selector.test.ts index f4ac22b6850..56921ac6f7d 100644 --- a/public/app/features/scopes/tests/selector.test.ts +++ b/public/app/features/scopes/tests/selector.test.ts @@ -132,11 +132,12 @@ describe('Selector', () => { expectRecentScope('Grafana Applications'); expectRecentScope('Grafana, Mimir Applications'); await selectRecentScope('Grafana Applications'); + await jest.runOnlyPendingTimersAsync(); expectScopesSelectorValue('Grafana'); await openSelector(); - // Close to root node so we can see the recent scopes + // Collapse tree to root level to see recent scopes section await expandResultApplications(); await expandRecentScopes(); @@ -155,8 +156,8 @@ describe('Selector', () => { await applyScopes(); await openSelector(); - // Close to root node so we can try to see the recent scopes - await expandResultApplications(); + // Tree expands to show selected scope, so recent scopes are not visible + // (recent scopes only show at root level with tree collapsed) expectRecentScopeNotPresentInDocument(); });