Skip to content

Commit

Permalink
UrlSync: Support browser history steps, remove singleton (#878)
Browse files Browse the repository at this point in the history
  • Loading branch information
torkelo authored Sep 4, 2024
1 parent 7d3162d commit 5f9bec6
Show file tree
Hide file tree
Showing 21 changed files with 343 additions and 98 deletions.
4 changes: 4 additions & 0 deletions docusaurus/docs/scene-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ Define a new Scenes app using the `SceneApp` object :
function getSceneApp() {
return new SceneApp({
pages: [],
urlSyncOptions: {
updateUrlOnInit: true,
createBrowserHistorySteps: true
}
});
}
```
Expand Down
92 changes: 92 additions & 0 deletions docusaurus/docs/url-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
---
id: url-sync
title: Url sync
---

Scenes comes with a URL sync system that enables two way syncing of scene object state to URL.

## UrlSyncContextProvider

To enable URL sync you have to wrap your root scene in a UrlSyncContextProvider

```tsx
<UrlSyncContextProvider scene={scene} updateUrlOnInit={true} createBrowserHistorySteps={true} />
```

## SceneApp

For scene apps that use SceenApp the url sync initialized for you, but you can still set url sync options on the SceneApp state.

```tsx
function getSceneApp() {
return new SceneApp({
pages: [],
urlSyncOptions: {
updateUrlOnInit: true,
createBrowserHistorySteps: true
}
});
}
```

## SceneObjectUrlSyncHandler

A scene objects that set's its `_urlSync` property will have the option to sync part of it's state to / from the URL.

This property has this interface type:

```tsx
export interface SceneObjectUrlSyncHandler {
getKeys(): string[];
getUrlState(): SceneObjectUrlValues;
updateFromUrl(values: SceneObjectUrlValues): void;
shouldCreateHistoryStep?(values: SceneObjectUrlValues): boolean;
}
```

The current behavior of updateFromUrl is a bit strange in that it will only pass on URL values that are different compared to what is returned by
getUrlState.

## Browser history

If createBrowserHistorySteps is enabled then for state changes where shouldCreateHistoryStep return true new browser history states will be returned.

## SceneObjectUrlSyncConfig

This class implements the SceneObjectUrlSyncHandler interface and is a utility class to make it a bit easier for scene objects to implement
url sync behavior.


Example:

```tsx
export class SomeObject extends SceneObjectBase<SomeObjectState> {
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['from', 'to'] });

public getUrlState() {
return { from: this.state.from, to: this.state.to };
}

public updateFromUrl(values: SceneObjectUrlValues) {
const update: Partial<SomeObjectState> = {};

if (typeof values.from === 'string') {
update.from = values.from;
}

if (typeof values.to === 'string') {
update.to = values.to;
}

this.setState(update);
}

