diff --git a/src/legacy/core_plugins/data/public/query_bar/components/__snapshots__/query_bar.test.tsx.snap b/src/legacy/core_plugins/data/public/query_bar/components/__snapshots__/query_bar.test.tsx.snap index 74b5183ba6478..bc0b15ca88e3e 100644 --- a/src/legacy/core_plugins/data/public/query_bar/components/__snapshots__/query_bar.test.tsx.snap +++ b/src/legacy/core_plugins/data/public/query_bar/components/__snapshots__/query_bar.test.tsx.snap @@ -1,252 +1,61 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`QueryBar Should disable autoFocus on EuiFieldText when disableAutoFocus prop is true 1`] = ` +exports[`QueryBar Should render the given query 1`] = ` - -
-
-
-
- - } - 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" - /> -
-
-
- -
-
-
- - - -
-`; - -exports[`QueryBar Should pass the query language to the language switcher 1`] = ` - - - -
-
-
-
- - } - 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" - /> -
-
-
- -
-
-
- - -
-`; - -exports[`QueryBar Should render the given query 1`] = ` - - - -
-
-
-
- - } - 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" - /> -
-
-
- -
-
-
diff --git a/src/legacy/core_plugins/data/public/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap b/src/legacy/core_plugins/data/public/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap new file mode 100644 index 0000000000000..58d409c227397 --- /dev/null +++ b/src/legacy/core_plugins/data/public/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap @@ -0,0 +1,1052 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`QueryBarInput Should disable autoFocus on EuiFieldText when disableAutoFocus prop is true 1`] = ` + + +
+
+
+
+ + } + 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" + > + + } + compressed={false} + fullWidth={true} + isLoading={false} + > +
+
+ + + + +
+ + + + + } + className="eui-displayBlock" + closePopover={[Function]} + hasArrow={true} + id="popover" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + withTitle={true} + > + +
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+`; + +exports[`QueryBarInput Should pass the query language to the language switcher 1`] = ` + + +
+
+
+
+ + } + 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" + > + + } + compressed={false} + fullWidth={true} + isLoading={false} + > +
+
+ + + + +
+ + + + + } + className="eui-displayBlock" + closePopover={[Function]} + hasArrow={true} + id="popover" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + withTitle={true} + > + +
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+`; + +exports[`QueryBarInput Should render the given query 1`] = ` + + +
+
+
+
+ + } + 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" + > + + } + compressed={false} + fullWidth={true} + isLoading={false} + > +
+
+ + + + +
+ + + + + } + className="eui-displayBlock" + closePopover={[Function]} + hasArrow={true} + id="popover" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + withTitle={true} + > + +
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+`; diff --git a/src/legacy/core_plugins/data/public/query_bar/components/query_bar.test.tsx b/src/legacy/core_plugins/data/public/query_bar/components/query_bar.test.tsx index 7afd6595786d5..04352bd4c6a62 100644 --- a/src/legacy/core_plugins/data/public/query_bar/components/query_bar.test.tsx +++ b/src/legacy/core_plugins/data/public/query_bar/components/query_bar.test.tsx @@ -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( - - ); - - expect(component).toMatchSnapshot(); - }); - - it('Should disable autoFocus on EuiFieldText when disableAutoFocus prop is true', () => { - const component = shallowWithIntl( - - ); - - expect(component).toMatchSnapshot(); - }); - it('Should create a unique PersistedLog based on the appName and query language', () => { shallowWithIntl( { 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( - - ); - - 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( - - ); - - 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( - - ); - - 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( - - ); - - expect(mockGetAutocompleteProvider).toHaveBeenCalledWith('kuery'); - expect(mockGetAutocompleteSuggestions).toHaveBeenCalled(); - }); }); diff --git a/src/legacy/core_plugins/data/public/query_bar/components/query_bar.tsx b/src/legacy/core_plugins/data/public/query_bar/components/query_bar.tsx index 339ef665684df..573124a6302b1 100644 --- a/src/legacy/core_plugins/data/public/query_bar/components/query_bar.tsx +++ b/src/legacy/core_plugins/data/public/query_bar/components/query_bar.tsx @@ -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 { 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 { */ 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 { ); }; - 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) => { - if (event.target instanceof HTMLInputElement) { - this.onInputChange(event.target.value); - } - }; - public onClickSubmitButton = (event: React.MouseEvent) => { - this.onSubmit(() => event.preventDefault()); - }; - - public onClickSuggestion = (suggestion: AutocompleteSuggestion) => { - if (!this.inputRef) { - return; + if (this.persistedLog) { + this.persistedLog.add(this.state.query.query); } - this.selectSuggestion(suggestion); - this.inputRef.focus(); - }; - - public onMouseEnterSuggestion = (index: number) => { - this.setState({ index }); + this.onSubmit(() => event.preventDefault()); }; - 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) => { - this.updateSuggestions(); - this.onInputChange(event.target.value); - }; - public onTimeChange = ({ start, end, @@ -400,83 +200,6 @@ export class QueryBarUI extends Component { ); }; - public onKeyUp = (event: React.KeyboardEvent) => { - 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) => { - 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 { 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 { 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 { 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 ( - + - - {/* position:relative required on container so the suggestions appear under the query bar*/} -
-
-
-
- { - 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={ - - } - /> -
-
-
- - -
-
+
{this.renderUpdateButton()}
diff --git a/src/legacy/core_plugins/data/public/query_bar/components/query_bar.test.mocks.ts b/src/legacy/core_plugins/data/public/query_bar/components/query_bar_input.test.mocks.ts similarity index 100% rename from src/legacy/core_plugins/data/public/query_bar/components/query_bar.test.mocks.ts rename to src/legacy/core_plugins/data/public/query_bar/components/query_bar_input.test.mocks.ts diff --git a/src/legacy/core_plugins/data/public/query_bar/components/query_bar_input.test.tsx b/src/legacy/core_plugins/data/public/query_bar/components/query_bar_input.test.tsx new file mode 100644 index 0000000000000..bcd007d4a601e --- /dev/null +++ b/src/legacy/core_plugins/data/public/query_bar/components/query_bar_input.test.tsx @@ -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( + + ); + + expect(component).toMatchSnapshot(); + }); + + it('Should pass the query language to the language switcher', () => { + const component = mountWithIntl( + + ); + + expect(component).toMatchSnapshot(); + }); + + it('Should disable autoFocus on EuiFieldText when disableAutoFocus prop is true', () => { + const component = mountWithIntl( + + ); + + expect(component).toMatchSnapshot(); + }); + + it('Should create a unique PersistedLog based on the appName and query language', () => { + mountWithIntl( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + expect(mockGetAutocompleteProvider).toHaveBeenCalledWith('kuery'); + expect(mockGetAutocompleteSuggestions).toHaveBeenCalled(); + }); +}); diff --git a/src/legacy/core_plugins/data/public/query_bar/components/query_bar_input.tsx b/src/legacy/core_plugins/data/public/query_bar/components/query_bar_input.tsx new file mode 100644 index 0000000000000..42bf972889e4d --- /dev/null +++ b/src/legacy/core_plugins/data/public/query_bar/components/query_bar_input.tsx @@ -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 { + 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) => { + this.onQueryStringChange(event.target.value); + }; + + private onClickInput = (event: React.MouseEvent) => { + if (event.target instanceof HTMLInputElement) { + this.onQueryStringChange(event.target.value); + } + }; + + private onKeyUp = (event: React.KeyboardEvent) => { + 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) => { + 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 ( + +
+
+
+
+ { + 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={ + + } + /> +
+
+
+ + +
+
+ ); + } +} + +export const QueryBarInput = injectI18n(QueryBarInputUI); diff --git a/src/legacy/core_plugins/data/public/query_bar/lib/get_query_log.ts b/src/legacy/core_plugins/data/public/query_bar/lib/get_query_log.ts new file mode 100644 index 0000000000000..a3b0fe891ff9f --- /dev/null +++ b/src/legacy/core_plugins/data/public/query_bar/lib/get_query_log.ts @@ -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, + }); +} diff --git a/test/functional/apps/dashboard/view_edit.js b/test/functional/apps/dashboard/view_edit.js index edef26dbc2d34..1c866cd2267ff 100644 --- a/test/functional/apps/dashboard/view_edit.js +++ b/test/functional/apps/dashboard/view_edit.js @@ -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(); diff --git a/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts b/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts index adfd2ae086e7a..080532187b70a 100644 --- a/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts +++ b/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts @@ -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); } } diff --git a/test/functional/services/query_bar.js b/test/functional/services/query_bar.js index 1cc525fbb6164..1652ac2144953 100644 --- a/test/functional/services/query_bar.js +++ b/test/functional/services/query_bar.js @@ -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}`);