diff --git a/packages/kbn-es-query/README.md b/packages/kbn-es-query/README.md index fc403447877d8..bd9f8389131a7 100644 --- a/packages/kbn-es-query/README.md +++ b/packages/kbn-es-query/README.md @@ -13,7 +13,7 @@ buildEsQuery(indexPattern, queries, filters, config) Generates the Elasticsearch query DSL from combining the queries and filters provided. ```javascript -buildQueryFromFilters(filters, indexPattern) +buildQueryFromFilters(filters, indexPattern, ignoreFilterIfFieldNotInIndex, allowLeadingWildcards, queryStringOptions, dateFormatTZ) ``` Generates the Elasticsearch query DSL from the given filters. @@ -72,6 +72,12 @@ buildRangeFilter(field, params, indexPattern) Creates a filter (`RangeFilter`) where the value for the given field is in the given range. `params` should contain `lt`, `lte`, `gt`, and/or `gte`. +```javascript +buildSavedQueryFilter(savedQueryId) +``` + +Creates a filter (`SavedQueryFilter`) corresponding to a saved query. `params` should be an object containing the saved query. + ## kuery This folder contains the code corresponding to generating Elasticsearch queries using the Kibana query language. diff --git a/packages/kbn-es-query/src/es_query/__tests__/build_es_query.js b/packages/kbn-es-query/src/es_query/__tests__/build_es_query.js index fde3d063caaa6..2581ad851d86a 100644 --- a/packages/kbn-es-query/src/es_query/__tests__/build_es_query.js +++ b/packages/kbn-es-query/src/es_query/__tests__/build_es_query.js @@ -134,6 +134,71 @@ describe('build query', function () { expect(result).to.eql(expectedResult); }); + it('should build an Elasticsearch query from a saved query', function () { + const filters = { + meta: { + alias: null, + disabled: false, + key: 'Bytes more than 2000 onlu', + negate: false, + params: { + savedQuery: { + attributes: { + description: 'no filters at all', + query: { + language: 'kuery', + query: 'bytes >= 2000' + }, + title: 'Bytes more than 2000 only', + }, + id: 'Bytes more than 2000 only', + } + }, + type: 'savedQuery', + value: undefined, + }, + saved_query: 'Bytes more than 2000 only' + }; + const config = { + allowLeadingWildcards: true, + queryStringOptions: {}, + }; + const expectedResult = { + bool: { + must: [], + filter: [ + { + bool: { + must: [], + filter: [ + { + bool: { + should: [ + { + range: { + bytes: { + gte: 2000 + } + } + } + ], + minimum_should_match: 1 + } + } + ], + should: [], + must_not: [] + } + } + ], + should: [], + must_not: [], + } + }; + const result = buildEsQuery(indexPattern, [], [filters], config); + + expect(result).to.eql(expectedResult); + }); }); }); diff --git a/packages/kbn-es-query/src/es_query/__tests__/from_filters.js b/packages/kbn-es-query/src/es_query/__tests__/from_filters.js index 59e5f4d6faf8a..53758e68fbef2 100644 --- a/packages/kbn-es-query/src/es_query/__tests__/from_filters.js +++ b/packages/kbn-es-query/src/es_query/__tests__/from_filters.js @@ -17,8 +17,29 @@ * under the License. */ + +/* + * 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/LICENSE2.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 expect from '@kbn/expect'; -import { buildQueryFromFilters } from '../from_filters'; +import { buildQueryFromFilters, translateToQuery } from '../from_filters'; +import indexPattern from '../../__fixtures__/index_pattern_response.json'; describe('build query', function () { describe('buildQueryFromFilters', function () { @@ -121,5 +142,203 @@ describe('build query', function () { expect(result.filter).to.eql(expectedESQueries); }); + + it('should convert a saved query filter into an ES query', function () { + const savedQueryFilter = { + '$state': { + store: 'appState', + }, + meta: { + alias: null, + disabled: false, + key: 'Bytes more than 2000 onlu', + negate: false, + params: { + savedQuery: { + attributes: { + description: 'no filters at all', + query: { + language: 'kuery', + query: 'bytes >= 2000' + }, + title: 'Bytes more than 2000 only', + }, + id: 'Bytes more than 2000 only', + } + }, + type: 'savedQuery', + value: undefined, + }, + saved_query: 'Bytes more than 2000 only' + }; + const expectedESQueries = [ + { + bool: { + must: [], + filter: [ + { + bool: { + should: [ + { + range: { + bytes: { + gte: 2000 + } + } + } + ], + minimum_should_match: 1 + } + } + ], + should: [], + must_not: [] + } + }]; + const result = buildQueryFromFilters([savedQueryFilter]); + expect(result.filter).to.eql(expectedESQueries); + }); + }); + describe('translateToQuery', function () { + it('should extract the contents of a saved query', function () { + const savedQueryFilter = { + '$state': { + store: 'appState', + }, + meta: { + alias: null, + disabled: false, + key: 'Bytes more than 2000 only', + negate: false, + params: { + savedQuery: { + attributes: { + description: 'no filters at all', + query: { + language: 'kuery', + query: 'bytes >= 2000' + }, + title: 'Bytes more than 2000 only', + }, + id: 'Bytes more than 2000 only', + } + }, + type: 'savedQuery', + value: undefined, + }, + saved_query: 'Bytes more than 2000 only' + }; + const expectedResult = { + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [{ range: { bytes: { gte: 2000 } } }], + } + } + ], + must: [], + must_not: [], + should: [], + } + }; + const result = translateToQuery(savedQueryFilter, { indexPattern }); + expect(result).to.eql(expectedResult); + }); + + it('should extract and translate saved query filters that contain saved query filters', function () { + const savedQueryFilter = { + '$state': { + store: 'appState', + }, + meta: { + alias: null, + disabled: false, + key: 'Compound', + negate: false, + params: { + savedQuery: { + attributes: { + description: 'Compound saved query', + filters: [ + { + '$state': { + store: 'appState', + }, + meta: { + alias: null, + disabled: false, + key: 'Ok response', + negate: false, + params: { + savedQuery: { + attributes: { + description: 'saved query', + query: { + language: 'kuery', + query: 'response.keyword: 200' + }, + title: 'Ok response', + }, + id: 'Ok response', + } + }, + type: 'savedQuery', + value: undefined, + }, + saved_query: 'Ok response' + }], + query: { + language: 'kuery', + query: '' + }, + title: 'Compound', + }, + id: 'Compound', + } + }, + type: 'savedQuery', + value: undefined, + }, + saved_query: 'Compound' + }; + const expectedResult = { + bool: { + filter: [ + { + match_all: {} + }, + { + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'response.keyword': 200 + } + } + ] + } + } + ], + must: [], + must_not: [], + should: [], + } + } + ], + must: [], + must_not: [], + should: [], + } + }; + const result = translateToQuery(savedQueryFilter, { indexPattern }); + expect(result).to.eql(expectedResult); + }); }); }); + diff --git a/packages/kbn-es-query/src/es_query/build_es_query.js b/packages/kbn-es-query/src/es_query/build_es_query.js index d17147761d8bc..3c00487c71fa4 100644 --- a/packages/kbn-es-query/src/es_query/build_es_query.js +++ b/packages/kbn-es-query/src/es_query/build_es_query.js @@ -29,6 +29,7 @@ import { buildQueryFromLucene } from './from_lucene'; * @param config - an objects with query:allowLeadingWildcards and query:queryString:options UI * settings in form of { allowLeadingWildcards, queryStringOptions } * config contains dateformat:tz + * @param allSavedQueries - an array of saved queries from which filters are built. Used in buildQueryFromFilters */ export function buildEsQuery( indexPattern, @@ -47,7 +48,13 @@ export function buildEsQuery( const queriesByLanguage = groupBy(validQueries, 'language'); const kueryQuery = buildQueryFromKuery(indexPattern, queriesByLanguage.kuery, config.allowLeadingWildcards, config.dateFormatTZ); const luceneQuery = buildQueryFromLucene(queriesByLanguage.lucene, config.queryStringOptions, config.dateFormatTZ); - const filterQuery = buildQueryFromFilters(filters, indexPattern, config.ignoreFilterIfFieldNotInIndex); + const filterQuery = buildQueryFromFilters( + filters, + indexPattern, + config.ignoreFilterIfFieldNotInIndex, + config.allowLeadingWildcards, + config.queryStringOptions, + config.dateFormatTZ); return { bool: { diff --git a/packages/kbn-es-query/src/es_query/from_filters.js b/packages/kbn-es-query/src/es_query/from_filters.js index b8193b7469a20..b23e68a36f846 100644 --- a/packages/kbn-es-query/src/es_query/from_filters.js +++ b/packages/kbn-es-query/src/es_query/from_filters.js @@ -18,8 +18,50 @@ */ import _ from 'lodash'; +import dateMath from '@elastic/datemath'; import { migrateFilter } from './migrate_filter'; import { filterMatchesIndex } from './filter_matches_index'; +import { buildEsQuery } from './build_es_query'; + + +const calculateBounds = function (timeRange) { + return { + min: dateMath.parse(timeRange.from), + max: dateMath.parse(timeRange.to, { roundUp: true }) + }; +}; + +/** + * Translate a saved query timefilter into a query + * @param {Object} indexPattern - The indexPattern from which to extract the time filter + * @param {Object} timeRange - The saved query time range bounds + * @return {Object} the query version of that filter + */ +const getEsTimeFilter = function (indexPattern, timeRange) { + if (!indexPattern) { + return; + } + const timefield = indexPattern.fields.find( + field => field.name === indexPattern.timeFieldName + ); + if (!timefield) { + return; + } + const bounds = calculateBounds(timeRange); + if (!bounds) { + return; + } + const filter = { + range: { [timefield.name]: { format: 'strict_date_optional_time' } }, + }; + if (bounds.min) { + filter.range[timefield.name].gte = bounds.min.toISOString(); + } + if (bounds.max) { + filter.range[timefield.name].lte = bounds.max.toISOString(); + } + return filter; +}; /** * Create a filter that can be reversed for filters with negate set @@ -34,18 +76,66 @@ const filterNegate = function (reverse) { return filter.meta && filter.meta.negate === reverse; }; }; +/** + * Check if a filter is a saved query + * @param {Object} filter The filter we're checking the type of + * @returns {boolean} + */ +const isSavedQueryFilter = function (filter) { + return filter.meta && filter.meta.type && filter.meta.type === 'savedQuery'; +}; /** * Translate a filter into a query to support es 5+ * @param {Object} filter - The filter to translate * @return {Object} the query version of that filter */ -const translateToQuery = function (filter) { +export const translateToQuery = function (filter, { + indexPattern, + allowLeadingWildcards = true, + queryStringOptions = {}, + dateFormatTZ = null, + ignoreFilterIfFieldNotInIndex = false, +}) { if (!filter) return; if (filter.query) { + // we have dsl so we simply return it return filter.query; } + if (isSavedQueryFilter(filter)) { + // generate raw dsl from the savedQuery filter + // Maybe TODO: move to an extractSavedQuery function + const savedQuery = filter.meta.params.savedQuery; + const query = _.get(savedQuery, 'attributes.query'); + let filters = _.get(savedQuery, 'attributes.filters', []); + // at this point we need to check if the filters themselves are saved queries and extract the contents if they are (we're recursively extracting and translating each filter) + filters = filters.map((filter) => { + if (isSavedQueryFilter(filter)) { + return translateToQuery( + filter, + { indexPattern, allowLeadingWildcards, queryStringOptions, dateFormatTZ, ignoreFilterIfFieldNotInIndex }, + ); + } else { + return filter; + } + }); + // timefilter addition + let timefilter = _.get(savedQuery, 'attributes.timefilter'); + if (timefilter) { + const timeRange = { from: timefilter.from, to: timefilter.to }; + timefilter = getEsTimeFilter(indexPattern, timeRange); + filters = [...filters, timefilter]; + } + const convertedQuery = buildEsQuery( + indexPattern, + [query], + filters, + { allowLeadingWildcards, queryStringOptions, dateFormatTZ, ignoreFilterIfFieldNotInIndex }, + ); + + filter = { ...convertedQuery }; + } return filter; }; @@ -56,16 +146,27 @@ const translateToQuery = function (filter) { * @returns {object} */ const cleanFilter = function (filter) { + if (filter.meta && filter.meta.type && filter.meta.type === 'savedQuery') { + return _.omit(filter, ['meta', '$state', 'saved_query']); + } return _.omit(filter, ['meta', '$state']); }; -export function buildQueryFromFilters(filters = [], indexPattern, ignoreFilterIfFieldNotInIndex) { +export function buildQueryFromFilters( + filters = [], + indexPattern, + ignoreFilterIfFieldNotInIndex, + allowLeadingWildcards, + queryStringOptions, + dateFormatTZ) { return { must: [], filter: filters .filter(filterNegate(false)) .filter(filter => !ignoreFilterIfFieldNotInIndex || filterMatchesIndex(filter, indexPattern)) - .map(translateToQuery) + .map((filter) => translateToQuery( + filter, + { indexPattern, allowLeadingWildcards, queryStringOptions, dateFormatTZ, ignoreFilterIfFieldNotInIndex })) .map(cleanFilter) .map(filter => { return migrateFilter(filter, indexPattern); @@ -74,10 +175,13 @@ export function buildQueryFromFilters(filters = [], indexPattern, ignoreFilterIf must_not: filters .filter(filterNegate(true)) .filter(filter => !ignoreFilterIfFieldNotInIndex || filterMatchesIndex(filter, indexPattern)) - .map(translateToQuery) + .map((filter) => translateToQuery( + filter, + { indexPattern, allowLeadingWildcards, queryStringOptions, dateFormatTZ, ignoreFilterIfFieldNotInIndex })) .map(cleanFilter) .map(filter => { return migrateFilter(filter, indexPattern); }), }; } + diff --git a/packages/kbn-es-query/src/filters/__tests__/saved_query.js b/packages/kbn-es-query/src/filters/__tests__/saved_query.js new file mode 100644 index 0000000000000..3bb9101f80c4b --- /dev/null +++ b/packages/kbn-es-query/src/filters/__tests__/saved_query.js @@ -0,0 +1,80 @@ +/* + * 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 { cloneDeep } from 'lodash'; +import expect from '@kbn/expect'; +import { FilterStateStore } from '@kbn/es-query'; +import { buildSavedQueryFilter } from '../saved_query'; +import filterSkeleton from '../../__fixtures__/filter_skeleton'; + +let expected; +describe('Filter Manager', function () { + describe('Saved query filter builder', function () { + beforeEach(() => { + expected = cloneDeep(filterSkeleton); + }); + + it('should be a function', function () { + expect(buildSavedQueryFilter).to.be.a(Function); + }); + + it('should return a saved query filter when passed a saved query id', function () { + const savedQueryTestItem = { + id: 'foo', + attributes: { + title: 'foo', + description: 'bar', + query: { + language: 'kuery', + query: 'response:200', + }, + filters: [ + { + query: { + match_phrase: { + 'extension.keyword': { + 'query': 'css' + } + } + }, + $state: { store: FilterStateStore.APP_STATE }, + meta: { + disabled: false, + negate: false, + alias: null, + }, + }, + ], + timefilter: { + range: { + timestamp: { + gte: '1940-02-01T00:00:00.000Z', + lte: '2000-02-01T00:00:00.000Z', + format: 'strict_date_optional_time', + } + }, + } + } + }; + expected = { meta: { type: 'savedQuery', key: 'foo', params: { savedQuery: { ...savedQueryTestItem } } }, saved_query: 'foo' }; + const actual = buildSavedQueryFilter(savedQueryTestItem); + expect(actual).to.eql(expected); + }); + }); +}); diff --git a/packages/kbn-es-query/src/filters/index.d.ts b/packages/kbn-es-query/src/filters/index.d.ts index c05e32dbf07b9..2a8fc5d945727 100644 --- a/packages/kbn-es-query/src/filters/index.d.ts +++ b/packages/kbn-es-query/src/filters/index.d.ts @@ -17,8 +17,16 @@ * under the License. */ -import { CustomFilter, ExistsFilter, PhraseFilter, PhrasesFilter, RangeFilter } from './lib'; +import { + CustomFilter, + ExistsFilter, + PhraseFilter, + PhrasesFilter, + RangeFilter, + SavedQueryFilter, +} from './lib'; import { RangeFilterParams } from './lib/range_filter'; +import { SavedQuery } from './lib/saved_query_filter'; export * from './lib'; @@ -49,3 +57,5 @@ export function buildRangeFilter( indexPattern: IndexPattern, formattedValue?: string ): RangeFilter; + +export function buildSavedQueryFilter(savedQuery: SavedQuery): SavedQueryFilter; diff --git a/packages/kbn-es-query/src/filters/index.js b/packages/kbn-es-query/src/filters/index.js index d7d092eabd8a2..e888b7532991a 100644 --- a/packages/kbn-es-query/src/filters/index.js +++ b/packages/kbn-es-query/src/filters/index.js @@ -22,4 +22,5 @@ export * from './phrase'; export * from './phrases'; export * from './query'; export * from './range'; +export * from './saved_query'; export * from './lib'; diff --git a/packages/kbn-es-query/src/filters/lib/index.ts b/packages/kbn-es-query/src/filters/lib/index.ts index d4948677a48af..519bd1a9b1f31 100644 --- a/packages/kbn-es-query/src/filters/lib/index.ts +++ b/packages/kbn-es-query/src/filters/lib/index.ts @@ -36,6 +36,7 @@ import { } from './range_filter'; import { MatchAllFilter, isMatchAllFilter } from './match_all_filter'; import { MissingFilter, isMissingFilter } from './missing_filter'; +import { SavedQueryFilter, SavedQueryFilterMeta, isSavedQueryFilter } from './saved_query_filter'; export { CustomFilter, @@ -60,6 +61,9 @@ export { isMatchAllFilter, MissingFilter, isMissingFilter, + SavedQueryFilter, + SavedQueryFilterMeta, + isSavedQueryFilter, }; // Any filter associated with a field (used in the filter bar/editor) @@ -84,4 +88,5 @@ export enum FILTERS { RANGE = 'range', GEO_BOUNDING_BOX = 'geo_bounding_box', GEO_POLYGON = 'geo_polygon', + SAVED_QUERY = 'savedQuery', } diff --git a/packages/kbn-es-query/src/filters/lib/meta_filter.ts b/packages/kbn-es-query/src/filters/lib/meta_filter.ts index 8f6aef782cea2..ac17c1d17c553 100644 --- a/packages/kbn-es-query/src/filters/lib/meta_filter.ts +++ b/packages/kbn-es-query/src/filters/lib/meta_filter.ts @@ -48,6 +48,7 @@ export interface Filter { $state?: FilterState; meta: FilterMeta; query?: any; + saved_query?: string; } export interface LatLon { diff --git a/packages/kbn-es-query/src/filters/lib/saved_query_filter.ts b/packages/kbn-es-query/src/filters/lib/saved_query_filter.ts new file mode 100644 index 0000000000000..72853129c5d17 --- /dev/null +++ b/packages/kbn-es-query/src/filters/lib/saved_query_filter.ts @@ -0,0 +1,64 @@ +/* + * 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, FilterMeta } from './meta_filter'; + +interface TimeRange { + from: string; + to: string; +} + +interface RefreshInterval { + pause: boolean; + value: number; +} +export interface Query { + query: string | { [key: string]: any }; + language: string; +} + +type SavedQueryTimeFilter = TimeRange & { + refreshInterval: RefreshInterval; +}; +interface SavedQueryAttributes { + title: string; + description: string; + query: Query; + filters?: Filter[]; + timefilter?: SavedQueryTimeFilter; +} +export interface SavedQuery { + id: string; + attributes: SavedQueryAttributes; +} + +interface SavedQueryParams { + savedQuery: SavedQuery; +} + +export type SavedQueryFilterMeta = FilterMeta & { + params?: SavedQueryParams; // the full saved query +}; + +export type SavedQueryFilter = Filter & { + meta: SavedQueryFilterMeta; +}; + +export const isSavedQueryFilter = (filter: any): filter is SavedQueryFilter => + filter && filter.meta && filter.meta.type === 'savedQuery'; diff --git a/packages/kbn-es-query/src/filters/saved_query.js b/packages/kbn-es-query/src/filters/saved_query.js new file mode 100644 index 0000000000000..b3ac5d6c02bb2 --- /dev/null +++ b/packages/kbn-es-query/src/filters/saved_query.js @@ -0,0 +1,33 @@ +/* + * 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. + */ + +// Creates an filter from a saved query +// dsl is created at query time +export function buildSavedQueryFilter(savedQuery) { + const filter = { + meta: { + type: 'savedQuery', + key: savedQuery.id, + params: { savedQuery } + }, + saved_query: savedQuery.id, + }; + return filter; +} + diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/_index.scss b/src/legacy/core_plugins/data/public/filter/filter_bar/_index.scss index 3c57b7fe2ca3a..cf9f9635f5716 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_bar/_index.scss +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/_index.scss @@ -1,2 +1,3 @@ @import 'global_filter_group'; @import 'global_filter_item'; +@import './filter_editor/saved_query_filter'; diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_bar.tsx b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_bar.tsx index 066adb1e3275e..caed3cb0f7961 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_bar.tsx +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_bar.tsx @@ -33,10 +33,12 @@ import classNames from 'classnames'; import React, { useState } from 'react'; import { CoreStart } from 'src/core/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { TimeHistoryContract } from 'ui/timefilter'; import { IndexPattern } from '../../index_patterns'; import { FilterEditor } from './filter_editor'; import { FilterItem } from './filter_item'; import { FilterOptions } from './filter_options'; +import { SavedQueryService } from '../../search/search_bar/lib/saved_query_service'; import { useKibana, KibanaContextProvider } from '../../../../../../plugins/kibana_react/public'; interface Props { @@ -45,11 +47,14 @@ interface Props { className: string; indexPatterns: IndexPattern[]; intl: InjectedIntl; - + savedObjects?: CoreStart['savedObjects']; + showSaveQuery?: boolean; + savedQueryService?: SavedQueryService; // TODO: Only for filter-bar directive! uiSettings?: CoreStart['uiSettings']; docLinks?: CoreStart['docLinks']; pluginDataStart?: DataPublicPluginStart; + timeHistory?: TimeHistoryContract; } function FilterBarUI(props: Props) { @@ -103,6 +108,9 @@ function FilterBarUI(props: Props) { onRemove={() => onRemove(i)} indexPatterns={props.indexPatterns} uiSettings={uiSettings!} + savedQueryService={props.savedQueryService!} + showSaveQuery={props.showSaveQuery} + timeHistory={props.timeHistory!} /> )); @@ -141,13 +149,17 @@ function FilterBarUI(props: Props) { ownFocus={true} > -
+
setIsAddFilterPopoverOpen(false)} key={JSON.stringify(newFilter)} + uiSettings={uiSettings!} + savedQueryService={props.savedQueryService!} + showSaveQuery={props.showSaveQuery} + timeHistory={props.timeHistory!} />
diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/_index.scss b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/_index.scss new file mode 100644 index 0000000000000..5bc2dddb1bc03 --- /dev/null +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/_index.scss @@ -0,0 +1 @@ +@import 'saved_query_filter'; diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/_saved_query_filter.scss b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/_saved_query_filter.scss new file mode 100644 index 0000000000000..3ad244f48ff0c --- /dev/null +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/_saved_query_filter.scss @@ -0,0 +1,9 @@ +@import '@elastic/eui/src/components/form/variables'; + +.kbnSavedQueryFilterEditor__text { + padding: $euiSizeM $euiSizeM ($euiSizeM / 2) $euiSizeM; +} +.savedQueryFilterEditor { + width: calc(95vw - #{$euiSizeXXL * 11}); + overflow-y: visible; +} diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/index.tsx b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/index.tsx index 5b295a759d694..ec0b79a774494 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/index.tsx +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/index.tsx @@ -17,6 +17,7 @@ * under the License. */ +// TODO: won't have anything besides import { EuiButton, EuiButtonEmpty, @@ -30,13 +31,18 @@ import { EuiPopoverTitle, EuiSpacer, EuiSwitch, + EuiTabs, + EuiTab, } from '@elastic/eui'; import { FieldFilter, Filter } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; import { get } from 'lodash'; import React, { Component } from 'react'; +import { UiSettingsClientContract } from 'kibana/public'; +import { TimeHistoryContract } from 'ui/timefilter'; import { Field, IndexPattern } from '../../../index_patterns'; +import { SavedQuery } from '../../../search/search_bar/index'; import { GenericComboBox, GenericComboBoxProps } from './generic_combo_box'; import { buildCustomFilter, @@ -49,11 +55,16 @@ import { getOperatorOptions, getQueryDslFromFilter, isFilterValid, + buildFilterFromSavedQuery, + getSavedQueryFromFilter, } from './lib/filter_editor_utils'; import { Operator } from './lib/filter_operators'; import { PhraseValueInput } from './phrase_value_input'; import { PhrasesValuesInput } from './phrases_values_input'; import { RangeValueInput } from './range_value_input'; +import { SavedQueryService } from '../../../search/search_bar/lib/saved_query_service'; +import { SavedQueryOption, SavedQueryPicker } from './saved_query_picker'; +import { SearchBarEditor } from './search_bar_editor'; interface Props { filter: Filter; @@ -61,6 +72,10 @@ interface Props { onSubmit: (filter: Filter) => void; onCancel: () => void; intl: InjectedIntl; + uiSettings: UiSettingsClientContract; + savedQueryService: SavedQueryService; + showSaveQuery?: boolean; + timeHistory?: TimeHistoryContract; } interface State { @@ -72,6 +87,10 @@ interface State { customLabel: string | null; queryDsl: string; isCustomEditorOpen: boolean; + isSavedQueryEditorOpen: boolean; + isRegularEditorOpen: boolean; + isNewFilter: boolean; + selectedSavedQuery?: SavedQuery[]; } class FilterEditorUI extends Component { @@ -86,6 +105,10 @@ class FilterEditorUI extends Component { customLabel: props.filter.meta.alias, queryDsl: JSON.stringify(getQueryDslFromFilter(props.filter), null, 2), isCustomEditorOpen: this.isUnknownFilterType(), + isSavedQueryEditorOpen: this.isSavedQueryFilterType(), + isRegularEditorOpen: this.isRegularFilterType(), + isNewFilter: !props.filter.meta.type, + selectedSavedQuery: this.getSavedQueryFromFilter(), }; } @@ -95,34 +118,112 @@ class FilterEditorUI extends Component { - + {/* Toggles between add and edit depending on if we have a filter or not */} + {this.state.isNewFilter ? ( + + ) : ( + + )} - - - {this.state.isCustomEditorOpen ? ( + + + +
+ + + + {this.state.isNewFilter ? ( ) : ( )} - - - - - -
- - {this.renderIndexPatternInput()} + + {this.props.showSaveQuery && ( + + {this.state.isNewFilter ? ( + + ) : ( + + )} + + )} + + + + {/* only render this is the saved Query editor is not open */} + {!this.state.isSavedQueryEditorOpen && ( + + {this.state.isCustomEditorOpen ? ( + this.state.isNewFilter ? ( + + ) : ( + + ) + ) : this.state.isNewFilter ? ( + + ) : ( + + )} + + )} + + + {/* only show the index pattern if we're not working with a saved query filter */} + {!this.state.isSavedQueryEditorOpen && this.renderIndexPatternInput()} - {this.state.isCustomEditorOpen ? this.renderCustomEditor() : this.renderRegularEditor()} + {this.renderEditor()} @@ -154,40 +255,50 @@ class FilterEditorUI extends Component { )} - - - - - - - - - - - - - - + {!this.state.isSavedQueryEditorOpen && ( + + + + + + + + + + + + + + )}
); } + private renderEditor() { + if (this.state.isCustomEditorOpen) { + return this.renderCustomEditor(); + } else if (this.state.isSavedQueryEditorOpen) { + return this.renderSavedQueryEditor(); + } else { + return this.renderRegularEditor(); + } + } private renderIndexPatternInput() { if ( @@ -363,16 +474,89 @@ class FilterEditorUI extends Component { } } + public onSavedQuerySelected = (selectedSavedQuery: SavedQuery[]) => { + const params = + get(this.state.selectedSavedQuery && this.state.selectedSavedQuery[0], 'id') === + get(selectedSavedQuery[0], 'id') + ? this.state.params + : selectedSavedQuery[0]; + this.setState(state => ({ ...state, selectedSavedQuery, params })); + }; + + // renders a custom instantiation of the search bar + private renderSavedQueryEditor() { + return ( + + + {/* */} + + + + ); + } + private validateQueryDataAndSubmit = (queryBarData: any) => { + if (queryBarData) this.setState({ params: { ...queryBarData } }); + }; + private toggleCustomEditor = () => { const isCustomEditorOpen = !this.state.isCustomEditorOpen; this.setState({ isCustomEditorOpen }); }; + private toggleRegularEditor = () => { + // close the saved query editor if it is open + if (this.state.isSavedQueryEditorOpen) { + this.toggleSavedQueryEditor(); + } + if (this.state.isCustomEditorOpen) { + this.toggleCustomEditor(); + } + const isRegularEditorOpen = !this.state.isRegularEditorOpen; + this.setState({ isRegularEditorOpen }); + }; + + private toggleSavedQueryEditor = () => { + // close the custom query editor if it is open + if (this.state.isCustomEditorOpen) { + this.toggleCustomEditor(); + } + if (this.state.isRegularEditorOpen) { + this.toggleRegularEditor(); + } + const isSavedQueryEditorOpen = !this.state.isSavedQueryEditorOpen; + this.setState({ isSavedQueryEditorOpen }); + }; + private isUnknownFilterType() { const { type } = this.props.filter.meta; - return !!type && !['phrase', 'phrases', 'range', 'exists'].includes(type); + return !!type && !['phrase', 'phrases', 'range', 'exists', 'savedQuery'].includes(type); } + private isSavedQueryFilterType = () => { + const { type } = this.props.filter.meta; + return !!type && ['savedQuery'].includes(type); + }; + + private isRegularFilterType = () => { + const { type } = this.props.filter.meta; + return !!type && ['phrase', 'phrases', 'range', 'exists'].includes(type); + }; + private getIndexPatternFromFilter() { return getIndexPatternFromFilter(this.props.filter, this.props.indexPatterns); } @@ -386,13 +570,24 @@ class FilterEditorUI extends Component { return getOperatorFromFilter(this.props.filter); } + private getSavedQueryFromFilter() { + if (this.isSavedQueryFilterType()) { + const savedQueryfilter = { ...this.props.filter }; + if (savedQueryfilter && savedQueryfilter.meta.params) { + return getSavedQueryFromFilter(savedQueryfilter); + } + } + } + private isFilterValid() { const { isCustomEditorOpen, + isSavedQueryEditorOpen, queryDsl, selectedIndexPattern: indexPattern, selectedField: field, selectedOperator: operator, + selectedSavedQuery, params, } = this.state; @@ -402,6 +597,8 @@ class FilterEditorUI extends Component { } catch (e) { return false; } + } else if (isSavedQueryEditorOpen) { + return selectedSavedQuery && selectedSavedQuery[0].id === params.id; } return isFilterValid(indexPattern, field, operator, params); @@ -444,6 +641,20 @@ class FilterEditorUI extends Component { this.setState({ params }); }; + private onSavedQueryChange = (selectedSavedQuery: SavedQuery[], savedQueries: SavedQuery[]) => { + // Reset selectedOperator and field when the operator type changes + // set the selected saved query to the params? + // the conditional checks to see if a different one has been selected and that it has a type. + // TODO: change the params into a shape of { savedQuery: {...selectedSavedQuery}} + const params = + get(this.state.selectedSavedQuery && this.state.selectedSavedQuery[0], 'id') === + get(selectedSavedQuery[0], 'id') + ? this.state.params + : selectedSavedQuery[0]; + + this.setState(state => ({ ...state, selectedSavedQuery, params })); + }; + private onQueryDslChange = (queryDsl: string) => { this.setState({ queryDsl }); }; @@ -457,7 +668,9 @@ class FilterEditorUI extends Component { useCustomLabel, customLabel, isCustomEditorOpen, + isSavedQueryEditorOpen, queryDsl, + selectedSavedQuery, } = this.state; const { $state } = this.props.filter; @@ -483,6 +696,14 @@ class FilterEditorUI extends Component { $state.store ); this.props.onSubmit(filter); + } else if (isSavedQueryEditorOpen && selectedSavedQuery) { + const filter = buildFilterFromSavedQuery( + this.props.filter.meta.disabled, + params, + alias, + $state.store + ); + this.props.onSubmit(filter); } }; } diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.test.ts b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.test.ts index 734c5d00e58d5..fdfbd8787f5c1 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.test.ts @@ -30,6 +30,8 @@ import { getOperatorOptions, getQueryDslFromFilter, isFilterValid, + buildFilterFromSavedQuery, + getSavedQueryFromFilter, } from './filter_editor_utils'; import { doesNotExistOperator, @@ -366,4 +368,36 @@ describe('Filter editor utils', () => { } }); }); + + describe('buildFilterFromSavedQuery', () => { + it('should build saved query filters', () => { + const state = FilterStateStore.APP_STATE; + const disabled = false; + const params = { + id: 'foo', + attributes: { title: 'foo', description: '', query: { language: 'kuery', query: 'bar' } }, + }; + const filter = buildFilterFromSavedQuery(disabled, params, '', state); + expect(filter.meta.type).toBe('savedQuery'); + }); + }); + + describe('getSavedQueryFromFilter', () => { + it('should get a saved query from the filter params', () => { + const state = FilterStateStore.APP_STATE; + const disabled = false; + const params = { + id: 'foo', + attributes: { title: 'foo', description: '', query: { language: 'kuery', query: 'bar' } }, + }; + const filter = buildFilterFromSavedQuery(disabled, params, '', state); + const result = getSavedQueryFromFilter(filter); + expect(result[0]).toHaveProperty('id', 'foo'); + expect(result[0]).toHaveProperty('attributes', { + title: 'foo', + description: '', + query: { language: 'kuery', query: 'bar' }, + }); + }); + }); }); diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.ts b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.ts index c6bd435708782..fd3fc27e758a6 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.ts @@ -23,6 +23,7 @@ import { buildPhraseFilter, buildPhrasesFilter, buildRangeFilter, + buildSavedQueryFilter, FieldFilter, Filter, FilterMeta, @@ -30,11 +31,13 @@ import { PhraseFilter, PhrasesFilter, RangeFilter, + SavedQueryFilter, } from '@kbn/es-query'; import { omit, get } from 'lodash'; import { Ipv4Address } from '../../../../../../../../plugins/kibana_utils/public'; import { Field, IndexPattern, isFilterable } from '../../../../index_patterns'; import { FILTER_OPERATORS, Operator } from './filter_operators'; +import { SavedQuery } from '../../../../search'; export function getIndexPatternFromFilter( filter: Filter, @@ -74,6 +77,10 @@ export function getOperatorFromFilter(filter: Filter) { }); } +export function getSavedQueryFromFilter(filter: SavedQueryFilter): SavedQuery[] { + return [filter.meta.params]; +} + export function getQueryDslFromFilter(filter: Filter) { return omit(filter, ['$state', 'meta']); } @@ -99,6 +106,8 @@ export function getFilterParams(filter: Filter) { from: (filter as RangeFilter).meta.params.gte, to: (filter as RangeFilter).meta.params.lt, }; + case 'savedQuery': + return (filter as SavedQueryFilter).meta.params; } } @@ -198,3 +207,16 @@ export function buildCustomFilter( filter.$state = { store }; return filter; } + +export function buildFilterFromSavedQuery( + disabled: boolean, + params: SavedQuery, + alias: string | null, + store: FilterStateStore +): Filter { + const filter = buildSavedQueryFilter(params); + filter.meta.disabled = disabled; + filter.meta.alias = alias; + filter.$state = { store }; + return filter; +} diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/get_filter_display_text.ts b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/get_filter_display_text.ts index 73ee1a69a2ce3..647a505e6261d 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/get_filter_display_text.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/get_filter_display_text.ts @@ -47,6 +47,8 @@ export function getFilterDisplayText(filter: Filter, filterDisplayName: string) return `${prefix}${filterDisplayName}`; case 'range': return `${prefix}${filter.meta.key}: ${filterDisplayName}`; + case 'savedQuery': + return `${prefix}#${filter.meta.key}`; default: return `${prefix}${JSON.stringify(filter.query)}`; } diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/saved_query_picker.tsx b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/saved_query_picker.tsx new file mode 100644 index 0000000000000..5e64091ee6fa9 --- /dev/null +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/saved_query_picker.tsx @@ -0,0 +1,128 @@ +/* + * 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, { Fragment, useState, useEffect, FunctionComponent } from 'react'; +import { EuiSelectable, EuiLoadingContent, EuiText, EuiSpacer } from '@elastic/eui'; + +import { sortBy } from 'lodash'; +import { i18n } from '@kbn/i18n'; +// Types +import { SavedQueryService } from '../../../search/search_bar/lib/saved_query_service'; +import { SavedQuery } from '../../../search/search_bar'; + +type OptionCheckedType = 'on' | 'off' | undefined; + +export interface SavedQueryOption { + label: string; + checked?: OptionCheckedType; + disabled?: boolean; + isGroupLabel?: boolean; + prepend?: React.ReactNode; + append?: React.ReactNode; + ref?: (optionIndex: number) => void; +} +interface Props { + savedQueryService: SavedQueryService; + onChange: (selectedOption: SavedQueryOption[], savedQueries: SavedQuery[]) => void; +} + +export const SavedQueryPicker: FunctionComponent = ({ savedQueryService, onChange }) => { + const [options, setOptions] = useState([] as SavedQueryOption[]); + const [savedQueries, setSavedQueries] = useState([] as SavedQuery[]); + const [savedQueriesLoaded, setSavedQueriesLoaded] = useState(false); + + useEffect(() => { + const fetchQueries = async () => { + const allSavedQueries = await savedQueryService.getAllSavedQueries(); + const sortedAllSavedQueries = sortBy(allSavedQueries, 'attributes.title'); + setSavedQueries(sortedAllSavedQueries); + setSavedQueriesLoaded(true); + }; + if (!savedQueriesLoaded) { + fetchQueries(); + getMappedSavedQueries(); + } + }, [options, !savedQueriesLoaded]); // I might need to be watching !savedQueriesLoaded here, + + /* + Checked has to be a conditional depending on if a saved query is already selected as a filter. + However, the saved query might have been changed in the mean time and the filter is a copy of + a saved query that's created at filter creation time. + I might need to do a deep comparison from the saved query filters that are active. + */ + const getMappedSavedQueries = () => { + const savedQueriesWithLabel = savedQueries + .map(sq => { + return { + label: sq.id, + checked: undefined, + }; + }) + .map(option => { + const { checked, ...checklessOption } = option; + return { ...checklessOption }; + }); + setOptions(savedQueriesWithLabel); + }; + + const noSavedQueriesText = i18n.translate( + 'data.filter.filterEditor.savedQueryFilterPicker.noSavedQueriesText', + { + defaultMessage: 'There are no saved queries.', + } + ); + + const savedQueryFilterCopyUsageText = i18n.translate( + 'data.filter.filterEditor.savedQueryFilterPicker.savedQueryFilterCopyUsageText', + { + defaultMessage: + 'Filters create a copy of a saved query and not a reference to it. Changes to the cloned saved query will not change the filter.', + } + ); + return ( + + {!savedQueriesLoaded && } + {options.length === 0 && ( + +

{noSavedQueriesText}

+
+ )} + {savedQueriesLoaded && options.length > 0 && ( + + +

{savedQueryFilterCopyUsageText}

+
+ + { + const selectedOption = newOptions.filter(option => option.checked === 'on'); + setOptions(newOptions); + onChange(selectedOption, savedQueries); + }} + singleSelection={true} + listProps={{ bordered: true }} + > + {list => list} + +
+ )} +
+ ); +}; diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/search_bar_editor.tsx b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/search_bar_editor.tsx new file mode 100644 index 0000000000000..7894189222343 --- /dev/null +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/search_bar_editor.tsx @@ -0,0 +1,118 @@ +/* + * 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, { FunctionComponent, useState } from 'react'; +import { SavedQuery } from '@kbn/es-query/src/filters/lib/saved_query_filter'; +import { UiSettingsClientContract } from 'kibana/public'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiPopover } from '@elastic/eui'; +import { TimeHistoryContract } from 'ui/timefilter'; +import { Filter } from '@kbn/es-query'; +import { TimeRange } from 'src/plugins/data/common/types'; +import { IndexPattern, Query } from '../../..'; +import { SearchBar } from '../../../search/search_bar/components/search_bar'; +/* +TODO: figure out how to import and use the Stateful SearchBar. So far, the following cause webpack errors: +// import { SearchBarProps } from '../../../../../../core_plugins/data/public'; +// import { start as data } from '../../../../../data/public/legacy'; +// import { start as data } from '../../../legacy'; +// const { SearchBar } = data.ui; + +Take a look at the graph app implementation: x-pack/legacy/plugins/graph/public/components/app.tsx +*/ +interface Props { + uiSettings: UiSettingsClientContract; + currentSavedQuery?: SavedQuery[]; + indexPatterns: IndexPattern[]; + showSaveQuery: boolean; + timeHistory?: TimeHistoryContract; // I need some other way of accessing timeHistory rather than passing it down all the way from the search bar + onChange: (item: any) => void; + onSelected: (savedQuery: SavedQuery[]) => void; +} +export const SearchBarEditor: FunctionComponent = ({ + uiSettings, + currentSavedQuery, + indexPatterns, + showSaveQuery, + timeHistory, + onChange, + onSelected, +}) => { + const [data, setData] = useState({ dateRange: {}, query: {}, filters: [] }); + const onClearSavedQuery = () => { + // console.log('saved query cleared'); + }; + const onQueryDataChange = (queryAndDateRange: { dateRange: TimeRange; query?: Query }) => { + const dateRange = queryAndDateRange.dateRange; + const query = queryAndDateRange.query + ? queryAndDateRange.query + : { query: '', language: 'kuery' }; + // console.log('queryAndDateRange changed with dateRange:', dateRange); + // console.log('queryAndDateRange changed with query:', query); + // add to the items on the search bar data + const newData = { ...data, dateRange, query }; + setData(newData); + // onChange(queryAndDateRange); + return queryAndDateRange; + }; + const onFiltersUpdated = (newFilters: Filter[] | any) => { + // I seem to get the filters back and can update them from here. + // console.log('filtersUpdated, filters?', newFilters); + if (newFilters.length > 0) { + const newData = { ...data, filters: newFilters }; + setData(newData); + } + return newFilters; + }; + return ( +
+ 0 && + currentSavedQuery[0].attributes.filters && + currentSavedQuery[0].attributes.filters.length > 0 + ? currentSavedQuery[0].attributes.filters + : [] + } + onFiltersUpdated={onFiltersUpdated} + showQueryInput={true} + query={ + currentSavedQuery && currentSavedQuery.length > 0 + ? { + language: currentSavedQuery[0].attributes.query.language, + query: currentSavedQuery[0].attributes.query.query, + } + : { language: uiSettings.get('search:queryLanguage'), query: '' } + } + onQueryChange={onQueryDataChange} + showSaveQuery={showSaveQuery} + savedQuery={ + currentSavedQuery && currentSavedQuery.length > 0 ? currentSavedQuery[0] : undefined + } + onClearSavedQuery={onClearSavedQuery} + showDatePicker={true} + timeHistory={timeHistory!} + customSubmitButton={null} + /> +
+ ); +}; diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_item.tsx b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_item.tsx index 0521b4e45aeb0..6d554e48c1176 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_item.tsx +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_item.tsx @@ -29,9 +29,11 @@ import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import classNames from 'classnames'; import React, { Component } from 'react'; import { UiSettingsClientContract } from 'src/core/public'; +import { TimeHistoryContract } from 'ui/timefilter'; import { IndexPattern } from '../../index_patterns'; import { FilterEditor } from './filter_editor'; import { FilterView } from './filter_view'; +import { SavedQueryService } from '../../search/search_bar/lib/saved_query_service'; import { getDisplayValueFromFilter } from './filter_editor/lib/filter_editor_utils'; interface Props { @@ -43,6 +45,9 @@ interface Props { onRemove: () => void; intl: InjectedIntl; uiSettings: UiSettingsClientContract; + savedQueryService?: SavedQueryService; + showSaveQuery?: boolean; + timeHistory?: TimeHistoryContract; } interface State { @@ -174,6 +179,10 @@ class FilterItemUI extends Component { indexPatterns={this.props.indexPatterns} onSubmit={this.onSubmit} onCancel={this.closePopover} + uiSettings={this.props.uiSettings} + savedQueryService={this.props.savedQueryService!} + showSaveQuery={this.props.showSaveQuery} + timeHistory={this.props.timeHistory!} />
), diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/dedup_filters.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/dedup_filters.test.ts index 75bd9d5dfbd81..049fcf235768b 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/dedup_filters.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/dedup_filters.test.ts @@ -17,7 +17,13 @@ * under the License. */ -import { Filter, buildRangeFilter, FilterStateStore, buildQueryFilter } from '@kbn/es-query'; +import { + Filter, + buildRangeFilter, + FilterStateStore, + buildQueryFilter, + buildSavedQueryFilter, +} from '@kbn/es-query'; import { dedupFilters } from './dedup_filters'; describe('filter manager utilities', () => { @@ -75,5 +81,55 @@ describe('filter manager utilities', () => { expect(results).toContain(filters[0]); expect(results).not.toContain(filters[1]); }); + + test('should deduplicate saved query filters', () => { + const savedQueryTestItem = { + id: 'foo', + attributes: { + title: 'foo', + description: 'bar', + query: { + language: 'kuery', + query: 'response:200', + }, + filters: [], + }, + }; + const savedQueryTestItem2 = { + id: 'baz', + attributes: { + title: 'baz', + description: 'qux', + query: { + language: 'kuery', + query: 'response:200', + }, + filters: [], + }, + }; + const savedQueryTestItem3 = { + id: 'qux', + attributes: { + title: 'qux', + description: 'blah', + query: { + language: 'kuery', + query: 'response:200', + }, + filters: [], + }, + }; + const existing: Filter[] = [ + buildSavedQueryFilter(savedQueryTestItem), + buildSavedQueryFilter(savedQueryTestItem2), + ]; + const filter: Filter[] = [ + buildSavedQueryFilter(savedQueryTestItem2), + buildSavedQueryFilter(savedQueryTestItem3), + ]; + const results = dedupFilters(existing, filter); + expect(results).toContain(filter[1]); + expect(results).not.toContain(filter[0]); + }); }); }); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_filter.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_filter.test.ts index ad1c4457f673e..662d0f06cb434 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_filter.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_filter.test.ts @@ -77,6 +77,16 @@ describe('filter manager utilities', () => { expect(after.meta).toHaveProperty('negate', false); }); + test('should map saved query filters', async () => { + const before: any = { + meta: { params: { savedQuery: { id: 'foo', attributes: {} } }, type: 'savedQuery' }, + saved_query: 'foo', + }; + const after = await mapFilter(indexPatterns, before as Filter); + expect(after).toHaveProperty('meta'); + expect(after.meta).toHaveProperty('type', 'savedQuery'); + }); + test('should finish with a catch', async done => { const before: any = { meta: { index: 'logstash-*' } }; diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_filter.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_filter.ts index c0d251e647fd1..185bec0419edc 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_filter.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_filter.ts @@ -31,6 +31,7 @@ import { mapGeoBoundingBox } from './map_geo_bounding_box'; import { mapGeoPolygon } from './map_geo_polygon'; import { mapDefault } from './map_default'; import { generateMappingChain } from './generate_mapping_chain'; +import { mapSavedQuery } from './map_saved_query'; export function mapFilter(filter: Filter) { /** Mappers **/ @@ -59,6 +60,7 @@ export function mapFilter(filter: Filter) { mapQueryString, mapGeoBoundingBox, mapGeoPolygon, + mapSavedQuery, mapDefault, ]; diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_saved_query.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_saved_query.test.ts new file mode 100644 index 0000000000000..f053474bd9061 --- /dev/null +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_saved_query.test.ts @@ -0,0 +1,62 @@ +/* + * 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 { SavedQueryFilter, buildSavedQueryFilter, buildEmptyFilter } from '@kbn/es-query'; +import { mapSavedQuery } from './map_saved_query'; + +describe('filter manager utilities', () => { + describe('mapSavedQuery', () => { + test('should return undefined for none matching', async done => { + const filter = buildEmptyFilter(true) as SavedQueryFilter; + try { + await mapSavedQuery(filter); + } catch (e) { + expect(e).toBe(filter); + done(); + } + }); + test('should return the key and params for matching filters', async () => { + const savedQueryTestItem = { + id: 'foo', + attributes: { + title: 'foo', + description: 'bar', + query: { + language: 'kuery', + query: 'response:200', + }, + filters: [], + }, + }; + const filter = buildSavedQueryFilter(savedQueryTestItem); + const result = await mapSavedQuery(filter); + expect(result).toHaveProperty('key', 'foo'); + expect(result).toHaveProperty('type', 'savedQuery'); + expect(result.params).toHaveProperty('savedQuery', { + attributes: { + description: 'bar', + filters: [], + query: { language: 'kuery', query: 'response:200' }, + title: 'foo', + }, + id: 'foo', + }); + }); + }); +}); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_saved_query.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_saved_query.ts new file mode 100644 index 0000000000000..1238998287a3d --- /dev/null +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_saved_query.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 { Filter, FILTERS, isSavedQueryFilter } from '@kbn/es-query'; + +export const mapSavedQuery = (filter: Filter) => { + if (!isSavedQueryFilter(filter)) { + throw filter; + } else { + return { + type: FILTERS.SAVED_QUERY, + key: filter.meta.key, + params: filter.meta.params, + }; + } +}; diff --git a/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.tsx b/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.tsx index a03019da4e0d7..5d39ed4e72f3d 100644 --- a/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.tsx +++ b/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.tsx @@ -82,6 +82,8 @@ export interface SearchBarOwnProps { onClearSavedQuery?: () => void; onRefresh?: (payload: { dateRange: TimeRange }) => void; + // Used only in React context within the filter editor + onQueryChange?: (payload: { dateRange: TimeRange; query?: Query }) => void; } export type SearchBarProps = SearchBarOwnProps & SearchBarInjectedDeps; @@ -105,8 +107,8 @@ class SearchBarUI extends Component { showAutoRefreshOnly: false, }; - private savedQueryService!: SavedQueryService; private services = this.props.kibana.services; + private savedQueryService = createSavedQueryService(this.services.savedObjects.client); public filterBarRef: Element | null = null; public filterBarWrapperRef: Element | null = null; @@ -300,6 +302,9 @@ class SearchBarUI extends Component { dateRangeFrom: queryAndDateRange.dateRange.from, dateRangeTo: queryAndDateRange.dateRange.to, }); + if (this.props.onQueryChange) { + this.props.onQueryChange(queryAndDateRange); + } }; public onQueryBarSubmit = (queryAndDateRange: { dateRange?: TimeRange; query?: Query }) => { @@ -346,9 +351,6 @@ class SearchBarUI extends Component { this.setFilterBarHeight(); this.ro.observe(this.filterBarRef); } - if (this.services.savedObjects) { - this.savedQueryService = createSavedQueryService(this.services.savedObjects.client); - } } public componentDidUpdate() { @@ -423,6 +425,9 @@ class SearchBarUI extends Component { filters={this.props.filters!} onFiltersUpdated={this.props.onFiltersUpdated} indexPatterns={this.props.indexPatterns!} + savedQueryService={this.savedQueryService} + showSaveQuery={this.props.showSaveQuery} + timeHistory={this.props.timeHistory} /> diff --git a/src/legacy/ui/public/courier/search_source/search_source.js b/src/legacy/ui/public/courier/search_source/search_source.js index afa42a7d7c015..0bf92323ecebc 100644 --- a/src/legacy/ui/public/courier/search_source/search_source.js +++ b/src/legacy/ui/public/courier/search_source/search_source.js @@ -582,7 +582,11 @@ export function SearchSourceProvider(Promise, Private, config) { } const esQueryConfigs = getEsQueryConfig(config); - flatData.body.query = buildEsQuery(flatData.index, flatData.query, flatData.filters, esQueryConfigs); + flatData.body.query = buildEsQuery( + flatData.index, + flatData.query, + flatData.filters, + esQueryConfigs); if (flatData.highlightAll != null) { if (flatData.highlightAll && flatData.body.query) { diff --git a/src/legacy/ui/public/kbn_top_nav/kbn_top_nav.js b/src/legacy/ui/public/kbn_top_nav/kbn_top_nav.js index 79365eb5cf1cc..a7644b8fad3b2 100644 --- a/src/legacy/ui/public/kbn_top_nav/kbn_top_nav.js +++ b/src/legacy/ui/public/kbn_top_nav/kbn_top_nav.js @@ -85,6 +85,7 @@ module.directive('kbnTopNavHelper', (reactDirective) => { ['intl', { watchDepth: 'reference' }], ['onQuerySubmit', { watchDepth: 'reference' }], + ['onQueryChange', { watchDepth: 'reference' }], ['onFiltersUpdated', { watchDepth: 'reference' }], ['onRefreshChange', { watchDepth: 'reference' }], ['onClearSavedQuery', { watchDepth: 'reference' }], @@ -94,6 +95,7 @@ module.directive('kbnTopNavHelper', (reactDirective) => { ['indexPatterns', { watchDepth: 'collection' }], ['filters', { watchDepth: 'collection' }], + // All modifiers default to true. // Set to false to hide subcomponents. 'showSearchBar',