Scenes: Upgrade to latest URL sync system (#88836)

* Urlsync updates

* Update

* Fixing tests

* Update to latest canary

* fix

* Update

* Update

* Update

* Fix data trails issue

* Data trails fixes

* Update

* correctly sync scene object graph with url state

* Update
pull/89202/head
Torkel Ödegaard 1 year ago committed by GitHub
parent dd3c3b5857
commit e3da5ed35d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      package.json
  2. 5
      public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
  3. 37
      public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts
  4. 4
      public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts
  5. 13
      public/app/features/dashboard-scene/pages/PublicDashboardScenePage.tsx
  6. 3
      public/app/features/dashboard-scene/saving/useSaveDashboard.ts
  7. 19
      public/app/features/dashboard-scene/scene/DashboardScene.tsx
  8. 14
      public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx
  9. 11
      public/app/features/dashboard-scene/utils/dashboardSessionState.test.ts
  10. 8
      public/app/features/dashboard-scene/utils/dashboardSessionState.ts
  11. 20
      public/app/features/trails/DataTrail.test.tsx
  12. 62
      public/app/features/trails/DataTrail.tsx
  13. 75
      public/app/features/trails/DataTrailsApp.tsx
  14. 2
      public/app/features/trails/TrailStore/TrailStore.test.ts
  15. 22
      public/app/features/trails/TrailStore/TrailStore.ts
  16. 608
      yarn.lock

@ -94,6 +94,7 @@
"@testing-library/jest-dom": "6.4.2", "@testing-library/jest-dom": "6.4.2",
"@testing-library/react": "15.0.2", "@testing-library/react": "15.0.2",
"@testing-library/user-event": "14.5.2", "@testing-library/user-event": "14.5.2",
"@types/add": "^2",
"@types/angular": "1.8.9", "@types/angular": "1.8.9",
"@types/angular-route": "1.7.6", "@types/angular-route": "1.7.6",
"@types/babel__core": "^7", "@types/babel__core": "^7",
@ -258,7 +259,7 @@
"@grafana/prometheus": "workspace:*", "@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*", "@grafana/runtime": "workspace:*",
"@grafana/saga-icons": "workspace:*", "@grafana/saga-icons": "workspace:*",
"@grafana/scenes": "4.29.0", "@grafana/scenes": "^5.0.2",
"@grafana/schema": "workspace:*", "@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*", "@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*", "@grafana/ui": "workspace:*",
@ -398,7 +399,8 @@
"uuid": "9.0.1", "uuid": "9.0.1",
"visjs-network": "4.25.0", "visjs-network": "4.25.0",
"whatwg-fetch": "3.6.20", "whatwg-fetch": "3.6.20",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz" "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz",
"yarn": "^1.22.22"
}, },
"resolutions": { "resolutions": {
"underscore": "1.13.6", "underscore": "1.13.6",

@ -2,6 +2,7 @@
import React, { useEffect, useMemo } from 'react'; import React, { useEffect, useMemo } from 'react';
import { PageLayoutType } from '@grafana/data'; import { PageLayoutType } from '@grafana/data';
import { UrlSyncContextProvider } from '@grafana/scenes';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
import PageLoader from 'app/core/components/PageLoader/PageLoader'; import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
@ -78,10 +79,10 @@ export function DashboardScenePage({ match, route, queryParams, history }: Props
} }
return ( return (
<> <UrlSyncContextProvider scene={dashboard}>
<dashboard.Component model={dashboard} key={dashboard.state.key} /> <dashboard.Component model={dashboard} key={dashboard.state.key} />
<DashboardPrompt dashboard={dashboard} /> <DashboardPrompt dashboard={dashboard} />
</> </UrlSyncContextProvider>
); );
} }

