Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UrlSync: Support browser history steps, remove singleton #878

Merged
merged 13 commits into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we allow to config updateUrlOnInit={true} createBrowserHistorySteps={true} here? Or the plugin dev should use a different UrlSyncContext provided to override those?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ivanortegaalba well scenes react is kind still not used for real anywhere, so figured we can leave these enabled by default until for now

{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
Loading