diff --git a/packages/scenes/CHANGELOG.md b/packages/scenes/CHANGELOG.md index 4cf633064..935d4938b 100644 --- a/packages/scenes/CHANGELOG.md +++ b/packages/scenes/CHANGELOG.md @@ -147,7 +147,7 @@ #### 🚀 Enhancement -- Variables: Query - Add optional `definition` prop to state [#489](https://github.com/grafana/scenes/pull/489) ([@axelavargas](https://github.com/axelavargas)) +- Variables: Query - Add optional `definition` prop to state [#489](https://github.com/grafana/scenes/pull/489) ([@axelavargas](https://github.com/axelavargas)) #### 🐛 Bug Fix @@ -164,7 +164,7 @@ #### 🚀 Enhancement -- Macros: Support $_interval[_ms] variable [#487](https://github.com/grafana/scenes/pull/487) ([@dprokop](https://github.com/dprokop)) +- Macros: Support $\_interval[_ms] variable [#487](https://github.com/grafana/scenes/pull/487) ([@dprokop](https://github.com/dprokop)) #### 🐛 Bug Fix @@ -338,7 +338,7 @@ - Variables: Support for variables on lower levels to depend on variables on higher levels [#443](https://github.com/grafana/scenes/pull/443) ([@torkelo](https://github.com/torkelo)) - VizPanel: Handle empty arrays when merging new panel options [#447](https://github.com/grafana/scenes/pull/447) ([@javiruiz01](https://github.com/javiruiz01)) - PanelContext: Eventbus should not filter out local events [#445](https://github.com/grafana/scenes/pull/445) ([@torkelo](https://github.com/torkelo)) -- Variables: Support __org and __user variable macros [#449](https://github.com/grafana/scenes/pull/449) ([@torkelo](https://github.com/torkelo)) +- Variables: Support **org and **user variable macros [#449](https://github.com/grafana/scenes/pull/449) ([@torkelo](https://github.com/torkelo)) - SceneQueryRunner: Fixes adhoc filters when using a variable data source [#422](https://github.com/grafana/scenes/pull/422) ([@torkelo](https://github.com/torkelo)) - VizPanel: Support passing legacyPanelId to PanelProps [#446](https://github.com/grafana/scenes/pull/446) ([@torkelo](https://github.com/torkelo)) diff --git a/packages/scenes/src/index.ts b/packages/scenes/src/index.ts index 7b40c18b6..61a49ae1a 100644 --- a/packages/scenes/src/index.ts +++ b/packages/scenes/src/index.ts @@ -2,6 +2,7 @@ import { getUrlWithAppState } from './components/SceneApp/utils'; import { registerRuntimePanelPlugin } from './components/VizPanel/registerRuntimePanelPlugin'; import { cloneSceneObjectState } from './core/sceneGraph/utils'; import { registerRuntimeDataSource } from './querying/RuntimeDataSource'; +import { getUrlState, syncStateFromSearchParams } from './services/utils'; import { registerVariableMacro } from './variables/macros'; import { renderPrometheusLabelFilters } from './variables/utils'; import { @@ -50,7 +51,7 @@ export { AdHocFilterSet } from './variables/adhoc/AdHocFiltersSet'; export { AdHocFiltersVariable } from './variables/adhoc/AdHocFiltersVariable'; export { type MacroVariableConstructor } from './variables/macros/types'; -export { type UrlSyncManagerLike as UrlSyncManager, getUrlSyncManager } from './services/UrlSyncManager'; +export { type UrlSyncManagerLike, UrlSyncManager, getUrlSyncManager } from './services/UrlSyncManager'; export { SceneObjectUrlSyncConfig } from './services/SceneObjectUrlSyncConfig'; export { EmbeddedScene, type EmbeddedSceneState } from './components/EmbeddedScene'; @@ -98,6 +99,8 @@ export const sceneUtils = { registerRuntimeDataSource, registerVariableMacro, cloneSceneObjectState, + syncStateFromSearchParams, + getUrlState, renderPrometheusLabelFilters, // Variable guards diff --git a/packages/scenes/src/services/UniqueUrlKeyMapper.ts b/packages/scenes/src/services/UniqueUrlKeyMapper.ts new file mode 100644 index 000000000..87d48f8ce --- /dev/null +++ b/packages/scenes/src/services/UniqueUrlKeyMapper.ts @@ -0,0 +1,45 @@ +import { SceneObject } from '../core/types'; + +export interface SceneObjectWithDepth { + sceneObject: SceneObject; + depth: number; +} + +export class UniqueUrlKeyMapper { + private index = new Map(); + + public getUniqueKey(key: string, obj: SceneObject) { + const objectsWithKey = this.index.get(key); + if (!objectsWithKey) { + throw new Error("Cannot find any scene object that uses the key '" + key + "'"); + } + + const address = objectsWithKey.findIndex((o) => o.sceneObject === obj); + if (address > 0) { + return `${key}-${address + 1}`; + } + + return key; + } + + public rebuildIndex(root: SceneObject) { + this.index.clear(); + this.buildIndex(root, 0); + } + + private buildIndex(sceneObject: SceneObject, depth: number) { + if (sceneObject.urlSync) { + for (const key of sceneObject.urlSync.getKeys()) { + const hit = this.index.get(key); + if (hit) { + hit.push({ sceneObject, depth }); + hit.sort((a, b) => a.depth - b.depth); + } else { + this.index.set(key, [{ sceneObject, depth }]); + } + } + } + + sceneObject.forEachChild((child) => this.buildIndex(child, depth + 1)); + } +} diff --git a/packages/scenes/src/services/UrlSyncManager.test.ts b/packages/scenes/src/services/UrlSyncManager.test.ts index e336df0b7..7881815da 100644 --- a/packages/scenes/src/services/UrlSyncManager.test.ts +++ b/packages/scenes/src/services/UrlSyncManager.test.ts @@ -5,23 +5,29 @@ import { locationService } from '@grafana/runtime'; import { SceneFlexItem, SceneFlexLayout } from '../components/layout/SceneFlexLayout'; import { SceneObjectBase } from '../core/SceneObjectBase'; import { SceneTimeRange } from '../core/SceneTimeRange'; -import { SceneObjectState, SceneObject, SceneObjectUrlValues } from '../core/types'; +import { SceneObjectState, SceneObjectUrlValues } from '../core/types'; import { SceneObjectUrlSyncConfig } from './SceneObjectUrlSyncConfig'; -import { isUrlValueEqual, UrlSyncManager } from './UrlSyncManager'; +import { UrlSyncManager } from './UrlSyncManager'; interface TestObjectState extends SceneObjectState { name: string; optional?: string; array?: string[]; other?: string; + nested?: TestObj; } class TestObj extends SceneObjectBase { - protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['name', 'array', 'optional'] }); + protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['name', 'array', 'optional', 'nested'] }); public getUrlState() { - return { name: this.state.name, array: this.state.array, optional: this.state.optional }; + return { + name: this.state.name, + array: this.state.array, + optional: this.state.optional, + nested: this.state.nested ? 'nested' : undefined, + }; } public updateFromUrl(values: SceneObjectUrlValues) { @@ -36,6 +42,12 @@ class TestObj extends SceneObjectBase { if (values.hasOwnProperty('optional')) { this.setState({ optional: typeof values.optional === 'string' ? values.optional : undefined }); } + + if (values.hasOwnProperty('nested')) { + this.setState({ nested: new TestObj({ name: 'default name' }) }); + } else if (this.state.nested) { + this.setState({ nested: undefined }); + } } } @@ -43,7 +55,7 @@ describe('UrlSyncManager', () => { let urlManager: UrlSyncManager; let locationUpdates: Location[] = []; let listenUnregister: () => void; - let scene: SceneObject; + let scene: SceneFlexLayout; let deactivate = () => {}; beforeEach(() => { @@ -57,6 +69,7 @@ describe('UrlSyncManager', () => { deactivate(); locationService.push('/'); listenUnregister(); + urlManager.cleanUp(scene); }); describe('getUrlState', () => { @@ -103,6 +116,42 @@ describe('UrlSyncManager', () => { }); }); + describe('Initiating state from url', () => { + it('Should sync nested objects created during sync', () => { + const obj = new TestObj({ name: 'test' }); + scene = new SceneFlexLayout({ + children: [new SceneFlexItem({ body: obj })], + }); + + locationService.partial({ name: 'name-from-url', nested: 'nested', 'name-2': 'nested name from initial url' }); + + urlManager = new UrlSyncManager(); + urlManager.initSync(scene); + + deactivate = scene.activate(); + + expect(obj.state.nested?.state.name).toEqual('nested name from initial url'); + }); + + // it('Should get url state from with objects created after initial sync', () => { + // const obj = new TestObj({ name: 'test' }); + // scene = new SceneFlexLayout({ + // children: [], + // }); + + // locationService.partial({ name: 'name-from-url' }); + + // urlManager = new UrlSyncManager(); + // urlManager.initSync(scene); + + // deactivate = scene.activate(); + + // scene.setState({ children: [new SceneFlexItem({ body: obj })] }); + + // expect(obj.state.name).toEqual('name-from-url'); + // }); + }); + describe('When url changes', () => { it('should update state', () => { const obj = new TestObj({ name: 'test' }); @@ -372,18 +421,3 @@ describe('UrlSyncManager', () => { }); }); }); - -describe('isUrlValueEqual', () => { - it('should handle all cases', () => { - expect(isUrlValueEqual([], [])).toBe(true); - expect(isUrlValueEqual([], undefined)).toBe(true); - expect(isUrlValueEqual([], null)).toBe(true); - - expect(isUrlValueEqual(['asd'], 'asd')).toBe(true); - expect(isUrlValueEqual(['asd'], ['asd'])).toBe(true); - expect(isUrlValueEqual(['asd', '2'], ['asd', '2'])).toBe(true); - - expect(isUrlValueEqual(['asd', '2'], 'asd')).toBe(false); - expect(isUrlValueEqual(['asd2'], 'asd')).toBe(false); - }); -}); diff --git a/packages/scenes/src/services/UrlSyncManager.ts b/packages/scenes/src/services/UrlSyncManager.ts index c08db77f0..f37554c99 100644 --- a/packages/scenes/src/services/UrlSyncManager.ts +++ b/packages/scenes/src/services/UrlSyncManager.ts @@ -1,12 +1,13 @@ import { Location, UnregisterCallback } from 'history'; -import { isEqual } from 'lodash'; import { locationService } from '@grafana/runtime'; import { SceneObjectStateChangedEvent } from '../core/events'; -import { SceneObject, SceneObjectUrlValue, SceneObjectUrlValues } from '../core/types'; +import { SceneObject, SceneObjectUrlValues } from '../core/types'; import { writeSceneLog } from '../utils/writeSceneLog'; import { Unsubscribable } from 'rxjs'; +import { UniqueUrlKeyMapper } from './UniqueUrlKeyMapper'; +import { getUrlState, isUrlValueEqual, syncStateFromUrl } from './utils'; export interface UrlSyncManagerLike { initSync(root: SceneObject): void; @@ -15,7 +16,7 @@ export interface UrlSyncManagerLike { } export class UrlSyncManager implements UrlSyncManagerLike { - private urlKeyMapper = new UniqueUrlKeyMapper(); + private _urlKeyMapper = new UniqueUrlKeyMapper(); private _sceneRoot!: SceneObject; private _stateSub: Unsubscribable | null = null; private _locationSub?: UnregisterCallback | null = null; @@ -71,8 +72,8 @@ export class UrlSyncManager implements UrlSyncManagerLike { public syncFrom(sceneObj: SceneObject) { const urlParams = locationService.getSearch(); // The index is always from the root - this.urlKeyMapper.rebuildIndex(this._sceneRoot); - this._syncSceneStateFromUrl(sceneObj, urlParams); + this._urlKeyMapper.rebuildIndex(this._sceneRoot); + syncStateFromUrl(sceneObj, urlParams, this._urlKeyMapper); } private _onLocationUpdate = (location: Location) => { @@ -82,9 +83,9 @@ export class UrlSyncManager implements UrlSyncManagerLike { const urlParams = new URLSearchParams(location.search); // Rebuild key mapper index before starting sync - this.urlKeyMapper.rebuildIndex(this._sceneRoot); + this._urlKeyMapper.rebuildIndex(this._sceneRoot); // Sync scene state tree from url - this._syncSceneStateFromUrl(this._sceneRoot, urlParams); + syncStateFromUrl(this._sceneRoot, urlParams, this._urlKeyMapper); this._lastPath = location.pathname; }; @@ -97,10 +98,10 @@ export class UrlSyncManager implements UrlSyncManagerLike { const searchParams = locationService.getSearch(); const mappedUpdated: SceneObjectUrlValues = {}; - this.urlKeyMapper.rebuildIndex(this._sceneRoot); + this._urlKeyMapper.rebuildIndex(this._sceneRoot); for (const [key, newUrlValue] of Object.entries(newUrlState)) { - const uniqueKey = this.urlKeyMapper.getUniqueKey(key, changedObject); + const uniqueKey = this._urlKeyMapper.getUniqueKey(key, changedObject); const currentUrlValue = searchParams.getAll(uniqueKey); if (!isUrlValueEqual(currentUrlValue, newUrlValue)) { @@ -114,124 +115,9 @@ export class UrlSyncManager implements UrlSyncManagerLike { } }; - private _syncSceneStateFromUrl(sceneObject: SceneObject, urlParams: URLSearchParams) { - if (sceneObject.urlSync) { - const urlState: SceneObjectUrlValues = {}; - const currentState = sceneObject.urlSync.getUrlState(); - - for (const key of sceneObject.urlSync.getKeys()) { - const uniqueKey = this.urlKeyMapper.getUniqueKey(key, sceneObject); - const newValue = urlParams.getAll(uniqueKey); - const currentValue = currentState[key]; - - if (isUrlValueEqual(newValue, currentValue)) { - continue; - } - - if (newValue.length > 0) { - if (Array.isArray(currentValue)) { - urlState[key] = newValue; - } else { - urlState[key] = newValue[0]; - } - } else { - // mark this key as having no url state - urlState[key] = null; - } - } - - if (Object.keys(urlState).length > 0) { - sceneObject.urlSync.updateFromUrl(urlState); - } - } - - sceneObject.forEachChild((child) => this._syncSceneStateFromUrl(child, urlParams)); - } - public getUrlState(root: SceneObject): SceneObjectUrlValues { - const urlKeyMapper = new UniqueUrlKeyMapper(); - urlKeyMapper.rebuildIndex(root); - - const result: SceneObjectUrlValues = {}; - - const visitNode = (obj: SceneObject) => { - if (obj.urlSync) { - const newUrlState = obj.urlSync.getUrlState(); - - for (const [key, value] of Object.entries(newUrlState)) { - if (value != null) { - const uniqueKey = urlKeyMapper.getUniqueKey(key, obj); - result[uniqueKey] = value; - } - } - } - - obj.forEachChild(visitNode); - }; - - visitNode(root); - return result; - } -} - -interface SceneObjectWithDepth { - sceneObject: SceneObject; - depth: number; -} -class UniqueUrlKeyMapper { - private index = new Map(); - - public getUniqueKey(key: string, obj: SceneObject) { - const objectsWithKey = this.index.get(key); - if (!objectsWithKey) { - throw new Error("Cannot find any scene object that uses the key '" + key + "'"); - } - - const address = objectsWithKey.findIndex((o) => o.sceneObject === obj); - if (address > 0) { - return `${key}-${address + 1}`; - } - - return key; - } - - public rebuildIndex(root: SceneObject) { - this.index.clear(); - this.buildIndex(root, 0); - } - - private buildIndex(sceneObject: SceneObject, depth: number) { - if (sceneObject.urlSync) { - for (const key of sceneObject.urlSync.getKeys()) { - const hit = this.index.get(key); - if (hit) { - hit.push({ sceneObject, depth }); - hit.sort((a, b) => a.depth - b.depth); - } else { - this.index.set(key, [{ sceneObject, depth }]); - } - } - } - - sceneObject.forEachChild((child) => this.buildIndex(child, depth + 1)); - } -} - -export function isUrlValueEqual(currentUrlValue: string[], newUrlValue: SceneObjectUrlValue): boolean { - if (currentUrlValue.length === 0 && newUrlValue == null) { - return true; + return getUrlState(root); } - - if (!Array.isArray(newUrlValue) && currentUrlValue?.length === 1) { - return newUrlValue === currentUrlValue[0]; - } - - if (newUrlValue?.length === 0 && currentUrlValue === null) { - return true; - } - - // We have two arrays, lets compare them - return isEqual(currentUrlValue, newUrlValue); } let urlSyncManager: UrlSyncManagerLike | undefined; diff --git a/packages/scenes/src/services/utils.test.ts b/packages/scenes/src/services/utils.test.ts new file mode 100644 index 000000000..9b644e270 --- /dev/null +++ b/packages/scenes/src/services/utils.test.ts @@ -0,0 +1,16 @@ +import { isUrlValueEqual } from './utils'; + +describe('isUrlValueEqual', () => { + it('should handle all cases', () => { + expect(isUrlValueEqual([], [])).toBe(true); + expect(isUrlValueEqual([], undefined)).toBe(true); + expect(isUrlValueEqual([], null)).toBe(true); + + expect(isUrlValueEqual(['asd'], 'asd')).toBe(true); + expect(isUrlValueEqual(['asd'], ['asd'])).toBe(true); + expect(isUrlValueEqual(['asd', '2'], ['asd', '2'])).toBe(true); + + expect(isUrlValueEqual(['asd', '2'], 'asd')).toBe(false); + expect(isUrlValueEqual(['asd2'], 'asd')).toBe(false); + }); +}); diff --git a/packages/scenes/src/services/utils.ts b/packages/scenes/src/services/utils.ts new file mode 100644 index 000000000..4f7e227bf --- /dev/null +++ b/packages/scenes/src/services/utils.ts @@ -0,0 +1,98 @@ +import { isEqual } from 'lodash'; + +import { SceneObject, SceneObjectUrlValue, SceneObjectUrlValues } from '../core/types'; +import { UniqueUrlKeyMapper } from './UniqueUrlKeyMapper'; + +/** + * @param root + * @returns the full scene url state as a object with keys and values + */ +export function getUrlState(root: SceneObject): SceneObjectUrlValues { + const urlKeyMapper = new UniqueUrlKeyMapper(); + urlKeyMapper.rebuildIndex(root); + + const result: SceneObjectUrlValues = {}; + + const visitNode = (obj: SceneObject) => { + if (obj.urlSync) { + const newUrlState = obj.urlSync.getUrlState(); + + for (const [key, value] of Object.entries(newUrlState)) { + if (value != null) { + const uniqueKey = urlKeyMapper.getUniqueKey(key, obj); + result[uniqueKey] = value; + } + } + } + + obj.forEachChild(visitNode); + }; + + visitNode(root); + return result; +} + +/** + * Exported util function to sync state from an initial url state. + * Useful for initializing an embedded scenes with a url state string. + */ +export function syncStateFromSearchParams(root: SceneObject, urlParams: URLSearchParams) { + const urlKeyMapper = new UniqueUrlKeyMapper(); + urlKeyMapper.rebuildIndex(root); + syncStateFromUrl(root, urlParams, urlKeyMapper); +} + +export function syncStateFromUrl( + sceneObject: SceneObject, + urlParams: URLSearchParams, + urlKeyMapper: UniqueUrlKeyMapper +) { + if (sceneObject.urlSync) { + const urlState: SceneObjectUrlValues = {}; + const currentState = sceneObject.urlSync.getUrlState(); + + for (const key of sceneObject.urlSync.getKeys()) { + const uniqueKey = urlKeyMapper.getUniqueKey(key, sceneObject); + const newValue = urlParams.getAll(uniqueKey); + const currentValue = currentState[key]; + + if (isUrlValueEqual(newValue, currentValue)) { + continue; + } + + if (newValue.length > 0) { + if (Array.isArray(currentValue)) { + urlState[key] = newValue; + } else { + urlState[key] = newValue[0]; + } + } else { + // mark this key as having no url state + urlState[key] = null; + } + } + + if (Object.keys(urlState).length > 0) { + sceneObject.urlSync.updateFromUrl(urlState); + } + } + + sceneObject.forEachChild((child) => syncStateFromUrl(child, urlParams, urlKeyMapper)); +} + +export function isUrlValueEqual(currentUrlValue: string[], newUrlValue: SceneObjectUrlValue): boolean { + if (currentUrlValue.length === 0 && newUrlValue == null) { + return true; + } + + if (!Array.isArray(newUrlValue) && currentUrlValue?.length === 1) { + return newUrlValue === currentUrlValue[0]; + } + + if (newUrlValue?.length === 0 && currentUrlValue === null) { + return true; + } + + // We have two arrays, lets compare them + return isEqual(currentUrlValue, newUrlValue); +} diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx index c01d5409c..c1311983b 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx @@ -21,7 +21,15 @@ export interface AdHocFiltersVariableState extends SceneVariableState { export type AdHocFiltersVariableCreateHelperArgs = Pick< AdHocFilterSetState, - 'name' | 'filters' | 'baseFilters' | 'datasource' | 'tagKeyRegexFilter' | 'getTagKeysProvider' | 'getTagValuesProvider' | 'name' | 'layout' + | 'name' + | 'filters' + | 'baseFilters' + | 'datasource' + | 'tagKeyRegexFilter' + | 'getTagKeysProvider' + | 'getTagValuesProvider' + | 'name' + | 'layout' >; export class AdHocFiltersVariable