From 5f9bec64c332ba168775af731dc6099ddd37bff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 4 Sep 2024 15:38:06 +0200 Subject: [PATCH] UrlSync: Support browser history steps, remove singleton (#878) --- docusaurus/docs/scene-app.md | 4 + docusaurus/docs/url-sync.md | 92 +++++++++++++ docusaurus/website/sidebars.js | 12 +- packages/scenes-app/src/demos/urlSyncTest.tsx | 4 +- .../scenes-app/src/pages/DemoListPage.tsx | 4 + .../src/contexts/SceneContextObject.tsx | 8 +- .../src/contexts/SceneContextProvider.tsx | 15 +-- .../src/components/SceneApp/SceneApp.tsx | 26 ++-- .../components/SceneApp/SceneAppPageView.tsx | 9 +- .../scenes/src/components/SceneApp/types.ts | 5 +- packages/scenes/src/core/SceneTimeRange.tsx | 8 +- packages/scenes/src/core/sceneGraph/index.ts | 2 + .../scenes/src/core/sceneGraph/sceneGraph.ts | 18 +++ packages/scenes/src/core/types.ts | 16 +++ packages/scenes/src/index.ts | 2 +- .../src/services/SceneObjectUrlSyncConfig.ts | 11 ++ .../src/services/UrlSyncContextProvider.ts | 13 +- .../src/services/UrlSyncManager.test.ts | 54 ++++++-- .../scenes/src/services/UrlSyncManager.ts | 124 +++++++++++++----- packages/scenes/src/services/useUrlSync.ts | 10 +- .../utils/test/updateUrlStateAndSyncState.tsx | 4 +- 21 files changed, 343 insertions(+), 98 deletions(-) create mode 100644 docusaurus/docs/url-sync.md diff --git a/docusaurus/docs/scene-app.md b/docusaurus/docs/scene-app.md index 91a5bae96..3ca0d20d1 100644 --- a/docusaurus/docs/scene-app.md +++ b/docusaurus/docs/scene-app.md @@ -24,6 +24,10 @@ Define a new Scenes app using the `SceneApp` object : function getSceneApp() { return new SceneApp({ pages: [], + urlSyncOptions: { + updateUrlOnInit: true, + createBrowserHistorySteps: true + } }); } ``` diff --git a/docusaurus/docs/url-sync.md b/docusaurus/docs/url-sync.md new file mode 100644 index 000000000..008026933 --- /dev/null +++ b/docusaurus/docs/url-sync.md @@ -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 + +``` + +## 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 { + 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 = {}; + + 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}) + }) + } +} +``` + diff --git a/docusaurus/website/sidebars.js b/docusaurus/website/sidebars.js index 23399f365..c72c8fa00 100644 --- a/docusaurus/website/sidebars.js +++ b/docusaurus/website/sidebars.js @@ -58,6 +58,7 @@ const sidebars = { 'advanced-behaviors', 'advanced-custom-datasource', 'advanced-time-range-comparison', + 'url-sync', ], }, { @@ -65,13 +66,10 @@ const sidebars = { 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; diff --git a/packages/scenes-app/src/demos/urlSyncTest.tsx b/packages/scenes-app/src/demos/urlSyncTest.tsx index 29d97a3d0..3140bd1c4 100644 --- a/packages/scenes-app/src/demos/urlSyncTest.tsx +++ b/packages/scenes-app/src/demos/urlSyncTest.tsx @@ -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({ @@ -102,7 +102,7 @@ class DynamicSubScene extends SceneObjectBase { private addScene() { const scene = buildNewSubScene(); - getUrlSyncManager().handleNewObject(scene); + this.publishEvent(new NewSceneObjectAddedEvent(scene), true); this.setState({ scene }); } diff --git a/packages/scenes-app/src/pages/DemoListPage.tsx b/packages/scenes-app/src/pages/DemoListPage.tsx index a601444b5..9fc19ea1c 100644 --- a/packages/scenes-app/src/pages/DemoListPage.tsx +++ b/packages/scenes-app/src/pages/DemoListPage.tsx @@ -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', diff --git a/packages/scenes-react/src/contexts/SceneContextObject.tsx b/packages/scenes-react/src/contexts/SceneContextObject.tsx index 9ace0c2dc..efad0a575 100644 --- a/packages/scenes-react/src/contexts/SceneContextObject.tsx +++ b/packages/scenes-react/src/contexts/SceneContextObject.tsx @@ -4,7 +4,7 @@ import { SceneObjectState, SceneVariable, SceneVariableSet, - getUrlSyncManager, + NewSceneObjectAddedEvent, } from '@grafana/scenes'; import { writeSceneLog } from '../utils'; @@ -23,7 +23,7 @@ export class SceneContextObject extends SceneObjectBase } 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}`); @@ -54,7 +54,7 @@ export class SceneContextObject extends SceneObjectBase 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] }); @@ -72,6 +72,8 @@ export class SceneContextObject extends SceneObjectBase } 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}`); diff --git a/packages/scenes-react/src/contexts/SceneContextProvider.tsx b/packages/scenes-react/src/contexts/SceneContextProvider.tsx index 3193f934d..9858bccff 100644 --- a/packages/scenes-react/src/contexts/SceneContextProvider.tsx +++ b/packages/scenes-react/src/contexts/SceneContextProvider.tsx @@ -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'; @@ -51,7 +45,6 @@ export function SceneContextProvider({ children, timeRange, withQueryController const childContext = new SceneContextObject(state); if (parentContext) { - getUrlSyncManager().handleNewObject(childContext); parentContext.addChildContext(childContext); } @@ -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 {innerProvider}; + return ( + + {innerProvider} + + ); } diff --git a/packages/scenes/src/components/SceneApp/SceneApp.tsx b/packages/scenes/src/components/SceneApp/SceneApp.tsx index e5491358d..fc4583fc4 100644 --- a/packages/scenes/src/components/SceneApp/SceneApp.tsx +++ b/packages/scenes/src/components/SceneApp/SceneApp.tsx @@ -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'; @@ -20,20 +20,24 @@ export class SceneApp extends SceneObjectBase implements DataRequ const { pages } = model.useState(); return ( - - {pages.map((page) => ( - renderSceneComponentWithRouteProps(page, props)} - > - ))} - + + + {pages.map((page) => ( + renderSceneComponentWithRouteProps(page, props)} + > + ))} + + ); }; } +export const SceneAppContext = createContext(null); + const sceneAppCache = new Map(); /** diff --git a/packages/scenes/src/components/SceneApp/SceneAppPageView.tsx b/packages/scenes/src/components/SceneApp/SceneAppPageView.tsx index 41555dc97..3bf312b7b 100644 --- a/packages/scenes/src/components/SceneApp/SceneAppPageView.tsx +++ b/packages/scenes/src/components/SceneApp/SceneAppPageView.tsx @@ -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'; @@ -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; } @@ -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. @@ -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; diff --git a/packages/scenes/src/components/SceneApp/types.ts b/packages/scenes/src/components/SceneApp/types.ts index cf4140576..054c8c72b 100644 --- a/packages/scenes/src/components/SceneApp/types.ts +++ b/packages/scenes/src/components/SceneApp/types.ts @@ -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'; @@ -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 { @@ -67,7 +68,7 @@ export interface SceneAppPageState extends SceneObjectState { */ getFallbackPage?: () => SceneAppPageLike; - layout?: PageLayoutType + layout?: PageLayoutType; } export interface SceneAppPageLike extends SceneObject, DataRequestEnricher { diff --git a/packages/scenes/src/core/SceneTimeRange.tsx b/packages/scenes/src/core/SceneTimeRange.tsx index 850038d95..d217e5798 100644 --- a/packages/scenes/src/core/SceneTimeRange.tsx +++ b/packages/scenes/src/core/SceneTimeRange.tsx @@ -177,12 +177,16 @@ export class SceneTimeRange extends SceneObjectBase 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 = () => { diff --git a/packages/scenes/src/core/sceneGraph/index.ts b/packages/scenes/src/core/sceneGraph/index.ts index c22c4454b..2c407ee8c 100644 --- a/packages/scenes/src/core/sceneGraph/index.ts +++ b/packages/scenes/src/core/sceneGraph/index.ts @@ -13,6 +13,7 @@ import { interpolate, getAncestor, getQueryController, + getUrlSyncManager, } from './sceneGraph'; export const sceneGraph = { @@ -30,4 +31,5 @@ export const sceneGraph = { findAllObjects, getAncestor, getQueryController, + getUrlSyncManager, }; diff --git a/packages/scenes/src/core/sceneGraph/sceneGraph.ts b/packages/scenes/src/core/sceneGraph/sceneGraph.ts index 169729210..cdbac86e4 100644 --- a/packages/scenes/src/core/sceneGraph/sceneGraph.ts +++ b/packages/scenes/src/core/sceneGraph/sceneGraph.ts @@ -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 @@ -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; +} diff --git a/packages/scenes/src/core/types.ts b/packages/scenes/src/core/types.ts index 23b9bcd4d..a40fa57fd 100644 --- a/packages/scenes/src/core/types.ts +++ b/packages/scenes/src/core/types.ts @@ -179,12 +179,14 @@ export function isSceneObject(obj: any): obj is SceneObject { export interface SceneObjectWithUrlSync extends SceneObject { getUrlState(): SceneObjectUrlValues; updateFromUrl(values: SceneObjectUrlValues): void; + shouldCreateHistoryStep?(values: SceneObjectUrlValues): boolean; } export interface SceneObjectUrlSyncHandler { getKeys(): string[]; getUrlState(): SceneObjectUrlValues; updateFromUrl(values: SceneObjectUrlValues): void; + shouldCreateHistoryStep?(values: SceneObjectUrlValues): boolean; } export interface DataRequestEnricher { @@ -276,3 +278,17 @@ export interface SceneDataQuery extends DataQuery { // Opt this query out of time window comparison timeRangeCompare?: boolean; } + +export interface SceneUrlSyncOptions { + /** + * This will update the url to contain all scene url state + * when the scene is initialized. Important for browser history "back" actions. + */ + updateUrlOnInit?: boolean; + /** + * This is only supported by some objects if they implement + * shouldCreateHistoryStep where they can control what changes + * url changes should add a new browser history entry. + */ + createBrowserHistorySteps?: boolean; +} diff --git a/packages/scenes/src/index.ts b/packages/scenes/src/index.ts index bcb6b7ca3..d2364f5db 100644 --- a/packages/scenes/src/index.ts +++ b/packages/scenes/src/index.ts @@ -69,7 +69,7 @@ export { AdHocFiltersVariable } from './variables/adhoc/AdHocFiltersVariable'; export { GroupByVariable } from './variables/groupby/GroupByVariable'; export { type MacroVariableConstructor } from './variables/macros/types'; -export { type UrlSyncManagerLike, UrlSyncManager, getUrlSyncManager } from './services/UrlSyncManager'; +export { type UrlSyncManagerLike, UrlSyncManager, NewSceneObjectAddedEvent } from './services/UrlSyncManager'; export { useUrlSync } from './services/useUrlSync'; export { UrlSyncContextProvider } from './services/UrlSyncContextProvider'; export { SceneObjectUrlSyncConfig } from './services/SceneObjectUrlSyncConfig'; diff --git a/packages/scenes/src/services/SceneObjectUrlSyncConfig.ts b/packages/scenes/src/services/SceneObjectUrlSyncConfig.ts index 48c91b3bd..38b3db3af 100644 --- a/packages/scenes/src/services/SceneObjectUrlSyncConfig.ts +++ b/packages/scenes/src/services/SceneObjectUrlSyncConfig.ts @@ -6,6 +6,7 @@ interface SceneObjectUrlSyncConfigOptions { export class SceneObjectUrlSyncConfig implements SceneObjectUrlSyncHandler { private _keys: string[] | (() => string[]); + private _nextChangeShouldAddHistoryStep = false; public constructor(private _sceneObject: SceneObjectWithUrlSync, _options: SceneObjectUrlSyncConfigOptions) { this._keys = _options.keys; @@ -26,4 +27,14 @@ export class SceneObjectUrlSyncConfig implements SceneObjectUrlSyncHandler { public updateFromUrl(values: SceneObjectUrlValues): void { this._sceneObject.updateFromUrl(values); } + + public performBrowserHistoryAction(callback: () => void) { + this._nextChangeShouldAddHistoryStep = true; + callback(); + this._nextChangeShouldAddHistoryStep = false; + } + + public shouldCreateHistoryStep(values: SceneObjectUrlValues): boolean { + return this._nextChangeShouldAddHistoryStep; + } } diff --git a/packages/scenes/src/services/UrlSyncContextProvider.ts b/packages/scenes/src/services/UrlSyncContextProvider.ts index f2858da67..ec7c54420 100644 --- a/packages/scenes/src/services/UrlSyncContextProvider.ts +++ b/packages/scenes/src/services/UrlSyncContextProvider.ts @@ -1,7 +1,7 @@ -import { SceneObject } from '../core/types'; +import { SceneObject, SceneUrlSyncOptions } from '../core/types'; import { useUrlSync } from './useUrlSync'; -export interface UrlSyncContextProviderProps { +export interface UrlSyncContextProviderProps extends SceneUrlSyncOptions { scene: SceneObject; children: React.ReactNode; } @@ -10,8 +10,13 @@ export interface UrlSyncContextProviderProps { * Right now this is actually not defining a context, but think it might in the future (with UrlSyncManager as the context value) */ -export function UrlSyncContextProvider({ children, scene }: UrlSyncContextProviderProps) { - const isInitialized = useUrlSync(scene); +export function UrlSyncContextProvider({ + children, + scene, + updateUrlOnInit, + createBrowserHistorySteps, +}: UrlSyncContextProviderProps) { + const isInitialized = useUrlSync(scene, { updateUrlOnInit, createBrowserHistorySteps }); if (!isInitialized) { return null; diff --git a/packages/scenes/src/services/UrlSyncManager.test.ts b/packages/scenes/src/services/UrlSyncManager.test.ts index ce12763f8..ed29e0c63 100644 --- a/packages/scenes/src/services/UrlSyncManager.test.ts +++ b/packages/scenes/src/services/UrlSyncManager.test.ts @@ -60,6 +60,7 @@ describe('UrlSyncManager', () => { beforeEach(() => { locationUpdates = []; + deactivate = () => {}; listenUnregister = locationService.getHistory().listen((location) => { locationUpdates.push(location); }); @@ -116,6 +117,35 @@ describe('UrlSyncManager', () => { }); }); + describe('Initiating url from state', () => { + it('Should sync initial scene state with url', () => { + const obj = new TestObj({ name: 'test' }); + scene = new SceneFlexLayout({ + children: [new SceneFlexItem({ body: obj })], + }); + + urlManager = new UrlSyncManager({ updateUrlOnInit: true }); + urlManager.initSync(scene); + + expect(locationUpdates.length).toBe(1); + expect(locationUpdates[0].search).toBe('?name=test'); + }); + + it('Should not update url if there is no difference', () => { + const obj = new TestObj({ name: 'test' }); + scene = new SceneFlexLayout({ + children: [new SceneFlexItem({ body: obj })], + }); + + locationService.partial({ name: 'test' }); + + urlManager = new UrlSyncManager(); + urlManager.initSync(scene); + + expect(locationUpdates.length).toBe(1); + }); + }); + describe('Initiating state from url', () => { it('Should sync nested objects created during sync', () => { const obj = new TestObj({ name: 'test' }); @@ -228,7 +258,7 @@ describe('UrlSyncManager', () => { $timeRange: outerTimeRange, }); - urlManager = new UrlSyncManager(); + urlManager = new UrlSyncManager({ updateUrlOnInit: true }); urlManager.initSync(scene); deactivate = scene.activate(); @@ -238,7 +268,9 @@ describe('UrlSyncManager', () => { // Should use unique key based where it is in the scene expect(locationService.getSearchObject()).toEqual({ + from: 'now-6h', ['from-2']: 'now-10m', + to: 'now', ['to-2']: 'now', }); @@ -259,7 +291,7 @@ describe('UrlSyncManager', () => { // should not update the first object expect(outerTimeRange.state.from).toBe('now-20m'); // Should not cause another url update - expect(locationUpdates.length).toBe(3); + expect(locationUpdates.length).toBe(4); }); it('should handle dynamically added objects that use same key', () => { @@ -438,7 +470,7 @@ describe('UrlSyncManager', () => { children: [obj1], }); - urlManager = new UrlSyncManager(); + urlManager = new UrlSyncManager({ updateUrlOnInit: true }); urlManager.initSync(scene1); deactivate = scene1.activate(); @@ -448,7 +480,7 @@ describe('UrlSyncManager', () => { obj1.setState({ name: 'B' }); // Should not update url - expect(locationService.getSearchObject().name).toBeUndefined(); + expect(locationService.getSearchObject().name).toBe('A'); // When updating via url updateUrlStateAndSyncState({ name: 'Hello' }, urlManager); @@ -487,23 +519,23 @@ describe('UrlSyncManager', () => { describe('When init sync root is not scene root', () => { it('Should sync init root', async () => { - const scene = new TestObj({ + const scene = new TestObj({ name: 'scene-root', - nested: new TestObj({ + nested: new TestObj({ name: 'url-sync-root', - }) - }); + }), + }); urlManager = new UrlSyncManager(); - + locationService.push(`/?name=test1`); urlManager.initSync(scene.state.nested!); - deactivate = activateFullSceneTree(scene); + deactivate = activateFullSceneTree(scene); // Only updated the nested scene (as it's the only part of scene tree that is synced) expect(scene.state.nested?.state.name).toEqual('test1'); - + // Unchanged expect(scene.state.name).toEqual('scene-root'); }); diff --git a/packages/scenes/src/services/UrlSyncManager.ts b/packages/scenes/src/services/UrlSyncManager.ts index a8c19382b..de7f145b7 100644 --- a/packages/scenes/src/services/UrlSyncManager.ts +++ b/packages/scenes/src/services/UrlSyncManager.ts @@ -3,45 +3,81 @@ import { Location } from 'history'; import { locationService } from '@grafana/runtime'; import { SceneObjectStateChangedEvent } from '../core/events'; -import { SceneObject, SceneObjectUrlValues } from '../core/types'; +import { SceneObject, SceneObjectUrlValues, SceneUrlSyncOptions } from '../core/types'; import { writeSceneLog } from '../utils/writeSceneLog'; -import { Unsubscribable } from 'rxjs'; +import { Subscription } from 'rxjs'; import { UniqueUrlKeyMapper } from './UniqueUrlKeyMapper'; import { getUrlState, isUrlValueEqual, syncStateFromUrl } from './utils'; +import { BusEventWithPayload } from '@grafana/data'; +import { useMemo } from 'react'; export interface UrlSyncManagerLike { initSync(root: SceneObject): void; cleanUp(root: SceneObject): void; - getUrlState(root: SceneObject): SceneObjectUrlValues; handleNewLocation(location: Location): void; handleNewObject(sceneObj: SceneObject): void; } +/** + * Notify the url sync manager of a new object that has been added to the scene + * that needs to init state from URL. + */ +export class NewSceneObjectAddedEvent extends BusEventWithPayload { + public static readonly type = 'new-scene-object-added'; +} + export class UrlSyncManager implements UrlSyncManagerLike { private _urlKeyMapper = new UniqueUrlKeyMapper(); private _sceneRoot?: SceneObject; - private _stateSub: Unsubscribable | null = null; + private _subs: Subscription | undefined; private _lastLocation: Location | undefined; private _paramsCache = new UrlParamsCache(); + private _options: SceneUrlSyncOptions; + + public constructor(_options: SceneUrlSyncOptions = {}) { + this._options = _options; + } /** * Updates the current scene state to match URL state. */ public initSync(root: SceneObject) { - if (this._stateSub) { + if (this._subs) { writeSceneLog('UrlSyncManager', 'Unregister previous scene state subscription', this._sceneRoot?.state.key); - this._stateSub.unsubscribe(); + this._subs.unsubscribe(); } writeSceneLog('UrlSyncManager', 'init', root.state.key); this._sceneRoot = root; - this._stateSub = root.subscribeToEvent(SceneObjectStateChangedEvent, this.#onStateChanged); + this._subs = new Subscription(); + + this._subs.add( + root.subscribeToEvent(SceneObjectStateChangedEvent, (evt) => { + this.handleSceneObjectStateChanged(evt.payload.changedObject); + }) + ); + + this._subs.add( + root.subscribeToEvent(NewSceneObjectAddedEvent, (evt) => { + this.handleNewObject(evt.payload); + }) + ); this._urlKeyMapper.clear(); this._lastLocation = locationService.getLocation(); + // Sync current url with state this.handleNewObject(this._sceneRoot); + + if (this._options.updateUrlOnInit) { + // Get current url state and update url to match + const urlState = getUrlState(root); + + if (isUrlStateDifferent(urlState, this._paramsCache.getParams())) { + locationService.partial(urlState, true); + } + } } public cleanUp(root: SceneObject) { @@ -52,9 +88,10 @@ export class UrlSyncManager implements UrlSyncManagerLike { writeSceneLog('UrlSyncManager', 'Clean up'); - if (this._stateSub) { - this._stateSub.unsubscribe(); - this._stateSub = null; + if (this._subs) { + this._subs.unsubscribe(); + this._subs = undefined; + writeSceneLog( 'UrlSyncManager', 'Root deactived, unsub to state', @@ -88,33 +125,36 @@ export class UrlSyncManager implements UrlSyncManagerLike { syncStateFromUrl(sceneObj, this._paramsCache.getParams(), this._urlKeyMapper); } - #onStateChanged = ({ payload }: SceneObjectStateChangedEvent) => { - const changedObject = payload.changedObject; + private handleSceneObjectStateChanged(changedObject: SceneObject) { + if (!changedObject.urlSync) { + return; + } - if (changedObject.urlSync) { - const newUrlState = changedObject.urlSync.getUrlState(); + const newUrlState = changedObject.urlSync.getUrlState(); - const searchParams = locationService.getSearch(); - const mappedUpdated: SceneObjectUrlValues = {}; + const searchParams = locationService.getSearch(); + const mappedUpdated: SceneObjectUrlValues = {}; - for (const [key, newUrlValue] of Object.entries(newUrlState)) { - const uniqueKey = this._urlKeyMapper.getUniqueKey(key, changedObject); - const currentUrlValue = searchParams.getAll(uniqueKey); + for (const [key, newUrlValue] of Object.entries(newUrlState)) { + const uniqueKey = this._urlKeyMapper.getUniqueKey(key, changedObject); + const currentUrlValue = searchParams.getAll(uniqueKey); - if (!isUrlValueEqual(currentUrlValue, newUrlValue)) { - mappedUpdated[uniqueKey] = newUrlValue; - } + if (!isUrlValueEqual(currentUrlValue, newUrlValue)) { + mappedUpdated[uniqueKey] = newUrlValue; } + } - if (Object.keys(mappedUpdated).length > 0) { - writeSceneLog('UrlSyncManager', 'onStateChange updating URL'); - locationService.partial(mappedUpdated, true); + if (Object.keys(mappedUpdated).length > 0) { + const shouldCreateHistoryEntry = changedObject.urlSync.shouldCreateHistoryStep?.(newUrlState); + const shouldReplace = shouldCreateHistoryEntry !== true; - /// Mark the location already handled - this._lastLocation = locationService.getLocation(); - } + writeSceneLog('UrlSyncManager', 'onStateChange updating URL'); + locationService.partial(mappedUpdated, shouldReplace); + + /// Mark the location already handled + this._lastLocation = locationService.getLocation(); } - }; + } public getUrlState(root: SceneObject): SceneObjectUrlValues { return getUrlState(root); @@ -139,12 +179,26 @@ class UrlParamsCache { } } -let urlSyncManager: UrlSyncManagerLike | undefined; - -export function getUrlSyncManager(): UrlSyncManagerLike { - if (!urlSyncManager) { - urlSyncManager = new UrlSyncManager(); +function isUrlStateDifferent(sceneUrlState: SceneObjectUrlValues, currentParams: URLSearchParams) { + for (let key in sceneUrlState) { + if (!isUrlValueEqual(currentParams.getAll(key), sceneUrlState[key])) { + return true; + } } - return urlSyncManager; + return false; +} + +/** + * Creates a new memoized instance of the UrlSyncManager based on options + */ +export function useUrlSyncManager(options: SceneUrlSyncOptions): UrlSyncManagerLike { + return useMemo( + () => + new UrlSyncManager({ + updateUrlOnInit: options.updateUrlOnInit, + createBrowserHistorySteps: options.createBrowserHistorySteps, + }), + [options.updateUrlOnInit, options.createBrowserHistorySteps] + ); } diff --git a/packages/scenes/src/services/useUrlSync.ts b/packages/scenes/src/services/useUrlSync.ts index 653cb8f2b..608cc0c0c 100644 --- a/packages/scenes/src/services/useUrlSync.ts +++ b/packages/scenes/src/services/useUrlSync.ts @@ -1,14 +1,14 @@ -import { SceneObject } from '../core/types'; +import { SceneObject, SceneUrlSyncOptions } from '../core/types'; import { useEffect, useState } from 'react'; import { useLocation } from 'react-router-dom'; -import { getUrlSyncManager } from './UrlSyncManager'; import { locationService } from '@grafana/runtime'; import { writeSceneLog } from '../utils/writeSceneLog'; +import { useUrlSyncManager } from './UrlSyncManager'; -export function useUrlSync(sceneRoot: SceneObject): boolean { - const urlSyncManager = getUrlSyncManager(); +export function useUrlSync(sceneRoot: SceneObject, options: SceneUrlSyncOptions = {}): boolean { const location = useLocation(); const [isInitialized, setIsInitialized] = useState(false); + const urlSyncManager = useUrlSyncManager(options); useEffect(() => { urlSyncManager.initSync(sceneRoot); @@ -22,7 +22,7 @@ export function useUrlSync(sceneRoot: SceneObject): boolean { const locationToHandle = latestLocation !== location ? latestLocation : location; if (latestLocation !== location) { - writeSceneLog('useUrlSync', 'latestLocation different from location') + writeSceneLog('useUrlSync', 'latestLocation different from location'); } urlSyncManager.handleNewLocation(locationToHandle); diff --git a/packages/scenes/utils/test/updateUrlStateAndSyncState.tsx b/packages/scenes/utils/test/updateUrlStateAndSyncState.tsx index e3328d422..411510d95 100644 --- a/packages/scenes/utils/test/updateUrlStateAndSyncState.tsx +++ b/packages/scenes/utils/test/updateUrlStateAndSyncState.tsx @@ -1,8 +1,8 @@ -import { getUrlSyncManager } from '../../src'; import { UrlQueryMap } from '@grafana/data'; import { locationService } from '@grafana/runtime'; +import { UrlSyncManager } from '../../src'; -export function updateUrlStateAndSyncState(searchParams: UrlQueryMap, urlManager = getUrlSyncManager()) { +export function updateUrlStateAndSyncState(searchParams: UrlQueryMap, urlManager: UrlSyncManager) { locationService.partial(searchParams); urlManager.handleNewLocation(locationService.getLocation()); }