@ -1,7 +1,6 @@
import { advanceBy } from 'jest-date-mock'; import { advanceBy } from 'jest-date-mock';
import { BackendSrv, locationService, setBackendSrv } from '@grafana/runtime'; import { BackendSrv, setBackendSrv } from '@grafana/runtime';
import { getUrlSyncManager } from '@grafana/scenes';
import store from 'app/core/store'; import store from 'app/core/store';
import { DASHBOARD_FROM_LS_KEY } from 'app/features/dashboard/state/initDashboard'; import { DASHBOARD_FROM_LS_KEY } from 'app/features/dashboard/state/initDashboard';
import { DashboardRoutes } from 'app/types'; import { DashboardRoutes } from 'app/types';
@ -95,40 +94,6 @@ describe('DashboardScenePageStateManager', () => {
expect(loader.state.isLoading).toBe(false); expect(loader.state.isLoading).toBe(false);
}); });
it('should initialize url sync', async () => {
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
locationService.partial({ from: 'now-5m', to: 'now' });
const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
const dash = loader.state.dashboard;
expect(dash!.state.$timeRange?.state.from).toEqual('now-5m');
getUrlSyncManager().cleanUp(dash!);
// try loading again (and hitting cache)
locationService.partial({ from: 'now-10m', to: 'now' });
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
const dash2 = loader.state.dashboard;
expect(dash2!.state.$timeRange?.state.from).toEqual('now-10m');
});
it('should not initialize url sync for embedded dashboards', async () => {
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
locationService.partial({ from: 'now-5m', to: 'now' });
const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Embedded });
const dash = loader.state.dashboard;
expect(dash!.state.$timeRange?.state.from).toEqual('now-6h');
});
describe('New dashboards', () => { describe('New dashboards', () => {
it('Should have new empty model with meta.isNew and should not be cached', async () => { it('Should have new empty model with meta.isNew and should not be cached', async () => {
const loader = new DashboardScenePageStateManager({}); const loader = new DashboardScenePageStateManager({});

@ -182,10 +182,6 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
restoreDashboardStateFromLocalStorage(dashboard); restoreDashboardStateFromLocalStorage(dashboard);
} }
if (!(config.publicDashboardAccessToken && dashboard.state.controls?.state.hideTimeControls)) {
dashboard.startUrlSync();
}
this.setState({ dashboard: dashboard, isLoading: false }); this.setState({ dashboard: dashboard, isLoading: false });
const measure = stopMeasure(LOAD_SCENE_MEASUREMENT); const measure = stopMeasure(LOAD_SCENE_MEASUREMENT);
trackDashboardSceneLoaded(dashboard, measure?.duration); trackDashboardSceneLoaded(dashboard, measure?.duration);

@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { SceneComponentProps } from '@grafana/scenes'; import { SceneComponentProps, UrlSyncContextProvider } from '@grafana/scenes';
import { Icon, Stack, useStyles2 } from '@grafana/ui'; import { Icon, Stack, useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
import PageLoader from 'app/core/components/PageLoader/PageLoader'; import PageLoader from 'app/core/components/PageLoader/PageLoader';
@ -55,7 +55,16 @@ export function PublicDashboardScenePage({ match, route }: Props) {
return <PublicDashboardNotAvailable />; return <PublicDashboardNotAvailable />;
} }
return <PublicDashboardSceneRenderer model={dashboard} />; // if no time picker render without url sync
if (dashboard.state.controls?.state.hideTimeControls) {
return <PublicDashboardSceneRenderer model={dashboard} />;
}
return (
<UrlSyncContextProvider scene={dashboard}>
<PublicDashboardSceneRenderer model={dashboard} />
</UrlSyncContextProvider>
);
} }
function PublicDashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) { function PublicDashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {

@ -61,10 +61,7 @@ export function useSaveDashboard(isCopy = false) {
if (newUrl !== currentLocation.pathname) { if (newUrl !== currentLocation.pathname) {
setTimeout(() => { setTimeout(() => {
// Because the path changes we need to stop and restart url sync
scene.stopUrlSync();
locationService.push({ pathname: newUrl, search: currentLocation.search }); locationService.push({ pathname: newUrl, search: currentLocation.search });
scene.startUrlSync();
}); });
} }

@ -12,7 +12,6 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { config, locationService } from '@grafana/runtime'; import { config, locationService } from '@grafana/runtime';
import { import {
getUrlSyncManager,
SceneFlexLayout, SceneFlexLayout,
sceneGraph, sceneGraph,
SceneGridLayout, SceneGridLayout,
@ -212,27 +211,15 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
window.__grafanaSceneContext = prevSceneContext; window.__grafanaSceneContext = prevSceneContext;
clearKeyBindings(); clearKeyBindings();
this._changeTracker.terminate(); this._changeTracker.terminate();
this.stopUrlSync();
oldDashboardWrapper.destroy(); oldDashboardWrapper.destroy();
dashboardWatcher.leave(); dashboardWatcher.leave();
}; };
} }
public startUrlSync() {
if (!this.state.meta.isEmbedded) {
getUrlSyncManager().initSync(this);
}
}
public stopUrlSync() {
getUrlSyncManager().cleanUp(this);
}
public onEnterEditMode = (fromExplore = false) => { public onEnterEditMode = (fromExplore = false) => {
this._fromExplore = fromExplore; this._fromExplore = fromExplore;
// Save this state // Save this state
this._initialState = sceneUtils.cloneSceneObjectState(this.state); this._initialState = sceneUtils.cloneSceneObjectState(this.state);
this._initialUrlState = locationService.getLocation(); this._initialUrlState = locationService.getLocation();
// Switch to edit mode // Switch to edit mode
@ -303,10 +290,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
private exitEditModeConfirmed(restoreInitialState = true) { private exitEditModeConfirmed(restoreInitialState = true) {
// No need to listen to changes anymore // No need to listen to changes anymore
this._changeTracker.stopTrackingChanges(); this._changeTracker.stopTrackingChanges();
// Stop url sync before updating url
this.stopUrlSync();
// Now we can update urls
// We are updating url and removing editview and editPanel. // We are updating url and removing editview and editPanel.
// The initial url may be including edit view, edit panel or inspect query params if the user pasted the url, // The initial url may be including edit view, edit panel or inspect query params if the user pasted the url,
// hence we need to cleanup those query params to get back to the dashboard view. Otherwise url sync can trigger overlays. // hence we need to cleanup those query params to get back to the dashboard view. Otherwise url sync can trigger overlays.
@ -330,8 +314,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
// Do not restore // Do not restore
this.setState({ isEditing: false }); this.setState({ isEditing: false });
} }
// and start url sync again
this.startUrlSync();
// Disable grid dragging // Disable grid dragging
this.propagateEditModeChange(); this.propagateEditModeChange();
} }

@ -5,8 +5,8 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime'; import { config, locationService } from '@grafana/runtime';
import { SceneGridLayout, SceneQueryRunner, SceneTimeRange, VizPanel } from '@grafana/scenes'; import { SceneGridLayout, SceneQueryRunner, SceneTimeRange, UrlSyncContextProvider, VizPanel } from '@grafana/scenes';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { buildPanelEditScene } from '../panel-edit/PanelEditor'; import { buildPanelEditScene } from '../panel-edit/PanelEditor';
@ -103,9 +103,8 @@ describe('NavToolbarActions', () => {
}); });
it('Should show correct buttons when in settings menu', async () => { it('Should show correct buttons when in settings menu', async () => {
const { dashboard } = setup(); setup();
dashboard.startUrlSync();
await userEvent.click(await screen.findByText('Edit')); await userEvent.click(await screen.findByText('Edit'));
await userEvent.click(await screen.findByText('Settings')); await userEvent.click(await screen.findByText('Settings'));
@ -118,6 +117,7 @@ describe('NavToolbarActions', () => {
it('Should show correct buttons when editing a new panel', async () => { it('Should show correct buttons when editing a new panel', async () => {
const { dashboard } = setup(); const { dashboard } = setup();
await act(() => { await act(() => {
dashboard.onEnterEditMode(); dashboard.onEnterEditMode();
const editingPanel = ((dashboard.state.body as SceneGridLayout).state.children[0] as DashboardGridItem).state const editingPanel = ((dashboard.state.body as SceneGridLayout).state.children[0] as DashboardGridItem).state
@ -205,9 +205,13 @@ function setup() {
const context = getGrafanaContextMock(); const context = getGrafanaContextMock();
locationService.push('/');
render( render(
<TestProvider grafanaContext={context}> <TestProvider grafanaContext={context}>
<ToolbarActions dashboard={dashboard} /> <UrlSyncContextProvider scene={dashboard}>
<ToolbarActions dashboard={dashboard} />
</UrlSyncContextProvider>
</TestProvider> </TestProvider>
); );

@ -1,5 +1,5 @@
import { config, locationService } from '@grafana/runtime'; import { config, locationService } from '@grafana/runtime';
import { CustomVariable } from '@grafana/scenes'; import { CustomVariable, getUrlSyncManager } from '@grafana/scenes';
import { DashboardDataDTO } from 'app/types'; import { DashboardDataDTO } from 'app/types';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
@ -50,7 +50,8 @@ describe('dashboardSessionState', () => {
restoreDashboardStateFromLocalStorage(scene); restoreDashboardStateFromLocalStorage(scene);
const variable = scene.state.$variables!.getByName('customVar') as CustomVariable; const variable = scene.state.$variables!.getByName('customVar') as CustomVariable;
const timeRange = scene.state.$timeRange; const timeRange = scene.state.$timeRange;
scene.startUrlSync();
getUrlSyncManager().initSync(scene);
expect(variable!.state!.value).toEqual(['b']); expect(variable!.state!.value).toEqual(['b']);
expect(variable!.state!.text).toEqual(['b']); expect(variable!.state!.text).toEqual(['b']);
@ -63,11 +64,12 @@ describe('dashboardSessionState', () => {
PRESERVED_SCENE_STATE_KEY, PRESERVED_SCENE_STATE_KEY,
'?var-customVar=b&var-nonApplicableVar=b&from=now-5m&to=now&timezone=browser' '?var-customVar=b&var-nonApplicableVar=b&from=now-5m&to=now&timezone=browser'
); );
const scene = buildTestScene(); const scene = buildTestScene();
restoreDashboardStateFromLocalStorage(scene); restoreDashboardStateFromLocalStorage(scene);
expect(locationService.getSearch().toString()).toBe('var-customVar=b&from=now-5m&to=now&timezone=browser'); expect(locationService.getLocation().search).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 // handles case when user navigates back to a dashboard with the same state, i.e. using back button
@ -79,7 +81,7 @@ describe('dashboardSessionState', () => {
restoreDashboardStateFromLocalStorage(scene); restoreDashboardStateFromLocalStorage(scene);
expect(locationService.getSearch().toString()).toBe('var-customVar=b&from=now-6h&to=now&timezone=browser'); expect(locationService.getLocation().search).toBe('?var-customVar=b&from=now-6h&to=now&timezone=browser');
}); });
}); });
}); });
@ -116,6 +118,7 @@ function buildTestScene() {
version: 24, version: 24,
weekStart: '', weekStart: '',
}; };
const scene = transformSaveModelToScene({ dashboard: testDashboard, meta: {} }); const scene = transformSaveModelToScene({ dashboard: testDashboard, meta: {} });
// Removing data layers to avoid mocking built-in Grafana data source // Removing data layers to avoid mocking built-in Grafana data source

@ -17,10 +17,6 @@ export function restoreDashboardStateFromLocalStorage(dashboard: DashboardScene)
preservedQueryParams.forEach((value, key) => { preservedQueryParams.forEach((value, key) => {
if (!currentQueryParams.has(key)) { if (!currentQueryParams.has(key)) {
currentQueryParams.append(key, value); currentQueryParams.append(key, value);
} else {
if (!currentQueryParams.getAll(key).includes(value)) {
currentQueryParams.append(key, value);
}
} }
}); });
@ -38,9 +34,7 @@ export function restoreDashboardStateFromLocalStorage(dashboard: DashboardScene)
const finalParams = currentQueryParams.toString(); const finalParams = currentQueryParams.toString();
if (finalParams) { if (finalParams) {
locationService.replace({ locationService.replace({ search: finalParams });
search: finalParams,
});
} }
} }
} }

@ -71,7 +71,7 @@ describe('DataTrail', () => {
}); });
it('should sync state with url', () => { it('should sync state with url', () => {
expect(locationService.getSearchObject().metric).toBe('metric_bucket'); expect(trail.getUrlState().metric).toBe('metric_bucket');
}); });
it('should add history step', () => { it('should add history step', () => {
@ -104,10 +104,6 @@ describe('DataTrail', () => {
trail.state.$timeRange?.setState({ from: 'now-1h' }); trail.state.$timeRange?.setState({ from: 'now-1h' });
}); });
it('should sync state with url', () => {
expect(locationService.getSearchObject().from).toBe('now-1h');
});
it('should add history step', () => { it('should add history step', () => {
expect(trail.state.history.state.steps[2].type).toBe('time'); expect(trail.state.history.state.steps[2].type).toBe('time');
}); });
@ -154,10 +150,6 @@ describe('DataTrail', () => {
trail.state.$timeRange?.setState({ from: 'now-15m' }); trail.state.$timeRange?.setState({ from: 'now-15m' });
}); });
it('should sync state with url', () => {
expect(locationService.getSearchObject().from).toBe('now-15m');
});
it('should add history step', () => { it('should add history step', () => {
expect(trail.state.history.state.steps[3].type).toBe('time'); expect(trail.state.history.state.steps[3].type).toBe('time');
}); });
@ -224,10 +216,6 @@ describe('DataTrail', () => {
getFilterVar().setState({ filters: [{ key: 'zone', operator: '=', value: 'a' }] }); getFilterVar().setState({ filters: [{ key: 'zone', operator: '=', value: 'a' }] });
}); });
it('should sync state with url', () => {
expect(decodeURIComponent(locationService.getSearchObject()['var-filters']?.toString()!)).toBe('zone|=|a');
});
it('should add history step', () => { it('should add history step', () => {
expect(trail.state.history.state.steps[2].type).toBe('filters'); expect(trail.state.history.state.steps[2].type).toBe('filters');
}); });
@ -276,12 +264,6 @@ describe('DataTrail', () => {
getFilterVar().setState({ filters: [{ key: 'zone', operator: '=', value: 'b' }] }); getFilterVar().setState({ filters: [{ key: 'zone', operator: '=', value: 'b' }] });
}); });
it('should sync state with url', () => {
expect(decodeURIComponent(locationService.getSearchObject()['var-filters']?.toString()!)).toBe(
'zone|=|b'
);
});
it('should add history step', () => { it('should add history step', () => {
expect(trail.state.history.state.steps[3].type).toBe('filters'); expect(trail.state.history.state.steps[3].type).toBe('filters');
}); });

@ -1,7 +1,7 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React from 'react'; import React from 'react';
import { AdHocVariableFilter, GrafanaTheme2, VariableHide, urlUtil } from '@grafana/data'; import { AdHocVariableFilter, GrafanaTheme2, PageLayoutType, VariableHide, urlUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime'; import { locationService } from '@grafana/runtime';
import { import {
AdHocFiltersVariable, AdHocFiltersVariable,
@ -25,6 +25,7 @@ import {
VariableValueSelectors, VariableValueSelectors,
} from '@grafana/scenes'; } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { DataTrailSettings } from './DataTrailSettings'; import { DataTrailSettings } from './DataTrailSettings';
import { DataTrailHistory } from './DataTrailsHistory'; import { DataTrailHistory } from './DataTrailsHistory';
@ -35,6 +36,7 @@ import { getTrailStore } from './TrailStore/TrailStore';
import { MetricDatasourceHelper } from './helpers/MetricDatasourceHelper'; import { MetricDatasourceHelper } from './helpers/MetricDatasourceHelper';
import { reportChangeInLabelFilters } from './interactions'; import { reportChangeInLabelFilters } from './interactions';
import { MetricSelectedEvent, trailDS, VAR_DATASOURCE, VAR_FILTERS } from './shared'; import { MetricSelectedEvent, trailDS, VAR_DATASOURCE, VAR_FILTERS } from './shared';
import { getMetricName } from './utils';
export interface DataTrailState extends SceneObjectState { export interface DataTrailState extends SceneObjectState {
topScene?: SceneObject; topScene?: SceneObject;
@ -93,21 +95,11 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
); );
} }
// Disconnects the current step history state from the current state, to prevent changes affecting history state
const currentState = this.state.history.state.steps[this.state.history.state.currentStep]?.trailState;
if (currentState) {
this.restoreFromHistoryStep(currentState);
}
this.enableUrlSync();
// Save the current trail as a recent if the browser closes or reloads // Save the current trail as a recent if the browser closes or reloads
const saveRecentTrail = () => getTrailStore().setRecentTrail(this); const saveRecentTrail = () => getTrailStore().setRecentTrail(this);
window.addEventListener('unload', saveRecentTrail); window.addEventListener('unload', saveRecentTrail);
return () => { return () => {
this.disableUrlSync();
if (!this.state.embedded) { if (!this.state.embedded) {
saveRecentTrail(); saveRecentTrail();
} }
@ -115,18 +107,6 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
}; };
} }
private enableUrlSync() {
if (!this.state.embedded) {
getUrlSyncManager().initSync(this);
}
}
private disableUrlSync() {
if (!this.state.embedded) {
getUrlSyncManager().cleanUp(this);
}
}
protected _variableDependency = new VariableDependencyConfig(this, { protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: [VAR_DATASOURCE], variableNames: [VAR_DATASOURCE],
onReferencedVariableValueChanged: (variable: SceneVariable) => { onReferencedVariableValueChanged: (variable: SceneVariable) => {
@ -167,8 +147,6 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
} }
public restoreFromHistoryStep(state: DataTrailState) { public restoreFromHistoryStep(state: DataTrailState) {
this.disableUrlSync();
if (!state.topScene && !state.metric) { if (!state.topScene && !state.metric) {
// If the top scene for an is missing, correct it. // If the top scene for an is missing, correct it.
state.topScene = new MetricSelectScene({}); state.topScene = new MetricSelectScene({});
@ -184,8 +162,6 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
const urlState = getUrlSyncManager().getUrlState(this); const urlState = getUrlSyncManager().getUrlState(this);
const fullUrl = urlUtil.renderUrl(locationService.getLocation().pathname, urlState); const fullUrl = urlUtil.renderUrl(locationService.getLocation().pathname, urlState);
locationService.replace(fullUrl); locationService.replace(fullUrl);
this.enableUrlSync();
} }
private _handleMetricSelectedEvent(evt: MetricSelectedEvent) { private _handleMetricSelectedEvent(evt: MetricSelectedEvent) {
@ -227,24 +203,26 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
} }
static Component = ({ model }: SceneComponentProps<DataTrail>) => { static Component = ({ model }: SceneComponentProps<DataTrail>) => {
const { controls, topScene, history, settings } = model.useState(); const { controls, topScene, history, settings, metric } = model.useState();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const showHeaderForFirstTimeUsers = getTrailStore().recent.length < 2; const showHeaderForFirstTimeUsers = getTrailStore().recent.length < 2;
return ( return (
<div className={styles.container}> <Page navId="explore/metrics" pageNav={{ text: getMetricName(metric) }} layout={PageLayoutType.Custom}>
{showHeaderForFirstTimeUsers && <MetricsHeader />} <div className={styles.container}>
<history.Component model={history} /> {showHeaderForFirstTimeUsers && <MetricsHeader />}
{controls && ( <history.Component model={history} />
<div className={styles.controls}> {controls && (
{controls.map((control) => ( <div className={styles.controls}>
<control.Component key={control.state.key} model={control} /> {controls.map((control) => (
))} <control.Component key={control.state.key} model={control} />
<settings.Component model={settings} /> ))}
</div> <settings.Component model={settings} />
)} </div>
<div className={styles.body}>{topScene && <topScene.Component model={topScene} />}</div> )}
</div> <div className={styles.body}>{topScene && <topScene.Component model={topScene} />}</div>
</div>
</Page>
); );
}; };
} }
@ -288,6 +266,8 @@ function getStyles(theme: GrafanaTheme2) {
gap: theme.spacing(1), gap: theme.spacing(1),
minHeight: '100%', minHeight: '100%',
flexDirection: 'column', flexDirection: 'column',
background: theme.isLight ? theme.colors.background.primary : theme.colors.background.canvas,
padding: theme.spacing(2, 3, 2, 3),
}), }),
body: css({ body: css({
flexGrow: 1, flexGrow: 1,

@ -1,11 +1,9 @@
import { css } from '@emotion/css';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Route, Switch } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; import { PageLayoutType } from '@grafana/data';
import { locationService } from '@grafana/runtime'; import { locationService } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, SceneObjectState, getUrlSyncManager } from '@grafana/scenes'; import { SceneComponentProps, SceneObjectBase, SceneObjectState, UrlSyncContextProvider } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
import { DataTrail } from './DataTrail'; import { DataTrail } from './DataTrail';
@ -13,7 +11,7 @@ import { DataTrailsHome } from './DataTrailsHome';
import { MetricsHeader } from './MetricsHeader'; import { MetricsHeader } from './MetricsHeader';
import { getTrailStore } from './TrailStore/TrailStore'; import { getTrailStore } from './TrailStore/TrailStore';
import { HOME_ROUTE, TRAILS_ROUTE } from './shared'; import { HOME_ROUTE, TRAILS_ROUTE } from './shared';
import { getMetricName, getUrlForTrail, newMetricsTrail } from './utils'; import { getUrlForTrail, newMetricsTrail } from './utils';
export interface DataTrailsAppState extends SceneObjectState { export interface DataTrailsAppState extends SceneObjectState {
trail: DataTrail; trail: DataTrail;
@ -26,13 +24,12 @@ export class DataTrailsApp extends SceneObjectBase<DataTrailsAppState> {
} }
goToUrlForTrail(trail: DataTrail) { goToUrlForTrail(trail: DataTrail) {
this.setState({ trail });
locationService.push(getUrlForTrail(trail)); locationService.push(getUrlForTrail(trail));
this.setState({ trail });
} }
static Component = ({ model }: SceneComponentProps<DataTrailsApp>) => { static Component = ({ model }: SceneComponentProps<DataTrailsApp>) => {
const { trail, home } = model.useState(); const { trail, home } = model.useState();
const styles = useStyles2(getStyles);
return ( return (
<Switch> <Switch>
@ -50,21 +47,7 @@ export class DataTrailsApp extends SceneObjectBase<DataTrailsAppState> {
</Page> </Page>
)} )}
/> />
<Route <Route exact={true} path={TRAILS_ROUTE} render={() => <DataTrailView trail={trail} />} />
exact={true}
path={TRAILS_ROUTE}
render={() => (
<Page
navId="explore/metrics"
pageNav={{ text: getMetricName(trail.state.metric) }}
layout={PageLayoutType.Custom}
>
<div className={styles.customPage}>
<DataTrailView trail={trail} />
</div>
</Page>
)}
/>
</Switch> </Switch>
); );
}; };
@ -84,7 +67,11 @@ function DataTrailView({ trail }: { trail: DataTrail }) {
return null; return null;
} }
return <trail.Component model={trail} />; return (
<UrlSyncContextProvider scene={trail}>
<trail.Component model={trail} />
</UrlSyncContextProvider>
);
} }
let dataTrailsApp: DataTrailsApp; let dataTrailsApp: DataTrailsApp;
@ -92,50 +79,10 @@ let dataTrailsApp: DataTrailsApp;
export function getDataTrailsApp() { export function getDataTrailsApp() {
if (!dataTrailsApp) { if (!dataTrailsApp) {
dataTrailsApp = new DataTrailsApp({ dataTrailsApp = new DataTrailsApp({
trail: getInitialTrail(), trail: newMetricsTrail(),
home: new DataTrailsHome({}), home: new DataTrailsHome({}),
}); });
} }
return dataTrailsApp; return dataTrailsApp;
} }
/**
* Get the initial trail for the app to work with based on the current URL
*
* It will either be a new trail that will be started based on the state represented
* in the URL parameters, or it will be the most recently used trail (according to the trail store)
* which has its current history step matching the URL parameters.
*
* The reason for trying to reinitialize from the recent trail is to resolve an issue
* where refreshing the browser would wipe the step history. This allows you to preserve
* it between browser refreshes, or when reaccessing the same URL.
*/
function getInitialTrail() {
const newTrail = newMetricsTrail();
// Set the initial state of the newTrail based on the URL,
// In case we are initializing from an externally created URL or a page reload
getUrlSyncManager().initSync(newTrail);
// Remove the URL sync for now. It will be restored on the trail if it is activated.
getUrlSyncManager().cleanUp(newTrail);
// If one of the recent trails is a match to the newTrail derived from the current URL,
// let's restore that trail so that a page refresh doesn't create a new trail.
const recentMatchingTrail = getTrailStore().findMatchingRecentTrail(newTrail)?.resolve();
// If there is a matching trail, initialize with that. Otherwise, use the new trail.
return recentMatchingTrail || newTrail;
}
function getStyles(theme: GrafanaTheme2) {
return {
customPage: css({
padding: theme.spacing(2, 3, 2, 3),
background: theme.isLight ? theme.colors.background.primary : theme.colors.background.canvas,
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
}),
};
}

@ -349,6 +349,7 @@ describe('TrailStore', () => {
describe('And time range is changed to now-15m to now', () => { describe('And time range is changed to now-15m to now', () => {
let trail: DataTrail; let trail: DataTrail;
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
localStorage.setItem(RECENT_TRAILS_KEY, JSON.stringify([{ history, currentStep: 1 }])); localStorage.setItem(RECENT_TRAILS_KEY, JSON.stringify([{ history, currentStep: 1 }]));
@ -357,6 +358,7 @@ describe('TrailStore', () => {
trail = store.recent[0].resolve(); trail = store.recent[0].resolve();
const urlState = getUrlSyncManager().getUrlState(trail); const urlState = getUrlSyncManager().getUrlState(trail);
locationService.partial(urlState); locationService.partial(urlState);
trail.activate(); trail.activate();
trail.state.history.activate(); trail.state.history.activate();
trail.state.$timeRange?.setState({ from: 'now-15m' }); trail.state.$timeRange?.setState({ from: 'now-15m' });

@ -1,5 +1,6 @@
import { debounce, isEqual } from 'lodash'; import { debounce, isEqual } from 'lodash';
import { urlUtil } from '@grafana/data';
import { getUrlSyncManager, SceneObject, SceneObjectRef, SceneObjectUrlValues, sceneUtils } from '@grafana/scenes'; import { getUrlSyncManager, SceneObject, SceneObjectRef, SceneObjectUrlValues, sceneUtils } from '@grafana/scenes';
import { dispatch } from 'app/store/store'; import { dispatch } from 'app/store/store';
@ -77,9 +78,14 @@ export class TrailStore {
}); });
const currentStep = t.currentStep ?? trail.state.history.state.steps.length - 1; const currentStep = t.currentStep ?? trail.state.history.state.steps.length - 1;
trail.state.history.setState({ currentStep }); trail.state.history.setState({ currentStep });
// The state change listeners aren't activated yet, so maually change to the current step state
trail.setState(trail.state.history.state.steps[currentStep].trailState); trail.setState(
sceneUtils.cloneSceneObjectState(trail.state.history.state.steps[currentStep].trailState, {
history: trail.state.history,
})
);
return trail; return trail;
} }
@ -102,8 +108,8 @@ export class TrailStore {
} }
private _loadFromUrl(node: SceneObject, urlValues: SceneObjectUrlValues) { private _loadFromUrl(node: SceneObject, urlValues: SceneObjectUrlValues) {
node.urlSync?.updateFromUrl(urlValues); const urlState = urlUtil.renderUrl('', urlValues);
node.forEachChild((child) => this._loadFromUrl(child, urlValues)); sceneUtils.syncStateFromSearchParams(node, new URLSearchParams(urlState));
} }
// Recent Trails // Recent Trails
@ -140,14 +146,6 @@ export class TrailStore {
this._save(); this._save();
} }
findMatchingRecentTrail(trail: DataTrail) {
const matchUrlState = getUrlStateForComparison(trail);
return this._recent.find((t) => {
const urlState = getUrlStateForComparison(t.resolve());
return isEqual(matchUrlState, urlState);
});
}
// Bookmarked Trails // Bookmarked Trails
get bookmarks() { get bookmarks() {
return this._bookmarks; return this._bookmarks;

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save