DashboardScenePageStateManager: Pass query params to loadDashboard when reloadDashboardsOnParamsChange enabled (#109543)

* DashboardScenePageStateManager: Pass query params to loadDashboard when reloadDashboardsOnParamsChange enabled

- Extract query param processing into helper function
- Pass processed params to loadDashboard when toggle is on
- Add test coverage for query parameter handling

* review
This commit is contained in:
Dominik Prokop
2025-08-14 10:33:28 +02:00
committed by GitHub
parent e165a490b4
commit 0705a44b0f
4 changed files with 199 additions and 5 deletions
@@ -36,6 +36,7 @@ jest.mock('@grafana/runtime', () => {
featureToggles: {
...original.config.featureToggles,
dashboardNewLayouts: false, // Default value
reloadDashboardsOnParamsChange: false, // Default value
},
bootData: {
@@ -122,11 +123,46 @@ const mockDashboardLoader = {
// Set the mock loader
setDashboardLoaderSrv(mockDashboardLoader as unknown as DashboardLoaderSrv);
// Test helpers for query parameter processing tests
const mockTimeAndLocation = (mockNow: Date, queryParams: Record<string, string>) => {
const originalDateNow = Date.now;
Date.now = jest.fn(() => mockNow.getTime());
const mockGetSearch = jest.fn().mockReturnValue(new URLSearchParams());
const mockGetSearchObject = jest.fn().mockReturnValue(queryParams);
locationService.getSearch = mockGetSearch;
locationService.getSearchObject = mockGetSearchObject;
return () => {
Date.now = originalDateNow;
};
};
const withFeatureToggle = (enabled: boolean, testFn: () => Promise<void>) => async () => {
const originalValue = config.featureToggles.reloadDashboardsOnParamsChange;
config.featureToggles.reloadDashboardsOnParamsChange = enabled;
try {
await testFn();
} finally {
config.featureToggles.reloadDashboardsOnParamsChange = originalValue;
}
};
// Test constants
const MOCK_DASHBOARD = { dashboard: { uid: 'fake-dash', editable: true }, meta: {} };
const MOCK_NOW = new Date('2023-10-01T12:00:00.000Z');
// Reset the mock between tests
beforeEach(() => {
jest.clearAllMocks();
mockDashboardLoader.loadDashboard.mockReset();
mockDashboardLoader.loadSnapshot.mockReset();
// Reset locationService mocks
locationService.getSearch = jest.fn().mockReturnValue(new URLSearchParams());
locationService.getSearchObject = jest.fn().mockReturnValue({});
});
describe('DashboardScenePageStateManager v1', () => {
@@ -152,6 +188,109 @@ describe('DashboardScenePageStateManager v1', () => {
expect(loadDashboardMock.mock.calls.length).toBe(1);
});
describe('reloadDashboardsOnParamsChange feature toggle', () => {
it(
'should process query params when enabled',
withFeatureToggle(true, async () => {
const cleanup = mockTimeAndLocation(MOCK_NOW, {
from: 'now-1h',
to: 'now',
timezone: 'UTC',
extraParam: 'shouldBeRemoved',
});
const expectedFromISO = '2023-10-01T06:00:00.000Z'; // now-1h (calculated by dateMath)
const expectedToISO = '2023-10-01T12:00:00.000Z'; // now
const loadDashboardMock = setupLoadDashboardMock(MOCK_DASHBOARD);
const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
// Verify that loadDashboard was called with processed query params
expect(loadDashboardMock).toHaveBeenCalledWith(
'db',
'',
'fake-dash',
expect.objectContaining({
from: expectedFromISO,
to: expectedToISO,
})
);
// Verify that extra parameters were filtered out (including timezone)
const callArgs = loadDashboardMock.mock.calls[0][3];
expect(callArgs).not.toHaveProperty('extraParam');
expect(callArgs).not.toHaveProperty('timezone'); // timezone is filtered out by design
cleanup();
})
);
it(
'should pass undefined query params when disabled',
withFeatureToggle(false, async () => {
const loadDashboardMock = setupLoadDashboardMock(MOCK_DASHBOARD);
const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
// Verify that loadDashboard was called without query params (3 parameters only)
expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash');
})
);
it(
'should filter parameters correctly when enabled',
withFeatureToggle(true, async () => {
const cleanup = mockTimeAndLocation(MOCK_NOW, {
from: 'now-6h',
to: 'now',
'var-server': 'web-01',
'var-env': 'production',
scopes: 'scope1,scope2',
version: '2',
refresh: '5s', // should be filtered out
theme: 'dark', // should be filtered out
kiosk: 'true', // should be filtered out
});
const expectedFromISO = '2023-10-01T06:00:00.000Z'; // now-6h
const expectedToISO = '2023-10-01T12:00:00.000Z'; // now
const loadDashboardMock = setupLoadDashboardMock(MOCK_DASHBOARD);
const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
const callArgs = loadDashboardMock.mock.calls[0][3];
// Should keep allowed parameters with specific expected values
expect(callArgs).toEqual(
expect.objectContaining({
from: expectedFromISO,
to: expectedToISO,
'var-server': 'web-01',
'var-env': 'production',
scopes: 'scope1,scope2',
version: '2',
})
);
// Should filter out disallowed parameters
expect(callArgs).toEqual(
expect.not.objectContaining({
refresh: expect.anything(),
theme: expect.anything(),
kiosk: expect.anything(),
})
);
cleanup();
})
);
});
it("should error when the dashboard doesn't exist", async () => {
setupLoadDashboardMockReject({
status: 404,
@@ -36,7 +36,7 @@ import { transformSaveModelSchemaV2ToScene } from '../serialization/transformSav
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState';
import { updateNavModel } from './utils';
import { processQueryParamsForDashboardLoad, updateNavModel } from './utils';
export interface LoadError {
status?: number;
@@ -447,7 +447,14 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
return await dashboardLoaderSrv.loadDashboard('public', '', uid);
}
default:
rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid);
// If reloadDashboardsOnParamsChange is on, we need to process query params for dashboard load
// Since the scene is not yet there, we need to process whatever came through URL
if (config.featureToggles.reloadDashboardsOnParamsChange) {
const queryParamsObject = processQueryParamsForDashboardLoad();
rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid, queryParamsObject);
} else {
rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid);
}
if (route === DashboardRoutes.Embedded) {
rsp.meta.isEmbedded = true;
@@ -1,3 +1,5 @@
import { UrlQueryMap, getTimeZone, getDefaultTimeRange, dateMath } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { updateNavIndex } from 'app/core/actions';
import { backendSrv } from 'app/core/services/backend_srv';
import { buildNavModel } from 'app/features/folders/state/navModel';
@@ -11,3 +13,49 @@ export async function updateNavModel(folderUid: string) {
console.warn('Error fetching parent folder', folderUid, 'for dashboard', err);
}
}
/**
* Processes query parameters for dashboard loading, normalizing time range and filtering allowed parameters
*/
export function processQueryParamsForDashboardLoad(): UrlQueryMap {
const queryParams = locationService.getSearch();
const queryParamsObject = locationService.getSearchObject();
queryParamsObject.timezone = queryParams.get('timezone') ?? undefined;
const now = Date.now();
const timeZone = getTimeZone({
timeZone: queryParams.get('timezone') ?? undefined,
});
const defaultTimeRange = getDefaultTimeRange();
const fromQP = queryParams.get('from');
const toQP = queryParams.get('to');
const to = toQP
? dateMath.toDateTime(toQP, {
roundUp: true,
timezone: timeZone,
now: now,
})
: undefined;
const from = fromQP
? dateMath.toDateTime(fromQP, {
roundUp: false,
timezone: timeZone,
now: now,
})
: undefined;
queryParamsObject.from = from?.toISOString() ?? defaultTimeRange.from.toISOString();
queryParamsObject.to = to?.toISOString() ?? defaultTimeRange.to.toISOString();
// Remove all properties that are not from, to, scopes, version or start with var-
Object.keys(queryParamsObject).forEach((key) => {
if (key !== 'from' && key !== 'to' && key !== 'scopes' && key !== 'version' && !key.startsWith('var-')) {
delete queryParamsObject[key];
}
});
return queryParamsObject;
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { locationUtil } from '@grafana/data';
import { locationUtil, UrlQueryMap } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Dashboard } from '@grafana/schema';
import { Status } from '@grafana/schema/src/schema/dashboard/v2';
@@ -115,9 +115,9 @@ export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
}));
}
async getDashboardDTO(uid: string) {
async getDashboardDTO(uid: string, params?: UrlQueryMap) {
try {
const dash = await this.client.subresource<DashboardWithAccessInfo<DashboardDataDTO>>(uid, 'dto');
const dash = await this.client.subresource<DashboardWithAccessInfo<DashboardDataDTO>>(uid, 'dto', params);
// This could come as conversion error from v0 or v2 to V1.
if (dash.status?.conversion?.failed && isV2StoredVersion(dash.status.conversion.storedVersion)) {