Scopes: Redirect to redirectUrl on selection (#112721)
* Add redirectUrl frontend functionality * Add test * Add e2e test suite * Fix linting
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
})),
|
||||
}),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user