From 212e2bafdb702c395b34627ca09223cd512e6ced Mon Sep 17 00:00:00 2001 From: Luke Elmers Date: Thu, 21 Nov 2019 17:31:23 -0700 Subject: [PATCH 01/11] Add sync and url utils. --- src/plugins/kibana_utils/public/store/sync.ts | 98 +++++++++++++++++++ src/plugins/kibana_utils/public/url/index.ts | 70 +++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 src/plugins/kibana_utils/public/store/sync.ts create mode 100644 src/plugins/kibana_utils/public/url/index.ts diff --git a/src/plugins/kibana_utils/public/store/sync.ts b/src/plugins/kibana_utils/public/store/sync.ts new file mode 100644 index 0000000000000..41651ae4cb912 --- /dev/null +++ b/src/plugins/kibana_utils/public/store/sync.ts @@ -0,0 +1,98 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable, Subscription } from 'rxjs'; +import { IStorage, IStorageWrapper, Storage } from '../storage'; +import { updateHash, readStateUrl, generateStateUrl } from '../url'; + +export type BaseState = Record; + +export interface IState { + get: () => State; + set: (state: State) => void; + state$: Observable; +} + +export interface StateSyncConfig { + syncToUrl?: boolean; + syncToStorage?: boolean; + storageProvider?: IStorage; + watchUrl?: boolean; +} + +function updateStorage(state: T, storage: IStorageWrapper): void { + // TODO + return; +} + +export function syncState( + states: Record, + { + syncToUrl = true, + syncToStorage = true, + storageProvider = window.sessionStorage, + watchUrl = false, + }: StateSyncConfig = {} +) { + const subscriptions: Subscription[] = []; + const storage: IStorageWrapper = new Storage(storageProvider); + + const keysToStateIndex: Map = new Map(); + const queryKeys: string[] = []; + const statesList: IState[] = Object.entries(states).flatMap(([key, vals]) => { + vals.forEach(v => queryKeys.push(key)); + return vals; + }); + + const handleEvent = (stateIndex: number, state: BaseState) => { + if (syncToUrl) { + const urlState = readStateUrl(); + const queryKey = queryKeys[stateIndex]; + urlState[queryKey] = { + ...urlState[queryKey], + ...state, + }; + updateHash(generateStateUrl(urlState)); + } + + if (syncToStorage) { + updateStorage(state, storage); + } + }; + + statesList.forEach((state, stateIndex) => { + Object.keys(state.get()).forEach(key => { + keysToStateIndex.set(key, stateIndex); + }); + subscriptions.push( + state.state$.subscribe((val: BaseState) => { + handleEvent(stateIndex, val); + }) + ); + }); + + if (watchUrl) { + // TODO subscribe to url updates and push updates back to the service + } + + return () => { + subscriptions.forEach(sub => sub.unsubscribe()); + // TODO unsubscribe url watch + }; +} diff --git a/src/plugins/kibana_utils/public/url/index.ts b/src/plugins/kibana_utils/public/url/index.ts new file mode 100644 index 0000000000000..3d38605464807 --- /dev/null +++ b/src/plugins/kibana_utils/public/url/index.ts @@ -0,0 +1,70 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { parse as parseUrl, format as formatUrl } from 'url'; +// @ts-ignore +import rison from 'rison-node'; +// @ts-ignore +import encodeUriQuery from 'encode-uri-query'; +import { stringify as stringifyQueryString } from 'querystring'; +import { BaseState } from '../store/sync'; + +const parseCurrentUrl = () => parseUrl(window.location.href, true); +const parseCurrentUrlHash = () => parseUrl(parseCurrentUrl().hash!.slice(1), true); + +export function readStateUrl() { + const { query } = parseCurrentUrlHash(); + + const decoded: Record = {}; + try { + Object.keys(query).forEach(q => (decoded[q] = rison.decode(query[q]))); + } catch (e) { + throw new Error('oops'); + } + + return decoded; +} + +export function generateStateUrl(state: T): string { + const url = parseCurrentUrl(); + const hash = parseCurrentUrlHash(); + + const encoded: Record = {}; + try { + Object.keys(state).forEach(s => (encoded[s] = rison.encode(state[s]))); + } catch (e) { + throw new Error('oops'); + } + + // encodeUriQuery implements the less-aggressive encoding done naturally by + // the browser. We use it to generate the same urls the browser would + const searchQueryString = stringifyQueryString(encoded, undefined, undefined, { + encodeURIComponent: encodeUriQuery, + }); + + return formatUrl({ + ...url, + hash: formatUrl({ + pathname: hash.pathname, + search: searchQueryString, + }), + }); +} + +export const updateHash = (url: string) => window.history.pushState({}, '', url); From a79545b6546e2145f26fcf75bb1de91a032d8f85 Mon Sep 17 00:00:00 2001 From: Luke Elmers Date: Mon, 25 Nov 2019 23:27:24 -0700 Subject: [PATCH 02/11] Update index patterns edit page to use sync util for tab management. --- .../edit_index_pattern/edit_index_pattern.js | 254 ++++++++---------- 1 file changed, 116 insertions(+), 138 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 150fae6e87dde..8f54bbadc60a5 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 @@ -39,41 +39,35 @@ import { I18nContext } from 'ui/i18n'; import { getEditBreadcrumbs } from '../breadcrumbs'; +import { createStore, syncState } from '../../../../../../../../plugins/kibana_utils/public'; + const REACT_SOURCE_FILTERS_DOM_ELEMENT_ID = 'reactSourceFiltersTable'; const REACT_INDEXED_FIELDS_DOM_ELEMENT_ID = 'reactIndexedFieldsTable'; const REACT_SCRIPTED_FIELDS_DOM_ELEMENT_ID = 'reactScriptedFieldsTable'; -function updateSourceFiltersTable($scope, $state) { - if ($state.tab === 'sourceFilters') { - $scope.$$postDigest(() => { - const node = document.getElementById(REACT_SOURCE_FILTERS_DOM_ELEMENT_ID); - if (!node) { - return; - } - - render( - - { - $scope.editSections = $scope.editSectionsProvider( - $scope.indexPattern, - $scope.fieldFilter, - $scope.indexPatternListProvider - ); - $scope.refreshFilters(); - $scope.$apply(); - }} - /> - , - node - ); - }); - } else { - destroySourceFiltersTable(); - } +function updateSourceFiltersTable($scope) { + $scope.$$postDigest(() => { + const node = document.getElementById(REACT_SOURCE_FILTERS_DOM_ELEMENT_ID); + if (!node) { + return; + } + + render( + + { + $scope.editSections = $scope.editSectionsProvider($scope.indexPattern, $scope.fieldFilter, $scope.indexPatternListProvider); + $scope.refreshFilters(); + $scope.$apply(); + }} + /> + , + node, + ); + }); } function destroySourceFiltersTable() { @@ -81,44 +75,37 @@ function destroySourceFiltersTable() { node && unmountComponentAtNode(node); } -function updateScriptedFieldsTable($scope, $state) { - if ($state.tab === 'scriptedFields') { - $scope.$$postDigest(() => { - const node = document.getElementById(REACT_SCRIPTED_FIELDS_DOM_ELEMENT_ID); - if (!node) { - return; - } - render( - - { - $scope.kbnUrl.redirectToRoute(obj, route); - $scope.$apply(); - }, - getRouteHref: (obj, route) => $scope.kbnUrl.getRouteHref(obj, route), - }} - onRemoveField={() => { - $scope.editSections = $scope.editSectionsProvider( - $scope.indexPattern, - $scope.fieldFilter, - $scope.indexPatternListProvider - ); - $scope.refreshFilters(); +function updateScriptedFieldsTable($scope) { + $scope.$$postDigest(() => { + const node = document.getElementById(REACT_SCRIPTED_FIELDS_DOM_ELEMENT_ID); + if (!node) { + return; + } + + render( + + { + $scope.kbnUrl.redirectToRoute(obj, route); $scope.$apply(); - }} - /> - , - node - ); - }); - } else { - destroyScriptedFieldsTable(); - } + }, + getRouteHref: (obj, route) => $scope.kbnUrl.getRouteHref(obj, route), + }} + onRemoveField={() => { + $scope.editSections = $scope.editSectionsProvider($scope.indexPattern, $scope.fieldFilter, $scope.indexPatternListProvider); + $scope.refreshFilters(); + $scope.$apply(); + }} + /> + , + node, + ); + }); } function destroyScriptedFieldsTable() { @@ -126,37 +113,33 @@ function destroyScriptedFieldsTable() { node && unmountComponentAtNode(node); } -function updateIndexedFieldsTable($scope, $state) { - if ($state.tab === 'indexedFields') { - $scope.$$postDigest(() => { - const node = document.getElementById(REACT_INDEXED_FIELDS_DOM_ELEMENT_ID); - if (!node) { - return; - } - - render( - - { - $scope.kbnUrl.redirectToRoute(obj, route); - $scope.$apply(); - }, - getFieldInfo: $scope.getFieldInfo, - }} - /> - , - node - ); - }); - } else { - destroyIndexedFieldsTable(); - } +function updateIndexedFieldsTable($scope) { + $scope.$$postDigest(() => { + const node = document.getElementById(REACT_INDEXED_FIELDS_DOM_ELEMENT_ID); + if (!node) { + return; + } + + render( + + { + $scope.kbnUrl.redirectToRoute(obj, route); + $scope.$apply(); + }, + getFieldInfo: $scope.getFieldInfo, + }} + /> + , + node, + ); + }); } function destroyIndexedFieldsTable() { @@ -164,14 +147,30 @@ function destroyIndexedFieldsTable() { node && unmountComponentAtNode(node); } -uiRoutes.when('/management/kibana/index_patterns/:indexPatternId', { - template, - k7Breadcrumbs: getEditBreadcrumbs, - resolve: { - indexPattern: function ($route, Promise, redirectWhenMissing, indexPatterns) { - return Promise.resolve(indexPatterns.get($route.current.params.indexPatternId)).catch( - redirectWhenMissing('/management/kibana/index_patterns') - ); +function handleTabChange($scope, newTab) { + destroyIndexedFieldsTable(); + destroyScriptedFieldsTable(); + destroySourceFiltersTable(); + + switch(newTab) { + case 'indexedFields': + updateIndexedFieldsTable($scope); + case 'scriptedFields': + updateScriptedFieldsTable($scope); + case 'sourceFilters': + updateSourceFiltersTable($scope); + } +} + +uiRoutes + .when('/management/kibana/index_patterns/:indexPatternId', { + template, + k7Breadcrumbs: getEditBreadcrumbs, + resolve: { + indexPattern: function ($route, Promise, redirectWhenMissing, indexPatterns) { + return Promise.resolve(indexPatterns.get($route.current.params.indexPatternId)) + .catch(redirectWhenMissing('/management/kibana/index_patterns')); + } }, }, }); @@ -179,8 +178,13 @@ uiRoutes.when('/management/kibana/index_patterns/:indexPatternId', { uiModules .get('apps/management') .controller('managementIndexPatternsEdit', function ( - $scope, $location, $route, Promise, config, indexPatterns, Private, AppState, confirmModal) { - const $state = $scope.state = new AppState(); + $scope, $location, $route, Promise, config, indexPatterns, Private, confirmModal) { + + const $stateContainer = $scope.stateContainer = createStore({ tab: 'indexedFields' }); + $scope.destroyStateContainer = $stateContainer.state$.subscribe(s => handleTabChange($scope, s.tab)); + $scope.destroyStateSync = syncState({ _a: [$stateContainer] }); + + const indexPatternListProvider = Private(IndexPatternListFactory)(); $scope.fieldWildcardMatcher = (...args) => fieldWildcardMatcher(...args, config.get('metaFields')); $scope.editSectionsProvider = Private(IndicesEditSectionsProvider); @@ -208,8 +212,6 @@ uiModules ); $scope.refreshFilters(); $scope.fields = $scope.indexPattern.getNonScriptedFields(); - updateIndexedFieldsTable($scope, $state); - updateScriptedFieldsTable($scope, $state); }); $scope.refreshFilters = function () { @@ -232,11 +234,7 @@ uiModules }; $scope.changeTab = function (obj) { - $state.tab = obj.index; - updateIndexedFieldsTable($scope, $state); - updateScriptedFieldsTable($scope, $state); - updateSourceFiltersTable($scope, $state); - $state.save(); + $stateContainer.set({ tab: obj.index }); }; $scope.$watch('state.tab', function (tab) { @@ -321,34 +319,14 @@ uiModules if ($scope.fieldFilter === undefined) { return; } - - switch ($state.tab) { - case 'indexedFields': - updateIndexedFieldsTable($scope, $state); - case 'scriptedFields': - updateScriptedFieldsTable($scope, $state); - case 'sourceFilters': - updateSourceFiltersTable($scope, $state); - } - }); - - $scope.$watch('indexedFieldTypeFilter', () => { - if ($scope.indexedFieldTypeFilter !== undefined && $state.tab === 'indexedFields') { - updateIndexedFieldsTable($scope, $state); - } - }); - - $scope.$watch('scriptedFieldLanguageFilter', () => { - if ($scope.scriptedFieldLanguageFilter !== undefined && $state.tab === 'scriptedFields') { - updateScriptedFieldsTable($scope, $state); - } }); $scope.$on('$destroy', () => { destroyIndexedFieldsTable(); destroyScriptedFieldsTable(); + destroySourceFiltersTable(); + $scope.destroyStateSync(); + $scope.destroyStateContainer(); }); - updateScriptedFieldsTable($scope, $state); - updateSourceFiltersTable($scope, $state); }); From fddfd98d3cb3fea152378a68f2c6a71d659c7a52 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 28 Nov 2019 18:27:48 +0100 Subject: [PATCH 03/11] fix bugs, add syching with history --- .../edit_index_pattern/edit_index_pattern.js | 33 +++-- src/plugins/kibana_utils/public/store/sync.ts | 136 ++++++++++++------ src/plugins/kibana_utils/public/url/index.ts | 29 +++- 3 files changed, 143 insertions(+), 55 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 8f54bbadc60a5..c912830dce4a3 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 @@ -154,11 +154,11 @@ function handleTabChange($scope, newTab) { switch(newTab) { case 'indexedFields': - updateIndexedFieldsTable($scope); + return updateIndexedFieldsTable($scope); case 'scriptedFields': - updateScriptedFieldsTable($scope); + return updateScriptedFieldsTable($scope); case 'sourceFilters': - updateSourceFiltersTable($scope); + return updateSourceFiltersTable($scope); } } @@ -179,10 +179,23 @@ uiModules .get('apps/management') .controller('managementIndexPatternsEdit', function ( $scope, $location, $route, Promise, config, indexPatterns, Private, confirmModal) { + const $stateContainer = createStore({ tab: 'indexedFields' }); + Object.defineProperty($scope, 'state', { + get() { + return $stateContainer.get; + } + }); + const stateContainerSub = $stateContainer.state$.subscribe(s => { + handleTabChange($scope, s.tab); + if ($scope.$$phase !== '$apply' && $scope.$$phase !== '$digest') { + $scope.$apply(); + } + }); + handleTabChange($scope, $stateContainer.get().tab); - const $stateContainer = $scope.stateContainer = createStore({ tab: 'indexedFields' }); - $scope.destroyStateContainer = $stateContainer.state$.subscribe(s => handleTabChange($scope, s.tab)); - $scope.destroyStateSync = syncState({ _a: [$stateContainer] }); + $scope.$$postDigest(() => { + $scope.destroyStateSync = syncState({ _a: $stateContainer }); + }); const indexPatternListProvider = Private(IndexPatternListFactory)(); @@ -237,10 +250,6 @@ uiModules $stateContainer.set({ tab: obj.index }); }; - $scope.$watch('state.tab', function (tab) { - if (!tab) $scope.changeTab($scope.editSections[0]); - }); - $scope.$watchCollection('indexPattern.fields', function () { $scope.conflictFields = $scope.indexPattern.fields.filter(field => field.type === 'conflict'); }); @@ -325,8 +334,8 @@ uiModules destroyIndexedFieldsTable(); destroyScriptedFieldsTable(); destroySourceFiltersTable(); - $scope.destroyStateSync(); - $scope.destroyStateContainer(); + if(stateContainerSub) stateContainerSub.unsubscribe(); + if ($scope.destroyStateSync) $scope.destroyStateSync(); }); }); diff --git a/src/plugins/kibana_utils/public/store/sync.ts b/src/plugins/kibana_utils/public/store/sync.ts index 41651ae4cb912..397579bf3ef10 100644 --- a/src/plugins/kibana_utils/public/store/sync.ts +++ b/src/plugins/kibana_utils/public/store/sync.ts @@ -19,7 +19,7 @@ import { Observable, Subscription } from 'rxjs'; import { IStorage, IStorageWrapper, Storage } from '../storage'; -import { updateHash, readStateUrl, generateStateUrl } from '../url'; +import { createUrlControls, readStateUrl, generateStateUrl } from '../url'; export type BaseState = Record; @@ -30,69 +30,123 @@ export interface IState { } export interface StateSyncConfig { - syncToUrl?: boolean; - syncToStorage?: boolean; - storageProvider?: IStorage; - watchUrl?: boolean; + syncToUrl: boolean; + syncToStorage: boolean; + storageProvider: IStorage; + initialTruthSource: InitialTruthSource; } -function updateStorage(state: T, storage: IStorageWrapper): void { - // TODO - return; +export enum InitialTruthSource { + State, + // eslint-disable-next-line no-shadow + Storage, + None, } export function syncState( - states: Record, + states: Record, { syncToUrl = true, syncToStorage = true, storageProvider = window.sessionStorage, - watchUrl = false, - }: StateSyncConfig = {} + initialTruthSource = InitialTruthSource.Storage, + }: Partial = {} ) { const subscriptions: Subscription[] = []; const storage: IStorageWrapper = new Storage(storageProvider); + const { update: updateUrl, listen: listenUrl } = createUrlControls(); - const keysToStateIndex: Map = new Map(); - const queryKeys: string[] = []; - const statesList: IState[] = Object.entries(states).flatMap(([key, vals]) => { - vals.forEach(v => queryKeys.push(key)); - return vals; + const keyToState = new Map(); + const stateToKey = new Map(); + + Object.entries(states).forEach(([key, state]) => { + keyToState.set(key, state); + stateToKey.set(state, key); }); - const handleEvent = (stateIndex: number, state: BaseState) => { - if (syncToUrl) { - const urlState = readStateUrl(); - const queryKey = queryKeys[stateIndex]; - urlState[queryKey] = { - ...urlState[queryKey], - ...state, - }; - updateHash(generateStateUrl(urlState)); - } + let ignoreStateUpdate = false; + let ignoreStorageUpdate = false; - if (syncToStorage) { - updateStorage(state, storage); - } + const updateState = (state$: IState): boolean => { + if (ignoreStateUpdate) return false; + const update = () => { + if (syncToUrl) { + const urlState = readStateUrl(); + const key = stateToKey.get(state$); + if (!key || !urlState[key]) { + ignoreStorageUpdate = false; + return false; + } + + if (key && urlState[key]) { + state$.set(urlState[key]); + return true; + } + } + + return false; + }; + + ignoreStorageUpdate = true; + const updated = update(); + ignoreStorageUpdate = false; + return updated; }; - statesList.forEach((state, stateIndex) => { - Object.keys(state.get()).forEach(key => { - keysToStateIndex.set(key, stateIndex); - }); - subscriptions.push( - state.state$.subscribe((val: BaseState) => { - handleEvent(stateIndex, val); - }) - ); + const updateStorage = (state$: IState, { replace = false } = {}): boolean => { + if (ignoreStorageUpdate) return false; + + const update = () => { + if (syncToUrl) { + const urlState = readStateUrl(); + const key = stateToKey.get(state$); + if (!key) return false; + urlState[key] = state$.get(); + updateUrl(generateStateUrl(urlState), replace); + return true; + } + + return false; + }; + + ignoreStateUpdate = true; + const hasUpdated = update(); + ignoreStateUpdate = false; + return hasUpdated; + }; + + Object.values(states).forEach(state => { + if (initialTruthSource === InitialTruthSource.Storage) { + const hasUpdated = updateState(state); + // if there is nothing by state key in storage + // then we should fallback and consider state source of truth + if (!hasUpdated) { + updateStorage(state, { replace: true }); + } + } else if (initialTruthSource === InitialTruthSource.State) { + updateStorage(state); + } }); - if (watchUrl) { - // TODO subscribe to url updates and push updates back to the service + subscriptions.push( + ...Object.values(states).map(s => + s.state$.subscribe(() => { + updateStorage(s); + }) + ) + ); + + let unlistenUrlChange: () => void; + if (syncToUrl) { + unlistenUrlChange = listenUrl(() => { + Object.values(states).forEach(state$ => updateState(state$)); + }); } return () => { + keyToState.clear(); + stateToKey.clear(); subscriptions.forEach(sub => sub.unsubscribe()); - // TODO unsubscribe url watch + if (unlistenUrlChange) unlistenUrlChange(); }; } diff --git a/src/plugins/kibana_utils/public/url/index.ts b/src/plugins/kibana_utils/public/url/index.ts index 3d38605464807..61d56db09b9af 100644 --- a/src/plugins/kibana_utils/public/url/index.ts +++ b/src/plugins/kibana_utils/public/url/index.ts @@ -17,11 +17,12 @@ * under the License. */ -import { parse as parseUrl, format as formatUrl } from 'url'; +import { format as formatUrl, parse as parseUrl } from 'url'; // @ts-ignore import rison from 'rison-node'; // @ts-ignore import encodeUriQuery from 'encode-uri-query'; +import { createBrowserHistory } from 'history'; import { stringify as stringifyQueryString } from 'querystring'; import { BaseState } from '../store/sync'; @@ -67,4 +68,28 @@ export function generateStateUrl(state: T): string { }); } -export const updateHash = (url: string) => window.history.pushState({}, '', url); +export const createUrlControls = () => { + const history = createBrowserHistory(); + return { + listen: (cb: () => void) => + history.listen(() => { + cb(); + }), + update: (url: string, replace = false) => { + const { pathname, hash, search } = parseUrl(url); + if (replace) { + history.replace({ + pathname, + hash, + search, + }); + } else { + history.push({ + pathname, + hash, + search, + }); + } + }, + }; +}; From f4a93d86077ec59af9aab83349983968b3ce83ec Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Fri, 29 Nov 2019 16:35:26 +0100 Subject: [PATCH 04/11] api improvements, bugfixes --- .../edit_index_pattern.html | 1 + .../edit_index_pattern/edit_index_pattern.js | 101 ++++++-- src/plugins/kibana_utils/public/store/sync.ts | 228 +++++++++++------- src/plugins/kibana_utils/public/url/index.ts | 67 ++--- 4 files changed, 263 insertions(+), 134 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 4b3014fd28a51..f51913cb33650 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 @@ -112,6 +112,7 @@ type="text" aria-label="{{::'kbn.management.editIndexPattern.fields.filterAria' | i18n: {defaultMessage: 'Filter'} }}" ng-model="fieldFilter" + ng-change="onFieldFilterInputChange(fieldFilter)" placeholder="{{::'kbn.management.editIndexPattern.fields.filterPlaceholder' | i18n: {defaultMessage: 'Filter'} }}" data-test-subj="indexPatternFieldFilter" > 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 c912830dce4a3..0b0a262cfad77 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 @@ -39,7 +39,11 @@ import { I18nContext } from 'ui/i18n'; import { getEditBreadcrumbs } from '../breadcrumbs'; -import { createStore, syncState } from '../../../../../../../../plugins/kibana_utils/public'; +import { + createStore, + syncState, + InitialTruthSource, +} from '../../../../../../../../plugins/kibana_utils/public'; const REACT_SOURCE_FILTERS_DOM_ELEMENT_ID = 'reactSourceFiltersTable'; const REACT_INDEXED_FIELDS_DOM_ELEMENT_ID = 'reactIndexedFieldsTable'; @@ -56,10 +60,14 @@ function updateSourceFiltersTable($scope) { { - $scope.editSections = $scope.editSectionsProvider($scope.indexPattern, $scope.fieldFilter, $scope.indexPatternListProvider); + $scope.editSections = $scope.editSectionsProvider( + $scope.indexPattern, + $scope.state.fieldFilter, + $scope.indexPatternListProvider + ); $scope.refreshFilters(); $scope.$apply(); }} @@ -75,9 +83,9 @@ function destroySourceFiltersTable() { node && unmountComponentAtNode(node); } - function updateScriptedFieldsTable($scope) { $scope.$$postDigest(() => { + const node = document.getElementById(REACT_SCRIPTED_FIELDS_DOM_ELEMENT_ID); if (!node) { return; @@ -87,8 +95,8 @@ function updateScriptedFieldsTable($scope) { { $scope.kbnUrl.redirectToRoute(obj, route); @@ -97,7 +105,11 @@ function updateScriptedFieldsTable($scope) { getRouteHref: (obj, route) => $scope.kbnUrl.getRouteHref(obj, route), }} onRemoveField={() => { - $scope.editSections = $scope.editSectionsProvider($scope.indexPattern, $scope.fieldFilter, $scope.indexPatternListProvider); + $scope.editSections = $scope.editSectionsProvider( + $scope.indexPattern, + $scope.state.fieldFilter, + $scope.indexPatternListProvider + ); $scope.refreshFilters(); $scope.$apply(); }} @@ -125,9 +137,9 @@ function updateIndexedFieldsTable($scope) { { $scope.kbnUrl.redirectToRoute(obj, route); @@ -179,14 +191,24 @@ uiModules .get('apps/management') .controller('managementIndexPatternsEdit', function ( $scope, $location, $route, Promise, config, indexPatterns, Private, confirmModal) { - const $stateContainer = createStore({ tab: 'indexedFields' }); + const $stateContainer = createStore( + { + tab: 'indexedFields', + fieldFilter: '', + indexedFieldTypeFilter: '', + scriptedFieldLanguageFilter: '' } + ); Object.defineProperty($scope, 'state', { get() { - return $stateContainer.get; - } + return $stateContainer.get(); + }, }); const stateContainerSub = $stateContainer.state$.subscribe(s => { handleTabChange($scope, s.tab); + $scope.fieldFilter = s.fieldFilter; + handleFieldFilterChange(s.fieldFilter); + $scope.indexedFieldTypeFilter = s.indexedFieldTypeFilter; + $scope.scriptedFieldLanguageFilter = s.scriptedFieldLanguageFilter; if ($scope.$$phase !== '$apply' && $scope.$$phase !== '$digest') { $scope.$apply(); } @@ -194,7 +216,40 @@ uiModules handleTabChange($scope, $stateContainer.get().tab); $scope.$$postDigest(() => { - $scope.destroyStateSync = syncState({ _a: $stateContainer }); + // $scope.destroyStateSync = syncState([ + // { + // syncKey: '_a', + // state: $stateContainer, + // initialTruthSource: InitialTruthSource.Storage, + // syncStrategy: 'url', + // toStorageMapper: state => ({ t: state.tab, f: state.fieldFilter }), + // fromStorageMapper: storageState => ({ tab: storageState.t || 'indexedFields', fieldFilter: storageState.f || '' }), + // } + // ]); + $scope.destroyStateSync = syncState([ + { + syncKey: '_a', + state: $stateContainer, + initialTruthSource: InitialTruthSource.Storage, + syncStrategy: 'url', + toStorageMapper: state => ({ t: state.tab }), + fromStorageMapper: storageState => ({ tab: storageState.t || 'indexedFields' }), + }, + { + syncKey: '_b', + state: $stateContainer, + initialTruthSource: InitialTruthSource.Storage, + syncStrategy: 'url', + 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)(); @@ -243,11 +298,11 @@ uiModules }; $scope.changeFilter = function (filter, val) { - $scope[filter] = val || ''; // null causes filter to check for null explicitly + $stateContainer.set({ ...$stateContainer.get(), [filter]: val || '' }); // null causes filter to check for null explicitly }; $scope.changeTab = function (obj) { - $stateContainer.set({ tab: obj.index }); + $stateContainer.set({ ...$stateContainer.get(), tab: obj.index }); }; $scope.$watchCollection('indexPattern.fields', function () { @@ -319,23 +374,29 @@ uiModules return $scope.indexPattern.save(); }; - $scope.$watch('fieldFilter', () => { + $scope.onFieldFilterInputChange = function (fieldFilter) { + $stateContainer.set({ + ...$stateContainer.get(), + fieldFilter, + }); + }; + + function handleFieldFilterChange() { $scope.editSections = $scope.editSectionsProvider( $scope.indexPattern, $scope.fieldFilter, - managementSetup.indexPattern.list + indexPatternListProvider ); if ($scope.fieldFilter === undefined) { return; } - }); + } $scope.$on('$destroy', () => { destroyIndexedFieldsTable(); destroyScriptedFieldsTable(); destroySourceFiltersTable(); - if(stateContainerSub) stateContainerSub.unsubscribe(); + if (stateContainerSub) stateContainerSub.unsubscribe(); if ($scope.destroyStateSync) $scope.destroyStateSync(); }); - }); diff --git a/src/plugins/kibana_utils/public/store/sync.ts b/src/plugins/kibana_utils/public/store/sync.ts index 397579bf3ef10..7fc01793a82a5 100644 --- a/src/plugins/kibana_utils/public/store/sync.ts +++ b/src/plugins/kibana_utils/public/store/sync.ts @@ -18,8 +18,8 @@ */ import { Observable, Subscription } from 'rxjs'; -import { IStorage, IStorageWrapper, Storage } from '../storage'; -import { createUrlControls, readStateUrl, generateStateUrl } from '../url'; +import { distinctUntilChanged, map, share, skip, startWith } from 'rxjs/operators'; +import { createUrlControls, getStateFromUrl, setStateToUrl } from '../url'; export type BaseState = Record; @@ -29,124 +29,186 @@ export interface IState { state$: Observable; } -export interface StateSyncConfig { - syncToUrl: boolean; - syncToStorage: boolean; - storageProvider: IStorage; - initialTruthSource: InitialTruthSource; -} - export enum InitialTruthSource { State, - // eslint-disable-next-line no-shadow Storage, None, } -export function syncState( - states: Record, - { - syncToUrl = true, - syncToStorage = true, - storageProvider = window.sessionStorage, - initialTruthSource = InitialTruthSource.Storage, - }: Partial = {} -) { - const subscriptions: Subscription[] = []; - const storage: IStorageWrapper = new Storage(storageProvider); - const { update: updateUrl, listen: listenUrl } = createUrlControls(); +export type SyncStrategyType = 'url' | 'hashed-url' | string; + +export interface StateSyncConfig< + State extends BaseState = BaseState, + StorageState extends BaseState = BaseState +> { + syncKey: string; + state: IState; + syncStrategy: SyncStrategyType; + toStorageMapper?: (state: State) => StorageState; + fromStorageMapper?: (storageState: StorageState) => Partial; + initialTruthSource?: InitialTruthSource; +} - const keyToState = new Map(); - const stateToKey = new Map(); +interface SyncStrategy { + // TODO: replace sounds like something url specific ... + toStorage: (state: State, opts: { replace: boolean }) => void; + fromStorage: () => State; + storageChange$: Observable; +} - Object.entries(states).forEach(([key, state]) => { - keyToState.set(key, state); - stateToKey.set(state, key); - }); +const createUrlSyncStrategy = (key: string): SyncStrategy => { + const { update: updateUrl, listen: listenUrl } = createUrlControls(); + return { + toStorage: (state: BaseState, { replace = false } = { replace: false }) => { + updateUrl(setStateToUrl(key, state), replace); + }, + fromStorage: () => getStateFromUrl(key), + storageChange$: new Observable(observer => { + const unlisten = listenUrl(() => { + observer.next(); + }); + + return () => { + unlisten(); + }; + }).pipe( + startWith(), + map(() => getStateFromUrl(key)), + distinctUntilChanged(shallowEqual), + skip(1), + share() + ), + }; +}; +export function syncState(config: StateSyncConfig[] | StateSyncConfig) { + const stateSyncConfigs = Array.isArray(config) ? config : [config]; + const subscriptions: Subscription[] = []; let ignoreStateUpdate = false; let ignoreStorageUpdate = false; - const updateState = (state$: IState): boolean => { - if (ignoreStateUpdate) return false; - const update = () => { - if (syncToUrl) { - const urlState = readStateUrl(); - const key = stateToKey.get(state$); - if (!key || !urlState[key]) { + stateSyncConfigs.forEach(stateSyncConfig => { + const { toStorage, fromStorage, storageChange$ } = createUrlSyncStrategy( + stateSyncConfig.syncKey + ); + + const updateState = (): boolean => { + if (ignoreStateUpdate) return false; + const update = () => { + const storageState = fromStorage(); + if (!storageState) { ignoreStorageUpdate = false; return false; } - if (key && urlState[key]) { - state$.set(urlState[key]); + const fromStorageMapper = stateSyncConfig.fromStorageMapper || (s => s); + + if (storageState) { + stateSyncConfig.state.set({ + ...stateSyncConfig.state.get(), + ...fromStorageMapper(storageState), + }); return true; } - } - return false; - }; + return false; + }; - ignoreStorageUpdate = true; - const updated = update(); - ignoreStorageUpdate = false; - return updated; - }; + ignoreStorageUpdate = true; + const updated = update(); + ignoreStorageUpdate = false; + return updated; + }; - const updateStorage = (state$: IState, { replace = false } = {}): boolean => { - if (ignoreStorageUpdate) return false; + const updateStorage = ({ replace = false } = {}): boolean => { + if (ignoreStorageUpdate) return false; - const update = () => { - if (syncToUrl) { - const urlState = readStateUrl(); - const key = stateToKey.get(state$); - if (!key) return false; - urlState[key] = state$.get(); - updateUrl(generateStateUrl(urlState), replace); + const update = () => { + const toStorageMapper = stateSyncConfig.toStorageMapper || (s => s); + const newStorageState = toStorageMapper(stateSyncConfig.state.get()); + toStorage(newStorageState, { replace }); return true; - } + }; - return false; + ignoreStateUpdate = true; + const hasUpdated = update(); + ignoreStateUpdate = false; + return hasUpdated; }; - ignoreStateUpdate = true; - const hasUpdated = update(); - ignoreStateUpdate = false; - return hasUpdated; - }; - - Object.values(states).forEach(state => { + const initialTruthSource = stateSyncConfig.initialTruthSource ?? InitialTruthSource.Storage; if (initialTruthSource === InitialTruthSource.Storage) { - const hasUpdated = updateState(state); + const hasUpdated = updateState(); // if there is nothing by state key in storage // then we should fallback and consider state source of truth if (!hasUpdated) { - updateStorage(state, { replace: true }); + updateStorage({ replace: true }); } } else if (initialTruthSource === InitialTruthSource.State) { - updateStorage(state); + updateStorage({ replace: true }); } - }); - subscriptions.push( - ...Object.values(states).map(s => - s.state$.subscribe(() => { - updateStorage(s); + subscriptions.push( + stateSyncConfig.state.state$ + .pipe( + startWith(stateSyncConfig.state.get()), + map(stateSyncConfig.toStorageMapper || (s => s)), + distinctUntilChanged(shallowEqual), + skip(1) + ) + .subscribe(() => { + // TODO: batch storage updates + updateStorage(); + }), + storageChange$.subscribe(() => { + // TODO: batch state updates? or should it be handled by state containers instead? + updateState(); }) - ) - ); - - let unlistenUrlChange: () => void; - if (syncToUrl) { - unlistenUrlChange = listenUrl(() => { - Object.values(states).forEach(state$ => updateState(state$)); - }); - } + ); + }); return () => { - keyToState.clear(); - stateToKey.clear(); subscriptions.forEach(sub => sub.unsubscribe()); - if (unlistenUrlChange) unlistenUrlChange(); }; } + +// https://github.com/facebook/fbjs/blob/master/packages/fbjs/src/core/shallowEqual.js +function shallowEqual(objA: any, objB: any): boolean { + if (is(objA, objB)) { + return true; + } + + if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) { + return false; + } + + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + + if (keysA.length !== keysB.length) { + return false; + } + + // Test for A's keys different from B. + for (let i = 0; i < keysA.length; i++) { + if ( + !Object.prototype.hasOwnProperty.call(objB, keysA[i]) || + !is(objA[keysA[i]], objB[keysA[i]]) + ) { + return false; + } + } + + return true; +} + +/** + * IE11 does not support Object.is + */ +function is(x: any, y: any): boolean { + if (x === y) { + return x !== 0 || y !== 0 || 1 / x === 1 / y; + } else { + return x !== x && y !== y; + } +} diff --git a/src/plugins/kibana_utils/public/url/index.ts b/src/plugins/kibana_utils/public/url/index.ts index 61d56db09b9af..c02c5dfeecdf5 100644 --- a/src/plugins/kibana_utils/public/url/index.ts +++ b/src/plugins/kibana_utils/public/url/index.ts @@ -17,22 +17,31 @@ * under the License. */ -import { format as formatUrl, parse as parseUrl } from 'url'; +import { format as formatUrl, parse as _parseUrl } from 'url'; // @ts-ignore import rison from 'rison-node'; // @ts-ignore import encodeUriQuery from 'encode-uri-query'; import { createBrowserHistory } from 'history'; -import { stringify as stringifyQueryString } from 'querystring'; +import { stringify as _stringifyQueryString, ParsedUrlQuery } from 'querystring'; import { BaseState } from '../store/sync'; -const parseCurrentUrl = () => parseUrl(window.location.href, true); -const parseCurrentUrlHash = () => parseUrl(parseCurrentUrl().hash!.slice(1), true); +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); -export function readStateUrl() { - const { query } = parseCurrentUrlHash(); +// encodeUriQuery implements the less-aggressive encoding done naturally by +// the browser. We use it to generate the same urls the browser would +const stringifyQueryString = (query: ParsedUrlQuery) => + _stringifyQueryString(query, undefined, undefined, { + encodeURIComponent: encodeUriQuery, + }); + +export function getStatesFromUrl(url: string = window.location.href): Record { + const { query } = parseUrlHash(url); - const decoded: Record = {}; + const decoded: Record = {}; try { Object.keys(query).forEach(q => (decoded[q] = rison.decode(query[q]))); } catch (e) { @@ -42,22 +51,16 @@ export function readStateUrl() { return decoded; } -export function generateStateUrl(state: T): string { +export function getStateFromUrl(key: string, url: string = window.location.href): BaseState { + return getStatesFromUrl(url)[key] || null; +} + +export function setStateToUrl(key: string, state: T): string { const url = parseCurrentUrl(); const hash = parseCurrentUrlHash(); - const encoded: Record = {}; - try { - Object.keys(state).forEach(s => (encoded[s] = rison.encode(state[s]))); - } catch (e) { - throw new Error('oops'); - } - - // encodeUriQuery implements the less-aggressive encoding done naturally by - // the browser. We use it to generate the same urls the browser would - const searchQueryString = stringifyQueryString(encoded, undefined, undefined, { - encodeURIComponent: encodeUriQuery, - }); + const encoded = rison.encode(state); + const searchQueryString = stringifyQueryString({ ...hash.query, [key]: encoded }); return formatUrl({ ...url, @@ -76,19 +79,21 @@ export const createUrlControls = () => { cb(); }), update: (url: string, replace = false) => { - const { pathname, hash, search } = parseUrl(url); + 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({ - pathname, - hash, - search, - }); + history.replace(location); } else { - history.push({ - pathname, - hash, - search, - }); + history.push(location); } }, }; From 4d4cdd18121ea7333d8a17426102534645b461c5 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Sat, 30 Nov 2019 08:40:42 +0100 Subject: [PATCH 05/11] make it a bit nicer extract distinctUntilChangedWithInitialValue operator --- src/plugins/kibana_utils/public/store/sync.ts | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/plugins/kibana_utils/public/store/sync.ts b/src/plugins/kibana_utils/public/store/sync.ts index 7fc01793a82a5..26de040698933 100644 --- a/src/plugins/kibana_utils/public/store/sync.ts +++ b/src/plugins/kibana_utils/public/store/sync.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Observable, Subscription } from 'rxjs'; +import { MonoTypeOperatorFunction, Observable, Subscription } from 'rxjs'; import { distinctUntilChanged, map, share, skip, startWith } from 'rxjs/operators'; import { createUrlControls, getStateFromUrl, setStateToUrl } from '../url'; @@ -53,7 +53,14 @@ interface SyncStrategy { // TODO: replace sounds like something url specific ... toStorage: (state: State, opts: { replace: boolean }) => void; fromStorage: () => State; - storageChange$: Observable; + storageChange$: Observable; +} + +function distinctUntilChangedWithInitialValue( + initialValue: T, + compare?: (x: T, y: T) => boolean +): MonoTypeOperatorFunction { + return input$ => input$.pipe(startWith(initialValue), distinctUntilChanged(compare), skip(1)); } const createUrlSyncStrategy = (key: string): SyncStrategy => { @@ -72,10 +79,8 @@ const createUrlSyncStrategy = (key: string): SyncStrategy => { unlisten(); }; }).pipe( - startWith(), map(() => getStateFromUrl(key)), - distinctUntilChanged(shallowEqual), - skip(1), + distinctUntilChangedWithInitialValue(getStateFromUrl(key), shallowEqual), share() ), }; @@ -88,6 +93,9 @@ export function syncState(config: StateSyncConfig[] | StateSyncConfig) { let ignoreStorageUpdate = false; stateSyncConfigs.forEach(stateSyncConfig => { + const toStorageMapper = stateSyncConfig.toStorageMapper || (s => s); + const fromStorageMapper = stateSyncConfig.fromStorageMapper || (s => s); + const { toStorage, fromStorage, storageChange$ } = createUrlSyncStrategy( stateSyncConfig.syncKey ); @@ -101,8 +109,6 @@ export function syncState(config: StateSyncConfig[] | StateSyncConfig) { return false; } - const fromStorageMapper = stateSyncConfig.fromStorageMapper || (s => s); - if (storageState) { stateSyncConfig.state.set({ ...stateSyncConfig.state.get(), @@ -124,7 +130,6 @@ export function syncState(config: StateSyncConfig[] | StateSyncConfig) { if (ignoreStorageUpdate) return false; const update = () => { - const toStorageMapper = stateSyncConfig.toStorageMapper || (s => s); const newStorageState = toStorageMapper(stateSyncConfig.state.get()); toStorage(newStorageState, { replace }); return true; @@ -151,10 +156,11 @@ export function syncState(config: StateSyncConfig[] | StateSyncConfig) { subscriptions.push( stateSyncConfig.state.state$ .pipe( - startWith(stateSyncConfig.state.get()), - map(stateSyncConfig.toStorageMapper || (s => s)), - distinctUntilChanged(shallowEqual), - skip(1) + map(toStorageMapper), + distinctUntilChangedWithInitialValue( + toStorageMapper(stateSyncConfig.state.get()), + shallowEqual + ) ) .subscribe(() => { // TODO: batch storage updates From 5531bc4141bbcda323e747c2683fff3dbef47961 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Mon, 2 Dec 2019 12:33:42 +0100 Subject: [PATCH 06/11] Add hashed url sync strategy --- .../edit_index_pattern/edit_index_pattern.js | 16 ++------ .../state_management/state_storage/index.d.ts | 25 +++++++++++++ src/plugins/kibana_utils/public/store/sync.ts | 37 ++++++++++++------- src/plugins/kibana_utils/public/url/index.ts | 34 +++++++++++++++-- 4 files changed, 83 insertions(+), 29 deletions(-) create mode 100644 src/legacy/ui/public/state_management/state_storage/index.d.ts 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 0b0a262cfad77..a02d8c54a770d 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, + InitialTruthSource, SyncStrategy, } from '../../../../../../../../plugins/kibana_utils/public'; const REACT_SOURCE_FILTERS_DOM_ELEMENT_ID = 'reactSourceFiltersTable'; @@ -216,22 +216,12 @@ uiModules handleTabChange($scope, $stateContainer.get().tab); $scope.$$postDigest(() => { - // $scope.destroyStateSync = syncState([ - // { - // syncKey: '_a', - // state: $stateContainer, - // initialTruthSource: InitialTruthSource.Storage, - // syncStrategy: 'url', - // toStorageMapper: state => ({ t: state.tab, f: state.fieldFilter }), - // fromStorageMapper: storageState => ({ tab: storageState.t || 'indexedFields', fieldFilter: storageState.f || '' }), - // } - // ]); $scope.destroyStateSync = syncState([ { syncKey: '_a', state: $stateContainer, initialTruthSource: InitialTruthSource.Storage, - syncStrategy: 'url', + syncStrategy: SyncStrategy.Url, toStorageMapper: state => ({ t: state.tab }), fromStorageMapper: storageState => ({ tab: storageState.t || 'indexedFields' }), }, @@ -239,7 +229,7 @@ uiModules syncKey: '_b', state: $stateContainer, initialTruthSource: InitialTruthSource.Storage, - syncStrategy: 'url', + syncStrategy: config.get('state:storeInSessionStorage') ? SyncStrategy.HashedUrl : SyncStrategy.Url, toStorageMapper: state => ({ f: state.fieldFilter, i: state.indexedFieldTypeFilter, l: state.scriptedFieldLanguageFilter }), fromStorageMapper: storageState => ( { diff --git a/src/legacy/ui/public/state_management/state_storage/index.d.ts b/src/legacy/ui/public/state_management/state_storage/index.d.ts new file mode 100644 index 0000000000000..a58d0494e93b4 --- /dev/null +++ b/src/legacy/ui/public/state_management/state_storage/index.d.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const HashedItemStoreSingleton: any; +export function isStateHash(value: string): boolean; +export function createStateHash( + value: string, + existingJsonProvider: (key: string) => string +): string; diff --git a/src/plugins/kibana_utils/public/store/sync.ts b/src/plugins/kibana_utils/public/store/sync.ts index 26de040698933..c67db51f0f4df 100644 --- a/src/plugins/kibana_utils/public/store/sync.ts +++ b/src/plugins/kibana_utils/public/store/sync.ts @@ -35,25 +35,28 @@ export enum InitialTruthSource { None, } -export type SyncStrategyType = 'url' | 'hashed-url' | string; +export enum SyncStrategy { + Url, + HashedUrl, +} -export interface StateSyncConfig< +export interface IStateSyncConfig< State extends BaseState = BaseState, StorageState extends BaseState = BaseState > { syncKey: string; state: IState; - syncStrategy: SyncStrategyType; + syncStrategy?: SyncStrategy; toStorageMapper?: (state: State) => StorageState; fromStorageMapper?: (storageState: StorageState) => Partial; initialTruthSource?: InitialTruthSource; } -interface SyncStrategy { +interface ISyncStrategy { // TODO: replace sounds like something url specific ... - toStorage: (state: State, opts: { replace: boolean }) => void; - fromStorage: () => State; - storageChange$: Observable; + toStorage: (state: StorageState, opts: { replace: boolean }) => void; + fromStorage: () => StorageState; + storageChange$: Observable; } function distinctUntilChangedWithInitialValue( @@ -63,11 +66,13 @@ function distinctUntilChangedWithInitialValue( return input$ => input$.pipe(startWith(initialValue), distinctUntilChanged(compare), skip(1)); } -const createUrlSyncStrategy = (key: string): SyncStrategy => { +const createUrlSyncStrategyFactory = ( + { useHash = false }: { useHash: boolean } = { useHash: false } +) => (key: string): ISyncStrategy => { const { update: updateUrl, listen: listenUrl } = createUrlControls(); return { toStorage: (state: BaseState, { replace = false } = { replace: false }) => { - updateUrl(setStateToUrl(key, state), replace); + updateUrl(setStateToUrl(key, state, { useHash }), replace); }, fromStorage: () => getStateFromUrl(key), storageChange$: new Observable(observer => { @@ -86,7 +91,13 @@ const createUrlSyncStrategy = (key: string): SyncStrategy => { }; }; -export function syncState(config: StateSyncConfig[] | StateSyncConfig) { +const Strategies: { [key in SyncStrategy]: (stateKey: string) => ISyncStrategy } = { + [SyncStrategy.Url]: createUrlSyncStrategyFactory({ useHash: false }), + [SyncStrategy.HashedUrl]: createUrlSyncStrategyFactory({ useHash: true }), + // Other SyncStrategies: LocalStorage, es, somewhere else... +}; + +export function syncState(config: IStateSyncConfig[] | IStateSyncConfig) { const stateSyncConfigs = Array.isArray(config) ? config : [config]; const subscriptions: Subscription[] = []; let ignoreStateUpdate = false; @@ -96,9 +107,9 @@ export function syncState(config: StateSyncConfig[] | StateSyncConfig) { const toStorageMapper = stateSyncConfig.toStorageMapper || (s => s); const fromStorageMapper = stateSyncConfig.fromStorageMapper || (s => s); - const { toStorage, fromStorage, storageChange$ } = createUrlSyncStrategy( - stateSyncConfig.syncKey - ); + const { toStorage, fromStorage, storageChange$ } = Strategies[ + stateSyncConfig.syncStrategy || SyncStrategy.Url + ](stateSyncConfig.syncKey); const updateState = (): boolean => { if (ignoreStateUpdate) return false; diff --git a/src/plugins/kibana_utils/public/url/index.ts b/src/plugins/kibana_utils/public/url/index.ts index c02c5dfeecdf5..a0dc25bb2af9c 100644 --- a/src/plugins/kibana_utils/public/url/index.ts +++ b/src/plugins/kibana_utils/public/url/index.ts @@ -26,6 +26,13 @@ import { createBrowserHistory } from 'history'; import { stringify as _stringifyQueryString, ParsedUrlQuery } from 'querystring'; import { BaseState } from '../store/sync'; +// TODO: NP, Typescriptify, Simplify +import { + createStateHash, + isStateHash, + 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 parseCurrentUrl = () => parseUrl(window.location.href); @@ -43,7 +50,13 @@ export function getStatesFromUrl(url: string = window.location.href): Record = {}; try { - Object.keys(query).forEach(q => (decoded[q] = rison.decode(query[q]))); + Object.entries(query).forEach(([q, value]) => { + if (isStateHash(value as string)) { + decoded[q] = JSON.parse(HashedItemStoreSingleton.getItem(value)!); + } else { + decoded[q] = rison.decode(query[q]); + } + }); } catch (e) { throw new Error('oops'); } @@ -55,11 +68,26 @@ export function getStateFromUrl(key: string, url: string = window.location.href) return getStatesFromUrl(url)[key] || null; } -export function setStateToUrl(key: string, state: T): string { +export function setStateToUrl( + key: string, + state: T, + { useHash = false }: { useHash: boolean } = { useHash: false } +): string { const url = parseCurrentUrl(); const hash = parseCurrentUrlHash(); - const encoded = rison.encode(state); + let encoded: string; + if (useHash) { + const stateJSON = JSON.stringify(state); + const stateHash = createStateHash(stateJSON, (hashKey: string) => + HashedItemStoreSingleton.getItem(hashKey) + ); + HashedItemStoreSingleton.setItem(stateHash, stateJSON); + encoded = stateHash; + } else { + encoded = rison.encode(state); + } + const searchQueryString = stringifyQueryString({ ...hash.query, [key]: encoded }); return formatUrl({ From dd57d7255ac19ed997fdfa5a2aef31ece4a9ed18 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 3 Dec 2019 10:15:14 +0100 Subject: [PATCH 07/11] add some basic comments to utils --- src/plugins/kibana_utils/public/store/sync.ts | 27 ++++++----- src/plugins/kibana_utils/public/url/index.ts | 47 +++++++++++++++++-- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/src/plugins/kibana_utils/public/store/sync.ts b/src/plugins/kibana_utils/public/store/sync.ts index c67db51f0f4df..9548091032e61 100644 --- a/src/plugins/kibana_utils/public/store/sync.ts +++ b/src/plugins/kibana_utils/public/store/sync.ts @@ -21,8 +21,23 @@ import { MonoTypeOperatorFunction, Observable, Subscription } from 'rxjs'; import { distinctUntilChanged, map, share, skip, startWith } from 'rxjs/operators'; import { createUrlControls, getStateFromUrl, setStateToUrl } from '../url'; +export interface IStateSyncConfig< + State extends BaseState = BaseState, + StorageState extends BaseState = BaseState +> { + syncKey: string; + state: IState; + syncStrategy?: SyncStrategy; + toStorageMapper?: (state: State) => StorageState; + fromStorageMapper?: (storageState: StorageState) => Partial; + initialTruthSource?: InitialTruthSource; +} + export type BaseState = Record; +/** + * To use StateSync util application have to pass state in the form of following interface + */ export interface IState { get: () => State; set: (state: State) => void; @@ -40,18 +55,6 @@ export enum SyncStrategy { HashedUrl, } -export interface IStateSyncConfig< - State extends BaseState = BaseState, - StorageState extends BaseState = BaseState -> { - syncKey: string; - state: IState; - syncStrategy?: SyncStrategy; - toStorageMapper?: (state: State) => StorageState; - fromStorageMapper?: (storageState: StorageState) => Partial; - initialTruthSource?: InitialTruthSource; -} - interface ISyncStrategy { // TODO: replace sounds like something url specific ... toStorage: (state: StorageState, opts: { replace: boolean }) => void; diff --git a/src/plugins/kibana_utils/public/url/index.ts b/src/plugins/kibana_utils/public/url/index.ts index a0dc25bb2af9c..9be82e781f6df 100644 --- a/src/plugins/kibana_utils/public/url/index.ts +++ b/src/plugins/kibana_utils/public/url/index.ts @@ -45,6 +45,16 @@ const stringifyQueryString = (query: ParsedUrlQuery) => encodeURIComponent: encodeUriQuery, }); +/** + * Parses a kibana url and retrieves all the states encoded into url, + * Handles both expanded rison state and hashed state (where the actual state stored in sessionStorage) + * e.g.: + * + * given an url: + * http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'') + * will return object: + * {_a: {tab: 'indexedFields'}, _b: {f: 'test', i: '', l: ''}}; + */ export function getStatesFromUrl(url: string = window.location.href): Record { const { query } = parseUrlHash(url); @@ -64,17 +74,41 @@ export function getStatesFromUrl(url: string = window.location.href): Record( key: string, state: T, - { useHash = false }: { useHash: boolean } = { useHash: false } + { useHash = false }: { useHash: boolean } = { useHash: false }, + rawUrl = window.location.href ): string { - const url = parseCurrentUrl(); - const hash = parseCurrentUrlHash(); + const url = parseUrl(rawUrl); + const hash = parseUrlHash(rawUrl); let encoded: string; if (useHash) { @@ -99,6 +133,13 @@ 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 + */ export const createUrlControls = () => { const history = createBrowserHistory(); return { From c0f4f8f5055a623043c384e6416a7b3500df48c8 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 3 Dec 2019 12:38:05 +0100 Subject: [PATCH 08/11] more comments --- .../edit_index_pattern/edit_index_pattern.js | 23 +- src/plugins/kibana_utils/public/store/sync.ts | 301 ++++++++++++++++-- 2 files changed, 285 insertions(+), 39 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 a02d8c54a770d..b3872af6bf3a1 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 @@ -191,7 +191,7 @@ uiModules .get('apps/management') .controller('managementIndexPatternsEdit', function ( $scope, $location, $route, Promise, config, indexPatterns, Private, confirmModal) { - const $stateContainer = createStore( + const store = createStore( { tab: 'indexedFields', fieldFilter: '', @@ -200,10 +200,10 @@ uiModules ); Object.defineProperty($scope, 'state', { get() { - return $stateContainer.get(); + return store.get(); }, }); - const stateContainerSub = $stateContainer.state$.subscribe(s => { + const stateContainerSub = store.state$.subscribe(s => { handleTabChange($scope, s.tab); $scope.fieldFilter = s.fieldFilter; handleFieldFilterChange(s.fieldFilter); @@ -213,13 +213,16 @@ uiModules $scope.$apply(); } }); - handleTabChange($scope, $stateContainer.get().tab); + handleTabChange($scope, store.get().tab); $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 $scope.destroyStateSync = syncState([ { syncKey: '_a', - state: $stateContainer, + store, initialTruthSource: InitialTruthSource.Storage, syncStrategy: SyncStrategy.Url, toStorageMapper: state => ({ t: state.tab }), @@ -227,7 +230,7 @@ uiModules }, { syncKey: '_b', - state: $stateContainer, + store, initialTruthSource: InitialTruthSource.Storage, syncStrategy: config.get('state:storeInSessionStorage') ? SyncStrategy.HashedUrl : SyncStrategy.Url, toStorageMapper: state => ({ f: state.fieldFilter, i: state.indexedFieldTypeFilter, l: state.scriptedFieldLanguageFilter }), @@ -288,11 +291,11 @@ uiModules }; $scope.changeFilter = function (filter, val) { - $stateContainer.set({ ...$stateContainer.get(), [filter]: val || '' }); // null causes filter to check for null explicitly + store.set({ ...store.get(), [filter]: val || '' }); // null causes filter to check for null explicitly }; $scope.changeTab = function (obj) { - $stateContainer.set({ ...$stateContainer.get(), tab: obj.index }); + store.set({ ...store.get(), tab: obj.index }); }; $scope.$watchCollection('indexPattern.fields', function () { @@ -365,8 +368,8 @@ uiModules }; $scope.onFieldFilterInputChange = function (fieldFilter) { - $stateContainer.set({ - ...$stateContainer.get(), + store.set({ + ...store.get(), fieldFilter, }); }; diff --git a/src/plugins/kibana_utils/public/store/sync.ts b/src/plugins/kibana_utils/public/store/sync.ts index 9548091032e61..a875966a0250d 100644 --- a/src/plugins/kibana_utils/public/store/sync.ts +++ b/src/plugins/kibana_utils/public/store/sync.ts @@ -21,63 +21,191 @@ import { MonoTypeOperatorFunction, Observable, Subscription } from 'rxjs'; import { distinctUntilChanged, map, share, skip, startWith } from 'rxjs/operators'; import { createUrlControls, getStateFromUrl, setStateToUrl } from '../url'; +/** + * Configuration of StateSync utility + * State - the interface of the form of application provided state + * StorageState - interface of the transformed State which will be serialised into storage + * (see toStorageMapper, fromStorageMapper) + */ export interface IStateSyncConfig< State extends BaseState = BaseState, StorageState extends BaseState = BaseState > { + /** + * Storage key to use for syncing, + * e.g. having syncKey '_a' will sync state to ?_a query param + */ syncKey: string; - state: IState; - syncStrategy?: SyncStrategy; + /** + * Store to keep in sync with storage, have to implement IStore interface + * The idea is that ./store/create_store.ts should be used as a state container, + * but it is also possible to implement own container for advanced use cases + */ + store: IStore; + /** + * Sync strategy to use, + * Is responsible for where to put to / where to get from the stored state + * 2 strategies available now, which replicate what State (AppState, GlobalState) implemented: + * + * 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 + */ + syncStrategy?: SyncStrategy | SyncStrategyFactory; + + /** + * These mappers are needed to transform application state to a different shape we want to store + * Some use cases: + * + * 1. Want to pick some specific parts of State to store. + * + * Having state in shape of: + * type State = {a: string, b: string}; + * + * Passing toStorageMapper as: + * toStorageMapper: (state) => ({b: state.b}) + * + * Will result in storing only b + * + * 2. Original state keys are too long and we want to give them a shorter name to persist in the url/storage + * + * Having state in shape of: + * type State = { someVeryLongAndReasonableName: string }; + * + * Passing toStorageMapper as: + * toStorageMapper: (state) => ({s: state.someVeryLongAndReasonableName}) + * + * Will result in having a bit shorter and nicer url (someVeryLongAndReasonableName -> s) + * + * In this case it is also mandatory to have fromStorageMapper which should mirror toStorageMapper: + * fromStorageMapper: (storageState) => ({someVeryLongAndReasonableName: state.s}) + * + * 3. Use different sync strategies for different state slices + * + * We could have multiple SyncStorageConfigs for a State container, + * These mappers allow to pick slices of state we want to use in this particular configuration. + * So we can setup a slice of state to be stored in the URL as expanded state + * and then different slice of the same state as HashedURL (just by using different strategies). + * + * 4. Backward compatibility + * + * Assume in v1 state was: + * type State = {a: string}; // v1 + * in v2 'a' was renamed into 'b' + * type State = {b: string}; // v2 + * + * To make sure old urls are still working we could have fromStorageMapper: + * fromStorageMapper: (storageState) => ({b: storageState.b || storageState.a}) + */ toStorageMapper?: (state: State) => StorageState; fromStorageMapper?: (storageState: StorageState) => Partial; + + /** + * On app start during StateSync util setup, + * Storage state and Applications's default state could be out of sync. + * + * initialTruthSource indicates who's values consider as source of truth + * + * InitialTruthSource.State - Application state take priority over storage state + * InitialTruthSource.Storage (default) - Storage state take priority over Application state + * InitialTruthSource.None - skip initial syncing do nothing + */ initialTruthSource?: InitialTruthSource; } -export type BaseState = Record; - /** - * To use StateSync util application have to pass state in the form of following interface + * To use StateSync util application have to pass state in the shape of following interface + * The idea is that ./store/create_store.ts should be used as state container, + * but it is also possible to implement own container for advanced use cases */ -export interface IState { +export type BaseState = Record; +export interface IStore { get: () => State; set: (state: State) => void; state$: Observable; } +/** + * On app start during initial setup, + * Storage state and applications's default state could be out of sync. + * + * initialTruthSource indicates who's values consider as source of truth + * + * InitialTruthSource.State - Application state take priority over storage state + * InitialTruthSource.Storage (default) - Storage state take priority over Application state + * InitialTruthSource.None - skip initial syncing do nothing + */ export enum InitialTruthSource { - State, + Store, Storage, None, } +/** + * Sync strategy is responsible for where to put to / where to get from the stored state + * 2 strategies available now, which replicate what State (AppState, GlobalState) implemented: + * + * 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 + */ export enum SyncStrategy { Url, HashedUrl, } +/** + * Any SyncStrategy have to implement ISyncStrategy interface + * SyncStrategy is responsible for: + * state serialisation / deserialization + * persisting to and retrieving from storage + * + * For an example take a look at already implemented URL sync strategies + */ 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; + /** + * Should retrieve state from the storage and deserialize it + */ fromStorage: () => StorageState; + /** + * Should notify when the storage has changed + */ storageChange$: Observable; } -function distinctUntilChangedWithInitialValue( - initialValue: T, - compare?: (x: T, y: T) => boolean -): MonoTypeOperatorFunction { - return input$ => input$.pipe(startWith(initialValue), distinctUntilChanged(compare), skip(1)); +export type SyncStrategyFactory = (syncKey: string) => ISyncStrategy; +export function isSyncStrategyFactory( + syncStrategy: SyncStrategy | SyncStrategyFactory | void +): syncStrategy is SyncStrategyFactory { + return typeof syncStrategy === 'function'; } +/** + * Implements syncing to/from url strategies. + * Replicates what was implemented in state (AppState, GlobalState) + * Both expanded and hashed use cases + */ const createUrlSyncStrategyFactory = ( { useHash = false }: { useHash: boolean } = { useHash: false } -) => (key: string): ISyncStrategy => { +): SyncStrategyFactory => (syncKey: string): ISyncStrategy => { const { update: updateUrl, listen: listenUrl } = createUrlControls(); return { toStorage: (state: BaseState, { replace = false } = { replace: false }) => { - updateUrl(setStateToUrl(key, state, { useHash }), replace); + const newUrl = setStateToUrl(syncKey, state, { useHash }); + updateUrl(newUrl, replace); }, - fromStorage: () => getStateFromUrl(key), + fromStorage: () => getStateFromUrl(syncKey), storageChange$: new Observable(observer => { const unlisten = listenUrl(() => { observer.next(); @@ -87,22 +215,125 @@ const createUrlSyncStrategyFactory = ( unlisten(); }; }).pipe( - map(() => getStateFromUrl(key)), - distinctUntilChangedWithInitialValue(getStateFromUrl(key), shallowEqual), + map(() => getStateFromUrl(syncKey)), + distinctUntilChangedWithInitialValue(getStateFromUrl(syncKey), shallowEqual), share() ), }; }; -const Strategies: { [key in SyncStrategy]: (stateKey: string) => ISyncStrategy } = { +/** + * 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 } = { [SyncStrategy.Url]: createUrlSyncStrategyFactory({ useHash: false }), [SyncStrategy.HashedUrl]: createUrlSyncStrategyFactory({ useHash: true }), // Other SyncStrategies: LocalStorage, es, somewhere else... }; -export function syncState(config: IStateSyncConfig[] | IStateSyncConfig) { +/** + * Utility for syncing application state wrapped in IState container shape + * with some kind of storage (e.g. URL) + * + * Minimal usage example: + * + * ``` + * type State = {tab: string}; + * const store: IStore = createStore({tab: 'indexedFields'}) + * + * syncState({ + * syncKey: '_s', + * store: store + * }) + * ``` + * Now State will be synced with url: + * * url will be updated on any store change + * * store will be updated on any url change + * + * By default SyncStrategy.Url is used, which serialises state in rison format + * + * The same example with different syncStrategy depending on kibana config: + * + * ``` + * syncState({ + * syncKey: '_s', + * store: store, + * syncStrategy: config.get('state:storeInSessionStorage') ? SyncStrategy.HashedUrl : SyncStrategy.Url + * }) + * ``` + * + * If there are multiple state containers: + * ``` + * type State1 = {tab: string}; + * const store1: IStore = createStore({tab: 'indexedFields'}) + * + * type State2 = {filter: string}; + * const store2: IStore = createStore({filter: 'filter1'}) + * + * syncState([ + * { + * syncKey: '_g', + * store: store1 + * }, + * { + * syncKey: '_a', + * store: store2 + * } + * ]) + * ``` + * + * If we want to sync only a slice of state + * + * ``` + * type State = {tab: string, filter: string}; + * const store: IStore = createStore({tab: 'indexedFields', filter: 'filter1'}) + * + * syncState({ + * syncKey: '_s', + * store: store, + * toStorageMapper: (state) => ({tab: state.tab}) + * }) + * ``` + * + * Only tab slice will be synced to storage. Updates to filter slice will be ignored + * + * Similar way we could use different sync strategies for different slices. + * E.g: to put into url an expanded 'tab' slice, but hashed 'filter' slice + * ``` + * syncState([{ + * syncKey: '_t', + * store: store, + * toStorageMapper: (state) => ({tab: state.tab}), + * syncStrategy: SyncStrategy.Url + * }, + * { + * syncKey: '_f', + * store: store, + * toStorageMapper: (state) => ({filter: state.filter}), + * syncStrategy: SyncStrategy.HashedUrl + * } + * }]) + * ``` + * + * syncState returns destroy function + * ``` + * const destroy = syncState(); + * destroy(); // stops listening for state and storage updates + * ``` + */ +export type DestroySyncStateFnType = () => void; +export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): DestroySyncStateFnType { 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; @@ -110,13 +341,16 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig) { const toStorageMapper = stateSyncConfig.toStorageMapper || (s => s); const fromStorageMapper = stateSyncConfig.fromStorageMapper || (s => s); - const { toStorage, fromStorage, storageChange$ } = Strategies[ - stateSyncConfig.syncStrategy || SyncStrategy.Url - ](stateSyncConfig.syncKey); + const { toStorage, fromStorage, storageChange$ } = (isSyncStrategyFactory( + stateSyncConfig.syncStrategy + ) + ? stateSyncConfig.syncStrategy + : Strategies[stateSyncConfig.syncStrategy || SyncStrategy.Url])(stateSyncConfig.syncKey); + // returned boolean indicates if update happen const updateState = (): boolean => { if (ignoreStateUpdate) return false; - const update = () => { + const update = (): boolean => { const storageState = fromStorage(); if (!storageState) { ignoreStorageUpdate = false; @@ -124,8 +358,8 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig) { } if (storageState) { - stateSyncConfig.state.set({ - ...stateSyncConfig.state.get(), + stateSyncConfig.store.set({ + ...stateSyncConfig.store.get(), ...fromStorageMapper(storageState), }); return true; @@ -140,11 +374,12 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig) { return updated; }; + // returned boolean indicates if update happen const updateStorage = ({ replace = false } = {}): boolean => { if (ignoreStorageUpdate) return false; const update = () => { - const newStorageState = toStorageMapper(stateSyncConfig.state.get()); + const newStorageState = toStorageMapper(stateSyncConfig.store.get()); toStorage(newStorageState, { replace }); return true; }; @@ -155,6 +390,7 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig) { return hasUpdated; }; + // initial syncing of store state and storage state const initialTruthSource = stateSyncConfig.initialTruthSource ?? InitialTruthSource.Storage; if (initialTruthSource === InitialTruthSource.Storage) { const hasUpdated = updateState(); @@ -163,16 +399,16 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig) { if (!hasUpdated) { updateStorage({ replace: true }); } - } else if (initialTruthSource === InitialTruthSource.State) { + } else if (initialTruthSource === InitialTruthSource.Store) { updateStorage({ replace: true }); } subscriptions.push( - stateSyncConfig.state.state$ + stateSyncConfig.store.state$ .pipe( map(toStorageMapper), distinctUntilChangedWithInitialValue( - toStorageMapper(stateSyncConfig.state.get()), + toStorageMapper(stateSyncConfig.store.get()), shallowEqual ) ) @@ -232,3 +468,10 @@ function is(x: any, y: any): boolean { return x !== x && y !== y; } } + +function distinctUntilChangedWithInitialValue( + initialValue: T, + compare?: (x: T, y: T) => boolean +): MonoTypeOperatorFunction { + return input$ => input$.pipe(startWith(initialValue), distinctUntilChanged(compare), skip(1)); +} From 9e3b176a6ff2d7170a7f75209bb80609ffc1fd80 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 3 Dec 2019 15:25:14 +0100 Subject: [PATCH 09/11] POC: Batching url updates improve improve improve --- .../edit_index_pattern.html | 2 + .../edit_index_pattern/edit_index_pattern.js | 97 ++++++--- src/plugins/kibana_utils/public/store/sync.ts | 202 ++++++++---------- src/plugins/kibana_utils/public/url/index.ts | 101 ++++++--- 4 files changed, 241 insertions(+), 161 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 b3872af6bf3a1..e937464eb3bc3 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, - InitialTruthSource, SyncStrategy, } from '../../../../../../../../plugins/kibana_utils/public'; const REACT_SOURCE_FILTERS_DOM_ELEMENT_ID = 'reactSourceFiltersTable'; @@ -215,34 +214,76 @@ uiModules }); 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 - // 2. to/from storage mappers are used to shorten state keys - $scope.destroyStateSync = syncState([ - { - syncKey: '_a', - store, - initialTruthSource: InitialTruthSource.Storage, - syncStrategy: SyncStrategy.Url, - toStorageMapper: state => ({ t: state.tab }), - fromStorageMapper: storageState => ({ tab: storageState.t || 'indexedFields' }), - }, - { - syncKey: '_b', - store, - initialTruthSource: InitialTruthSource.Storage, - syncStrategy: config.get('state:storeInSessionStorage') ? SyncStrategy.HashedUrl : SyncStrategy.Url, - toStorageMapper: state => ({ f: state.fieldFilter, i: state.indexedFieldTypeFilter, l: state.scriptedFieldLanguageFilter }), - fromStorageMapper: storageState => ( - { - fieldFilter: storageState.f || '', - indexedFieldTypeFilter: storageState.i || '', - scriptedFieldLanguageFilter: storageState.l || '' - } - ), - }, - ]); + // 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 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: localStorageSyncStrategy + // }); + + // 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, + // 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 a875966a0250d..c76937c04d8aa 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 @@ -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; /** @@ -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 }) => void; + toStorage: (syncKey: string, state: StorageState, opts: { replace: boolean }) => Promise; /** * Should retrieve state from the storage and deserialize it */ - fromStorage: () => StorageState; + 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,47 +190,58 @@ export function isSyncStrategyFactory( * Both expanded and hashed use cases */ const createUrlSyncStrategyFactory = ( - { useHash = false }: { useHash: boolean } = { useHash: false } -): SyncStrategyFactory => (syncKey: string): ISyncStrategy => { - const { update: updateUrl, listen: listenUrl } = createUrlControls(); + { useHash = false }: { useHash: boolean } = { useHash: false }, + { updateAsync: updateUrlAsync, listen: listenUrl }: IUrlControls = createUrlControls() +): ISyncStrategy => { return { - toStorage: (state: BaseState, { replace = false } = { replace: false }) => { - const newUrl = setStateToUrl(syncKey, state, { useHash }); - updateUrl(newUrl, replace); + toStorage: async ( + syncKey: string, + state: BaseState, + { replace = false } = { replace: false } + ) => { + await updateUrlAsync( + currentUrl => setStateToUrl(syncKey, state, { useHash }, currentUrl), + replace + ); }, - fromStorage: () => getStateFromUrl(syncKey), - storageChange$: new Observable(observer => { - const unlisten = listenUrl(() => { - observer.next(); - }); - - return () => { - unlisten(); - }; - }).pipe( - map(() => getStateFromUrl(syncKey)), - distinctUntilChangedWithInitialValue(getStateFromUrl(syncKey), shallowEqual), - share() - ), + 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() + ), }; }; -/** - * 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 } = { - [SyncStrategy.Url]: createUrlSyncStrategyFactory({ useHash: false }), - [SyncStrategy.HashedUrl]: createUrlSyncStrategyFactory({ useHash: true }), - // Other SyncStrategies: LocalStorage, es, somewhere else... +export function isSyncStrategy( + syncStrategy: SyncStrategy | ISyncStrategy | void +): syncStrategy is ISyncStrategy { + return typeof syncStrategy === 'object'; +} + +// strategies provided out of the box +const createStrategies: () => { + [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 + * Utility for syncing application state wrapped in IStore container * with some kind of storage (e.g. URL) * * Minimal usage example: @@ -331,78 +335,42 @@ 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; + const syncStrategies = createStrategies(); stateSyncConfigs.forEach(stateSyncConfig => { 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 - : Strategies[stateSyncConfig.syncStrategy || SyncStrategy.Url])(stateSyncConfig.syncKey); + : syncStrategies[stateSyncConfig.syncStrategy || SyncStrategy.Url]; // 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(stateSyncConfig.syncKey); + if (!storageState) { return false; - }; - - ignoreStorageUpdate = true; - const updated = update(); - ignoreStorageUpdate = false; - return updated; - }; - - // 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 }); + if (storageState) { + stateSyncConfig.store.set({ + ...stateSyncConfig.store.get(), + ...fromStorageMapper(storageState), + }); return true; - }; + } - ignoreStateUpdate = true; - const hasUpdated = update(); - ignoreStateUpdate = false; - return hasUpdated; + return false; }; - // 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) { - updateStorage({ replace: true }); - } - } else if (initialTruthSource === InitialTruthSource.Store) { - updateStorage({ replace: true }); - } + // returned boolean indicates if update happen + const updateStorage = async ({ replace = false } = {}): Promise => { + const newStorageState = toStorageMapper(stateSyncConfig.store.get()); + await toStorage(stateSyncConfig.syncKey, newStorageState, { replace }); + return true; + }; + // subscribe to state and storage updates subscriptions.push( stateSyncConfig.store.state$ .pipe( @@ -413,14 +381,30 @@ 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(); - }) + }) ); + if (storageChange$) { + subscriptions.push( + storageChange$(stateSyncConfig.syncKey).subscribe(() => { + 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 9be82e781f6df..5f2763cd8ef31 100644 --- a/src/plugins/kibana_utils/public/url/index.ts +++ b/src/plugins/kibana_utils/public/url/index.ts @@ -33,10 +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 parseCurrentUrl = () => parseUrl(window.location.href); -const parseCurrentUrlHash = () => parseUrlHash(window.location.href); +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 @@ -136,34 +137,86 @@ 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 */ -export const createUrlControls = () => { +export interface IUrlControls { + /** + * Allows to listen for url changes + * @param cb - get's called when url has been changed + */ + listen: (cb: () => void) => () => void; + + /** + * 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; +} +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(() => { 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: (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); + shouldReplace = true; + + return newUrl; + }); }, }; + + 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 8430a9aee4ab691d181e33a9d562ec7b93af1990 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 5 Dec 2019 12:15:00 +0100 Subject: [PATCH 10/11] improve comments --- src/plugins/kibana_utils/public/store/sync.ts | 54 +++++++++---------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/src/plugins/kibana_utils/public/store/sync.ts b/src/plugins/kibana_utils/public/store/sync.ts index c76937c04d8aa..78b6d4ff8b97c 100644 --- a/src/plugins/kibana_utils/public/store/sync.ts +++ b/src/plugins/kibana_utils/public/store/sync.ts @@ -23,8 +23,8 @@ import { createUrlControls, getStateFromUrl, IUrlControls, setStateToUrl } from /** * Configuration of StateSync utility - * State - the interface of the form of application provided state - * StorageState - interface of the transformed State which will be serialised into storage + * State - interface for application provided state + * StorageState - interface for the transformed State which will be passed into SyncStrategy for serialising and persisting * (see toStorageMapper, fromStorageMapper) */ export interface IStateSyncConfig< @@ -33,21 +33,21 @@ export interface IStateSyncConfig< > { /** * Storage key to use for syncing, - * e.g. syncKey '_a' should be synced state to ?_a query param + * e.g. syncKey '_a' should sync state to ?_a query param */ syncKey: string; /** * Store to keep in sync with storage, have to implement IStore interface * The idea is that ./store/create_store.ts should be used as a state container, - * but it is also possible to implement own container for advanced use cases + * but it is also possible to implement own custom container for advanced use cases */ store: IStore; /** * Sync strategy to use, - * Is responsible for where to put to / where to get from the stored state - * 2 strategies available now, which replicate what State (AppState, GlobalState) implemented: + * Sync strategy is responsible for serialising / deserialising and persisting / retrieving stored state + * 2 strategies available now out of the box, which replicate what State (AppState, GlobalState) implemented: * - * SyncStrategy.Url: the same as old persisting of expanded state in rison format to url + * SyncStrategy.Url: the same as old persisting of expanded state in rison format to the 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 @@ -60,7 +60,7 @@ export interface IStateSyncConfig< * These mappers are needed to transform application state to a different shape we want to store * Some use cases: * - * 1. Want to pick some specific parts of State to store. + * 1. Pick some specific parts of the state to persist. * * Having state in shape of: * type State = {a: string, b: string}; @@ -68,7 +68,7 @@ export interface IStateSyncConfig< * Passing toStorageMapper as: * toStorageMapper: (state) => ({b: state.b}) * - * Will result in storing only b + * Will result in storing only `b` * * 2. Original state keys are too long and we want to give them a shorter name to persist in the url/storage * @@ -85,9 +85,9 @@ export interface IStateSyncConfig< * * 3. Use different sync strategies for different state slices * - * We could have multiple SyncStorageConfigs for a State container, + * We could have multiple SyncStorageConfigs for a state container, * These mappers allow to pick slices of state we want to use in this particular configuration. - * So we can setup a slice of state to be stored in the URL as expanded state + * For example: we can setup a slice of state to be stored in the URL as expanded state * and then different slice of the same state as HashedURL (just by using different strategies). * * 4. Backward compatibility @@ -104,12 +104,10 @@ export interface IStateSyncConfig< fromStorageMapper?: (storageState: StorageState) => Partial; /** - * On app start during StateSync util setup, - * Storage state and Applications's default state could be out of sync. + * During app bootstrap we could have default app state and data in storage to be out of sync, + * initialTruthSource indicates who's values to consider as source of truth * - * initialTruthSource indicates who's values consider as source of truth - * - * InitialTruthSource.State - Application state take priority over storage state + * InitialTruthSource.Store - Application state take priority over storage state * InitialTruthSource.Storage (default) - Storage state take priority over Application state * InitialTruthSource.None - skip initial syncing do nothing */ @@ -117,9 +115,9 @@ export interface IStateSyncConfig< } /** - * To use StateSync util application have to pass state in the shape of following interface - * The idea is that ./store/create_store.ts should be used as state container, - * but it is also possible to implement own container for advanced use cases + * To use stateSync util application have to pass state container which implements IStore interface. + * The idea is that ./store/create_store.ts should be used as a state container by default, + * but it is also possible to implement own custom container for advanced use cases */ export type BaseState = Record; export interface IStore { @@ -129,12 +127,10 @@ export interface IStore { } /** - * On app start during initial setup, - * Storage state and applications's default state could be out of sync. - * - * initialTruthSource indicates who's values consider as source of truth + * During app bootstrap we could have default app state and data in storage to be out of sync, + * initialTruthSource indicates who's values to consider as source of truth * - * InitialTruthSource.State - Application state take priority over storage state + * InitialTruthSource.Store - Application state take priority over storage state * InitialTruthSource.Storage (default) - Storage state take priority over Application state * InitialTruthSource.None - skip initial syncing do nothing */ @@ -145,10 +141,10 @@ export enum InitialTruthSource { } /** - * Sync strategy is responsible for where to put to / where to get from the stored state - * 2 strategies available now, which replicate what State (AppState, GlobalState) implemented: + * Sync strategy is responsible for serialising / deserialising and persisting / retrieving stored state + * 2 strategies available now out of the box, which replicate what State (AppState, GlobalState) implemented: * - * SyncStrategy.Url: the same as old persisting of expanded state in rison format to url + * SyncStrategy.Url: the same as old persisting of expanded state in rison format to the 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 @@ -163,8 +159,8 @@ export enum SyncStrategy { /** * Any SyncStrategy have to implement ISyncStrategy interface * SyncStrategy is responsible for: - * state serialisation / deserialization - * persisting to and retrieving from storage + * * state serialisation / deserialization + * * persisting to and retrieving from storage * * For an example take a look at already implemented URL sync strategies */ From b0358e2dfa7349bb042c654acbbfb046eab9c15d Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 17 Dec 2019 13:07:52 +0300 Subject: [PATCH 11/11] poc: remove mappers, after rebase fixes --- .../edit_index_pattern/edit_index_pattern.js | 126 ++++---- src/plugins/kibana_utils/public/index.ts | 1 + .../kibana_utils/public/state_sync/index.ts} | 22 +- .../sync.ts => state_sync/state_sync.ts} | 275 ++++++------------ src/plugins/kibana_utils/public/url/index.ts | 25 +- 5 files changed, 191 insertions(+), 258 deletions(-) rename src/{legacy/ui/public/state_management/state_storage/index.d.ts => plugins/kibana_utils/public/state_sync/index.ts} (75%) rename src/plugins/kibana_utils/public/{store/sync.ts => state_sync/state_sync.ts} (58%) 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 e937464eb3bc3..634e690749863 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 @@ -20,6 +20,7 @@ import _ from 'lodash'; import './index_header'; import './create_edit_field'; +import { map } from 'rxjs/operators'; import { docTitle } from 'ui/doc_title'; import { KbnUrlProvider } from 'ui/url'; import { IndicesEditSectionsProvider } from './edit_sections'; @@ -40,8 +41,9 @@ import { I18nContext } from 'ui/i18n'; import { getEditBreadcrumbs } from '../breadcrumbs'; import { - createStore, + createStateContainer, syncState, + SyncStrategy } from '../../../../../../../../plugins/kibana_utils/public'; const REACT_SOURCE_FILTERS_DOM_ELEMENT_ID = 'reactSourceFiltersTable'; @@ -183,26 +185,26 @@ uiRoutes .catch(redirectWhenMissing('/management/kibana/index_patterns')); } }, - }, -}); + }); uiModules .get('apps/management') .controller('managementIndexPatternsEdit', function ( $scope, $location, $route, Promise, config, indexPatterns, Private, confirmModal) { - const store = createStore( + const stateContainer = createStateContainer( { tab: 'indexedFields', fieldFilter: '', indexedFieldTypeFilter: '', - scriptedFieldLanguageFilter: '' } + scriptedFieldLanguageFilter: '' }, + {} ); Object.defineProperty($scope, 'state', { get() { - return store.get(); + return stateContainer.get(); }, }); - const stateContainerSub = store.state$.subscribe(s => { + const stateContainerSub = stateContainer.state$.subscribe(s => { handleTabChange($scope, s.tab); $scope.fieldFilter = s.fieldFilter; handleFieldFilterChange(s.fieldFilter); @@ -212,28 +214,28 @@ uiModules $scope.$apply(); } }); - handleTabChange($scope, store.get().tab); + handleTabChange($scope, stateContainer.get().tab); $scope.crazyBatchUpdate = () => { - store.set({ ...store.get(), tab: 'indexedFiles' }); - store.set({ ...store.get() }); - store.set({ ...store.get(), fieldFilter: 'BATCH!' }); + stateContainer.set({ ...stateContainer.get(), tab: 'indexedFiles' }); + stateContainer.set({ ...stateContainer.get() }); + stateContainer.set({ ...stateContainer.get(), fieldFilter: 'BATCH!' }); }; $scope.$$postDigest(() => { // 1. the simplest use case - $scope.destroyStateSync = syncState({ - syncKey: '_s', - store, - }); - + // $scope.destroyStateSync = syncState({ + // syncKey: '_s', + // stateContainer, + // }); + // // 2. conditionally picking sync strategy // $scope.destroyStateSync = syncState({ // syncKey: '_s', - // store, - // syncStrategy: config.get('state:storeInSessionStorage') ? SyncStrategy.HashedUrl : SyncStrategy.Url + // stateContainer, + // syncStrategy: config.get('state:stateContainerInSessionStorage') ? SyncStrategy.HashedUrl : SyncStrategy.Url // }); - + // // 3. implementing custom sync strategy // const localStorageSyncStrategy = { // toStorage: (syncKey, state) => localStorage.setItem(syncKey, JSON.stringify(state)), @@ -241,53 +243,63 @@ uiModules // }; // $scope.destroyStateSync = syncState({ // syncKey: '_s', - // store, + // stateContainer, // syncStrategy: localStorageSyncStrategy // }); - + // // 4. syncing only part of state + // const stateToStorage = (s) => ({ tab: s.tab }); // $scope.destroyStateSync = syncState({ // syncKey: '_s', - // store, - // toStorageMapper: s => ({ tab: s.tab }) + // stateContainer: { + // get: () => stateToStorage(stateContainer.get()), + // set: stateContainer.set(({ tab }) => ({ ...stateContainer.get(), tab }), + // state$: stateContainer.state$.pipe(map(stateToStorage)) + // } // }); - + // // 5. transform state before serialising // this could be super useful for backward compatibility + // const stateToStorage = (s) => ({ t: s.tab }); // $scope.destroyStateSync = syncState({ // syncKey: '_s', - // store, - // toStorageMapper: s => ({ t: s.tab }), - // fromStorageMapper: s => ({ tab: s.t }) + // stateContainer: { + // get: () => stateToStorage(stateContainer.get()), + // set: ({ t }) => stateContainer.set({ ...stateContainer.get(), tab: t }), + // state$: stateContainer.state$.pipe(map(stateToStorage)) + // } // }); - + // // 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 || '' - // } - // ), - // }, - // ]); + const stateAToStorage = s => ({ t: s.tab }); + const stateBToStorage = s => ({ f: s.fieldFilter, i: s.indexedFieldTypeFilter, l: s.scriptedFieldLanguageFilter }); + $scope.destroyStateSync = syncState([ + { + syncKey: '_a', + syncStrategy: SyncStrategy.Url, + stateContainer: { + get: () => stateAToStorage(stateContainer.get()), + set: s => stateContainer.set(({ ...stateContainer.get(), tab: s.t })), + state$: stateContainer.state$.pipe(map(stateAToStorage)) + }, + }, + { + syncKey: '_b', + syncStrategy: SyncStrategy.HashedUrl, + stateContainer: { + get: () => stateBToStorage(stateContainer.get()), + set: s => stateContainer.set({ + ...stateContainer.get(), + fieldFilter: s.f || '', + indexedFieldTypeFilter: s.i || '', + scriptedFieldLanguageFilter: s.l || '' + }), + state$: stateContainer.state$.pipe(map(stateBToStorage)) + }, + }, + ]); }); - const indexPatternListProvider = Private(IndexPatternListFactory)(); - $scope.fieldWildcardMatcher = (...args) => fieldWildcardMatcher(...args, config.get('metaFields')); $scope.editSectionsProvider = Private(IndicesEditSectionsProvider); $scope.kbnUrl = Private(KbnUrlProvider); @@ -332,11 +344,11 @@ uiModules }; $scope.changeFilter = function (filter, val) { - store.set({ ...store.get(), [filter]: val || '' }); // null causes filter to check for null explicitly + stateContainer.set({ ...stateContainer.get(), [filter]: val || '' }); // null causes filter to check for null explicitly }; $scope.changeTab = function (obj) { - store.set({ ...store.get(), tab: obj.index }); + stateContainer.set({ ...stateContainer.get(), tab: obj.index }); }; $scope.$watchCollection('indexPattern.fields', function () { @@ -409,8 +421,8 @@ uiModules }; $scope.onFieldFilterInputChange = function (fieldFilter) { - store.set({ - ...store.get(), + stateContainer.set({ + ...stateContainer.get(), fieldFilter, }); }; @@ -419,7 +431,7 @@ uiModules $scope.editSections = $scope.editSectionsProvider( $scope.indexPattern, $scope.fieldFilter, - indexPatternListProvider + $scope.indexPatternListProvider ); if ($scope.fieldFilter === undefined) { return; diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts index 3f5aeebac54d8..3b994c68f946f 100644 --- a/src/plugins/kibana_utils/public/index.ts +++ b/src/plugins/kibana_utils/public/index.ts @@ -29,3 +29,4 @@ export * from './storage'; export * from './storage/hashed_item_store'; export * from './state_management/state_hash'; export * from './state_management/url'; +export * from './state_sync'; diff --git a/src/legacy/ui/public/state_management/state_storage/index.d.ts b/src/plugins/kibana_utils/public/state_sync/index.ts similarity index 75% rename from src/legacy/ui/public/state_management/state_storage/index.d.ts rename to src/plugins/kibana_utils/public/state_sync/index.ts index a58d0494e93b4..b939418f42246 100644 --- a/src/legacy/ui/public/state_management/state_storage/index.d.ts +++ b/src/plugins/kibana_utils/public/state_sync/index.ts @@ -17,9 +17,19 @@ * under the License. */ -export const HashedItemStoreSingleton: any; -export function isStateHash(value: string): boolean; -export function createStateHash( - value: string, - existingJsonProvider: (key: string) => string -): string; +import { + syncState, + SyncStrategy, + InitialTruthSource, + IStateSyncConfig, + DestroySyncStateFnType, + BaseState, +} from './state_sync'; +export { + syncState, + SyncStrategy, + InitialTruthSource, + IStateSyncConfig, + DestroySyncStateFnType, + BaseState, +}; diff --git a/src/plugins/kibana_utils/public/store/sync.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.ts similarity index 58% rename from src/plugins/kibana_utils/public/store/sync.ts rename to src/plugins/kibana_utils/public/state_sync/state_sync.ts index 78b6d4ff8b97c..5ca4488451521 100644 --- a/src/plugins/kibana_utils/public/store/sync.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync.ts @@ -20,28 +20,25 @@ import { MonoTypeOperatorFunction, Observable, Subscription } from 'rxjs'; import { distinctUntilChanged, map, share, skip, startWith } from 'rxjs/operators'; import { createUrlControls, getStateFromUrl, IUrlControls, setStateToUrl } from '../url'; +import { BaseStateContainer } from '../state_containers/types'; /** * Configuration of StateSync utility - * State - interface for application provided state - * StorageState - interface for the transformed State which will be passed into SyncStrategy for serialising and persisting - * (see toStorageMapper, fromStorageMapper) + * State - interface for application provided state to sync with storage */ -export interface IStateSyncConfig< - State extends BaseState = BaseState, - StorageState extends BaseState = BaseState -> { +export type BaseState = Record; +export interface IStateSyncConfig { /** * Storage key to use for syncing, * e.g. syncKey '_a' should sync state to ?_a query param */ syncKey: string; /** - * Store to keep in sync with storage, have to implement IStore interface - * The idea is that ./store/create_store.ts should be used as a state container, + * State container to keep in sync with storage, have to implement BaseStateContainer interface + * The idea is that ./state_containers/ should be used as a state container, * but it is also possible to implement own custom container for advanced use cases */ - store: IStore; + stateContainer: BaseStateContainer; /** * Sync strategy to use, * Sync strategy is responsible for serialising / deserialising and persisting / retrieving stored state @@ -56,86 +53,27 @@ export interface IStateSyncConfig< */ syncStrategy?: SyncStrategy | ISyncStrategy; - /** - * These mappers are needed to transform application state to a different shape we want to store - * Some use cases: - * - * 1. Pick some specific parts of the state to persist. - * - * Having state in shape of: - * type State = {a: string, b: string}; - * - * Passing toStorageMapper as: - * toStorageMapper: (state) => ({b: state.b}) - * - * Will result in storing only `b` - * - * 2. Original state keys are too long and we want to give them a shorter name to persist in the url/storage - * - * Having state in shape of: - * type State = { someVeryLongAndReasonableName: string }; - * - * Passing toStorageMapper as: - * toStorageMapper: (state) => ({s: state.someVeryLongAndReasonableName}) - * - * Will result in having a bit shorter and nicer url (someVeryLongAndReasonableName -> s) - * - * In this case it is also mandatory to have fromStorageMapper which should mirror toStorageMapper: - * fromStorageMapper: (storageState) => ({someVeryLongAndReasonableName: state.s}) - * - * 3. Use different sync strategies for different state slices - * - * We could have multiple SyncStorageConfigs for a state container, - * These mappers allow to pick slices of state we want to use in this particular configuration. - * For example: we can setup a slice of state to be stored in the URL as expanded state - * and then different slice of the same state as HashedURL (just by using different strategies). - * - * 4. Backward compatibility - * - * Assume in v1 state was: - * type State = {a: string}; // v1 - * in v2 'a' was renamed into 'b' - * type State = {b: string}; // v2 - * - * To make sure old urls are still working we could have fromStorageMapper: - * fromStorageMapper: (storageState) => ({b: storageState.b || storageState.a}) - */ - toStorageMapper?: (state: State) => StorageState; - fromStorageMapper?: (storageState: StorageState) => Partial; - /** * During app bootstrap we could have default app state and data in storage to be out of sync, * initialTruthSource indicates who's values to consider as source of truth * - * InitialTruthSource.Store - Application state take priority over storage state + * InitialTruthSource.StateContainer - Application state take priority over storage state * InitialTruthSource.Storage (default) - Storage state take priority over Application state * InitialTruthSource.None - skip initial syncing do nothing */ initialTruthSource?: InitialTruthSource; } -/** - * To use stateSync util application have to pass state container which implements IStore interface. - * The idea is that ./store/create_store.ts should be used as a state container by default, - * but it is also possible to implement own custom container for advanced use cases - */ -export type BaseState = Record; -export interface IStore { - get: () => State; - set: (state: State) => void; - state$: Observable; -} - /** * During app bootstrap we could have default app state and data in storage to be out of sync, * initialTruthSource indicates who's values to consider as source of truth * - * InitialTruthSource.Store - Application state take priority over storage state + * InitialTruthSource.StateContainer - Application state take priority over storage state * InitialTruthSource.Storage (default) - Storage state take priority over Application state * InitialTruthSource.None - skip initial syncing do nothing */ export enum InitialTruthSource { - Store, + StateContainer, Storage, None, } @@ -164,20 +102,20 @@ export enum SyncStrategy { * * For an example take a look at already implemented URL sync strategies */ -interface ISyncStrategy { +interface ISyncStrategy { /** * Take in a state object, should serialise and persist */ // TODO: replace sounds like something url specific ... - toStorage: (syncKey: string, state: StorageState, opts: { replace: boolean }) => Promise; + toStorage: (syncKey: string, state: State, opts: { replace: boolean }) => Promise; /** * Should retrieve state from the storage and deserialize it */ - fromStorage: (syncKey: string) => Promise; + fromStorage: (syncKey: string) => Promise; /** * Should notify when the storage has changed */ - storageChange$?: (syncKey: string) => Observable; + storageChange$?: (syncKey: string) => Observable; } /** @@ -237,95 +175,84 @@ const createStrategies: () => { }; /** - * Utility for syncing application state wrapped in IStore container + * Utility for syncing application state wrapped in state container * with some kind of storage (e.g. URL) - * - * Minimal usage example: - * - * ``` - * type State = {tab: string}; - * const store: IStore = createStore({tab: 'indexedFields'}) - * - * syncState({ - * syncKey: '_s', - * store: store - * }) - * ``` - * Now State will be synced with url: - * * url will be updated on any store change - * * store will be updated on any url change - * - * By default SyncStrategy.Url is used, which serialises state in rison format - * - * The same example with different syncStrategy depending on kibana config: - * - * ``` - * syncState({ - * syncKey: '_s', - * store: store, - * syncStrategy: config.get('state:storeInSessionStorage') ? SyncStrategy.HashedUrl : SyncStrategy.Url - * }) - * ``` - * - * If there are multiple state containers: - * ``` - * type State1 = {tab: string}; - * const store1: IStore = createStore({tab: 'indexedFields'}) - * - * type State2 = {filter: string}; - * const store2: IStore = createStore({filter: 'filter1'}) - * - * syncState([ - * { - * syncKey: '_g', - * store: store1 - * }, - * { - * syncKey: '_a', - * store: store2 - * } - * ]) - * ``` - * - * If we want to sync only a slice of state - * - * ``` - * type State = {tab: string, filter: string}; - * const store: IStore = createStore({tab: 'indexedFields', filter: 'filter1'}) - * - * syncState({ - * syncKey: '_s', - * store: store, - * toStorageMapper: (state) => ({tab: state.tab}) - * }) - * ``` - * - * Only tab slice will be synced to storage. Updates to filter slice will be ignored - * - * Similar way we could use different sync strategies for different slices. - * E.g: to put into url an expanded 'tab' slice, but hashed 'filter' slice - * ``` - * syncState([{ - * syncKey: '_t', - * store: store, - * toStorageMapper: (state) => ({tab: state.tab}), - * syncStrategy: SyncStrategy.Url - * }, - * { - * syncKey: '_f', - * store: store, - * toStorageMapper: (state) => ({filter: state.filter}), - * syncStrategy: SyncStrategy.HashedUrl - * } - * }]) - * ``` - * - * syncState returns destroy function - * ``` - * const destroy = syncState(); - * destroy(); // stops listening for state and storage updates - * ``` */ +// 1. the simplest use case +// $scope.destroyStateSync = syncState({ +// syncKey: '_s', +// stateContainer, +// }); +// +// 2. conditionally picking sync strategy +// $scope.destroyStateSync = syncState({ +// syncKey: '_s', +// stateContainer, +// syncStrategy: config.get('state:stateContainerInSessionStorage') ? SyncStrategy.HashedUrl : SyncStrategy.Url +// }); +// +// 3. implementing custom sync strategy +// 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', +// stateContainer, +// syncStrategy: localStorageSyncStrategy +// }); +// +// 4. syncing only part of state +// const stateToStorage = (s) => ({ tab: s.tab }); +// $scope.destroyStateSync = syncState({ +// syncKey: '_s', +// stateContainer: { +// get: () => stateToStorage(stateContainer.get()), +// set: stateContainer.set(({ tab }) => ({ ...stateContainer.get(), tab }), +// state$: stateContainer.state$.pipe(map(stateToStorage)) +// } +// }); +// +// 5. transform state before serialising +// this could be super useful for backward compatibility +// const stateToStorage = (s) => ({ t: s.tab }); +// $scope.destroyStateSync = syncState({ +// syncKey: '_s', +// stateContainer: { +// get: () => stateToStorage(stateContainer.get()), +// set: ({ t }) => stateContainer.set({ ...stateContainer.get(), tab: t }), +// state$: stateContainer.state$.pipe(map(stateToStorage)) +// } +// }); +// +// 6. multiple different sync configs +// const stateAToStorage = s => ({ t: s.tab }); +// const stateBToStorage = s => ({ f: s.fieldFilter, i: s.indexedFieldTypeFilter, l: s.scriptedFieldLanguageFilter }); +// $scope.destroyStateSync = syncState([ +// { +// syncKey: '_a', +// syncStrategy: SyncStrategy.Url, +// stateContainer: { +// get: () => stateAToStorage(stateContainer.get()), +// set: s => stateContainer.set(({ ...stateContainer.get(), tab: s.t })), +// state$: stateContainer.state$.pipe(map(stateAToStorage)) +// }, +// }, +// { +// syncKey: '_b', +// syncStrategy: SyncStrategy.HashedUrl, +// stateContainer: { +// get: () => stateBToStorage(stateContainer.get()), +// set: s => stateContainer.set({ +// ...stateContainer.get(), +// fieldFilter: s.f || '', +// indexedFieldTypeFilter: s.i || '', +// scriptedFieldLanguageFilter: s.l || '' +// }), +// state$: stateContainer.state$.pipe(map(stateBToStorage)) +// }, +// }, +// ]); export type DestroySyncStateFnType = () => void; export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): DestroySyncStateFnType { const stateSyncConfigs = Array.isArray(config) ? config : [config]; @@ -334,9 +261,6 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): Destro const syncStrategies = createStrategies(); stateSyncConfigs.forEach(stateSyncConfig => { - const toStorageMapper = stateSyncConfig.toStorageMapper || (s => s); - const fromStorageMapper = stateSyncConfig.fromStorageMapper || (s => s); - const { toStorage, fromStorage, storageChange$ } = isSyncStrategy(stateSyncConfig.syncStrategy) ? stateSyncConfig.syncStrategy : syncStrategies[stateSyncConfig.syncStrategy || SyncStrategy.Url]; @@ -349,10 +273,7 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): Destro } if (storageState) { - stateSyncConfig.store.set({ - ...stateSyncConfig.store.get(), - ...fromStorageMapper(storageState), - }); + stateSyncConfig.stateContainer.set(storageState); return true; } @@ -361,20 +282,16 @@ 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()); + const newStorageState = stateSyncConfig.stateContainer.get(); await toStorage(stateSyncConfig.syncKey, newStorageState, { replace }); return true; }; // subscribe to state and storage updates subscriptions.push( - stateSyncConfig.store.state$ + stateSyncConfig.stateContainer.state$ .pipe( - map(toStorageMapper), - distinctUntilChangedWithInitialValue( - toStorageMapper(stateSyncConfig.store.get()), - shallowEqual - ) + distinctUntilChangedWithInitialValue(stateSyncConfig.stateContainer.get(), shallowEqual) ) .subscribe(() => { updateStorage(); @@ -388,7 +305,7 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): Destro ); } - // initial syncing of store state and storage state + // initial syncing of stateContainer state and storage state const initialTruthSource = stateSyncConfig.initialTruthSource ?? InitialTruthSource.Storage; if (initialTruthSource === InitialTruthSource.Storage) { updateState().then(hasUpdated => { @@ -398,7 +315,7 @@ export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): Destro updateStorage({ replace: true }); } }); - } else if (initialTruthSource === InitialTruthSource.Store) { + } else if (initialTruthSource === InitialTruthSource.StateContainer) { updateStorage({ replace: true }); } }); diff --git a/src/plugins/kibana_utils/public/url/index.ts b/src/plugins/kibana_utils/public/url/index.ts index 5f2763cd8ef31..f8f45e97610b8 100644 --- a/src/plugins/kibana_utils/public/url/index.ts +++ b/src/plugins/kibana_utils/public/url/index.ts @@ -19,19 +19,14 @@ import { format as formatUrl, parse as _parseUrl } from 'url'; // @ts-ignore -import rison from 'rison-node'; +import rison, { RisonValue } from 'rison-node'; // @ts-ignore import encodeUriQuery from 'encode-uri-query'; import { createBrowserHistory } from 'history'; import { stringify as _stringifyQueryString, ParsedUrlQuery } from 'querystring'; -import { BaseState } from '../store/sync'; - -// TODO: NP, Typescriptify, Simplify -import { - createStateHash, - isStateHash, - HashedItemStoreSingleton, -} from '../../../../legacy/ui/public/state_management/state_storage'; +import { BaseState } from '../state_sync'; +import { createStateHash, isStateHash } from '../state_management/state_hash'; +import { hashedItemStore } from '../storage/hashed_item_store'; export const parseUrl = (url: string) => _parseUrl(url, true); export const parseUrlHash = (url: string) => parseUrl(parseUrl(url).hash!.slice(1)); @@ -63,9 +58,9 @@ export function getStatesFromUrl(url: string = window.location.href): Record { if (isStateHash(value as string)) { - decoded[q] = JSON.parse(HashedItemStoreSingleton.getItem(value)!); + decoded[q] = JSON.parse(hashedItemStore.getItem(value as string)!); } else { - decoded[q] = rison.decode(query[q]); + decoded[q] = rison.decode(query[q] as string) as BaseState; } }); } catch (e) { @@ -115,12 +110,12 @@ export function setStateToUrl( if (useHash) { const stateJSON = JSON.stringify(state); const stateHash = createStateHash(stateJSON, (hashKey: string) => - HashedItemStoreSingleton.getItem(hashKey) + hashedItemStore.getItem(hashKey) ); - HashedItemStoreSingleton.setItem(stateHash, stateJSON); + hashedItemStore.setItem(stateHash, stateJSON); encoded = stateHash; } else { - encoded = rison.encode(state); + encoded = rison.encode(state as RisonValue); } const searchQueryString = stringifyQueryString({ ...hash.query, [key]: encoded }); @@ -216,7 +211,5 @@ export const createUrlControls = (): IUrlControls => { history.push(location); } return getCurrentUrl(); - - return newUrl; } };