diff --git a/examples/state_containers_examples/common/index.ts b/examples/state_containers_examples/common/index.ts new file mode 100644 index 00000000000000..25dc2eacf9c75c --- /dev/null +++ b/examples/state_containers_examples/common/index.ts @@ -0,0 +1,21 @@ +/* + * 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 PLUGIN_ID = 'stateContainersExampleWithDataServices'; +export const PLUGIN_NAME = 'State containers example - with data services'; diff --git a/examples/state_containers_examples/kibana.json b/examples/state_containers_examples/kibana.json index 9114a414a4da39..437e9a4fac63c2 100644 --- a/examples/state_containers_examples/kibana.json +++ b/examples/state_containers_examples/kibana.json @@ -3,8 +3,8 @@ "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["state_containers_examples"], - "server": false, + "server": true, "ui": true, - "requiredPlugins": [], + "requiredPlugins": ["navigation", "data"], "optionalPlugins": [] } diff --git a/examples/state_containers_examples/public/plugin.ts b/examples/state_containers_examples/public/plugin.ts index beb7b93dbc5b66..38ebf315789c09 100644 --- a/examples/state_containers_examples/public/plugin.ts +++ b/examples/state_containers_examples/public/plugin.ts @@ -18,14 +18,16 @@ */ import { AppMountParameters, CoreSetup, Plugin } from 'kibana/public'; +import { AppPluginDependencies } from './with_data_services/types'; +import { PLUGIN_ID, PLUGIN_NAME } from '../common'; export class StateContainersExamplesPlugin implements Plugin { public setup(core: CoreSetup) { core.application.register({ - id: 'state-containers-example-browser-history', + id: 'stateContainersExampleBrowserHistory', title: 'State containers example - browser history routing', async mount(params: AppMountParameters) { - const { renderApp, History } = await import('./app'); + const { renderApp, History } = await import('./todo/app'); return renderApp(params, { appInstanceId: '1', appTitle: 'Routing with browser history', @@ -34,10 +36,10 @@ export class StateContainersExamplesPlugin implements Plugin { }, }); core.application.register({ - id: 'state-containers-example-hash-history', + id: 'stateContainersExampleHashHistory', title: 'State containers example - hash history routing', async mount(params: AppMountParameters) { - const { renderApp, History } = await import('./app'); + const { renderApp, History } = await import('./todo/app'); return renderApp(params, { appInstanceId: '2', appTitle: 'Routing with hash history', @@ -45,6 +47,19 @@ export class StateContainersExamplesPlugin implements Plugin { }); }, }); + + core.application.register({ + id: PLUGIN_ID, + title: PLUGIN_NAME, + async mount(params: AppMountParameters) { + // Load application bundle + const { renderApp } = await import('./with_data_services/application'); + // Get start services as specified in kibana.json + const [coreStart, depsStart] = await core.getStartServices(); + // Render the application + return renderApp(coreStart, depsStart as AppPluginDependencies, params); + }, + }); } public start() {} diff --git a/examples/state_containers_examples/public/app.tsx b/examples/state_containers_examples/public/todo/app.tsx similarity index 100% rename from examples/state_containers_examples/public/app.tsx rename to examples/state_containers_examples/public/todo/app.tsx diff --git a/examples/state_containers_examples/public/todo.tsx b/examples/state_containers_examples/public/todo/todo.tsx similarity index 98% rename from examples/state_containers_examples/public/todo.tsx rename to examples/state_containers_examples/public/todo/todo.tsx index 84f64f99d01798..c0617620bde53f 100644 --- a/examples/state_containers_examples/public/todo.tsx +++ b/examples/state_containers_examples/public/todo/todo.tsx @@ -42,14 +42,14 @@ import { syncStates, getStateFromKbnUrl, BaseState, -} from '../../../src/plugins/kibana_utils/public'; -import { useUrlTracker } from '../../../src/plugins/kibana_react/public'; +} from '../../../../src/plugins/kibana_utils/public'; +import { useUrlTracker } from '../../../../src/plugins/kibana_react/public'; import { defaultState, pureTransitions, TodoActions, TodoState, -} from '../../../src/plugins/kibana_utils/demos/state_containers/todomvc'; +} from '../../../../src/plugins/kibana_utils/demos/state_containers/todomvc'; interface GlobalState { text: string; diff --git a/examples/state_containers_examples/public/with_data_services/application.tsx b/examples/state_containers_examples/public/with_data_services/application.tsx new file mode 100644 index 00000000000000..1de3cbbc5f9884 --- /dev/null +++ b/examples/state_containers_examples/public/with_data_services/application.tsx @@ -0,0 +1,49 @@ +/* + * 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 React from 'react'; +import ReactDOM from 'react-dom'; +import { createBrowserHistory } from 'history'; +import { AppMountParameters, CoreStart } from '../../../../src/core/public'; +import { AppPluginDependencies } from './types'; +import { StateDemoApp } from './components/app'; +import { createKbnUrlStateStorage } from '../../../../src/plugins/kibana_utils/public/'; + +export const renderApp = ( + { notifications, http }: CoreStart, + { navigation, data }: AppPluginDependencies, + { appBasePath, element }: AppMountParameters +) => { + const history = createBrowserHistory({ basename: appBasePath }); + const kbnUrlStateStorage = createKbnUrlStateStorage({ useHash: false, history }); + + ReactDOM.render( + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/examples/state_containers_examples/public/with_data_services/components/app.tsx b/examples/state_containers_examples/public/with_data_services/components/app.tsx new file mode 100644 index 00000000000000..c820929d8a61da --- /dev/null +++ b/examples/state_containers_examples/public/with_data_services/components/app.tsx @@ -0,0 +1,243 @@ +/* + * 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 React, { useEffect, useRef, useState, useCallback } from 'react'; +import { History } from 'history'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { Router } from 'react-router-dom'; + +import { + EuiFieldText, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageHeader, + EuiTitle, +} from '@elastic/eui'; + +import { CoreStart } from '../../../../../src/core/public'; +import { NavigationPublicPluginStart } from '../../../../../src/plugins/navigation/public'; +import { + connectToQueryState, + syncQueryStateWithUrl, + DataPublicPluginStart, + IIndexPattern, + QueryState, + Filter, + esFilters, + Query, +} from '../../../../../src/plugins/data/public'; +import { + BaseState, + BaseStateContainer, + createStateContainer, + createStateContainerReactHelpers, + IKbnUrlStateStorage, + ReduxLikeStateContainer, + syncState, +} from '../../../../../src/plugins/kibana_utils/public'; +import { PLUGIN_ID, PLUGIN_NAME } from '../../../common'; + +interface StateDemoAppDeps { + notifications: CoreStart['notifications']; + http: CoreStart['http']; + navigation: NavigationPublicPluginStart; + data: DataPublicPluginStart; + history: History; + kbnUrlStateStorage: IKbnUrlStateStorage; +} + +interface AppState { + name: string; + filters: Filter[]; + query?: Query; +} +const defaultAppState: AppState = { + name: '', + filters: [], +}; +const { + Provider: AppStateContainerProvider, + useState: useAppState, + useContainer: useAppStateContainer, +} = createStateContainerReactHelpers>(); + +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 00000000000000..c63074a7a3810e --- /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 00000000000000..51005d78462a26 --- /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 00000000000000..1c3fa9bfb290e9 --- /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 00000000000000..f6da48ae62c617 --- /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 00000000000000..6acfc27bd681b5 --- /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 091130487791bd..3f43072c2aade0 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 075516d52bab63..84dd73882d1343 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 ce9cc85be57b27..35b510894179de 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 7d64ee969212f4..9e645b7fb3c5f4 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 e8ded9d99f8924..3ba0418d35f718 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 9f2283d29c203c..b9e4487ae84fbc 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 cf8537ba7ab3ec..75f48beb140a2c 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 978f140eb1d263..5dcf51ecc81eb6 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 2710dadaa23a33..47b0a5b871ce2e 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 ebef8b8d450500..c885d596f19435 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 00000000000000..6b9b158100573c --- /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 00000000000000..5da929c441cdee --- /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 00000000000000..a22e66860c7658 --- /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 00000000000000..d0d97bfaaeb362 --- /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 27e02940765cf7..e1a3561e022dbf 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 e01547b1c0fd8c..00000000000000 --- 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 d9956fcc0f6ae5..00000000000000 --- 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 373f9aa0a56685..00000000000000 --- 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 1e7db2b9fd22f7..50dc35ea955ee3 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 00000000000000..cd7058b9f8f1c1 --- /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 00000000000000..747d4d45fe29b4 --- /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 58806a9328b1cd..4fbdac47fb3b02 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 80c13464ad98a9..7863000b1ace43 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 b411d27a2a965d..58f00ff9ed6578 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 632385e019e4c4..7d65e947c0f042 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 (