mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Solution for use cases that need a query bar without a submit button or date picker that still want KQL and autocomplete. Necessary for the KQL in TSVB and KQL in filters aggregation efforts.
This commit is contained in:
parent
66054e8208
commit
d6100f4f36
11 changed files with 1894 additions and 821 deletions
|
@ -1,179 +1,5 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`QueryBar Should disable autoFocus on EuiFieldText when disableAutoFocus prop is true 1`] = `
|
||||
<EuiFlexGroup
|
||||
className="kbnQueryBar"
|
||||
gutterSize="s"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<EuiOutsideClickDetector
|
||||
onOutsideClick={[Function]}
|
||||
>
|
||||
<div
|
||||
aria-controls="kbnTypeahead__items"
|
||||
aria-expanded={false}
|
||||
aria-haspopup="true"
|
||||
aria-owns="kbnTypeahead__items"
|
||||
role="combobox"
|
||||
style={
|
||||
Object {
|
||||
"position": "relative",
|
||||
}
|
||||
}
|
||||
>
|
||||
<form
|
||||
name="queryBarForm"
|
||||
>
|
||||
<div
|
||||
role="search"
|
||||
>
|
||||
<div
|
||||
className="kuiLocalSearchAssistedInput"
|
||||
>
|
||||
<EuiFieldText
|
||||
append={
|
||||
<QueryLanguageSwitcher
|
||||
language="kuery"
|
||||
onSelectLanguage={[Function]}
|
||||
/>
|
||||
}
|
||||
aria-activedescendant=""
|
||||
aria-autocomplete="list"
|
||||
aria-controls="kbnTypeahead__items"
|
||||
aria-label="You are on search box of Another Screen page. Start typing to search and filter the discover"
|
||||
autoComplete="off"
|
||||
autoFocus={false}
|
||||
compressed={false}
|
||||
data-test-subj="queryInput"
|
||||
fullWidth={true}
|
||||
inputRef={[Function]}
|
||||
isLoading={false}
|
||||
onChange={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onKeyUp={[Function]}
|
||||
placeholder="Search"
|
||||
role="textbox"
|
||||
spellCheck={false}
|
||||
type="text"
|
||||
value="response:200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<SuggestionsComponent
|
||||
index={null}
|
||||
loadMore={[Function]}
|
||||
onClick={[Function]}
|
||||
onMouseEnter={[Function]}
|
||||
show={false}
|
||||
suggestions={Array []}
|
||||
/>
|
||||
</div>
|
||||
</EuiOutsideClickDetector>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
<EuiSuperUpdateButton
|
||||
data-test-subj="querySubmitButton"
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
needsUpdate={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
`;
|
||||
|
||||
exports[`QueryBar Should pass the query language to the language switcher 1`] = `
|
||||
<EuiFlexGroup
|
||||
className="kbnQueryBar"
|
||||
gutterSize="s"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<EuiOutsideClickDetector
|
||||
onOutsideClick={[Function]}
|
||||
>
|
||||
<div
|
||||
aria-controls="kbnTypeahead__items"
|
||||
aria-expanded={false}
|
||||
aria-haspopup="true"
|
||||
aria-owns="kbnTypeahead__items"
|
||||
role="combobox"
|
||||
style={
|
||||
Object {
|
||||
"position": "relative",
|
||||
}
|
||||
}
|
||||
>
|
||||
<form
|
||||
name="queryBarForm"
|
||||
>
|
||||
<div
|
||||
role="search"
|
||||
>
|
||||
<div
|
||||
className="kuiLocalSearchAssistedInput"
|
||||
>
|
||||
<EuiFieldText
|
||||
append={
|
||||
<QueryLanguageSwitcher
|
||||
language="lucene"
|
||||
onSelectLanguage={[Function]}
|
||||
/>
|
||||
}
|
||||
aria-activedescendant=""
|
||||
aria-autocomplete="list"
|
||||
aria-controls="kbnTypeahead__items"
|
||||
aria-label="You are on search box of Another Screen page. Start typing to search and filter the discover"
|
||||
autoComplete="off"
|
||||
autoFocus={true}
|
||||
compressed={false}
|
||||
data-test-subj="queryInput"
|
||||
fullWidth={true}
|
||||
inputRef={[Function]}
|
||||
isLoading={false}
|
||||
onChange={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onKeyUp={[Function]}
|
||||
placeholder="Search"
|
||||
role="textbox"
|
||||
spellCheck={false}
|
||||
type="text"
|
||||
value="response:200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<SuggestionsComponent
|
||||
index={null}
|
||||
loadMore={[Function]}
|
||||
onClick={[Function]}
|
||||
onMouseEnter={[Function]}
|
||||
show={false}
|
||||
suggestions={Array []}
|
||||
/>
|
||||
</div>
|
||||
</EuiOutsideClickDetector>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
<EuiSuperUpdateButton
|
||||
data-test-subj="querySubmitButton"
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
needsUpdate={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
`;
|
||||
|
||||
exports[`QueryBar Should render the given query 1`] = `
|
||||
<EuiFlexGroup
|
||||
className="kbnQueryBar"
|
||||
|
@ -181,71 +7,54 @@ exports[`QueryBar Should render the given query 1`] = `
|
|||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<EuiOutsideClickDetector
|
||||
onOutsideClick={[Function]}
|
||||
>
|
||||
<div
|
||||
aria-controls="kbnTypeahead__items"
|
||||
aria-expanded={false}
|
||||
aria-haspopup="true"
|
||||
aria-owns="kbnTypeahead__items"
|
||||
role="combobox"
|
||||
style={
|
||||
<InjectIntl(QueryBarInputUI)
|
||||
appName="discover"
|
||||
indexPatterns={
|
||||
Array [
|
||||
Object {
|
||||
"position": "relative",
|
||||
}
|
||||
"fields": Array [
|
||||
Object {
|
||||
"aggregatable": true,
|
||||
"esTypes": Array [
|
||||
"integer",
|
||||
],
|
||||
"filterable": true,
|
||||
"name": "response",
|
||||
"searchable": true,
|
||||
"type": "number",
|
||||
},
|
||||
],
|
||||
"id": "1234",
|
||||
"title": "logstash-*",
|
||||
},
|
||||
]
|
||||
}
|
||||
onChange={[Function]}
|
||||
onSubmit={[Function]}
|
||||
query={
|
||||
Object {
|
||||
"language": "kuery",
|
||||
"query": "response:200",
|
||||
}
|
||||
>
|
||||
<form
|
||||
name="queryBarForm"
|
||||
>
|
||||
<div
|
||||
role="search"
|
||||
>
|
||||
<div
|
||||
className="kuiLocalSearchAssistedInput"
|
||||
>
|
||||
<EuiFieldText
|
||||
append={
|
||||
<QueryLanguageSwitcher
|
||||
language="kuery"
|
||||
onSelectLanguage={[Function]}
|
||||
/>
|
||||
}
|
||||
aria-activedescendant=""
|
||||
aria-autocomplete="list"
|
||||
aria-controls="kbnTypeahead__items"
|
||||
aria-label="You are on search box of Another Screen page. Start typing to search and filter the discover"
|
||||
autoComplete="off"
|
||||
autoFocus={true}
|
||||
compressed={false}
|
||||
data-test-subj="queryInput"
|
||||
fullWidth={true}
|
||||
inputRef={[Function]}
|
||||
isLoading={false}
|
||||
onChange={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onKeyUp={[Function]}
|
||||
placeholder="Search"
|
||||
role="textbox"
|
||||
spellCheck={false}
|
||||
type="text"
|
||||
value="response:200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<SuggestionsComponent
|
||||
index={null}
|
||||
loadMore={[Function]}
|
||||
onClick={[Function]}
|
||||
onMouseEnter={[Function]}
|
||||
show={false}
|
||||
suggestions={Array []}
|
||||
/>
|
||||
</div>
|
||||
</EuiOutsideClickDetector>
|
||||
}
|
||||
screenTitle="Another Screen"
|
||||
store={
|
||||
Object {
|
||||
"clear": [MockFunction],
|
||||
"get": [MockFunction],
|
||||
"remove": [MockFunction],
|
||||
"set": [MockFunction],
|
||||
"store": Object {
|
||||
"clear": [MockFunction],
|
||||
"getItem": [MockFunction],
|
||||
"key": [MockFunction],
|
||||
"length": 0,
|
||||
"removeItem": [MockFunction],
|
||||
"setItem": [MockFunction],
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
|
|
1052
src/legacy/core_plugins/data/public/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap
generated
Normal file
1052
src/legacy/core_plugins/data/public/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -17,19 +17,11 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
mockGetAutocompleteProvider,
|
||||
mockGetAutocompleteSuggestions,
|
||||
mockPersistedLog,
|
||||
mockPersistedLogFactory,
|
||||
} from './query_bar.test.mocks';
|
||||
import { mockPersistedLogFactory } from './query_bar_input.test.mocks';
|
||||
|
||||
import { EuiFieldText } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
|
||||
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
|
||||
import { QueryBar } from './query_bar';
|
||||
import { QueryLanguageSwitcher } from './language_switcher';
|
||||
import { QueryBarUI } from './query_bar';
|
||||
|
||||
const noop = () => {
|
||||
return;
|
||||
|
@ -40,11 +32,6 @@ const kqlQuery = {
|
|||
language: 'kuery',
|
||||
};
|
||||
|
||||
const luceneQuery = {
|
||||
query: 'response:200',
|
||||
language: 'lucene',
|
||||
};
|
||||
|
||||
const createMockWebStorage = () => ({
|
||||
clear: jest.fn(),
|
||||
getItem: jest.fn(),
|
||||
|
@ -98,39 +85,6 @@ describe('QueryBar', () => {
|
|||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should pass the query language to the language switcher', () => {
|
||||
const component = shallowWithIntl(
|
||||
<QueryBar.WrappedComponent
|
||||
query={luceneQuery}
|
||||
onSubmit={noop}
|
||||
appName={'discover'}
|
||||
screenTitle={'Another Screen'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
intl={null as any}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should disable autoFocus on EuiFieldText when disableAutoFocus prop is true', () => {
|
||||
const component = shallowWithIntl(
|
||||
<QueryBar.WrappedComponent
|
||||
query={kqlQuery}
|
||||
onSubmit={noop}
|
||||
appName={'discover'}
|
||||
screenTitle={'Another Screen'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
disableAutoFocus={true}
|
||||
intl={null as any}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should create a unique PersistedLog based on the appName and query language', () => {
|
||||
shallowWithIntl(
|
||||
<QueryBar.WrappedComponent
|
||||
|
@ -147,118 +101,4 @@ describe('QueryBar', () => {
|
|||
|
||||
expect(mockPersistedLogFactory.mock.calls[0][0]).toBe('typeahead:discover-kuery');
|
||||
});
|
||||
|
||||
it("On language selection, should store the user's preference in localstorage and reset the query", () => {
|
||||
const mockStorage = createMockStorage();
|
||||
const mockCallback = jest.fn();
|
||||
|
||||
const component = mountWithIntl(
|
||||
<QueryBar.WrappedComponent
|
||||
query={kqlQuery}
|
||||
onSubmit={mockCallback}
|
||||
appName={'discover'}
|
||||
screenTitle={'Another Screen'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={mockStorage}
|
||||
disableAutoFocus={true}
|
||||
intl={null as any}
|
||||
/>
|
||||
);
|
||||
|
||||
component
|
||||
.find(QueryLanguageSwitcher)
|
||||
.props()
|
||||
.onSelectLanguage('lucene');
|
||||
expect(mockStorage.set).toHaveBeenCalledWith('kibana.userQueryLanguage', 'lucene');
|
||||
expect(mockCallback).toHaveBeenCalledWith({
|
||||
dateRange: {
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
},
|
||||
query: {
|
||||
query: '',
|
||||
language: 'lucene',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Should call onSubmit with the current query when the user hits enter inside the query bar', () => {
|
||||
const mockCallback = jest.fn();
|
||||
|
||||
const component = mountWithIntl(
|
||||
<QueryBar.WrappedComponent
|
||||
query={kqlQuery}
|
||||
onSubmit={mockCallback}
|
||||
appName={'discover'}
|
||||
screenTitle={'Another Screen'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
disableAutoFocus={true}
|
||||
intl={null as any}
|
||||
/>
|
||||
);
|
||||
|
||||
const instance = component.instance() as QueryBarUI;
|
||||
const input = instance.inputRef;
|
||||
const inputWrapper = component.find(EuiFieldText).find('input');
|
||||
inputWrapper.simulate('change', { target: { value: 'extension:jpg' } });
|
||||
inputWrapper.simulate('keyDown', { target: input, keyCode: 13, key: 'Enter', metaKey: true });
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
expect(mockCallback).toHaveBeenCalledWith({
|
||||
dateRange: {
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
},
|
||||
query: {
|
||||
query: 'extension:jpg',
|
||||
language: 'kuery',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Should use PersistedLog for recent search suggestions', async () => {
|
||||
const component = mountWithIntl(
|
||||
<QueryBar.WrappedComponent
|
||||
query={kqlQuery}
|
||||
onSubmit={noop}
|
||||
appName={'discover'}
|
||||
screenTitle={'Another Screen'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
disableAutoFocus={true}
|
||||
intl={null as any}
|
||||
/>
|
||||
);
|
||||
|
||||
const instance = component.instance() as QueryBarUI;
|
||||
const input = instance.inputRef;
|
||||
const inputWrapper = component.find(EuiFieldText).find('input');
|
||||
inputWrapper.simulate('change', { target: { value: 'extension:jpg' } });
|
||||
inputWrapper.simulate('keyDown', { target: input, keyCode: 13, key: 'Enter', metaKey: true });
|
||||
|
||||
expect(mockPersistedLog.add).toHaveBeenCalledWith('extension:jpg');
|
||||
|
||||
mockPersistedLog.get.mockClear();
|
||||
inputWrapper.simulate('change', { target: { value: 'extensi' } });
|
||||
expect(mockPersistedLog.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should get suggestions from the autocomplete provider for the current language', () => {
|
||||
mountWithIntl(
|
||||
<QueryBar.WrappedComponent
|
||||
query={kqlQuery}
|
||||
onSubmit={noop}
|
||||
appName={'discover'}
|
||||
screenTitle={'Another Screen'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
disableAutoFocus={true}
|
||||
intl={null as any}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(mockGetAutocompleteProvider).toHaveBeenCalledWith('kuery');
|
||||
expect(mockGetAutocompleteSuggestions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -22,22 +22,12 @@ import { IndexPattern } from 'ui/index_patterns';
|
|||
|
||||
import classNames from 'classnames';
|
||||
import _ from 'lodash';
|
||||
import { compact, debounce, get, isEqual } from 'lodash';
|
||||
import { get, isEqual } from 'lodash';
|
||||
import React, { Component } from 'react';
|
||||
import { kfetch } from 'ui/kfetch';
|
||||
import { PersistedLog } from 'ui/persisted_log';
|
||||
import { Storage } from 'ui/storage';
|
||||
import { timeHistory } from 'ui/timefilter/time_history';
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiFieldText,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiLink,
|
||||
EuiOutsideClickDetector,
|
||||
EuiSuperDatePicker,
|
||||
} from '@elastic/eui';
|
||||
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLink, EuiSuperDatePicker } from '@elastic/eui';
|
||||
|
||||
// @ts-ignore
|
||||
import { EuiSuperUpdateButton } from '@elastic/eui';
|
||||
|
@ -45,31 +35,13 @@ import { EuiSuperUpdateButton } from '@elastic/eui';
|
|||
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
|
||||
import { documentationLinks } from 'ui/documentation_links';
|
||||
import { Toast, toastNotifications } from 'ui/notify';
|
||||
import {
|
||||
AutocompleteSuggestion,
|
||||
AutocompleteSuggestionType,
|
||||
getAutocompleteProvider,
|
||||
} from 'ui/autocomplete_providers';
|
||||
import chrome from 'ui/chrome';
|
||||
import { PersistedLog } from 'ui/persisted_log';
|
||||
import { QueryBarInput } from './query_bar_input';
|
||||
|
||||
import { fromUser, matchPairs, toUser } from '../lib';
|
||||
import { QueryLanguageSwitcher } from './language_switcher';
|
||||
import { SuggestionsComponent } from './typeahead/suggestions_component';
|
||||
|
||||
const KEY_CODES = {
|
||||
LEFT: 37,
|
||||
UP: 38,
|
||||
RIGHT: 39,
|
||||
DOWN: 40,
|
||||
ENTER: 13,
|
||||
ESC: 27,
|
||||
TAB: 9,
|
||||
HOME: 36,
|
||||
END: 35,
|
||||
};
|
||||
import { getQueryLog } from '../lib/get_query_log';
|
||||
|
||||
const config = chrome.getUiSettingsClient();
|
||||
const recentSearchType: AutocompleteSuggestionType = 'recentSearch';
|
||||
|
||||
interface Query {
|
||||
query: string;
|
||||
|
@ -104,10 +76,6 @@ interface Props {
|
|||
interface State {
|
||||
query: Query;
|
||||
inputIsPristine: boolean;
|
||||
isSuggestionsVisible: boolean;
|
||||
index: number | null;
|
||||
suggestions: AutocompleteSuggestion[];
|
||||
suggestionLimit: number;
|
||||
currentProps?: Props;
|
||||
dateRangeFrom: string;
|
||||
dateRangeTo: string;
|
||||
|
@ -123,7 +91,7 @@ export class QueryBarUI extends Component<Props, State> {
|
|||
let nextQuery = null;
|
||||
if (nextProps.query.query !== prevState.query.query) {
|
||||
nextQuery = {
|
||||
query: toUser(nextProps.query.query),
|
||||
query: nextProps.query.query,
|
||||
language: nextProps.query.language,
|
||||
};
|
||||
} else if (nextProps.query.language !== prevState.query.language) {
|
||||
|
@ -171,31 +139,19 @@ export class QueryBarUI extends Component<Props, State> {
|
|||
*/
|
||||
public state = {
|
||||
query: {
|
||||
query: toUser(this.props.query.query),
|
||||
query: this.props.query.query,
|
||||
language: this.props.query.language,
|
||||
},
|
||||
inputIsPristine: true,
|
||||
isSuggestionsVisible: false,
|
||||
currentProps: this.props,
|
||||
index: null,
|
||||
suggestions: [],
|
||||
suggestionLimit: 50,
|
||||
dateRangeFrom: _.get(this.props, 'dateRangeFrom', 'now-15m'),
|
||||
dateRangeTo: _.get(this.props, 'dateRangeTo', 'now'),
|
||||
isDateRangeInvalid: false,
|
||||
};
|
||||
|
||||
public updateSuggestions = debounce(async () => {
|
||||
const suggestions = (await this.getSuggestions()) || [];
|
||||
if (!this.componentIsUnmounting) {
|
||||
this.setState({ suggestions });
|
||||
}
|
||||
}, 100);
|
||||
|
||||
public inputRef: HTMLInputElement | null = null;
|
||||
|
||||
private componentIsUnmounting = false;
|
||||
private persistedLog: PersistedLog | null = null;
|
||||
private persistedLog: PersistedLog | undefined;
|
||||
|
||||
public isDirty = () => {
|
||||
if (!this.props.showDatePicker) {
|
||||
|
@ -209,176 +165,20 @@ export class QueryBarUI extends Component<Props, State> {
|
|||
);
|
||||
};
|
||||
|
||||
public increaseLimit = () => {
|
||||
this.setState({
|
||||
suggestionLimit: this.state.suggestionLimit + 50,
|
||||
});
|
||||
};
|
||||
|
||||
public incrementIndex = (currentIndex: number) => {
|
||||
let nextIndex = currentIndex + 1;
|
||||
if (currentIndex === null || nextIndex >= this.state.suggestions.length) {
|
||||
nextIndex = 0;
|
||||
}
|
||||
this.setState({ index: nextIndex });
|
||||
};
|
||||
|
||||
public decrementIndex = (currentIndex: number) => {
|
||||
const previousIndex = currentIndex - 1;
|
||||
if (previousIndex < 0) {
|
||||
this.setState({ index: this.state.suggestions.length - 1 });
|
||||
} else {
|
||||
this.setState({ index: previousIndex });
|
||||
}
|
||||
};
|
||||
|
||||
public getSuggestions = async () => {
|
||||
if (!this.inputRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
query: { query, language },
|
||||
} = this.state;
|
||||
const recentSearchSuggestions = this.getRecentSearchSuggestions(query);
|
||||
|
||||
const autocompleteProvider = getAutocompleteProvider(language);
|
||||
if (
|
||||
!autocompleteProvider ||
|
||||
!Array.isArray(this.props.indexPatterns) ||
|
||||
compact(this.props.indexPatterns).length === 0
|
||||
) {
|
||||
return recentSearchSuggestions;
|
||||
}
|
||||
|
||||
const indexPatterns = this.props.indexPatterns;
|
||||
const getAutocompleteSuggestions = autocompleteProvider({ config, indexPatterns });
|
||||
|
||||
const { selectionStart, selectionEnd } = this.inputRef;
|
||||
if (selectionStart === null || selectionEnd === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestions: AutocompleteSuggestion[] = await getAutocompleteSuggestions({
|
||||
query,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
});
|
||||
return [...suggestions, ...recentSearchSuggestions];
|
||||
};
|
||||
|
||||
public selectSuggestion = ({
|
||||
type,
|
||||
text,
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
type: AutocompleteSuggestionType;
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}) => {
|
||||
if (!this.inputRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = this.state.query.query;
|
||||
const { selectionStart, selectionEnd } = this.inputRef;
|
||||
if (selectionStart === null || selectionEnd === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = query.substr(0, selectionStart) + query.substr(selectionEnd);
|
||||
|
||||
this.setState(
|
||||
{
|
||||
query: {
|
||||
...this.state.query,
|
||||
query: value.substr(0, start) + text + value.substr(end),
|
||||
},
|
||||
index: null,
|
||||
},
|
||||
() => {
|
||||
if (!this.inputRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inputRef.setSelectionRange(start + text.length, start + text.length);
|
||||
|
||||
if (type === recentSearchType) {
|
||||
this.onSubmit();
|
||||
} else {
|
||||
this.updateSuggestions();
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
public getRecentSearchSuggestions = (query: string) => {
|
||||
if (!this.persistedLog) {
|
||||
return [];
|
||||
}
|
||||
const recentSearches = this.persistedLog.get();
|
||||
const matchingRecentSearches = recentSearches.filter(recentQuery => {
|
||||
const recentQueryString = typeof recentQuery === 'object' ? toUser(recentQuery) : recentQuery;
|
||||
return recentQueryString.includes(query);
|
||||
});
|
||||
return matchingRecentSearches.map(recentSearch => {
|
||||
const text = recentSearch;
|
||||
const start = 0;
|
||||
const end = query.length;
|
||||
return { type: recentSearchType, text, start, end };
|
||||
});
|
||||
};
|
||||
|
||||
public onOutsideClick = () => {
|
||||
if (this.state.isSuggestionsVisible) {
|
||||
this.setState({ isSuggestionsVisible: false, index: null });
|
||||
}
|
||||
};
|
||||
|
||||
public onClickInput = (event: React.MouseEvent<HTMLInputElement>) => {
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
this.onInputChange(event.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
public onClickSubmitButton = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (this.persistedLog) {
|
||||
this.persistedLog.add(this.state.query.query);
|
||||
}
|
||||
this.onSubmit(() => event.preventDefault());
|
||||
};
|
||||
|
||||
public onClickSuggestion = (suggestion: AutocompleteSuggestion) => {
|
||||
if (!this.inputRef) {
|
||||
return;
|
||||
}
|
||||
this.selectSuggestion(suggestion);
|
||||
this.inputRef.focus();
|
||||
};
|
||||
|
||||
public onMouseEnterSuggestion = (index: number) => {
|
||||
this.setState({ index });
|
||||
};
|
||||
|
||||
public onInputChange = (value: string) => {
|
||||
const hasValue = Boolean(value.trim());
|
||||
|
||||
public onChange = (query: Query) => {
|
||||
this.setState({
|
||||
query: {
|
||||
query: value,
|
||||
language: this.state.query.language,
|
||||
},
|
||||
query,
|
||||
inputIsPristine: false,
|
||||
isSuggestionsVisible: hasValue,
|
||||
index: null,
|
||||
suggestionLimit: 50,
|
||||
});
|
||||
};
|
||||
|
||||
public onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.updateSuggestions();
|
||||
this.onInputChange(event.target.value);
|
||||
};
|
||||
|
||||
public onTimeChange = ({
|
||||
start,
|
||||
end,
|
||||
|
@ -400,83 +200,6 @@ export class QueryBarUI extends Component<Props, State> {
|
|||
);
|
||||
};
|
||||
|
||||
public onKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if ([KEY_CODES.LEFT, KEY_CODES.RIGHT, KEY_CODES.HOME, KEY_CODES.END].includes(event.keyCode)) {
|
||||
this.setState({ isSuggestionsVisible: true });
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
this.onInputChange(event.target.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
const { isSuggestionsVisible, index } = this.state;
|
||||
const preventDefault = event.preventDefault.bind(event);
|
||||
const { target, key, metaKey } = event;
|
||||
const { value, selectionStart, selectionEnd } = target;
|
||||
const updateQuery = (query: string, newSelectionStart: number, newSelectionEnd: number) => {
|
||||
this.setState(
|
||||
{
|
||||
query: {
|
||||
...this.state.query,
|
||||
query,
|
||||
},
|
||||
},
|
||||
() => {
|
||||
target.setSelectionRange(newSelectionStart, newSelectionEnd);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
switch (event.keyCode) {
|
||||
case KEY_CODES.DOWN:
|
||||
event.preventDefault();
|
||||
if (isSuggestionsVisible && index !== null) {
|
||||
this.incrementIndex(index);
|
||||
} else {
|
||||
this.setState({ isSuggestionsVisible: true, index: 0 });
|
||||
}
|
||||
break;
|
||||
case KEY_CODES.UP:
|
||||
event.preventDefault();
|
||||
if (isSuggestionsVisible && index !== null) {
|
||||
this.decrementIndex(index);
|
||||
}
|
||||
break;
|
||||
case KEY_CODES.ENTER:
|
||||
event.preventDefault();
|
||||
if (isSuggestionsVisible && index !== null && this.state.suggestions[index]) {
|
||||
this.selectSuggestion(this.state.suggestions[index]);
|
||||
} else {
|
||||
this.onSubmit(() => event.preventDefault());
|
||||
}
|
||||
break;
|
||||
case KEY_CODES.ESC:
|
||||
event.preventDefault();
|
||||
this.setState({ isSuggestionsVisible: false, index: null });
|
||||
break;
|
||||
case KEY_CODES.TAB:
|
||||
this.setState({ isSuggestionsVisible: false, index: null });
|
||||
break;
|
||||
default:
|
||||
if (selectionStart !== null && selectionEnd !== null) {
|
||||
matchPairs({
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
key,
|
||||
metaKey,
|
||||
updateQuery,
|
||||
preventDefault,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public onSubmit = (preventDefault?: () => void) => {
|
||||
if (preventDefault) {
|
||||
preventDefault();
|
||||
|
@ -484,10 +207,6 @@ export class QueryBarUI extends Component<Props, State> {
|
|||
|
||||
this.handleLuceneSyntaxWarning();
|
||||
|
||||
if (this.persistedLog) {
|
||||
this.persistedLog.add(this.state.query.query);
|
||||
}
|
||||
|
||||
timeHistory.add({
|
||||
from: this.state.dateRangeFrom,
|
||||
to: this.state.dateRangeTo,
|
||||
|
@ -495,7 +214,7 @@ export class QueryBarUI extends Component<Props, State> {
|
|||
|
||||
this.props.onSubmit({
|
||||
query: {
|
||||
query: fromUser(this.state.query.query),
|
||||
query: this.state.query.query,
|
||||
language: this.state.query.language,
|
||||
},
|
||||
dateRange: {
|
||||
|
@ -503,146 +222,44 @@ export class QueryBarUI extends Component<Props, State> {
|
|||
to: this.state.dateRangeTo,
|
||||
},
|
||||
});
|
||||
this.setState({ isSuggestionsVisible: false });
|
||||
};
|
||||
|
||||
public onSelectLanguage = (language: string) => {
|
||||
// Send telemetry info every time the user opts in or out of kuery
|
||||
// As a result it is important this function only ever gets called in the
|
||||
// UI component's change handler.
|
||||
kfetch({
|
||||
pathname: '/api/kibana/kql_opt_in_telemetry',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ opt_in: language === 'kuery' }),
|
||||
});
|
||||
|
||||
this.props.store.set('kibana.userQueryLanguage', language);
|
||||
this.props.onSubmit({
|
||||
query: {
|
||||
query: '',
|
||||
language,
|
||||
},
|
||||
dateRange: {
|
||||
from: this.state.dateRangeFrom,
|
||||
to: this.state.dateRangeTo,
|
||||
},
|
||||
private onInputSubmit = (query: Query) => {
|
||||
this.setState({ query }, () => {
|
||||
this.onSubmit();
|
||||
});
|
||||
};
|
||||
|
||||
public componentDidMount() {
|
||||
this.persistedLog = new PersistedLog(
|
||||
`typeahead:${this.props.appName}-${this.state.query.language}`,
|
||||
{
|
||||
maxLength: config.get('history:limit'),
|
||||
filterDuplicates: true,
|
||||
}
|
||||
);
|
||||
this.updateSuggestions();
|
||||
this.persistedLog = getQueryLog(this.props.appName, this.props.query.language);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.query.language !== this.props.query.language) {
|
||||
this.persistedLog = new PersistedLog(
|
||||
`typeahead:${this.props.appName}-${this.state.query.language}`,
|
||||
{
|
||||
maxLength: config.get('history:limit'),
|
||||
filterDuplicates: true,
|
||||
}
|
||||
);
|
||||
this.updateSuggestions();
|
||||
this.persistedLog = getQueryLog(this.props.appName, this.props.query.language);
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.updateSuggestions.cancel();
|
||||
this.componentIsUnmounting = true;
|
||||
}
|
||||
|
||||
public render() {
|
||||
const classes = classNames('kbnQueryBar', {
|
||||
'kbnQueryBar--withDatePicker': this.props.showDatePicker,
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
className={classes}
|
||||
responsive={this.props.showDatePicker ? true : false}
|
||||
gutterSize="s"
|
||||
>
|
||||
<EuiFlexGroup className={classes} responsive={!!this.props.showDatePicker} gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiOutsideClickDetector onOutsideClick={this.onOutsideClick}>
|
||||
{/* position:relative required on container so the suggestions appear under the query bar*/}
|
||||
<div
|
||||
style={{ position: 'relative' }}
|
||||
role="combobox"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={this.state.isSuggestionsVisible}
|
||||
aria-owns="kbnTypeahead__items"
|
||||
aria-controls="kbnTypeahead__items"
|
||||
>
|
||||
<form name="queryBarForm">
|
||||
<div role="search">
|
||||
<div className="kuiLocalSearchAssistedInput">
|
||||
<EuiFieldText
|
||||
placeholder={this.props.intl.formatMessage({
|
||||
id: 'data.query.queryBar.searchInputPlaceholder',
|
||||
defaultMessage: 'Search',
|
||||
})}
|
||||
value={this.state.query.query}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={this.onKeyUp}
|
||||
onChange={this.onChange}
|
||||
onClick={this.onClickInput}
|
||||
fullWidth
|
||||
autoFocus={!this.props.disableAutoFocus}
|
||||
inputRef={node => {
|
||||
if (node) {
|
||||
this.inputRef = node;
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-label={this.props.intl.formatMessage(
|
||||
{
|
||||
id: 'data.query.queryBar.searchInputAriaLabel',
|
||||
defaultMessage:
|
||||
'You are on search box of {previouslyTranslatedPageTitle} page. Start typing to search and filter the {pageType}',
|
||||
},
|
||||
{
|
||||
previouslyTranslatedPageTitle: this.props.screenTitle,
|
||||
pageType: this.props.appName,
|
||||
}
|
||||
)}
|
||||
type="text"
|
||||
data-test-subj="queryInput"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="kbnTypeahead__items"
|
||||
aria-activedescendant={
|
||||
this.state.isSuggestionsVisible ? 'suggestion-' + this.state.index : ''
|
||||
}
|
||||
role="textbox"
|
||||
prepend={this.props.prepend}
|
||||
append={
|
||||
<QueryLanguageSwitcher
|
||||
language={this.state.query.language}
|
||||
onSelectLanguage={this.onSelectLanguage}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<SuggestionsComponent
|
||||
show={this.state.isSuggestionsVisible}
|
||||
suggestions={this.state.suggestions.slice(0, this.state.suggestionLimit)}
|
||||
index={this.state.index}
|
||||
onClick={this.onClickSuggestion}
|
||||
onMouseEnter={this.onMouseEnterSuggestion}
|
||||
loadMore={this.increaseLimit}
|
||||
/>
|
||||
</div>
|
||||
</EuiOutsideClickDetector>
|
||||
<QueryBarInput
|
||||
appName={this.props.appName}
|
||||
disableAutoFocus={this.props.disableAutoFocus}
|
||||
indexPatterns={this.props.indexPatterns}
|
||||
prepend={this.props.prepend}
|
||||
query={this.state.query}
|
||||
screenTitle={this.props.screenTitle}
|
||||
store={this.props.store}
|
||||
onChange={this.onChange}
|
||||
onSubmit={this.onInputSubmit}
|
||||
persistedLog={this.persistedLog}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>{this.renderUpdateButton()}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -0,0 +1,243 @@
|
|||
/*
|
||||
* 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 {
|
||||
mockGetAutocompleteProvider,
|
||||
mockGetAutocompleteSuggestions,
|
||||
mockPersistedLog,
|
||||
mockPersistedLogFactory,
|
||||
} from './query_bar_input.test.mocks';
|
||||
|
||||
import { EuiFieldText } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { mountWithIntl } from 'test_utils/enzyme_helpers';
|
||||
import { QueryLanguageSwitcher } from './language_switcher';
|
||||
import { QueryBarInput, QueryBarInputUI } from './query_bar_input';
|
||||
|
||||
const noop = () => {
|
||||
return;
|
||||
};
|
||||
|
||||
const kqlQuery = {
|
||||
query: 'response:200',
|
||||
language: 'kuery',
|
||||
};
|
||||
|
||||
const luceneQuery = {
|
||||
query: 'response:200',
|
||||
language: 'lucene',
|
||||
};
|
||||
|
||||
const createMockWebStorage = () => ({
|
||||
clear: jest.fn(),
|
||||
getItem: jest.fn(),
|
||||
key: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
length: 0,
|
||||
});
|
||||
|
||||
const createMockStorage = () => ({
|
||||
store: createMockWebStorage(),
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
});
|
||||
|
||||
const mockIndexPattern = {
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields: [
|
||||
{
|
||||
name: 'response',
|
||||
type: 'number',
|
||||
esTypes: ['integer'],
|
||||
aggregatable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('QueryBarInput', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Should render the given query', () => {
|
||||
const component = mountWithIntl(
|
||||
<QueryBarInput.WrappedComponent
|
||||
query={kqlQuery}
|
||||
onSubmit={noop}
|
||||
appName={'discover'}
|
||||
screenTitle={'Another Screen'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
intl={null as any}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should pass the query language to the language switcher', () => {
|
||||
const component = mountWithIntl(
|
||||
<QueryBarInput.WrappedComponent
|
||||
query={luceneQuery}
|
||||
onSubmit={noop}
|
||||
appName={'discover'}
|
||||
screenTitle={'Another Screen'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
intl={null as any}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should disable autoFocus on EuiFieldText when disableAutoFocus prop is true', () => {
|
||||
const component = mountWithIntl(
|
||||
<QueryBarInput.WrappedComponent
|
||||
query={kqlQuery}
|
||||
onSubmit={noop}
|
||||
appName={'discover'}
|
||||
screenTitle={'Another Screen'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
disableAutoFocus={true}
|
||||
intl={null as any}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should create a unique PersistedLog based on the appName and query language', () => {
|
||||
mountWithIntl(
|
||||
<QueryBarInput.WrappedComponent
|
||||
query={kqlQuery}
|
||||
onSubmit={noop}
|
||||
appName={'discover'}
|
||||
screenTitle={'Another Screen'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
disableAutoFocus={true}
|
||||
intl={null as any}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(mockPersistedLogFactory.mock.calls[0][0]).toBe('typeahead:discover-kuery');
|
||||
});
|
||||
|
||||
it("On language selection, should store the user's preference in localstorage and reset the query", () => {
|
||||
const mockStorage = createMockStorage();
|
||||
const mockCallback = jest.fn();
|
||||
|
||||
const component = mountWithIntl(
|
||||
<QueryBarInput.WrappedComponent
|
||||
query={kqlQuery}
|
||||
onSubmit={mockCallback}
|
||||
appName={'discover'}
|
||||
screenTitle={'Another Screen'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={mockStorage}
|
||||
disableAutoFocus={true}
|
||||
intl={null as any}
|
||||
/>
|
||||
);
|
||||
|
||||
component
|
||||
.find(QueryLanguageSwitcher)
|
||||
.props()
|
||||
.onSelectLanguage('lucene');
|
||||
expect(mockStorage.set).toHaveBeenCalledWith('kibana.userQueryLanguage', 'lucene');
|
||||
expect(mockCallback).toHaveBeenCalledWith({ query: '', language: 'lucene' });
|
||||
});
|
||||
|
||||
it('Should call onSubmit when the user hits enter inside the query bar', () => {
|
||||
const mockCallback = jest.fn();
|
||||
|
||||
const component = mountWithIntl(
|
||||
<QueryBarInput.WrappedComponent
|
||||
query={kqlQuery}
|
||||
onSubmit={mockCallback}
|
||||
appName={'discover'}
|
||||
screenTitle={'Another Screen'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
disableAutoFocus={true}
|
||||
intl={null as any}
|
||||
/>
|
||||
);
|
||||
|
||||
const instance = component.instance() as QueryBarInputUI;
|
||||
const input = instance.inputRef;
|
||||
const inputWrapper = component.find(EuiFieldText).find('input');
|
||||
inputWrapper.simulate('keyDown', { target: input, keyCode: 13, key: 'Enter', metaKey: true });
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
expect(mockCallback).toHaveBeenCalledWith({ query: 'response:200', language: 'kuery' });
|
||||
});
|
||||
|
||||
it('Should use PersistedLog for recent search suggestions', async () => {
|
||||
const component = mountWithIntl(
|
||||
<QueryBarInput.WrappedComponent
|
||||
query={kqlQuery}
|
||||
onSubmit={noop}
|
||||
appName={'discover'}
|
||||
screenTitle={'Another Screen'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
disableAutoFocus={true}
|
||||
intl={null as any}
|
||||
/>
|
||||
);
|
||||
|
||||
const instance = component.instance() as QueryBarInputUI;
|
||||
const input = instance.inputRef;
|
||||
const inputWrapper = component.find(EuiFieldText).find('input');
|
||||
inputWrapper.simulate('keyDown', { target: input, keyCode: 13, key: 'Enter', metaKey: true });
|
||||
|
||||
expect(mockPersistedLog.add).toHaveBeenCalledWith('response:200');
|
||||
|
||||
mockPersistedLog.get.mockClear();
|
||||
inputWrapper.simulate('change', { target: { value: 'extensi' } });
|
||||
expect(mockPersistedLog.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Should get suggestions from the autocomplete provider for the current language', () => {
|
||||
mountWithIntl(
|
||||
<QueryBarInput.WrappedComponent
|
||||
query={kqlQuery}
|
||||
onSubmit={noop}
|
||||
appName={'discover'}
|
||||
screenTitle={'Another Screen'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
disableAutoFocus={true}
|
||||
intl={null as any}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(mockGetAutocompleteProvider).toHaveBeenCalledWith('kuery');
|
||||
expect(mockGetAutocompleteSuggestions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,476 @@
|
|||
/*
|
||||
* 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 { Component } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { EuiFieldText, EuiOutsideClickDetector } from '@elastic/eui';
|
||||
|
||||
import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
|
||||
import {
|
||||
AutocompleteSuggestion,
|
||||
AutocompleteSuggestionType,
|
||||
getAutocompleteProvider,
|
||||
} from 'ui/autocomplete_providers';
|
||||
import { debounce, compact } from 'lodash';
|
||||
import { IndexPattern } from 'ui/index_patterns';
|
||||
import { PersistedLog } from 'ui/persisted_log';
|
||||
import chrome from 'ui/chrome';
|
||||
import { kfetch } from 'ui/kfetch';
|
||||
import { Storage } from 'ui/storage';
|
||||
import { fromUser, matchPairs, toUser } from '../lib';
|
||||
import { QueryLanguageSwitcher } from './language_switcher';
|
||||
import { SuggestionsComponent } from './typeahead/suggestions_component';
|
||||
import { getQueryLog } from '../lib/get_query_log';
|
||||
|
||||
interface Query {
|
||||
query: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
indexPatterns: IndexPattern[];
|
||||
intl: InjectedIntl;
|
||||
query: Query;
|
||||
appName: string;
|
||||
disableAutoFocus?: boolean;
|
||||
screenTitle: string;
|
||||
prepend?: any;
|
||||
store: Storage;
|
||||
persistedLog?: PersistedLog;
|
||||
onChange?: (query: Query) => void;
|
||||
onSubmit?: (query: Query) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isSuggestionsVisible: boolean;
|
||||
index: number | null;
|
||||
suggestions: AutocompleteSuggestion[];
|
||||
suggestionLimit: number;
|
||||
selectionStart: number | null;
|
||||
selectionEnd: number | null;
|
||||
}
|
||||
|
||||
const KEY_CODES = {
|
||||
LEFT: 37,
|
||||
UP: 38,
|
||||
RIGHT: 39,
|
||||
DOWN: 40,
|
||||
ENTER: 13,
|
||||
ESC: 27,
|
||||
TAB: 9,
|
||||
HOME: 36,
|
||||
END: 35,
|
||||
};
|
||||
|
||||
const config = chrome.getUiSettingsClient();
|
||||
const recentSearchType: AutocompleteSuggestionType = 'recentSearch';
|
||||
|
||||
export class QueryBarInputUI extends Component<Props, State> {
|
||||
public state = {
|
||||
isSuggestionsVisible: false,
|
||||
index: null,
|
||||
suggestions: [],
|
||||
suggestionLimit: 50,
|
||||
selectionStart: null,
|
||||
selectionEnd: null,
|
||||
};
|
||||
|
||||
public inputRef: HTMLInputElement | null = null;
|
||||
|
||||
private persistedLog: PersistedLog | undefined;
|
||||
private componentIsUnmounting = false;
|
||||
|
||||
private getQueryString = () => {
|
||||
return toUser(this.props.query.query);
|
||||
};
|
||||
|
||||
private getSuggestions = async () => {
|
||||
if (!this.inputRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
query: { query, language },
|
||||
} = this.props;
|
||||
const recentSearchSuggestions = this.getRecentSearchSuggestions(query);
|
||||
|
||||
const autocompleteProvider = getAutocompleteProvider(language);
|
||||
if (
|
||||
!autocompleteProvider ||
|
||||
!Array.isArray(this.props.indexPatterns) ||
|
||||
compact(this.props.indexPatterns).length === 0
|
||||
) {
|
||||
return recentSearchSuggestions;
|
||||
}
|
||||
|
||||
const indexPatterns = this.props.indexPatterns;
|
||||
const getAutocompleteSuggestions = autocompleteProvider({ config, indexPatterns });
|
||||
|
||||
const { selectionStart, selectionEnd } = this.inputRef;
|
||||
if (selectionStart === null || selectionEnd === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestions: AutocompleteSuggestion[] = await getAutocompleteSuggestions({
|
||||
query,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
});
|
||||
return [...suggestions, ...recentSearchSuggestions];
|
||||
};
|
||||
|
||||
private getRecentSearchSuggestions = (query: string) => {
|
||||
if (!this.persistedLog) {
|
||||
return [];
|
||||
}
|
||||
const recentSearches = this.persistedLog.get();
|
||||
const matchingRecentSearches = recentSearches.filter(recentQuery => {
|
||||
const recentQueryString = typeof recentQuery === 'object' ? toUser(recentQuery) : recentQuery;
|
||||
return recentQueryString.includes(query);
|
||||
});
|
||||
return matchingRecentSearches.map(recentSearch => {
|
||||
const text = toUser(recentSearch);
|
||||
const start = 0;
|
||||
const end = query.length;
|
||||
return { type: recentSearchType, text, start, end };
|
||||
});
|
||||
};
|
||||
|
||||
private updateSuggestions = debounce(async () => {
|
||||
const suggestions = (await this.getSuggestions()) || [];
|
||||
if (!this.componentIsUnmounting) {
|
||||
this.setState({ suggestions });
|
||||
}
|
||||
}, 100);
|
||||
|
||||
private onSubmit = (query: Query) => {
|
||||
if (this.props.onSubmit) {
|
||||
if (this.persistedLog) {
|
||||
this.persistedLog.add(query.query);
|
||||
}
|
||||
|
||||
this.props.onSubmit({ query: fromUser(query.query), language: query.language });
|
||||
}
|
||||
};
|
||||
|
||||
private onChange = (query: Query) => {
|
||||
this.updateSuggestions();
|
||||
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange({ query: fromUser(query.query), language: query.language });
|
||||
}
|
||||
};
|
||||
|
||||
private onQueryStringChange = (value: string) => {
|
||||
const hasValue = Boolean(value.trim());
|
||||
|
||||
this.setState({
|
||||
isSuggestionsVisible: hasValue,
|
||||
index: null,
|
||||
suggestionLimit: 50,
|
||||
});
|
||||
|
||||
this.onChange({ query: value, language: this.props.query.language });
|
||||
};
|
||||
|
||||
private onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.onQueryStringChange(event.target.value);
|
||||
};
|
||||
|
||||
private onClickInput = (event: React.MouseEvent<HTMLInputElement>) => {
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
this.onQueryStringChange(event.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
private onKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if ([KEY_CODES.LEFT, KEY_CODES.RIGHT, KEY_CODES.HOME, KEY_CODES.END].includes(event.keyCode)) {
|
||||
this.setState({ isSuggestionsVisible: true });
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
this.onQueryStringChange(event.target.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
const { isSuggestionsVisible, index } = this.state;
|
||||
const preventDefault = event.preventDefault.bind(event);
|
||||
const { target, key, metaKey } = event;
|
||||
const { value, selectionStart, selectionEnd } = target;
|
||||
const updateQuery = (query: string, newSelectionStart: number, newSelectionEnd: number) => {
|
||||
this.onQueryStringChange(query);
|
||||
this.setState({
|
||||
selectionStart: newSelectionStart,
|
||||
selectionEnd: newSelectionEnd,
|
||||
});
|
||||
};
|
||||
|
||||
switch (event.keyCode) {
|
||||
case KEY_CODES.DOWN:
|
||||
event.preventDefault();
|
||||
if (isSuggestionsVisible && index !== null) {
|
||||
this.incrementIndex(index);
|
||||
} else {
|
||||
this.setState({ isSuggestionsVisible: true, index: 0 });
|
||||
}
|
||||
break;
|
||||
case KEY_CODES.UP:
|
||||
event.preventDefault();
|
||||
if (isSuggestionsVisible && index !== null) {
|
||||
this.decrementIndex(index);
|
||||
}
|
||||
break;
|
||||
case KEY_CODES.ENTER:
|
||||
event.preventDefault();
|
||||
if (isSuggestionsVisible && index !== null && this.state.suggestions[index]) {
|
||||
this.selectSuggestion(this.state.suggestions[index]);
|
||||
} else {
|
||||
this.onSubmit(this.props.query);
|
||||
this.setState({
|
||||
isSuggestionsVisible: false,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case KEY_CODES.ESC:
|
||||
event.preventDefault();
|
||||
this.setState({ isSuggestionsVisible: false, index: null });
|
||||
break;
|
||||
case KEY_CODES.TAB:
|
||||
this.setState({ isSuggestionsVisible: false, index: null });
|
||||
break;
|
||||
default:
|
||||
if (selectionStart !== null && selectionEnd !== null) {
|
||||
matchPairs({
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
key,
|
||||
metaKey,
|
||||
updateQuery,
|
||||
preventDefault,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private selectSuggestion = ({
|
||||
type,
|
||||
text,
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
type: AutocompleteSuggestionType;
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}) => {
|
||||
if (!this.inputRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = this.getQueryString();
|
||||
const { selectionStart, selectionEnd } = this.inputRef;
|
||||
if (selectionStart === null || selectionEnd === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = query.substr(0, selectionStart) + query.substr(selectionEnd);
|
||||
const newQueryString = value.substr(0, start) + text + value.substr(end);
|
||||
|
||||
this.onQueryStringChange(newQueryString);
|
||||
|
||||
if (type === recentSearchType) {
|
||||
this.setState({ isSuggestionsVisible: false, index: null });
|
||||
this.onSubmit({ query: newQueryString, language: this.props.query.language });
|
||||
}
|
||||
};
|
||||
|
||||
private increaseLimit = () => {
|
||||
this.setState({
|
||||
suggestionLimit: this.state.suggestionLimit + 50,
|
||||
});
|
||||
};
|
||||
|
||||
private incrementIndex = (currentIndex: number) => {
|
||||
let nextIndex = currentIndex + 1;
|
||||
if (currentIndex === null || nextIndex >= this.state.suggestions.length) {
|
||||
nextIndex = 0;
|
||||
}
|
||||
this.setState({ index: nextIndex });
|
||||
};
|
||||
|
||||
private decrementIndex = (currentIndex: number) => {
|
||||
const previousIndex = currentIndex - 1;
|
||||
if (previousIndex < 0) {
|
||||
this.setState({ index: this.state.suggestions.length - 1 });
|
||||
} else {
|
||||
this.setState({ index: previousIndex });
|
||||
}
|
||||
};
|
||||
|
||||
private onSelectLanguage = (language: string) => {
|
||||
// Send telemetry info every time the user opts in or out of kuery
|
||||
// As a result it is important this function only ever gets called in the
|
||||
// UI component's change handler.
|
||||
kfetch({
|
||||
pathname: '/api/kibana/kql_opt_in_telemetry',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ opt_in: language === 'kuery' }),
|
||||
});
|
||||
|
||||
this.props.store.set('kibana.userQueryLanguage', language);
|
||||
|
||||
const newQuery = { query: '', language };
|
||||
this.onChange(newQuery);
|
||||
this.onSubmit(newQuery);
|
||||
};
|
||||
|
||||
private onOutsideClick = () => {
|
||||
if (this.state.isSuggestionsVisible) {
|
||||
this.setState({ isSuggestionsVisible: false, index: null });
|
||||
}
|
||||
};
|
||||
|
||||
private onClickSuggestion = (suggestion: AutocompleteSuggestion) => {
|
||||
if (!this.inputRef) {
|
||||
return;
|
||||
}
|
||||
this.selectSuggestion(suggestion);
|
||||
this.inputRef.focus();
|
||||
};
|
||||
|
||||
public onMouseEnterSuggestion = (index: number) => {
|
||||
this.setState({ index });
|
||||
};
|
||||
|
||||
public componentDidMount() {
|
||||
this.persistedLog = this.props.persistedLog
|
||||
? this.props.persistedLog
|
||||
: getQueryLog(this.props.appName, this.props.query.language);
|
||||
this.updateSuggestions();
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Props) {
|
||||
this.persistedLog = this.props.persistedLog
|
||||
? this.props.persistedLog
|
||||
: getQueryLog(this.props.appName, this.props.query.language);
|
||||
this.updateSuggestions();
|
||||
|
||||
if (this.state.selectionStart !== null && this.state.selectionEnd !== null) {
|
||||
if (this.inputRef) {
|
||||
// For some reason the type guard above does not make the compiler happy
|
||||
// @ts-ignore
|
||||
this.inputRef.setSelectionRange(this.state.selectionStart, this.state.selectionEnd);
|
||||
}
|
||||
this.setState({
|
||||
selectionStart: null,
|
||||
selectionEnd: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.updateSuggestions.cancel();
|
||||
this.componentIsUnmounting = true;
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<EuiOutsideClickDetector onOutsideClick={this.onOutsideClick}>
|
||||
<div
|
||||
style={{ position: 'relative' }}
|
||||
role="combobox"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={this.state.isSuggestionsVisible}
|
||||
aria-owns="kbnTypeahead__items"
|
||||
aria-controls="kbnTypeahead__items"
|
||||
>
|
||||
<form name="queryBarForm">
|
||||
<div role="search">
|
||||
<div className="kuiLocalSearchAssistedInput">
|
||||
<EuiFieldText
|
||||
placeholder={this.props.intl.formatMessage({
|
||||
id: 'data.query.queryBar.searchInputPlaceholder',
|
||||
defaultMessage: 'Search',
|
||||
})}
|
||||
value={this.getQueryString()}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={this.onKeyUp}
|
||||
onChange={this.onInputChange}
|
||||
onClick={this.onClickInput}
|
||||
fullWidth
|
||||
autoFocus={!this.props.disableAutoFocus}
|
||||
inputRef={node => {
|
||||
if (node) {
|
||||
this.inputRef = node;
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-label={this.props.intl.formatMessage(
|
||||
{
|
||||
id: 'data.query.queryBar.searchInputAriaLabel',
|
||||
defaultMessage:
|
||||
'You are on search box of {previouslyTranslatedPageTitle} page. Start typing to search and filter the {pageType}',
|
||||
},
|
||||
{
|
||||
previouslyTranslatedPageTitle: this.props.screenTitle,
|
||||
pageType: this.props.appName,
|
||||
}
|
||||
)}
|
||||
type="text"
|
||||
data-test-subj="queryInput"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="kbnTypeahead__items"
|
||||
aria-activedescendant={
|
||||
this.state.isSuggestionsVisible ? 'suggestion-' + this.state.index : ''
|
||||
}
|
||||
role="textbox"
|
||||
prepend={this.props.prepend}
|
||||
append={
|
||||
<QueryLanguageSwitcher
|
||||
language={this.props.query.language}
|
||||
onSelectLanguage={this.onSelectLanguage}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<SuggestionsComponent
|
||||
show={this.state.isSuggestionsVisible}
|
||||
suggestions={this.state.suggestions.slice(0, this.state.suggestionLimit)}
|
||||
index={this.state.index}
|
||||
onClick={this.onClickSuggestion}
|
||||
onMouseEnter={this.onMouseEnterSuggestion}
|
||||
loadMore={this.increaseLimit}
|
||||
/>
|
||||
</div>
|
||||
</EuiOutsideClickDetector>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const QueryBarInput = injectI18n(QueryBarInputUI);
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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 chrome from 'ui/chrome';
|
||||
import { PersistedLog } from 'ui/persisted_log';
|
||||
|
||||
const config = chrome.getUiSettingsClient();
|
||||
|
||||
export function getQueryLog(appName: string, language: string) {
|
||||
return new PersistedLog(`typeahead:${appName}-${language}`, {
|
||||
maxLength: config.get('history:limit'),
|
||||
filterDuplicates: true,
|
||||
});
|
||||
}
|
|
@ -81,7 +81,7 @@ export default function ({ getService, getPageObjects }) {
|
|||
|
||||
it('when the query is edited and applied', async function () {
|
||||
const originalQuery = await queryBar.getQueryString();
|
||||
await queryBar.setQuery(`${originalQuery} and extra stuff`);
|
||||
await queryBar.setQuery(`${originalQuery}and extra stuff`);
|
||||
await queryBar.submitQuery();
|
||||
|
||||
await PageObjects.dashboard.clickCancelOutOfEditMode();
|
||||
|
@ -209,7 +209,7 @@ export default function ({ getService, getPageObjects }) {
|
|||
await PageObjects.dashboard.gotoDashboardEditMode(dashboardName);
|
||||
|
||||
const originalQuery = await queryBar.getQueryString();
|
||||
await queryBar.setQuery(`${originalQuery} extra stuff`);
|
||||
await queryBar.setQuery(`${originalQuery}extra stuff`);
|
||||
|
||||
await PageObjects.dashboard.clickCancelOutOfEditMode();
|
||||
|
||||
|
|
|
@ -165,10 +165,9 @@ export class WebElementWrapper {
|
|||
await delay(100);
|
||||
}
|
||||
} else {
|
||||
const selectionKey = this.Keys[process.platform === 'darwin' ? 'COMMAND' : 'CONTROL'];
|
||||
await this.pressKeys([selectionKey, 'a']);
|
||||
await this.pressKeys(this.Keys.NULL); // Release modifier keys
|
||||
await this.pressKeys(this.Keys.BACK_SPACE); // Delete all content
|
||||
// https://bugs.chromium.org/p/chromedriver/issues/detail?id=30
|
||||
await this.driver.executeScript(`arguments[0].select();`, this._webElement);
|
||||
await this.pressKeys(this.Keys.BACK_SPACE);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ export function QueryBarProvider({ getService, getPageObjects }) {
|
|||
const retry = getService('retry');
|
||||
const log = getService('log');
|
||||
const PageObjects = getPageObjects(['header', 'common']);
|
||||
const find = getService('find');
|
||||
|
||||
class QueryBar {
|
||||
|
||||
|
@ -34,7 +35,13 @@ export function QueryBarProvider({ getService, getPageObjects }) {
|
|||
// Extra caution used because of flaky test here: https://github.com/elastic/kibana/issues/16978 doesn't seem
|
||||
// to be actually setting the query in the query input based off
|
||||
await retry.try(async () => {
|
||||
await testSubjects.setValue('queryInput', query);
|
||||
await testSubjects.click('queryInput');
|
||||
|
||||
// testSubjects.setValue uses input.clearValue which wasn't working, but input.clearValueWithKeyboard does.
|
||||
// So the following lines do the same thing as input.setValue but with input.clearValueWithKeyboard instead.
|
||||
const input = await find.activeElement();
|
||||
await input.clearValueWithKeyboard();
|
||||
await input.type(query);
|
||||
const currentQuery = await this.getQueryString();
|
||||
if (currentQuery !== query) {
|
||||
throw new Error(`Failed to set query input to ${query}, instead query is ${currentQuery}`);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue