Implement saved queries and filters (#39140)

Introduces "saved queries". Saved queries are a new saved object type similar to saved searches but more limited in scope. They allow users to store the the query string in the query bar and optionally the set of filters and timefilter in order to reload them anywhere a query is expected: Discover, Visualize, Dashboard, anywhere that uses our full SearchBar component.
This commit is contained in:
Matt Bargar 2019-08-21 16:53:19 -04:00 committed by GitHub
parent cb8133aee5
commit e233e419cf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 2612 additions and 258 deletions

View file

@ -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,

View file

@ -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',
};
},
},
},
},
};

View file

@ -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,
},
},
},
};

View file

@ -4,3 +4,4 @@
@import './filter/filter_bar/index';
@import './search/search_bar/index';

View file

@ -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';

View file

@ -85,7 +85,7 @@ export class DataPlugin implements Plugin<DataSetup, void, DataPluginSetupDepend
indexPatterns: indexPatternsService.indexPatterns,
}),
query: this.query.setup(),
search: this.search.setup(),
search: this.search.setup(savedObjectsClient),
};
}

View file

@ -106,6 +106,8 @@ describe('QueryBar', () => {
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', () => {
<QueryBar.WrappedComponent
uiSettings={setupMock.uiSettings}
onSubmit={noop}
onChange={noop}
isDirty={false}
appName={'discover'}
store={createMockStorage()}
intl={null as any}
@ -151,6 +157,8 @@ describe('QueryBar', () => {
<QueryBar.WrappedComponent
uiSettings={setupMock.uiSettings}
onSubmit={noop}
onChange={noop}
isDirty={false}
appName={'discover'}
store={createMockStorage()}
intl={null as any}
@ -167,6 +175,8 @@ describe('QueryBar', () => {
<QueryBar.WrappedComponent
uiSettings={setupMock.uiSettings}
onSubmit={noop}
onChange={noop}
isDirty={false}
appName={'discover'}
screenTitle={'Another Screen'}
store={createMockStorage()}
@ -187,6 +197,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', () => {
<QueryBar.WrappedComponent
uiSettings={setupMock.uiSettings}
onSubmit={noop}
onChange={noop}
isDirty={false}
appName={'discover'}
screenTitle={'Another Screen'}
store={createMockStorage()}

View file

@ -20,8 +20,6 @@
import { doesKueryExpressionHaveLuceneSyntaxError } from '@kbn/es-query';
import classNames from 'classnames';
import _ from 'lodash';
import { get, isEqual } from 'lodash';
import React, { Component } from 'react';
import { Storage } from 'ui/storage';
import { timeHistory } from 'ui/timefilter/time_history';
@ -50,13 +48,14 @@ interface DateRange {
interface Props {
query?: Query;
onSubmit: (payload: { dateRange: DateRange; query?: Query }) => void;
onChange: (payload: { dateRange: DateRange; query?: Query }) => void;
disableAutoFocus?: boolean;
appName: string;
screenTitle?: string;
indexPatterns?: Array<IndexPattern | string>;
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<Props, State> {
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<Props, State> {
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<HTMLButtonElement>) => {
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<Props, State> {
}) => {
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<Props, State> {
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<Props, State> {
}
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<Props, State> {
React.cloneElement(this.props.customSubmitButton, { onClick: this.onClickSubmitButton })
) : (
<EuiSuperUpdateButton
needsUpdate={this.isDirty()}
needsUpdate={this.props.isDirty}
isDisabled={this.state.isDateRangeInvalid}
onClick={this.onClickSubmitButton}
data-test-subj="querySubmitButton"
@ -358,8 +278,8 @@ export class QueryBarUI extends Component<Props, State> {
return (
<EuiFlexItem className="kbnQueryBar__datePickerWrapper">
<EuiSuperDatePicker
start={this.state.dateRangeFrom}
end={this.state.dateRangeTo}
start={this.props.dateRangeFrom}
end={this.props.dateRangeTo}
isPaused={this.props.isRefreshPaused}
refreshInterval={this.props.refreshInterval}
onTimeChange={this.onTimeChange}
@ -375,13 +295,13 @@ export class QueryBarUI extends Component<Props, State> {
}
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<Props, State> {
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButton size="s" onClick={() => this.onLuceneSyntaxWarningOptOut(toast)}>
Don't show again
<FormattedMessage
id="data.query.queryBar.luceneSyntaxWarningOptOutText"
defaultMessage="Don't show again"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
@ -422,6 +345,7 @@ export class QueryBarUI extends Component<Props, State> {
}
private onLuceneSyntaxWarningOptOut(toast: Toast) {
if (!this.props.store) return;
this.props.store.set('kibana.luceneSyntaxWarningOptOut', true);
toastNotifications.remove(toast);
}

View file

@ -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<Props, State> {
};
private onQueryStringChange = (value: string) => {
const hasValue = Boolean(value.trim());
this.setState({
isSuggestionsVisible: hasValue,
isSuggestionsVisible: true,
index: null,
suggestionLimit: 50,
});

View file

@ -4,6 +4,7 @@ exports[`SuggestionComponent Should display the suggestion and use the provided
<div
aria-selected={false}
className="kbnTypeahead__item"
data-test-subj="autocompleteSuggestion-value-as-promised,-not-helpful"
id="suggestion-1"
onClick={[Function]}
onMouseEnter={[Function]}
@ -37,6 +38,7 @@ exports[`SuggestionComponent Should make the element active if the selected prop
<div
aria-selected={true}
className="kbnTypeahead__item active"
data-test-subj="autocompleteSuggestion-value-as-promised,-not-helpful"
id="suggestion-1"
onClick={[Function]}
onMouseEnter={[Function]}

View file

@ -19,7 +19,7 @@
import { EuiIcon } from '@elastic/eui';
import classNames from 'classnames';
import React, { SFC } from 'react';
import React, { FunctionComponent } from 'react';
import { AutocompleteSuggestion } from 'ui/autocomplete_providers';
function getEuiIconType(type: string) {
@ -48,7 +48,7 @@ interface Props {
ariaId: string;
}
export const SuggestionComponent: SFC<Props> = props => {
export const SuggestionComponent: FunctionComponent<Props> = props => {
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
<div
@ -62,6 +62,9 @@ export const SuggestionComponent: SFC<Props> = props => {
ref={props.innerRef}
id={props.ariaId}
aria-selected={props.selected}
data-test-subj={`autocompleteSuggestion-${
props.suggestion.type
}-${props.suggestion.text.replace(/\s/g, '-')}`}
>
<div className={'kbnSuggestionItem kbnSuggestionItem--' + props.suggestion.type}>
<div className="kbnSuggestionItem__type">

View file

@ -0,0 +1 @@
@import 'components/saved_query_management/saved_query_management_component';

View file

@ -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;
}

View file

@ -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<Props> = ({
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<SavedQuery[]>([]);
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 = (
<EuiForm isInvalid={hasErrors} error={errors()}>
<EuiFormRow>
<EuiText color="subdued">{savedQueryDescriptionText}</EuiText>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('data.search.searchBar.savedQueryNameLabelText', {
defaultMessage: 'Name',
})}
helpText={i18n.translate('data.search.searchBar.savedQueryNameHelpText', {
defaultMessage:
'Name cannot contain leading or trailing whitespace. Name must be unique.',
})}
isInvalid={hasErrors}
>
<EuiFieldText
disabled={!!savedQuery}
value={title}
name="title"
onChange={event => {
setTitle(event.target.value);
}}
data-test-subj="saveQueryFormTitle"
isInvalid={hasErrors}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('data.search.searchBar.savedQueryDescriptionLabelText', {
defaultMessage: 'Description',
})}
>
<EuiFieldText
value={description}
name="description"
onChange={event => {
setDescription(event.target.value);
}}
data-test-subj="saveQueryFormDescription"
/>
</EuiFormRow>
{showFilterOption && (
<EuiFormRow>
<EuiSwitch
name="shouldIncludeFilters"
label={i18n.translate('data.search.searchBar.savedQueryIncludeFiltersLabelText', {
defaultMessage: 'Include filters',
})}
checked={shouldIncludeFilters}
onChange={() => {
setShouldIncludeFilters(!shouldIncludeFilters);
}}
data-test-subj="saveQueryFormIncludeFiltersOption"
/>
</EuiFormRow>
)}
{showTimeFilterOption && (
<EuiFormRow>
<EuiSwitch
name="shouldIncludeTimefilter"
label={i18n.translate('data.search.searchBar.savedQueryIncludeTimeFilterLabelText', {
defaultMessage: 'Include time filter',
})}
checked={shouldIncludeTimefilter}
onChange={() => {
setIncludeTimefilter(!shouldIncludeTimefilter);
}}
data-test-subj="saveQueryFormIncludeTimeFilterOption"
/>
</EuiFormRow>
)}
</EuiForm>
);
return (
<EuiOverlayMask>
<EuiModal onClose={onClose} initialFocus="[name=title]">
<EuiModalHeader>
<EuiModalHeaderTitle>
{i18n.translate('data.search.searchBar.savedQueryFormTitle', {
defaultMessage: 'Save query',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>{saveQueryForm}</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onClose} data-test-subj="savedQueryFormCancelButton">
{i18n.translate('data.search.searchBar.savedQueryFormCancelButtonText', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
<EuiButton
onClick={() =>
onSave({
title,
description,
shouldIncludeFilters,
shouldIncludeTimefilter,
})
}
fill
data-test-subj="savedQueryFormSaveButton"
disabled={hasErrors}
>
{i18n.translate('data.search.searchBar.savedQueryFormSaveButtonText', {
defaultMessage: 'Save',
})}
</EuiButton>
</EuiModalFooter>
</EuiModal>
</EuiOverlayMask>
);
};

View file

@ -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 (
<Fragment>
<li
key={savedQuery.id}
data-test-subj={`saved-query-list-item ${
isSelected ? 'saved-query-list-item-selected' : ''
}`}
>
<EuiFlexGroup justifyContent="spaceBetween" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={() => {
onSelect(savedQuery);
}}
flush="left"
data-test-subj={selectButtonDataTestSubj}
textProps={isSelected ? { className: 'saved-query-list-item-text' } : undefined}
aria-label={selectButtonAriaLabelText}
>
{savedQuery.attributes.title}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="none" justifyContent="flexEnd" alignItems="center">
<EuiFlexItem>
{savedQuery.attributes.description && (
<EuiIconTip
type="iInCircle"
content={savedQuery.attributes.description}
aria-label={i18n.translate(
'data.search.searchBar.savedQueryPopoverSavedQueryListItemDescriptionAriaLabel',
{
defaultMessage: '{savedQueryName} description',
values: { savedQueryName: savedQuery.attributes.title },
}
)}
/>
)}
</EuiFlexItem>
<EuiFlexItem>
{showWriteOperations && (
<Fragment>
<EuiToolTip
position="top"
content={
<p>
{i18n.translate(
'data.search.searchBar.savedQueryPopoverDeleteButtonTooltip',
{
defaultMessage: 'Delete saved query',
}
)}
</p>
}
>
<EuiButtonEmpty
onClick={() => {
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`}
/>
</EuiToolTip>
</Fragment>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</li>
{showDeletionConfirmationModal && (
<EuiOverlayMask>
<EuiConfirmModal
title={i18n.translate('data.search.searchBar.savedQueryPopoverConfirmDeletionTitle', {
defaultMessage: 'Delete {savedQueryName}?',
values: {
savedQueryName: savedQuery.attributes.title,
},
})}
confirmButtonText={i18n.translate(
'data.search.searchBar.savedQueryPopoverConfirmDeletionConfirmButtonText',
{
defaultMessage: 'Delete',
}
)}
cancelButtonText={i18n.translate(
'data.search.searchBar.savedQueryPopoverConfirmDeletionCancelButtonText',
{
defaultMessage: 'Cancel',
}
)}
onConfirm={() => {
onDelete(savedQuery);
setShowDeletionConfirmationModal(false);
}}
onCancel={() => {
setShowDeletionConfirmationModal(false);
}}
/>
</EuiOverlayMask>
)}
</Fragment>
);
};

View file

@ -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<Props> = ({
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 = (
<EuiButtonEmpty
className="euiFormControlLayout__prepend"
iconType="arrowDown"
iconSide="right"
onClick={() => {
setIsOpen(!isOpen);
}}
aria-label={i18n.translate('data.search.searchBar.savedQueryPopoverButtonText', {
defaultMessage: 'See saved queries',
})}
data-test-subj="saved-query-management-popover-button"
>
#
</EuiButtonEmpty>
);
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 => (
<SavedQueryListItem
key={savedQuery.id}
savedQuery={savedQuery}
isSelected={!!loadedSavedQuery && loadedSavedQuery.id === savedQuery.id}
onSelect={savedQueryToSelect => {
onLoad(savedQueryToSelect);
setIsOpen(false);
}}
onDelete={savedQueryToDelete => onDeleteSavedQuery(savedQueryToDelete)}
showWriteOperations={!!showSaveQuery}
/>
));
};
return (
<Fragment>
<EuiPopover
id="savedQueryPopover"
button={savedQueryPopoverButton}
isOpen={isOpen}
closePopover={() => {
setIsOpen(false);
}}
anchorPosition="downLeft"
ownFocus
>
<div
className="saved-query-management-popover"
data-test-subj="saved-query-management-popover"
>
<EuiPopoverTitle id={'savedQueryManagementPopoverTitle'}>
{savedQueryPopoverTitleText}
</EuiPopoverTitle>
{savedQueries.length > 0 ? (
<Fragment>
<EuiFlexGroup wrap>
<EuiFlexItem>
<EuiText>{savedQueryDescriptionText}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem className="saved-query-list-wrapper">
<ul
className="saved-query-list"
aria-labelledby={'savedQueryManagementPopoverTitle'}
>
{savedQueryRows()}
</ul>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiPagination
pageCount={Math.ceil(savedQueries.length / pageCount)}
activePage={activePage}
onPageClick={goToPage}
/>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
) : (
<EuiText grow={false}>{noSavedQueriesDescriptionText}</EuiText>
)}
<EuiFlexGroup direction="rowReverse" alignItems="center" justifyContent="flexEnd">
{showSaveQuery && loadedSavedQuery && (
<EuiFlexItem grow={false}>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
onClick={() => 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',
}
)}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
fill
onClick={() => 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',
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
)}
{showSaveQuery && !loadedSavedQuery && (
<EuiFlexItem grow={false}>
<EuiButton
fill
onClick={() => 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',
})}
</EuiButton>
</EuiFlexItem>
)}
<EuiFlexItem />
<EuiFlexItem grow={false}>
{loadedSavedQuery && (
<EuiButtonEmpty
onClick={() => 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',
})}
</EuiButtonEmpty>
)}
</EuiFlexItem>
</EuiFlexGroup>
</div>
</EuiPopover>
</Fragment>
);
};

View file

@ -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(
<SearchBar.WrappedComponent
uiSettings={setupMock.uiSettings}
savedQueryService={mockSavedQueryService}
appName={'test'}
indexPatterns={[mockIndexPattern]}
intl={null as any}
@ -99,6 +115,7 @@ describe('SearchBar', () => {
const component = mountWithIntl(
<SearchBar.WrappedComponent
uiSettings={setupMock.uiSettings}
savedQueryService={mockSavedQueryService}
appName={'test'}
indexPatterns={[mockIndexPattern]}
intl={null as any}
@ -115,6 +132,7 @@ describe('SearchBar', () => {
const component = mountWithIntl(
<SearchBar.WrappedComponent
uiSettings={setupMock.uiSettings}
savedQueryService={mockSavedQueryService}
appName={'test'}
indexPatterns={[mockIndexPattern]}
intl={null as any}
@ -133,6 +151,7 @@ describe('SearchBar', () => {
const component = mountWithIntl(
<SearchBar.WrappedComponent
uiSettings={setupMock.uiSettings}
savedQueryService={mockSavedQueryService}
appName={'test'}
indexPatterns={[mockIndexPattern]}
intl={null as any}
@ -152,6 +171,7 @@ describe('SearchBar', () => {
const component = mountWithIntl(
<SearchBar.WrappedComponent
uiSettings={setupMock.uiSettings}
savedQueryService={mockSavedQueryService}
appName={'test'}
indexPatterns={[mockIndexPattern]}
intl={null as any}
@ -171,6 +191,7 @@ describe('SearchBar', () => {
const component = mountWithIntl(
<SearchBar.WrappedComponent
uiSettings={setupMock.uiSettings}
savedQueryService={mockSavedQueryService}
appName={'test'}
indexPatterns={[mockIndexPattern]}
intl={null as any}
@ -191,6 +212,7 @@ describe('SearchBar', () => {
const component = mountWithIntl(
<SearchBar.WrappedComponent
uiSettings={setupMock.uiSettings}
savedQueryService={mockSavedQueryService}
appName={'test'}
indexPatterns={[mockIndexPattern]}
intl={null as any}

View file

@ -17,18 +17,21 @@
* under the License.
*/
// @ts-ignore
import { EuiFilterButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Filter } from '@kbn/es-query';
import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import classNames from 'classnames';
import React, { Component } from 'react';
import ResizeObserver from 'resize-observer-polyfill';
import { Storage } from 'ui/storage';
import { get, isEqual } from 'lodash';
import { toastNotifications } from 'ui/notify';
import { UiSettingsClientContract } from 'src/core/public';
import { IndexPattern, Query, QueryBar, FilterBar } from '../../../../../data/public';
import { SavedQuery, SavedQueryAttributes } from '../index';
import { SavedQueryMeta, SaveQueryForm } from './saved_query_management/save_query_form';
import { SavedQueryManagementComponent } from './saved_query_management/saved_query_management_component';
import { SavedQueryService } from '../lib/saved_query_service';
interface DateRange {
from: string;
@ -43,6 +46,7 @@ export interface SearchBarProps {
appName: string;
intl: InjectedIntl;
uiSettings: UiSettingsClientContract;
savedQueryService: SavedQueryService;
indexPatterns?: IndexPattern[];
// Query bar
showQueryBar?: boolean;
@ -50,6 +54,7 @@ export interface SearchBarProps {
screenTitle?: string;
store?: Storage;
query?: Query;
savedQuery?: SavedQuery;
onQuerySubmit?: (payload: { dateRange: DateRange; query?: Query }) => 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<SearchBarProps, State> {
@ -81,15 +98,86 @@ class SearchBarUI extends Component<SearchBarProps, State> {
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<SearchBarProps, State> {
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<SearchBarProps, State> {
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 (
<EuiFilterButton
onClick={this.toggleFiltersVisible}
isSelected={this.state.isFiltersVisible}
hasActiveFilters={this.state.isFiltersVisible}
numFilters={filterCount ? this.getFilterLength() : undefined}
aria-controls="GlobalFilterGroup"
aria-expanded={!!this.state.isFiltersVisible}
title={`${filterCount ? filtersAppliedText : ''} ${clickToShowOrHideText}`}
>
{i18n.translate('data.search.searchBar.searchBar.filtersButtonLabel', {
defaultMessage: 'Filters',
description: 'The noun "filter" in plural.',
})}
</EuiFilterButton>
);
}
public setFilterBarHeight = () => {
requestAnimationFrame(() => {
const height =
@ -164,12 +212,131 @@ class SearchBarUI extends Component<SearchBarProps, State> {
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({
isFiltersVisible: !this.state.isFiltersVisible,
showSaveQueryModal: true,
});
};
public onInitiateSaveNew = () => {
this.setState({
showSaveNewQueryModal: true,
});
};
public onQueryBarChange = (queryAndDateRange: { dateRange: DateRange; query?: Query }) => {
this.setState({
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<SearchBarProps, State> {
return null;
}
const savedQueryManagement = this.state.query && this.props.onClearSavedQuery && (
<SavedQueryManagementComponent
showSaveQuery={this.props.showSaveQuery}
loadedSavedQuery={this.props.savedQuery}
onSave={this.onInitiateSave}
onSaveAsNew={this.onInitiateSaveNew}
onLoad={this.onLoadSavedQuery}
savedQueryService={this.props.savedQueryService}
onClearSavedQuery={this.props.onClearSavedQuery}
></SavedQueryManagementComponent>
);
let queryBar;
if (this.shouldRenderQueryBar()) {
queryBar = (
<QueryBar
uiSettings={this.props.uiSettings}
query={this.props.query}
query={this.state.query}
screenTitle={this.props.screenTitle}
onSubmit={this.props.onQuerySubmit!}
onSubmit={this.onQueryBarSubmit}
appName={this.props.appName}
indexPatterns={this.props.indexPatterns}
store={this.props.store!}
prepend={this.props.showFilterBar ? this.getFilterTriggerButton() : undefined}
store={this.props.store}
prepend={this.props.showFilterBar ? savedQueryManagement : undefined}
showDatePicker={this.props.showDatePicker}
showQueryInput={this.props.showQueryInput}
dateRangeFrom={this.props.dateRangeFrom}
dateRangeTo={this.props.dateRangeTo}
dateRangeFrom={this.state.dateRangeFrom}
dateRangeTo={this.state.dateRangeTo}
isRefreshPaused={this.props.isRefreshPaused}
refreshInterval={this.props.refreshInterval}
showAutoRefreshOnly={this.props.showAutoRefreshOnly}
showQueryInput={this.props.showQueryInput}
onRefreshChange={this.props.onRefreshChange}
onChange={this.onQueryBarChange}
isDirty={this.isDirty()}
customSubmitButton={
this.props.customSubmitButton ? this.props.customSubmitButton : undefined
}
/>
);
}
@ -249,6 +433,26 @@ class SearchBarUI extends Component<SearchBarProps, State> {
<div className="globalQueryBar">
{queryBar}
{filterBar}
{this.state.showSaveQueryModal ? (
<SaveQueryForm
savedQuery={this.props.savedQuery ? this.props.savedQuery.attributes : undefined}
savedQueryService={this.props.savedQueryService}
onSave={this.onSave}
onClose={() => this.setState({ showSaveQueryModal: false })}
showFilterOption={this.props.showFilterBar}
showTimeFilterOption={this.props.showDatePicker}
/>
) : null}
{this.state.showSaveNewQueryModal ? (
<SaveQueryForm
savedQueryService={this.props.savedQueryService}
onSave={savedQueryMeta => this.onSave(savedQueryMeta, true)}
onClose={() => this.setState({ showSaveNewQueryModal: false })}
showFilterOption={this.props.showFilterBar}
showTimeFilterOption={this.props.showDatePicker}
/>
) : null}
</div>
);
}

View file

@ -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;
}

View file

@ -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');
});
});
});

View file

@ -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<SavedQuery>;
getAllSavedQueries: () => Promise<SavedQuery[]>;
findSavedQueries: (searchText?: string) => Promise<SavedQuery[]>;
getSavedQuery: (id: string) => Promise<SavedQuery>;
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<SavedQuery[]> => {
const response = await savedObjectsClient.find<SerializedSavedQueryAttributes>({
type: 'query',
});
return response.savedObjects.map(
(savedObject: { id: string; attributes: SerializedSavedQueryAttributes }) =>
parseSavedQueryObject(savedObject)
);
};
const findSavedQueries = async (searchText: string = ''): Promise<SavedQuery[]> => {
const response = await savedObjectsClient.find<SerializedSavedQueryAttributes>({
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<SavedQuery> => {
const response = await savedObjectsClient.get<SerializedSavedQueryAttributes>('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,
};
};

View file

@ -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() {}

View file

@ -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,

View file

@ -3,16 +3,18 @@
ng-class="{'dshAppContainer--withMargins': model.useMargins}"
>
<!-- Local nav. -->
<kbn-top-nav
<kbn-top-nav
ng-show="chrome.getVisible()"
app-name="'dashboard'"
config="topNavMenu"
config="topNavMenu"
show-search-bar="chrome.getVisible()"
show-filter-bar="showFilterBar()"
show-save-query="showSaveQuery"
filters="model.filters"
query="model.query"
saved-query="savedQuery"
screen-title="screenTitle"
on-query-submit="updateQueryAndFetch"
index-patterns="indexPatterns"
@ -22,6 +24,9 @@
date-range-to="model.timeRange.to"
is-refresh-paused="model.refreshInterval.pause"
refresh-interval="model.refreshInterval.value"
on-saved="onQuerySaved"
on-saved-query-updated="onSavedQueryUpdated"
on-clear-saved-query="onClearSavedQuery"
on-refresh-change="onRefreshChange">
</kbn-top-nav>

View file

@ -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;

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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"
>
</kbn-top-nav>

View file

@ -23,25 +23,26 @@
</a>
</div>
</div>
<!--
Local nav.
Most visualizations have all search bar components enabled
Some visualizations have fewer options but all visualizations have a search bar
<!--
Local nav.
Most visualizations have all search bar components enabled
Some visualizations have fewer options but all visualizations have a search bar
which is why show-search-baris set to "true".
All visualizaions also have least a timepicker \ autorefresh component, which is why
show-query-bar is set to "true".
-->
<kbn-top-nav
<kbn-top-nav
app-name="'visualize'"
config="topNavMenu"
show-search-bar="true"
show-query-bar="true"
show-query-bar="true"
show-query-input="showQueryInput()"
show-filter-bar="showFilterBar() && chrome.getVisible()"
show-date-picker="showQueryBarTimePicker()"
show-auto-refresh-only="!showQueryBarTimePicker()"
query="state.query"
saved-query="savedQuery"
screen-title="state.vis.title"
on-query-submit="updateQueryAndFetch"
index-patterns="[indexPattern]"
@ -52,6 +53,10 @@
is-refresh-paused="refreshInterval.pause"
refresh-interval="refreshInterval.value"
on-refresh-change="onRefreshChange"
show-save-query="showSaveQuery"
on-saved="onQuerySaved"
on-saved-query-updated="onSavedQueryUpdated"
on-clear-saved-query="onClearSavedQuery"
>
</kbn-top-nav>

View file

@ -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.
*/

View file

@ -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(<TopNavMenu name="test" uiSettings={setupMock.uiSettings} />);
const component = shallowWithIntl(
<TopNavMenu
name="test"
uiSettings={setupMock.uiSettings}
savedObjectsClient={startMock.savedObjects.client}
/>
);
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(
<TopNavMenu name="test" uiSettings={setupMock.uiSettings} config={[menuItems[0]]} />
<TopNavMenu
name="test"
uiSettings={setupMock.uiSettings}
savedObjectsClient={startMock.savedObjects.client}
config={[menuItems[0]]}
/>
);
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(
<TopNavMenu name="test" uiSettings={setupMock.uiSettings} config={menuItems} />
<TopNavMenu
name="test"
uiSettings={setupMock.uiSettings}
savedObjectsClient={startMock.savedObjects.client}
config={menuItems}
/>
);
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(
<TopNavMenu name="test" uiSettings={setupMock.uiSettings} showSearchBar={true} />
<TopNavMenu
name="test"
uiSettings={setupMock.uiSettings}
savedObjectsClient={startMock.savedObjects.client}
showSearchBar={true}
/>
);
expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0);

View file

@ -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<SearchBarProps> & {
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 (
<SearchBar
query={props.query}
filters={props.filters}
uiSettings={props.uiSettings}
savedQueryService={savedQueryService}
showQueryBar={props.showQueryBar}
showQueryInput={props.showQueryInput}
showFilterBar={props.showFilterBar}
@ -79,6 +84,11 @@ export function TopNavMenu(props: Props) {
refreshInterval={props.refreshInterval}
indexPatterns={props.indexPatterns}
store={props.store}
savedQuery={props.savedQuery}
showSaveQuery={props.showSaveQuery}
onClearSavedQuery={props.onClearSavedQuery}
onSaved={props.onSaved}
onSavedQueryUpdated={props.onSavedQueryUpdated}
/>
);
}

View file

@ -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[];

View file

@ -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',

View file

@ -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<Props, State> {
{this.renderDuplicateTitleCallout()}
<EuiForm>
{this.props.description && (
<EuiFormRow>
<EuiText color="subdued">{this.props.description}</EuiText>
</EuiFormRow>
)}
{this.renderCopyOnSave()}
<EuiFormRow

View file

@ -23,7 +23,7 @@ import { RefreshInterval } from '../../../../plugins/data/public';
// NOTE: These types are somewhat guessed, they may be incorrect.
export { RefreshInterval };
export { RefreshInterval, TimeRange };
export interface Timefilter {
time: TimeRange;

View file

@ -227,7 +227,7 @@ export default function ({ getService }) {
.then(resp => {
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: {

View file

@ -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: {

View file

@ -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'],

View file

@ -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('');
});
});
});
}

View file

@ -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'));

View file

@ -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 @@
}
}
}
}
}

View file

@ -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 @@
}
}
}
}
}

View file

@ -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,
};

View file

@ -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();
}

View file

@ -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"
>
<div className="mapFilterEditor" data-test-subj="mapFilterEditor">
<QueryBar
<SearchBar
uiSettings={settings}
query={layerQuery ? layerQuery : { language: settings.get('search:queryLanguage'), query: '' }}
onSubmit={this._onQueryChange}
appName="maps"
showFilterBar={false}
showDatePicker={false}
showQueryInput={true}
query={layerQuery ? layerQuery : { language: settings.get('search:queryLanguage'), query: '' }}
onQuerySubmit={this._onQueryChange}
appName="maps"
indexPatterns={this.state.indexPatterns}
store={localStorage}
customSubmitButton={

View file

@ -14,7 +14,7 @@ import {
EuiFormHelpText,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { QueryBar } from 'plugins/data';
import { SearchBar } from 'plugins/data';
import { Storage } from 'ui/storage';
const settings = chrome.getUiSettingsClient();
@ -78,12 +78,14 @@ export class WhereExpression extends Component {
defaultMessage="Use a query to narrow right source."
/>
</EuiFormHelpText>
<QueryBar
<SearchBar
uiSettings={settings}
query={whereQuery ? whereQuery : { language: settings.get('search:queryLanguage'), query: '' }}
onSubmit={this._onQueryChange}
appName="maps"
showFilterBar={false}
showDatePicker={false}
showQueryInput={true}
query={whereQuery ? whereQuery : { language: settings.get('search:queryLanguage'), query: '' }}
onQuerySubmit={this._onQueryChange}
appName="maps"
indexPatterns={[indexPattern]}
store={localStorage}
customSubmitButton={

View file

@ -20,15 +20,15 @@ const buildKibanaFeatures = (savedObjectTypes: string[]) => {
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'],

View file

@ -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} を作成",

View file

@ -796,8 +796,6 @@
"data.query.queryBar.syntaxOptionsDescription": "{docsLink} (KQL) 提供简化查询语法并支持脚本字段。如果您具有基本许可或更高级别的许可KQL 还提供自动填充功能。如果关闭 KQLKibana 将使用 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}",

View file

@ -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();
});
});

View file

@ -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', () => {

View file

@ -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();
});
});

View file

@ -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"
}
}
}

View file

@ -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
}
}
}
}
}

View file

@ -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"
}
}
}

View file

@ -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
}
}
}
}
}

View file

@ -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"
}
}
}

View file

@ -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
}
}
}
}
}

View file

@ -60,7 +60,7 @@ export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message: `child \"objects\" fails because [\"objects\" at position 0 fails because [child \"type\" fails because [\"type\" must be one of [canvas-element, canvas-workpad, config, dashboard, globaltype, index-pattern, map, search, url, visualization]]]]`,
message: `child \"objects\" fails because [\"objects\" at position 0 fails because [child \"type\" fails because [\"type\" must be one of [canvas-element, canvas-workpad, config, dashboard, globaltype, index-pattern, map, query, search, url, visualization]]]]`,
validation: {
source: 'payload',
keys: ['objects.0.type'],