From 1fa01a746e3df6e9d55cc3d365e2b106c5abb3c2 Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Thu, 6 Feb 2020 14:00:32 +0200 Subject: [PATCH] Stateful search bar default behaviors (#56160) * Clean up discover * Clean up visualize * Clean up dashboard * use-default-behaviors * ts * Discover interval changing * timerange for interval definition in editor * ts * Revert most of changes to dashboard app because of changes in #55443 * Fix spaces * Revert editor to scope PR! * typo * keep savedQuery state in create search bar * update saved query to save it with the state * revert all dashboard changes * saved queries * @kertal code review * fix applying filters from histogram * @dosant code review * Merge changes from #56643 to handle saved query errors Fix bug where saved query clean does not reset query * change string path * if * Extract useFilterManager and useTimefilter * Split useSavedQuery and restore capability of changing saved query in URL * Added some tests * context view * Remove useMemo * spaces * Support filter intial values Improve useSavedQuery hook in terface Co-authored-by: Elastic Machine --- .../np_ready/angular/context_app.html | 5 +- .../discover/np_ready/angular/context_app.js | 8 +- .../discover/np_ready/angular/discover.html | 25 ++- .../discover/np_ready/angular/discover.js | 163 +++-------------- .../components/doc/use_es_doc_search.ts | 50 +++--- .../visualize/np_ready/editor/editor.js | 21 ++- .../ui/public/kbn_top_nav/kbn_top_nav.js | 5 + .../query/saved_query/saved_query_service.ts | 7 +- .../ui/search_bar/create_search_bar.tsx | 167 +++++++++++++----- .../search_bar/lib/clear_saved_query.test.ts | 50 ++++++ .../ui/search_bar/lib/clear_saved_query.ts | 31 ++++ .../populate_state_from_saved_query.test.ts | 115 ++++++++++++ .../lib/populate_state_from_saved_query.ts | 49 +++++ .../ui/search_bar/lib/use_filter_manager.ts | 50 ++++++ .../ui/search_bar/lib/use_saved_query.ts | 94 ++++++++++ .../ui/search_bar/lib/use_timefilter.ts | 61 +++++++ .../data/public/ui/search_bar/search_bar.tsx | 4 +- 17 files changed, 657 insertions(+), 248 deletions(-) create mode 100644 src/plugins/data/public/ui/search_bar/lib/clear_saved_query.test.ts create mode 100644 src/plugins/data/public/ui/search_bar/lib/clear_saved_query.ts create mode 100644 src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.test.ts create mode 100644 src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.ts create mode 100644 src/plugins/data/public/ui/search_bar/lib/use_filter_manager.ts create mode 100644 src/plugins/data/public/ui/search_bar/lib/use_saved_query.ts create mode 100644 src/plugins/data/public/ui/search_bar/lib/use_timefilter.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_app.html b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_app.html index 3e0f8a832915..8bbb746fa45f 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_app.html +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_app.html @@ -2,12 +2,11 @@ app-name="'context'" show-search-bar="true" show-filter-bar="true" + show-query-bar="false" show-save-query="false" show-date-picker="false" - - filters="contextApp.state.queryParameters.filters" - on-filters-updated="contextApp.actions.updateFilters" index-patterns="[contextApp.indexPattern]" + use-default-behaviors="true" > diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_app.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_app.js index 5fa0958249d7..6549f1355637 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_app.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_app.js @@ -18,7 +18,7 @@ */ import _ from 'lodash'; -import { getServices, callAfterBindingsWorkaround, getAngularModule } from '../../kibana_services'; +import { callAfterBindingsWorkaround, getAngularModule } from '../../kibana_services'; import contextAppTemplate from './context_app.html'; import './context/components/action_bar'; import { getFirstSortableField } from './context/api/utils/sorting'; @@ -34,8 +34,6 @@ import { QueryActionsProvider, } from './context/query'; -const { timefilter } = getServices(); - const module = getAngularModule(); module.directive('contextApp', function ContextApp() { @@ -61,10 +59,6 @@ module.directive('contextApp', function ContextApp() { function ContextAppController($scope, config, Private) { const queryParameterActions = getQueryParameterActions(); const queryActions = Private(QueryActionsProvider); - - timefilter.disableAutoRefreshSelector(); - timefilter.disableTimeRangeSelector(); - this.state = createInitialState( parseInt(config.get('context:step'), 10), getFirstSortableField(this.indexPattern, config.get('context:tieBreakerFields')), diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html index 45490ac7adc0..efde83a0e35f 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html @@ -5,24 +5,19 @@

{{screenTitle}}

diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js index 2f73af2ab77e..978a58ef5b2c 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js @@ -19,7 +19,8 @@ import _ from 'lodash'; import React from 'react'; -import { Subscription } from 'rxjs'; +import { Subscription, Subject, merge } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; import moment from 'moment'; import dateMath from '@elastic/datemath'; import { i18n } from '@kbn/i18n'; @@ -62,7 +63,6 @@ import { Vis } from '../../../../../visualizations/public'; const { core, chrome, - data, docTitle, filterManager, share, @@ -79,8 +79,6 @@ import { import { getIndexPatternId } from '../helpers/get_index_pattern_id'; import { FilterStateManager } from '../../../../../data/public'; -const { getSavedQuery } = data.query.savedQueries; - const fetchStatuses = { UNINITIALIZED: 'uninitialized', LOADING: 'loading', @@ -205,8 +203,6 @@ function discoverController( const subscriptions = new Subscription(); - timefilter.disableTimeRangeSelector(); - timefilter.disableAutoRefreshSelector(); $scope.timefilterUpdateHandler = ranges => { timefilter.setTime({ from: moment(ranges.from).toISOString(), @@ -218,7 +214,6 @@ function discoverController( $scope.showInterval = false; $scope.minimumVisibleRows = 50; $scope.fetchStatus = fetchStatuses.UNINITIALIZED; - $scope.refreshInterval = timefilter.getRefreshInterval(); $scope.showSaveQuery = uiCapabilities.discover.saveQuery; $scope.$watch( @@ -436,15 +431,10 @@ function discoverController( let stateMonitor; const $state = ($scope.state = new AppState(getStateDefaults())); + const $fetchObservable = new Subject(); - $scope.filters = filterManager.getFilters(); $scope.screenTitle = savedSearch.title; - $scope.onFiltersUpdated = filters => { - // The filters will automatically be set when the filterManager emits an update event (see below) - filterManager.setFilters(filters); - }; - const getFieldCounts = async () => { // the field counts aren't set until we have the data back, // so we wait for the fetch to be done before proceeding @@ -571,17 +561,12 @@ function discoverController( }; const shouldSearchOnPageLoad = () => { - // If a saved query is referenced in the app state, omit the initial load because the saved query will - // be fetched separately and trigger a reload - if ($scope.state.savedQuery) { - return false; - } // A saved search is created on every page load, so we check the ID to see if we're loading a // previously saved search or if it is just transient return ( config.get('discover:searchOnPageLoad') || savedSearch.id !== undefined || - _.get($scope, 'refreshInterval.pause') === false + timefilter.getRefreshInterval().pause === false ); }; @@ -593,25 +578,23 @@ function discoverController( $scope.$on('$destroy', () => stateMonitor.destroy()); $scope.updateDataSource().then(function() { - subscriptions.add( - subscribeWithScope($scope, timefilter.getAutoRefreshFetch$(), { - next: $scope.fetch, - }) - ); + const searchBarChanges = merge( + timefilter.getAutoRefreshFetch$(), + timefilter.getFetch$(), + filterManager.getFetches$(), + $fetchObservable + ).pipe(debounceTime(100)); subscriptions.add( - subscribeWithScope($scope, timefilter.getRefreshIntervalUpdate$(), { - next: $scope.updateRefreshInterval, + subscribeWithScope($scope, searchBarChanges, { + next: $scope.fetch, }) ); subscriptions.add( subscribeWithScope($scope, timefilter.getTimeUpdate$(), { - next: $scope.updateTime, - }) - ); - subscriptions.add( - subscribeWithScope($scope, timefilter.getFetch$(), { - next: $scope.fetch, + next: () => { + $scope.updateTime(); + }, }) ); @@ -622,14 +605,14 @@ function discoverController( const currentSort = getSortArray($scope.searchSource.getField('sort'), $scope.indexPattern); // if the searchSource doesn't know, tell it so - if (!angular.equals(sort, currentSort)) $scope.fetch(); + if (!angular.equals(sort, currentSort)) $fetchObservable.next(); }); // update data source when filters update + subscriptions.add( subscribeWithScope($scope, filterManager.getUpdates$(), { next: () => { - $scope.filters = filterManager.getFilters(); $scope.updateDataSource().then(function() { $state.save(); }); @@ -637,16 +620,9 @@ function discoverController( }) ); - // fetch data when filters fire fetch event - subscriptions.add( - subscribeWithScope($scope, filterManager.getFetches$(), { - next: $scope.fetch, - }) - ); - // update data source when hitting forward/back and the query changes $scope.$listen($state, 'fetch_with_changes', function(diff) { - if (diff.indexOf('query') >= 0) $scope.fetch(); + if (diff.indexOf('query') >= 0) $fetchObservable.next(); }); $scope.$watch('opts.timefield', function(timefield) { @@ -655,7 +631,7 @@ function discoverController( $scope.$watch('state.interval', function(newInterval, oldInterval) { if (newInterval !== oldInterval) { - $scope.fetch(); + $fetchObservable.next(); } }); @@ -674,7 +650,7 @@ function discoverController( if (!_.isEqual(newQuery, oldQuery)) { const query = migrateLegacyQuery(newQuery); if (!_.isEqual(query, newQuery)) { - $scope.updateQueryAndFetch({ query }); + $scope.updateQuery({ query }); } } }); @@ -734,7 +710,7 @@ function discoverController( $state.replace(); if (shouldSearchOnPageLoad()) { - $scope.fetch(); + $fetchObservable.next(); } }); }); @@ -827,15 +803,9 @@ function discoverController( }); }; - $scope.updateQueryAndFetch = function({ query, dateRange }) { - const oldDateRange = timefilter.getTime(); - timefilter.setTime(dateRange); + $scope.updateQuery = function({ query }) { $state.query = query; - // storing the updated timerange in the state will trigger a fetch - // call automatically, so only trigger fetch in case this is a refresh call (no changes in parameters). - if (_.isEqual(oldDateRange, dateRange)) { - $scope.fetch(); - } + $fetchObservable.next(); }; function onResults(resp) { @@ -896,32 +866,12 @@ function discoverController( from: dateMath.parse(timefilter.getTime().from), to: dateMath.parse(timefilter.getTime().to, { roundUp: true }), }; - $scope.time = timefilter.getTime(); }; $scope.toMoment = function(datetime) { return moment(datetime).format(config.get('dateFormat')); }; - $scope.updateRefreshInterval = function() { - const newInterval = timefilter.getRefreshInterval(); - const shouldFetch = - _.get($scope, 'refreshInterval.pause') === true && newInterval.pause === false; - - $scope.refreshInterval = newInterval; - - if (shouldFetch) { - $scope.fetch(); - } - }; - - $scope.onRefreshChange = function({ isPaused, refreshInterval }) { - timefilter.setRefreshInterval({ - pause: isPaused, - value: refreshInterval ? refreshInterval : $scope.refreshInterval.value, - }); - }; - $scope.resetQuery = function() { kbnUrl.change('/discover/{{id}}', { id: $route.current.params.id }); }; @@ -988,69 +938,14 @@ function discoverController( $scope.minimumVisibleRows = $scope.hits; }; - $scope.onQuerySaved = savedQuery => { - $scope.savedQuery = savedQuery; - }; - - $scope.onSavedQueryUpdated = savedQuery => { - $scope.savedQuery = { ...savedQuery }; - }; - - $scope.onClearSavedQuery = () => { - delete $scope.savedQuery; - delete $state.savedQuery; - $state.query = { - query: '', - language: localStorage.get('kibana.userQueryLanguage') || config.get('search:queryLanguage'), - }; - filterManager.setFilters(filterManager.getGlobalFilters()); - $state.save(); - $scope.fetch(); - }; - - const updateStateFromSavedQuery = savedQuery => { - $state.query = savedQuery.attributes.query; - $state.save(); - const savedQueryFilters = savedQuery.attributes.filters || []; - const globalFilters = filterManager.getGlobalFilters(); - filterManager.setFilters([...globalFilters, ...savedQueryFilters]); - - if (savedQuery.attributes.timefilter) { - timefilter.setTime({ - from: savedQuery.attributes.timefilter.from, - to: savedQuery.attributes.timefilter.to, - }); - if (savedQuery.attributes.timefilter.refreshInterval) { - timefilter.setRefreshInterval(savedQuery.attributes.timefilter.refreshInterval); - } + $scope.updateSavedQueryId = newSavedQueryId => { + if (newSavedQueryId) { + $state.savedQuery = newSavedQueryId; + } else { + delete $state.savedQuery; } - - $scope.fetch(); - }; - - $scope.$watch('savedQuery', newSavedQuery => { - if (!newSavedQuery) return; - - $state.savedQuery = newSavedQuery.id; $state.save(); - - updateStateFromSavedQuery(newSavedQuery); - }); - - $scope.$watch('state.savedQuery', newSavedQueryId => { - if (!newSavedQueryId) { - $scope.savedQuery = undefined; - return; - } - if (!$scope.savedQuery || newSavedQueryId !== $scope.savedQuery.id) { - getSavedQuery(newSavedQueryId).then(savedQuery => { - $scope.$evalAsync(() => { - $scope.savedQuery = savedQuery; - updateStateFromSavedQuery(savedQuery); - }); - }); - } - }); + }; async function setupVisualization() { // If no timefield has been specified we don't create a histogram of messages diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts index d3bf3696c08a..6cffc2cc533b 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts @@ -62,36 +62,34 @@ export function useEsDocSearch({ const [status, setStatus] = useState(ElasticRequestState.Loading); const [hit, setHit] = useState(null); - async function requestData() { - try { - const indexPatternEntity = await indexPatternService.get(indexPatternId); - setIndexPattern(indexPatternEntity); + useEffect(() => { + async function requestData() { + try { + const indexPatternEntity = await indexPatternService.get(indexPatternId); + setIndexPattern(indexPatternEntity); - const { hits } = await esClient.search({ - index, - body: buildSearchBody(id, indexPatternEntity), - }); + const { hits } = await esClient.search({ + index, + body: buildSearchBody(id, indexPatternEntity), + }); - if (hits && hits.hits && hits.hits[0]) { - setStatus(ElasticRequestState.Found); - setHit(hits.hits[0]); - } else { - setStatus(ElasticRequestState.NotFound); - } - } catch (err) { - if (err.savedObjectId) { - setStatus(ElasticRequestState.NotFoundIndexPattern); - } else if (err.status === 404) { - setStatus(ElasticRequestState.NotFound); - } else { - setStatus(ElasticRequestState.Error); + if (hits && hits.hits && hits.hits[0]) { + setStatus(ElasticRequestState.Found); + setHit(hits.hits[0]); + } else { + setStatus(ElasticRequestState.NotFound); + } + } catch (err) { + if (err.savedObjectId) { + setStatus(ElasticRequestState.NotFoundIndexPattern); + } else if (err.status === 404) { + setStatus(ElasticRequestState.NotFound); + } else { + setStatus(ElasticRequestState.Error); + } } } - } - - useEffect(() => { requestData(); - }); - + }, [esClient, id, index, indexPatternId, indexPatternService]); return [status, hit, indexPattern]; } diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js index 96a583bec7dc..886187cbb942 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js @@ -97,7 +97,6 @@ function VisualizeAppController( } = getServices(); const filterStateManager = new FilterStateManager(globalState, getAppState, filterManager); - const queryFilter = filterManager; // Retrieve the resolved SavedVis instance. const savedVis = $route.current.locals.savedVis; const _applyVis = () => { @@ -311,11 +310,11 @@ function VisualizeAppController( return appState; })(); - $scope.filters = queryFilter.getFilters(); + $scope.filters = filterManager.getFilters(); $scope.onFiltersUpdated = filters => { - // The filters will automatically be set when the queryFilter emits an update event (see below) - queryFilter.setFilters(filters); + // The filters will automatically be set when the filterManager emits an update event (see below) + filterManager.setFilters(filters); }; $scope.showSaveQuery = visualizeCapabilities.saveQuery; @@ -426,15 +425,15 @@ function VisualizeAppController( // update the searchSource when filters update subscriptions.add( - subscribeWithScope($scope, queryFilter.getUpdates$(), { + subscribeWithScope($scope, filterManager.getUpdates$(), { next: () => { - $scope.filters = queryFilter.getFilters(); - $scope.globalFilters = queryFilter.getGlobalFilters(); + $scope.filters = filterManager.getFilters(); + $scope.globalFilters = filterManager.getGlobalFilters(); }, }) ); subscriptions.add( - subscribeWithScope($scope, queryFilter.getFetches$(), { + subscribeWithScope($scope, filterManager.getFetches$(), { next: $scope.fetch, }) ); @@ -500,7 +499,7 @@ function VisualizeAppController( language: localStorage.get('kibana.userQueryLanguage') || uiSettings.get('search:queryLanguage'), }; - queryFilter.setFilters(queryFilter.getGlobalFilters()); + filterManager.setFilters(filterManager.getGlobalFilters()); $state.save(); $scope.fetch(); }; @@ -510,8 +509,8 @@ function VisualizeAppController( $state.save(); const savedQueryFilters = savedQuery.attributes.filters || []; - const globalFilters = queryFilter.getGlobalFilters(); - queryFilter.setFilters([...globalFilters, ...savedQueryFilters]); + const globalFilters = filterManager.getGlobalFilters(); + filterManager.setFilters([...globalFilters, ...savedQueryFilters]); if (savedQuery.attributes.timefilter) { timefilter.setTime({ 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 0788afd5f74e..12c3ca2acc3c 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 @@ -92,6 +92,7 @@ export const createTopNavHelper = ({ TopNavMenu }) => reactDirective => { ['onClearSavedQuery', { watchDepth: 'reference' }], ['onSaved', { watchDepth: 'reference' }], ['onSavedQueryUpdated', { watchDepth: 'reference' }], + ['onSavedQueryIdChange', { watchDepth: 'reference' }], ['indexPatterns', { watchDepth: 'collection' }], ['filters', { watchDepth: 'collection' }], @@ -109,10 +110,14 @@ export const createTopNavHelper = ({ TopNavMenu }) => reactDirective => { 'screenTitle', 'dateRangeFrom', 'dateRangeTo', + 'savedQueryId', 'isRefreshPaused', 'refreshInterval', 'disableAutoFocus', 'showAutoRefreshOnly', + + // temporary flag to use the stateful components + 'useDefaultBehaviors', ]); }; diff --git a/src/plugins/data/public/query/saved_query/saved_query_service.ts b/src/plugins/data/public/query/saved_query/saved_query_service.ts index 434efe80ecd8..80dec1c9373e 100644 --- a/src/plugins/data/public/query/saved_query/saved_query_service.ts +++ b/src/plugins/data/public/query/saved_query/saved_query_service.ts @@ -112,8 +112,11 @@ export const createSavedQueryService = ( }; const getSavedQuery = async (id: string): Promise => { - const response = await savedObjectsClient.get('query', id); - return parseSavedQueryObject(response); + const savedObject = await savedObjectsClient.get('query', id); + if (savedObject.error) { + throw new Error(savedObject.error.message); + } + return parseSavedQueryObject(savedObject); }; const deleteSavedQuery = async (id: string) => { diff --git a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx index 6f1be2825dd0..71d76f4db49e 100644 --- a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx @@ -18,13 +18,15 @@ */ import React, { useState, useEffect } from 'react'; -import { Subscription } from 'rxjs'; import { CoreStart } from 'src/core/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { KibanaContextProvider } from '../../../../kibana_react/public'; -import { DataPublicPluginStart, esFilters } from '../..'; +import { DataPublicPluginStart, esFilters, Query, TimeRange, SavedQuery } from '../..'; import { QueryStart } from '../../query'; import { SearchBarOwnProps, SearchBar } from './search_bar'; +import { useFilterManager } from './lib/use_filter_manager'; +import { useTimefilter } from './lib/use_timefilter'; +import { useSavedQuery } from './lib/use_saved_query'; interface StatefulSearchBarDeps { core: CoreStart; @@ -34,16 +36,21 @@ interface StatefulSearchBarDeps { export type StatefulSearchBarProps = SearchBarOwnProps & { appName: string; + useDefaultBehaviors?: boolean; + savedQueryId?: string; + onSavedQueryIdChange?: (savedQueryId?: string) => void; }; -const defaultFiltersUpdated = (query: QueryStart) => { +// Respond to user changing the filters +const defaultFiltersUpdated = (queryService: QueryStart) => { return (filters: esFilters.Filter[]) => { - query.filterManager.setFilters(filters); + queryService.filterManager.setFilters(filters); }; }; -const defaultOnRefreshChange = (query: QueryStart) => { - const { timefilter } = query.timefilter; +// Respond to user changing the refresh settings +const defaultOnRefreshChange = (queryService: QueryStart) => { + const { timefilter } = queryService.timefilter; return (options: { isPaused: boolean; refreshInterval: number }) => { timefilter.setRefreshInterval({ value: options.refreshInterval, @@ -52,53 +59,103 @@ const defaultOnRefreshChange = (query: QueryStart) => { }; }; +// Respond to user changing the query string or time settings +const defaultOnQuerySubmit = ( + props: StatefulSearchBarProps, + queryService: QueryStart, + currentQuery: Query, + setQueryStringState: Function +) => { + if (!props.useDefaultBehaviors) return props.onQuerySubmit; + + const { timefilter } = queryService.timefilter; + + return (payload: { dateRange: TimeRange; query?: Query }) => { + const isUpdate = + !_.isEqual(timefilter.getTime(), payload.dateRange) || + !_.isEqual(payload.query, currentQuery); + if (isUpdate) { + timefilter.setTime(payload.dateRange); + setQueryStringState(payload.query); + } else { + // Refresh button triggered for an update + if (props.onQuerySubmit) + props.onQuerySubmit( + { + dateRange: timefilter.getTime(), + query: currentQuery, + }, + false + ); + } + }; +}; + +// Respond to user clearing a saved query +const defaultOnClearSavedQuery = (props: StatefulSearchBarProps, clearSavedQuery: Function) => { + if (!props.useDefaultBehaviors) return props.onClearSavedQuery; + return () => { + clearSavedQuery(); + if (props.onSavedQueryIdChange) props.onSavedQueryIdChange(); + }; +}; + +// Respond to user saving or updating a saved query +const defaultOnSavedQueryUpdated = (props: StatefulSearchBarProps, setSavedQuery: Function) => { + if (!props.useDefaultBehaviors) return props.onSavedQueryUpdated; + return (savedQuery: SavedQuery) => { + setSavedQuery(savedQuery); + if (props.onSavedQueryIdChange) props.onSavedQueryIdChange(savedQuery.id); + }; +}; + +const overrideDefaultBehaviors = (props: StatefulSearchBarProps) => { + return props.useDefaultBehaviors ? {} : props; +}; + export function createSearchBar({ core, storage, data }: StatefulSearchBarDeps) { // App name should come from the core application service. // Until it's available, we'll ask the user to provide it for the pre-wired component. return (props: StatefulSearchBarProps) => { - const { filterManager, timefilter } = data.query; - const tfRefreshInterval = timefilter.timefilter.getRefreshInterval(); - const fmFilters = filterManager.getFilters(); - const [refreshInterval, setRefreshInterval] = useState(tfRefreshInterval.value); - const [refreshPaused, setRefreshPaused] = useState(tfRefreshInterval.pause); + // Handle queries + const [query, setQuery] = useState( + props.query || { + query: '', + language: core.uiSettings.get('search:queryLanguage'), + } + ); - const [filters, setFilters] = useState(fmFilters); + // handle service state updates. + // i.e. filters being added from a visualization directly to filterManager. + const { filters } = useFilterManager({ + filters: props.filters, + filterManager: data.query.filterManager, + }); + const { timeRange, refreshInterval } = useTimefilter({ + timefilter: data.query.timefilter.timefilter, + }); - // We do not really need to keep track of the time - // since this is just for initialization - const timeRange = timefilter.timefilter.getTime(); + // Fetch and update UI from saved query + const { savedQuery, setSavedQuery, clearSavedQuery } = useSavedQuery({ + queryService: data.query, + setQuery, + savedQueryId: props.savedQueryId, + notifications: core.notifications, + uiSettings: core.uiSettings, + }); + // Fire onQuerySubmit on query or timerange change useEffect(() => { - let isSubscribed = true; - const subscriptions = new Subscription(); - subscriptions.add( - timefilter.timefilter.getRefreshIntervalUpdate$().subscribe({ - next: () => { - if (isSubscribed) { - const newRefreshInterval = timefilter.timefilter.getRefreshInterval(); - setRefreshInterval(newRefreshInterval.value); - setRefreshPaused(newRefreshInterval.pause); - } + if (!props.useDefaultBehaviors) return; + if (props.onQuerySubmit) + props.onQuerySubmit( + { + dateRange: timeRange, + query, }, - }) - ); - - subscriptions.add( - filterManager.getUpdates$().subscribe({ - next: () => { - if (isSubscribed) { - const newFilters = filterManager.getFilters(); - setFilters(newFilters); - } - }, - }) - ); - - return () => { - isSubscribed = false; - subscriptions.unsubscribe(); - }; - }, [filterManager, timefilter.timefilter]); + true + ); + }, [props, props.onQuerySubmit, props.useDefaultBehaviors, query, timeRange]); return ( ); diff --git a/src/plugins/data/public/ui/search_bar/lib/clear_saved_query.test.ts b/src/plugins/data/public/ui/search_bar/lib/clear_saved_query.test.ts new file mode 100644 index 000000000000..ccfe5464b959 --- /dev/null +++ b/src/plugins/data/public/ui/search_bar/lib/clear_saved_query.test.ts @@ -0,0 +1,50 @@ +/* + * 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 { clearStateFromSavedQuery } from './clear_saved_query'; + +import { dataPluginMock } from '../../../mocks'; +import { DataPublicPluginStart } from '../../../types'; +import { Query } from '../../..'; + +describe('clearStateFromSavedQuery', () => { + const DEFAULT_LANGUAGE = 'banana'; + let dataMock: jest.Mocked; + + beforeEach(() => { + dataMock = dataPluginMock.createStartContract(); + }); + + it('should clear filters and query', async () => { + const setQueryState = jest.fn(); + dataMock.query.filterManager.removeAll = jest.fn(); + clearStateFromSavedQuery(dataMock.query, setQueryState, DEFAULT_LANGUAGE); + expect(setQueryState).toHaveBeenCalled(); + expect(dataMock.query.filterManager.removeAll).toHaveBeenCalled(); + }); + + it('should use search:queryLanguage', async () => { + const setQueryState = jest.fn(); + dataMock.query.filterManager.removeAll = jest.fn(); + clearStateFromSavedQuery(dataMock.query, setQueryState, DEFAULT_LANGUAGE); + expect(setQueryState).toHaveBeenCalled(); + expect((setQueryState.mock.calls[0][0] as Query).language).toBe(DEFAULT_LANGUAGE); + expect(dataMock.query.filterManager.removeAll).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/data/public/ui/search_bar/lib/clear_saved_query.ts b/src/plugins/data/public/ui/search_bar/lib/clear_saved_query.ts new file mode 100644 index 000000000000..b2c777261c25 --- /dev/null +++ b/src/plugins/data/public/ui/search_bar/lib/clear_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 { QueryStart } from '../../../query'; + +export const clearStateFromSavedQuery = ( + queryService: QueryStart, + setQueryStringState: Function, + defaultLanguage: string +) => { + queryService.filterManager.removeAll(); + setQueryStringState({ + query: '', + language: defaultLanguage, + }); +}; diff --git a/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.test.ts b/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.test.ts new file mode 100644 index 000000000000..3242b37becd9 --- /dev/null +++ b/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.test.ts @@ -0,0 +1,115 @@ +/* + * 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 { populateStateFromSavedQuery } from './populate_state_from_saved_query'; + +import { dataPluginMock } from '../../../mocks'; +import { DataPublicPluginStart } from '../../../types'; +import { SavedQuery, esFilters } from '../../..'; +import { getFilter } from '../../../query/filter_manager/test_helpers/get_stub_filter'; + +describe('populateStateFromSavedQuery', () => { + let dataMock: jest.Mocked; + + const baseSavedQuery: SavedQuery = { + id: 'test', + attributes: { + title: 'test', + description: 'test', + query: { + query: 'test', + language: 'kuery', + }, + }, + }; + + beforeEach(() => { + dataMock = dataPluginMock.createStartContract(); + dataMock.query.filterManager.setFilters = jest.fn(); + dataMock.query.filterManager.getGlobalFilters = jest.fn().mockReturnValue([]); + }); + + it('should set query', async () => { + const setQueryState = jest.fn(); + const savedQuery: SavedQuery = { + ...baseSavedQuery, + }; + populateStateFromSavedQuery(dataMock.query, setQueryState, savedQuery); + expect(setQueryState).toHaveBeenCalled(); + }); + + it('should set filters', async () => { + const setQueryState = jest.fn(); + const savedQuery: SavedQuery = { + ...baseSavedQuery, + }; + const f1 = getFilter(esFilters.FilterStateStore.APP_STATE, false, false, 'age', 34); + savedQuery.attributes.filters = [f1]; + populateStateFromSavedQuery(dataMock.query, setQueryState, savedQuery); + expect(setQueryState).toHaveBeenCalled(); + expect(dataMock.query.filterManager.setFilters).toHaveBeenCalledWith([f1]); + }); + + it('should preserve global filters', async () => { + const globalFilter = getFilter( + esFilters.FilterStateStore.GLOBAL_STATE, + false, + false, + 'age', + 34 + ); + dataMock.query.filterManager.getGlobalFilters = jest.fn().mockReturnValue([globalFilter]); + const setQueryState = jest.fn(); + const savedQuery: SavedQuery = { + ...baseSavedQuery, + }; + const f1 = getFilter(esFilters.FilterStateStore.APP_STATE, false, false, 'age', 34); + savedQuery.attributes.filters = [f1]; + populateStateFromSavedQuery(dataMock.query, setQueryState, savedQuery); + expect(setQueryState).toHaveBeenCalled(); + expect(dataMock.query.filterManager.setFilters).toHaveBeenCalledWith([globalFilter, f1]); + }); + + it('should update timefilter', async () => { + const savedQuery: SavedQuery = { + ...baseSavedQuery, + }; + savedQuery.attributes.timefilter = { + from: '2018', + to: '2019', + refreshInterval: { + pause: true, + value: 10, + }, + }; + + dataMock.query.timefilter.timefilter.setTime = jest.fn(); + dataMock.query.timefilter.timefilter.setRefreshInterval = jest.fn(); + + populateStateFromSavedQuery(dataMock.query, jest.fn(), savedQuery); + + expect(dataMock.query.timefilter.timefilter.setTime).toHaveBeenCalledWith({ + from: savedQuery.attributes.timefilter.from, + to: savedQuery.attributes.timefilter.to, + }); + expect(dataMock.query.timefilter.timefilter.setRefreshInterval).toHaveBeenCalledWith( + savedQuery.attributes.timefilter.refreshInterval + ); + }); +}); diff --git a/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.ts b/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.ts new file mode 100644 index 000000000000..fd1517097753 --- /dev/null +++ b/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.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 { QueryStart, SavedQuery } from '../../..'; + +export const populateStateFromSavedQuery = ( + queryService: QueryStart, + setQueryStringState: Function, + savedQuery: SavedQuery +) => { + const { + timefilter: { timefilter }, + filterManager, + } = queryService; + // timefilter + if (savedQuery.attributes.timefilter) { + timefilter.setTime({ + from: savedQuery.attributes.timefilter.from, + to: savedQuery.attributes.timefilter.to, + }); + if (savedQuery.attributes.timefilter.refreshInterval) { + timefilter.setRefreshInterval(savedQuery.attributes.timefilter.refreshInterval); + } + } + + // query string + setQueryStringState(savedQuery.attributes.query); + + // filters + const savedQueryFilters = savedQuery.attributes.filters || []; + const globalFilters = filterManager.getGlobalFilters(); + filterManager.setFilters([...globalFilters, ...savedQueryFilters]); +}; diff --git a/src/plugins/data/public/ui/search_bar/lib/use_filter_manager.ts b/src/plugins/data/public/ui/search_bar/lib/use_filter_manager.ts new file mode 100644 index 000000000000..e889583aef60 --- /dev/null +++ b/src/plugins/data/public/ui/search_bar/lib/use_filter_manager.ts @@ -0,0 +1,50 @@ +/* + * 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 { useState, useEffect } from 'react'; +import { Subscription } from 'rxjs'; +import { DataPublicPluginStart, esFilters } from '../../..'; + +interface UseFilterManagerProps { + filters?: esFilters.Filter[]; + filterManager: DataPublicPluginStart['query']['filterManager']; +} + +export const useFilterManager = (props: UseFilterManagerProps) => { + // Filters should be either what's passed in the initial state or the current state of the filter manager + const [filters, setFilters] = useState(props.filters || props.filterManager.getFilters()); + useEffect(() => { + const subscriptions = new Subscription(); + + subscriptions.add( + props.filterManager.getUpdates$().subscribe({ + next: () => { + const newFilters = props.filterManager.getFilters(); + setFilters(newFilters); + }, + }) + ); + + return () => { + subscriptions.unsubscribe(); + }; + }, [props.filterManager]); + + return { filters }; +}; diff --git a/src/plugins/data/public/ui/search_bar/lib/use_saved_query.ts b/src/plugins/data/public/ui/search_bar/lib/use_saved_query.ts new file mode 100644 index 000000000000..fdeeaab1dff0 --- /dev/null +++ b/src/plugins/data/public/ui/search_bar/lib/use_saved_query.ts @@ -0,0 +1,94 @@ +/* + * 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 { useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { CoreStart } from 'kibana/public'; +import { SavedQuery } from '../../../query'; +import { DataPublicPluginStart } from '../../..'; +import { populateStateFromSavedQuery } from './populate_state_from_saved_query'; +import { clearStateFromSavedQuery } from './clear_saved_query'; + +interface UseSavedQueriesProps { + queryService: DataPublicPluginStart['query']; + setQuery: Function; + notifications: CoreStart['notifications']; + uiSettings: CoreStart['uiSettings']; + savedQueryId?: string; +} + +interface UseSavedQueriesReturn { + savedQuery?: SavedQuery; + setSavedQuery: (savedQuery: SavedQuery) => void; + clearSavedQuery: () => void; +} + +export const useSavedQuery = (props: UseSavedQueriesProps): UseSavedQueriesReturn => { + // Handle saved queries + const defaultLanguage = props.uiSettings.get('search:queryLanguage'); + const [savedQuery, setSavedQuery] = useState(); + + // Effect is used to convert a saved query id into an object + useEffect(() => { + const fetchSavedQuery = async (savedQueryId: string) => { + try { + // fetch saved query + const newSavedQuery = await props.queryService.savedQueries.getSavedQuery(savedQueryId); + // Make sure we set the saved query to the most recent one + if (newSavedQuery && newSavedQuery.id === savedQueryId) { + setSavedQuery(newSavedQuery); + populateStateFromSavedQuery(props.queryService, props.setQuery, newSavedQuery); + } + } catch (error) { + // Clear saved query + setSavedQuery(undefined); + clearStateFromSavedQuery(props.queryService, props.setQuery, defaultLanguage); + // notify of saving error + props.notifications.toasts.addWarning({ + title: i18n.translate('data.search.unableToGetSavedQueryToastTitle', { + defaultMessage: 'Unable to load saved query {savedQueryId}', + values: { savedQueryId }, + }), + text: `${error.message}`, + }); + } + }; + + if (props.savedQueryId) fetchSavedQuery(props.savedQueryId); + }, [ + defaultLanguage, + props.notifications.toasts, + props.queryService, + props.queryService.savedQueries, + props.savedQueryId, + props.setQuery, + ]); + + return { + savedQuery, + setSavedQuery: (q: SavedQuery) => { + setSavedQuery(q); + populateStateFromSavedQuery(props.queryService, props.setQuery, q); + }, + clearSavedQuery: () => { + setSavedQuery(undefined); + clearStateFromSavedQuery(props.queryService, props.setQuery, defaultLanguage); + }, + }; +}; diff --git a/src/plugins/data/public/ui/search_bar/lib/use_timefilter.ts b/src/plugins/data/public/ui/search_bar/lib/use_timefilter.ts new file mode 100644 index 000000000000..942902ebd728 --- /dev/null +++ b/src/plugins/data/public/ui/search_bar/lib/use_timefilter.ts @@ -0,0 +1,61 @@ +/* + * 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 { useState, useEffect } from 'react'; +import { Subscription } from 'rxjs'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; + +interface UseTimefilterProps { + timefilter: DataPublicPluginStart['query']['timefilter']['timefilter']; +} + +export const useTimefilter = (props: UseTimefilterProps) => { + const [timeRange, setTimerange] = useState(props.timefilter.getTime()); + const [refreshInterval, setRefreshInterval] = useState(props.timefilter.getRefreshInterval()); + + useEffect(() => { + const subscriptions = new Subscription(); + + subscriptions.add( + props.timefilter.getRefreshIntervalUpdate$().subscribe({ + next: () => { + const newRefreshInterval = props.timefilter.getRefreshInterval(); + setRefreshInterval(newRefreshInterval); + }, + }) + ); + + subscriptions.add( + props.timefilter.getTimeUpdate$().subscribe({ + next: () => { + setTimerange(props.timefilter.getTime()); + }, + }) + ); + + return () => { + subscriptions.unsubscribe(); + }; + }, [props.timefilter]); + + return { + refreshInterval, + timeRange, + }; +}; diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index ceaeb24e7fe7..2f0cdb322912 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -47,7 +47,6 @@ interface SearchBarInjectedDeps { timeHistory: TimeHistoryContract; // Filter bar onFiltersUpdated?: (filters: esFilters.Filter[]) => void; - filters?: esFilters.Filter[]; // Date picker dateRangeFrom?: string; dateRangeTo?: string; @@ -69,13 +68,14 @@ export interface SearchBarOwnProps { showFilterBar?: boolean; showDatePicker?: boolean; showAutoRefreshOnly?: boolean; + filters?: esFilters.Filter[]; // Query bar - should be in SearchBarInjectedDeps query?: Query; // Show when user has privileges to save showSaveQuery?: boolean; savedQuery?: SavedQuery; onQueryChange?: (payload: { dateRange: TimeRange; query?: Query }) => void; - onQuerySubmit?: (payload: { dateRange: TimeRange; query?: Query }) => void; + onQuerySubmit?: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; // User has saved the current state as a saved query onSaved?: (savedQuery: SavedQuery) => void; // User has modified the saved query, your app should persist the update