From b3d9fd677ba038c5d8cc211952fa66c9a9bc99ab Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Wed, 22 Oct 2025 09:16:58 +0200 Subject: [PATCH] Scopes: Redirect to redirectUrl on selection (#112721) * Add redirectUrl frontend functionality * Add test * Add e2e test suite * Fix linting --- .../dashboard-cujs/scope-redirect.spec.ts | 88 +++++++++++++++ e2e-playwright/utils/scope-helpers.ts | 4 + e2e-playwright/utils/scopes.ts | 21 ++++ packages/grafana-data/src/types/scopes.ts | 3 + .../selector/ScopesSelectorService.test.ts | 104 ++++++++++++++++++ .../scopes/selector/ScopesSelectorService.ts | 58 ++++++---- 6 files changed, 255 insertions(+), 23 deletions(-) create mode 100644 e2e-playwright/dashboard-cujs/scope-redirect.spec.ts diff --git a/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts b/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts new file mode 100644 index 00000000000..aca89ed8901 --- /dev/null +++ b/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +import { applyScopes, openScopesSelector, selectScope } from '../utils/scope-helpers'; +import { testScopesWithRedirect } from '../utils/scopes'; + +test.use({ + featureToggles: { + scopeFilters: true, + groupByVariable: true, + reloadDashboardsOnParamsChange: true, + }, +}); + +test.describe('Scope Redirect Functionality', () => { + test('should redirect to custom URL when scope has redirectUrl', async ({ page, gotoDashboardPage }) => { + const scopes = testScopesWithRedirect(); + + await test.step('Navigate to dashboard and open scopes selector', async () => { + await gotoDashboardPage({ uid: 'cuj-dashboard-1' }); + await openScopesSelector(page, scopes); + }); + + await test.step('Select scope with redirectUrl', async () => { + // Select the scope with redirectUrl directly + await selectScope(page, 'sn-redirect-custom', scopes[0]); + }); + + await test.step('Apply scopes and verify redirect to custom URL', async () => { + await applyScopes(page, [scopes[0]]); + + // Verify we were redirected to the custom URL + await expect(page).toHaveURL(/\/d\/cuj-dashboard-2/); + + // Also verify the scope was applied by checking the URL contains the scope + await expect(page).toHaveURL(/scopes=scope-sn-redirect-custom/); + }); + }); + + test('should prioritize redirectUrl over scope navigation fallback', async ({ page, gotoDashboardPage }) => { + const scopes = testScopesWithRedirect(); + + await test.step('Navigate to dashboard and open scopes selector', async () => { + await gotoDashboardPage({ uid: 'cuj-dashboard-1' }); + await openScopesSelector(page, scopes); + }); + + await test.step('Select scope with redirectUrl', async () => { + // Select the scope with redirectUrl directly + await selectScope(page, 'sn-redirect-custom', scopes[0]); + }); + + await test.step('Apply scopes and verify redirectUrl takes priority', async () => { + await applyScopes(page, [scopes[0]]); + + // Should redirect to custom URL, not stay on current dashboard + await expect(page).toHaveURL(/\/d\/cuj-dashboard-2/); + await expect(page).not.toHaveURL(/\/d\/cuj-dashboard-1/); + + // Verify the scope was applied + await expect(page).toHaveURL(/scopes=scope-sn-redirect-custom/); + }); + }); + + test('should fall back to scope navigation when no redirectUrl', async ({ page, gotoDashboardPage }) => { + const scopes = testScopesWithRedirect(); + + await test.step('Navigate to dashboard and open scopes selector', async () => { + await gotoDashboardPage({ uid: 'cuj-dashboard-1' }); + await openScopesSelector(page, scopes); + }); + + await test.step('Select scope without redirectUrl', async () => { + // Select the scope without redirectUrl directly + await selectScope(page, 'sn-redirect-fallback', scopes[1]); + }); + + await test.step('Apply scopes and verify fallback behavior', async () => { + await applyScopes(page, [scopes[1]]); + + // Should stay on current dashboard since no redirectUrl is provided + // The scope navigation fallback should not redirect (as per existing behavior) + await expect(page).toHaveURL(/\/d\/cuj-dashboard-1/); + + // Verify the scope was applied + await expect(page).toHaveURL(/scopes=scope-sn-redirect-fallback/); + }); + }); +}); diff --git a/e2e-playwright/utils/scope-helpers.ts b/e2e-playwright/utils/scope-helpers.ts index 300350e4c47..867b0ae3c8d 100644 --- a/e2e-playwright/utils/scope-helpers.ts +++ b/e2e-playwright/utils/scope-helpers.ts @@ -19,6 +19,7 @@ export type TestScope = { type?: string; category?: string; addLinks?: boolean; + redirectUrl?: string; }; type ScopeDashboardBinding = Resource; @@ -55,6 +56,9 @@ export async function scopeNodeChildrenRequest( linkType: 'scope', linkId: `scope-${scope.name}`, }), + ...(scope.redirectUrl && { + redirectUrl: scope.redirectUrl, + }), }, })), }), diff --git a/e2e-playwright/utils/scopes.ts b/e2e-playwright/utils/scopes.ts index 0f9e8b071eb..2dc1923cb7f 100644 --- a/e2e-playwright/utils/scopes.ts +++ b/e2e-playwright/utils/scopes.ts @@ -105,3 +105,24 @@ export const testScopes = (scopeBindingSetting?: { uid: string; title: string }) }, ]; }; + +export const testScopesWithRedirect = (): TestScope[] => { + return [ + { + name: 'sn-redirect-custom', + title: 'Custom Redirect', + redirectUrl: '/d/cuj-dashboard-2', // Use existing dashboard + filters: [{ key: 'namespace', operator: 'equals', value: 'custom-redirect' }], + addLinks: true, + }, + { + name: 'sn-redirect-fallback', + title: 'Fallback Navigation', + // No redirectUrl - should fall back to scope navigation + filters: [{ key: 'namespace', operator: 'equals', value: 'fallback-nav' }], + dashboardUid: 'cuj-dashboard-2', // Use existing dashboard + dashboardTitle: 'CUJ Dashboard 2', + addLinks: true, + }, + ]; +}; diff --git a/packages/grafana-data/src/types/scopes.ts b/packages/grafana-data/src/types/scopes.ts index e3321e2a8db..e69029f1efa 100644 --- a/packages/grafana-data/src/types/scopes.ts +++ b/packages/grafana-data/src/types/scopes.ts @@ -83,6 +83,9 @@ export interface ScopeNodeSpec { // Id of the parent node. parentName?: string; + + // Redirect to a specific URL when this node is selected. + redirectUrl?: string; } // TODO: Use Resource from apiserver when we export the types diff --git a/public/app/features/scopes/selector/ScopesSelectorService.test.ts b/public/app/features/scopes/selector/ScopesSelectorService.test.ts index 7b636ffa696..d6873709bd7 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.test.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.test.ts @@ -709,5 +709,109 @@ describe('ScopesSelectorService', () => { expect(locationService.push).toHaveBeenCalledWith('/d/first-dashboard'); expect(locationService.push).toHaveBeenCalledTimes(1); }); + + it('should redirect to redirectUrl when scope node has explicit redirectUrl', async () => { + const mockNodeWithRedirect: ScopeNode = { + metadata: { name: 'test-scope-node' }, + spec: { + linkId: 'test-scope', + linkType: 'scope', + parentName: '', + nodeType: 'leaf', + title: 'test-scope-node', + redirectUrl: '/custom-redirect-url', + }, + }; + + // Mock fetchNodes to return the node with redirectUrl + apiClient.fetchNodes = jest + .fn() + .mockImplementation((options: { parent?: string; query?: string; limit?: number }) => { + if (options.parent === '' && !options.query) { + return [mockNodeWithRedirect]; + } else { + return []; + } + }); + + // First update the node to populate the service state + await service.updateNode('', true, ''); + + // Then select the scope to set scopeNodeId in selectedScopes + await service.selectScope('test-scope-node'); + + // Then apply to trigger the redirect + await service.apply(); + + expect(locationService.push).toHaveBeenCalledWith('/custom-redirect-url'); + }); + + it('should prioritize redirectUrl over scope navigation fallback', async () => { + const mockNodeWithRedirect: ScopeNode = { + metadata: { name: 'test-scope-node' }, + spec: { + linkId: 'test-scope', + linkType: 'scope', + parentName: '', + nodeType: 'leaf', + title: 'test-scope-node', + redirectUrl: '/priority-redirect', + }, + }; + + const mockNavigations: ScopeNavigation[] = [ + { + spec: { scope: 'test-scope', url: '/d/dashboard1' }, + status: { title: 'Dashboard 1', groups: [] }, + metadata: { name: 'dashboard1' }, + }, + ]; + + // Mock fetchNodes to return the node with redirectUrl + apiClient.fetchNodes = jest + .fn() + .mockImplementation((options: { parent?: string; query?: string; limit?: number }) => { + if (options.parent === '' && !options.query) { + return [mockNodeWithRedirect]; + } else { + return []; + } + }); + + dashboardsService.state.scopeNavigations = mockNavigations; + (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' }); + + // First update the node to populate the service state + await service.updateNode('', true, ''); + + // Then select the scope to set scopeNodeId in selectedScopes + await service.selectScope('test-scope-node'); + + // Then apply to trigger the redirect + await service.apply(); + + // Should use redirectUrl, not scope navigation + expect(locationService.push).toHaveBeenCalledWith('/priority-redirect'); + expect(locationService.push).toHaveBeenCalledTimes(1); + }); + + it('should fall back to scope navigation when scope node is undefined', async () => { + const mockNavigations: ScopeNavigation[] = [ + { + spec: { scope: 'test-scope', url: '/d/dashboard1' }, + status: { title: 'Dashboard 1', groups: [] }, + metadata: { name: 'dashboard1' }, + }, + ]; + + // Don't add the node to the service state, so it will be undefined + dashboardsService.state.scopeNavigations = mockNavigations; + (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' }); + + await service.changeScopes(['test-scope']); + + // Should fall back to scope navigation since scope node is undefined + expect(locationService.push).toHaveBeenCalledWith('/d/dashboard1'); + }); }); }); diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts index e0705a31f2d..ab191148474 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.ts @@ -363,29 +363,8 @@ export class ScopesSelectorService extends ScopesServiceBase s.scopeId)).then(() => { - // Redirect to first scopeNavigation if current URL isn't a scopeNavigation - const currentPath = locationService.getLocation().pathname; - const activeScopeNavigation = this.dashboardsService.state.scopeNavigations.find((s) => { - if (!('url' in s.spec) || typeof s.spec.url !== 'string') { - return false; - } - return isCurrentPath(currentPath, s.spec.url); - }); - - if (!activeScopeNavigation && this.dashboardsService.state.scopeNavigations.length > 0) { - // Redirect to the first available scopeNavigation - const firstScopeNavigation = this.dashboardsService.state.scopeNavigations[0]; - - if ( - firstScopeNavigation && - 'url' in firstScopeNavigation.spec && - typeof firstScopeNavigation.spec.url === 'string' && - // Only redirect to dashboards TODO: Remove this once Logs Drilldown has Scopes support - firstScopeNavigation.spec.url.includes('/d/') - ) { - locationService.push(firstScopeNavigation.spec.url); - } - } + const selectedScopeNode = scopes[0]?.scopeNodeId ? this.state.nodes[scopes[0]?.scopeNodeId] : undefined; + this.redirectAfterApply(selectedScopeNode); }); if (scopes.length > 0) { @@ -408,6 +387,39 @@ export class ScopesSelectorService extends ScopesServiceBase { + // Check if the selected scope has a redirect URL + if (scopeNode && scopeNode.spec.redirectUrl && typeof scopeNode.spec.redirectUrl === 'string') { + locationService.push(scopeNode.spec.redirectUrl); + return; + } + + // Redirect to first scopeNavigation if current URL isn't a scopeNavigation + const currentPath = locationService.getLocation().pathname; + const activeScopeNavigation = this.dashboardsService.state.scopeNavigations.find((s) => { + if (!('url' in s.spec) || typeof s.spec.url !== 'string') { + return false; + } + return isCurrentPath(currentPath, s.spec.url); + }); + + if (!activeScopeNavigation && this.dashboardsService.state.scopeNavigations.length > 0) { + // Redirect to the first available scopeNavigation + const firstScopeNavigation = this.dashboardsService.state.scopeNavigations[0]; + + if ( + firstScopeNavigation && + 'url' in firstScopeNavigation.spec && + typeof firstScopeNavigation.spec.url === 'string' && + // Only redirect to dashboards TODO: Remove this once Logs Drilldown has Scopes support + firstScopeNavigation.spec.url.includes('/d/') + ) { + locationService.push(firstScopeNavigation.spec.url); + } + } + }; + public removeAllScopes = () => this.applyScopes([]); private addRecentScopes = (scopes: Scope[], parentNode?: ScopeNode) => {