From 92ffa4c074e143ac5519f1bf25ef499fbb2102ae Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 3 Dec 2019 15:25:14 +0100 Subject: [PATCH 1/4] POC: Batching url updates --- .../edit_index_pattern.html | 2 + .../edit_index_pattern/edit_index_pattern.js | 6 ++ src/plugins/kibana_utils/public/store/sync.ts | 84 +++++++------------ src/plugins/kibana_utils/public/url/index.ts | 67 ++++++++++----- 4 files changed, 83 insertions(+), 76 deletions(-) diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.html b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.html index f51913cb33650..5d0bfe6491e03 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.html +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.html @@ -14,6 +14,8 @@ delete="removePattern()" > + +

diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js index eddf16618c8f2..621e9f72eb644 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js @@ -213,6 +213,12 @@ uiModules.get('apps/management') }); handleTabChange($scope, store.get().tab); + $scope.crazyBatchUpdate = () => { + store.set({ ...store.get(), tab: 'indexedFiles' }); + store.set({ ...store.get() }); + store.set({ ...store.get(), fieldFilter: 'BATCH!' }); + }; + $scope.$$postDigest(() => { // just an artificial example of advanced syncState util setup // 1. different strategies are used for different slices diff --git a/src/plugins/kibana_utils/public/store/sync.ts b/src/plugins/kibana_utils/public/store/sync.ts index a875966a0250d..5601fca33c763 100644 --- a/src/plugins/kibana_utils/public/store/sync.ts +++ b/src/plugins/kibana_utils/public/store/sync.ts @@ -19,7 +19,7 @@ import { MonoTypeOperatorFunction, Observable, Subscription } from 'rxjs'; import { distinctUntilChanged, map, share, skip, startWith } from 'rxjs/operators'; -import { createUrlControls, getStateFromUrl, setStateToUrl } from '../url'; +import { getUrlControls, getStateFromUrl, setStateToUrl } from '../url'; /** * Configuration of StateSync utility @@ -173,11 +173,11 @@ interface ISyncStrategy { * Take in a state object, should serialise and persist */ // TODO: replace sounds like something url specific ... - toStorage: (state: StorageState, opts: { replace: boolean }) => void; + toStorage: (state: StorageState, opts: { replace: boolean }) => Promise; /** * Should retrieve state from the storage and deserialize it */ - fromStorage: () => StorageState; + fromStorage: () => Promise; /** * Should notify when the storage has changed */ @@ -199,13 +199,15 @@ export function isSyncStrategyFactory( const createUrlSyncStrategyFactory = ( { useHash = false }: { useHash: boolean } = { useHash: false } ): SyncStrategyFactory => (syncKey: string): ISyncStrategy => { - const { update: updateUrl, listen: listenUrl } = createUrlControls(); + const { update: updateUrl, listen: listenUrl } = getUrlControls(); return { - toStorage: (state: BaseState, { replace = false } = { replace: false }) => { - const newUrl = setStateToUrl(syncKey, state, { useHash }); - updateUrl(newUrl, replace); + toStorage: async (state: BaseState, { replace = false } = { replace: false }) => { + await updateUrl( + currentUrl => setStateToUrl(syncKey, state, { useHash }, currentUrl), + replace + ); }, - fromStorage: () => getStateFromUrl(syncKey), + fromStorage: async () => getStateFromUrl(syncKey), storageChange$: new Observable(observer => { const unlisten = listenUrl(() => { observer.next(); @@ -331,13 +333,7 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): Destro const stateSyncConfigs = Array.isArray(config) ? config : [config]; const subscriptions: Subscription[] = []; - // flags are needed to be able to skip our own state / storage updates - // e.g. when we trigger state because storage changed, - // we want to make sure we won't run into infinite cycle - let ignoreStateUpdate = false; - let ignoreStorageUpdate = false; - - stateSyncConfigs.forEach(stateSyncConfig => { + stateSyncConfigs.forEach(async stateSyncConfig => { const toStorageMapper = stateSyncConfig.toStorageMapper || (s => s); const fromStorageMapper = stateSyncConfig.fromStorageMapper || (s => s); @@ -348,46 +344,28 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): Destro : Strategies[stateSyncConfig.syncStrategy || SyncStrategy.Url])(stateSyncConfig.syncKey); // returned boolean indicates if update happen - const updateState = (): boolean => { - if (ignoreStateUpdate) return false; - const update = (): boolean => { - const storageState = fromStorage(); - if (!storageState) { - ignoreStorageUpdate = false; - return false; - } - - if (storageState) { - stateSyncConfig.store.set({ - ...stateSyncConfig.store.get(), - ...fromStorageMapper(storageState), - }); - return true; - } - + const updateState = async (): Promise => { + const storageState = await fromStorage(); + if (!storageState) { return false; - }; + } - ignoreStorageUpdate = true; - const updated = update(); - ignoreStorageUpdate = false; - return updated; + if (storageState) { + stateSyncConfig.store.set({ + ...stateSyncConfig.store.get(), + ...fromStorageMapper(storageState), + }); + return true; + } + + return false; }; // returned boolean indicates if update happen - const updateStorage = ({ replace = false } = {}): boolean => { - if (ignoreStorageUpdate) return false; - - const update = () => { - const newStorageState = toStorageMapper(stateSyncConfig.store.get()); - toStorage(newStorageState, { replace }); - return true; - }; - - ignoreStateUpdate = true; - const hasUpdated = update(); - ignoreStateUpdate = false; - return hasUpdated; + const updateStorage = async ({ replace = false } = {}): Promise => { + const newStorageState = toStorageMapper(stateSyncConfig.store.get()); + await toStorage(newStorageState, { replace }); + return true; }; // initial syncing of store state and storage state @@ -397,10 +375,10 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): Destro // if there is nothing by state key in storage // then we should fallback and consider state source of truth if (!hasUpdated) { - updateStorage({ replace: true }); + await updateStorage({ replace: true }); } } else if (initialTruthSource === InitialTruthSource.Store) { - updateStorage({ replace: true }); + await updateStorage({ replace: true }); } subscriptions.push( @@ -413,11 +391,9 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): Destro ) ) .subscribe(() => { - // TODO: batch storage updates updateStorage(); }), storageChange$.subscribe(() => { - // TODO: batch state updates? or should it be handled by state containers instead? updateState(); }) ); diff --git a/src/plugins/kibana_utils/public/url/index.ts b/src/plugins/kibana_utils/public/url/index.ts index 9be82e781f6df..4c06a404780d8 100644 --- a/src/plugins/kibana_utils/public/url/index.ts +++ b/src/plugins/kibana_utils/public/url/index.ts @@ -35,8 +35,9 @@ import { const parseUrl = (url: string) => _parseUrl(url, true); const parseUrlHash = (url: string) => parseUrl(parseUrl(url).hash!.slice(1)); -const parseCurrentUrl = () => parseUrl(window.location.href); -const parseCurrentUrlHash = () => parseUrlHash(window.location.href); +const getCurrentUrl = () => window.location.href; +const parseCurrentUrl = () => parseUrl(getCurrentUrl()); +const parseCurrentUrlHash = () => parseUrlHash(getCurrentUrl()); // encodeUriQuery implements the less-aggressive encoding done naturally by // the browser. We use it to generate the same urls the browser would @@ -140,30 +141,52 @@ export function setStateToUrl( * listen(cb) - accepts a callback which will be called whenever url has changed * update(url: string, replace: boolean) - get an absolute / relative url to update the location to */ -export const createUrlControls = () => { +interface IUrlControls { + listen: (cb: () => void) => () => void; + update: (updater: (currentUrl: string) => string, replace: boolean) => Promise; +} + +let urlControls: IUrlControls; +export const getUrlControls = () => { + if (urlControls) return urlControls; + const history = createBrowserHistory(); - return { + const updateQueue: Array<(currentUrl: string) => string> = []; + return (urlControls = { listen: (cb: () => void) => history.listen(() => { cb(); }), - update: (url: string, replace = false) => { - const { pathname, search } = parseUrl(url); - const parsedHash = parseUrlHash(url); - const searchQueryString = stringifyQueryString(parsedHash.query); - const location = { - pathname, - hash: formatUrl({ - pathname: parsedHash.pathname, - search: searchQueryString, - }), - search, - }; - if (replace) { - history.replace(location); - } else { - history.push(location); - } + update: (updater: (currentUrl: string) => string, replace = false) => { + updateQueue.push(updater); + + return Promise.resolve().then(() => { + if (updater.length === 0) return getCurrentUrl(); + + const resultUrl = updateQueue.reduce((url, nextUpdate) => nextUpdate(url), getCurrentUrl()); + + updateQueue.splice(0, updateQueue.length); + + if (resultUrl === getCurrentUrl()) return getCurrentUrl(); + + const { pathname, search } = parseUrl(resultUrl); + const parsedHash = parseUrlHash(resultUrl); + const searchQueryString = stringifyQueryString(parsedHash.query); + const location = { + pathname, + hash: formatUrl({ + pathname: parsedHash.pathname, + search: searchQueryString, + }), + search, + }; + if (replace) { + history.replace(location); + } else { + history.push(location); + } + return getCurrentUrl(); + }); }, - }; + }); }; From aa9c1ae365b7cadb317ccbb35f7e2f592ce4bab8 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 4 Dec 2019 12:33:01 +0100 Subject: [PATCH 2/4] improve --- src/plugins/kibana_utils/public/store/sync.ts | 56 ++++++------- src/plugins/kibana_utils/public/url/index.ts | 84 +++++++++++-------- 2 files changed, 78 insertions(+), 62 deletions(-) diff --git a/src/plugins/kibana_utils/public/store/sync.ts b/src/plugins/kibana_utils/public/store/sync.ts index 5601fca33c763..87c435b27c3cf 100644 --- a/src/plugins/kibana_utils/public/store/sync.ts +++ b/src/plugins/kibana_utils/public/store/sync.ts @@ -19,7 +19,7 @@ import { MonoTypeOperatorFunction, Observable, Subscription } from 'rxjs'; import { distinctUntilChanged, map, share, skip, startWith } from 'rxjs/operators'; -import { getUrlControls, getStateFromUrl, setStateToUrl } from '../url'; +import { createUrlControls, getStateFromUrl, setStateToUrl } from '../url'; /** * Configuration of StateSync utility @@ -33,7 +33,7 @@ export interface IStateSyncConfig< > { /** * Storage key to use for syncing, - * e.g. having syncKey '_a' will sync state to ?_a query param + * e.g. syncKey '_a' should be synced state to ?_a query param */ syncKey: string; /** @@ -199,10 +199,10 @@ export function isSyncStrategyFactory( const createUrlSyncStrategyFactory = ( { useHash = false }: { useHash: boolean } = { useHash: false } ): SyncStrategyFactory => (syncKey: string): ISyncStrategy => { - const { update: updateUrl, listen: listenUrl } = getUrlControls(); + const { updateAsync: updateUrlAsync, listen: listenUrl } = createUrlControls(); return { toStorage: async (state: BaseState, { replace = false } = { replace: false }) => { - await updateUrl( + await updateUrlAsync( currentUrl => setStateToUrl(syncKey, state, { useHash }, currentUrl), replace ); @@ -224,19 +224,13 @@ const createUrlSyncStrategyFactory = ( }; }; -/** - * SyncStrategy.Url: the same as old persisting of expanded state in rison format to url - * SyncStrategy.HashedUrl: the same as old persisting of hashed state using sessionStorage for storing expanded state - * - * Possible to provide own custom SyncStrategy by implementing ISyncStrategy - * - * SyncStrategy.Url is default - */ -const Strategies: { [key in SyncStrategy]: (syncKey: string) => ISyncStrategy } = { +const createStrategies: () => { + [key in SyncStrategy]: (syncKey: string) => ISyncStrategy; +} = () => ({ [SyncStrategy.Url]: createUrlSyncStrategyFactory({ useHash: false }), [SyncStrategy.HashedUrl]: createUrlSyncStrategyFactory({ useHash: true }), // Other SyncStrategies: LocalStorage, es, somewhere else... -}; +}); /** * Utility for syncing application state wrapped in IState container shape @@ -333,7 +327,9 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): Destro const stateSyncConfigs = Array.isArray(config) ? config : [config]; const subscriptions: Subscription[] = []; - stateSyncConfigs.forEach(async stateSyncConfig => { + const syncStrategies = createStrategies(); + + stateSyncConfigs.forEach(stateSyncConfig => { const toStorageMapper = stateSyncConfig.toStorageMapper || (s => s); const fromStorageMapper = stateSyncConfig.fromStorageMapper || (s => s); @@ -341,7 +337,7 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): Destro stateSyncConfig.syncStrategy ) ? stateSyncConfig.syncStrategy - : Strategies[stateSyncConfig.syncStrategy || SyncStrategy.Url])(stateSyncConfig.syncKey); + : syncStrategies[stateSyncConfig.syncStrategy || SyncStrategy.Url])(stateSyncConfig.syncKey); // returned boolean indicates if update happen const updateState = async (): Promise => { @@ -368,19 +364,7 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): Destro return true; }; - // initial syncing of store state and storage state - const initialTruthSource = stateSyncConfig.initialTruthSource ?? InitialTruthSource.Storage; - if (initialTruthSource === InitialTruthSource.Storage) { - const hasUpdated = updateState(); - // if there is nothing by state key in storage - // then we should fallback and consider state source of truth - if (!hasUpdated) { - await updateStorage({ replace: true }); - } - } else if (initialTruthSource === InitialTruthSource.Store) { - await updateStorage({ replace: true }); - } - + // subscribe to state and storage updates subscriptions.push( stateSyncConfig.store.state$ .pipe( @@ -397,6 +381,20 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): Destro updateState(); }) ); + + // initial syncing of store state and storage state + const initialTruthSource = stateSyncConfig.initialTruthSource ?? InitialTruthSource.Storage; + if (initialTruthSource === InitialTruthSource.Storage) { + updateState().then(hasUpdated => { + // if there is nothing by state key in storage + // then we should fallback and consider state source of truth + if (!hasUpdated) { + updateStorage({ replace: true }); + } + }); + } else if (initialTruthSource === InitialTruthSource.Store) { + updateStorage({ replace: true }); + } }); return () => { diff --git a/src/plugins/kibana_utils/public/url/index.ts b/src/plugins/kibana_utils/public/url/index.ts index 4c06a404780d8..0294598678032 100644 --- a/src/plugins/kibana_utils/public/url/index.ts +++ b/src/plugins/kibana_utils/public/url/index.ts @@ -137,56 +137,74 @@ export function setStateToUrl( /** * A tiny wrapper around history library to listen for url changes and update url * History library handles a bunch of cross browser edge cases - * - * listen(cb) - accepts a callback which will be called whenever url has changed - * update(url: string, replace: boolean) - get an absolute / relative url to update the location to */ interface IUrlControls { + /** + * Allows to listen for url changes + * @param cb - get's called when url has been changed + */ listen: (cb: () => void) => () => void; - update: (updater: (currentUrl: string) => string, replace: boolean) => Promise; -} -let urlControls: IUrlControls; -export const getUrlControls = () => { - if (urlControls) return urlControls; + /** + * Updates url synchronously + * @param url - url to update to + * @param replace - use replace instead of push + */ + update: (url: string, replace: boolean) => string; + + /** + * Schedules url update to next microtask, + * Useful to ignore sync changes to url + * @param updater - fn which receives current url and should return next url to update to + * @param replace - use replace instead of push + */ + updateAsync: (updater: UrlUpdaterFnType, replace: boolean) => Promise; +} +type UrlUpdaterFnType = (currentUrl: string) => string; +export const createUrlControls = (): IUrlControls => { const history = createBrowserHistory(); const updateQueue: Array<(currentUrl: string) => string> = []; - return (urlControls = { + return { listen: (cb: () => void) => history.listen(() => { cb(); }), - update: (updater: (currentUrl: string) => string, replace = false) => { + update: (newUrl: string, replace = false) => updateUrl(newUrl, replace), + updateAsync: (updater: (currentUrl: string) => string, replace = false) => { updateQueue.push(updater); + // Schedule url update to the next microtask return Promise.resolve().then(() => { if (updater.length === 0) return getCurrentUrl(); - const resultUrl = updateQueue.reduce((url, nextUpdate) => nextUpdate(url), getCurrentUrl()); - updateQueue.splice(0, updateQueue.length); - - if (resultUrl === getCurrentUrl()) return getCurrentUrl(); - - const { pathname, search } = parseUrl(resultUrl); - const parsedHash = parseUrlHash(resultUrl); - const searchQueryString = stringifyQueryString(parsedHash.query); - const location = { - pathname, - hash: formatUrl({ - pathname: parsedHash.pathname, - search: searchQueryString, - }), - search, - }; - if (replace) { - history.replace(location); - } else { - history.push(location); - } - return getCurrentUrl(); + return updateUrl(resultUrl, replace); }); }, - }); + }; + + function updateUrl(newUrl: string, replace = false): string { + if (newUrl === getCurrentUrl()) return getCurrentUrl(); + + const { pathname, search } = parseUrl(newUrl); + const parsedHash = parseUrlHash(newUrl); + const searchQueryString = stringifyQueryString(parsedHash.query); + const location = { + pathname, + hash: formatUrl({ + pathname: parsedHash.pathname, + search: searchQueryString, + }), + search, + }; + if (replace) { + history.replace(location); + } else { + history.push(location); + } + return getCurrentUrl(); + + return newUrl; + } }; From 522337fcf1c3457e628f4758ce0b88f3a40de0d9 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 4 Dec 2019 15:33:42 +0100 Subject: [PATCH 3/4] improve --- .../edit_index_pattern/edit_index_pattern.js | 54 ++++++++-- src/plugins/kibana_utils/public/store/sync.ts | 99 ++++++++++--------- src/plugins/kibana_utils/public/url/index.ts | 28 ++++-- 3 files changed, 119 insertions(+), 62 deletions(-) diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js index 621e9f72eb644..4dcd026a06d15 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js @@ -42,7 +42,7 @@ import { getEditBreadcrumbs } from '../breadcrumbs'; import { createStore, syncState, - InitialTruthSource, SyncStrategy, + SyncStrategy, } from '../../../../../../../../plugins/kibana_utils/public'; const REACT_SOURCE_FILTERS_DOM_ELEMENT_ID = 'reactSourceFiltersTable'; @@ -220,23 +220,59 @@ uiModules.get('apps/management') }; $scope.$$postDigest(() => { - // just an artificial example of advanced syncState util setup - // 1. different strategies are used for different slices - // 2. to/from storage mappers are used to shorten state keys + // 1. the simplest use case + // $scope.destroyStateSync = syncState({ + // syncKey: '_s', + // store, + // }); + + // 2. conditionally picking sync strategy + // $scope.destroyStateSync = syncState({ + // syncKey: '_s', + // store, + // syncStrategy: config.get('state:storeInSessionStorage') ? SyncStrategy.HashedUrl : SyncStrategy.Url + // }); + + // 3. implementing custom sync strategy + // const localStorageSyncStrategyFactory = (syncKey) => ({ + // toStorage: (state, syncKey) => localStorage.setItem(syncKey, JSON.stringify(state)), + // fromStorage: (syncKey) => localStorage.getItem(syncKey) ? JSON.parse(localStorage.getItem(syncKey)) : null + // }); + // $scope.destroyStateSync = syncState({ + // syncKey: '_s', + // store, + // syncStrategy: localStorageSyncStrategyFactory + // }); + + // 4. syncing only part of state + // $scope.destroyStateSync = syncState({ + // syncKey: '_s', + // store, + // toStorageMapper: s => ({ tab: s.tab }) + // }); + + // 5. transform state before serialising + // this could be super useful for backward compatibility + // $scope.destroyStateSync = syncState({ + // syncKey: '_s', + // store, + // toStorageMapper: s => ({ t: s.tab }), + // fromStorageMapper: s => ({ tab: s.t }) + // }); + + // 6. multiple different sync configs $scope.destroyStateSync = syncState([ { syncKey: '_a', store, - initialTruthSource: InitialTruthSource.Storage, syncStrategy: SyncStrategy.Url, - toStorageMapper: state => ({ t: state.tab }), - fromStorageMapper: storageState => ({ tab: storageState.t || 'indexedFields' }), + toStorageMapper: s => ({ t: s.tab }), + fromStorageMapper: s => ({ tab: s.t }) }, { syncKey: '_b', store, - initialTruthSource: InitialTruthSource.Storage, - syncStrategy: config.get('state:storeInSessionStorage') ? SyncStrategy.HashedUrl : SyncStrategy.Url, + syncStrategy: SyncStrategy.HashedUrl, toStorageMapper: state => ({ f: state.fieldFilter, i: state.indexedFieldTypeFilter, l: state.scriptedFieldLanguageFilter }), fromStorageMapper: storageState => ( { diff --git a/src/plugins/kibana_utils/public/store/sync.ts b/src/plugins/kibana_utils/public/store/sync.ts index 87c435b27c3cf..ba9f69b2100ba 100644 --- a/src/plugins/kibana_utils/public/store/sync.ts +++ b/src/plugins/kibana_utils/public/store/sync.ts @@ -19,7 +19,7 @@ import { MonoTypeOperatorFunction, Observable, Subscription } from 'rxjs'; import { distinctUntilChanged, map, share, skip, startWith } from 'rxjs/operators'; -import { createUrlControls, getStateFromUrl, setStateToUrl } from '../url'; +import { createUrlControls, getStateFromUrl, IUrlControls, setStateToUrl } from '../url'; /** * Configuration of StateSync utility @@ -54,7 +54,7 @@ export interface IStateSyncConfig< * * SyncStrategy.Url is default */ - syncStrategy?: SyncStrategy | SyncStrategyFactory; + syncStrategy?: SyncStrategy | ISyncStrategy; /** * These mappers are needed to transform application state to a different shape we want to store @@ -173,22 +173,15 @@ interface ISyncStrategy { * Take in a state object, should serialise and persist */ // TODO: replace sounds like something url specific ... - toStorage: (state: StorageState, opts: { replace: boolean }) => Promise; + toStorage: (syncKey: string, state: StorageState, opts: { replace: boolean }) => Promise; /** * Should retrieve state from the storage and deserialize it */ - fromStorage: () => Promise; + fromStorage: (syncKey: string) => Promise; /** * Should notify when the storage has changed */ - storageChange$: Observable; -} - -export type SyncStrategyFactory = (syncKey: string) => ISyncStrategy; -export function isSyncStrategyFactory( - syncStrategy: SyncStrategy | SyncStrategyFactory | void -): syncStrategy is SyncStrategyFactory { - return typeof syncStrategy === 'function'; + storageChange$?: (syncKey: string) => Observable; } /** @@ -197,40 +190,54 @@ export function isSyncStrategyFactory( * Both expanded and hashed use cases */ const createUrlSyncStrategyFactory = ( - { useHash = false }: { useHash: boolean } = { useHash: false } -): SyncStrategyFactory => (syncKey: string): ISyncStrategy => { - const { updateAsync: updateUrlAsync, listen: listenUrl } = createUrlControls(); + { useHash = false }: { useHash: boolean } = { useHash: false }, + { updateAsync: updateUrlAsync, listen: listenUrl }: IUrlControls = createUrlControls() +): ISyncStrategy => { return { - toStorage: async (state: BaseState, { replace = false } = { replace: false }) => { + toStorage: async ( + syncKey: string, + state: BaseState, + { replace = false } = { replace: false } + ) => { await updateUrlAsync( currentUrl => setStateToUrl(syncKey, state, { useHash }, currentUrl), replace ); }, - fromStorage: async () => getStateFromUrl(syncKey), - storageChange$: new Observable(observer => { - const unlisten = listenUrl(() => { - observer.next(); - }); + fromStorage: async syncKey => getStateFromUrl(syncKey), + storageChange$: (syncKey: string) => + new Observable(observer => { + const unlisten = listenUrl(() => { + observer.next(); + }); - return () => { - unlisten(); - }; - }).pipe( - map(() => getStateFromUrl(syncKey)), - distinctUntilChangedWithInitialValue(getStateFromUrl(syncKey), shallowEqual), - share() - ), + return () => { + unlisten(); + }; + }).pipe( + map(() => getStateFromUrl(syncKey)), + distinctUntilChangedWithInitialValue(getStateFromUrl(syncKey), shallowEqual), + share() + ), }; }; +export function isSyncStrategy( + syncStrategy: SyncStrategy | ISyncStrategy | void +): syncStrategy is ISyncStrategy { + return typeof syncStrategy === 'object'; +} + const createStrategies: () => { - [key in SyncStrategy]: (syncKey: string) => ISyncStrategy; -} = () => ({ - [SyncStrategy.Url]: createUrlSyncStrategyFactory({ useHash: false }), - [SyncStrategy.HashedUrl]: createUrlSyncStrategyFactory({ useHash: true }), - // Other SyncStrategies: LocalStorage, es, somewhere else... -}); + [key in SyncStrategy]: ISyncStrategy; +} = () => { + const urlControls = createUrlControls(); + return { + [SyncStrategy.Url]: createUrlSyncStrategyFactory({ useHash: false }, urlControls), + [SyncStrategy.HashedUrl]: createUrlSyncStrategyFactory({ useHash: true }, urlControls), + // Other SyncStrategies: LocalStorage, es, somewhere else... + }; +}; /** * Utility for syncing application state wrapped in IState container shape @@ -333,15 +340,13 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): Destro const toStorageMapper = stateSyncConfig.toStorageMapper || (s => s); const fromStorageMapper = stateSyncConfig.fromStorageMapper || (s => s); - const { toStorage, fromStorage, storageChange$ } = (isSyncStrategyFactory( - stateSyncConfig.syncStrategy - ) + const { toStorage, fromStorage, storageChange$ } = isSyncStrategy(stateSyncConfig.syncStrategy) ? stateSyncConfig.syncStrategy - : syncStrategies[stateSyncConfig.syncStrategy || SyncStrategy.Url])(stateSyncConfig.syncKey); + : syncStrategies[stateSyncConfig.syncStrategy || SyncStrategy.Url]; // returned boolean indicates if update happen const updateState = async (): Promise => { - const storageState = await fromStorage(); + const storageState = await fromStorage(stateSyncConfig.syncKey); if (!storageState) { return false; } @@ -360,7 +365,7 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): Destro // returned boolean indicates if update happen const updateStorage = async ({ replace = false } = {}): Promise => { const newStorageState = toStorageMapper(stateSyncConfig.store.get()); - await toStorage(newStorageState, { replace }); + await toStorage(stateSyncConfig.syncKey, newStorageState, { replace }); return true; }; @@ -376,11 +381,15 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): Destro ) .subscribe(() => { updateStorage(); - }), - storageChange$.subscribe(() => { - updateState(); - }) + }) ); + if (storageChange$) { + subscriptions.push( + storageChange$(stateSyncConfig.syncKey).subscribe(() => { + updateState(); + }) + ); + } // initial syncing of store state and storage state const initialTruthSource = stateSyncConfig.initialTruthSource ?? InitialTruthSource.Storage; diff --git a/src/plugins/kibana_utils/public/url/index.ts b/src/plugins/kibana_utils/public/url/index.ts index 0294598678032..5f2763cd8ef31 100644 --- a/src/plugins/kibana_utils/public/url/index.ts +++ b/src/plugins/kibana_utils/public/url/index.ts @@ -33,11 +33,11 @@ import { HashedItemStoreSingleton, } from '../../../../legacy/ui/public/state_management/state_storage'; -const parseUrl = (url: string) => _parseUrl(url, true); -const parseUrlHash = (url: string) => parseUrl(parseUrl(url).hash!.slice(1)); -const getCurrentUrl = () => window.location.href; -const parseCurrentUrl = () => parseUrl(getCurrentUrl()); -const parseCurrentUrlHash = () => parseUrlHash(getCurrentUrl()); +export const parseUrl = (url: string) => _parseUrl(url, true); +export const parseUrlHash = (url: string) => parseUrl(parseUrl(url).hash!.slice(1)); +export const getCurrentUrl = () => window.location.href; +export const parseCurrentUrl = () => parseUrl(getCurrentUrl()); +export const parseCurrentUrlHash = () => parseUrlHash(getCurrentUrl()); // encodeUriQuery implements the less-aggressive encoding done naturally by // the browser. We use it to generate the same urls the browser would @@ -138,7 +138,7 @@ export function setStateToUrl( * A tiny wrapper around history library to listen for url changes and update url * History library handles a bunch of cross browser edge cases */ -interface IUrlControls { +export interface IUrlControls { /** * Allows to listen for url changes * @param cb - get's called when url has been changed @@ -160,11 +160,16 @@ interface IUrlControls { */ updateAsync: (updater: UrlUpdaterFnType, replace: boolean) => Promise; } -type UrlUpdaterFnType = (currentUrl: string) => string; +export type UrlUpdaterFnType = (currentUrl: string) => string; export const createUrlControls = (): IUrlControls => { const history = createBrowserHistory(); const updateQueue: Array<(currentUrl: string) => string> = []; + + // if we should replace or push with next async update, + // if any call in a queue asked to push, then we should push + let shouldReplace = true; + return { listen: (cb: () => void) => history.listen(() => { @@ -173,13 +178,20 @@ export const createUrlControls = (): IUrlControls => { update: (newUrl: string, replace = false) => updateUrl(newUrl, replace), updateAsync: (updater: (currentUrl: string) => string, replace = false) => { updateQueue.push(updater); + if (shouldReplace) { + shouldReplace = replace; + } // Schedule url update to the next microtask return Promise.resolve().then(() => { if (updater.length === 0) return getCurrentUrl(); const resultUrl = updateQueue.reduce((url, nextUpdate) => nextUpdate(url), getCurrentUrl()); + const newUrl = updateUrl(resultUrl, shouldReplace); + // queue clean up updateQueue.splice(0, updateQueue.length); - return updateUrl(resultUrl, replace); + shouldReplace = true; + + return newUrl; }); }, }; From fd2ae902e2bfa88f7aeff9b4abdb1f0d3e46db94 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 4 Dec 2019 16:53:27 +0100 Subject: [PATCH 4/4] improve --- .../edit_index_pattern/edit_index_pattern.js | 61 +++++++++---------- src/plugins/kibana_utils/public/store/sync.ts | 3 +- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js index 4dcd026a06d15..bf3fa9c1c1fb2 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js @@ -42,7 +42,6 @@ import { getEditBreadcrumbs } from '../breadcrumbs'; import { createStore, syncState, - SyncStrategy, } from '../../../../../../../../plugins/kibana_utils/public'; const REACT_SOURCE_FILTERS_DOM_ELEMENT_ID = 'reactSourceFiltersTable'; @@ -221,10 +220,10 @@ uiModules.get('apps/management') $scope.$$postDigest(() => { // 1. the simplest use case - // $scope.destroyStateSync = syncState({ - // syncKey: '_s', - // store, - // }); + $scope.destroyStateSync = syncState({ + syncKey: '_s', + store, + }); // 2. conditionally picking sync strategy // $scope.destroyStateSync = syncState({ @@ -234,14 +233,14 @@ uiModules.get('apps/management') // }); // 3. implementing custom sync strategy - // const localStorageSyncStrategyFactory = (syncKey) => ({ - // toStorage: (state, syncKey) => localStorage.setItem(syncKey, JSON.stringify(state)), + // const localStorageSyncStrategy = { + // toStorage: (syncKey, state) => localStorage.setItem(syncKey, JSON.stringify(state)), // fromStorage: (syncKey) => localStorage.getItem(syncKey) ? JSON.parse(localStorage.getItem(syncKey)) : null - // }); + // }; // $scope.destroyStateSync = syncState({ // syncKey: '_s', // store, - // syncStrategy: localStorageSyncStrategyFactory + // syncStrategy: localStorageSyncStrategy // }); // 4. syncing only part of state @@ -261,28 +260,28 @@ uiModules.get('apps/management') // }); // 6. multiple different sync configs - $scope.destroyStateSync = syncState([ - { - syncKey: '_a', - store, - syncStrategy: SyncStrategy.Url, - toStorageMapper: s => ({ t: s.tab }), - fromStorageMapper: s => ({ tab: s.t }) - }, - { - syncKey: '_b', - store, - syncStrategy: SyncStrategy.HashedUrl, - toStorageMapper: state => ({ f: state.fieldFilter, i: state.indexedFieldTypeFilter, l: state.scriptedFieldLanguageFilter }), - fromStorageMapper: storageState => ( - { - fieldFilter: storageState.f || '', - indexedFieldTypeFilter: storageState.i || '', - scriptedFieldLanguageFilter: storageState.l || '' - } - ), - }, - ]); + // $scope.destroyStateSync = syncState([ + // { + // syncKey: '_a', + // store, + // syncStrategy: SyncStrategy.Url, + // toStorageMapper: s => ({ t: s.tab }), + // fromStorageMapper: s => ({ tab: s.t }) + // }, + // { + // syncKey: '_b', + // store, + // syncStrategy: SyncStrategy.HashedUrl, + // toStorageMapper: state => ({ f: state.fieldFilter, i: state.indexedFieldTypeFilter, l: state.scriptedFieldLanguageFilter }), + // fromStorageMapper: storageState => ( + // { + // fieldFilter: storageState.f || '', + // indexedFieldTypeFilter: storageState.i || '', + // scriptedFieldLanguageFilter: storageState.l || '' + // } + // ), + // }, + // ]); }); const indexPatternListProvider = Private(IndexPatternListFactory)(); diff --git a/src/plugins/kibana_utils/public/store/sync.ts b/src/plugins/kibana_utils/public/store/sync.ts index ba9f69b2100ba..c76937c04d8aa 100644 --- a/src/plugins/kibana_utils/public/store/sync.ts +++ b/src/plugins/kibana_utils/public/store/sync.ts @@ -228,6 +228,7 @@ export function isSyncStrategy( return typeof syncStrategy === 'object'; } +// strategies provided out of the box const createStrategies: () => { [key in SyncStrategy]: ISyncStrategy; } = () => { @@ -240,7 +241,7 @@ const createStrategies: () => { }; /** - * Utility for syncing application state wrapped in IState container shape + * Utility for syncing application state wrapped in IStore container * with some kind of storage (e.g. URL) * * Minimal usage example: