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..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()" > + +

@@ -112,6 +114,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 6ae84b9c641c2..cbfff8c794774 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'; @@ -39,37 +40,43 @@ import { I18nContext } from 'ui/i18n'; import { getEditBreadcrumbs } from '../breadcrumbs'; +import { + createStore, + syncState, + SyncStrategy +} 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.state.fieldFilter, + $scope.indexPatternListProvider + ); + $scope.refreshFilters(); + $scope.$apply(); + }} + /> + , + node, + ); + }); } function destroySourceFiltersTable() { @@ -77,41 +84,41 @@ 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.state.fieldFilter, + $scope.indexPatternListProvider + ); + $scope.refreshFilters(); + $scope.$apply(); + }} + /> + , + node, + ); + }); } function destroyScriptedFieldsTable() { @@ -119,37 +126,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() { @@ -157,6 +160,21 @@ function destroyIndexedFieldsTable() { node && unmountComponentAtNode(node); } +function handleTabChange($scope, newTab) { + destroyIndexedFieldsTable(); + destroyScriptedFieldsTable(); + destroySourceFiltersTable(); + + switch(newTab) { + case 'indexedFields': + return updateIndexedFieldsTable($scope); + case 'scriptedFields': + return updateScriptedFieldsTable($scope); + case 'sourceFilters': + return updateSourceFiltersTable($scope); + } +} + uiRoutes .when('/management/kibana/index_patterns/:indexPatternId', { template, @@ -171,8 +189,115 @@ uiRoutes 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 store = createStore( + { + tab: 'indexedFields', + fieldFilter: '', + indexedFieldTypeFilter: '', + scriptedFieldLanguageFilter: '' } + ); + Object.defineProperty($scope, 'state', { + get() { + return store.get(); + }, + }); + const stateContainerSub = store.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(); + } + }); + 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(() => { + // 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 + // const stateToStorage = (s) => ({ tab: s.tab }); + // $scope.destroyStateSync = syncState({ + // syncKey: '_s', + // store: { + // get: () => stateToStorage(store.get()), + // set: store.set(({ tab }) => ({ ...store.get(), tab }), + // state$: store.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: { + // get: () => stateToStorage(store.get()), + // set: ({ t }) => store.set({ ...store.get(), tab: t }), + // state$: store.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, + store: { + get: () => stateAToStorage(store.get()), + set: s => store.set(({ ...store.get(), tab: s.t })), + state$: store.state$.pipe(map(stateAToStorage)) + }, + }, + { + syncKey: '_b', + syncStrategy: SyncStrategy.HashedUrl, + store: { + get: () => stateBToStorage(store.get()), + set: s => store.set({ + ...store.get(), + fieldFilter: s.f || '', + indexedFieldTypeFilter: s.i || '', + scriptedFieldLanguageFilter: s.l || '' + }), + state$: store.state$.pipe(map(stateBToStorage)) + }, + }, + ]); + }); + const indexPatternListProvider = Private(IndexPatternListFactory)(); $scope.fieldWildcardMatcher = (...args) => fieldWildcardMatcher(...args, config.get('metaFields')); @@ -195,8 +320,6 @@ uiModules.get('apps/management') $scope.editSections = $scope.editSectionsProvider($scope.indexPattern, $scope.fieldFilter, indexPatternListProvider); $scope.refreshFilters(); $scope.fields = $scope.indexPattern.getNonScriptedFields(); - updateIndexedFieldsTable($scope, $state); - updateScriptedFieldsTable($scope, $state); }); $scope.refreshFilters = function () { @@ -215,21 +338,13 @@ uiModules.get('apps/management') }; $scope.changeFilter = function (filter, val) { - $scope[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) { - $state.tab = obj.index; - updateIndexedFieldsTable($scope, $state); - updateScriptedFieldsTable($scope, $state); - updateSourceFiltersTable($scope, $state); - $state.save(); + store.set({ ...store.get(), 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'); @@ -294,39 +409,29 @@ uiModules.get('apps/management') return $scope.indexPattern.save(); }; - $scope.$watch('fieldFilter', () => { - $scope.editSections = $scope.editSectionsProvider($scope.indexPattern, $scope.fieldFilter, indexPatternListProvider); + $scope.onFieldFilterInputChange = function (fieldFilter) { + store.set({ + ...store.get(), + fieldFilter, + }); + }; + + function handleFieldFilterChange() { + $scope.editSections = $scope.editSectionsProvider( + $scope.indexPattern, + $scope.fieldFilter, + indexPatternListProvider + ); 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(); + if (stateContainerSub) stateContainerSub.unsubscribe(); + if ($scope.destroyStateSync) $scope.destroyStateSync(); }); - - updateScriptedFieldsTable($scope, $state); - updateSourceFiltersTable($scope, $state); }); 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/index.ts b/src/plugins/kibana_utils/public/store/index.ts index 468e8ab8c5ade..9656039c52b99 100644 --- a/src/plugins/kibana_utils/public/store/index.ts +++ b/src/plugins/kibana_utils/public/store/index.ts @@ -19,3 +19,4 @@ export * from './create_store'; export * from './react'; +export * from './sync'; 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..6d6cdd5320436 --- /dev/null +++ b/src/plugins/kibana_utils/public/store/sync.ts @@ -0,0 +1,382 @@ +/* + * 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 { MonoTypeOperatorFunction, Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, map, share, skip, startWith } from 'rxjs/operators'; +import { createUrlControls, getStateFromUrl, IUrlControls, setStateToUrl } from '../url'; + +/** + * Configuration of StateSync utility + * State - interface for application provided state to sync with storage + */ +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, + * but it is also possible to implement own custom container for advanced use cases + */ + store: IStore; + /** + * Sync strategy to use, + * 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 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 + * + * SyncStrategy.Url is default + */ + syncStrategy?: SyncStrategy | ISyncStrategy; + + /** + * 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.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.Storage (default) - Storage state take priority over Application state + * InitialTruthSource.None - skip initial syncing do nothing + */ +export enum InitialTruthSource { + Store, + Storage, + None, +} + +/** + * 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 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 + * + * 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: (syncKey: string, state: State, opts: { replace: boolean }) => Promise; + /** + * Should retrieve state from the storage and deserialize it + */ + fromStorage: (syncKey: string) => Promise; + /** + * Should notify when the storage has changed + */ + storageChange$?: (syncKey: string) => Observable; +} + +/** + * 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 }, + { updateAsync: updateUrlAsync, listen: listenUrl }: IUrlControls = createUrlControls() +): ISyncStrategy => { + return { + toStorage: async ( + syncKey: string, + state: BaseState, + { replace = false } = { replace: false } + ) => { + await updateUrlAsync( + currentUrl => setStateToUrl(syncKey, state, { useHash }, currentUrl), + replace + ); + }, + 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() + ), + }; +}; + +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 IStore container + * with some kind of storage (e.g. URL) + */ +// 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 +// const stateToStorage = (s) => ({ tab: s.tab }); +// $scope.destroyStateSync = syncState({ +// syncKey: '_s', +// store: { +// get: () => stateToStorage(store.get()), +// set: store.set(({ tab }) => ({ ...store.get(), tab }), +// state$: store.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: { +// get: () => stateToStorage(store.get()), +// set: ({ t }) => store.set({ ...store.get(), tab: t }), +// state$: store.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, +// store: { +// get: () => stateAToStorage(store.get()), +// set: s => store.set(({ ...store.get(), tab: s.t })), +// state$: store.state$.pipe(map(stateAToStorage)) +// }, +// }, +// { +// syncKey: '_b', +// syncStrategy: SyncStrategy.HashedUrl, +// store: { +// get: () => stateBToStorage(store.get()), +// set: s => store.set({ +// ...store.get(), +// fieldFilter: s.f || '', +// indexedFieldTypeFilter: s.i || '', +// scriptedFieldLanguageFilter: s.l || '' +// }), +// state$: store.state$.pipe(map(stateBToStorage)) +// }, +// }, +// ]); +export type DestroySyncStateFnType = () => void; +export function syncState(config: IStateSyncConfig[] | IStateSyncConfig): DestroySyncStateFnType { + const stateSyncConfigs = Array.isArray(config) ? config : [config]; + const subscriptions: Subscription[] = []; + + const syncStrategies = createStrategies(); + + stateSyncConfigs.forEach(stateSyncConfig => { + const { toStorage, fromStorage, storageChange$ } = isSyncStrategy(stateSyncConfig.syncStrategy) + ? stateSyncConfig.syncStrategy + : syncStrategies[stateSyncConfig.syncStrategy || SyncStrategy.Url]; + + // returned boolean indicates if update happen + const updateState = async (): Promise => { + const storageState = await fromStorage(stateSyncConfig.syncKey); + if (!storageState) { + return false; + } + + if (storageState) { + stateSyncConfig.store.set(storageState); + return true; + } + + return false; + }; + + // returned boolean indicates if update happen + const updateStorage = async ({ replace = false } = {}): Promise => { + const newStorageState = stateSyncConfig.store.get(); + await toStorage(stateSyncConfig.syncKey, newStorageState, { replace }); + return true; + }; + + // subscribe to state and storage updates + subscriptions.push( + stateSyncConfig.store.state$ + .pipe(distinctUntilChangedWithInitialValue(stateSyncConfig.store.get(), shallowEqual)) + .subscribe(() => { + updateStorage(); + }) + ); + 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 () => { + subscriptions.forEach(sub => sub.unsubscribe()); + }; +} + +// 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; + } +} + +function distinctUntilChangedWithInitialValue( + initialValue: T, + compare?: (x: T, y: T) => boolean +): MonoTypeOperatorFunction { + return input$ => input$.pipe(startWith(initialValue), distinctUntilChanged(compare), skip(1)); +} 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..5f2763cd8ef31 --- /dev/null +++ b/src/plugins/kibana_utils/public/url/index.ts @@ -0,0 +1,222 @@ +/* + * 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 { 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, ParsedUrlQuery } from 'querystring'; +import { BaseState } from '../store/sync'; + +// TODO: NP, Typescriptify, Simplify +import { + createStateHash, + isStateHash, + HashedItemStoreSingleton, +} from '../../../../legacy/ui/public/state_management/state_storage'; + +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 +const stringifyQueryString = (query: ParsedUrlQuery) => + _stringifyQueryString(query, undefined, undefined, { + 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); + + const decoded: Record = {}; + try { + 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'); + } + + return decoded; +} + +/** + * Retrieves specific state from url by key + * e.g.: + * + * given an url: + * http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'') + * and key '_a' + * will return object: + * {tab: 'indexedFields'} + */ +// TODO: Optimize to not parse all the states if we need just one specific state by key +export function getStateFromUrl(key: string, url: string = window.location.href): BaseState { + return getStatesFromUrl(url)[key] || null; +} + +/** + * Sets state to the url by key and returns a new url string. + * Doesn't actually updates history + * + * e.g.: + * given a url: http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'') + * key: '_a' + * and state: {tab: 'other'} + * + * will return url: + * http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:other)&_b=(f:test,i:'',l:'') + */ +export function setStateToUrl( + key: string, + state: T, + { useHash = false }: { useHash: boolean } = { useHash: false }, + rawUrl = window.location.href +): string { + const url = parseUrl(rawUrl); + const hash = parseUrlHash(rawUrl); + + 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({ + ...url, + hash: formatUrl({ + pathname: hash.pathname, + search: searchQueryString, + }), + }); +} + +/** + * A tiny wrapper around history library to listen for url changes and update url + * History library handles a bunch of cross browser edge cases + */ +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: (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; + } +};