onUserUpdate(from: string, to: string) {
// For state actions that should add browser history wrap them in this callback
this._urlSync.performBrowserHistoryAction(() => {
this.setState({from, to})
})
}
}
```

12 changes: 5 additions & 7 deletions docusaurus/website/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,18 @@ const sidebars = {
'advanced-behaviors',
'advanced-custom-datasource',
'advanced-time-range-comparison',
'url-sync',
],
},
{
type: 'category',
label: '@grafana/scenes-ml',
collapsible: true,
collapsed: false,
items: [
'getting-started',
'baselines-and-forecasts',
'outlier-detection',
'changepoint-detection',
].map(id => `scenes-ml/${id}`),
}
items: ['getting-started', 'baselines-and-forecasts', 'outlier-detection', 'changepoint-detection'].map(
(id) => `scenes-ml/${id}`
),
},
],
};
module.exports = sidebars;
4 changes: 2 additions & 2 deletions packages/scenes-app/src/demos/urlSyncTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ import {
SceneTimeRange,
SceneVariableSet,
VariableValueSelectors,
getUrlSyncManager,
} from '@grafana/scenes';
import { getQueryRunnerWithRandomWalkQuery } from './utils';
import { Button, Stack } from '@grafana/ui';
import { NewSceneObjectAddedEvent } from '@grafana/scenes/src/services/UrlSyncManager';

export function getUrlSyncTest(defaults: SceneAppPageState) {
return new SceneAppPage({
Expand Down Expand Up @@ -102,7 +102,7 @@ class DynamicSubScene extends SceneObjectBase<DynamicSubSceneState> {

private addScene() {
const scene = buildNewSubScene();
getUrlSyncManager().handleNewObject(scene);
this.publishEvent(new NewSceneObjectAddedEvent(scene), true);
this.setState({ scene });
}

Expand Down
4 changes: 4 additions & 0 deletions packages/scenes-app/src/pages/DemoListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import { css } from '@emotion/css';
function getDemoSceneApp() {
return new SceneApp({
name: 'scenes-demos-app',
urlSyncOptions: {
updateUrlOnInit: true,
createBrowserHistorySteps: true,
},
pages: [
new SceneAppPage({
title: 'Demos',
Expand Down
8 changes: 5 additions & 3 deletions packages/scenes-react/src/contexts/SceneContextObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
SceneObjectState,
SceneVariable,
SceneVariableSet,
getUrlSyncManager,
NewSceneObjectAddedEvent,
} from '@grafana/scenes';
import { writeSceneLog } from '../utils';

Expand All @@ -23,7 +23,7 @@ export class SceneContextObject extends SceneObjectBase<SceneContextObjectState>
}

public addToScene(obj: SceneObject) {
getUrlSyncManager().handleNewObject(obj);
this.publishEvent(new NewSceneObjectAddedEvent(obj), true);

this.setState({ children: [...this.state.children, obj] });
writeSceneLog('SceneContext', `Adding to scene: ${obj.constructor.name} key: ${obj.state.key}`);
Expand Down Expand Up @@ -54,7 +54,7 @@ export class SceneContextObject extends SceneObjectBase<SceneContextObjectState>
public addVariable(variable: SceneVariable) {
let set = this.state.$variables as SceneVariableSet;

getUrlSyncManager().handleNewObject(variable);
this.publishEvent(new NewSceneObjectAddedEvent(variable), true);

if (set) {
set.setState({ variables: [...set.state.variables, variable] });
Expand All @@ -72,6 +72,8 @@ export class SceneContextObject extends SceneObjectBase<SceneContextObjectState>
}

public addChildContext(ctx: SceneContextObject) {
this.publishEvent(new NewSceneObjectAddedEvent(ctx), true);

this.setState({ childContexts: [...(this.state.childContexts ?? []), ctx] });

writeSceneLog('SceneContext', `Adding child context: ${ctx.constructor.name} key: ${ctx.state.key}`);
Expand Down
15 changes: 6 additions & 9 deletions packages/scenes-react/src/contexts/SceneContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import {
SceneTimeRangeState,
SceneTimeRange,
behaviors,
UrlSyncContextProvider,
getUrlSyncManager,
} from '@grafana/scenes';
import { SceneTimeRangeState, SceneTimeRange, behaviors, UrlSyncContextProvider } from '@grafana/scenes';

import { SceneContextObject, SceneContextObjectState } from './SceneContextObject';

Expand Down Expand Up @@ -51,7 +45,6 @@ export function SceneContextProvider({ children, timeRange, withQueryController
const childContext = new SceneContextObject(state);

if (parentContext) {
getUrlSyncManager().handleNewObject(childContext);
parentContext.addChildContext(childContext);
}

Expand Down Expand Up @@ -79,5 +72,9 @@ export function SceneContextProvider({ children, timeRange, withQueryController
}

// For root context we wrap the provider in a UrlSyncWrapper that handles the hook that updates state on location changes
return <UrlSyncContextProvider scene={childContext}>{innerProvider}</UrlSyncContextProvider>;
return (
<UrlSyncContextProvider scene={childContext} updateUrlOnInit={true} createBrowserHistorySteps={true}>
{innerProvider}
</UrlSyncContextProvider>
);
}
26 changes: 15 additions & 11 deletions packages/scenes/src/components/SceneApp/SceneApp.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { createContext } from 'react';
import { Route, Switch } from 'react-router-dom';

import { DataRequestEnricher, SceneComponentProps } from '../../core/types';
Expand All @@ -20,20 +20,24 @@ export class SceneApp extends SceneObjectBase<SceneAppState> implements DataRequ
const { pages } = model.useState();

return (
<Switch>
{pages.map((page) => (
<Route
key={page.state.url}
exact={false}
path={page.state.url}
render={(props) => renderSceneComponentWithRouteProps(page, props)}
></Route>
))}
</Switch>
<SceneAppContext.Provider value={model}>
<Switch>
{pages.map((page) => (
<Route
key={page.state.url}
exact={false}
path={page.state.url}
render={(props) => renderSceneComponentWithRouteProps(page, props)}
></Route>
))}
</Switch>
</SceneAppContext.Provider>
);
};
}

export const SceneAppContext = createContext<SceneApp | null>(null);

const sceneAppCache = new Map<object, SceneApp>();

/**
Expand Down
9 changes: 5 additions & 4 deletions packages/scenes/src/components/SceneApp/SceneAppPageView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NavModelItem, UrlQueryMap } from '@grafana/data';
import { PluginPage } from '@grafana/runtime';
import React, { useEffect, useLayoutEffect } from 'react';
import React, { useContext, useEffect, useLayoutEffect } from 'react';

import { RouteComponentProps } from 'react-router-dom';
import { SceneObject } from '../../core/types';
Expand All @@ -9,10 +9,10 @@ import { SceneAppPage } from './SceneAppPage';
import { SceneAppDrilldownView, SceneAppPageLike } from './types';
import { getUrlWithAppState, renderSceneComponentWithRouteProps, useAppQueryParams } from './utils';
import { useUrlSync } from '../../services/useUrlSync';
import { SceneAppContext } from './SceneApp';

export interface Props {
page: SceneAppPageLike;
// activeTab?: SceneAppPageLike;
routeProps: RouteComponentProps;
}

Expand All @@ -21,8 +21,9 @@ export function SceneAppPageView({ page, routeProps }: Props) {
const containerState = containerPage.useState();
const params = useAppQueryParams();
const scene = page.getScene(routeProps.match);
const appContext = useContext(SceneAppContext);
const isInitialized = containerState.initializedScene === scene;
const {layout} = page.state;
const { layout } = page.state;

useLayoutEffect(() => {
// Before rendering scene components, we are making sure the URL sync is enabled for.
Expand All @@ -36,7 +37,7 @@ export function SceneAppPageView({ page, routeProps }: Props) {
return () => containerPage.setState({ initializedScene: undefined });
}, [containerPage]);

const urlSyncInitialized = useUrlSync(containerPage);
const urlSyncInitialized = useUrlSync(containerPage, appContext?.state.urlSyncOptions);

if (!isInitialized && !urlSyncInitialized) {
return null;
Expand Down
5 changes: 3 additions & 2 deletions packages/scenes/src/components/SceneApp/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ComponentType } from 'react';
import { DataRequestEnricher, SceneObject, SceneObjectState } from '../../core/types';
import { DataRequestEnricher, SceneObject, SceneObjectState, SceneUrlSyncOptions } from '../../core/types';
import { EmbeddedScene } from '../EmbeddedScene';
import { IconName, PageLayoutType } from '@grafana/data';

Expand All @@ -14,6 +14,7 @@ export interface SceneAppState extends SceneObjectState {
// Array of SceneAppPage objects that are considered app's top level pages
pages: SceneAppPageLike[];
name?: string;
urlSyncOptions?: SceneUrlSyncOptions;
}

export interface SceneAppRoute {
Expand Down Expand Up @@ -67,7 +68,7 @@ export interface SceneAppPageState extends SceneObjectState {
*/
getFallbackPage?: () => SceneAppPageLike;

layout?: PageLayoutType
layout?: PageLayoutType;
}

export interface SceneAppPageLike extends SceneObject<SceneAppPageState>, DataRequestEnricher {
Expand Down
8 changes: 6 additions & 2 deletions packages/scenes/src/core/SceneTimeRange.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,16 @@ export class SceneTimeRange extends SceneObjectBase<SceneTimeRangeState> impleme

// Only update if time range actually changed
if (update.from !== this.state.from || update.to !== this.state.to) {
this.setState(update);
this._urlSync.performBrowserHistoryAction(() => {
this.setState(update);
});
}
};

public onTimeZoneChange = (timeZone: TimeZone) => {
this.setState({ timeZone });
this._urlSync.performBrowserHistoryAction(() => {
this.setState({ timeZone });
});
};

public onRefresh = () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/scenes/src/core/sceneGraph/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
interpolate,
getAncestor,
getQueryController,
getUrlSyncManager,
} from './sceneGraph';

export const sceneGraph = {
Expand All @@ -30,4 +31,5 @@ export const sceneGraph = {
findAllObjects,
getAncestor,
getQueryController,
getUrlSyncManager,
};
18 changes: 18 additions & 0 deletions packages/scenes/src/core/sceneGraph/sceneGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getClosest } from './utils';
import { SceneQueryControllerLike, isQueryController } from '../../behaviors/SceneQueryController';
import { VariableInterpolation } from '@grafana/runtime';
import { QueryVariable } from '../../variables/variants/query/QueryVariable';
import { UrlSyncManagerLike } from '../../services/UrlSyncManager';

/**
* Get the closest node with variables
Expand Down Expand Up @@ -269,3 +270,20 @@ export function getQueryController(sceneObject: SceneObject): SceneQueryControll

return undefined;
}

/**
* Returns the closest SceneObject that has a state property with the
* name urlSyncManager that is of type UrlSyncManager
*/
export function getUrlSyncManager(sceneObject: SceneObject): UrlSyncManagerLike | undefined {
let parent: SceneObject | undefined = sceneObject;

while (parent) {
if ('urlSyncManager' in parent.state) {
return parent.state.urlSyncManager as UrlSyncManagerLike;
}
parent = parent.parent;
}

return undefined;
}
Loading

0 comments on commit 5f9bec6

Please sign in to comment.