diff --git a/packages/scenes/src/services/UrlSyncManager.test.ts b/packages/scenes/src/services/UrlSyncManager.test.ts index 160af259f..eac7dbd07 100644 --- a/packages/scenes/src/services/UrlSyncManager.test.ts +++ b/packages/scenes/src/services/UrlSyncManager.test.ts @@ -32,23 +32,32 @@ class TestObj extends SceneObjectBase { } public updateFromUrl(values: SceneObjectUrlValues) { + console.log('updateFromUrl', values); + const stateUpdate: Partial = {}; + if (typeof values.name === 'string') { - this.setState({ name: values.name ?? 'NA' }); + stateUpdate.name = values.name; } - if (Array.isArray(values.array)) { - this.setState({ array: values.array }); + if (typeof values.optional === 'string') { + stateUpdate.optional = values.optional; } - if (values.hasOwnProperty('optional')) { - this.setState({ optional: typeof values.optional === 'string' ? values.optional : undefined }); - } + this.setState({ + name: typeof values.name === 'string' ? values.name : this.state.name, + optional: typeof values.optional === 'string' ? values.optional : this.state.optional, + array: Array.isArray(values.array) ? values.array : this.state.array, + }); - if (values.hasOwnProperty('nested')) { - this.setState({ nested: new TestObj({ name: 'default name' }) }); - } else if (this.state.nested) { - this.setState({ nested: undefined }); - } + // 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 }); + // } } } @@ -73,24 +82,24 @@ describe('UrlSyncManager', () => { locationService.push('/'); }); - describe('getUrlState', () => { - it('returns the full url state', () => { - const obj = new TestObj({ name: 'test', optional: 'handler', array: ['A', 'B'] }); - scene = new SceneFlexLayout({ - children: [new SceneFlexItem({ body: obj })], - }); + // describe('getUrlState', () => { + // it('returns the full url state', () => { + // const obj = new TestObj({ name: 'test', optional: 'handler', array: ['A', 'B'] }); + // scene = new SceneFlexLayout({ + // children: [new SceneFlexItem({ body: obj })], + // }); - urlManager = new UrlSyncManager(); + // urlManager = new UrlSyncManager(); - expect(urlManager.getUrlState(scene)).toEqual({ - name: 'test', - optional: 'handler', - array: ['A', 'B'], - }); - }); - }); + // expect(urlManager.getUrlState(scene)).toEqual({ + // name: 'test', + // optional: 'handler', + // array: ['A', 'B'], + // }); + // }); + // }); - describe('When state changes', () => { + describe.only('When state changes', () => { it('should update url', () => { const obj = new TestObj({ name: 'test' }); scene = new SceneFlexLayout({ @@ -117,310 +126,310 @@ 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 })], - }); + // 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' }); + // locationService.partial({ name: 'name-from-url', nested: 'nested', 'name-2': 'nested name from initial url' }); - urlManager = new UrlSyncManager(); - urlManager.initSync(scene); + // urlManager = new UrlSyncManager(); + // urlManager.initSync(scene); - deactivate = scene.activate(); + // deactivate = scene.activate(); - expect(obj.state.nested?.state.name).toEqual('nested name from initial url'); - }); + // 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: [], - // }); + // // 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' }); + // // locationService.partial({ name: 'name-from-url' }); - // urlManager = new UrlSyncManager(); - // urlManager.initSync(scene); + // // urlManager = new UrlSyncManager(); + // // urlManager.initSync(scene); - // deactivate = scene.activate(); + // // deactivate = scene.activate(); - // scene.setState({ children: [new SceneFlexItem({ body: obj })] }); + // // scene.setState({ children: [new SceneFlexItem({ body: obj })] }); - // expect(obj.state.name).toEqual('name-from-url'); - // }); - }); + // // expect(obj.state.name).toEqual('name-from-url'); + // // }); + // }); - describe('When url changes', () => { - it('should update state', () => { - const obj = new TestObj({ name: 'test' }); - const initialObjState = obj.state; - scene = new SceneFlexLayout({ - children: [new SceneFlexItem({ body: obj })], - }); + // describe('When url changes', () => { + // it('should update state', () => { + // const obj = new TestObj({ name: 'test' }); + // const initialObjState = obj.state; + // scene = new SceneFlexLayout({ + // children: [new SceneFlexItem({ body: obj })], + // }); + + // urlManager = new UrlSyncManager(); + // urlManager.initSync(scene); + + // deactivate = scene.activate(); + + // // When non relevant key changes in url + // locationService.partial({ someOtherProp: 'test2' }); + // // Should not affect state + // expect(obj.state).toBe(initialObjState); + + // // When relevant key changes in url + // locationService.partial({ name: 'test2' }); + // // Should update state + // expect(obj.state.name).toBe('test2'); + + // // When relevant key is cleared + // locationService.partial({ name: null }); + + // // Should revert to initial state + // // expect(obj.state.name).toBe('test'); + + // // When relevant key is set to current state + // const currentState = obj.state; + // locationService.partial({ name: currentState.name }); + // // Should not affect state (same instance) + // expect(obj.state).toBe(currentState); + // }); + + // it('should ignore state update when path also changed', () => { + // const obj = new TestObj({ name: 'test' }); + // scene = new SceneFlexLayout({ + // children: [new SceneFlexItem({ body: obj })], + // }); + + // urlManager = new UrlSyncManager(); + // urlManager.initSync(scene); + + // deactivate = scene.activate(); + + // obj.setState({ optional: 'newValue' }); + + // // Should not affect state + // expect(locationService.getSearchObject().optional).toBe('newValue'); + + // // Move to new path + // locationService.push('/new/path'); + + // // Expect state to remain + // expect(obj.state.optional).toBe('newValue'); + // }); + // }); + + // describe('When multiple scene objects wants to set same url keys', () => { + // it('should give each object a unique key', () => { + // const outerTimeRange = new SceneTimeRange(); + // const innerTimeRange = new SceneTimeRange(); + + // scene = new SceneFlexLayout({ + // children: [ + // new SceneFlexItem({ + // body: new SceneFlexLayout({ + // $timeRange: innerTimeRange, + // children: [], + // }), + // }), + // ], + // $timeRange: outerTimeRange, + // }); + + // urlManager = new UrlSyncManager(); + // urlManager.initSync(scene); + + // deactivate = scene.activate(); + + // // When making state changes for second object with same key + // innerTimeRange.setState({ from: 'now-10m' }); - urlManager = new UrlSyncManager(); - urlManager.initSync(scene); + // // Should use unique key based where it is in the scene + // expect(locationService.getSearchObject()).toEqual({ + // ['from-2']: 'now-10m', + // ['to-2']: 'now', + // }); + + // outerTimeRange.setState({ from: 'now-20m' }); + + // // Should not suffix key for first object + // expect(locationService.getSearchObject()).toEqual({ + // from: 'now-20m', + // to: 'now', + // ['from-2']: 'now-10m', + // ['to-2']: 'now', + // }); + + // // When updating via url + // locationService.partial({ ['from-2']: 'now-10s' }); + // // should find the correct object + // expect(innerTimeRange.state.from).toBe('now-10s'); + // // should not update the first object + // expect(outerTimeRange.state.from).toBe('now-20m'); + // // Should not cause another url update + // expect(locationUpdates.length).toBe(3); + // }); + // }); - deactivate = scene.activate(); + // describe('When updating array value', () => { + // it('Should update url correctly', () => { + // const obj = new TestObj({ name: 'test' }); + // scene = new SceneFlexLayout({ + // children: [new SceneFlexItem({ body: obj })], + // }); + + // urlManager = new UrlSyncManager(); + // urlManager.initSync(scene); - // When non relevant key changes in url - locationService.partial({ someOtherProp: 'test2' }); - // Should not affect state - expect(obj.state).toBe(initialObjState); + // deactivate = scene.activate(); - // When relevant key changes in url - locationService.partial({ name: 'test2' }); - // Should update state - expect(obj.state.name).toBe('test2'); + // // When making state change + // obj.setState({ array: ['A', 'B'] }); - // When relevant key is cleared - locationService.partial({ name: null }); + // // Should update url + // const searchObj = locationService.getSearchObject(); + // expect(searchObj.array).toEqual(['A', 'B']); - // Should revert to initial state - // expect(obj.state.name).toBe('test'); + // // When making unrelated state change + // obj.setState({ other: 'not synced' }); - // When relevant key is set to current state - const currentState = obj.state; - locationService.partial({ name: currentState.name }); - // Should not affect state (same instance) - expect(obj.state).toBe(currentState); - }); + // // Should not update url + // expect(locationUpdates.length).toBe(1); - it('should ignore state update when path also changed', () => { - const obj = new TestObj({ name: 'test' }); - scene = new SceneFlexLayout({ - children: [new SceneFlexItem({ body: obj })], - }); + // // When updating via url + // locationService.partial({ array: ['A', 'B', 'C'] }); + // // Should update state + // expect(obj.state.array).toEqual(['A', 'B', 'C']); + // }); + // }); - urlManager = new UrlSyncManager(); - urlManager.initSync(scene); + // describe('When initial state is undefined', () => { + // it('Should update from url correctly', () => { + // const obj = new TestObj({ name: 'test' }); + // scene = new SceneFlexLayout({ + // children: [new SceneFlexItem({ body: obj })], + // }); - deactivate = scene.activate(); + // urlManager = new UrlSyncManager(); + // urlManager.initSync(scene); - obj.setState({ optional: 'newValue' }); + // deactivate = scene.activate(); - // Should not affect state - expect(locationService.getSearchObject().optional).toBe('newValue'); + // // When setting value via url + // locationService.partial({ optional: 'handler' }); - // Move to new path - locationService.push('/new/path'); + // // Should update state + // expect(obj.state.optional).toBe('handler'); - // Expect state to remain - expect(obj.state.optional).toBe('newValue'); - }); - }); + // // When updating via url and remove optional + // locationService.partial({ optional: null }); - describe('When multiple scene objects wants to set same url keys', () => { - it('should give each object a unique key', () => { - const outerTimeRange = new SceneTimeRange(); - const innerTimeRange = new SceneTimeRange(); + // // Should update state + // expect(obj.state.optional).toBe(undefined); + // }); - scene = new SceneFlexLayout({ - children: [ - new SceneFlexItem({ - body: new SceneFlexLayout({ - $timeRange: innerTimeRange, - children: [], - }), - }), - ], - $timeRange: outerTimeRange, - }); + // it('When updating via state and removing from url', () => { + // const obj = new TestObj({ name: 'test' }); + // scene = new SceneFlexLayout({ + // children: [new SceneFlexItem({ body: obj })], + // }); - urlManager = new UrlSyncManager(); - urlManager.initSync(scene); + // urlManager = new UrlSyncManager(); + // urlManager.initSync(scene); - deactivate = scene.activate(); + // deactivate = scene.activate(); - // When making state changes for second object with same key - innerTimeRange.setState({ from: 'now-10m' }); + // obj.setState({ optional: 'handler' }); - // Should use unique key based where it is in the scene - expect(locationService.getSearchObject()).toEqual({ - ['from-2']: 'now-10m', - ['to-2']: 'now', - }); + // // Should update url + // expect(locationService.getSearchObject().optional).toEqual('handler'); - outerTimeRange.setState({ from: 'now-20m' }); + // // When updating via url and remove optional + // locationService.partial({ optional: null }); - // Should not suffix key for first object - expect(locationService.getSearchObject()).toEqual({ - from: 'now-20m', - to: 'now', - ['from-2']: 'now-10m', - ['to-2']: 'now', - }); + // // Should update state + // expect(obj.state.optional).toBe(undefined); + // }); - // When updating via url - locationService.partial({ ['from-2']: 'now-10s' }); - // should find the correct object - expect(innerTimeRange.state.from).toBe('now-10s'); - // should not update the first object - expect(outerTimeRange.state.from).toBe('now-20m'); - // Should not cause another url update - expect(locationUpdates.length).toBe(3); - }); - }); + // it('When removing optional state via state change', () => { + // const obj = new TestObj({ name: 'test' }); + // scene = new SceneFlexLayout({ + // children: [new SceneFlexItem({ body: obj })], + // }); - describe('When updating array value', () => { - it('Should update url correctly', () => { - const obj = new TestObj({ name: 'test' }); - scene = new SceneFlexLayout({ - children: [new SceneFlexItem({ body: obj })], - }); + // urlManager = new UrlSyncManager(); + // urlManager.initSync(scene); - urlManager = new UrlSyncManager(); - urlManager.initSync(scene); + // deactivate = scene.activate(); - deactivate = scene.activate(); + // obj.setState({ optional: 'handler' }); - // When making state change - obj.setState({ array: ['A', 'B'] }); + // expect(locationService.getSearchObject().optional).toEqual('handler'); - // Should update url - const searchObj = locationService.getSearchObject(); - expect(searchObj.array).toEqual(['A', 'B']); + // obj.setState({ optional: undefined }); - // When making unrelated state change - obj.setState({ other: 'not synced' }); + // expect(locationService.getSearchObject().optional).toEqual(undefined); + // }); + // }); - // Should not update url - expect(locationUpdates.length).toBe(1); + // describe('When moving between scene roots', () => { + // it('Should unsub from previous scene', () => { + // const obj1 = new TestObj({ name: 'A' }); + // const scene1 = new SceneFlexLayout({ + // children: [obj1], + // }); - // When updating via url - locationService.partial({ array: ['A', 'B', 'C'] }); - // Should update state - expect(obj.state.array).toEqual(['A', 'B', 'C']); - }); - }); + // urlManager = new UrlSyncManager(); + // urlManager.initSync(scene1); - describe('When initial state is undefined', () => { - it('Should update from url correctly', () => { - const obj = new TestObj({ name: 'test' }); - scene = new SceneFlexLayout({ - children: [new SceneFlexItem({ body: obj })], - }); + // deactivate = scene1.activate(); - urlManager = new UrlSyncManager(); - urlManager.initSync(scene); + // obj1.setState({ name: 'B' }); - deactivate = scene.activate(); + // // Should update url + // expect(locationService.getSearchObject().name).toEqual('B'); - // When setting value via url - locationService.partial({ optional: 'handler' }); + // const obj2 = new TestObj({ name: 'test' }); + // const scene2 = new SceneFlexLayout({ + // children: [new SceneFlexItem({ body: obj2 })], + // }); - // Should update state - expect(obj.state.optional).toBe('handler'); + // urlManager.initSync(scene2); - // When updating via url and remove optional - locationService.partial({ optional: null }); + // obj1.setState({ name: 'new name' }); - // Should update state - expect(obj.state.optional).toBe(undefined); - }); + // // Should not update url + // expect(locationService.getSearchObject().name).toEqual('B'); + // }); - it('When updating via state and removing from url', () => { - const obj = new TestObj({ name: 'test' }); - scene = new SceneFlexLayout({ - children: [new SceneFlexItem({ body: obj })], - }); + // it('cleanUp should unsub from state and history', () => { + // const obj1 = new TestObj({ name: 'A' }); + // const scene1 = new SceneFlexLayout({ + // children: [obj1], + // }); - urlManager = new UrlSyncManager(); - urlManager.initSync(scene); + // urlManager = new UrlSyncManager(); + // urlManager.initSync(scene1); - deactivate = scene.activate(); + // deactivate = scene1.activate(); - obj.setState({ optional: 'handler' }); - - // Should update url - expect(locationService.getSearchObject().optional).toEqual('handler'); - - // When updating via url and remove optional - locationService.partial({ optional: null }); - - // Should update state - expect(obj.state.optional).toBe(undefined); - }); - - it('When removing optional state via state change', () => { - const obj = new TestObj({ name: 'test' }); - scene = new SceneFlexLayout({ - children: [new SceneFlexItem({ body: obj })], - }); - - urlManager = new UrlSyncManager(); - urlManager.initSync(scene); - - deactivate = scene.activate(); + // urlManager.cleanUp(scene1); - obj.setState({ optional: 'handler' }); + // obj1.setState({ name: 'B' }); - expect(locationService.getSearchObject().optional).toEqual('handler'); + // // Should not update url + // expect(locationService.getSearchObject().name).toBeUndefined(); - obj.setState({ optional: undefined }); + // // When updating via url + // locationService.partial({ name: 'Hello' }); - expect(locationService.getSearchObject().optional).toEqual(undefined); - }); - }); - - describe('When moving between scene roots', () => { - it('Should unsub from previous scene', () => { - const obj1 = new TestObj({ name: 'A' }); - const scene1 = new SceneFlexLayout({ - children: [obj1], - }); - - urlManager = new UrlSyncManager(); - urlManager.initSync(scene1); - - deactivate = scene1.activate(); - - obj1.setState({ name: 'B' }); - - // Should update url - expect(locationService.getSearchObject().name).toEqual('B'); - - const obj2 = new TestObj({ name: 'test' }); - const scene2 = new SceneFlexLayout({ - children: [new SceneFlexItem({ body: obj2 })], - }); - - urlManager.initSync(scene2); - - obj1.setState({ name: 'new name' }); - - // Should not update url - expect(locationService.getSearchObject().name).toEqual('B'); - }); - - it('cleanUp should unsub from state and history', () => { - const obj1 = new TestObj({ name: 'A' }); - const scene1 = new SceneFlexLayout({ - children: [obj1], - }); - - urlManager = new UrlSyncManager(); - urlManager.initSync(scene1); - - deactivate = scene1.activate(); - - urlManager.cleanUp(scene1); - - obj1.setState({ name: 'B' }); - - // Should not update url - expect(locationService.getSearchObject().name).toBeUndefined(); - - // When updating via url - locationService.partial({ name: 'Hello' }); - - // Should not update state - expect(obj1.state.name).toBe('B'); - }); - }); + // // Should not update state + // expect(obj1.state.name).toBe('B'); + // }); + // }); describe('When a state update triggers another state update with url sync', () => { it('Should update url correctly', async () => { diff --git a/packages/scenes/src/services/UrlSyncManager.ts b/packages/scenes/src/services/UrlSyncManager.ts index c8d9d6ec9..2226bb823 100644 --- a/packages/scenes/src/services/UrlSyncManager.ts +++ b/packages/scenes/src/services/UrlSyncManager.ts @@ -86,6 +86,7 @@ export class UrlSyncManager implements UrlSyncManagerLike { if (this._lastPath !== location.pathname) { return; } + console.log('_onLocationUpdate', location.search); const urlParams = new URLSearchParams(location.search); // Rebuild key mapper index before starting sync @@ -100,6 +101,7 @@ export class UrlSyncManager implements UrlSyncManagerLike { if (changedObject.urlSync) { const newUrlState = changedObject.urlSync.getUrlState(); + console.log('UrlSyncManager.onStateChanged newUrlState', newUrlState); const searchParams = locationService.getSearch(); const mappedUpdated: SceneObjectUrlValues = {}; @@ -108,9 +110,14 @@ export class UrlSyncManager implements UrlSyncManagerLike { for (const [key, newUrlValue] of Object.entries(newUrlState)) { const uniqueKey = this._urlKeyMapper.getUniqueKey(key, changedObject); - const currentUrlValue = searchParams.getAll(uniqueKey); + let currentUrlValue: string | string[] = searchParams.getAll(uniqueKey); + + if (!Array.isArray(newUrlValue)) { + currentUrlValue = currentUrlValue[0]; + } if (!isUrlValueEqual(currentUrlValue, newUrlValue)) { + console.log(`UrlSyncManager.onStateChanged url state diff ${uniqueKey} ${currentUrlValue} => ${newUrlValue}`); mappedUpdated[uniqueKey] = newUrlValue; } } diff --git a/packages/scenes/src/services/utils.ts b/packages/scenes/src/services/utils.ts index 4f7e227bf..949bb4c1a 100644 --- a/packages/scenes/src/services/utils.ts +++ b/packages/scenes/src/services/utils.ts @@ -1,4 +1,4 @@ -import { isEqual } from 'lodash'; +import { isArray, isEqual } from 'lodash'; import { SceneObject, SceneObjectUrlValue, SceneObjectUrlValues } from '../core/types'; import { UniqueUrlKeyMapper } from './UniqueUrlKeyMapper'; @@ -50,29 +50,33 @@ export function syncStateFromUrl( if (sceneObject.urlSync) { const urlState: SceneObjectUrlValues = {}; const currentState = sceneObject.urlSync.getUrlState(); + let changeDetected = false; for (const key of sceneObject.urlSync.getKeys()) { const uniqueKey = urlKeyMapper.getUniqueKey(key, sceneObject); - const newValue = urlParams.getAll(uniqueKey); const currentValue = currentState[key]; + const values: string[] = urlParams.getAll(uniqueKey); + let newValue: string | string[] | undefined; - if (isUrlValueEqual(newValue, currentValue)) { - continue; - } - - if (newValue.length > 0) { - if (Array.isArray(currentValue)) { - urlState[key] = newValue; - } else { - urlState[key] = newValue[0]; + if (values.length > 0) { + if (!Array.isArray(currentValue)) { + newValue = values[0]; } } else { // mark this key as having no url state - urlState[key] = null; + newValue = undefined; + } + + urlState[key] = newValue; + + // console.log('new value', uniqueKey, newValue, currentValue); + if (!changeDetected && !isUrlValueEqual(currentValue, newValue)) { + console.log('change detected', uniqueKey, newValue, currentValue); + changeDetected = true; } } - if (Object.keys(urlState).length > 0) { + if (changeDetected) { sceneObject.urlSync.updateFromUrl(urlState); } } @@ -80,19 +84,15 @@ export function syncStateFromUrl( sceneObject.forEachChild((child) => syncStateFromUrl(child, urlParams, urlKeyMapper)); } -export function isUrlValueEqual(currentUrlValue: string[], newUrlValue: SceneObjectUrlValue): boolean { - if (currentUrlValue.length === 0 && newUrlValue == null) { +export function isUrlValueEqual(a: SceneObjectUrlValue, b: SceneObjectUrlValue): boolean { + if (a == null && b == null) { return true; } - if (!Array.isArray(newUrlValue) && currentUrlValue?.length === 1) { - return newUrlValue === currentUrlValue[0]; - } - - if (newUrlValue?.length === 0 && currentUrlValue === null) { - return true; + if (typeof a === 'string' && typeof b === 'string') { + return a === b; } // We have two arrays, lets compare them - return isEqual(currentUrlValue, newUrlValue); + return isEqual(a, b); }