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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user