Scopes: Redirect to redirectUrl on selection (#112721)

* Add redirectUrl frontend functionality

* Add test

* Add e2e test suite

* Fix linting
This commit is contained in:
Tobias Skarhed
2025-10-22 09:16:58 +02:00
committed by GitHub
parent 8ab7a20265
commit b3d9fd677b
6 changed files with 255 additions and 23 deletions
@@ -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/);
});
});
});
+4
View File
@@ -19,6 +19,7 @@ export type TestScope = {
type?: string;
category?: string;
addLinks?: boolean;
redirectUrl?: string;
};
type ScopeDashboardBinding = Resource<ScopeDashboardBindingSpec, ScopeDashboardBindingStatus, 'ScopeDashboardBinding'>;
@@ -55,6 +56,9 @@ export async function scopeNodeChildrenRequest(
linkType: 'scope',
linkId: `scope-${scope.name}`,
}),
...(scope.redirectUrl && {
redirectUrl: scope.redirectUrl,
}),
},
})),
}),
+21
View File
@@ -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,
},
];
};
@@ -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
@@ -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');
});
});
});
@@ -363,29 +363,8 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
// Fetches both dashboards and scope navigations
// We call this even if we have 0 scope because in that case it also closes the dashboard drawer.
this.dashboardsService.fetchDashboards(scopes.map((s) => s.scopeId)).then(() => {
// 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<ScopesSelectorServi
}
};
// Redirect to the scope node's redirect URL if it exists, otherwise redirect to the first scope navigation.
private redirectAfterApply = (scopeNode: ScopeNode | undefined) => {
// 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) => {