diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/filter_state_manager.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/filter_state_manager.ts index ad5c91d2e19de..c9c72a7be9a14 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/filter_state_manager.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/filter_state_manager.ts @@ -21,13 +21,7 @@ import _ from 'lodash'; import { Subscription } from 'rxjs'; import { State } from 'ui/state_management/state'; import { FilterManager, esFilters } from '../../../../../../plugins/data/public'; - -import { - compareFilters, - COMPARE_ALL_OPTIONS, - // this whole file will soon be deprecated by new state management. - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../../plugins/data/public/query/filter_manager/lib/compare_filters'; +import { compareFilters, COMPARE_ALL_OPTIONS } from '../../../../../../plugins/data/public'; type GetAppStateFunc = () => { filters?: esFilters.Filter[]; save?: () => void } | undefined | null; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts b/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts index 244a58e8a65e5..97d165b6b5c23 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts @@ -27,7 +27,6 @@ import chrome from 'ui/chrome'; export const legacyChrome = chrome; -export { State } from 'ui/state_management/state'; export { SavedObjectSaveOpts } from 'ui/saved_objects/types'; export { npSetup, npStart } from 'ui/new_platform'; export { IPrivate } from 'ui/private'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/application.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/application.ts index 429a7f7279996..d9c0579638047 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/application.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/application.ts @@ -19,32 +19,31 @@ import { EuiConfirmModal, EuiIcon } from '@elastic/eui'; import angular, { IModule } from 'angular'; +import { History } from 'history'; import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular'; import { AppMountContext, ChromeStart, + IUiSettingsClient, LegacyCoreStart, SavedObjectsClientContract, - IUiSettingsClient, } from 'kibana/public'; -import { Storage } from '../../../../../../plugins/kibana_utils/public'; +import { IKbnUrlStateStorage, Storage } from '../../../../../../plugins/kibana_utils/public'; import { - GlobalStateProvider, - StateManagementConfigProvider, - PrivateProvider, - EventsProvider, - PersistedState, + configureAppAngularModule, + confirmModalFactory, createTopNavDirective, createTopNavHelper, - PromiseServiceCreator, + EventsProvider, + IPrivate, KbnUrlProvider, + PersistedState, + PrivateProvider, + PromiseServiceCreator, RedirectWhenMissingProvider, - confirmModalFactory, - configureAppAngularModule, SavedObjectLoader, - IPrivate, + StateManagementConfigProvider, } from '../legacy_imports'; - // @ts-ignore import { initDashboardApp } from './legacy_app'; import { IEmbeddableStart } from '../../../../../../plugins/embeddable/public'; @@ -67,6 +66,8 @@ export interface RenderDeps { embeddables: IEmbeddableStart; localStorage: Storage; share: SharePluginStart; + history: History; + kbnUrlStateStorage: IKbnUrlStateStorage; } let angularModuleInstance: IModule | null = null; @@ -79,7 +80,9 @@ export const renderApp = (element: HTMLElement, appBasePath: string, deps: Rende // custom routing stuff initDashboardApp(angularModuleInstance, deps); } + const $injector = mountDashboardApp(appBasePath, element); + return () => { $injector.get('$rootScope').$destroy(); }; @@ -146,17 +149,13 @@ function createLocalConfirmModalModule() { } function createLocalStateModule() { - angular - .module('app/dashboard/State', [ - 'app/dashboard/Private', - 'app/dashboard/Config', - 'app/dashboard/KbnUrl', - 'app/dashboard/Promise', - 'app/dashboard/PersistedState', - ]) - .service('globalState', function(Private: any) { - return Private(GlobalStateProvider); - }); + angular.module('app/dashboard/State', [ + 'app/dashboard/Private', + 'app/dashboard/Config', + 'app/dashboard/KbnUrl', + 'app/dashboard/Promise', + 'app/dashboard/PersistedState', + ]); } function createLocalPersistedStateModule() { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx index f56990ae82e56..367db6644ff57 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx @@ -96,14 +96,12 @@ export function initDashboardAppDirective(app: any, deps: RenderDeps) { $route: any, $routeParams: { id?: string; - }, - globalState: any + } ) => new DashboardAppController({ $route, $scope, $routeParams, - globalState, config, confirmModal, indexPatterns: deps.npDataStart.indexPatterns, 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 4da445166df45..e85054cd7fb34 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 @@ -23,7 +23,7 @@ import React from 'react'; import angular from 'angular'; import { Subscription } from 'rxjs'; -import { createHashHistory } from 'history'; +import { map } from 'rxjs/operators'; import { DashboardEmptyScreen, DashboardEmptyScreenProps } from './dashboard_empty_screen'; import { @@ -32,16 +32,17 @@ import { SavedObjectSaveOpts, SaveResult, showSaveModal, - State, subscribeWithScope, } from '../legacy_imports'; -import { FilterStateManager } from '../../../../data/public'; import { - esFilters, + COMPARE_ALL_OPTIONS, + compareFilters, IndexPattern, IndexPatternsContract, Query, SavedQuery, + syncAppFilters, + syncQuery, } from '../../../../../../plugins/data/public'; import { @@ -82,7 +83,6 @@ export interface DashboardAppControllerDependencies extends RenderDeps { $scope: DashboardAppScope; $route: any; $routeParams: any; - globalState: State; indexPatterns: IndexPatternsContract; dashboardConfig: any; config: any; @@ -99,7 +99,6 @@ export class DashboardAppController { $scope, $route, $routeParams, - globalState, dashboardConfig, localStorage, indexPatterns, @@ -109,15 +108,21 @@ export class DashboardAppController { embeddables, share, dashboardCapabilities, - npDataStart: { - query: { - filterManager, - timefilter: { timefilter }, - }, - }, + npDataStart: { query: queryService }, core: { notifications, overlays, chrome, injectedMetadata, uiSettings, savedObjects, http }, + history, + kbnUrlStateStorage, }: DashboardAppControllerDependencies) { + const filterManager = queryService.filterManager; const queryFilter = filterManager; + const timefilter = queryService.timefilter.timefilter; + + // starts syncing `_g` portion of url with query services + // note: dashboard_state_manager.ts syncs `_a` portion of url + const { + stop: stopSyncingGlobalStateWithUrl, + hasInheritedQueryFromUrl: hasInheritedGlobalStateFromUrl, + } = syncQuery(queryService, kbnUrlStateStorage); let lastReloadRequestTime = 0; @@ -126,34 +131,23 @@ export class DashboardAppController { chrome.docTitle.change(dash.title); } - const history = createHashHistory(); const dashboardStateManager = new DashboardStateManager({ savedDashboard: dash, - useHashedUrl: config.get('state:storeInSessionStorage'), hideWriteControls: dashboardConfig.getHideWriteControls(), kibanaVersion: injectedMetadata.getKibanaVersion(), + kbnUrlStateStorage, history, }); - const filterStateManager = new FilterStateManager( - globalState, - () => { - // Temporary AppState replacement - return { - set filters(_filters: esFilters.Filter[]) { - dashboardStateManager.setFilters(_filters); - }, - get filters() { - return dashboardStateManager.appState.filters; - }, - }; - }, - filterManager - ); + const stopSyncingAppFilters = syncAppFilters(filterManager, { + set: filters => dashboardStateManager.setFilters(filters), + get: () => dashboardStateManager.appState.filters, + state$: dashboardStateManager.appState$.pipe(map(state => state.filters)), + }); // The hash check is so we only update the time filter on dashboard open, not during // normal cross app navigation. - if (dashboardStateManager.getIsTimeSavedWithDashboard() && !globalState.$inheritedGlobalState) { + if (dashboardStateManager.getIsTimeSavedWithDashboard() && !hasInheritedGlobalStateFromUrl) { dashboardStateManager.syncTimefilterWithDashboard(timefilter); } $scope.showSaveQuery = dashboardCapabilities.saveQuery as boolean; @@ -316,8 +310,14 @@ export class DashboardAppController { // This has to be first because handleDashboardContainerChanges causes // appState.save which will cause refreshDashboardContainer to be called. - // Add filters modifies the object passed to it, hence the clone deep. - if (!_.isEqual(container.getInput().filters, queryFilter.getFilters())) { + if ( + !compareFilters( + container.getInput().filters, + queryFilter.getFilters(), + COMPARE_ALL_OPTIONS + ) + ) { + // Add filters modifies the object passed to it, hence the clone deep. queryFilter.addFilters(_.cloneDeep(container.getInput().filters)); dashboardStateManager.applyFilters($scope.model.query, container.getInput().filters); @@ -411,19 +411,27 @@ export class DashboardAppController { const containerInput = dashboardContainer.getInput(); const differences: Partial = {}; - Object.keys(containerInput).forEach(key => { + + // Filters shouldn't be compared using regular isEqual + if ( + !compareFilters(containerInput.filters, appStateDashboardInput.filters, COMPARE_ALL_OPTIONS) + ) { + differences.filters = appStateDashboardInput.filters; + } + + Object.keys(_.omit(containerInput, 'filters')).forEach(key => { const containerValue = (containerInput as { [key: string]: unknown })[key]; const appStateValue = ((appStateDashboardInput as unknown) as { [key: string]: unknown })[ key ]; if (!_.isEqual(containerValue, appStateValue)) { - // cloneDeep hack is needed, as there are multiple place, where container's input mutated, - // but values from appStateValue are deeply frozen, as they can't be mutated directly - (differences as { [key: string]: unknown })[key] = _.cloneDeep(appStateValue); + (differences as { [key: string]: unknown })[key] = appStateValue; } }); - return Object.values(differences).length === 0 ? undefined : differences; + // cloneDeep hack is needed, as there are multiple place, where container's input mutated, + // but values from appStateValue are deeply frozen, as they can't be mutated directly + return Object.values(differences).length === 0 ? undefined : _.cloneDeep(differences); }; const refreshDashboardContainer = () => { @@ -878,6 +886,8 @@ export class DashboardAppController { $scope.$on('$destroy', () => { updateSubscription.unsubscribe(); + stopSyncingGlobalStateWithUrl(); + stopSyncingAppFilters(); visibleSubscription.unsubscribe(); $scope.timefilterSubscriptions$.unsubscribe(); @@ -891,9 +901,6 @@ export class DashboardAppController { if (dashboardContainer) { dashboardContainer.destroy(); } - if (filterStateManager) { - filterStateManager.destroy(); - } }); } } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state.test.ts index 8806684aab13c..152cd84b7c38d 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state.test.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state.test.ts @@ -23,6 +23,7 @@ import { DashboardStateManager } from './dashboard_state_manager'; import { getSavedDashboardMock } from '../__tests__'; import { InputTimeRange, TimefilterContract, TimeRange } from 'src/plugins/data/public'; import { ViewMode } from 'src/plugins/embeddable/public'; +import { createKbnUrlStateStorage } from 'src/plugins/kibana_utils/public'; jest.mock('ui/agg_types', () => ({ aggTypes: { @@ -48,9 +49,9 @@ describe('DashboardState', function() { function initDashboardState() { dashboardState = new DashboardStateManager({ savedDashboard, - useHashedUrl: false, hideWriteControls: false, kibanaVersion: '7.0.0', + kbnUrlStateStorage: createKbnUrlStateStorage(), history: createBrowserHistory(), }); } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts index 451e7c8ff96db..987afd65bb67b 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts @@ -19,9 +19,9 @@ import { i18n } from '@kbn/i18n'; import _ from 'lodash'; -import { History } from 'history'; -import { Subscription } from 'rxjs'; +import { Observable, Subscription } from 'rxjs'; import { Moment } from 'moment'; +import { History } from 'history'; import { DashboardContainer } from 'src/legacy/core_plugins/dashboard_embeddable_container/public/np_ready/public'; import { ViewMode } from '../../../../../../plugins/embeddable/public'; @@ -44,7 +44,6 @@ import { SavedDashboardPanel, } from './types'; import { - createKbnUrlStateStorage, createStateContainer, IKbnUrlStateStorage, ISyncStateRef, @@ -76,6 +75,10 @@ export class DashboardStateManager { return this.stateContainer.get(); } + public get appState$(): Observable { + return this.stateContainer.state$; + } + private readonly stateContainer: ReduxLikeStateContainer< DashboardAppState, DashboardAppStateTransitions @@ -97,13 +100,13 @@ export class DashboardStateManager { savedDashboard, hideWriteControls, kibanaVersion, - useHashedUrl, + kbnUrlStateStorage, history, }: { savedDashboard: SavedObjectDashboard; hideWriteControls: boolean; kibanaVersion: string; - useHashedUrl: boolean; + kbnUrlStateStorage: IKbnUrlStateStorage; history: History; }) { this.history = history; @@ -117,7 +120,7 @@ export class DashboardStateManager { kibanaVersion ); - this.kbnUrlStateStorage = createKbnUrlStateStorage({ useHash: useHashedUrl, history }); + this.kbnUrlStateStorage = kbnUrlStateStorage; // setup initial state by merging defaults with state from url // also run migration, as state in url could be of older version diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/global_state_sync.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/global_state_sync.ts deleted file mode 100644 index 1a6c2b09ee3fc..0000000000000 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/global_state_sync.ts +++ /dev/null @@ -1,67 +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 { State } from '../legacy_imports'; -import { DataPublicPluginStart as NpDataStart } from '../../../../../../plugins/data/public'; - -/** - * Helper function to sync the global state with the various state providers - * when a local angular application mounts. There are three different ways - * global state can be passed into the application: - * * parameter in the URL hash - e.g. shared link - * * in-memory state in the data plugin exports (timefilter and filterManager) - e.g. default values - * - * This function looks up the three sources (earlier in the list means it takes precedence), - * puts it into the globalState object and syncs it with the url. - * - * Currently the legacy chrome takes care of restoring the global state when navigating from - * one app to another - to migrate away from that it will become necessary to also write the current - * state to local storage - */ -export function syncOnMount( - globalState: State, - { - query: { - filterManager, - timefilter: { timefilter }, - }, - }: NpDataStart -) { - // pull in global state information from the URL - globalState.fetch(); - // remember whether there were info in the URL - const hasGlobalURLState = Boolean(Object.keys(globalState.toObject()).length); - - // sync kibana platform state with the angular global state - if (!globalState.time) { - globalState.time = timefilter.getTime(); - } - if (!globalState.refreshInterval) { - globalState.refreshInterval = timefilter.getRefreshInterval(); - } - if (!globalState.filters && filterManager.getGlobalFilters().length > 0) { - globalState.filters = filterManager.getGlobalFilters(); - } - // only inject cross app global state if there is none in the url itself (that takes precedence) - if (hasGlobalURLState) { - // set flag the global state is set from the URL - globalState.$inheritedGlobalState = true; - } - globalState.save(); -} 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 7dc408ea4b801..7ba404d52d9a6 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 @@ -22,20 +22,16 @@ import { i18n } from '@kbn/i18n'; import dashboardTemplate from './dashboard_app.html'; import dashboardListingTemplate from './listing/dashboard_listing_ng_wrapper.html'; -import { - ensureDefaultIndexPattern, - registerTimefilterWithGlobalStateFactory, -} from '../legacy_imports'; +import { ensureDefaultIndexPattern } from '../legacy_imports'; import { initDashboardAppDirective } from './dashboard_app'; -import { DashboardConstants, createDashboardEditUrl } from './dashboard_constants'; +import { createDashboardEditUrl, DashboardConstants } from './dashboard_constants'; import { InvalidJSONProperty, SavedObjectNotFound, } from '../../../../../../plugins/kibana_utils/public'; import { DashboardListing, EMPTY_FILTER } from './listing/dashboard_listing'; import { addHelpMenuToAppChrome } from './help_menu/help_menu_util'; -import { syncOnMount } from './global_state_sync'; -import { createHashHistory } from 'history'; +import { syncQuery } from '../../../../../../plugins/data/public'; export function initDashboardApp(app, deps) { initDashboardAppDirective(app, deps); @@ -61,16 +57,9 @@ export function initDashboardApp(app, deps) { addHelpMenuToAppChrome(deps.chrome, deps.core.docLinks); } - app.run(globalState => { - syncOnMount(globalState, deps.npDataStart); - }); - - app.run((globalState, $rootScope) => { - registerTimefilterWithGlobalStateFactory( - deps.npDataStart.query.timefilter.timefilter, - globalState, - $rootScope - ); + app.config(stateManagementConfigProvider => { + // Dashboard state management is handled by state containers and state_sync utilities + stateManagementConfigProvider.disable(); }); app.config(function($routeProvider) { @@ -100,10 +89,15 @@ export function initDashboardApp(app, deps) { template: dashboardListingTemplate, controller($injector, $location, $scope) { const service = deps.savedDashboards; - const kbnUrl = $injector.get('kbnUrl'); const dashboardConfig = deps.dashboardConfig; + // syncs `_g` portion of url with query services + const { stop: stopSyncingGlobalStateWithUrl } = syncQuery( + deps.npDataStart.query, + deps.kbnUrlStateStorage + ); + $scope.listingLimit = deps.uiSettings.get('savedObjects:listingLimit'); $scope.create = () => { kbnUrl.redirect(DashboardConstants.CREATE_NEW_DASHBOARD_URL); @@ -131,6 +125,10 @@ export function initDashboardApp(app, deps) { ]); addHelpMenuToAppChrome(deps.chrome, deps.core.docLinks); $scope.core = deps.core; + + $scope.$on('$destroy', () => { + stopSyncingGlobalStateWithUrl(); + }); }, resolve: { dash: function($rootScope, $route, redirectWhenMissing, kbnUrl) { @@ -218,7 +216,7 @@ export function initDashboardApp(app, deps) { // See https://github.com/elastic/kibana/issues/10951 for more context. if (error instanceof SavedObjectNotFound && id === 'create') { // Note preserve querystring part is necessary so the state is preserved through the redirect. - const history = createHashHistory(); + const history = deps.history; history.replace({ ...history.location, // preserve query, pathname: DashboardConstants.CREATE_NEW_DASHBOARD_URL, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts b/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts index ca4b18a37504c..732fbd525ae37 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts @@ -25,12 +25,13 @@ import { Plugin, SavedObjectsClientContract, } from 'kibana/public'; +import { createHashHistory } from 'history'; import { i18n } from '@kbn/i18n'; import { RenderDeps } from './np_ready/application'; import { DataStart } from '../../../data/public'; import { DataPublicPluginStart as NpDataStart } from '../../../../../plugins/data/public'; import { IEmbeddableStart } from '../../../../../plugins/embeddable/public'; -import { Storage } from '../../../../../plugins/kibana_utils/public'; +import { createKbnUrlStateStorage, Storage } from '../../../../../plugins/kibana_utils/public'; import { NavigationPublicPluginStart as NavigationStart } from '../../../../../plugins/navigation/public'; import { DashboardConstants } from './np_ready/dashboard_constants'; import { @@ -96,6 +97,12 @@ export class DashboardPlugin implements Plugin { overlays: contextCore.overlays, }); + const history = createHashHistory(); + const kbnUrlStateStorage = createKbnUrlStateStorage({ + history, + useHash: core.uiSettings.get('state:storeInSessionStorage'), + }); + const deps: RenderDeps = { core: contextCore as LegacyCoreStart, ...angularDependencies, @@ -111,6 +118,8 @@ export class DashboardPlugin implements Plugin { embeddables, dashboardCapabilities: contextCore.application.capabilities.dashboard, localStorage: new Storage(localStorage), + history, + kbnUrlStateStorage, }; const { renderApp } = await import('./np_ready/application'); return renderApp(params.element, params.appBasePath, deps); diff --git a/src/plugins/dashboard_embeddable_container/public/actions/replace_panel_flyout.tsx b/src/plugins/dashboard_embeddable_container/public/actions/replace_panel_flyout.tsx index 36313353e3c33..7b3842bd33dbd 100644 --- a/src/plugins/dashboard_embeddable_container/public/actions/replace_panel_flyout.tsx +++ b/src/plugins/dashboard_embeddable_container/public/actions/replace_panel_flyout.tsx @@ -76,7 +76,7 @@ export class ReplacePanelFlyout extends React.Component { // add the new view const newObj = await this.props.container.addSavedObjectEmbeddable(type, id); - const finalPanels = this.props.container.getInput().panels; + const finalPanels = _.cloneDeep(this.props.container.getInput().panels); (finalPanels[newObj.id] as DashboardPanelState).gridData.w = nnw; (finalPanels[newObj.id] as DashboardPanelState).gridData.h = nnh; (finalPanels[newObj.id] as DashboardPanelState).gridData.x = nnx; @@ -86,7 +86,7 @@ export class ReplacePanelFlyout extends React.Component { delete finalPanels[this.props.panelToRemove.id]; // apply changes - this.props.container.updateInput(finalPanels); + this.props.container.updateInput({ panels: finalPanels }); this.props.container.reload(); this.showToast(name); diff --git a/src/plugins/data/public/query/filter_manager/filter_manager.test.ts b/src/plugins/data/public/query/filter_manager/filter_manager.test.ts index 3f31749ee71f3..1ade48b3537c8 100644 --- a/src/plugins/data/public/query/filter_manager/filter_manager.test.ts +++ b/src/plugins/data/public/query/filter_manager/filter_manager.test.ts @@ -196,6 +196,34 @@ describe('filter_manager', () => { }).length ).toBe(3); }); + + test('should set app filters and remove any duplicated global filters', async function() { + filterManager.addFilters(readyFilters, true); + const appFilter1 = _.cloneDeep(readyFilters[1]); + const appFilter2 = _.cloneDeep(readyFilters[2]); + + filterManager.setAppFilters([appFilter1, appFilter2]); + + const newGlobalFilters = filterManager.getGlobalFilters(); + const newAppFilters = filterManager.getAppFilters(); + + expect(newGlobalFilters).toHaveLength(1); + expect(newAppFilters).toHaveLength(2); + }); + + test('should set global filters and remove any duplicated app filters', async function() { + filterManager.addFilters(readyFilters, false); + const globalFilter1 = _.cloneDeep(readyFilters[1]); + const globalFilter2 = _.cloneDeep(readyFilters[2]); + + filterManager.setGlobalFilters([globalFilter1, globalFilter2]); + + const newGlobalFilters = filterManager.getGlobalFilters(); + const newAppFilters = filterManager.getAppFilters(); + + expect(newGlobalFilters).toHaveLength(2); + expect(newAppFilters).toHaveLength(1); + }); }); describe('add filters', () => { diff --git a/src/plugins/data/public/query/filter_manager/filter_manager.ts b/src/plugins/data/public/query/filter_manager/filter_manager.ts index 18bb619f6a137..6c5cdbaffce5e 100644 --- a/src/plugins/data/public/query/filter_manager/filter_manager.ts +++ b/src/plugins/data/public/query/filter_manager/filter_manager.ts @@ -22,7 +22,7 @@ import { Subject } from 'rxjs'; import { IUiSettingsClient } from 'src/core/public'; -import { compareFilters, COMPARE_ALL_OPTIONS } from './lib/compare_filters'; +import { COMPARE_ALL_OPTIONS, compareFilters } from './lib/compare_filters'; import { sortFilters } from './lib/sort_filters'; import { mapAndFlattenFilters } from './lib/map_and_flatten_filters'; import { uniqFilters } from './lib/uniq_filters'; @@ -164,6 +164,48 @@ export class FilterManager { this.handleStateUpdate(mergedFilters); } + /** + * Sets new global filters and leaves app filters untouched, + * Removes app filters for which there is a duplicate within new global filters + * @param newGlobalFilters + */ + public setGlobalFilters(newGlobalFilters: esFilters.Filter[]) { + newGlobalFilters = mapAndFlattenFilters(newGlobalFilters); + FilterManager.setFiltersStore(newGlobalFilters, esFilters.FilterStateStore.GLOBAL_STATE, true); + const { appFilters: currentAppFilters } = this.getPartitionedFilters(); + // remove duplicates from current app filters, to make sure global will take precedence + const filteredAppFilters = currentAppFilters.filter( + appFilter => !newGlobalFilters.find(globalFilter => compareFilters(globalFilter, appFilter)) + ); + const newFilters = this.mergeIncomingFilters({ + appFilters: filteredAppFilters, + globalFilters: newGlobalFilters, + }); + + this.handleStateUpdate(newFilters); + } + + /** + * Sets new app filters and leaves global filters untouched, + * Removes app filters for which there is a duplicate within new global filters + * @param newAppFilters + */ + public setAppFilters(newAppFilters: esFilters.Filter[]) { + newAppFilters = mapAndFlattenFilters(newAppFilters); + FilterManager.setFiltersStore(newAppFilters, esFilters.FilterStateStore.APP_STATE, true); + const { globalFilters: currentGlobalFilters } = this.getPartitionedFilters(); + // remove duplicates from current global filters, to make sure app will take precedence + const filteredGlobalFilters = currentGlobalFilters.filter( + globalFilter => !newAppFilters.find(appFilter => compareFilters(appFilter, globalFilter)) + ); + + const newFilters = this.mergeIncomingFilters({ + globalFilters: filteredGlobalFilters, + appFilters: newAppFilters, + }); + this.handleStateUpdate(newFilters); + } + public removeFilter(filter: esFilters.Filter) { const filterIndex = _.findIndex(this.filters, item => { return _.isEqual(item.meta, filter.meta) && _.isEqual(item.query, filter.query); @@ -180,10 +222,15 @@ export class FilterManager { this.setFilters([]); } - public static setFiltersStore(filters: esFilters.Filter[], store: esFilters.FilterStateStore) { + public static setFiltersStore( + filters: esFilters.Filter[], + store: esFilters.FilterStateStore, + shouldOverrideStore = false + ) { _.map(filters, (filter: esFilters.Filter) => { // Override status only for filters that didn't have state in the first place. - if (filter.$state === undefined) { + // or if shouldOverrideStore is explicitly true + if (shouldOverrideStore || filter.$state === undefined) { filter.$state = { store }; } }); diff --git a/src/plugins/data/public/query/filter_manager/index.ts b/src/plugins/data/public/query/filter_manager/index.ts index ce7a479151797..09990adacde45 100644 --- a/src/plugins/data/public/query/filter_manager/index.ts +++ b/src/plugins/data/public/query/filter_manager/index.ts @@ -23,3 +23,4 @@ export { uniqFilters } from './lib/uniq_filters'; export { mapAndFlattenFilters } from './lib/map_and_flatten_filters'; export { onlyDisabledFiltersChanged } from './lib/only_disabled'; export { generateFilters } from './lib/generate_filters'; +export { compareFilters, COMPARE_ALL_OPTIONS } from './lib/compare_filters'; diff --git a/src/plugins/data/public/query/index.tsx b/src/plugins/data/public/query/index.tsx index dd3d11b4ac25c..35e58575d8870 100644 --- a/src/plugins/data/public/query/index.tsx +++ b/src/plugins/data/public/query/index.tsx @@ -24,3 +24,4 @@ export * from './filter_manager'; export * from './timefilter'; export * from './saved_query'; export * from './persisted_log'; +export * from './state_sync'; diff --git a/src/plugins/data/public/query/state_sync/index.ts b/src/plugins/data/public/query/state_sync/index.ts new file mode 100644 index 0000000000000..7eefda0d0aec1 --- /dev/null +++ b/src/plugins/data/public/query/state_sync/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 { syncQuery } from './sync_query'; +export { syncAppFilters } from './sync_app_filters'; 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 new file mode 100644 index 0000000000000..61270ecc09979 --- /dev/null +++ b/src/plugins/data/public/query/state_sync/sync_app_filters.test.ts @@ -0,0 +1,197 @@ +/* + * 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 { esFilters } 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: esFilters.Filter; + let gF2: esFilters.Filter; + let aF1: esFilters.Filter; + let aF2: esFilters.Filter; + + beforeEach(() => { + filterManager = new FilterManager(setupMock.uiSettings); + appState = createStateContainer([] as esFilters.Filter[]); + appStateChangeTriggered = jest.fn(); + appStateSub = appState.state$.subscribe(appStateChangeTriggered); + + filterManagerChangeTriggered = jest.fn(); + filterManagerChangeSub = filterManager.getUpdates$().subscribe(filterManagerChangeTriggered); + + gF1 = getFilter(esFilters.FilterStateStore.GLOBAL_STATE, true, true, 'key1', 'value1'); + gF2 = getFilter(esFilters.FilterStateStore.GLOBAL_STATE, false, false, 'key2', 'value2'); + aF1 = getFilter(esFilters.FilterStateStore.APP_STATE, true, true, 'key3', 'value3'); + aF2 = getFilter(esFilters.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 new file mode 100644 index 0000000000000..7954729cd8665 --- /dev/null +++ b/src/plugins/data/public/query/state_sync/sync_app_filters.ts @@ -0,0 +1,65 @@ +/* + * 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 { esFilters } 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.test.ts b/src/plugins/data/public/query/state_sync/sync_query.test.ts new file mode 100644 index 0000000000000..0973af13cacd5 --- /dev/null +++ b/src/plugins/data/public/query/state_sync/sync_query.test.ts @@ -0,0 +1,166 @@ +/* + * 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 { createBrowserHistory, History } from 'history'; +import { FilterManager } from '../filter_manager'; +import { getFilter } from '../filter_manager/test_helpers/get_stub_filter'; +import { esFilters } from '../../../common'; +import { coreMock } from '../../../../../core/public/mocks'; +import { + createKbnUrlStateStorage, + IKbnUrlStateStorage, + Storage, +} from '../../../../kibana_utils/public'; +import { QueryService, QueryStart } from '../query_service'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; +import { TimefilterContract } from '../timefilter'; +import { QuerySyncState, syncQuery } from './sync_query'; + +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('sync_query', () => { + let queryServiceStart: QueryStart; + let filterManager: FilterManager; + let timefilter: TimefilterContract; + let kbnUrlStateStorage: IKbnUrlStateStorage; + let history: History; + + let filterManagerChangeSub: Subscription; + let filterManagerChangeTriggered = jest.fn(); + + let gF: esFilters.Filter; + let aF: esFilters.Filter; + + const pathWithFilter = + "/#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!t,index:'logstash-*',key:query,negate:!t,type:custom,value:'%7B%22match%22:%7B%22key1%22:%22value1%22%7D%7D'),query:(match:(key1:value1)))),refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))"; + + 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; + + filterManagerChangeTriggered = jest.fn(); + filterManagerChangeSub = filterManager.getUpdates$().subscribe(filterManagerChangeTriggered); + + window.location.href = '/'; + history = createBrowserHistory(); + kbnUrlStateStorage = createKbnUrlStateStorage({ useHash: false, history }); + + gF = getFilter(esFilters.FilterStateStore.GLOBAL_STATE, true, true, 'key1', 'value1'); + aF = getFilter(esFilters.FilterStateStore.APP_STATE, true, true, 'key3', 'value3'); + }); + afterEach(() => { + filterManagerChangeSub.unsubscribe(); + }); + + test('url is actually changed when data in services changes', () => { + const { stop } = syncQuery(queryServiceStart, kbnUrlStateStorage); + filterManager.setFilters([gF, aF]); + kbnUrlStateStorage.flush(); // sync force location change + expect(history.location.hash).toMatchInlineSnapshot( + `"#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!t,index:'logstash-*',key:query,negate:!t,type:custom,value:'%7B%22match%22:%7B%22key1%22:%22value1%22%7D%7D'),query:(match:(key1:value1)))),refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))"` + ); + stop(); + }); + + test('when filters change, global filters synced to urlStorage', () => { + const { stop } = syncQuery(queryServiceStart, kbnUrlStateStorage); + filterManager.setFilters([gF, aF]); + expect(kbnUrlStateStorage.get('_g')?.filters).toHaveLength(1); + stop(); + }); + + test('when time range changes, time synced to urlStorage', () => { + const { stop } = syncQuery(queryServiceStart, kbnUrlStateStorage); + timefilter.setTime({ from: 'now-30m', to: 'now' }); + expect(kbnUrlStateStorage.get('_g')?.time).toEqual({ + from: 'now-30m', + to: 'now', + }); + stop(); + }); + + test('when refresh interval changes, refresh interval is synced to urlStorage', () => { + const { stop } = syncQuery(queryServiceStart, kbnUrlStateStorage); + timefilter.setRefreshInterval({ pause: true, value: 100 }); + expect(kbnUrlStateStorage.get('_g')?.refreshInterval).toEqual({ + pause: true, + value: 100, + }); + stop(); + }); + + test('when url is changed, filters synced back to filterManager', () => { + const { stop } = syncQuery(queryServiceStart, kbnUrlStateStorage); + kbnUrlStateStorage.cancel(); // stop initial syncing pending update + history.push(pathWithFilter); + expect(filterManager.getGlobalFilters()).toHaveLength(1); + stop(); + }); + + test('initial url should be synced with services', () => { + history.push(pathWithFilter); + + const { stop, hasInheritedQueryFromUrl } = syncQuery(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); + filterManagerChangeTriggered.mockClear(); + + history.push(pathWithFilter); + history.push(pathWithFilter); + history.push(pathWithFilter); + + expect(filterManagerChangeTriggered).not.toBeCalled(); + stop(); + }); + + test("if data didn't change, kbnUrlStateStorage.set shouldn't be called", () => { + const { stop } = syncQuery(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(); + }); +}); diff --git a/src/plugins/data/public/query/state_sync/sync_query.ts b/src/plugins/data/public/query/state_sync/sync_query.ts new file mode 100644 index 0000000000000..be641e89f9b76 --- /dev/null +++ b/src/plugins/data/public/query/state_sync/sync_query.ts @@ -0,0 +1,167 @@ +/* + * 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 { esFilters, RefreshInterval, TimeRange } from '../../../common'; +import { QueryStart } from '../query_service'; + +const GLOBAL_STATE_STORAGE_KEY = '_g'; + +export interface QuerySyncState { + time?: TimeRange; + refreshInterval?: RefreshInterval; + filters?: esFilters.Filter[]; +} + +/** + * Helper utility to set up syncing between query services and url's '_g' query param + */ +export const syncQuery = ( + { timefilter: { timefilter }, filterManager }: QueryStart, + urlStateStorage: IKbnUrlStateStorage +) => { + const defaultState: QuerySyncState = { + time: timefilter.getTime(), + refreshInterval: timefilter.getRefreshInterval(), + filters: filterManager.getGlobalFilters(), + }; + + // 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 + ); + + // prepare initial state, whatever was in URL takes precedences over current state in services + const initialState: QuerySyncState = { + ...defaultState, + ...initialStateFromUrl, + }; + + // 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: esFilters.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); + }), + 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 } = 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: () => { + subs.forEach(s => s.unsubscribe()); + stop(); + }, + hasInheritedQueryFromUrl, + }; +};