diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts index d96d43eb9e7f6..f850954e84323 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts @@ -20,7 +20,7 @@ import Boom from 'boom'; import { getProperty, IndexMapping } from '../../../mappings'; -const TOP_LEVEL_FIELDS = ['_id']; +const TOP_LEVEL_FIELDS = ['_id', '_score']; export function getSortingParams( mappings: IndexMapping, diff --git a/src/legacy/core_plugins/data/index.ts b/src/legacy/core_plugins/data/index.ts index 0557fd82edbc8..a985d3f023108 100644 --- a/src/legacy/core_plugins/data/index.ts +++ b/src/legacy/core_plugins/data/index.ts @@ -19,6 +19,8 @@ import { resolve } from 'path'; import { Legacy } from '../../../../kibana'; +import { mappings } from './mappings'; +import { SavedQuery } from './public'; // eslint-disable-next-line import/no-default-export export default function DataPlugin(kibana: any) { @@ -35,6 +37,23 @@ export default function DataPlugin(kibana: any) { uiExports: { injectDefaultVars: () => ({}), styleSheetPaths: resolve(__dirname, 'public/index.scss'), + mappings, + savedObjectsManagement: { + query: { + icon: 'search', + defaultSearchField: 'title', + isImportableAndExportable: true, + getTitle(obj: SavedQuery) { + return obj.attributes.title; + }, + getInAppUrl(obj: SavedQuery) { + return { + path: `/app/kibana#/discover?_a=(savedQuery:'${encodeURIComponent(obj.id)}')`, + uiCapabilitiesPath: 'discover.show', + }; + }, + }, + }, }, }; diff --git a/src/legacy/core_plugins/data/mappings.ts b/src/legacy/core_plugins/data/mappings.ts new file mode 100644 index 0000000000000..90777ec8e3651 --- /dev/null +++ b/src/legacy/core_plugins/data/mappings.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. + */ + +export const mappings = { + query: { + properties: { + title: { + type: 'text', + }, + description: { + type: 'text', + }, + query: { + properties: { + language: { + type: 'keyword', + }, + query: { + type: 'keyword', + index: false, + }, + }, + }, + filters: { + type: 'object', + enabled: false, + }, + timefilter: { + type: 'object', + enabled: false, + }, + }, + }, +}; diff --git a/src/legacy/core_plugins/data/public/index.scss b/src/legacy/core_plugins/data/public/index.scss index 993e52665defa..14274d27c13ee 100644 --- a/src/legacy/core_plugins/data/public/index.scss +++ b/src/legacy/core_plugins/data/public/index.scss @@ -4,3 +4,4 @@ @import './filter/filter_bar/index'; +@import './search/search_bar/index'; diff --git a/src/legacy/core_plugins/data/public/index.ts b/src/legacy/core_plugins/data/public/index.ts index 7aab6ddbf23c3..4e1e83d71afe8 100644 --- a/src/legacy/core_plugins/data/public/index.ts +++ b/src/legacy/core_plugins/data/public/index.ts @@ -38,7 +38,7 @@ export { StaticIndexPattern, } from './index_patterns'; export { Query, QueryBar, QueryBarInput } from './query'; -export { SearchBar, SearchBarProps } from './search'; +export { SearchBar, SearchBarProps, SavedQueryAttributes, SavedQuery } from './search'; /** @public static code */ export * from '../common'; diff --git a/src/legacy/core_plugins/data/public/plugin.ts b/src/legacy/core_plugins/data/public/plugin.ts index 34edb355d2ef8..a38e55e8139ed 100644 --- a/src/legacy/core_plugins/data/public/plugin.ts +++ b/src/legacy/core_plugins/data/public/plugin.ts @@ -85,7 +85,7 @@ export class DataPlugin implements Plugin { indexPatterns={[mockIndexPattern]} store={createMockStorage()} intl={null as any} + onChange={noop} + isDirty={false} /> ); @@ -125,6 +127,8 @@ describe('QueryBar', () => { store={createMockStorage()} disableAutoFocus={true} intl={null as any} + onChange={noop} + isDirty={false} /> ); @@ -136,6 +140,8 @@ describe('QueryBar', () => { { { { uiSettings={setupMock.uiSettings} query={kqlQuery} onSubmit={noop} + onChange={noop} + isDirty={false} appName={'discover'} screenTitle={'Another Screen'} indexPatterns={[mockIndexPattern]} @@ -206,6 +218,8 @@ describe('QueryBar', () => { uiSettings={setupMock.uiSettings} query={kqlQuery} onSubmit={noop} + onChange={noop} + isDirty={false} appName={'discover'} screenTitle={'Another Screen'} indexPatterns={[mockIndexPattern]} @@ -225,6 +239,8 @@ describe('QueryBar', () => { void; + onChange: (payload: { dateRange: DateRange; query?: Query }) => void; disableAutoFocus?: boolean; appName: string; screenTitle?: string; indexPatterns?: Array; - store: Storage; + store?: Storage; intl: InjectedIntl; - prepend?: any; + prepend?: React.ReactNode; showQueryInput?: boolean; showDatePicker?: boolean; dateRangeFrom?: string; @@ -66,15 +65,11 @@ interface Props { showAutoRefreshOnly?: boolean; onRefreshChange?: (options: { isPaused: boolean; refreshInterval: number }) => void; customSubmitButton?: any; + isDirty: boolean; uiSettings: UiSettingsClientContract; } interface State { - query?: Query; - inputIsPristine: boolean; - currentProps?: Props; - dateRangeFrom: string; - dateRangeTo: string; isDateRangeInvalid: boolean; } @@ -85,71 +80,7 @@ export class QueryBarUI extends Component { showAutoRefreshOnly: false, }; - public static getDerivedStateFromProps(nextProps: Props, prevState: State) { - if (isEqual(prevState.currentProps, nextProps)) { - return null; - } - - let nextQuery = null; - if (nextProps.query && prevState.query) { - if (nextProps.query.query !== prevState.query.query) { - nextQuery = { - query: nextProps.query.query, - language: nextProps.query.language, - }; - } else if (nextProps.query.language !== prevState.query.language) { - nextQuery = { - query: '', - language: nextProps.query.language, - }; - } - } - - let nextDateRange = null; - if ( - nextProps.dateRangeFrom !== get(prevState, 'currentProps.dateRangeFrom') || - nextProps.dateRangeTo !== get(prevState, 'currentProps.dateRangeTo') - ) { - nextDateRange = { - dateRangeFrom: nextProps.dateRangeFrom, - dateRangeTo: nextProps.dateRangeTo, - }; - } - - const nextState: any = { - currentProps: nextProps, - }; - if (nextQuery) { - nextState.query = nextQuery; - } - if (nextDateRange) { - nextState.dateRangeFrom = nextDateRange.dateRangeFrom; - nextState.dateRangeTo = nextDateRange.dateRangeTo; - } - return nextState; - } - - /* - Keep the "draft" value in local state until the user actually submits the query. There are a couple advantages: - - 1. Each app doesn't have to maintain its own "draft" value if it wants to put off updating the query in app state - until the user manually submits their changes. Most apps have watches on the query value in app state so we don't - want to trigger those on every keypress. Also, some apps (e.g. dashboard) already juggle multiple query values, - each with slightly different semantics and I'd rather not add yet another variable to the mix. - - 2. Changes to the local component state won't trigger an Angular digest cycle. Triggering digest cycles on every - keypress has been a major source of performance issues for us in previous implementations of the query bar. - See https://github.com/elastic/kibana/issues/14086 - */ public state = { - query: this.props.query && { - query: this.props.query.query, - language: this.props.query.language, - }, - inputIsPristine: true, - currentProps: this.props, - dateRangeFrom: _.get(this.props, 'dateRangeFrom', 'now-15m'), - dateRangeTo: _.get(this.props, 'dateRangeTo', 'now'), isDateRangeInvalid: false, }; @@ -157,35 +88,26 @@ export class QueryBarUI extends Component { private persistedLog: PersistedLog | undefined; - private isQueryDirty = () => { - return ( - !!this.props.query && !!this.state.query && this.state.query.query !== this.props.query.query - ); - }; - - public isDirty = () => { - if (!this.props.showDatePicker) { - return this.isQueryDirty(); - } - - return ( - this.isQueryDirty() || - this.state.dateRangeFrom !== this.props.dateRangeFrom || - this.state.dateRangeTo !== this.props.dateRangeTo - ); - }; - public onClickSubmitButton = (event: React.MouseEvent) => { - if (this.persistedLog && this.state.query) { - this.persistedLog.add(this.state.query.query); + if (this.persistedLog && this.props.query) { + this.persistedLog.add(this.props.query.query); } - this.onSubmit(() => event.preventDefault()); + event.preventDefault(); + this.onSubmit({ query: this.props.query, dateRange: this.getDateRange() }); }; - public onChange = (query: Query) => { - this.setState({ + public getDateRange() { + const defaultTimeSetting = this.props.uiSettings.get('timepicker:timeDefaults'); + return { + from: this.props.dateRangeFrom || defaultTimeSetting.from, + to: this.props.dateRangeTo || defaultTimeSetting.to, + }; + } + + public onQueryChange = (query: Query) => { + this.props.onChange({ query, - inputIsPristine: false, + dateRange: this.getDateRange(), }); }; @@ -202,41 +124,37 @@ export class QueryBarUI extends Component { }) => { this.setState( { - dateRangeFrom: start, - dateRangeTo: end, isDateRangeInvalid: isInvalid, }, - () => isQuickSelection && this.onSubmit() + () => { + const retVal = { + query: this.props.query, + dateRange: { + from: start, + to: end, + }, + }; + + if (isQuickSelection) { + this.props.onSubmit(retVal); + } else { + this.props.onChange(retVal); + } + } ); }; - public onSubmit = (preventDefault?: () => void) => { - if (preventDefault) { - preventDefault(); - } - + public onSubmit = ({ query, dateRange }: { query?: Query; dateRange: DateRange }) => { this.handleLuceneSyntaxWarning(); + timeHistory.add(dateRange); - timeHistory.add({ - from: this.state.dateRangeFrom, - to: this.state.dateRangeTo, - }); - - this.props.onSubmit({ - query: this.state.query && { - query: this.state.query.query, - language: this.state.query.language, - }, - dateRange: { - from: this.state.dateRangeFrom, - to: this.state.dateRangeTo, - }, - }); + this.props.onSubmit({ query, dateRange }); }; private onInputSubmit = (query: Query) => { - this.setState({ query }, () => { - this.onSubmit(); + this.onSubmit({ + query, + dateRange: this.getDateRange(), }); }; @@ -287,10 +205,10 @@ export class QueryBarUI extends Component { disableAutoFocus={this.props.disableAutoFocus} indexPatterns={this.props.indexPatterns!} prepend={this.props.prepend} - query={this.state.query!} + query={this.props.query!} screenTitle={this.props.screenTitle} - store={this.props.store} - onChange={this.onChange} + store={this.props.store!} + onChange={this.onQueryChange} onSubmit={this.onInputSubmit} persistedLog={this.persistedLog} uiSettings={this.props.uiSettings} @@ -304,7 +222,9 @@ export class QueryBarUI extends Component { } private shouldRenderQueryInput() { - return this.props.showQueryInput && this.props.indexPatterns && this.props.query; + return ( + this.props.showQueryInput && this.props.indexPatterns && this.props.query && this.props.store + ); } private renderUpdateButton() { @@ -312,7 +232,7 @@ export class QueryBarUI extends Component { React.cloneElement(this.props.customSubmitButton, { onClick: this.onClickSubmitButton }) ) : ( { return ( { } private handleLuceneSyntaxWarning() { - if (!this.state.query) return; + if (!this.props.query) return; const { intl, store } = this.props; - const { query, language } = this.state.query; + const { query, language } = this.props.query; if ( language === 'kuery' && typeof query === 'string' && - !store.get('kibana.luceneSyntaxWarningOptOut') && + (!store || !store.get('kibana.luceneSyntaxWarningOptOut')) && doesKueryExpressionHaveLuceneSyntaxError(query) ) { const toast = toastNotifications.addWarning({ @@ -411,7 +331,10 @@ export class QueryBarUI extends Component { this.onLuceneSyntaxWarningOptOut(toast)}> - Don't show again + @@ -422,6 +345,7 @@ export class QueryBarUI extends Component { } private onLuceneSyntaxWarningOptOut(toast: Toast) { + if (!this.props.store) return; this.props.store.set('kibana.luceneSyntaxWarningOptOut', true); toastNotifications.remove(toast); } diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.tsx b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.tsx index b8a8331b705a9..a8538bf7cb8ac 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.tsx +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.tsx @@ -50,7 +50,7 @@ interface Props { appName: string; disableAutoFocus?: boolean; screenTitle?: string; - prepend?: any; + prepend?: React.ReactNode; persistedLog?: PersistedLog; bubbleSubmitEvent?: boolean; languageSwitcherPopoverAnchorPosition?: PopoverAnchorPosition; @@ -202,10 +202,8 @@ export class QueryBarInputUI extends Component { }; private onQueryStringChange = (value: string) => { - const hasValue = Boolean(value.trim()); - this.setState({ - isSuggestionsVisible: hasValue, + isSuggestionsVisible: true, index: null, suggestionLimit: 50, }); diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/typeahead/__snapshots__/suggestion_component.test.tsx.snap b/src/legacy/core_plugins/data/public/query/query_bar/components/typeahead/__snapshots__/suggestion_component.test.tsx.snap index 92ca296ed8058..38b570c86c6c5 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/typeahead/__snapshots__/suggestion_component.test.tsx.snap +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/typeahead/__snapshots__/suggestion_component.test.tsx.snap @@ -4,6 +4,7 @@ exports[`SuggestionComponent Should display the suggestion and use the provided
= props => { +export const SuggestionComponent: FunctionComponent = props => { return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
= props => { ref={props.innerRef} id={props.ariaId} aria-selected={props.selected} + data-test-subj={`autocompleteSuggestion-${ + props.suggestion.type + }-${props.suggestion.text.replace(/\s/g, '-')}`} >
diff --git a/src/legacy/core_plugins/data/public/search/search_bar/_index.scss b/src/legacy/core_plugins/data/public/search/search_bar/_index.scss new file mode 100644 index 0000000000000..94619b88c2ac2 --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/search_bar/_index.scss @@ -0,0 +1 @@ +@import 'components/saved_query_management/saved_query_management_component'; diff --git a/src/legacy/core_plugins/data/public/search/search_bar/components/saved_query_management/_saved_query_management_component.scss b/src/legacy/core_plugins/data/public/search/search_bar/components/saved_query_management/_saved_query_management_component.scss new file mode 100644 index 0000000000000..29d187b7ecb6a --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/search_bar/components/saved_query_management/_saved_query_management_component.scss @@ -0,0 +1,14 @@ +.saved-query-management-popover { + width: 400px; +} +.saved-query-list { + @include euiYScrollWithShadows; +} +.saved-query-list-wrapper { + height: 20vh; + overflow-y:hidden; +} +.saved-query-list li:first-child .saved-query-list-item-text { + font-weight: $euiFontWeightBold; +} + diff --git a/src/legacy/core_plugins/data/public/search/search_bar/components/saved_query_management/save_query_form.tsx b/src/legacy/core_plugins/data/public/search/search_bar/components/saved_query_management/save_query_form.tsx new file mode 100644 index 0000000000000..b9066cd8be622 --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/search_bar/components/saved_query_management/save_query_form.tsx @@ -0,0 +1,235 @@ +/* + * 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, useEffect, useState } from 'react'; +import { + EuiButtonEmpty, + EuiOverlayMask, + EuiModal, + EuiButton, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiSwitch, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { sortBy } from 'lodash'; +import { SavedQuery, SavedQueryAttributes } from '../../index'; +import { SavedQueryService } from '../../lib/saved_query_service'; + +interface Props { + savedQuery?: SavedQueryAttributes; + savedQueryService: SavedQueryService; + onSave: (savedQueryMeta: SavedQueryMeta) => void; + onClose: () => void; + showFilterOption: boolean | undefined; + showTimeFilterOption: boolean | undefined; +} + +export interface SavedQueryMeta { + title: string; + description: string; + shouldIncludeFilters: boolean; + shouldIncludeTimefilter: boolean; +} + +export const SaveQueryForm: FunctionComponent = ({ + savedQuery, + savedQueryService, + onSave, + onClose, + showFilterOption = true, + showTimeFilterOption = true, +}) => { + const [title, setTitle] = useState(savedQuery ? savedQuery.title : ''); + const [description, setDescription] = useState(savedQuery ? savedQuery.description : ''); + const [savedQueries, setSavedQueries] = useState([]); + const [shouldIncludeFilters, setShouldIncludeFilters] = useState( + savedQuery ? !!savedQuery.filters : true + ); + // Defaults to false because saved queries are meant to be as portable as possible and loading + // a saved query with a time filter will override whatever the current value of the global timepicker + // is. We expect this option to be used rarely and only when the user knows they want this behavior. + const [shouldIncludeTimefilter, setIncludeTimefilter] = useState( + savedQuery ? !!savedQuery.timefilter : false + ); + + useEffect(() => { + const fetchQueries = async () => { + const allSavedQueries = await savedQueryService.getAllSavedQueries(); + const sortedAllSavedQueries = sortBy(allSavedQueries, 'attributes.title') as SavedQuery[]; + setSavedQueries(sortedAllSavedQueries); + }; + fetchQueries(); + }, []); + + const savedQueryDescriptionText = i18n.translate( + 'data.search.searchBar.savedQueryDescriptionText', + { + defaultMessage: 'Save query text and filters that you want to use again.', + } + ); + + const hasTitleConflict = !!savedQueries.find( + existingSavedQuery => !savedQuery && existingSavedQuery.attributes.title === title + ); + + const hasWhitespaceError = title.length > title.trim().length; + + const titleConflictErrorText = i18n.translate( + 'data.search.searchBar.savedQueryForm.titleConflictText', + { + defaultMessage: 'Title conflicts with an existing saved query', + } + ); + const whitespaceErrorText = i18n.translate( + 'data.search.searchBar.savedQueryForm.whitespaceErrorText', + { + defaultMessage: 'Title cannot contain leading or trailing white space', + } + ); + const hasErrors = hasWhitespaceError || hasTitleConflict; + + const errors = () => { + if (hasWhitespaceError) return [whitespaceErrorText]; + if (hasTitleConflict) return [titleConflictErrorText]; + return []; + }; + const saveQueryForm = ( + + + {savedQueryDescriptionText} + + + { + setTitle(event.target.value); + }} + data-test-subj="saveQueryFormTitle" + isInvalid={hasErrors} + /> + + + + { + setDescription(event.target.value); + }} + data-test-subj="saveQueryFormDescription" + /> + + {showFilterOption && ( + + { + setShouldIncludeFilters(!shouldIncludeFilters); + }} + data-test-subj="saveQueryFormIncludeFiltersOption" + /> + + )} + + {showTimeFilterOption && ( + + { + setIncludeTimefilter(!shouldIncludeTimefilter); + }} + data-test-subj="saveQueryFormIncludeTimeFilterOption" + /> + + )} + + ); + + return ( + + + + + {i18n.translate('data.search.searchBar.savedQueryFormTitle', { + defaultMessage: 'Save query', + })} + + + + {saveQueryForm} + + + + {i18n.translate('data.search.searchBar.savedQueryFormCancelButtonText', { + defaultMessage: 'Cancel', + })} + + + + onSave({ + title, + description, + shouldIncludeFilters, + shouldIncludeTimefilter, + }) + } + fill + data-test-subj="savedQueryFormSaveButton" + disabled={hasErrors} + > + {i18n.translate('data.search.searchBar.savedQueryFormSaveButtonText', { + defaultMessage: 'Save', + })} + + + + + ); +}; diff --git a/src/legacy/core_plugins/data/public/search/search_bar/components/saved_query_management/saved_query_list_item.tsx b/src/legacy/core_plugins/data/public/search/search_bar/components/saved_query_management/saved_query_list_item.tsx new file mode 100644 index 0000000000000..c690fbaac4fb5 --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/search_bar/components/saved_query_management/saved_query_list_item.tsx @@ -0,0 +1,182 @@ +/* + * 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 { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiConfirmModal, + EuiOverlayMask, + EuiIconTip, + EuiToolTip, +} from '@elastic/eui'; + +import React, { Fragment, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { SavedQuery } from '../../index'; + +interface Props { + savedQuery: SavedQuery; + isSelected: boolean; + showWriteOperations: boolean; + onSelect: (savedQuery: SavedQuery) => void; + onDelete: (savedQuery: SavedQuery) => void; +} + +export const SavedQueryListItem = ({ + savedQuery, + isSelected, + onSelect, + onDelete, + showWriteOperations, +}: Props) => { + const [showDeletionConfirmationModal, setShowDeletionConfirmationModal] = useState(false); + + const selectButtonAriaLabelText = isSelected + ? i18n.translate( + 'data.search.searchBar.savedQueryPopoverSavedQueryListItemSelectedButtonAriaLabel', + { + defaultMessage: + 'Saved query button selected {savedQueryName}. Press to clear any changes.', + values: { savedQueryName: savedQuery.attributes.title }, + } + ) + : i18n.translate('data.search.searchBar.savedQueryPopoverSavedQueryListItemButtonAriaLabel', { + defaultMessage: 'Saved query button {savedQueryName}', + values: { savedQueryName: savedQuery.attributes.title }, + }); + + const selectButtonDataTestSubj = isSelected + ? `load-saved-query-${savedQuery.attributes.title}-button saved-query-list-item-selected` + : `load-saved-query-${savedQuery.attributes.title}-button`; + + return ( + +
  • + + + { + onSelect(savedQuery); + }} + flush="left" + data-test-subj={selectButtonDataTestSubj} + textProps={isSelected ? { className: 'saved-query-list-item-text' } : undefined} + aria-label={selectButtonAriaLabelText} + > + {savedQuery.attributes.title} + + + + + + {savedQuery.attributes.description && ( + + )} + + + + {showWriteOperations && ( + + + {i18n.translate( + 'data.search.searchBar.savedQueryPopoverDeleteButtonTooltip', + { + defaultMessage: 'Delete saved query', + } + )} +

    + } + > + { + setShowDeletionConfirmationModal(true); + }} + iconType="trash" + color="danger" + aria-label={i18n.translate( + 'data.search.searchBar.savedQueryPopoverDeleteButtonAriaLabel', + { + defaultMessage: 'Delete saved query {savedQueryName}', + values: { savedQueryName: savedQuery.attributes.title }, + } + )} + data-test-subj={`delete-saved-query-${savedQuery.attributes.title}-button`} + /> +
    +
    + )} +
    +
    +
    +
    +
  • + + {showDeletionConfirmationModal && ( + + { + onDelete(savedQuery); + setShowDeletionConfirmationModal(false); + }} + onCancel={() => { + setShowDeletionConfirmationModal(false); + }} + /> + + )} +
    + ); +}; diff --git a/src/legacy/core_plugins/data/public/search/search_bar/components/saved_query_management/saved_query_management_component.tsx b/src/legacy/core_plugins/data/public/search/search_bar/components/saved_query_management/saved_query_management_component.tsx new file mode 100644 index 0000000000000..d9e68119a2797 --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/search_bar/components/saved_query_management/saved_query_management_component.tsx @@ -0,0 +1,293 @@ +/* + * 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 { + EuiPopover, + EuiPopoverTitle, + EuiButtonEmpty, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiPagination, + EuiText, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import React, { FunctionComponent, useEffect, useState, Fragment } from 'react'; +import { sortBy } from 'lodash'; +import { SavedQuery } from '../../index'; +import { SavedQueryService } from '../../lib/saved_query_service'; +import { SavedQueryListItem } from './saved_query_list_item'; + +const pageCount = 50; + +interface Props { + showSaveQuery?: boolean; + loadedSavedQuery?: SavedQuery; + savedQueryService: SavedQueryService; + onSave: () => void; + onSaveAsNew: () => void; + onLoad: (savedQuery: SavedQuery) => void; + onClearSavedQuery: () => void; +} + +export const SavedQueryManagementComponent: FunctionComponent = ({ + showSaveQuery, + loadedSavedQuery, + onSave, + onSaveAsNew, + onLoad, + onClearSavedQuery, + savedQueryService, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [savedQueries, setSavedQueries] = useState([] as SavedQuery[]); + const [activePage, setActivePage] = useState(0); + + useEffect(() => { + const fetchQueries = async () => { + const allSavedQueries = await savedQueryService.getAllSavedQueries(); + const sortedAllSavedQueries = sortBy(allSavedQueries, 'attributes.title'); + setSavedQueries(sortedAllSavedQueries); + }; + if (isOpen) { + fetchQueries(); + } + }, [isOpen]); + + const goToPage = (pageNumber: number) => { + setActivePage(pageNumber); + }; + + const savedQueryDescriptionText = i18n.translate( + 'data.search.searchBar.savedQueryDescriptionText', + { + defaultMessage: 'Save query text and filters that you want to use again.', + } + ); + + const noSavedQueriesDescriptionText = + i18n.translate('data.search.searchBar.savedQueryNoSavedQueriesText', { + defaultMessage: 'There are no saved queries.', + }) + + ' ' + + savedQueryDescriptionText; + + const savedQueryPopoverTitleText = i18n.translate( + 'data.search.searchBar.savedQueryPopoverTitleText', + { + defaultMessage: 'Saved Queries', + } + ); + + const onDeleteSavedQuery = async (savedQuery: SavedQuery) => { + setSavedQueries( + savedQueries.filter(currentSavedQuery => currentSavedQuery.id !== savedQuery.id) + ); + + if (loadedSavedQuery && loadedSavedQuery.id === savedQuery.id) { + onClearSavedQuery(); + } + + await savedQueryService.deleteSavedQuery(savedQuery.id); + }; + + const savedQueryPopoverButton = ( + { + setIsOpen(!isOpen); + }} + aria-label={i18n.translate('data.search.searchBar.savedQueryPopoverButtonText', { + defaultMessage: 'See saved queries', + })} + data-test-subj="saved-query-management-popover-button" + > + # + + ); + + const savedQueryRows = () => { + // we should be recalculating the savedQueryRows after a delete action + const savedQueriesWithoutCurrent = savedQueries.filter(savedQuery => { + if (!loadedSavedQuery) return true; + return savedQuery.id !== loadedSavedQuery.id; + }); + const savedQueriesReordered = + loadedSavedQuery && savedQueriesWithoutCurrent.length !== savedQueries.length + ? [loadedSavedQuery, ...savedQueriesWithoutCurrent] + : [...savedQueriesWithoutCurrent]; + const savedQueriesDisplayRows = savedQueriesReordered.slice( + activePage * pageCount, + activePage * pageCount + pageCount + ); + return savedQueriesDisplayRows.map(savedQuery => ( + { + onLoad(savedQueryToSelect); + setIsOpen(false); + }} + onDelete={savedQueryToDelete => onDeleteSavedQuery(savedQueryToDelete)} + showWriteOperations={!!showSaveQuery} + /> + )); + }; + + return ( + + { + setIsOpen(false); + }} + anchorPosition="downLeft" + ownFocus + > +
    + + {savedQueryPopoverTitleText} + + {savedQueries.length > 0 ? ( + + + + {savedQueryDescriptionText} + + + + +
      + {savedQueryRows()} +
    +
    +
    + + + + + +
    + ) : ( + {noSavedQueriesDescriptionText} + )} + + {showSaveQuery && loadedSavedQuery && ( + + + + onSaveAsNew()} + aria-label={i18n.translate( + 'data.search.searchBar.savedQueryPopoverSaveAsNewButtonAriaLabel', + { + defaultMessage: 'Save as a new saved query', + } + )} + data-test-subj="saved-query-management-save-as-new-button" + > + {i18n.translate( + 'data.search.searchBar.savedQueryPopoverSaveAsNewButtonText', + { + defaultMessage: 'Save as new', + } + )} + + + + + onSave()} + aria-label={i18n.translate( + 'data.search.searchBar.savedQueryPopoverSaveChangesButtonAriaLabel', + { + defaultMessage: 'Save changes to {title}', + values: { title: loadedSavedQuery.attributes.title }, + } + )} + data-test-subj="saved-query-management-save-changes-button" + > + {i18n.translate( + 'data.search.searchBar.savedQueryPopoverSaveChangesButtonText', + { + defaultMessage: 'Save changes', + } + )} + + + + + )} + {showSaveQuery && !loadedSavedQuery && ( + + onSave()} + aria-label={i18n.translate( + 'data.search.searchBar.savedQueryPopoverSaveButtonAriaLabel', + { defaultMessage: 'Save a new saved query' } + )} + data-test-subj="saved-query-management-save-button" + > + {i18n.translate('data.search.searchBar.savedQueryPopoverSaveButtonText', { + defaultMessage: 'Save', + })} + + + )} + + + {loadedSavedQuery && ( + onClearSavedQuery()} + aria-label={i18n.translate( + 'data.search.searchBar.savedQueryPopoverClearButtonAriaLabel', + { defaultMessage: 'Clear current saved query' } + )} + data-test-subj="saved-query-management-clear-button" + > + {i18n.translate('data.search.searchBar.savedQueryPopoverClearButtonText', { + defaultMessage: 'Clear', + })} + + )} + + +
    +
    +
    + ); +}; diff --git a/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.test.tsx b/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.test.tsx index 5026bd04afded..7aa4767e21dba 100644 --- a/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.test.tsx +++ b/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.test.tsx @@ -32,6 +32,13 @@ jest.mock('../../../../../data/public', () => { }; }); +jest.mock('ui/notify', () => ({ + toastNotifications: { + addSuccess: () => {}, + addDanger: () => {}, + }, +})); + const noop = jest.fn(); const createMockWebStorage = () => ({ @@ -66,6 +73,14 @@ const mockIndexPattern = { ], } as IndexPattern; +const mockSavedQueryService = { + saveQuery: jest.fn(), + getAllSavedQueries: jest.fn(), + findSavedQueries: jest.fn(), + getSavedQuery: jest.fn(), + deleteSavedQuery: jest.fn(), +}; + const kqlQuery = { query: 'response:200', language: 'kuery', @@ -84,6 +99,7 @@ describe('SearchBar', () => { const component = mountWithIntl( { const component = mountWithIntl( { const component = mountWithIntl( { const component = mountWithIntl( { const component = mountWithIntl( { const component = mountWithIntl( { const component = mountWithIntl( void; // Filter bar showFilterBar?: boolean; @@ -63,11 +68,23 @@ export interface SearchBarProps { isRefreshPaused?: boolean; refreshInterval?: number; showAutoRefreshOnly?: boolean; + showSaveQuery?: boolean; onRefreshChange?: (options: { isPaused: boolean; refreshInterval: number }) => void; + onSaved?: (savedQuery: SavedQuery) => void; + onSavedQueryUpdated?: (savedQuery: SavedQuery) => void; + onClearSavedQuery?: () => void; + customSubmitButton?: React.ReactNode; } interface State { isFiltersVisible: boolean; + showSaveQueryModal: boolean; + showSaveNewQueryModal: boolean; + showSavedQueryPopover: boolean; + currentProps?: SearchBarProps; + query?: Query; + dateRangeFrom: string; + dateRangeTo: string; } class SearchBarUI extends Component { @@ -81,15 +98,86 @@ class SearchBarUI extends Component { public filterBarRef: Element | null = null; public filterBarWrapperRef: Element | null = null; + public static getDerivedStateFromProps(nextProps: SearchBarProps, prevState: State) { + if (isEqual(prevState.currentProps, nextProps)) { + return null; + } + + let nextQuery = null; + if (nextProps.query && nextProps.query.query !== get(prevState, 'currentProps.query.query')) { + nextQuery = { + query: nextProps.query.query, + language: nextProps.query.language, + }; + } else if ( + nextProps.query && + prevState.query && + nextProps.query.language !== prevState.query.language + ) { + nextQuery = { + query: '', + language: nextProps.query.language, + }; + } + + let nextDateRange = null; + if ( + nextProps.dateRangeFrom !== get(prevState, 'currentProps.dateRangeFrom') || + nextProps.dateRangeTo !== get(prevState, 'currentProps.dateRangeTo') + ) { + nextDateRange = { + dateRangeFrom: nextProps.dateRangeFrom, + dateRangeTo: nextProps.dateRangeTo, + }; + } + + const nextState: any = { + currentProps: nextProps, + }; + if (nextQuery) { + nextState.query = nextQuery; + } + if (nextDateRange) { + nextState.dateRangeFrom = nextDateRange.dateRangeFrom; + nextState.dateRangeTo = nextDateRange.dateRangeTo; + } + return nextState; + } + + /* + Keep the "draft" value in local state until the user actually submits the query. There are a couple advantages: + + 1. Each app doesn't have to maintain its own "draft" value if it wants to put off updating the query in app state + until the user manually submits their changes. Most apps have watches on the query value in app state so we don't + want to trigger those on every keypress. Also, some apps (e.g. dashboard) already juggle multiple query values, + each with slightly different semantics and I'd rather not add yet another variable to the mix. + + 2. Changes to the local component state won't trigger an Angular digest cycle. Triggering digest cycles on every + keypress has been a major source of performance issues for us in previous implementations of the query bar. + See https://github.com/elastic/kibana/issues/14086 + */ public state = { isFiltersVisible: true, + showSaveQueryModal: false, + showSaveNewQueryModal: false, + showSavedQueryPopover: false, + currentProps: this.props, + query: this.props.query ? { ...this.props.query } : undefined, + dateRangeFrom: get(this.props, 'dateRangeFrom', 'now-15m'), + dateRangeTo: get(this.props, 'dateRangeTo', 'now'), }; - private getFilterLength() { - if (this.props.showFilterBar && this.props.filters) { - return this.props.filters.length; + public isDirty = () => { + if (!this.props.showDatePicker && this.state.query && this.props.query) { + return this.state.query.query !== this.props.query.query; } - } + + return ( + (this.state.query && this.props.query && this.state.query.query !== this.props.query.query) || + this.state.dateRangeFrom !== this.props.dateRangeFrom || + this.state.dateRangeTo !== this.props.dateRangeTo + ); + }; private getFilterUpdateFunction() { if (this.props.showFilterBar && this.props.onFiltersUpdated) { @@ -101,7 +189,7 @@ class SearchBarUI extends Component { private shouldRenderQueryBar() { const showDatePicker = this.props.showDatePicker || this.props.showAutoRefreshOnly; const showQueryInput = - this.props.showQueryInput && this.props.indexPatterns && this.props.query; + this.props.showQueryInput && this.props.indexPatterns && this.state.query; return this.props.showQueryBar && (showDatePicker || showQueryInput); } @@ -109,46 +197,6 @@ class SearchBarUI extends Component { return this.props.showFilterBar && this.props.filters && this.props.indexPatterns; } - private getFilterTriggerButton() { - const filterCount = this.getFilterLength(); - const filtersAppliedText = this.props.intl.formatMessage( - { - id: 'data.search.searchBar.searchBar.filtersButtonFiltersAppliedTitle', - defaultMessage: - '{filterCount} {filterCount, plural, one {filter} other {filters}} applied.', - }, - { - filterCount, - } - ); - const clickToShowOrHideText = this.state.isFiltersVisible - ? this.props.intl.formatMessage({ - id: 'data.search.searchBar.searchBar.filtersButtonClickToShowTitle', - defaultMessage: 'Select to hide', - }) - : this.props.intl.formatMessage({ - id: 'data.search.searchBar.searchBar.filtersButtonClickToHideTitle', - defaultMessage: 'Select to show', - }); - - return ( - - {i18n.translate('data.search.searchBar.searchBar.filtersButtonLabel', { - defaultMessage: 'Filters', - description: 'The noun "filter" in plural.', - })} - - ); - } - public setFilterBarHeight = () => { requestAnimationFrame(() => { const height = @@ -164,12 +212,131 @@ class SearchBarUI extends Component { public ro = new ResizeObserver(this.setFilterBarHeight); /* eslint-enable */ - public toggleFiltersVisible = () => { + public onSave = async (savedQueryMeta: SavedQueryMeta, saveAsNew = false) => { + if (!this.state.query) return; + + const savedQueryAttributes: SavedQueryAttributes = { + title: savedQueryMeta.title, + description: savedQueryMeta.description, + query: this.state.query, + }; + + if (savedQueryMeta.shouldIncludeFilters) { + savedQueryAttributes.filters = this.props.filters; + } + + if ( + savedQueryMeta.shouldIncludeTimefilter && + this.state.dateRangeTo !== undefined && + this.state.dateRangeFrom !== undefined && + this.props.refreshInterval !== undefined && + this.props.isRefreshPaused !== undefined + ) { + savedQueryAttributes.timefilter = { + from: this.state.dateRangeFrom, + to: this.state.dateRangeTo, + refreshInterval: { + value: this.props.refreshInterval, + pause: this.props.isRefreshPaused, + }, + }; + } + + try { + let response; + if (this.props.savedQuery && !saveAsNew) { + response = await this.props.savedQueryService.saveQuery(savedQueryAttributes, { + overwrite: true, + }); + } else { + response = await this.props.savedQueryService.saveQuery(savedQueryAttributes); + } + + toastNotifications.addSuccess(`Your query "${response.attributes.title}" was saved`); + + this.setState({ + showSaveQueryModal: false, + showSaveNewQueryModal: false, + }); + + if (this.props.onSaved) { + this.props.onSaved(response); + } + + if (this.props.onQuerySubmit) { + this.props.onQuerySubmit({ + query: this.state.query, + dateRange: { + from: this.state.dateRangeFrom, + to: this.state.dateRangeTo, + }, + }); + } + } catch (error) { + toastNotifications.addDanger(`An error occured while saving your query: ${error.message}`); + throw error; + } + }; + + public onInitiateSave = () => { + this.setState({ + showSaveQueryModal: true, + }); + }; + + public onInitiateSaveNew = () => { + this.setState({ + showSaveNewQueryModal: true, + }); + }; + + public onQueryBarChange = (queryAndDateRange: { dateRange: DateRange; query?: Query }) => { this.setState({ - isFiltersVisible: !this.state.isFiltersVisible, + query: queryAndDateRange.query, + dateRangeFrom: queryAndDateRange.dateRange.from, + dateRangeTo: queryAndDateRange.dateRange.to, }); }; + public onQueryBarSubmit = (queryAndDateRange: { dateRange?: DateRange; query?: Query }) => { + this.setState( + { + query: queryAndDateRange.query, + dateRangeFrom: + (queryAndDateRange.dateRange && queryAndDateRange.dateRange.from) || + this.state.dateRangeFrom, + dateRangeTo: + (queryAndDateRange.dateRange && queryAndDateRange.dateRange.to) || this.state.dateRangeTo, + }, + () => { + if (this.props.onQuerySubmit) { + this.props.onQuerySubmit({ + query: this.state.query, + dateRange: { + from: this.state.dateRangeFrom, + to: this.state.dateRangeTo, + }, + }); + } + } + ); + }; + + public onLoadSavedQuery = (savedQuery: SavedQuery) => { + const dateRangeFrom = get(savedQuery, 'attributes.timefilter.from', this.state.dateRangeFrom); + const dateRangeTo = get(savedQuery, 'attributes.timefilter.to', this.state.dateRangeTo); + + this.setState({ + query: savedQuery.attributes.query, + dateRangeFrom, + dateRangeTo, + }); + + if (this.props.onSavedQueryUpdated) { + this.props.onSavedQueryUpdated(savedQuery); + } + }; + public componentDidMount() { if (this.filterBarRef) { this.setFilterBarHeight(); @@ -191,26 +358,43 @@ class SearchBarUI extends Component { return null; } + const savedQueryManagement = this.state.query && this.props.onClearSavedQuery && ( + + ); + let queryBar; if (this.shouldRenderQueryBar()) { queryBar = ( ); } @@ -249,6 +433,26 @@ class SearchBarUI extends Component {
    {queryBar} {filterBar} + + {this.state.showSaveQueryModal ? ( + this.setState({ showSaveQueryModal: false })} + showFilterOption={this.props.showFilterBar} + showTimeFilterOption={this.props.showDatePicker} + /> + ) : null} + {this.state.showSaveNewQueryModal ? ( + this.onSave(savedQueryMeta, true)} + onClose={() => this.setState({ showSaveNewQueryModal: false })} + showFilterOption={this.props.showFilterBar} + showTimeFilterOption={this.props.showDatePicker} + /> + ) : null}
    ); } diff --git a/src/legacy/core_plugins/data/public/search/search_bar/index.tsx b/src/legacy/core_plugins/data/public/search/search_bar/index.tsx index faf6e24aa6ed5..d110c420cdc07 100644 --- a/src/legacy/core_plugins/data/public/search/search_bar/index.tsx +++ b/src/legacy/core_plugins/data/public/search/search_bar/index.tsx @@ -17,4 +17,25 @@ * under the License. */ +import { Filter } from '@kbn/es-query'; +import { RefreshInterval, TimeRange } from 'ui/timefilter/timefilter'; +import { Query } from '../../query/query_bar'; + export * from './components'; + +type SavedQueryTimeFilter = TimeRange & { + refreshInterval: RefreshInterval; +}; + +export interface SavedQuery { + id: string; + attributes: SavedQueryAttributes; +} + +export interface SavedQueryAttributes { + title: string; + description: string; + query: Query; + filters?: Filter[]; + timefilter?: SavedQueryTimeFilter; +} diff --git a/src/legacy/core_plugins/data/public/search/search_bar/lib/saved_query_service.test.ts b/src/legacy/core_plugins/data/public/search/search_bar/lib/saved_query_service.test.ts new file mode 100644 index 0000000000000..cbf78aab31d8a --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/search_bar/lib/saved_query_service.test.ts @@ -0,0 +1,220 @@ +/* + * 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 { SavedQueryAttributes } from '../index'; +import { createSavedQueryService } from './saved_query_service'; +import { FilterStateStore } from '@kbn/es-query'; + +const savedQueryAttributes: SavedQueryAttributes = { + title: 'foo', + description: 'bar', + query: { + language: 'kuery', + query: 'response:200', + }, +}; + +const savedQueryAttributesWithFilters: SavedQueryAttributes = { + ...savedQueryAttributes, + filters: [ + { + query: { match_all: {} }, + $state: { store: FilterStateStore.APP_STATE }, + meta: { + disabled: false, + negate: false, + alias: null, + }, + }, + ], + timefilter: { + to: 'now', + from: 'now-15m', + refreshInterval: { + pause: false, + value: 0, + }, + }, +}; + +const mockSavedObjectsClient = { + create: jest.fn(), + error: jest.fn(), + find: jest.fn(), + get: jest.fn(), + delete: jest.fn(), +}; + +const { deleteSavedQuery, getSavedQuery, findSavedQueries, saveQuery } = createSavedQueryService( + // @ts-ignore + mockSavedObjectsClient +); + +describe('saved query service', () => { + afterEach(() => { + mockSavedObjectsClient.create.mockReset(); + mockSavedObjectsClient.find.mockReset(); + mockSavedObjectsClient.get.mockReset(); + mockSavedObjectsClient.delete.mockReset(); + }); + + describe('saveQuery', function() { + it('should create a saved object for the given attributes', async () => { + mockSavedObjectsClient.create.mockReturnValue({ + id: 'foo', + attributes: savedQueryAttributes, + }); + + const response = await saveQuery(savedQueryAttributes); + expect(mockSavedObjectsClient.create).toHaveBeenCalledWith('query', savedQueryAttributes, { + id: 'foo', + }); + expect(response).toEqual({ id: 'foo', attributes: savedQueryAttributes }); + }); + + it('should allow overwriting an existing saved query', async () => { + mockSavedObjectsClient.create.mockReturnValue({ + id: 'foo', + attributes: savedQueryAttributes, + }); + + const response = await saveQuery(savedQueryAttributes, { overwrite: true }); + expect(mockSavedObjectsClient.create).toHaveBeenCalledWith('query', savedQueryAttributes, { + id: 'foo', + overwrite: true, + }); + expect(response).toEqual({ id: 'foo', attributes: savedQueryAttributes }); + }); + + it('should optionally accept filters and timefilters in object format', async () => { + const serializedSavedQueryAttributesWithFilters = { + ...savedQueryAttributesWithFilters, + filters: savedQueryAttributesWithFilters.filters, + timefilter: savedQueryAttributesWithFilters.timefilter, + }; + + mockSavedObjectsClient.create.mockReturnValue({ + id: 'foo', + attributes: serializedSavedQueryAttributesWithFilters, + }); + + const response = await saveQuery(savedQueryAttributesWithFilters); + + expect(mockSavedObjectsClient.create).toHaveBeenCalledWith( + 'query', + serializedSavedQueryAttributesWithFilters, + { id: 'foo' } + ); + expect(response).toEqual({ id: 'foo', attributes: savedQueryAttributesWithFilters }); + }); + + it('should throw an error when saved objects client returns error', async () => { + mockSavedObjectsClient.create.mockReturnValue({ + error: { + error: '123', + message: 'An Error', + }, + }); + + let error = null; + try { + await saveQuery(savedQueryAttributes); + } catch (e) { + error = e; + } + expect(error).not.toBe(null); + }); + }); + describe('findSavedQueries', function() { + it('should find and return saved queries without search text', async () => { + mockSavedObjectsClient.find.mockReturnValue({ + savedObjects: [{ id: 'foo', attributes: savedQueryAttributes }], + }); + + const response = await findSavedQueries(); + expect(response).toEqual([{ id: 'foo', attributes: savedQueryAttributes }]); + }); + + it('should find and return saved queries with search text matching the title field', async () => { + mockSavedObjectsClient.find.mockReturnValue({ + savedObjects: [{ id: 'foo', attributes: savedQueryAttributes }], + }); + const response = await findSavedQueries('foo'); + expect(mockSavedObjectsClient.find).toHaveBeenCalledWith({ + search: 'foo', + searchFields: ['title^5', 'description'], + sortField: '_score', + type: 'query', + }); + expect(response).toEqual([{ id: 'foo', attributes: savedQueryAttributes }]); + }); + it('should find and return parsed filters and timefilters items', async () => { + const serializedSavedQueryAttributesWithFilters = { + ...savedQueryAttributesWithFilters, + filters: savedQueryAttributesWithFilters.filters, + timefilter: savedQueryAttributesWithFilters.timefilter, + }; + mockSavedObjectsClient.find.mockReturnValue({ + savedObjects: [{ id: 'foo', attributes: serializedSavedQueryAttributesWithFilters }], + }); + const response = await findSavedQueries('bar'); + expect(response).toEqual([{ id: 'foo', attributes: savedQueryAttributesWithFilters }]); + }); + it('should return an array of saved queries', async () => { + mockSavedObjectsClient.find.mockReturnValue({ + savedObjects: [{ id: 'foo', attributes: savedQueryAttributes }], + }); + const response = await findSavedQueries(); + expect(response).toEqual( + expect.objectContaining([ + { + attributes: { + description: 'bar', + query: { language: 'kuery', query: 'response:200' }, + title: 'foo', + }, + id: 'foo', + }, + ]) + ); + }); + }); + + describe('getSavedQuery', function() { + it('should retrieve a saved query by id', async () => { + mockSavedObjectsClient.get.mockReturnValue({ id: 'foo', attributes: savedQueryAttributes }); + + const response = await getSavedQuery('foo'); + expect(response).toEqual({ id: 'foo', attributes: savedQueryAttributes }); + }); + it('should only return saved queries', async () => { + mockSavedObjectsClient.get.mockReturnValue({ id: 'foo', attributes: savedQueryAttributes }); + + await getSavedQuery('foo'); + expect(mockSavedObjectsClient.get).toHaveBeenCalledWith('query', 'foo'); + }); + }); + + describe('deleteSavedQuery', function() { + it('should delete the saved query for the given ID', async () => { + await deleteSavedQuery('foo'); + expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith('query', 'foo'); + }); + }); +}); diff --git a/src/legacy/core_plugins/data/public/search/search_bar/lib/saved_query_service.ts b/src/legacy/core_plugins/data/public/search/search_bar/lib/saved_query_service.ts new file mode 100644 index 0000000000000..9a68b40e5be2e --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/search_bar/lib/saved_query_service.ts @@ -0,0 +1,159 @@ +/* + * 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 { SavedObjectAttributes } from 'src/core/server'; +import { SavedObjectsClientContract } from 'src/core/public'; +import { SavedQueryAttributes, SavedQuery } from '../index'; + +type SerializedSavedQueryAttributes = SavedObjectAttributes & + SavedQueryAttributes & { + query: { + query: string; + language: string; + }; + }; + +export interface SavedQueryService { + saveQuery: ( + attributes: SavedQueryAttributes, + config?: { overwrite: boolean } + ) => Promise; + getAllSavedQueries: () => Promise; + findSavedQueries: (searchText?: string) => Promise; + getSavedQuery: (id: string) => Promise; + deleteSavedQuery: (id: string) => Promise<{}>; +} + +export const createSavedQueryService = ( + savedObjectsClient: SavedObjectsClientContract +): SavedQueryService => { + const saveQuery = async (attributes: SavedQueryAttributes, { overwrite = false } = {}) => { + const query = { + query: + typeof attributes.query.query === 'string' + ? attributes.query.query + : JSON.stringify(attributes.query.query), + language: attributes.query.language, + }; + + const queryObject: SerializedSavedQueryAttributes = { + title: attributes.title.trim(), // trim whitespace before save as an extra precaution against circumventing the front end + description: attributes.description, + query, + }; + + if (attributes.filters) { + queryObject.filters = attributes.filters; + } + + if (attributes.timefilter) { + queryObject.timefilter = attributes.timefilter; + } + + let rawQueryResponse; + if (!overwrite) { + rawQueryResponse = await savedObjectsClient.create('query', queryObject, { + id: attributes.title, + }); + } else { + rawQueryResponse = await savedObjectsClient.create('query', queryObject, { + id: attributes.title, + overwrite: true, + }); + } + + if (rawQueryResponse.error) { + throw new Error(rawQueryResponse.error.message); + } + + return parseSavedQueryObject(rawQueryResponse); + }; + + const getAllSavedQueries = async (): Promise => { + const response = await savedObjectsClient.find({ + type: 'query', + }); + + return response.savedObjects.map( + (savedObject: { id: string; attributes: SerializedSavedQueryAttributes }) => + parseSavedQueryObject(savedObject) + ); + }; + + const findSavedQueries = async (searchText: string = ''): Promise => { + const response = await savedObjectsClient.find({ + type: 'query', + search: searchText, + searchFields: ['title^5', 'description'], + sortField: '_score', + }); + + return response.savedObjects.map( + (savedObject: { id: string; attributes: SerializedSavedQueryAttributes }) => + parseSavedQueryObject(savedObject) + ); + }; + + const getSavedQuery = async (id: string): Promise => { + const response = await savedObjectsClient.get('query', id); + return parseSavedQueryObject(response); + }; + + const deleteSavedQuery = async (id: string) => { + return await savedObjectsClient.delete('query', id); + }; + + const parseSavedQueryObject = (savedQuery: { + id: string; + attributes: SerializedSavedQueryAttributes; + }) => { + let queryString; + try { + queryString = JSON.parse(savedQuery.attributes.query.query); + } catch (error) { + queryString = savedQuery.attributes.query.query; + } + const savedQueryItems: SavedQueryAttributes = { + title: savedQuery.attributes.title || '', + description: savedQuery.attributes.description || '', + query: { + query: queryString, + language: savedQuery.attributes.query.language, + }, + }; + if (savedQuery.attributes.filters) { + savedQueryItems.filters = savedQuery.attributes.filters; + } + if (savedQuery.attributes.timefilter) { + savedQueryItems.timefilter = savedQuery.attributes.timefilter; + } + return { + id: savedQuery.id, + attributes: savedQueryItems, + }; + }; + + return { + saveQuery, + getAllSavedQueries, + findSavedQueries, + getSavedQuery, + deleteSavedQuery, + }; +}; diff --git a/src/legacy/core_plugins/data/public/search/search_service.ts b/src/legacy/core_plugins/data/public/search/search_service.ts index efebe89180ced..2bb996d0fd45f 100644 --- a/src/legacy/core_plugins/data/public/search/search_service.ts +++ b/src/legacy/core_plugins/data/public/search/search_service.ts @@ -17,14 +17,21 @@ * under the License. */ +import { SavedObjectsClientContract } from 'src/core/public'; +import { createSavedQueryService } from './search_bar/lib/saved_query_service'; + /** * Search Service * @internal */ export class SearchService { - public setup() { - return {}; + public setup(savedObjectsClient: SavedObjectsClientContract) { + return { + services: { + savedQueryService: createSavedQueryService(savedObjectsClient), + }, + }; } public stop() {} diff --git a/src/legacy/core_plugins/kibana/index.js b/src/legacy/core_plugins/kibana/index.js index 83b820ef2d789..f76b7e1734278 100644 --- a/src/legacy/core_plugins/kibana/index.js +++ b/src/legacy/core_plugins/kibana/index.js @@ -173,7 +173,7 @@ export default function (kibana) { }, }, search: { - icon: 'search', + icon: 'discoverApp', defaultSearchField: 'title', isImportableAndExportable: true, getTitle(obj) { @@ -268,17 +268,20 @@ export default function (kibana) { show: true, createShortUrl: true, save: true, + saveQuery: true, }, visualize: { show: true, createShortUrl: true, delete: true, save: true, + saveQuery: true, }, dashboard: { createNew: true, show: true, showWriteControls: true, + saveQuery: true, }, catalogue: { discover: true, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html index b6dcfd1e4de55..5ceb28e6b225b 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html @@ -3,16 +3,18 @@ ng-class="{'dshAppContainer--withMargins': model.useMargins}" > - diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.tsx b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.tsx index 9db21d9b24adc..52a4b9d6e803e 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.tsx @@ -39,8 +39,8 @@ import { Filter } from '@kbn/es-query'; import { TimeRange } from 'ui/timefilter/time_history'; import { IndexPattern } from 'ui/index_patterns'; import { IPrivate } from 'ui/private'; +import { StaticIndexPattern, Query, SavedQuery } from 'plugins/data'; import moment from 'moment'; -import { StaticIndexPattern, Query } from '../../../data/public'; import { ViewMode } from '../../../embeddable_api/public/np_ready/public'; import { SavedObjectDashboard } from './saved_dashboard/saved_dashboard'; @@ -63,6 +63,7 @@ export interface DashboardAppScope extends ng.IScope { | { to: string | moment.Moment | undefined; from: string | moment.Moment | undefined }; refreshInterval: any; }; + savedQuery?: SavedQuery; refreshInterval: any; panels: SavedDashboardPanel[]; indexPatterns: StaticIndexPattern[]; @@ -83,9 +84,13 @@ export interface DashboardAppScope extends ng.IScope { $listenAndDigestAsync: any; onCancelApplyFilters: () => void; onApplyFilters: (filters: Filter[]) => void; + onQuerySaved: (savedQuery: SavedQuery) => void; + onSavedQueryUpdated: (savedQuery: SavedQuery) => void; + onClearSavedQuery: () => void; topNavMenu: any; showFilterBar: () => boolean; showAddPanel: any; + showSaveQuery: boolean; kbnTopNav: any; enterEditMode: () => void; $listen: any; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx index 1bd27ea1711ef..6c10cb1cdd5cf 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx @@ -51,11 +51,14 @@ import { KbnUrl } from 'ui/url/kbn_url'; import { Filter } from '@kbn/es-query'; import { IndexPattern } from 'ui/index_patterns'; import { IPrivate } from 'ui/private'; -import { Query } from 'src/legacy/core_plugins/data/public'; +import { Query, SavedQuery } from 'src/legacy/core_plugins/data/public'; import { SaveOptions } from 'ui/saved_objects/saved_object'; +import { capabilities } from 'ui/capabilities'; import { Subscription } from 'rxjs'; import { npStart } from 'ui/new_platform'; import { SavedObjectFinder } from 'ui/saved_objects/components/saved_object_finder'; +import { data } from '../../../data/public/setup'; + import { DashboardContainer, DASHBOARD_CONTAINER_TYPE, @@ -85,6 +88,8 @@ import { DashboardAppScope } from './dashboard_app'; import { VISUALIZE_EMBEDDABLE_TYPE } from '../visualize/embeddable'; import { convertSavedDashboardPanelToPanelState } from './lib/embeddable_saved_object_converters'; +const { savedQueryService } = data.search.services; + export class DashboardAppController { // Part of the exposed plugin API - do not remove without careful consideration. appStatus: { @@ -150,6 +155,7 @@ export class DashboardAppController { if (dashboardStateManager.getIsTimeSavedWithDashboard() && !getAppState.previouslyStored()) { dashboardStateManager.syncTimefilterWithDashboard(timefilter); } + $scope.showSaveQuery = capabilities.get().dashboard.saveQuery as boolean; const updateIndexPatterns = (container?: DashboardContainer) => { if (!container || isErrorEmbeddable(container)) { @@ -420,6 +426,75 @@ export class DashboardAppController { $scope.appState.$newFilters = []; }; + $scope.onQuerySaved = savedQuery => { + $scope.savedQuery = savedQuery; + }; + + $scope.onSavedQueryUpdated = savedQuery => { + $scope.savedQuery = savedQuery; + }; + + $scope.onClearSavedQuery = () => { + delete $scope.savedQuery; + dashboardStateManager.setSavedQueryId(undefined); + queryFilter.removeAll(); + dashboardStateManager.applyFilters( + { + query: '', + language: + localStorage.get('kibana.userQueryLanguage') || config.get('search:queryLanguage'), + }, + [] + ); + courier.fetch(); + }; + + const updateStateFromSavedQuery = (savedQuery: SavedQuery) => { + queryFilter.setFilters(savedQuery.attributes.filters || []); + dashboardStateManager.applyFilters( + savedQuery.attributes.query, + savedQuery.attributes.filters || [] + ); + 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); + } + } + courier.fetch(); + }; + + $scope.$watch('savedQuery', (newSavedQuery: SavedQuery, oldSavedQuery: SavedQuery) => { + if (!newSavedQuery) return; + dashboardStateManager.setSavedQueryId(newSavedQuery.id); + + if (newSavedQuery.id === (oldSavedQuery && oldSavedQuery.id)) { + updateStateFromSavedQuery(newSavedQuery); + } + }); + + $scope.$watch( + () => { + return dashboardStateManager.getSavedQueryId(); + }, + newSavedQueryId => { + if (!newSavedQueryId) { + $scope.savedQuery = undefined; + return; + } + + savedQueryService.getSavedQuery(newSavedQueryId).then((savedQuery: SavedQuery) => { + $scope.$evalAsync(() => { + $scope.savedQuery = savedQuery; + updateStateFromSavedQuery(savedQuery); + }); + }); + } + ); + $scope.$watch('appState.$newFilters', (filters: Filter[] = []) => { if (filters.length === 1) { $scope.onApplyFilters(filters); @@ -433,6 +508,13 @@ export class DashboardAppController { $scope.updateQueryAndFetch({ query }); }); + $scope.$watch( + () => capabilities.get().dashboard.saveQuery, + newCapability => { + $scope.showSaveQuery = newCapability as boolean; + } + ); + $scope.$listenAndDigestAsync(timefilter, 'fetch', () => { // The only reason this is here is so that search embeddables work on a dashboard with // a refresh interval turned on. This kicks off the search poller. It should be diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state_manager.ts b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state_manager.ts index 60857de07389a..358c434b327e1 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state_manager.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state_manager.ts @@ -252,6 +252,15 @@ export class DashboardStateManager { return migrateLegacyQuery(this.appState.query); } + public getSavedQueryId() { + return this.appState.savedQuery; + } + + public setSavedQueryId(id?: string) { + this.appState.savedQuery = id; + this.saveState(); + } + public getUseMargins() { // Existing dashboards that don't define this should default to false. return this.appState.options.useMargins === undefined diff --git a/src/legacy/core_plugins/kibana/public/dashboard/types.ts b/src/legacy/core_plugins/kibana/public/dashboard/types.ts index b596882f1e83a..ef7192bf30025 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/types.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/types.ts @@ -112,6 +112,7 @@ export interface DashboardAppStateParameters { query: Query | string; filters: Filter[]; viewMode: ViewMode; + savedQuery?: string; } // This could probably be improved if we flesh out AppState more... though AppState will be going away diff --git a/src/legacy/core_plugins/kibana/public/discover/controllers/discover.js b/src/legacy/core_plugins/kibana/public/discover/controllers/discover.js index 18355281c5961..35fbd05ad925f 100644 --- a/src/legacy/core_plugins/kibana/public/discover/controllers/discover.js +++ b/src/legacy/core_plugins/kibana/public/discover/controllers/discover.js @@ -72,6 +72,10 @@ import { buildVislibDimensions } from 'ui/visualize/loader/pipeline_helpers/buil import 'ui/capabilities/route_setup'; import { addHelpMenuToAppChrome } from '../components/help_menu/help_menu_util'; +import { setup as data } from '../../../../../core_plugins/data/public/legacy'; + +const { savedQueryService } = data.search.services; + const fetchStatuses = { UNINITIALIZED: 'uninitialized', LOADING: 'loading', @@ -217,6 +221,12 @@ function discoverController( $scope.minimumVisibleRows = 50; $scope.fetchStatus = fetchStatuses.UNINITIALIZED; $scope.refreshInterval = timefilter.getRefreshInterval(); + $scope.savedQuery = $route.current.locals.savedQuery; + $scope.showSaveQuery = uiCapabilities.discover.saveQuery; + + $scope.$watch(() => uiCapabilities.discover.saveQuery, (newCapability) => { + $scope.showSaveQuery = newCapability; + }); $scope.intervalEnabled = function (interval) { return interval.val !== 'custom'; @@ -282,6 +292,9 @@ function discoverController( title={savedSearch.title} showCopyOnSave={savedSearch.id ? true : false} objectType="search" + description={i18n.translate('kbn.discover.localMenu.saveSaveSearchDescription', { + defaultMessage: 'Save your Discover search so you can use it in visualizations and dashboards', + })} />); showSaveModal(saveModal); } @@ -375,7 +388,7 @@ function discoverController( // searchSource which applies time range const timeRangeSearchSource = savedSearch.searchSource.create(); - if(isDefaultTypeIndexPattern($scope.indexPattern)) { + if (isDefaultTypeIndexPattern($scope.indexPattern)) { timeRangeSearchSource.setField('filter', () => { return timefilter.createFilter($scope.indexPattern); }); @@ -392,7 +405,7 @@ function discoverController( if (savedSearch.id && savedSearch.title) { chrome.breadcrumbs.set([{ text: discoverBreadcrumbsTitle, - href: '#/discover' + href: '#/discover', }, { text: savedSearch.title }]); } else { chrome.breadcrumbs.set([{ @@ -487,15 +500,18 @@ function discoverController( function getStateDefaults() { return { - query: $scope.searchSource.getField('query') || { - query: '', - language: localStorage.get('kibana.userQueryLanguage') || config.get('search:queryLanguage') - }, + query: ($scope.savedQuery && $scope.savedQuery.attributes.query) + || $scope.searchSource.getField('query') + || { + query: '', + language: localStorage.get('kibana.userQueryLanguage') || config.get('search:queryLanguage') + }, sort: getSort.array(savedSearch.sort, $scope.indexPattern, config.get('discover:sort:defaultOrder')), columns: savedSearch.columns.length > 0 ? savedSearch.columns : config.get('defaultColumns').slice(), index: $scope.indexPattern.id, interval: 'auto', - filters: _.cloneDeep($scope.searchSource.getOwnField('filter')) + filters: ($scope.savedQuery && $scope.savedQuery.attributes.filters) + || _.cloneDeep($scope.searchSource.getOwnField('filter')) }; } @@ -903,6 +919,68 @@ 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'), + }; + queryFilter.removeAll(); + $state.save(); + $scope.fetch(); + }; + + const updateStateFromSavedQuery = (savedQuery) => { + $state.query = savedQuery.attributes.query; + queryFilter.setFilters(savedQuery.attributes.filters || []); + + 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.fetch(); + }; + + $scope.$watch('savedQuery', (newSavedQuery, oldSavedQuery) => { + if (!newSavedQuery) return; + + $state.savedQuery = newSavedQuery.id; + $state.save(); + + if (newSavedQuery.id === (oldSavedQuery && oldSavedQuery.id)) { + updateStateFromSavedQuery(newSavedQuery); + } + }); + + $scope.$watch('state.savedQuery', newSavedQueryId => { + if (!newSavedQueryId) { + $scope.savedQuery = undefined; + return; + } + + savedQueryService.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 if (!$scope.opts.timefield) return; diff --git a/src/legacy/core_plugins/kibana/public/discover/index.html b/src/legacy/core_plugins/kibana/public/discover/index.html index 68b168971f7e9..980b3fefc6dd8 100644 --- a/src/legacy/core_plugins/kibana/public/discover/index.html +++ b/src/legacy/core_plugins/kibana/public/discover/index.html @@ -6,7 +6,9 @@ config="topNavMenu" show-search-bar="true" show-date-picker="enableTimeRangeSelector" + show-save-query="showSaveQuery" query="state.query" + saved-query="savedQuery" screen-title="screenTitle" on-query-submit="updateQueryAndFetch" index-patterns="[indexPattern]" @@ -17,6 +19,9 @@ is-refresh-paused="refreshInterval.pause" refresh-interval="refreshInterval.value" on-refresh-change="onRefreshChange" + on-saved="onQuerySaved" + on-saved-query-updated="onSavedQueryUpdated" + on-clear-saved-query="onClearSavedQuery" > diff --git a/src/legacy/core_plugins/kibana/public/visualize/editor/editor.html b/src/legacy/core_plugins/kibana/public/visualize/editor/editor.html index 022c4bfa2f0b3..8513deee800e3 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/editor/editor.html +++ b/src/legacy/core_plugins/kibana/public/visualize/editor/editor.html @@ -23,25 +23,26 @@
    - - - diff --git a/src/legacy/core_plugins/kibana/public/visualize/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/editor/editor.js index d0896dd438684..85911160f1e62 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/editor/editor.js @@ -54,8 +54,10 @@ import { showSaveModal } from 'ui/saved_objects/show_saved_object_save_modal'; import { SavedObjectSaveModal } from 'ui/saved_objects/components/saved_object_save_modal'; import { getEditBreadcrumbs, getCreateBreadcrumbs } from '../breadcrumbs'; import { npStart } from 'ui/new_platform'; +import { setup as data } from '../../../../../core_plugins/data/public/legacy'; import { addHelpMenuToAppChrome } from '../help_menu/help_menu_util'; +const { savedQueryService } = data.search.services; uiRoutes .when(VisualizeConstants.CREATE_PATH, { @@ -334,6 +336,12 @@ function VisEditor( } }); + $scope.showSaveQuery = capabilities.get().visualize.saveQuery; + + $scope.$watch(() => capabilities.get().visualize.saveQuery, (newCapability) => { + $scope.showSaveQuery = newCapability; + }); + function init() { // export some objects $scope.savedVis = savedVis; @@ -464,6 +472,67 @@ function VisEditor( }); }; + $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') + }; + queryFilter.removeAll(); + $state.save(); + $scope.fetch(); + }; + + const updateStateFromSavedQuery = (savedQuery) => { + $state.query = savedQuery.attributes.query; + queryFilter.setFilters(savedQuery.attributes.filters || []); + + 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.fetch(); + }; + + $scope.$watch('savedQuery', (newSavedQuery, oldSavedQuery) => { + if (!newSavedQuery) return; + $state.savedQuery = newSavedQuery.id; + $state.save(); + + if (newSavedQuery.id === (oldSavedQuery && oldSavedQuery.id)) { + updateStateFromSavedQuery(newSavedQuery); + } + }); + + $scope.$watch('state.savedQuery', newSavedQueryId => { + if (!newSavedQueryId) { + $scope.savedQuery = undefined; + return; + } + + savedQueryService.getSavedQuery(newSavedQueryId).then((savedQuery) => { + $scope.$evalAsync(() => { + $scope.savedQuery = savedQuery; + updateStateFromSavedQuery(savedQuery); + }); + }); + }); + /** * Called when the user clicks "Save" button. */ diff --git a/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.test.tsx b/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.test.tsx index 83212a4c7c64e..050e55aed43c3 100644 --- a/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.test.tsx @@ -24,6 +24,7 @@ import { shallowWithIntl } from 'test_utils/enzyme_helpers'; import { coreMock } from '../../../../../core/public/mocks'; const setupMock = coreMock.createSetup(); +const startMock = coreMock.createStart(); jest.mock('../../../../core_plugins/data/public', () => { return { @@ -54,14 +55,25 @@ describe('TopNavMenu', () => { ]; it('Should render nothing when no config is provided', () => { - const component = shallowWithIntl(); + const component = shallowWithIntl( + + ); expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0); expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(0); }); it('Should render 1 menu item', () => { const component = shallowWithIntl( - + ); expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(1); expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(0); @@ -69,7 +81,12 @@ describe('TopNavMenu', () => { it('Should render multiple menu items', () => { const component = shallowWithIntl( - + ); expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(menuItems.length); expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(0); @@ -77,7 +94,12 @@ describe('TopNavMenu', () => { it('Should render search bar', () => { const component = shallowWithIntl( - + ); expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0); diff --git a/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.tsx b/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.tsx index 6b1c207982873..5c11e99265f10 100644 --- a/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.tsx +++ b/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.tsx @@ -21,14 +21,16 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { I18nProvider } from '@kbn/i18n/react'; -import { UiSettingsClientContract } from 'src/core/public'; +import { UiSettingsClientContract, SavedObjectsClientContract } from 'src/core/public'; import { TopNavMenuData } from './top_nav_menu_data'; import { TopNavMenuItem } from './top_nav_menu_item'; import { SearchBar, SearchBarProps } from '../../../../core_plugins/data/public'; +import { createSavedQueryService } from '../../../data/public/search/search_bar/lib/saved_query_service'; type Props = Partial & { name: string; uiSettings: UiSettingsClientContract; + savedObjectsClient: SavedObjectsClientContract; config?: TopNavMenuData[]; showSearchBar?: boolean; }; @@ -58,11 +60,14 @@ export function TopNavMenu(props: Props) { // Validate presense of all required fields if (!props.showSearchBar) return; + const savedQueryService = createSavedQueryService(props.savedObjectsClient); + return ( ); } diff --git a/src/legacy/plugin_discovery/plugin_spec/plugin_spec_options.d.ts b/src/legacy/plugin_discovery/plugin_spec/plugin_spec_options.d.ts index 422311222d94a..6e087e3ee7f87 100644 --- a/src/legacy/plugin_discovery/plugin_spec/plugin_spec_options.d.ts +++ b/src/legacy/plugin_discovery/plugin_spec/plugin_spec_options.d.ts @@ -18,11 +18,15 @@ */ import { Server } from '../../server/kbn_server'; import { Capabilities } from '../../../core/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { SavedObjectsManagementDefinition } from '../../../core/server/saved_objects/management'; export type InitPluginFunction = (server: Server) => void; export interface UiExports { injectDefaultVars?: (server: Server) => { [key: string]: any }; styleSheetPaths?: string; + savedObjectsManagement?: SavedObjectsManagementDefinition; + mappings?: unknown; visTypes?: string[]; interpreter?: string[]; hacks?: string[]; 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 6e070d401ad3c..de97a26e8f49e 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 @@ -47,6 +47,7 @@ module.directive('kbnTopNav', () => { const localStorage = new Storage(window.localStorage); child.setAttribute('store', 'store'); child.setAttribute('ui-settings', 'uiSettings'); + child.setAttribute('saved-objects-client', 'savedObjectsClient'); // Append helper directive elem.append(child); @@ -54,6 +55,7 @@ module.directive('kbnTopNav', () => { const linkFn = ($scope, _, $attr) => { $scope.store = localStorage; $scope.uiSettings = chrome.getUiSettingsClient(); + $scope.savedObjectsClient = chrome.getSavedObjectsClient(); // Watch config changes $scope.$watch(() => { @@ -92,14 +94,19 @@ module.directive('kbnTopNavHelper', (reactDirective) => { ['disabledButtons', { watchDepth: 'reference' }], ['query', { watchDepth: 'reference' }], + ['savedQuery', { watchDepth: 'reference' }], ['store', { watchDepth: 'reference' }], ['uiSettings', { watchDepth: 'reference' }], + ['savedObjectsClient', { watchDepth: 'reference' }], ['intl', { watchDepth: 'reference' }], ['store', { watchDepth: 'reference' }], ['onQuerySubmit', { watchDepth: 'reference' }], ['onFiltersUpdated', { watchDepth: 'reference' }], ['onRefreshChange', { watchDepth: 'reference' }], + ['onClearSavedQuery', { watchDepth: 'reference' }], + ['onSaved', { watchDepth: 'reference' }], + ['onSavedQueryUpdated', { watchDepth: 'reference' }], ['indexPatterns', { watchDepth: 'collection' }], ['filters', { watchDepth: 'collection' }], @@ -111,6 +118,7 @@ module.directive('kbnTopNavHelper', (reactDirective) => { 'showQueryBar', 'showQueryInput', 'showDatePicker', + 'showSaveQuery', 'appName', 'screenTitle', diff --git a/src/legacy/ui/public/saved_objects/components/saved_object_save_modal.tsx b/src/legacy/ui/public/saved_objects/components/saved_object_save_modal.tsx index 2abf598b23ad0..b6802758b958a 100644 --- a/src/legacy/ui/public/saved_objects/components/saved_object_save_modal.tsx +++ b/src/legacy/ui/public/saved_objects/components/saved_object_save_modal.tsx @@ -34,6 +34,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Fragment } from 'react'; +import { EuiText } from '@elastic/eui'; interface OnSaveProps { newTitle: string; @@ -50,6 +51,7 @@ interface Props { objectType: string; confirmButtonLabel?: React.ReactNode; options?: React.ReactNode; + description?: string; } interface State { @@ -94,6 +96,11 @@ export class SavedObjectSaveModal extends React.Component { {this.renderDuplicateTitleCallout()} + {this.props.description && ( + + {this.props.description} + + )} {this.renderCopyOnSave()} { expect(resp.body.saved_objects).to.have.length(1); expect(resp.body.saved_objects[0].meta).to.eql({ - icon: 'search', + icon: 'discoverApp', title: 'OneRecord', editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { diff --git a/test/api_integration/apis/management/saved_objects/relationships.js b/test/api_integration/apis/management/saved_objects/relationships.js index 8e5152f0f6463..33e747b661420 100644 --- a/test/api_integration/apis/management/saved_objects/relationships.js +++ b/test/api_integration/apis/management/saved_objects/relationships.js @@ -262,7 +262,7 @@ export default function ({ getService }) { type: 'search', relationship: 'child', meta: { - icon: 'search', + icon: 'discoverApp', title: 'OneRecord', editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { @@ -299,7 +299,7 @@ export default function ({ getService }) { id: '960372e0-3224-11e8-a572-ffca06da1357', type: 'search', meta: { - icon: 'search', + icon: 'discoverApp', title: 'OneRecord', editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { @@ -342,7 +342,7 @@ export default function ({ getService }) { type: 'search', relationship: 'parent', meta: { - icon: 'search', + icon: 'discoverApp', title: 'OneRecord', editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { @@ -379,7 +379,7 @@ export default function ({ getService }) { id: '960372e0-3224-11e8-a572-ffca06da1357', type: 'search', meta: { - icon: 'search', + icon: 'discoverApp', title: 'OneRecord', editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { diff --git a/test/api_integration/apis/saved_objects/export.js b/test/api_integration/apis/saved_objects/export.js index 5444299215082..0564c095faa88 100644 --- a/test/api_integration/apis/saved_objects/export.js +++ b/test/api_integration/apis/saved_objects/export.js @@ -139,7 +139,7 @@ export default function ({ getService }) { statusCode: 400, error: 'Bad Request', message: 'child "type" fails because ["type" at position 0 fails because ' + - '["0" must be one of [config, dashboard, index-pattern, search, url, visualization]]]', + '["0" must be one of [config, dashboard, index-pattern, query, search, url, visualization]]]', validation: { source: 'payload', keys: ['type.0'], diff --git a/test/functional/apps/discover/_saved_queries.js b/test/functional/apps/discover/_saved_queries.js new file mode 100644 index 0000000000000..b744f7d9e224b --- /dev/null +++ b/test/functional/apps/discover/_saved_queries.js @@ -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 expect from '@kbn/expect'; + +export default function ({ getService, getPageObjects }) { + const log = getService('log'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); + + const defaultSettings = { + defaultIndex: 'logstash-*', + }; + const filterBar = getService('filterBar'); + const queryBar = getService('queryBar'); + const savedQueryManagementComponent = getService('savedQueryManagementComponent'); + const testSubjects = getService('testSubjects'); + + describe('saved queries saved objects', function describeIndexTests() { + const fromTime = '2015-09-19 06:31:44.000'; + const toTime = '2015-09-23 18:31:44.000'; + + before(async function () { + log.debug('load kibana index with default index pattern'); + await esArchiver.load('discover'); + + // and load a set of makelogs data + await esArchiver.loadIfNeeded('logstash_functional'); + await kibanaServer.uiSettings.replace(defaultSettings); + log.debug('discover'); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + }); + + describe('saved query management component functionality', function () { + before(async function () { + // set up a query with filters and a time filter + log.debug('set up a query with filters to save'); + await queryBar.setQuery('response:200'); + await filterBar.addFilter('extension.raw', 'is one of', 'jpg'); + const fromTime = '2015-09-20 08:00:00.000'; + const toTime = '2015-09-21 08:00:00.000'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + }); + + it('should show the saved query management component when there are no saved queries', async () => { + await savedQueryManagementComponent.openSavedQueryManagementComponent(); + const descriptionText = await testSubjects.getVisibleText('saved-query-management-popover'); + expect(descriptionText) + .to + .eql('SAVED QUERIES\nThere are no saved queries. Save query text and filters that you want to use again.\nSave'); + }); + + it('should allow a query to be saved via the saved objects management component', async () => { + await savedQueryManagementComponent.saveNewQuery('OkResponse', '200 responses for .jpg over 24 hours', true, true); + await savedQueryManagementComponent.savedQueryExistOrFail('OkResponse'); + }); + + it('reinstates filters and the time filter when a saved query has filters and a time filter included', async () => { + const fromTime = '2015-09-19 06:31:44.000'; + const toTime = '2015-09-23 18:31:44.000'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + await savedQueryManagementComponent.loadSavedQuery('OkResponse'); + const timePickerValues = await PageObjects.timePicker.getTimeConfigAsAbsoluteTimes(); + expect(await filterBar.hasFilter('extension.raw', 'jpg')).to.be(true); + expect(timePickerValues.start).to.not.eql(fromTime); + expect(timePickerValues.end).to.not.eql(toTime); + }); + + it('allows saving changes to a currently loaded query via the saved query management component', async () => { + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQuery( + 'OkResponse', + '404 responses', + false, + false + ); + await savedQueryManagementComponent.savedQueryExistOrFail('OkResponse'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + expect(await queryBar.getQueryString()).to.eql(''); + await savedQueryManagementComponent.loadSavedQuery('OkResponse'); + expect(await queryBar.getQueryString()).to.eql('response:404'); + }); + + it('allows saving the currently loaded query as a new query', async () => { + await savedQueryManagementComponent.saveCurrentlyLoadedAsNewQuery('OkResponseCopy', '200 responses', false, false); + await savedQueryManagementComponent.savedQueryExistOrFail('OkResponseCopy'); + }); + + it('allows deleting the currently loaded saved query in the saved query management component and clears the query', async () => { + await savedQueryManagementComponent.deleteSavedQuery('OkResponseCopy'); + await savedQueryManagementComponent.savedQueryMissingOrFail('OkResponseCopy'); + expect(await queryBar.getQueryString()).to.eql(''); + }); + + it('does not allow saving a query with a non-unique name', async () => { + await savedQueryManagementComponent.saveNewQueryWithNameError('OkResponse'); + }); + + it('does not allow saving a query with leading or trailing whitespace in the name', async () => { + await savedQueryManagementComponent.saveNewQueryWithNameError('OkResponse '); + }); + it('allows clearing the currently loaded saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OkResponse'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + expect(await queryBar.getQueryString()).to.eql(''); + }); + }); + }); +} diff --git a/test/functional/apps/discover/index.js b/test/functional/apps/discover/index.js index a0b2c43defa24..9e4430ca71c1f 100644 --- a/test/functional/apps/discover/index.js +++ b/test/functional/apps/discover/index.js @@ -32,6 +32,7 @@ export default function ({ getService, loadTestFile }) { return esArchiver.unload('logstash_functional'); }); + loadTestFile(require.resolve('./_saved_queries')); loadTestFile(require.resolve('./_discover')); loadTestFile(require.resolve('./_errors')); loadTestFile(require.resolve('./_field_data')); diff --git a/test/functional/fixtures/es_archiver/discover/mappings.json b/test/functional/fixtures/es_archiver/discover/mappings.json index a89fe1dfacfc8..82002c095bcc5 100644 --- a/test/functional/fixtures/es_archiver/discover/mappings.json +++ b/test/functional/fixtures/es_archiver/discover/mappings.json @@ -231,6 +231,35 @@ "type": "text" } } + }, + "query": { + "properties": { + "title": { + "type": "text" + }, + "description": { + "type": "text" + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "type": "keyword", + "index": false + } + } + }, + "filters": { + "type": "object", + "enabled": false + }, + "timefilter": { + "type": "object", + "enabled": false + } + } } } }, @@ -241,4 +270,4 @@ } } } -} \ No newline at end of file +} diff --git a/test/functional/fixtures/es_archiver/empty_kibana/mappings.json b/test/functional/fixtures/es_archiver/empty_kibana/mappings.json index 3e871dddb0d71..403a891ba1175 100644 --- a/test/functional/fixtures/es_archiver/empty_kibana/mappings.json +++ b/test/functional/fixtures/es_archiver/empty_kibana/mappings.json @@ -240,6 +240,35 @@ "type": "text" } } + }, + "query": { + "properties": { + "title": { + "type": "text" + }, + "description": { + "type": "text" + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "type": "keyword", + "index": false + } + } + }, + "filters": { + "type": "object", + "enabled": false + }, + "timefilter": { + "type": "object", + "enabled": false + } + } } } }, @@ -250,4 +279,4 @@ } } } -} \ No newline at end of file +} diff --git a/test/functional/services/index.ts b/test/functional/services/index.ts index aca945e3ee592..60ab726bafd64 100644 --- a/test/functional/services/index.ts +++ b/test/functional/services/index.ts @@ -60,6 +60,8 @@ import { ToastsProvider } from './toasts'; import { PieChartProvider } from './visualizations'; // @ts-ignore not TS yet import { VisualizeListingTableProvider } from './visualize_listing_table'; +// @ts-ignore not TS yet +import { SavedQueryManagementComponentProvider } from './saved_query_management_component'; export const services = { ...commonServiceProviders, @@ -89,4 +91,5 @@ export const services = { appsMenu: AppsMenuProvider, globalNav: GlobalNavProvider, toasts: ToastsProvider, + savedQueryManagementComponent: SavedQueryManagementComponentProvider, }; diff --git a/test/functional/services/saved_query_management_component.js b/test/functional/services/saved_query_management_component.js new file mode 100644 index 0000000000000..6c8119999eb72 --- /dev/null +++ b/test/functional/services/saved_query_management_component.js @@ -0,0 +1,146 @@ +/* + * 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 expect from '@kbn/expect'; + +export function SavedQueryManagementComponentProvider({ getService }) { + const testSubjects = getService('testSubjects'); + const queryBar = getService('queryBar'); + const retry = getService('retry'); + + class SavedQueryManagementComponent { + + async saveNewQuery(name, description, includeFilters, includeTimeFilter) { + await this.openSavedQueryManagementComponent(); + await testSubjects.click('saved-query-management-save-button'); + await this.submitSaveQueryForm(name, description, includeFilters, includeTimeFilter); + } + + async saveNewQueryWithNameError(name) { + await this.openSavedQueryManagementComponent(); + await testSubjects.click('saved-query-management-save-button'); + if (name) { + await testSubjects.setValue('saveQueryFormTitle', name); + } + const saveQueryFormSaveButtonStatus = await testSubjects.isEnabled('savedQueryFormSaveButton'); + expect(saveQueryFormSaveButtonStatus).to.not.eql(true); + await testSubjects.click('savedQueryFormCancelButton'); + } + + async saveCurrentlyLoadedAsNewQuery(name, description, includeFilters, includeTimeFilter) { + await this.openSavedQueryManagementComponent(); + await testSubjects.click('saved-query-management-save-as-new-button'); + await this.submitSaveQueryForm(name, description, includeFilters, includeTimeFilter); + } + + async updateCurrentlyLoadedQuery(description, includeFilters, includeTimeFilter) { + await this.openSavedQueryManagementComponent(); + await testSubjects.click('saved-query-management-save-changes-button'); + await this.submitSaveQueryForm(null, description, includeFilters, includeTimeFilter); + } + + async loadSavedQuery(title) { + await this.openSavedQueryManagementComponent(); + await testSubjects.click(`load-saved-query-${title}-button`); + await retry.try(async () => { + await this.openSavedQueryManagementComponent(); + const selectedSavedQueryText = await testSubjects.getVisibleText('saved-query-list-item-selected'); + expect(selectedSavedQueryText).to.eql(title); + }); + await this.closeSavedQueryManagementComponent(); + } + + async deleteSavedQuery(title) { + await this.openSavedQueryManagementComponent(); + await testSubjects.click(`delete-saved-query-${title}-button`); + await testSubjects.click('confirmModalConfirmButton'); + } + + async clearCurrentlyLoadedQuery() { + await this.openSavedQueryManagementComponent(); + await testSubjects.click('saved-query-management-clear-button'); + await this.closeSavedQueryManagementComponent(); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.be.empty(); + } + + async submitSaveQueryForm(title, description, includeFilters, includeTimeFilter) { + if (title) { + await testSubjects.setValue('saveQueryFormTitle', title); + } + await testSubjects.setValue('saveQueryFormDescription', description); + + const currentIncludeFiltersValue = (await testSubjects.getAttribute('saveQueryFormIncludeFiltersOption', 'checked')) === 'true'; + if (currentIncludeFiltersValue !== includeFilters) { + await testSubjects.click('saveQueryFormIncludeFiltersOption'); + } + + const currentIncludeTimeFilterValue = (await testSubjects.getAttribute('saveQueryFormIncludeTimeFilterOption', 'checked')) === 'true'; + if (currentIncludeTimeFilterValue !== includeTimeFilter) { + await testSubjects.click('saveQueryFormIncludeTimeFilterOption'); + } + + await testSubjects.click('savedQueryFormSaveButton'); + } + + async savedQueryExistOrFail(title) { + await this.openSavedQueryManagementComponent(); + await testSubjects.existOrFail(`load-saved-query-${title}-button`); + } + + async savedQueryMissingOrFail(title) { + await retry.try(async () => { + await this.openSavedQueryManagementComponent(); + await testSubjects.missingOrFail(`load-saved-query-${title}-button`); + }); + await this.closeSavedQueryManagementComponent(); + } + + async openSavedQueryManagementComponent() { + const isOpenAlready = await testSubjects.exists('saved-query-management-popover'); + if (isOpenAlready) return; + + await testSubjects.click('saved-query-management-popover-button'); + } + + async closeSavedQueryManagementComponent() { + const isOpenAlready = await testSubjects.exists('saved-query-management-popover'); + if (!isOpenAlready) return; + + await testSubjects.click('saved-query-management-popover-button'); + } + + async saveNewQueryMissingOrFail() { + await this.openSavedQueryManagementComponent(); + await testSubjects.missingOrFail('saved-query-management-save-button'); + } + + async updateCurrentlyLoadedQueryMissingOrFail() { + await this.openSavedQueryManagementComponent(); + await testSubjects.missingOrFail('saved-query-management-save-changes-button'); + } + + async deleteSavedQueryMissingOrFail(title) { + await this.openSavedQueryManagementComponent(); + await testSubjects.missingOrFail(`delete-saved-query-${title}-button`); + } + } + + return new SavedQueryManagementComponent(); +} diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js index 44e380b4f834c..e478ddf91ac6b 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js @@ -22,7 +22,7 @@ import { i18n } from '@kbn/i18n'; import { indexPatternService } from '../../../kibana_services'; import { Storage } from 'ui/storage'; -import { QueryBar } from 'plugins/data'; +import { SearchBar } from 'plugins/data'; const settings = chrome.getUiSettingsClient(); const localStorage = new Storage(window.localStorage); @@ -91,12 +91,14 @@ export class FilterEditor extends Component { anchorPosition="leftCenter" >
    - - { privileges: { all: { savedObject: { - all: ['search', 'url'], + all: ['search', 'url', 'query'], read: ['index-pattern'], }, - ui: ['show', 'createShortUrl', 'save'], + ui: ['show', 'createShortUrl', 'save', 'saveQuery'], }, read: { savedObject: { all: [], - read: ['index-pattern', 'search'], + read: ['index-pattern', 'search', 'query'], }, ui: ['show'], }, @@ -46,15 +46,15 @@ const buildKibanaFeatures = (savedObjectTypes: string[]) => { privileges: { all: { savedObject: { - all: ['visualization', 'url'], + all: ['visualization', 'url', 'query'], read: ['index-pattern', 'search'], }, - ui: ['show', 'createShortUrl', 'delete', 'save'], + ui: ['show', 'createShortUrl', 'delete', 'save', 'saveQuery'], }, read: { savedObject: { all: [], - read: ['index-pattern', 'search', 'visualization'], + read: ['index-pattern', 'search', 'visualization', 'query'], }, ui: ['show'], }, @@ -72,7 +72,7 @@ const buildKibanaFeatures = (savedObjectTypes: string[]) => { privileges: { all: { savedObject: { - all: ['dashboard', 'url'], + all: ['dashboard', 'url', 'query'], read: [ 'index-pattern', 'search', @@ -82,7 +82,7 @@ const buildKibanaFeatures = (savedObjectTypes: string[]) => { 'map', ], }, - ui: ['createNew', 'show', 'showWriteControls'], + ui: ['createNew', 'show', 'showWriteControls', 'saveQuery'], }, read: { savedObject: { @@ -95,6 +95,7 @@ const buildKibanaFeatures = (savedObjectTypes: string[]) => { 'canvas-workpad', 'map', 'dashboard', + 'query', ], }, ui: ['show'], diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 456ea04186187..114a923c12fc7 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -796,8 +796,6 @@ "data.query.queryBar.syntaxOptionsDescription": "{docsLink} (KQL) は、シンプルなクエリ構文とスクリプトフィールドのサポートを提供します。また、KQL はベーシックライセンス以上をご利用の場合、自動入力も提供します。KQL をオフにすると、Kibana は Lucene を使用します。", "data.query.queryBar.syntaxOptionsDescription.docsLinkText": "こちら", "data.query.queryBar.syntaxOptionsTitle": "構文オプション", - "data.search.searchBar.searchBar.filtersButtonClickToHideTitle": "選択して表示", - "data.search.searchBar.searchBar.filtersButtonClickToShowTitle": "選択して非表示", "embeddableApi.actionPanel.title": "オプション", "embeddableApi.actions.applyFilterActionTitle": "現在のビューにフィルターを適用", "embeddableApi.addPanel.createNew": "新規 {factoryName} を作成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 63a749559b167..70b0f9f11a7a4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -796,8 +796,6 @@ "data.query.queryBar.syntaxOptionsDescription": "{docsLink} (KQL) 提供简化查询语法并支持脚本字段。如果您具有基本许可或更高级别的许可,KQL 还提供自动填充功能。如果关闭 KQL,Kibana 将使用 Lucene。", "data.query.queryBar.syntaxOptionsDescription.docsLinkText": "此处", "data.query.queryBar.syntaxOptionsTitle": "语法选项", - "data.search.searchBar.searchBar.filtersButtonClickToHideTitle": "选择以显示", - "data.search.searchBar.searchBar.filtersButtonClickToShowTitle": "选择以隐藏", "embeddableApi.actionPanel.title": "选项", "embeddableApi.actions.applyFilterActionTitle": "将筛选应用于当前视图", "embeddableApi.addPanel.createNew": "创建新的{factoryName}", diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts index 5a43b15db56e2..3bc75ff5492fa 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts @@ -20,6 +20,8 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { const panelActions = getService('dashboardPanelActions'); const testSubjects = getService('testSubjects'); const globalNav = getService('globalNav'); + const queryBar = getService('queryBar'); + const savedQueryManagementComponent = getService('savedQueryManagementComponent'); describe('dashboard security', () => { before(async () => { @@ -187,6 +189,35 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await panelActions.openContextMenu(); await panelActions.expectExistsEditPanelAction(); }); + + it('allow saving via the saved query management component popover with no query loaded', async () => { + await savedQueryManagementComponent.saveNewQuery('foo', 'bar', true, false); + await savedQueryManagementComponent.savedQueryExistOrFail('foo'); + }); + + it('allow saving a currently loaded saved query as a new query via the saved query management component ', async () => { + await savedQueryManagementComponent.saveCurrentlyLoadedAsNewQuery( + 'foo2', + 'bar2', + true, + false + ); + await savedQueryManagementComponent.savedQueryExistOrFail('foo2'); + }); + + it('allow saving changes to a currently loaded query via the saved query management component', async () => { + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQuery('bar2', false, false); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + await savedQueryManagementComponent.loadSavedQuery('foo2'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:404'); + }); + + it('allows deleting saved queries in the saved query management component ', async () => { + await savedQueryManagementComponent.deleteSavedQuery('foo2'); + await savedQueryManagementComponent.savedQueryMissingOrFail('foo2'); + }); }); describe('global dashboard read-only privileges', () => { @@ -272,6 +303,33 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it(`Permalinks doesn't show create short-url button`, async () => { await PageObjects.share.openShareMenuItem('Permalinks'); await PageObjects.share.createShortUrlMissingOrFail(); + // close the menu + await PageObjects.share.clickShareTopNavButton(); + }); + + it('allows loading a saved query via the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:200'); + }); + + it('does not allow saving via the saved query management component popover with no query loaded', async () => { + await savedQueryManagementComponent.saveNewQueryMissingOrFail(); + }); + + it('does not allow saving changes to saved query from the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQueryMissingOrFail(); + }); + + it('does not allow deleting a saved query from the saved query management component', async () => { + await savedQueryManagementComponent.deleteSavedQueryMissingOrFail('OKJpgs'); + }); + + it('allows clearing the currently loaded saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); }); }); diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts index 8712247ee7a24..67bc8bd38ff1c 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts @@ -21,6 +21,8 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { ]); const testSubjects = getService('testSubjects'); const appsMenu = getService('appsMenu'); + const queryBar = getService('queryBar'); + const savedQueryManagementComponent = getService('savedQueryManagementComponent'); async function setDiscoverTimeRange() { const fromTime = '2015-09-19 06:31:44.000'; @@ -100,6 +102,37 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('Permalinks shows create short-url button', async () => { await PageObjects.share.openShareMenuItem('Permalinks'); await PageObjects.share.createShortUrlExistOrFail(); + // close the menu + await PageObjects.share.clickShareTopNavButton(); + }); + + it('allow saving via the saved query management component popover with no query loaded', async () => { + await savedQueryManagementComponent.saveNewQuery('foo', 'bar', true, false); + await savedQueryManagementComponent.savedQueryExistOrFail('foo'); + }); + + it('allow saving a currently loaded saved query as a new query via the saved query management component ', async () => { + await savedQueryManagementComponent.saveCurrentlyLoadedAsNewQuery( + 'foo2', + 'bar2', + true, + false + ); + await savedQueryManagementComponent.savedQueryExistOrFail('foo2'); + }); + + it('allow saving changes to a currently loaded query via the saved query management component', async () => { + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQuery('bar2', false, false); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + await savedQueryManagementComponent.loadSavedQuery('foo2'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:404'); + }); + + it('allows deleting saved queries in the saved query management component ', async () => { + await savedQueryManagementComponent.deleteSavedQuery('foo2'); + await savedQueryManagementComponent.savedQueryMissingOrFail('foo2'); }); }); @@ -167,6 +200,31 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.share.openShareMenuItem('Permalinks'); await PageObjects.share.createShortUrlMissingOrFail(); }); + + it('allows loading a saved query via the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:200'); + }); + + it('does not allow saving via the saved query management component popover with no query loaded', async () => { + await savedQueryManagementComponent.saveNewQueryMissingOrFail(); + }); + + it('does not allow saving changes to saved query from the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQueryMissingOrFail(); + }); + + it('does not allow deleting a saved query from the saved query management component', async () => { + await savedQueryManagementComponent.deleteSavedQueryMissingOrFail('OKJpgs'); + }); + + it('allows clearing the currently loaded saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + }); }); describe('discover and visualize privileges', () => { diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts index a0f364964e7b7..d85271fe166b1 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts @@ -21,6 +21,8 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const appsMenu = getService('appsMenu'); const globalNav = getService('globalNav'); + const queryBar = getService('queryBar'); + const savedQueryManagementComponent = getService('savedQueryManagementComponent'); describe('feature controls security', () => { before(async () => { @@ -112,6 +114,41 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('Permalinks shows create short-url button', async () => { await PageObjects.share.openShareMenuItem('Permalinks'); await PageObjects.share.createShortUrlExistOrFail(); + // close menu + await PageObjects.share.clickShareTopNavButton(); + }); + + it('allow saving via the saved query management component popover with no saved query loaded', async () => { + await queryBar.setQuery('response:200'); + await savedQueryManagementComponent.saveNewQuery('foo', 'bar', true, false); + await savedQueryManagementComponent.savedQueryExistOrFail('foo'); + await savedQueryManagementComponent.closeSavedQueryManagementComponent(); + }); + + it('allow saving a currently loaded saved query as a new query via the saved query management component ', async () => { + await savedQueryManagementComponent.saveCurrentlyLoadedAsNewQuery( + 'foo2', + 'bar2', + true, + false + ); + await savedQueryManagementComponent.savedQueryExistOrFail('foo2'); + await savedQueryManagementComponent.closeSavedQueryManagementComponent(); + }); + + it('allow saving changes to a currently loaded query via the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('foo2'); + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQuery('bar2', false, false); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + await savedQueryManagementComponent.loadSavedQuery('foo2'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:404'); + }); + + it('allows deleting saved queries in the saved query management component ', async () => { + await savedQueryManagementComponent.deleteSavedQuery('foo2'); + await savedQueryManagementComponent.savedQueryMissingOrFail('foo2'); }); }); @@ -194,6 +231,33 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it(`Permalinks doesn't show create short-url button`, async () => { await PageObjects.share.openShareMenuItem('Permalinks'); await PageObjects.share.createShortUrlMissingOrFail(); + // close the menu + await PageObjects.share.clickShareTopNavButton(); + }); + + it('allows loading a saved query via the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:200'); + }); + + it('does not allow saving via the saved query management component popover with no query loaded', async () => { + await savedQueryManagementComponent.saveNewQueryMissingOrFail(); + }); + + it('does not allow saving changes to saved query from the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQueryMissingOrFail(); + }); + + it('does not allow deleting a saved query from the saved query management component', async () => { + await savedQueryManagementComponent.deleteSavedQueryMissingOrFail('OKJpgs'); + }); + + it('allows clearing the currently loaded saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); }); }); diff --git a/x-pack/test/functional/es_archives/dashboard/feature_controls/security/data.json b/x-pack/test/functional/es_archives/dashboard/feature_controls/security/data.json index 46cec764158d5..4ff13f76bc43e 100644 --- a/x-pack/test/functional/es_archives/dashboard/feature_controls/security/data.json +++ b/x-pack/test/functional/es_archives/dashboard/feature_controls/security/data.json @@ -169,3 +169,25 @@ } } } + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "query:okjpgs", + "source": { + "query": { + "title": "OKJpgs", + "description": "Ok responses for jpg files", + "query": { + "query": "response:200", + "language": "kuery" + }, + "filters": [{"meta":{"index":"b15b1d40-a8bb-11e9-98cf-2bb06ef63e0b","alias":null,"negate":false,"type":"phrase","key":"extension.raw","value":"jpg","params":{"query":"jpg"},"disabled":false},"query":{"match":{"extension.raw":{"query":"jpg","type":"phrase"}}},"$state":{"store":"appState"}}] + }, + "type": "query", + "updated_at": "2019-07-17T17:54:26.378Z" + } + } +} diff --git a/x-pack/test/functional/es_archives/dashboard/feature_controls/security/mappings.json b/x-pack/test/functional/es_archives/dashboard/feature_controls/security/mappings.json index 032a109804b35..beb6aefbb4932 100644 --- a/x-pack/test/functional/es_archives/dashboard/feature_controls/security/mappings.json +++ b/x-pack/test/functional/es_archives/dashboard/feature_controls/security/mappings.json @@ -481,6 +481,35 @@ "type": "text" } } + }, + "query": { + "properties": { + "title": { + "type": "text" + }, + "description": { + "type": "text" + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "type": "keyword", + "index": false + } + } + }, + "filters": { + "type": "object", + "enabled": false + }, + "timefilter": { + "type": "object", + "enabled": false + } + } } } } diff --git a/x-pack/test/functional/es_archives/discover/feature_controls/security/data.json b/x-pack/test/functional/es_archives/discover/feature_controls/security/data.json index 3c0613005c950..394393dce4962 100644 --- a/x-pack/test/functional/es_archives/discover/feature_controls/security/data.json +++ b/x-pack/test/functional/es_archives/discover/feature_controls/security/data.json @@ -35,3 +35,25 @@ } } } + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "query:okjpgs", + "source": { + "query": { + "title": "OKJpgs", + "description": "Ok responses for jpg files", + "query": { + "query": "response:200", + "language": "kuery" + }, + "filters": [{"meta":{"index":"b15b1d40-a8bb-11e9-98cf-2bb06ef63e0b","alias":null,"negate":false,"type":"phrase","key":"extension.raw","value":"jpg","params":{"query":"jpg"},"disabled":false},"query":{"match":{"extension.raw":{"query":"jpg","type":"phrase"}}},"$state":{"store":"appState"}}] + }, + "type": "query", + "updated_at": "2019-07-17T17:54:26.378Z" + } + } +} diff --git a/x-pack/test/functional/es_archives/discover/feature_controls/security/mappings.json b/x-pack/test/functional/es_archives/discover/feature_controls/security/mappings.json index 35696c187537f..11e5ac6eeea26 100644 --- a/x-pack/test/functional/es_archives/discover/feature_controls/security/mappings.json +++ b/x-pack/test/functional/es_archives/discover/feature_controls/security/mappings.json @@ -453,6 +453,35 @@ "type": "text" } } + }, + "query": { + "properties": { + "title": { + "type": "text" + }, + "description": { + "type": "text" + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "type": "keyword", + "index": false + } + } + }, + "filters": { + "type": "object", + "enabled": false + }, + "timefilter": { + "type": "object", + "enabled": false + } + } } } } diff --git a/x-pack/test/functional/es_archives/visualize/default/data.json b/x-pack/test/functional/es_archives/visualize/default/data.json index 250af4d5c5c13..aa3428b62a636 100644 --- a/x-pack/test/functional/es_archives/visualize/default/data.json +++ b/x-pack/test/functional/es_archives/visualize/default/data.json @@ -108,3 +108,25 @@ } } } + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "query:okjpgs", + "source": { + "query": { + "title": "OKJpgs", + "description": "Ok responses for jpg files", + "query": { + "query": "response:200", + "language": "kuery" + }, + "filters": [{"meta":{"index":"b15b1d40-a8bb-11e9-98cf-2bb06ef63e0b","alias":null,"negate":false,"type":"phrase","key":"extension.raw","value":"jpg","params":{"query":"jpg"},"disabled":false},"query":{"match":{"extension.raw":{"query":"jpg","type":"phrase"}}},"$state":{"store":"appState"}}] + }, + "type": "query", + "updated_at": "2019-07-17T17:54:26.378Z" + } + } +} diff --git a/x-pack/test/functional/es_archives/visualize/default/mappings.json b/x-pack/test/functional/es_archives/visualize/default/mappings.json index 35696c187537f..11e5ac6eeea26 100644 --- a/x-pack/test/functional/es_archives/visualize/default/mappings.json +++ b/x-pack/test/functional/es_archives/visualize/default/mappings.json @@ -453,6 +453,35 @@ "type": "text" } } + }, + "query": { + "properties": { + "title": { + "type": "text" + }, + "description": { + "type": "text" + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "type": "keyword", + "index": false + } + } + }, + "filters": { + "type": "object", + "enabled": false + }, + "timefilter": { + "type": "object", + "enabled": false + } + } } } } diff --git a/x-pack/test/saved_object_api_integration/common/suites/export.ts b/x-pack/test/saved_object_api_integration/common/suites/export.ts index 548f803b1a969..5f1eef440b6f4 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/export.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/export.ts @@ -60,7 +60,7 @@ export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest