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;
+ }
+};