>();
+
+const App = ({
+ notifications,
+ http,
+ navigation,
+ data,
+ history,
+ kbnUrlStateStorage,
+}: StateDemoAppDeps) => {
+ const appStateContainer = useAppStateContainer();
+ const appState = useAppState();
+
+ useGlobalStateSyncing(data.query, kbnUrlStateStorage);
+ useAppStateSyncing(appStateContainer, data.query, kbnUrlStateStorage);
+
+ const onQuerySubmit = useCallback(
+ ({ query }) => {
+ appStateContainer.set({ ...appState, query });
+ },
+ [appStateContainer, appState]
+ );
+
+ const indexPattern = useIndexPattern(data);
+ if (!indexPattern)
+ return No index pattern found. Please create an intex patter before loading...
;
+
+ // Render the application DOM.
+ // Note that `navigation.ui.TopNavMenu` is a stateful component exported on the `navigation` plugin's start contract.
+ return (
+
+
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ appStateContainer.set({ ...appState, name: e.target.value })}
+ aria-label="My name"
+ />
+
+
+
+ >
+
+
+ );
+};
+
+export const StateDemoApp = (props: StateDemoAppDeps) => {
+ const appStateContainer = useCreateStateContainer(defaultAppState);
+
+ return (
+
+
+
+ );
+};
+
+function useCreateStateContainer(
+ defaultState: State
+): ReduxLikeStateContainer {
+ const stateContainerRef = useRef | null>(null);
+ if (!stateContainerRef.current) {
+ stateContainerRef.current = createStateContainer(defaultState);
+ }
+ return stateContainerRef.current;
+}
+
+function useIndexPattern(data: DataPublicPluginStart) {
+ const [indexPattern, setIndexPattern] = useState();
+ useEffect(() => {
+ const fetchIndexPattern = async () => {
+ const defaultIndexPattern = await data.indexPatterns.getDefault();
+ if (defaultIndexPattern) {
+ setIndexPattern(defaultIndexPattern);
+ }
+ };
+ fetchIndexPattern();
+ }, [data.indexPatterns]);
+
+ return indexPattern;
+}
+
+function useGlobalStateSyncing(
+ query: DataPublicPluginStart['query'],
+ kbnUrlStateStorage: IKbnUrlStateStorage
+) {
+ // setup sync state utils
+ useEffect(() => {
+ // sync global filters, time filters, refresh interval from data.query to url '_g'
+ const { stop } = syncQueryStateWithUrl(query, kbnUrlStateStorage);
+ return () => {
+ stop();
+ };
+ }, [query, kbnUrlStateStorage]);
+}
+
+function useAppStateSyncing(
+ appStateContainer: BaseStateContainer,
+ query: DataPublicPluginStart['query'],
+ kbnUrlStateStorage: IKbnUrlStateStorage
+) {
+ // setup sync state utils
+ useEffect(() => {
+ // sync app filters with app state container from data.query to state container
+ const stopSyncingQueryAppStateWithStateContainer = connectToQueryState(
+ query,
+ appStateContainer,
+ { filters: esFilters.FilterStateStore.APP_STATE }
+ );
+
+ // sets up syncing app state container with url
+ const { start: startSyncingAppStateWithUrl, stop: stopSyncingAppStateWithUrl } = syncState({
+ storageKey: '_a',
+ stateStorage: kbnUrlStateStorage,
+ stateContainer: {
+ ...appStateContainer,
+ // stateSync utils requires explicit handling of default state ("null")
+ set: state => state && appStateContainer.set(state),
+ },
+ });
+
+ // merge initial state from app state container and current state in url
+ const initialAppState: AppState = {
+ ...appStateContainer.get(),
+ ...kbnUrlStateStorage.get('_a'),
+ };
+ // trigger state update. actually needed in case some data was in url
+ appStateContainer.set(initialAppState);
+
+ // set current url to whatever is in app state container
+ kbnUrlStateStorage.set('_a', initialAppState);
+
+ // finally start syncing state containers with url
+ startSyncingAppStateWithUrl();
+
+ return () => {
+ stopSyncingQueryAppStateWithStateContainer();
+ stopSyncingAppStateWithUrl();
+ };
+ }, [query, kbnUrlStateStorage, appStateContainer]);
+}
diff --git a/examples/state_containers_examples/public/with_data_services/types.ts b/examples/state_containers_examples/public/with_data_services/types.ts
new file mode 100644
index 0000000000000..c63074a7a3810
--- /dev/null
+++ b/examples/state_containers_examples/public/with_data_services/types.ts
@@ -0,0 +1,31 @@
+/*
+ * 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 { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public';
+import { DataPublicPluginStart } from '../../../../src/plugins/data/public';
+
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface StateDemoPublicPluginSetup {}
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface StateDemoPublicPluginStart {}
+
+export interface AppPluginDependencies {
+ data: DataPublicPluginStart;
+ navigation: NavigationPublicPluginStart;
+}
diff --git a/examples/state_containers_examples/server/index.ts b/examples/state_containers_examples/server/index.ts
new file mode 100644
index 0000000000000..51005d78462a2
--- /dev/null
+++ b/examples/state_containers_examples/server/index.ts
@@ -0,0 +1,28 @@
+/*
+ * 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 { PluginInitializerContext } from '../../../src/core/server';
+import { StateDemoServerPlugin } from './plugin';
+
+export function plugin(initializerContext: PluginInitializerContext) {
+ return new StateDemoServerPlugin(initializerContext);
+}
+
+export { StateDemoServerPlugin as Plugin };
+export * from '../common';
diff --git a/examples/state_containers_examples/server/plugin.ts b/examples/state_containers_examples/server/plugin.ts
new file mode 100644
index 0000000000000..1c3fa9bfb290e
--- /dev/null
+++ b/examples/state_containers_examples/server/plugin.ts
@@ -0,0 +1,56 @@
+/*
+ * 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 {
+ PluginInitializerContext,
+ CoreSetup,
+ CoreStart,
+ Plugin,
+ Logger,
+} from '../../../src/core/server';
+
+import { StateDemoPluginSetup, StateDemoPluginStart } from './types';
+import { defineRoutes } from './routes';
+
+export class StateDemoServerPlugin implements Plugin {
+ private readonly logger: Logger;
+
+ constructor(initializerContext: PluginInitializerContext) {
+ this.logger = initializerContext.logger.get();
+ }
+
+ public setup(core: CoreSetup) {
+ this.logger.debug('State_demo: Ssetup');
+ const router = core.http.createRouter();
+
+ // Register server side APIs
+ defineRoutes(router);
+
+ return {};
+ }
+
+ public start(core: CoreStart) {
+ this.logger.debug('State_demo: Started');
+ return {};
+ }
+
+ public stop() {}
+}
+
+export { StateDemoServerPlugin as Plugin };
diff --git a/examples/state_containers_examples/server/routes/index.ts b/examples/state_containers_examples/server/routes/index.ts
new file mode 100644
index 0000000000000..f6da48ae62c61
--- /dev/null
+++ b/examples/state_containers_examples/server/routes/index.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 { IRouter } from '../../../../src/core/server';
+
+export function defineRoutes(router: IRouter) {
+ router.get(
+ {
+ path: '/api/state_demo/example',
+ validate: false,
+ },
+ async (context, request, response) => {
+ return response.ok({
+ body: {
+ time: new Date().toISOString(),
+ },
+ });
+ }
+ );
+}
diff --git a/examples/state_containers_examples/server/types.ts b/examples/state_containers_examples/server/types.ts
new file mode 100644
index 0000000000000..6acfc27bd681b
--- /dev/null
+++ b/examples/state_containers_examples/server/types.ts
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface StateDemoPluginSetup {}
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface StateDemoPluginStart {}
diff --git a/examples/state_containers_examples/tsconfig.json b/examples/state_containers_examples/tsconfig.json
index 091130487791b..3f43072c2aade 100644
--- a/examples/state_containers_examples/tsconfig.json
+++ b/examples/state_containers_examples/tsconfig.json
@@ -9,6 +9,7 @@
"public/**/*.ts",
"public/**/*.tsx",
"server/**/*.ts",
+ "common/**/*.ts",
"../../typings/**/*"
],
"exclude": []
diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx
index 075516d52bab6..84dd73882d134 100644
--- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx
+++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx
@@ -31,18 +31,18 @@ import { DashboardEmptyScreen, DashboardEmptyScreenProps } from './dashboard_emp
import { migrateLegacyQuery, subscribeWithScope } from '../legacy_imports';
import {
+ connectToQueryState,
esFilters,
IndexPattern,
IndexPatternsContract,
Query,
SavedQuery,
- syncAppFilters,
- syncQuery,
+ syncQueryStateWithUrl,
} from '../../../../../../plugins/data/public';
import {
+ getSavedObjectFinder,
SaveResult,
showSaveModal,
- getSavedObjectFinder,
} from '../../../../../../plugins/saved_objects/public';
import {
@@ -129,9 +129,9 @@ export class DashboardAppController {
// starts syncing `_g` portion of url with query services
// note: dashboard_state_manager.ts syncs `_a` portion of url
const {
- stop: stopSyncingGlobalStateWithUrl,
+ stop: stopSyncingQueryServiceStateWithUrl,
hasInheritedQueryFromUrl: hasInheritedGlobalStateFromUrl,
- } = syncQuery(queryService, kbnUrlStateStorage);
+ } = syncQueryStateWithUrl(queryService, kbnUrlStateStorage);
let lastReloadRequestTime = 0;
@@ -148,11 +148,20 @@ export class DashboardAppController {
history,
});
- const stopSyncingAppFilters = syncAppFilters(filterManager, {
- set: filters => dashboardStateManager.setFilters(filters),
- get: () => dashboardStateManager.appState.filters,
- state$: dashboardStateManager.appState$.pipe(map(state => state.filters)),
- });
+ // sync initial app filters from state to filterManager
+ filterManager.setAppFilters(_.cloneDeep(dashboardStateManager.appState.filters));
+ // setup syncing of app filters between appState and filterManager
+ const stopSyncingAppFilters = connectToQueryState(
+ queryService,
+ {
+ set: ({ filters }) => dashboardStateManager.setFilters(filters || []),
+ get: () => ({ filters: dashboardStateManager.appState.filters }),
+ state$: dashboardStateManager.appState$.pipe(map(state => ({ filters: state.filters }))),
+ },
+ {
+ filters: esFilters.FilterStateStore.APP_STATE,
+ }
+ );
// The hash check is so we only update the time filter on dashboard open, not during
// normal cross app navigation.
@@ -899,7 +908,7 @@ export class DashboardAppController {
$scope.$on('$destroy', () => {
updateSubscription.unsubscribe();
- stopSyncingGlobalStateWithUrl();
+ stopSyncingQueryServiceStateWithUrl();
stopSyncingAppFilters();
visibleSubscription.unsubscribe();
$scope.timefilterSubscriptions$.unsubscribe();
diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/legacy_app.js b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/legacy_app.js
index ce9cc85be57b2..35b510894179d 100644
--- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/legacy_app.js
+++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/legacy_app.js
@@ -33,7 +33,7 @@ import {
} from '../../../../../../plugins/kibana_utils/public';
import { DashboardListing, EMPTY_FILTER } from './listing/dashboard_listing';
import { addHelpMenuToAppChrome } from './help_menu/help_menu_util';
-import { syncQuery } from '../../../../../../plugins/data/public';
+import { syncQueryStateWithUrl } from '../../../../../../plugins/data/public';
export function initDashboardApp(app, deps) {
initDashboardAppDirective(app, deps);
@@ -98,7 +98,7 @@ export function initDashboardApp(app, deps) {
const dashboardConfig = deps.dashboardConfig;
// syncs `_g` portion of url with query services
- const { stop: stopSyncingGlobalStateWithUrl } = syncQuery(
+ const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl(
deps.data.query,
kbnUrlStateStorage
);
@@ -132,7 +132,7 @@ export function initDashboardApp(app, deps) {
$scope.core = deps.core;
$scope.$on('$destroy', () => {
- stopSyncingGlobalStateWithUrl();
+ stopSyncingQueryServiceStateWithUrl();
});
},
resolve: {
diff --git a/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts b/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts
index 253f9c77bd8e5..dc805d95d787f 100644
--- a/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts
+++ b/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts
@@ -18,6 +18,7 @@
*/
import { BehaviorSubject } from 'rxjs';
+import { filter, map } from 'rxjs/operators';
import {
App,
AppMountParameters,
@@ -29,7 +30,11 @@ import {
} from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { RenderDeps } from './np_ready/application';
-import { DataPublicPluginStart, DataPublicPluginSetup } from '../../../../../plugins/data/public';
+import {
+ DataPublicPluginStart,
+ DataPublicPluginSetup,
+ esFilters,
+} from '../../../../../plugins/data/public';
import { IEmbeddableStart } from '../../../../../plugins/embeddable/public';
import { Storage } from '../../../../../plugins/kibana_utils/public';
import { NavigationPublicPluginStart as NavigationStart } from '../../../../../plugins/navigation/public';
@@ -46,7 +51,6 @@ import {
} from '../../../../../plugins/kibana_legacy/public';
import { createSavedDashboardLoader } from './saved_dashboard/saved_dashboards';
import { createKbnUrlTracker } from '../../../../../plugins/kibana_utils/public';
-import { getQueryStateContainer } from '../../../../../plugins/data/public';
export interface DashboardPluginStartDependencies {
data: DataPublicPluginStart;
@@ -78,9 +82,6 @@ export class DashboardPlugin implements Plugin {
constructor(private initializerContext: PluginInitializerContext) {}
public setup(core: CoreSetup, { home, kibanaLegacy, data }: DashboardPluginSetupDependencies) {
- const { querySyncStateContainer, stop: stopQuerySyncStateContainer } = getQueryStateContainer(
- data.query
- );
const { appMounted, appUnMounted, stop: stopUrlTracker } = createKbnUrlTracker({
baseUrl: core.http.basePath.prepend('/app/kibana'),
defaultSubUrl: `#${DashboardConstants.LANDING_PAGE_PATH}`,
@@ -97,12 +98,19 @@ export class DashboardPlugin implements Plugin {
stateParams: [
{
kbnUrlKey: '_g',
- stateUpdate$: querySyncStateContainer.state$,
+ stateUpdate$: data.query.state$.pipe(
+ filter(
+ ({ changes }) => !!(changes.globalFilters || changes.time || changes.refreshInterval)
+ ),
+ map(({ state }) => ({
+ ...state,
+ filters: state.filters?.filter(esFilters.isFilterPinned),
+ }))
+ ),
},
],
});
this.stopUrlTracking = () => {
- stopQuerySyncStateContainer();
stopUrlTracker();
};
const app: App = {
diff --git a/src/legacy/core_plugins/kibana/public/discover/plugin.ts b/src/legacy/core_plugins/kibana/public/discover/plugin.ts
index e8ded9d99f892..3ba0418d35f71 100644
--- a/src/legacy/core_plugins/kibana/public/discover/plugin.ts
+++ b/src/legacy/core_plugins/kibana/public/discover/plugin.ts
@@ -18,6 +18,7 @@
*/
import { BehaviorSubject } from 'rxjs';
+import { filter, map } from 'rxjs/operators';
import { i18n } from '@kbn/i18n';
import { AppMountParameters, CoreSetup, CoreStart, Plugin } from 'kibana/public';
import angular, { auto } from 'angular';
@@ -25,7 +26,7 @@ import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public';
import {
DataPublicPluginStart,
DataPublicPluginSetup,
- getQueryStateContainer,
+ esFilters,
} from '../../../../../plugins/data/public';
import { registerFeature } from './np_ready/register_feature';
import './kibana_services';
@@ -103,9 +104,6 @@ export class DiscoverPlugin implements Plugin {
public initializeServices?: () => Promise<{ core: CoreStart; plugins: DiscoverStartPlugins }>;
setup(core: CoreSetup, plugins: DiscoverSetupPlugins): DiscoverSetup {
- const { querySyncStateContainer, stop: stopQuerySyncStateContainer } = getQueryStateContainer(
- plugins.data.query
- );
const { appMounted, appUnMounted, stop: stopUrlTracker } = createKbnUrlTracker({
baseUrl: core.http.basePath.prepend('/app/kibana'),
defaultSubUrl: '#/discover',
@@ -115,12 +113,19 @@ export class DiscoverPlugin implements Plugin {
stateParams: [
{
kbnUrlKey: '_g',
- stateUpdate$: querySyncStateContainer.state$,
+ stateUpdate$: plugins.data.query.state$.pipe(
+ filter(
+ ({ changes }) => !!(changes.globalFilters || changes.time || changes.refreshInterval)
+ ),
+ map(({ state }) => ({
+ ...state,
+ filters: state.filters?.filter(esFilters.isFilterPinned),
+ }))
+ ),
},
],
});
this.stopUrlTracking = () => {
- stopQuerySyncStateContainer();
stopUrlTracker();
};
diff --git a/src/legacy/core_plugins/kibana/public/visualize/plugin.ts b/src/legacy/core_plugins/kibana/public/visualize/plugin.ts
index 9f2283d29c203..b9e4487ae84fb 100644
--- a/src/legacy/core_plugins/kibana/public/visualize/plugin.ts
+++ b/src/legacy/core_plugins/kibana/public/visualize/plugin.ts
@@ -19,6 +19,7 @@
import { BehaviorSubject } from 'rxjs';
import { i18n } from '@kbn/i18n';
+import { filter, map } from 'rxjs/operators';
import {
AppMountParameters,
@@ -33,7 +34,7 @@ import { Storage, createKbnUrlTracker } from '../../../../../plugins/kibana_util
import {
DataPublicPluginStart,
DataPublicPluginSetup,
- getQueryStateContainer,
+ esFilters,
} from '../../../../../plugins/data/public';
import { IEmbeddableStart } from '../../../../../plugins/embeddable/public';
import { NavigationPublicPluginStart as NavigationStart } from '../../../../../plugins/navigation/public';
@@ -85,9 +86,6 @@ export class VisualizePlugin implements Plugin {
core: CoreSetup,
{ home, kibanaLegacy, usageCollection, data }: VisualizePluginSetupDependencies
) {
- const { querySyncStateContainer, stop: stopQuerySyncStateContainer } = getQueryStateContainer(
- data.query
- );
const { appMounted, appUnMounted, stop: stopUrlTracker, setActiveUrl } = createKbnUrlTracker({
baseUrl: core.http.basePath.prepend('/app/kibana'),
defaultSubUrl: '#/visualize',
@@ -97,12 +95,19 @@ export class VisualizePlugin implements Plugin {
stateParams: [
{
kbnUrlKey: '_g',
- stateUpdate$: querySyncStateContainer.state$,
+ stateUpdate$: data.query.state$.pipe(
+ filter(
+ ({ changes }) => !!(changes.globalFilters || changes.time || changes.refreshInterval)
+ ),
+ map(({ state }) => ({
+ ...state,
+ filters: state.filters?.filter(esFilters.isFilterPinned),
+ }))
+ ),
},
],
});
this.stopUrlTracking = () => {
- stopQuerySyncStateContainer();
stopUrlTracker();
};
diff --git a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js
index cf8537ba7ab3e..75f48beb140a2 100644
--- a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js
+++ b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js
@@ -104,6 +104,7 @@ export const npSetup = {
getProvider: sinon.fake(),
},
query: {
+ state$: mockObservable(),
filterManager: {
getFetches$: sinon.fake(),
getFilters: sinon.fake(),
diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts
index 978f140eb1d26..5dcf51ecc81eb 100644
--- a/src/plugins/data/public/index.ts
+++ b/src/plugins/data/public/index.ts
@@ -294,11 +294,11 @@ export { Filter, Query, RefreshInterval, TimeRange } from '../common';
export {
createSavedQueryService,
- syncAppFilters,
- syncQuery,
+ connectToQueryState,
+ syncQueryStateWithUrl,
+ QueryState,
getTime,
getQueryLog,
- getQueryStateContainer,
FilterManager,
SavedQuery,
SavedQueryService,
diff --git a/src/plugins/data/public/query/mocks.ts b/src/plugins/data/public/query/mocks.ts
index 2710dadaa23a3..47b0a5b871ce2 100644
--- a/src/plugins/data/public/query/mocks.ts
+++ b/src/plugins/data/public/query/mocks.ts
@@ -17,7 +17,8 @@
* under the License.
*/
-import { QueryService, QuerySetup } from '.';
+import { Observable } from 'rxjs';
+import { QueryService, QuerySetup, QueryStart } from '.';
import { timefilterServiceMock } from './timefilter/timefilter_service.mock';
type QueryServiceClientContract = PublicMethodsOf;
@@ -26,16 +27,18 @@ const createSetupContractMock = () => {
const setupContract: jest.Mocked = {
filterManager: jest.fn() as any,
timefilter: timefilterServiceMock.createSetupContract(),
+ state$: new Observable(),
};
return setupContract;
};
const createStartContractMock = () => {
- const startContract = {
+ const startContract: jest.Mocked = {
filterManager: jest.fn() as any,
timefilter: timefilterServiceMock.createStartContract(),
savedQueries: jest.fn() as any,
+ state$: new Observable(),
};
return startContract;
diff --git a/src/plugins/data/public/query/query_service.ts b/src/plugins/data/public/query/query_service.ts
index ebef8b8d45050..c885d596f1943 100644
--- a/src/plugins/data/public/query/query_service.ts
+++ b/src/plugins/data/public/query/query_service.ts
@@ -17,11 +17,13 @@
* under the License.
*/
+import { share } from 'rxjs/operators';
import { CoreStart } from 'src/core/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { FilterManager } from './filter_manager';
import { TimefilterService, TimefilterSetup } from './timefilter';
import { createSavedQueryService } from './saved_query/saved_query_service';
+import { createQueryStateObservable } from './state_sync/create_global_query_observable';
/**
* Query Service
@@ -36,6 +38,8 @@ export class QueryService {
filterManager!: FilterManager;
timefilter!: TimefilterSetup;
+ state$!: ReturnType;
+
public setup({ uiSettings, storage }: QueryServiceDependencies) {
this.filterManager = new FilterManager(uiSettings);
@@ -45,9 +49,15 @@ export class QueryService {
storage,
});
+ this.state$ = createQueryStateObservable({
+ filterManager: this.filterManager,
+ timefilter: this.timefilter,
+ }).pipe(share());
+
return {
filterManager: this.filterManager,
timefilter: this.timefilter,
+ state$: this.state$,
};
}
@@ -55,6 +65,7 @@ export class QueryService {
return {
filterManager: this.filterManager,
timefilter: this.timefilter,
+ state$: this.state$,
savedQueries: createSavedQueryService(savedObjects.client),
};
}
diff --git a/src/plugins/data/public/query/state_sync/README.md b/src/plugins/data/public/query/state_sync/README.md
new file mode 100644
index 0000000000000..6b9b158100573
--- /dev/null
+++ b/src/plugins/data/public/query/state_sync/README.md
@@ -0,0 +1,3 @@
+# Query state syncing utilities
+
+Set of helpers to connect data services to state containers and state syncing utilities
diff --git a/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts b/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts
new file mode 100644
index 0000000000000..5da929c441cde
--- /dev/null
+++ b/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts
@@ -0,0 +1,465 @@
+/*
+ * 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 { Subscription } from 'rxjs';
+import { FilterManager } from '../filter_manager';
+import { getFilter } from '../filter_manager/test_helpers/get_stub_filter';
+import { Filter, FilterStateStore } from '../../../common';
+import { coreMock } from '../../../../../core/public/mocks';
+import { BaseStateContainer, createStateContainer, Storage } from '../../../../kibana_utils/public';
+import { QueryService, QueryStart } from '../query_service';
+import { StubBrowserStorage } from '../../../../../test_utils/public/stub_browser_storage';
+import { connectToQueryState } from './connect_to_query_state';
+import { TimefilterContract } from '../timefilter';
+import { QueryState } from './types';
+
+const connectToQueryGlobalState = (query: QueryStart, state: BaseStateContainer) =>
+ connectToQueryState(query, state, {
+ refreshInterval: true,
+ time: true,
+ filters: FilterStateStore.GLOBAL_STATE,
+ });
+
+const connectToQueryAppState = (query: QueryStart, state: BaseStateContainer) =>
+ connectToQueryState(query, state, {
+ filters: FilterStateStore.APP_STATE,
+ });
+
+const setupMock = coreMock.createSetup();
+const startMock = coreMock.createStart();
+
+setupMock.uiSettings.get.mockImplementation((key: string) => {
+ switch (key) {
+ case 'filters:pinnedByDefault':
+ return true;
+ case 'timepicker:timeDefaults':
+ return { from: 'now-15m', to: 'now' };
+ case 'timepicker:refreshIntervalDefaults':
+ return { pause: false, value: 0 };
+ default:
+ throw new Error(`sync_query test: not mocked uiSetting: ${key}`);
+ }
+});
+
+describe('connect_to_global_state', () => {
+ let queryServiceStart: QueryStart;
+ let filterManager: FilterManager;
+ let timeFilter: TimefilterContract;
+ let globalState: BaseStateContainer;
+ let globalStateSub: Subscription;
+ let globalStateChangeTriggered = jest.fn();
+ let filterManagerChangeSub: Subscription;
+ let filterManagerChangeTriggered = jest.fn();
+
+ let gF1: Filter;
+ let gF2: Filter;
+ let aF1: Filter;
+ let aF2: Filter;
+
+ beforeEach(() => {
+ const queryService = new QueryService();
+ queryService.setup({
+ uiSettings: setupMock.uiSettings,
+ storage: new Storage(new StubBrowserStorage()),
+ });
+ queryServiceStart = queryService.start(startMock.savedObjects);
+ filterManager = queryServiceStart.filterManager;
+ timeFilter = queryServiceStart.timefilter.timefilter;
+
+ globalState = createStateContainer({});
+ globalStateChangeTriggered = jest.fn();
+ globalStateSub = globalState.state$.subscribe(globalStateChangeTriggered);
+
+ filterManagerChangeTriggered = jest.fn();
+ filterManagerChangeSub = filterManager.getUpdates$().subscribe(filterManagerChangeTriggered);
+
+ gF1 = getFilter(FilterStateStore.GLOBAL_STATE, true, true, 'key1', 'value1');
+ gF2 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'key2', 'value2');
+ aF1 = getFilter(FilterStateStore.APP_STATE, true, true, 'key3', 'value3');
+ aF2 = getFilter(FilterStateStore.APP_STATE, false, false, 'key4', 'value4');
+ });
+ afterEach(() => {
+ globalStateSub.unsubscribe();
+ filterManagerChangeSub.unsubscribe();
+ });
+
+ test('state is initialized with state from query service', () => {
+ const stop = connectToQueryGlobalState(queryServiceStart, globalState);
+
+ expect(globalState.get()).toEqual({
+ filters: filterManager.getGlobalFilters(),
+ refreshInterval: timeFilter.getRefreshInterval(),
+ time: timeFilter.getTime(),
+ });
+
+ stop();
+ });
+
+ test('when time range changes, state container contains updated time range', () => {
+ const stop = connectToQueryGlobalState(queryServiceStart, globalState);
+ timeFilter.setTime({ from: 'now-30m', to: 'now' });
+ expect(globalState.get().time).toEqual({
+ from: 'now-30m',
+ to: 'now',
+ });
+ stop();
+ });
+
+ test('when refresh interval changes, state container contains updated refresh interval', () => {
+ const stop = connectToQueryGlobalState(queryServiceStart, globalState);
+ timeFilter.setRefreshInterval({ pause: true, value: 100 });
+ expect(globalState.get().refreshInterval).toEqual({
+ pause: true,
+ value: 100,
+ });
+ stop();
+ });
+
+ test('state changes should propagate to services', () => {
+ const stop = connectToQueryGlobalState(queryServiceStart, globalState);
+ globalStateChangeTriggered.mockClear();
+ globalState.set({
+ ...globalState.get(),
+ filters: [gF1, gF2],
+ refreshInterval: { pause: true, value: 100 },
+ time: { from: 'now-30m', to: 'now' },
+ });
+
+ expect(globalStateChangeTriggered).toBeCalledTimes(1);
+
+ expect(filterManager.getGlobalFilters()).toHaveLength(2);
+ expect(timeFilter.getRefreshInterval()).toEqual({ pause: true, value: 100 });
+ expect(timeFilter.getTime()).toEqual({ from: 'now-30m', to: 'now' });
+ stop();
+ });
+
+ describe('sync from filterManager to global state', () => {
+ test('should sync global filters to global state when new global filters set to filterManager', () => {
+ const stop = connectToQueryGlobalState(queryServiceStart, globalState);
+
+ filterManager.setFilters([gF1, aF1]);
+
+ expect(globalState.get().filters).toHaveLength(1);
+ stop();
+ });
+
+ test('should not sync app filters to global state ', () => {
+ const stop = connectToQueryGlobalState(queryServiceStart, globalState);
+
+ filterManager.setFilters([aF1, aF2]);
+
+ expect(globalState.get().filters).toHaveLength(0);
+ stop();
+ });
+
+ test("should not trigger changes when global filters didn't change", () => {
+ const stop = connectToQueryGlobalState(queryServiceStart, globalState);
+ globalStateChangeTriggered.mockClear();
+
+ filterManager.setFilters([gF1, aF1]);
+ filterManager.setFilters([gF1, aF2]);
+
+ expect(globalStateChangeTriggered).toBeCalledTimes(1);
+ expect(globalState.get().filters).toHaveLength(1);
+
+ stop();
+ });
+
+ test('should trigger changes when global filters change', () => {
+ const stop = connectToQueryGlobalState(queryServiceStart, globalState);
+ globalStateChangeTriggered.mockClear();
+
+ filterManager.setFilters([gF1, aF1]);
+ filterManager.setFilters([gF2, aF1]);
+
+ expect(globalStateChangeTriggered).toBeCalledTimes(2);
+ expect(globalState.get().filters).toHaveLength(1);
+
+ stop();
+ });
+
+ test('resetting filters should sync to global state', () => {
+ const stop = connectToQueryGlobalState(queryServiceStart, globalState);
+
+ filterManager.setFilters([gF1, aF1]);
+
+ expect(globalState.get().filters).toHaveLength(1);
+
+ filterManager.removeAll();
+
+ expect(globalState.get().filters).toHaveLength(0);
+
+ stop();
+ });
+
+ test("shouldn't sync filters when syncing is stopped", () => {
+ const stop = connectToQueryGlobalState(queryServiceStart, globalState);
+
+ filterManager.setFilters([gF1, aF1]);
+
+ expect(globalState.get().filters).toHaveLength(1);
+
+ stop();
+
+ filterManager.removeAll();
+
+ expect(globalState.get().filters).toHaveLength(1);
+ });
+
+ test('should pick up initial state from filterManager', () => {
+ globalState.set({ filters: [gF1] });
+ filterManager.setFilters([aF1]);
+
+ globalStateChangeTriggered.mockClear();
+ const stop = connectToQueryGlobalState(queryServiceStart, globalState);
+ expect(globalStateChangeTriggered).toBeCalledTimes(1);
+ expect(globalState.get().filters).toHaveLength(0);
+
+ stop();
+ });
+ });
+ describe('sync from global state to filterManager', () => {
+ test('changes to global state should be synced to global filters', () => {
+ filterManager.setFilters([aF1]);
+ const stop = connectToQueryGlobalState(queryServiceStart, globalState);
+ globalStateChangeTriggered.mockClear();
+
+ globalState.set({ ...globalState.get(), filters: [gF1] });
+
+ expect(filterManager.getFilters()).toHaveLength(2);
+ expect(filterManager.getAppFilters()).toHaveLength(1);
+ expect(filterManager.getGlobalFilters()).toHaveLength(1);
+ expect(globalStateChangeTriggered).toBeCalledTimes(1);
+ stop();
+ });
+
+ test('app filters should remain untouched', () => {
+ filterManager.setFilters([gF1, gF2, aF1, aF2]);
+ const stop = connectToQueryGlobalState(queryServiceStart, globalState);
+ globalStateChangeTriggered.mockClear();
+
+ globalState.set({ ...globalState.get(), filters: [] });
+
+ expect(filterManager.getFilters()).toHaveLength(2);
+ expect(filterManager.getAppFilters()).toHaveLength(2);
+ expect(filterManager.getGlobalFilters()).toHaveLength(0);
+ expect(globalStateChangeTriggered).toBeCalledTimes(1);
+ stop();
+ });
+
+ test("if filters are not changed, filterManager shouldn't trigger update", () => {
+ filterManager.setFilters([gF1, gF2, aF1, aF2]);
+ filterManagerChangeTriggered.mockClear();
+
+ globalState.set({ ...globalState.get(), filters: [gF1, gF2] });
+ const stop = connectToQueryGlobalState(queryServiceStart, globalState);
+ globalState.set({ ...globalState.get(), filters: [gF1, gF2] });
+
+ expect(filterManagerChangeTriggered).toBeCalledTimes(0);
+ stop();
+ });
+
+ test('stop() should stop syncing', () => {
+ filterManager.setFilters([gF1, gF2, aF1, aF2]);
+ const stop = connectToQueryGlobalState(queryServiceStart, globalState);
+ globalState.set({ ...globalState.get(), filters: [] });
+ expect(filterManager.getFilters()).toHaveLength(2);
+ stop();
+ globalState.set({ ...globalState.get(), filters: [gF1] });
+ expect(filterManager.getFilters()).toHaveLength(2);
+ });
+ });
+});
+
+describe('connect_to_app_state', () => {
+ let queryServiceStart: QueryStart;
+ let filterManager: FilterManager;
+ let appState: BaseStateContainer;
+ let appStateSub: Subscription;
+ let appStateChangeTriggered = jest.fn();
+ let filterManagerChangeSub: Subscription;
+ let filterManagerChangeTriggered = jest.fn();
+
+ let gF1: Filter;
+ let gF2: Filter;
+ let aF1: Filter;
+ let aF2: Filter;
+
+ beforeEach(() => {
+ const queryService = new QueryService();
+ queryService.setup({
+ uiSettings: setupMock.uiSettings,
+ storage: new Storage(new StubBrowserStorage()),
+ });
+ queryServiceStart = queryService.start(startMock.savedObjects);
+ filterManager = queryServiceStart.filterManager;
+
+ appState = createStateContainer({});
+ appStateChangeTriggered = jest.fn();
+ appStateSub = appState.state$.subscribe(appStateChangeTriggered);
+
+ filterManagerChangeTriggered = jest.fn();
+ filterManagerChangeSub = filterManager.getUpdates$().subscribe(filterManagerChangeTriggered);
+
+ gF1 = getFilter(FilterStateStore.GLOBAL_STATE, true, true, 'key1', 'value1');
+ gF2 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'key2', 'value2');
+ aF1 = getFilter(FilterStateStore.APP_STATE, true, true, 'key3', 'value3');
+ aF2 = getFilter(FilterStateStore.APP_STATE, false, false, 'key4', 'value4');
+ });
+ afterEach(() => {
+ appStateSub.unsubscribe();
+ filterManagerChangeSub.unsubscribe();
+ });
+
+ describe('sync from filterManager to app state', () => {
+ test('should sync app filters to app state when new app filters set to filterManager', () => {
+ const stop = connectToQueryAppState(queryServiceStart, appState);
+
+ filterManager.setFilters([gF1, aF1]);
+
+ expect(appState.get().filters).toHaveLength(1);
+ stop();
+ });
+
+ test('should not sync global filters to app state ', () => {
+ const stop = connectToQueryAppState(queryServiceStart, appState);
+
+ filterManager.setFilters([gF1, gF2]);
+
+ expect(appState.get().filters).toHaveLength(0);
+ stop();
+ });
+
+ test("should not trigger changes when app filters didn't change", () => {
+ const stop = connectToQueryAppState(queryServiceStart, appState);
+ appStateChangeTriggered.mockClear();
+
+ filterManager.setFilters([gF1, aF1]);
+ filterManager.setFilters([gF2, aF1]);
+
+ expect(appStateChangeTriggered).toBeCalledTimes(1);
+ expect(appState.get().filters).toHaveLength(1);
+
+ stop();
+ });
+
+ test('should trigger changes when app filters change', () => {
+ const stop = connectToQueryAppState(queryServiceStart, appState);
+ appStateChangeTriggered.mockClear();
+
+ filterManager.setFilters([gF1, aF1]);
+ filterManager.setFilters([gF1, aF2]);
+
+ expect(appStateChangeTriggered).toBeCalledTimes(2);
+ expect(appState.get().filters).toHaveLength(1);
+
+ stop();
+ });
+
+ test('resetting filters should sync to app state', () => {
+ const stop = connectToQueryAppState(queryServiceStart, appState);
+
+ filterManager.setFilters([gF1, aF1]);
+
+ expect(appState.get().filters).toHaveLength(1);
+
+ filterManager.removeAll();
+
+ expect(appState.get().filters).toHaveLength(0);
+
+ stop();
+ });
+
+ test("shouldn't sync filters when syncing is stopped", () => {
+ const stop = connectToQueryAppState(queryServiceStart, appState);
+
+ filterManager.setFilters([gF1, aF1]);
+
+ expect(appState.get().filters).toHaveLength(1);
+
+ stop();
+
+ filterManager.removeAll();
+
+ expect(appState.get().filters).toHaveLength(1);
+ });
+
+ test('should pick up initial state from filterManager', () => {
+ appState.set({ filters: [aF1] });
+ filterManager.setFilters([gF1]);
+
+ appStateChangeTriggered.mockClear();
+ const stop = connectToQueryAppState(queryServiceStart, appState);
+ expect(appStateChangeTriggered).toBeCalledTimes(1);
+ expect(appState.get().filters).toHaveLength(0);
+
+ stop();
+ });
+ });
+ describe('sync from app state to filterManager', () => {
+ test('changes to app state should be synced to app filters', () => {
+ filterManager.setFilters([gF1]);
+ const stop = connectToQueryAppState(queryServiceStart, appState);
+ appStateChangeTriggered.mockClear();
+
+ appState.set({ filters: [aF1] });
+
+ expect(filterManager.getFilters()).toHaveLength(2);
+ expect(filterManager.getAppFilters()).toHaveLength(1);
+ expect(filterManager.getGlobalFilters()).toHaveLength(1);
+ expect(appStateChangeTriggered).toBeCalledTimes(1);
+ stop();
+ });
+
+ test('global filters should remain untouched', () => {
+ filterManager.setFilters([gF1, gF2, aF1, aF2]);
+ const stop = connectToQueryAppState(queryServiceStart, appState);
+ appStateChangeTriggered.mockClear();
+
+ appState.set({ filters: [] });
+
+ expect(filterManager.getFilters()).toHaveLength(2);
+ expect(filterManager.getGlobalFilters()).toHaveLength(2);
+ expect(appStateChangeTriggered).toBeCalledTimes(1);
+ stop();
+ });
+
+ test("if filters are not changed, filterManager shouldn't trigger update", () => {
+ filterManager.setFilters([gF1, gF2, aF1, aF2]);
+ filterManagerChangeTriggered.mockClear();
+
+ appState.set({ filters: [aF1, aF2] });
+ const stop = connectToQueryAppState(queryServiceStart, appState);
+ appState.set({ filters: [aF1, aF2] });
+
+ expect(filterManagerChangeTriggered).toBeCalledTimes(0);
+ stop();
+ });
+
+ test('stop() should stop syncing', () => {
+ filterManager.setFilters([gF1, gF2, aF1, aF2]);
+ const stop = connectToQueryAppState(queryServiceStart, appState);
+ appState.set({ filters: [] });
+ expect(filterManager.getFilters()).toHaveLength(2);
+ stop();
+ appState.set({ filters: [aF1] });
+ expect(filterManager.getFilters()).toHaveLength(2);
+ });
+ });
+});
diff --git a/src/plugins/data/public/query/state_sync/connect_to_query_state.ts b/src/plugins/data/public/query/state_sync/connect_to_query_state.ts
new file mode 100644
index 0000000000000..a22e66860c765
--- /dev/null
+++ b/src/plugins/data/public/query/state_sync/connect_to_query_state.ts
@@ -0,0 +1,194 @@
+/*
+ * 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 { Subscription } from 'rxjs';
+import { filter, map } from 'rxjs/operators';
+import _ from 'lodash';
+import { BaseStateContainer } from '../../../../kibana_utils/public';
+import { COMPARE_ALL_OPTIONS, compareFilters } from '../filter_manager/lib/compare_filters';
+import { QuerySetup, QueryStart } from '../query_service';
+import { QueryState, QueryStateChange } from './types';
+import { FilterStateStore } from '../../../common/es_query/filters';
+
+/**
+ * Helper to setup two-way syncing of global data and a state container
+ * @param QueryService: either setup or start
+ * @param stateContainer to use for syncing
+ */
+export const connectToQueryState = (
+ {
+ timefilter: { timefilter },
+ filterManager,
+ state$,
+ }: Pick,
+ stateContainer: BaseStateContainer,
+ syncConfig: { time?: boolean; refreshInterval?: boolean; filters?: FilterStateStore | boolean }
+) => {
+ const syncKeys: Array = [];
+ if (syncConfig.time) {
+ syncKeys.push('time');
+ }
+ if (syncConfig.refreshInterval) {
+ syncKeys.push('refreshInterval');
+ }
+ if (syncConfig.filters) {
+ switch (syncConfig.filters) {
+ case true:
+ syncKeys.push('filters');
+ break;
+ case FilterStateStore.APP_STATE:
+ syncKeys.push('appFilters');
+ break;
+ case FilterStateStore.GLOBAL_STATE:
+ syncKeys.push('globalFilters');
+ break;
+ }
+ }
+
+ // initial syncing
+ // TODO:
+ // data services take precedence, this seems like a good default,
+ // and apps could anyway set their own value after initialisation,
+ // but maybe maybe this should be a configurable option?
+ const initialState: QueryState = { ...stateContainer.get() };
+ let initialDirty = false;
+ if (syncConfig.time && !_.isEqual(initialState.time, timefilter.getTime())) {
+ initialState.time = timefilter.getTime();
+ initialDirty = true;
+ }
+ if (
+ syncConfig.refreshInterval &&
+ !_.isEqual(initialState.refreshInterval, timefilter.getRefreshInterval())
+ ) {
+ initialState.refreshInterval = timefilter.getRefreshInterval();
+ initialDirty = true;
+ }
+
+ if (syncConfig.filters) {
+ if (syncConfig.filters === true) {
+ if (
+ !initialState.filters ||
+ !compareFilters(initialState.filters, filterManager.getFilters(), COMPARE_ALL_OPTIONS)
+ ) {
+ initialState.filters = filterManager.getFilters();
+ initialDirty = true;
+ }
+ } else if (syncConfig.filters === FilterStateStore.GLOBAL_STATE) {
+ if (
+ !initialState.filters ||
+ !compareFilters(initialState.filters, filterManager.getGlobalFilters(), COMPARE_ALL_OPTIONS)
+ ) {
+ initialState.filters = filterManager.getGlobalFilters();
+ initialDirty = true;
+ }
+ } else if (syncConfig.filters === FilterStateStore.APP_STATE) {
+ if (
+ !initialState.filters ||
+ !compareFilters(initialState.filters, filterManager.getAppFilters(), COMPARE_ALL_OPTIONS)
+ ) {
+ initialState.filters = filterManager.getAppFilters();
+ initialDirty = true;
+ }
+ }
+ }
+
+ if (initialDirty) {
+ stateContainer.set({ ...stateContainer.get(), ...initialState });
+ }
+
+ // to ignore own state updates
+ let updateInProgress = false;
+
+ const subs: Subscription[] = [
+ state$
+ .pipe(
+ filter(({ changes, state }) => {
+ if (updateInProgress) return false;
+ return syncKeys.some(syncKey => changes[syncKey]);
+ }),
+ map(({ changes }) => {
+ const newState: QueryState = {};
+ if (syncConfig.time && changes.time) {
+ newState.time = timefilter.getTime();
+ }
+ if (syncConfig.refreshInterval && changes.refreshInterval) {
+ newState.refreshInterval = timefilter.getRefreshInterval();
+ }
+ if (syncConfig.filters) {
+ if (syncConfig.filters === true && changes.filters) {
+ newState.filters = filterManager.getFilters();
+ } else if (
+ syncConfig.filters === FilterStateStore.GLOBAL_STATE &&
+ changes.globalFilters
+ ) {
+ newState.filters = filterManager.getGlobalFilters();
+ } else if (syncConfig.filters === FilterStateStore.APP_STATE && changes.appFilters) {
+ newState.filters = filterManager.getAppFilters();
+ }
+ }
+ return newState;
+ })
+ )
+ .subscribe(newState => {
+ stateContainer.set({ ...stateContainer.get(), ...newState });
+ }),
+ stateContainer.state$.subscribe(state => {
+ updateInProgress = true;
+
+ // cloneDeep is required because services are mutating passed objects
+ // and state in state container is frozen
+ if (syncConfig.time) {
+ const time = state.time || timefilter.getTimeDefaults();
+ if (!_.isEqual(time, timefilter.getTime())) {
+ timefilter.setTime(_.cloneDeep(time));
+ }
+ }
+
+ if (syncConfig.refreshInterval) {
+ const refreshInterval = state.refreshInterval || timefilter.getRefreshIntervalDefaults();
+ if (!_.isEqual(refreshInterval, timefilter.getRefreshInterval())) {
+ timefilter.setRefreshInterval(_.cloneDeep(refreshInterval));
+ }
+ }
+
+ if (syncConfig.filters) {
+ const filters = state.filters || [];
+ if (syncConfig.filters === true) {
+ if (!compareFilters(filters, filterManager.getFilters(), COMPARE_ALL_OPTIONS)) {
+ filterManager.setFilters(_.cloneDeep(filters));
+ }
+ } else if (syncConfig.filters === FilterStateStore.APP_STATE) {
+ if (!compareFilters(filters, filterManager.getAppFilters(), COMPARE_ALL_OPTIONS)) {
+ filterManager.setAppFilters(_.cloneDeep(filters));
+ }
+ } else if (syncConfig.filters === FilterStateStore.GLOBAL_STATE) {
+ if (!compareFilters(filters, filterManager.getGlobalFilters(), COMPARE_ALL_OPTIONS)) {
+ filterManager.setGlobalFilters(_.cloneDeep(filters));
+ }
+ }
+ }
+
+ updateInProgress = false;
+ }),
+ ];
+
+ return () => {
+ subs.forEach(s => s.unsubscribe());
+ };
+};
diff --git a/src/plugins/data/public/query/state_sync/create_global_query_observable.ts b/src/plugins/data/public/query/state_sync/create_global_query_observable.ts
new file mode 100644
index 0000000000000..d0d97bfaaeb36
--- /dev/null
+++ b/src/plugins/data/public/query/state_sync/create_global_query_observable.ts
@@ -0,0 +1,87 @@
+/*
+ * 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 { map, tap } from 'rxjs/operators';
+import { TimefilterSetup } from '../timefilter';
+import { COMPARE_ALL_OPTIONS, compareFilters, FilterManager } from '../filter_manager';
+import { QueryState, QueryStateChange } from './index';
+import { createStateContainer } from '../../../../kibana_utils/public';
+import { isFilterPinned } from '../../../common/es_query/filters';
+
+export function createQueryStateObservable({
+ timefilter: { timefilter },
+ filterManager,
+}: {
+ timefilter: TimefilterSetup;
+ filterManager: FilterManager;
+}): Observable<{ changes: QueryStateChange; state: QueryState }> {
+ return new Observable(subscriber => {
+ const state = createStateContainer({
+ time: timefilter.getTime(),
+ refreshInterval: timefilter.getRefreshInterval(),
+ filters: filterManager.getFilters(),
+ });
+
+ let currentChange: QueryStateChange = {};
+ const subs: Subscription[] = [
+ timefilter.getTimeUpdate$().subscribe(() => {
+ currentChange.time = true;
+ state.set({ ...state.get(), time: timefilter.getTime() });
+ }),
+ timefilter.getRefreshIntervalUpdate$().subscribe(() => {
+ currentChange.refreshInterval = true;
+ state.set({ ...state.get(), refreshInterval: timefilter.getRefreshInterval() });
+ }),
+ filterManager.getUpdates$().subscribe(() => {
+ currentChange.filters = true;
+
+ const { filters } = state.get();
+ const globalOld = filters?.filter(f => isFilterPinned(f));
+ const appOld = filters?.filter(f => !isFilterPinned(f));
+ const globalNew = filterManager.getGlobalFilters();
+ const appNew = filterManager.getAppFilters();
+
+ if (!globalOld || !compareFilters(globalOld, globalNew, COMPARE_ALL_OPTIONS)) {
+ currentChange.globalFilters = true;
+ }
+
+ if (!appOld || !compareFilters(appOld, appNew, COMPARE_ALL_OPTIONS)) {
+ currentChange.appFilters = true;
+ }
+
+ state.set({
+ ...state.get(),
+ filters: filterManager.getFilters(),
+ });
+ }),
+ state.state$
+ .pipe(
+ map(newState => ({ state: newState, changes: currentChange })),
+ tap(() => {
+ currentChange = {};
+ })
+ )
+ .subscribe(subscriber),
+ ];
+ return () => {
+ subs.forEach(s => s.unsubscribe());
+ };
+ });
+}
diff --git a/src/plugins/data/public/query/state_sync/index.ts b/src/plugins/data/public/query/state_sync/index.ts
index 27e02940765cf..e1a3561e022db 100644
--- a/src/plugins/data/public/query/state_sync/index.ts
+++ b/src/plugins/data/public/query/state_sync/index.ts
@@ -17,5 +17,6 @@
* under the License.
*/
-export { syncQuery, getQueryStateContainer } from './sync_query';
-export { syncAppFilters } from './sync_app_filters';
+export { connectToQueryState } from './connect_to_query_state';
+export { syncQueryStateWithUrl } from './sync_state_with_url';
+export { QueryState, QueryStateChange } from './types';
diff --git a/src/plugins/data/public/query/state_sync/sync_app_filters.test.ts b/src/plugins/data/public/query/state_sync/sync_app_filters.test.ts
deleted file mode 100644
index e01547b1c0fd8..0000000000000
--- a/src/plugins/data/public/query/state_sync/sync_app_filters.test.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-/*
- * 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 { Subscription } from 'rxjs';
-import { FilterManager } from '../filter_manager';
-import { getFilter } from '../filter_manager/test_helpers/get_stub_filter';
-import { Filter, FilterStateStore } from '../../../common';
-import { syncAppFilters } from './sync_app_filters';
-import { coreMock } from '../../../../../core/public/mocks';
-import { BaseStateContainer, createStateContainer } from '../../../../kibana_utils/public';
-
-const setupMock = coreMock.createSetup();
-
-setupMock.uiSettings.get.mockImplementation((key: string) => {
- return true;
-});
-
-describe('sync_app_filters', () => {
- let filterManager: FilterManager;
- let appState: BaseStateContainer;
- let appStateSub: Subscription;
- let appStateChangeTriggered = jest.fn();
- let filterManagerChangeSub: Subscription;
- let filterManagerChangeTriggered = jest.fn();
-
- let gF1: Filter;
- let gF2: Filter;
- let aF1: Filter;
- let aF2: Filter;
-
- beforeEach(() => {
- filterManager = new FilterManager(setupMock.uiSettings);
- appState = createStateContainer([] as Filter[]);
- appStateChangeTriggered = jest.fn();
- appStateSub = appState.state$.subscribe(appStateChangeTriggered);
-
- filterManagerChangeTriggered = jest.fn();
- filterManagerChangeSub = filterManager.getUpdates$().subscribe(filterManagerChangeTriggered);
-
- gF1 = getFilter(FilterStateStore.GLOBAL_STATE, true, true, 'key1', 'value1');
- gF2 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'key2', 'value2');
- aF1 = getFilter(FilterStateStore.APP_STATE, true, true, 'key3', 'value3');
- aF2 = getFilter(FilterStateStore.APP_STATE, false, false, 'key4', 'value4');
- });
- afterEach(() => {
- appStateSub.unsubscribe();
- filterManagerChangeSub.unsubscribe();
- });
-
- describe('sync from filterManager to app state', () => {
- test('should sync app filters to app state when new app filters set to filterManager', () => {
- const stop = syncAppFilters(filterManager, appState);
-
- filterManager.setFilters([gF1, aF1]);
-
- expect(appState.get()).toHaveLength(1);
- stop();
- });
-
- test('should not sync global filters to app state ', () => {
- const stop = syncAppFilters(filterManager, appState);
-
- filterManager.setFilters([gF1, gF2]);
-
- expect(appState.get()).toHaveLength(0);
- stop();
- });
-
- test("should not trigger changes when app filters didn't change", () => {
- const stop = syncAppFilters(filterManager, appState);
-
- filterManager.setFilters([gF1, aF1]);
-
- filterManager.setFilters([gF2, aF1]);
-
- expect(appStateChangeTriggered).toBeCalledTimes(1);
- expect(appState.get()).toHaveLength(1);
-
- stop();
- });
-
- test('should trigger changes when app filters change', () => {
- const stop = syncAppFilters(filterManager, appState);
-
- filterManager.setFilters([gF1, aF1]);
- filterManager.setFilters([gF1, aF2]);
-
- expect(appStateChangeTriggered).toBeCalledTimes(2);
- expect(appState.get()).toHaveLength(1);
-
- stop();
- });
-
- test('resetting filters should sync to app state', () => {
- const stop = syncAppFilters(filterManager, appState);
-
- filterManager.setFilters([gF1, aF1]);
-
- expect(appState.get()).toHaveLength(1);
-
- filterManager.removeAll();
-
- expect(appState.get()).toHaveLength(0);
-
- stop();
- });
-
- test("shouldn't sync filters when syncing is stopped", () => {
- const stop = syncAppFilters(filterManager, appState);
-
- filterManager.setFilters([gF1, aF1]);
-
- expect(appState.get()).toHaveLength(1);
-
- stop();
-
- filterManager.removeAll();
-
- expect(appState.get()).toHaveLength(1);
- });
- });
- describe('sync from app state to filterManager', () => {
- test('should pick up initial state from app state', () => {
- appState.set([aF1]);
- filterManager.setFilters([gF1]);
-
- const stop = syncAppFilters(filterManager, appState);
- expect(filterManager.getFilters()).toHaveLength(2);
- expect(appStateChangeTriggered).toBeCalledTimes(1);
-
- stop();
- });
-
- test('changes to app state should be synced to app filters', () => {
- filterManager.setFilters([gF1]);
- const stop = syncAppFilters(filterManager, appState);
-
- appState.set([aF1]);
-
- expect(filterManager.getFilters()).toHaveLength(2);
- expect(filterManager.getAppFilters()).toHaveLength(1);
- expect(filterManager.getGlobalFilters()).toHaveLength(1);
- expect(appStateChangeTriggered).toBeCalledTimes(1);
- stop();
- });
-
- test('global filters should remain untouched', () => {
- filterManager.setFilters([gF1, gF2, aF1, aF2]);
- const stop = syncAppFilters(filterManager, appState);
-
- appState.set([]);
-
- expect(filterManager.getFilters()).toHaveLength(2);
- expect(filterManager.getGlobalFilters()).toHaveLength(2);
- expect(appStateChangeTriggered).toBeCalledTimes(1);
- stop();
- });
-
- test("if filters are not changed, filterManager shouldn't trigger update", () => {
- filterManager.setFilters([gF1, gF2, aF1, aF2]);
- filterManagerChangeTriggered.mockClear();
-
- appState.set([aF1, aF2]);
- const stop = syncAppFilters(filterManager, appState);
- appState.set([aF1, aF2]);
-
- expect(filterManagerChangeTriggered).toBeCalledTimes(0);
- stop();
- });
-
- test('stop() should stop syncing', () => {
- filterManager.setFilters([gF1, gF2, aF1, aF2]);
- const stop = syncAppFilters(filterManager, appState);
- appState.set([]);
- expect(filterManager.getFilters()).toHaveLength(2);
- stop();
- appState.set([aF1]);
- expect(filterManager.getFilters()).toHaveLength(2);
- });
- });
-});
diff --git a/src/plugins/data/public/query/state_sync/sync_app_filters.ts b/src/plugins/data/public/query/state_sync/sync_app_filters.ts
deleted file mode 100644
index d9956fcc0f6ae..0000000000000
--- a/src/plugins/data/public/query/state_sync/sync_app_filters.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * 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 _ from 'lodash';
-import { filter, map } from 'rxjs/operators';
-import { COMPARE_ALL_OPTIONS, compareFilters } from '../filter_manager/lib/compare_filters';
-import { Filter } from '../../../common';
-import { FilterManager } from '../filter_manager';
-import { BaseStateContainer } from '../../../../../plugins/kibana_utils/public';
-
-/**
- * Helper utility to sync application's state filters, with filter manager
- * @param filterManager
- * @param appState
- */
-export function syncAppFilters(
- filterManager: FilterManager,
- appState: BaseStateContainer
-) {
- // make sure initial app filters are picked by filterManager
- filterManager.setAppFilters(_.cloneDeep(appState.get()));
-
- const subs = [
- filterManager
- .getUpdates$()
- .pipe(
- map(() => filterManager.getAppFilters()),
- filter(
- // continue only if app state filters updated
- appFilters => !compareFilters(appFilters, appState.get(), COMPARE_ALL_OPTIONS)
- )
- )
- .subscribe(appFilters => {
- appState.set(appFilters);
- }),
-
- // if appFilters in dashboardStateManager changed (e.g browser history update),
- // sync it to filterManager
- appState.state$.subscribe(() => {
- if (!compareFilters(appState.get(), filterManager.getAppFilters(), COMPARE_ALL_OPTIONS)) {
- filterManager.setAppFilters(_.cloneDeep(appState.get()));
- }
- }),
- ];
-
- return () => {
- subs.forEach(s => s.unsubscribe());
- };
-}
diff --git a/src/plugins/data/public/query/state_sync/sync_query.ts b/src/plugins/data/public/query/state_sync/sync_query.ts
deleted file mode 100644
index 373f9aa0a5668..0000000000000
--- a/src/plugins/data/public/query/state_sync/sync_query.ts
+++ /dev/null
@@ -1,188 +0,0 @@
-/*
- * 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 { Subscription } from 'rxjs';
-import _ from 'lodash';
-import { filter, map } from 'rxjs/operators';
-import {
- createStateContainer,
- IKbnUrlStateStorage,
- syncState,
-} from '../../../../kibana_utils/public';
-import { COMPARE_ALL_OPTIONS, compareFilters } from '../filter_manager/lib/compare_filters';
-import { Filter, RefreshInterval, TimeRange } from '../../../common';
-import { QuerySetup, QueryStart } from '../query_service';
-
-const GLOBAL_STATE_STORAGE_KEY = '_g';
-
-export interface QuerySyncState {
- time?: TimeRange;
- refreshInterval?: RefreshInterval;
- filters?: Filter[];
-}
-
-/**
- * Helper utility to set up syncing between query services and url's '_g' query param
- */
-export const syncQuery = (queryStart: QueryStart, urlStateStorage: IKbnUrlStateStorage) => {
- const {
- timefilter: { timefilter },
- filterManager,
- } = queryStart;
- // retrieve current state from `_g` url
- const initialStateFromUrl = urlStateStorage.get(GLOBAL_STATE_STORAGE_KEY);
-
- // remember whether there were info in the URL
- const hasInheritedQueryFromUrl = Boolean(
- initialStateFromUrl && Object.keys(initialStateFromUrl).length
- );
-
- const {
- querySyncStateContainer,
- stop: stopPullQueryState,
- initialState,
- } = getQueryStateContainer(queryStart, initialStateFromUrl || {});
-
- const pushQueryStateSubscription = querySyncStateContainer.state$.subscribe(
- ({ time, filters: globalFilters, refreshInterval }) => {
- // cloneDeep is required because services are mutating passed objects
- // and state in state container is frozen
- if (time && !_.isEqual(time, timefilter.getTime())) {
- timefilter.setTime(_.cloneDeep(time));
- }
-
- if (refreshInterval && !_.isEqual(refreshInterval, timefilter.getRefreshInterval())) {
- timefilter.setRefreshInterval(_.cloneDeep(refreshInterval));
- }
-
- if (
- globalFilters &&
- !compareFilters(globalFilters, filterManager.getGlobalFilters(), COMPARE_ALL_OPTIONS)
- ) {
- filterManager.setGlobalFilters(_.cloneDeep(globalFilters));
- }
- }
- );
-
- // if there weren't any initial state in url,
- // then put _g key into url
- if (!initialStateFromUrl) {
- urlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, initialState, {
- replace: true,
- });
- }
-
- // trigger initial syncing from state container to services if needed
- querySyncStateContainer.set(initialState);
-
- const { start, stop: stopSyncState } = syncState({
- stateStorage: urlStateStorage,
- stateContainer: {
- ...querySyncStateContainer,
- set: state => {
- if (state) {
- // syncState utils requires to handle incoming "null" value
- querySyncStateContainer.set(state);
- }
- },
- },
- storageKey: GLOBAL_STATE_STORAGE_KEY,
- });
-
- start();
- return {
- stop: () => {
- stopSyncState();
- pushQueryStateSubscription.unsubscribe();
- stopPullQueryState();
- },
- hasInheritedQueryFromUrl,
- };
-};
-
-export const getQueryStateContainer = (
- { timefilter: { timefilter }, filterManager }: QuerySetup,
- initialStateOverrides: Partial = {}
-) => {
- const defaultState: QuerySyncState = {
- time: timefilter.getTime(),
- refreshInterval: timefilter.getRefreshInterval(),
- filters: filterManager.getGlobalFilters(),
- };
-
- const initialState: QuerySyncState = {
- ...defaultState,
- ...initialStateOverrides,
- };
-
- // create state container, which will be used for syncing with syncState() util
- const querySyncStateContainer = createStateContainer(
- initialState,
- {
- setTime: (state: QuerySyncState) => (time: TimeRange) => ({ ...state, time }),
- setRefreshInterval: (state: QuerySyncState) => (refreshInterval: RefreshInterval) => ({
- ...state,
- refreshInterval,
- }),
- setFilters: (state: QuerySyncState) => (filters: Filter[]) => ({
- ...state,
- filters,
- }),
- },
- {
- time: (state: QuerySyncState) => () => state.time,
- refreshInterval: (state: QuerySyncState) => () => state.refreshInterval,
- filters: (state: QuerySyncState) => () => state.filters,
- }
- );
-
- const subs: Subscription[] = [
- timefilter.getTimeUpdate$().subscribe(() => {
- querySyncStateContainer.transitions.setTime(timefilter.getTime());
- }),
- timefilter.getRefreshIntervalUpdate$().subscribe(() => {
- querySyncStateContainer.transitions.setRefreshInterval(timefilter.getRefreshInterval());
- }),
- filterManager
- .getUpdates$()
- .pipe(
- map(() => filterManager.getGlobalFilters()), // we need to track only global filters here
- filter(newGlobalFilters => {
- // continue only if global filters changed
- // and ignore app state filters
- const oldGlobalFilters = querySyncStateContainer.get().filters;
- return (
- !oldGlobalFilters ||
- !compareFilters(newGlobalFilters, oldGlobalFilters, COMPARE_ALL_OPTIONS)
- );
- })
- )
- .subscribe(newGlobalFilters => {
- querySyncStateContainer.transitions.setFilters(newGlobalFilters);
- }),
- ];
-
- return {
- querySyncStateContainer,
- stop: () => {
- subs.forEach(s => s.unsubscribe());
- },
- initialState,
- };
-};
diff --git a/src/plugins/data/public/query/state_sync/sync_query.test.ts b/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts
similarity index 63%
rename from src/plugins/data/public/query/state_sync/sync_query.test.ts
rename to src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts
index 1e7db2b9fd22f..50dc35ea955ee 100644
--- a/src/plugins/data/public/query/state_sync/sync_query.test.ts
+++ b/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts
@@ -31,7 +31,8 @@ import {
import { QueryService, QueryStart } from '../query_service';
import { StubBrowserStorage } from 'test_utils/stub_browser_storage';
import { TimefilterContract } from '../timefilter';
-import { getQueryStateContainer, QuerySyncState, syncQuery } from './sync_query';
+import { syncQueryStateWithUrl } from './sync_state_with_url';
+import { QueryState } from './types';
const setupMock = coreMock.createSetup();
const startMock = coreMock.createStart();
@@ -49,7 +50,7 @@ setupMock.uiSettings.get.mockImplementation((key: string) => {
}
});
-describe('sync_query', () => {
+describe('sync_query_state_with_url', () => {
let queryServiceStart: QueryStart;
let filterManager: FilterManager;
let timefilter: TimefilterContract;
@@ -90,7 +91,7 @@ describe('sync_query', () => {
});
test('url is actually changed when data in services changes', () => {
- const { stop } = syncQuery(queryServiceStart, kbnUrlStateStorage);
+ const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage);
filterManager.setFilters([gF, aF]);
kbnUrlStateStorage.flush(); // sync force location change
expect(history.location.hash).toMatchInlineSnapshot(
@@ -100,16 +101,16 @@ describe('sync_query', () => {
});
test('when filters change, global filters synced to urlStorage', () => {
- const { stop } = syncQuery(queryServiceStart, kbnUrlStateStorage);
+ const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage);
filterManager.setFilters([gF, aF]);
- expect(kbnUrlStateStorage.get('_g')?.filters).toHaveLength(1);
+ expect(kbnUrlStateStorage.get('_g')?.filters).toHaveLength(1);
stop();
});
test('when time range changes, time synced to urlStorage', () => {
- const { stop } = syncQuery(queryServiceStart, kbnUrlStateStorage);
+ const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage);
timefilter.setTime({ from: 'now-30m', to: 'now' });
- expect(kbnUrlStateStorage.get('_g')?.time).toEqual({
+ expect(kbnUrlStateStorage.get('_g')?.time).toEqual({
from: 'now-30m',
to: 'now',
});
@@ -117,9 +118,9 @@ describe('sync_query', () => {
});
test('when refresh interval changes, refresh interval is synced to urlStorage', () => {
- const { stop } = syncQuery(queryServiceStart, kbnUrlStateStorage);
+ const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage);
timefilter.setRefreshInterval({ pause: true, value: 100 });
- expect(kbnUrlStateStorage.get('_g')?.refreshInterval).toEqual({
+ expect(kbnUrlStateStorage.get('_g')?.refreshInterval).toEqual({
pause: true,
value: 100,
});
@@ -127,7 +128,7 @@ describe('sync_query', () => {
});
test('when url is changed, filters synced back to filterManager', () => {
- const { stop } = syncQuery(queryServiceStart, kbnUrlStateStorage);
+ const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage);
kbnUrlStateStorage.cancel(); // stop initial syncing pending update
history.push(pathWithFilter);
expect(filterManager.getGlobalFilters()).toHaveLength(1);
@@ -137,14 +138,17 @@ describe('sync_query', () => {
test('initial url should be synced with services', () => {
history.push(pathWithFilter);
- const { stop, hasInheritedQueryFromUrl } = syncQuery(queryServiceStart, kbnUrlStateStorage);
+ const { stop, hasInheritedQueryFromUrl } = syncQueryStateWithUrl(
+ queryServiceStart,
+ kbnUrlStateStorage
+ );
expect(hasInheritedQueryFromUrl).toBe(true);
expect(filterManager.getGlobalFilters()).toHaveLength(1);
stop();
});
test("url changes shouldn't trigger services updates if data didn't change", () => {
- const { stop } = syncQuery(queryServiceStart, kbnUrlStateStorage);
+ const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage);
filterManagerChangeTriggered.mockClear();
history.push(pathWithFilter);
@@ -156,76 +160,11 @@ describe('sync_query', () => {
});
test("if data didn't change, kbnUrlStateStorage.set shouldn't be called", () => {
- const { stop } = syncQuery(queryServiceStart, kbnUrlStateStorage);
+ const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage);
filterManager.setFilters([gF, aF]);
const spy = jest.spyOn(kbnUrlStateStorage, 'set');
filterManager.setFilters([gF]); // global filters didn't change
expect(spy).not.toBeCalled();
stop();
});
-
- describe('getQueryStateContainer', () => {
- test('state is initialized with state from query service', () => {
- const { stop, querySyncStateContainer, initialState } = getQueryStateContainer(
- queryServiceStart
- );
- expect(querySyncStateContainer.getState()).toMatchInlineSnapshot(`
- Object {
- "filters": Array [],
- "refreshInterval": Object {
- "pause": true,
- "value": 0,
- },
- "time": Object {
- "from": "now-15m",
- "to": "now",
- },
- }
- `);
- expect(initialState).toEqual(querySyncStateContainer.getState());
- stop();
- });
-
- test('state takes initial overrides into account', () => {
- const { stop, querySyncStateContainer, initialState } = getQueryStateContainer(
- queryServiceStart,
- {
- time: { from: 'now-99d', to: 'now' },
- }
- );
- expect(querySyncStateContainer.getState().time).toEqual({
- from: 'now-99d',
- to: 'now',
- });
- expect(initialState).toEqual(querySyncStateContainer.getState());
- stop();
- });
-
- test('when filters change, state container contains updated global filters', () => {
- const { stop, querySyncStateContainer } = getQueryStateContainer(queryServiceStart);
- filterManager.setFilters([gF, aF]);
- expect(querySyncStateContainer.getState().filters).toHaveLength(1);
- stop();
- });
-
- test('when time range changes, state container contains updated time range', () => {
- const { stop, querySyncStateContainer } = getQueryStateContainer(queryServiceStart);
- timefilter.setTime({ from: 'now-30m', to: 'now' });
- expect(querySyncStateContainer.getState().time).toEqual({
- from: 'now-30m',
- to: 'now',
- });
- stop();
- });
-
- test('when refresh interval changes, state container contains updated refresh interval', () => {
- const { stop, querySyncStateContainer } = getQueryStateContainer(queryServiceStart);
- timefilter.setRefreshInterval({ pause: true, value: 100 });
- expect(querySyncStateContainer.getState().refreshInterval).toEqual({
- pause: true,
- value: 100,
- });
- stop();
- });
- });
});
diff --git a/src/plugins/data/public/query/state_sync/sync_state_with_url.ts b/src/plugins/data/public/query/state_sync/sync_state_with_url.ts
new file mode 100644
index 0000000000000..cd7058b9f8f1c
--- /dev/null
+++ b/src/plugins/data/public/query/state_sync/sync_state_with_url.ts
@@ -0,0 +1,102 @@
+/*
+ * 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 {
+ createStateContainer,
+ IKbnUrlStateStorage,
+ syncState,
+} from '../../../../kibana_utils/public';
+import { QuerySetup, QueryStart } from '../query_service';
+import { connectToQueryState } from './connect_to_query_state';
+import { QueryState } from './types';
+import { FilterStateStore } from '../../../common/es_query/filters';
+
+const GLOBAL_STATE_STORAGE_KEY = '_g';
+
+/**
+ * Helper to setup syncing of global data with the URL
+ * @param QueryService: either setup or start
+ * @param kbnUrlStateStorage to use for syncing
+ */
+export const syncQueryStateWithUrl = (
+ query: Pick,
+ kbnUrlStateStorage: IKbnUrlStateStorage
+) => {
+ const {
+ timefilter: { timefilter },
+ filterManager,
+ } = query;
+ const defaultState: QueryState = {
+ time: timefilter.getTime(),
+ refreshInterval: timefilter.getRefreshInterval(),
+ filters: filterManager.getGlobalFilters(),
+ };
+
+ // retrieve current state from `_g` url
+ const initialStateFromUrl = kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY);
+
+ // remember whether there was info in the URL
+ const hasInheritedQueryFromUrl = Boolean(
+ initialStateFromUrl && Object.keys(initialStateFromUrl).length
+ );
+
+ // prepare initial state, whatever was in URL takes precedences over current state in services
+ const initialState: QueryState = {
+ ...defaultState,
+ ...initialStateFromUrl,
+ };
+
+ const globalQueryStateContainer = createStateContainer(initialState);
+ const stopSyncingWithStateContainer = connectToQueryState(query, globalQueryStateContainer, {
+ refreshInterval: true,
+ time: true,
+ filters: FilterStateStore.GLOBAL_STATE,
+ });
+
+ // if there weren't any initial state in url,
+ // then put _g key into url
+ if (!initialStateFromUrl) {
+ kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, initialState, {
+ replace: true,
+ });
+ }
+
+ // trigger initial syncing from state container to services if needed
+ globalQueryStateContainer.set(initialState);
+
+ const { start, stop: stopSyncingWithUrl } = syncState({
+ stateStorage: kbnUrlStateStorage,
+ stateContainer: {
+ ...globalQueryStateContainer,
+ set: state => {
+ globalQueryStateContainer.set(state || defaultState);
+ },
+ },
+ storageKey: GLOBAL_STATE_STORAGE_KEY,
+ });
+
+ start();
+ return {
+ stop: () => {
+ stopSyncingWithStateContainer();
+ stopSyncingWithUrl();
+ },
+ hasInheritedQueryFromUrl,
+ };
+};
diff --git a/src/plugins/data/public/query/state_sync/types.ts b/src/plugins/data/public/query/state_sync/types.ts
new file mode 100644
index 0000000000000..747d4d45fe29b
--- /dev/null
+++ b/src/plugins/data/public/query/state_sync/types.ts
@@ -0,0 +1,38 @@
+/*
+ * 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 { Filter, RefreshInterval, TimeRange } from '../../../common';
+
+/**
+ * All query state service state
+ */
+export interface QueryState {
+ time?: TimeRange;
+ refreshInterval?: RefreshInterval;
+ filters?: Filter[];
+}
+
+type QueryStateChangePartial = {
+ [P in keyof QueryState]?: boolean;
+};
+
+export interface QueryStateChange extends QueryStateChangePartial {
+ appFilters?: boolean; // specifies if app filters change
+ globalFilters?: boolean; // specifies if global filters change
+}
diff --git a/src/plugins/data/public/query/timefilter/timefilter.ts b/src/plugins/data/public/query/timefilter/timefilter.ts
index 58806a9328b1c..4fbdac47fb3b0 100644
--- a/src/plugins/data/public/query/timefilter/timefilter.ts
+++ b/src/plugins/data/public/query/timefilter/timefilter.ts
@@ -50,8 +50,13 @@ export class Timefilter {
private _autoRefreshIntervalId: number = 0;
+ private readonly timeDefaults: TimeRange;
+ private readonly refreshIntervalDefaults: RefreshInterval;
+
constructor(config: TimefilterConfig, timeHistory: TimeHistoryContract) {
this._history = timeHistory;
+ this.timeDefaults = config.timeDefaults;
+ this.refreshIntervalDefaults = config.refreshIntervalDefaults;
this._time = config.timeDefaults;
this.setRefreshInterval(config.refreshIntervalDefaults);
}
@@ -208,6 +213,14 @@ export class Timefilter {
this.enabledUpdated$.next(false);
};
+ public getTimeDefaults(): TimeRange {
+ return _.cloneDeep(this.timeDefaults);
+ }
+
+ public getRefreshIntervalDefaults(): RefreshInterval {
+ return _.cloneDeep(this.refreshIntervalDefaults);
+ }
+
private getForceNow = () => {
const forceNow = parseQueryString().forceNow as string;
if (!forceNow) {
diff --git a/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts b/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts
index 80c13464ad98a..7863000b1ace4 100644
--- a/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts
+++ b/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts
@@ -43,6 +43,8 @@ const createSetupContractMock = () => {
getBounds: jest.fn(),
calculateBounds: jest.fn(),
createFilter: jest.fn(),
+ getRefreshIntervalDefaults: jest.fn(),
+ getTimeDefaults: jest.fn(),
};
const historyMock: jest.Mocked = {
diff --git a/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap b/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap
index b411d27a2a965..58f00ff9ed657 100644
--- a/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap
+++ b/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap
@@ -197,6 +197,9 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA
"query": Object {
"filterManager": [MockFunction],
"savedQueries": [MockFunction],
+ "state$": Observable {
+ "_isScalar": false,
+ },
"timefilter": Object {
"history": Object {
"add": [MockFunction],
@@ -215,8 +218,10 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA
"getEnabledUpdated$": [MockFunction],
"getFetch$": [MockFunction],
"getRefreshInterval": [MockFunction],
+ "getRefreshIntervalDefaults": [MockFunction],
"getRefreshIntervalUpdate$": [MockFunction],
"getTime": [MockFunction],
+ "getTimeDefaults": [MockFunction],
"getTimeUpdate$": [MockFunction],
"isAutoRefreshSelectorEnabled": [MockFunction],
"isTimeRangeSelectorEnabled": [MockFunction],
@@ -855,6 +860,9 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA
"query": Object {
"filterManager": [MockFunction],
"savedQueries": [MockFunction],
+ "state$": Observable {
+ "_isScalar": false,
+ },
"timefilter": Object {
"history": Object {
"add": [MockFunction],
@@ -873,8 +881,10 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA
"getEnabledUpdated$": [MockFunction],
"getFetch$": [MockFunction],
"getRefreshInterval": [MockFunction],
+ "getRefreshIntervalDefaults": [MockFunction],
"getRefreshIntervalUpdate$": [MockFunction],
"getTime": [MockFunction],
+ "getTimeDefaults": [MockFunction],
"getTimeUpdate$": [MockFunction],
"isAutoRefreshSelectorEnabled": [MockFunction],
"isTimeRangeSelectorEnabled": [MockFunction],
@@ -1495,6 +1505,9 @@ exports[`QueryStringInput Should pass the query language to the language switche
"query": Object {
"filterManager": [MockFunction],
"savedQueries": [MockFunction],
+ "state$": Observable {
+ "_isScalar": false,
+ },
"timefilter": Object {
"history": Object {
"add": [MockFunction],
@@ -1513,8 +1526,10 @@ exports[`QueryStringInput Should pass the query language to the language switche
"getEnabledUpdated$": [MockFunction],
"getFetch$": [MockFunction],
"getRefreshInterval": [MockFunction],
+ "getRefreshIntervalDefaults": [MockFunction],
"getRefreshIntervalUpdate$": [MockFunction],
"getTime": [MockFunction],
+ "getTimeDefaults": [MockFunction],
"getTimeUpdate$": [MockFunction],
"isAutoRefreshSelectorEnabled": [MockFunction],
"isTimeRangeSelectorEnabled": [MockFunction],
@@ -2150,6 +2165,9 @@ exports[`QueryStringInput Should pass the query language to the language switche
"query": Object {
"filterManager": [MockFunction],
"savedQueries": [MockFunction],
+ "state$": Observable {
+ "_isScalar": false,
+ },
"timefilter": Object {
"history": Object {
"add": [MockFunction],
@@ -2168,8 +2186,10 @@ exports[`QueryStringInput Should pass the query language to the language switche
"getEnabledUpdated$": [MockFunction],
"getFetch$": [MockFunction],
"getRefreshInterval": [MockFunction],
+ "getRefreshIntervalDefaults": [MockFunction],
"getRefreshIntervalUpdate$": [MockFunction],
"getTime": [MockFunction],
+ "getTimeDefaults": [MockFunction],
"getTimeUpdate$": [MockFunction],
"isAutoRefreshSelectorEnabled": [MockFunction],
"isTimeRangeSelectorEnabled": [MockFunction],
@@ -2790,6 +2810,9 @@ exports[`QueryStringInput Should render the given query 1`] = `
"query": Object {
"filterManager": [MockFunction],
"savedQueries": [MockFunction],
+ "state$": Observable {
+ "_isScalar": false,
+ },
"timefilter": Object {
"history": Object {
"add": [MockFunction],
@@ -2808,8 +2831,10 @@ exports[`QueryStringInput Should render the given query 1`] = `
"getEnabledUpdated$": [MockFunction],
"getFetch$": [MockFunction],
"getRefreshInterval": [MockFunction],
+ "getRefreshIntervalDefaults": [MockFunction],
"getRefreshIntervalUpdate$": [MockFunction],
"getTime": [MockFunction],
+ "getTimeDefaults": [MockFunction],
"getTimeUpdate$": [MockFunction],
"isAutoRefreshSelectorEnabled": [MockFunction],
"isTimeRangeSelectorEnabled": [MockFunction],
@@ -3445,6 +3470,9 @@ exports[`QueryStringInput Should render the given query 1`] = `
"query": Object {
"filterManager": [MockFunction],
"savedQueries": [MockFunction],
+ "state$": Observable {
+ "_isScalar": false,
+ },
"timefilter": Object {
"history": Object {
"add": [MockFunction],
@@ -3463,8 +3491,10 @@ exports[`QueryStringInput Should render the given query 1`] = `
"getEnabledUpdated$": [MockFunction],
"getFetch$": [MockFunction],
"getRefreshInterval": [MockFunction],
+ "getRefreshIntervalDefaults": [MockFunction],
"getRefreshIntervalUpdate$": [MockFunction],
"getTime": [MockFunction],
+ "getTimeDefaults": [MockFunction],
"getTimeUpdate$": [MockFunction],
"isAutoRefreshSelectorEnabled": [MockFunction],
"isTimeRangeSelectorEnabled": [MockFunction],
diff --git a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx
index 632385e019e4c..7d65e947c0f04 100644
--- a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx
+++ b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import { CoreStart } from 'src/core/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { KibanaContextProvider } from '../../../../kibana_react/public';
@@ -117,13 +117,28 @@ export function createSearchBar({ core, storage, data }: StatefulSearchBarDeps)
// App name should come from the core application service.
// Until it's available, we'll ask the user to provide it for the pre-wired component.
return (props: StatefulSearchBarProps) => {
+ const { useDefaultBehaviors } = props;
// Handle queries
- const [query, setQuery] = useState(
- props.query || {
- query: '',
- language: core.uiSettings.get('search:queryLanguage'),
+ const queryRef = useRef(props.query);
+ const onQuerySubmitRef = useRef(props.onQuerySubmit);
+ const defaultQuery = {
+ query: '',
+ language: core.uiSettings.get('search:queryLanguage'),
+ };
+ const [query, setQuery] = useState(props.query || defaultQuery);
+
+ useEffect(() => {
+ if (props.query !== queryRef.current) {
+ queryRef.current = props.query;
+ setQuery(props.query || defaultQuery);
}
- );
+ }, [defaultQuery, props.query]);
+
+ useEffect(() => {
+ if (props.onQuerySubmit !== onQuerySubmitRef.current) {
+ onQuerySubmitRef.current = props.onQuerySubmit;
+ }
+ }, [props.onQuerySubmit]);
// handle service state updates.
// i.e. filters being added from a visualization directly to filterManager.
@@ -150,16 +165,15 @@ export function createSearchBar({ core, storage, data }: StatefulSearchBarDeps)
// Fire onQuerySubmit on query or timerange change
useEffect(() => {
- if (!props.useDefaultBehaviors) return;
- if (props.onQuerySubmit)
- props.onQuerySubmit(
- {
- dateRange: timeRange,
- query,
- },
- true
- );
- }, [props, props.onQuerySubmit, props.useDefaultBehaviors, query, timeRange]);
+ if (!useDefaultBehaviors || !onQuerySubmitRef.current) return;
+ onQuerySubmitRef.current(
+ {
+ dateRange: timeRange,
+ query,
+ },
+ true
+ );
+ }, [query, timeRange, useDefaultBehaviors]);
return (