diff --git a/public/kibana-integrations/kibana-discover.js b/public/kibana-integrations/kibana-discover.js index e1cee46c66..902188448f 100644 --- a/public/kibana-integrations/kibana-discover.js +++ b/public/kibana-integrations/kibana-discover.js @@ -68,7 +68,7 @@ import { } from 'ui/courier'; import { toastNotifications } from 'ui/notify'; import { VisProvider } from 'ui/vis'; -import { FilterBarQueryFilterProvider } from 'ui/filter_manager/query_filter'; +import { FilterBarQueryFilterProvider } from './search-bar/query-filter'; import { vislibSeriesResponseHandlerProvider } from 'ui/vis/response_handlers/vislib'; import { docTitle } from 'ui/doc_title'; import { intervalOptions } from 'ui/agg_types/buckets/_interval_options'; @@ -78,7 +78,7 @@ import uiRoutes from 'ui/routes'; import { StateProvider } from 'ui/state_management/state'; import { migrateLegacyQuery } from 'ui/utils/migrate_legacy_query'; import { subscribeWithScope } from 'ui/utils/subscribe_with_scope'; -import { getFilterGenerator } from 'ui/filter_manager'; +import { getFilterGenerator } from './search-bar/filter-generator'; import { SavedObjectsClientProvider } from 'ui/saved_objects'; import { VisualizeLoaderProvider } from 'ui/visualize/loader/visualize_loader'; import { recentlyAccessed } from 'ui/persisted_log'; diff --git a/public/kibana-integrations/loader/visualize_loader.ts b/public/kibana-integrations/loader/visualize_loader.ts index 4d01813060..277e162c62 100644 --- a/public/kibana-integrations/loader/visualize_loader.ts +++ b/public/kibana-integrations/loader/visualize_loader.ts @@ -26,7 +26,7 @@ // @ts-ignore import chrome from 'ui/chrome'; // @ts-ignore -import { FilterBarQueryFilterProvider } from 'ui/filter_manager/query_filter'; +import { FilterBarQueryFilterProvider } from '../search-bar/query-filter'; // @ts-ignore import { IPrivate } from 'ui/private'; import { EmbeddedVisualizeHandler } from './embedded_visualize_handler'; @@ -150,7 +150,7 @@ export class VisualizeLoader { // lets add Private to the params, we'll need to pass it to visualize later Private: this.Private, }; - + // @ts-ignore return new EmbeddedVisualizeHandler(element, savedObj, handlerParams, this.injector, this.errorHandler); } } diff --git a/public/kibana-integrations/search-bar/filter-generator.tsx b/public/kibana-integrations/search-bar/filter-generator.tsx new file mode 100644 index 0000000000..0734b89640 --- /dev/null +++ b/public/kibana-integrations/search-bar/filter-generator.tsx @@ -0,0 +1,98 @@ +/* + * 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 { getPhraseScript } from '@kbn/es-query'; + +// Adds a filter to a passed state +export function getFilterGenerator(queryFilter) { + const filterGen = {}; + + filterGen.generate = (field, values, operation, index) => { + values = Array.isArray(values) ? values : [values]; + const fieldName = _.isObject(field) ? field.name : field; + const filters = _.flatten([queryFilter.getAppFilters()]); + const newFilters = []; + + const negate = (operation === '-'); + + // TODO: On array fields, negating does not negate the combination, rather all terms + _.each(values, function (value) { + let filter; + const existing = _.find(filters, function (filter) { + if (!filter) return; + + if (fieldName === '_exists_' && filter.exists) { + return filter.exists.field === value; + } + + if (_.has(filter, 'query.match')) { + return filter.query.match[fieldName] && filter.query.match[fieldName].query === value; + } + + if (filter.script) { + return filter.meta.field === fieldName && filter.script.script.params.value === value; + } + }); + + if (existing) { + existing.meta.disabled = false; + if (existing.meta.negate !== negate) { + existing.meta.negate = !existing.meta.negate; + } + newFilters.push(existing); + return; + } + + switch (fieldName) { + case '_exists_': + filter = { + meta: { negate, index }, + exists: { + field: value + } + }; + break; + default: + if (field.scripted) { + filter = { + meta: { negate, index, field: fieldName }, + script: getPhraseScript(field, value) + }; + } else { + filter = { meta: { negate, index }, query: { match: {} } }; + filter.query.match[fieldName] = { query: value, type: 'phrase' }; + } + + break; + } + + newFilters.push(filter); + }); + + return newFilters; + }; + + filterGen.add = function (field, values, operation, index) { + const newFilters = this.generate(field, values, operation, index); + return queryFilter.addFilters(newFilters); + }; + + return filterGen; +} \ No newline at end of file diff --git a/public/kibana-integrations/search-bar/filter-manager.tsx b/public/kibana-integrations/search-bar/filter-manager.tsx new file mode 100644 index 0000000000..4c01b4d251 --- /dev/null +++ b/public/kibana-integrations/search-bar/filter-manager.tsx @@ -0,0 +1,206 @@ +/* + * 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, isFilterPinned, FilterStateStore } from '@kbn/es-query'; + +import _ from 'lodash'; +import { Subject } from 'rxjs'; + +import { npSetup } from 'ui/new_platform'; + +// @ts-ignore +import { compareFilters } from 'plugins/data/filter/filter_manager/lib/compare_filters'; +// @ts-ignore +import { mapAndFlattenFilters } from 'plugins/data/filter/filter_manager/lib/map_and_flatten_filters'; +// @ts-ignore +import { uniqFilters } from 'plugins/data/filter/filter_manager/lib/uniq_filters'; +// @ts-ignore +import { extractTimeFilter } from 'plugins/data/filter/filter_manager/lib/extract_time_filter'; +// @ts-ignore +import { changeTimeFilter } from 'plugins/data/filter/filter_manager/lib/change_time_filter'; + +import { onlyDisabledFiltersChanged } from './only_disabled'; + +import { PartitionedFilters } from 'plugins/data/filter/filter_manager/partitioned_filters'; + +import { IndexPatterns } from 'plugins/data/index_patterns'; + +export class FilterManager { + private indexPatterns: IndexPatterns; + private filters: Filter[] = []; + private updated$: Subject = new Subject(); + private fetch$: Subject = new Subject(); + + constructor(indexPatterns: IndexPatterns) { + this.indexPatterns = indexPatterns; + } + + private mergeIncomingFilters(partitionedFilters: PartitionedFilters): Filter[] { + const globalFilters = partitionedFilters.globalFilters; + const appFilters = partitionedFilters.appFilters; + + // existing globalFilters should be mutated by appFilters + _.each(appFilters, function (filter, i) { + const match = _.find(globalFilters, function (globalFilter) { + return compareFilters(globalFilter, filter); + }); + + // no match, do nothing + if (!match) return; + + // matching filter in globalState, update global and remove from appState + _.assign(match.meta, filter.meta); + appFilters.splice(i, 1); + }); + + return FilterManager.mergeFilters(appFilters, globalFilters); + } + + private static mergeFilters(appFilters: Filter[], globalFilters: Filter[]): Filter[] { + return uniqFilters(appFilters.reverse().concat(globalFilters.reverse())).reverse(); + } + + private static partitionFilters(filters: Filter[]): PartitionedFilters { + const [globalFilters, appFilters] = _.partition(filters, isFilterPinned); + return { + globalFilters, + appFilters, + }; + } + + private handleStateUpdate(newFilters: Filter[]) { + // global filters should always be first + newFilters.sort(({ $state: a }: Filter, { $state: b }: Filter): number => { + return a!.store === FilterStateStore.GLOBAL_STATE && + b!.store !== FilterStateStore.GLOBAL_STATE + ? -1 + : 1; + }); + + const filtersUpdated = !_.isEqual(this.filters, newFilters); + const updatedOnlyDisabledFilters = onlyDisabledFiltersChanged(newFilters, this.filters); + + this.filters = newFilters; + if (filtersUpdated) { + this.updated$.next(); + if (!updatedOnlyDisabledFilters) { + this.fetch$.next(); + } + } + } + + /* Getters */ + + public getFilters() { + return _.cloneDeep(this.filters); + } + + public getAppFilters() { + const { appFilters } = this.getPartitionedFilters(); + return appFilters; + } + + public getGlobalFilters() { + const { globalFilters } = this.getPartitionedFilters(); + return globalFilters; + } + + public getPartitionedFilters(): PartitionedFilters { + return FilterManager.partitionFilters(this.getFilters()); + } + + public getUpdates$() { + return this.updated$.asObservable(); + } + + public getFetches$() { + return this.fetch$.asObservable(); + } + + /* Setters */ + + public async addFilters(filters: Filter[] | Filter, pinFilterStatus?: boolean) { + if (!Array.isArray(filters)) { + filters = [filters]; + } + + if (filters.length === 0) { + return; + } + + const { uiSettings } = npSetup.core; + if (pinFilterStatus === undefined) { + pinFilterStatus = uiSettings.get('filters:pinnedByDefault'); + } + + // Set the store of all filters. For now. + // In the future, all filters should come in with filter state store already set. + const store = pinFilterStatus ? FilterStateStore.GLOBAL_STATE : FilterStateStore.APP_STATE; + FilterManager.setFiltersStore(filters, store); + + const mappedFilters = await mapAndFlattenFilters(this.indexPatterns, filters); + + // This is where we add new filters to the correct place (app \ global) + const newPartitionedFilters = FilterManager.partitionFilters(mappedFilters); + const currentFilters = this.getPartitionedFilters(); + currentFilters.appFilters.push(...newPartitionedFilters.appFilters); + currentFilters.globalFilters.push(...newPartitionedFilters.globalFilters); + + const newFilters = this.mergeIncomingFilters(currentFilters); + this.handleStateUpdate(newFilters); + } + + public async setFilters(newFilters: Filter[]) { + const mappedFilters = await mapAndFlattenFilters(this.indexPatterns, newFilters); + const newPartitionedFilters = FilterManager.partitionFilters(mappedFilters); + const mergedFilters = this.mergeIncomingFilters(newPartitionedFilters); + this.handleStateUpdate(mergedFilters); + } + + public removeFilter(filter: Filter) { + const filterIndex = _.findIndex(this.filters, item => { + return _.isEqual(item.meta, filter.meta) && _.isEqual(item.query, filter.query); + }); + + if (filterIndex >= 0) { + const newFilters = _.cloneDeep(this.filters); + newFilters.splice(filterIndex, 1); + this.handleStateUpdate(newFilters); + } + } + + public async removeAll() { + await this.setFilters([]); + } + + public async addFiltersAndChangeTimeFilter(filters: Filter[]) { + const timeFilter = await extractTimeFilter(this.indexPatterns, filters); + if (timeFilter) changeTimeFilter(timeFilter); + return this.addFilters(filters.filter(filter => filter !== timeFilter)); + } + + public static setFiltersStore(filters: Filter[], store: FilterStateStore) { + _.map(filters, (filter: Filter) => { + // Override status only for filters that didn't have state in the first place. + if (filter.$state === undefined) { + filter.$state = { store }; + } + }); + } +} \ No newline at end of file diff --git a/public/kibana-integrations/search-bar/filter-state-manager.tsx b/public/kibana-integrations/search-bar/filter-state-manager.tsx new file mode 100644 index 0000000000..615b7bd294 --- /dev/null +++ b/public/kibana-integrations/search-bar/filter-state-manager.tsx @@ -0,0 +1,99 @@ +/* + * 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 { FilterStateStore } from '@kbn/es-query'; + +import _ from 'lodash'; +import { State } from 'ui/state_management/state'; +import { FilterManager } from './filter-manager'; + +type GetAppStateFunc = () => State | undefined | null; + +/** + * FilterStateManager is responsible for watching for filter changes + * and syncing with FilterManager, as well as syncing FilterManager changes + * back to the URL. + **/ +export class FilterStateManager { + filterManager: FilterManager; + globalState: State; + getAppState: GetAppStateFunc; + interval: NodeJS.Timeout | undefined; + + constructor(globalState: State, getAppState: GetAppStateFunc, filterManager: FilterManager) { + this.getAppState = getAppState; + this.globalState = globalState; + this.filterManager = filterManager; + + this.watchFilterState(); + + this.filterManager.getUpdates$().subscribe(() => { + this.updateAppState(); + }); + } + + destroy() { + if (this.interval) { + clearInterval(this.interval); + } + } + + private watchFilterState() { + // This is a temporary solution to remove rootscope. + // Moving forward, state should provide observable subscriptions. + this.interval = setInterval(() => { + const appState = this.getAppState(); + const stateUndefined = !appState || !this.globalState; + if (stateUndefined) return; + + const globalFilters = this.globalState.filters || []; + const appFilters = (appState && appState.filters) || []; + + const globalFilterChanged = !_.isEqual(this.filterManager.getGlobalFilters(), globalFilters); + const appFilterChanged = !_.isEqual(this.filterManager.getAppFilters(), appFilters); + const filterStateChanged = globalFilterChanged || appFilterChanged; + + if (!filterStateChanged) return; + + const newGlobalFilters = _.cloneDeep(globalFilters); + const newAppFilters = _.cloneDeep(appFilters); + FilterManager.setFiltersStore(newAppFilters, FilterStateStore.APP_STATE); + FilterManager.setFiltersStore(newGlobalFilters, FilterStateStore.GLOBAL_STATE); + + this.filterManager.setFilters(newGlobalFilters.concat(newAppFilters)); + }, 10); + } + + private saveState() { + const appState = this.getAppState(); + if (appState) appState.save(); + this.globalState.save(); + } + + private updateAppState() { + // Update Angular state before saving State objects (which save it to URL) + const partitionedFilters = this.filterManager.getPartitionedFilters(); + const appState = this.getAppState(); + if (appState) { + appState.filters = partitionedFilters.appFilters; + } + this.globalState.filters = partitionedFilters.globalFilters; + this.saveState(); + } +} \ No newline at end of file diff --git a/public/kibana-integrations/search-bar/only_disabled.tsx b/public/kibana-integrations/search-bar/only_disabled.tsx new file mode 100644 index 0000000000..eb380cbbe5 --- /dev/null +++ b/public/kibana-integrations/search-bar/only_disabled.tsx @@ -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 _ from 'lodash'; +import { Filter } from '@kbn/es-query'; + +const isEnabled = function(filter: Filter) { + return filter && filter.meta && !filter.meta.disabled; +}; +/** + * Checks to see if only disabled filters have been changed + * @returns {bool} Only disabled filters + */ +export function onlyDisabledFiltersChanged(newFilters: Filter[], oldFilters: Filter[]) { + // If it's the same - compare only enabled filters + const newEnabledFilters = _.filter(newFilters, isEnabled); + const oldEnabledFilters = _.filter(oldFilters, isEnabled); + + return _.isEqual(oldEnabledFilters, newEnabledFilters); +} \ No newline at end of file diff --git a/public/kibana-integrations/search-bar/query-filter.ts b/public/kibana-integrations/search-bar/query-filter.ts new file mode 100644 index 0000000000..4b62c7f77b --- /dev/null +++ b/public/kibana-integrations/search-bar/query-filter.ts @@ -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 { FilterStateManager } from './filter-state-manager'; +import { DataPlugin } from './setup'; + +export function FilterBarQueryFilterProvider(getAppState, globalState) { + // TODO: this is imported here to avoid circular imports. + // eslint-disable-next-line @typescript-eslint/no-var-requires + + const data = new DataPlugin().setup(); + const filterManager = data.filter.filterManager; + const filterStateManager = new FilterStateManager(globalState, getAppState, filterManager); + + const queryFilter = {}; + queryFilter.getUpdates$ = filterManager.getUpdates$.bind(filterManager); + queryFilter.getFetches$ = filterManager.getFetches$.bind(filterManager); + queryFilter.getFilters = filterManager.getFilters.bind(filterManager); + queryFilter.getAppFilters = filterManager.getAppFilters.bind(filterManager); + queryFilter.getGlobalFilters = filterManager.getGlobalFilters.bind(filterManager); + queryFilter.removeFilter = filterManager.removeFilter.bind(filterManager); + queryFilter.addFilters = filterManager.addFilters.bind(filterManager); + queryFilter.setFilters = filterManager.setFilters.bind(filterManager); + queryFilter.addFiltersAndChangeTimeFilter = filterManager.addFiltersAndChangeTimeFilter.bind(filterManager); + queryFilter.removeAll = filterManager.removeAll.bind(filterManager); + + queryFilter.destroy = () => { + filterManager.destroy(); + filterStateManager.destroy(); + }; + + return queryFilter; +} \ No newline at end of file diff --git a/public/kibana-integrations/search-bar/setup.tsx b/public/kibana-integrations/search-bar/setup.tsx new file mode 100644 index 0000000000..089de0b58d --- /dev/null +++ b/public/kibana-integrations/search-bar/setup.tsx @@ -0,0 +1,104 @@ +/* + * 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. + */ + +// TODO these are imports from the old plugin world. +// Once the new platform is ready, they can get removed +// and handled by the platform itself in the setup method +// of the ExpressionExectorService +// @ts-ignore +import { renderersRegistry } from 'plugins/interpreter/registries'; +import { ExpressionsService, ExpressionsSetup } from 'plugins/data/expressions'; +import { QueryService, QuerySetup } from 'plugins/data/query'; +import { FilterService, FilterSetup } from 'plugins/data/filter'; +import { IndexPatternsService, IndexPatternsSetup } from 'plugins/data/index_patterns'; + +export class DataPlugin { + // Exposed services, sorted alphabetically + private readonly expressions: ExpressionsService; + private readonly filter: FilterService; + private readonly indexPatterns: IndexPatternsService; + private readonly query: QueryService; + + constructor() { + this.indexPatterns = new IndexPatternsService(); + this.filter = new FilterService(); + this.query = new QueryService(); + this.expressions = new ExpressionsService(); + } + + public setup(): DataSetup { + // TODO: this is imported here to avoid circular imports. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { getInterpreter } = require('plugins/interpreter/interpreter'); + const indexPatternsService = this.indexPatterns.setup(); + return { + expressions: this.expressions.setup({ + interpreter: { + getInterpreter, + renderersRegistry, + }, + }), + indexPatterns: indexPatternsService, + filter: this.filter.setup({ + indexPatterns: indexPatternsService.indexPatterns, + }), + query: this.query.setup(), + }; + } + + public stop() { + this.expressions.stop(); + this.indexPatterns.stop(); + this.filter.stop(); + this.query.stop(); + } +} + +/** @public */ +export interface DataSetup { + expressions: ExpressionsSetup; + indexPatterns: IndexPatternsSetup; + filter: FilterSetup; + query: QuerySetup; +} + +/** @public types */ +export { ExpressionRenderer, ExpressionRendererProps, ExpressionRunner } from 'plugins/data/expressions'; + +/** @public types */ +export { IndexPattern, StaticIndexPattern, StaticIndexPatternField, Field } from 'plugins/data/index_patterns'; +export { Query, QueryBar } from 'plugins/data/query'; +export { FilterBar } from 'plugins/data/filter'; +export { + FilterManager, + FilterStateManager, + uniqFilters, + onlyDisabledFiltersChanged, +} from './filter-manager'; + +/** @public static code */ +export { dateHistogramInterval } from 'plugins/data/filter/../../common/date_histogram_interval'; +/** @public static code */ +export { + isValidEsInterval, + InvalidEsCalendarIntervalError, + InvalidEsIntervalFormatError, + parseEsInterval, + ParsedInterval, +} from 'plugins/data/filter/../../common/parse_es_interval'; \ No newline at end of file