diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 3e450d7ca88..1074b64cd1b 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -193,4 +193,5 @@ export interface FeatureToggles { dashboardRestore?: boolean; datasourceProxyDisableRBAC?: boolean; alertingDisableSendAlertsExternal?: boolean; + preserveDashboardStateWhenNavigating?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 5486ce98491..19f25677309 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1306,6 +1306,14 @@ var ( HideFromDocs: true, HideFromAdminPage: true, }, + { + Name: "preserveDashboardStateWhenNavigating", + Description: "Enables possibility to preserve dashboard variables and time range when navigating between dashboards", + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + HideFromDocs: true, + HideFromAdminPage: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 8cee18735f7..6a71506c26b 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -174,3 +174,4 @@ notificationBanner,experimental,@grafana/grafana-frontend-platform,false,false,f dashboardRestore,experimental,@grafana/grafana-frontend-platform,false,false,false datasourceProxyDisableRBAC,GA,@grafana/identity-access-team,false,false,false alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false +preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index a49679849d1..174f0332b7e 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -706,4 +706,8 @@ const ( // FlagAlertingDisableSendAlertsExternal // Disables the ability to send alerts to an external Alertmanager datasource. FlagAlertingDisableSendAlertsExternal = "alertingDisableSendAlertsExternal" + + // FlagPreserveDashboardStateWhenNavigating + // Enables possibility to preserve dashboard variables and time range when navigating between dashboards + FlagPreserveDashboardStateWhenNavigating = "preserveDashboardStateWhenNavigating" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index f8fdb103952..02a2f7175c8 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2250,6 +2250,20 @@ "codeowner": "@grafana/alerting-squad", "frontend": true } + }, + { + "metadata": { + "name": "preserveDashboardStateWhenNavigating", + "resourceVersion": "1716555778767", + "creationTimestamp": "2024-05-24T13:02:58Z" + }, + "spec": { + "description": "Enables possibility to preserve dashboard variables and time range when navigating between dashboards", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "hideFromAdminPage": true, + "hideFromDocs": true + } } ] } \ No newline at end of file diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 66c00150b0e..a39c5ff1c1c 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -16,6 +16,7 @@ import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardScene } from '../scene/DashboardScene'; import { buildNewDashboardSaveModel } from '../serialization/buildNewDashboardSaveModel'; import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; +import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState'; import { updateNavModel } from './utils'; @@ -177,6 +178,10 @@ export class DashboardScenePageStateManager extends StateManagerBase { + beforeAll(() => { + config.featureToggles.preserveDashboardStateWhenNavigating = true; + }); + + afterAll(() => { + config.featureToggles.preserveDashboardStateWhenNavigating = false; + }); + + beforeEach(() => {}); + + describe('behavior', () => { + it('should do nothing for default home dashboard', () => { + const scene = buildTestScene(); + scene.setState({ uid: undefined }); + + const deactivate = scene.activate(); + expect(window.sessionStorage.getItem(PRESERVED_SCENE_STATE_KEY)).toBeNull(); + + deactivate(); + expect(window.sessionStorage.getItem(PRESERVED_SCENE_STATE_KEY)).toBeNull(); + }); + + it('should capture dashboard scene state and save it to session storage on deactivation', () => { + const scene = buildTestScene(); + + const deactivate = scene.activate(); + expect(window.sessionStorage.getItem(PRESERVED_SCENE_STATE_KEY)).toBeNull(); + + deactivate(); + expect(window.sessionStorage.getItem(PRESERVED_SCENE_STATE_KEY)).toBe( + '?from=now-6h&to=now&timezone=browser&var-customVar=a' + ); + }); + }); + + describe('restoreDashboardStateFromLocalStorage', () => { + it('should restore dashboard state from session storage', () => { + window.sessionStorage.setItem(PRESERVED_SCENE_STATE_KEY, '?var-customVar=b&from=now-5m&to=now&timezone=browser'); + const scene = buildTestScene(); + + restoreDashboardStateFromLocalStorage(scene); + const variable = scene.state.$variables!.getByName('customVar') as CustomVariable; + const timeRange = scene.state.$timeRange; + scene.startUrlSync(); + + expect(variable!.state!.value).toEqual(['b']); + expect(variable!.state!.text).toEqual(['b']); + expect(timeRange?.state.from).toEqual('now-5m'); + expect(timeRange?.state.to).toEqual('now'); + }); + + it('should remove query params that are not applicable on a target dashboard', () => { + window.sessionStorage.setItem( + PRESERVED_SCENE_STATE_KEY, + '?var-customVar=b&var-nonApplicableVar=b&from=now-5m&to=now&timezone=browser' + ); + const scene = buildTestScene(); + + restoreDashboardStateFromLocalStorage(scene); + + expect(locationService.getSearch().toString()).toBe('var-customVar=b&from=now-5m&to=now&timezone=browser'); + }); + + // handles case when user navigates back to a dashboard with the same state, i.e. using back button + it('should remove duplicate query params', () => { + locationService.replace({ search: 'var-customVar=b&from=now-6h&to=now&timezone=browser' }); + + window.sessionStorage.setItem(PRESERVED_SCENE_STATE_KEY, '?var-customVar=b&from=now-5m&to=now&timezone=browser'); + const scene = buildTestScene(); + + restoreDashboardStateFromLocalStorage(scene); + + expect(locationService.getSearch().toString()).toBe('var-customVar=b&from=now-6h&to=now&timezone=browser'); + }); + }); +}); + +function buildTestScene() { + const testDashboard: DashboardDataDTO = { + annotations: { list: [] }, + editable: true, + fiscalYearStartMonth: 0, + graphTooltip: 0, + id: 2483, + links: [], + panels: [], + schemaVersion: 39, + tags: [], + templating: { + list: [ + { + multi: true, + name: 'customVar', + query: 'a,b,c', + type: 'custom', + }, + ], + }, + time: { + from: 'now-6h', + to: 'now', + }, + timepicker: {}, + timezone: 'browser', + title: 'Test dashboard', + uid: 'edhmd9stpd6o0a', + version: 24, + weekStart: '', + }; + const scene = transformSaveModelToScene({ dashboard: testDashboard, meta: {} }); + + // Removing data layers to avoid mocking built-in Grafana data source + scene.setState({ $data: undefined }); + + return scene; +} diff --git a/public/app/features/dashboard-scene/utils/dashboardSessionState.ts b/public/app/features/dashboard-scene/utils/dashboardSessionState.ts new file mode 100644 index 00000000000..c93e530a530 --- /dev/null +++ b/public/app/features/dashboard-scene/utils/dashboardSessionState.ts @@ -0,0 +1,79 @@ +import { UrlQueryMap, urlUtil } from '@grafana/data'; +import { config, locationService } from '@grafana/runtime'; +import { UrlSyncManager } from '@grafana/scenes'; + +import { DashboardScene } from '../scene/DashboardScene'; + +export const PRESERVED_SCENE_STATE_KEY = `grafana.dashboard.preservedUrlFiltersState`; + +export function restoreDashboardStateFromLocalStorage(dashboard: DashboardScene) { + const preservedUrlState = window.sessionStorage.getItem(PRESERVED_SCENE_STATE_KEY); + + if (preservedUrlState) { + const preservedQueryParams = new URLSearchParams(preservedUrlState); + const currentQueryParams = locationService.getSearch(); + + // iterate over preserved query params and append them to current query params if they don't already exist + preservedQueryParams.forEach((value, key) => { + if (!currentQueryParams.has(key)) { + currentQueryParams.append(key, value); + } else { + if (!currentQueryParams.getAll(key).includes(value)) { + currentQueryParams.append(key, value); + } + } + }); + + for (const key of Array.from(currentQueryParams.keys())) { + // preserve non-variable query params, i.e. time range + if (!key.startsWith('var-')) { + continue; + } + + // remove params for variables that are not present on the target dashboard + if (!dashboard.state.$variables?.getByName(key.replace('var-', ''))) { + currentQueryParams.delete(key); + } + } + + const finalParams = currentQueryParams.toString(); + if (finalParams) { + locationService.replace({ + search: finalParams, + }); + } + } +} + +/** + * Scenes behavior that will capture currently selected variables and time range and save them to local storage, so that they can be applied when the next dashboard is loaded. + */ +export function preserveDashboardSceneStateInLocalStorage(scene: DashboardScene) { + if (!config.featureToggles.preserveDashboardStateWhenNavigating) { + return; + } + + return () => { + // Skipping saving state for default home dashboard + if (!scene.state.uid) { + return; + } + + const urlStates: UrlQueryMap = Object.fromEntries( + Object.entries(new UrlSyncManager().getUrlState(scene)).filter( + ([key]) => key.startsWith('var-') || key === 'from' || key === 'to' || key === 'timezone' + ) + ); + + const nonEmptyUrlStates = Object.fromEntries( + Object.entries(urlStates).filter(([key, value]) => !(Array.isArray(value) && value.length === 0)) + ); + + // If there's anything to preserve, save it to local storage + if (Object.keys(nonEmptyUrlStates).length > 0) { + window.sessionStorage.setItem(PRESERVED_SCENE_STATE_KEY, urlUtil.renderUrl('', nonEmptyUrlStates)); + } else { + window.sessionStorage.removeItem(PRESERVED_SCENE_STATE_KEY); + } + }; +}