diff --git a/src/core_plugins/kibana/public/index.scss b/src/core_plugins/kibana/public/index.scss
index a7b2b0b3ce534..390694b0a52c2 100644
--- a/src/core_plugins/kibana/public/index.scss
+++ b/src/core_plugins/kibana/public/index.scss
@@ -1,5 +1,7 @@
@import 'ui/public/styles/styling_constants';
+@import 'ui/public/query_bar/index';
+
// Context styles
@import './context/index';
diff --git a/src/core_plugins/kibana/public/visualize/editor/editor.html b/src/core_plugins/kibana/public/visualize/editor/editor.html
index cc05d1c48ff3e..c73805e4eb678 100644
--- a/src/core_plugins/kibana/public/visualize/editor/editor.html
+++ b/src/core_plugins/kibana/public/visualize/editor/editor.html
@@ -40,11 +40,10 @@
-
+ >
diff --git a/src/ui/public/autocomplete_providers/index.d.ts b/src/ui/public/autocomplete_providers/index.d.ts
index 4f18ca62d29de..9c252bb05e52b 100644
--- a/src/ui/public/autocomplete_providers/index.d.ts
+++ b/src/ui/public/autocomplete_providers/index.d.ts
@@ -28,7 +28,7 @@ export type AutocompleteProvider = (
get(configKey: string): any;
};
indexPatterns: StaticIndexPattern[];
- boolFilter: any;
+ boolFilter?: any;
}
) => GetSuggestions;
@@ -40,10 +40,15 @@ export type GetSuggestions = (
}
) => Promise;
-export type AutocompleteSuggestionType = 'field' | 'value' | 'operator' | 'conjunction';
+export type AutocompleteSuggestionType =
+ | 'field'
+ | 'value'
+ | 'operator'
+ | 'conjunction'
+ | 'recentSearch';
export interface AutocompleteSuggestion {
- description: string;
+ description?: string;
end: number;
start: number;
text: string;
diff --git a/src/ui/public/autoload/modules.js b/src/ui/public/autoload/modules.js
index e4058ec22a427..107be2145995c 100644
--- a/src/ui/public/autoload/modules.js
+++ b/src/ui/public/autoload/modules.js
@@ -47,10 +47,10 @@ import '../style_compile';
import '../timefilter';
import '../timepicker';
import '../tooltip';
-import '../typeahead';
import '../url';
import '../validate_date_interval';
import '../watch_multi';
import '../courier/saved_object/ui/saved_object_save_as_checkbox';
import '../react_components';
import '../i18n';
+import '../query_bar/directive';
diff --git a/src/ui/public/directives/__tests__/parse_query.js b/src/ui/public/directives/__tests__/parse_query.js
index b3c24cc9deb8b..3239bbceae248 100644
--- a/src/ui/public/directives/__tests__/parse_query.js
+++ b/src/ui/public/directives/__tests__/parse_query.js
@@ -25,26 +25,23 @@ import ngMock from 'ng_mock';
let $rootScope;
let $compile;
-let Private;
let config;
let $elemScope;
let $elem;
let cycleIndex = 0;
const markup = '';
-let fromUser;
import { toUser } from '../../parse_query/lib/to_user';
-import '../../parse_query';
-import { ParseQueryLibFromUserProvider } from '../../parse_query/lib/from_user';
+import '../../parse_query/index';
+import { fromUser } from '../../parse_query/lib/from_user';
const init = function () {
// Load the application
ngMock.module('kibana');
// Create the scope
- ngMock.inject(function ($injector, _$rootScope_, _$compile_, _$timeout_, _Private_, _config_) {
+ ngMock.inject(function ($injector, _$rootScope_, _$compile_, _$timeout_, _config_) {
$compile = _$compile_;
- Private = _Private_;
config = _config_;
// Give us a scope
@@ -77,7 +74,6 @@ describe('parse-query directive', function () {
describe('user input parser', function () {
beforeEach(function () {
- fromUser = Private(ParseQueryLibFromUserProvider);
config.set('query:queryString:options', {});
});
diff --git a/src/ui/public/documentation_links/documentation_links.js b/src/ui/public/documentation_links/documentation_links.ts
similarity index 93%
rename from src/ui/public/documentation_links/documentation_links.js
rename to src/ui/public/documentation_links/documentation_links.ts
index 484a48a5765bd..7eb91d2f3be83 100644
--- a/src/ui/public/documentation_links/documentation_links.js
+++ b/src/ui/public/documentation_links/documentation_links.ts
@@ -29,16 +29,15 @@ export const documentationLinks = {
installation: `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}/filebeat-installation.html`,
configuration: `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}/filebeat-configuration.html`,
elasticsearchOutput: `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}/elasticsearch-output.html`,
- elasticsearchOutputAnchorParameters:
- `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}/elasticsearch-output.html#_parameters`,
+ elasticsearchOutputAnchorParameters: `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}/elasticsearch-output.html#_parameters`,
startup: `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}/filebeat-starting.html`,
- exportedFields: `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}/exported-fields.html`
+ exportedFields: `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}/exported-fields.html`,
},
metricbeat: {
- base: `${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}`
+ base: `${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}`,
},
logstash: {
- base: `${ELASTIC_WEBSITE_URL}guide/en/logstash/${DOC_LINK_VERSION}`
+ base: `${ELASTIC_WEBSITE_URL}guide/en/logstash/${DOC_LINK_VERSION}`,
},
aggs: {
date_histogram: `${ELASTIC_DOCS}search-aggregations-bucket-datehistogram-aggregation.html`,
@@ -78,19 +77,18 @@ export const documentationLinks = {
painless: `${ELASTIC_DOCS}modules-scripting-painless.html`,
painlessApi: `${ELASTIC_DOCS}modules-scripting-painless.html#painless-api`,
painlessSyntax: `${ELASTIC_DOCS}modules-scripting-painless-syntax.html`,
- luceneExpressions: `${ELASTIC_DOCS}modules-scripting-expression.html`
+ luceneExpressions: `${ELASTIC_DOCS}modules-scripting-expression.html`,
},
indexPatterns: {
loadingData: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/tutorial-load-dataset.html`,
introduction: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index-patterns.html`,
},
query: {
- luceneQuerySyntax:
- `${ELASTIC_DOCS}query-dsl-query-string-query.html#query-string-syntax`,
+ luceneQuerySyntax: `${ELASTIC_DOCS}query-dsl-query-string-query.html#query-string-syntax`,
queryDsl: `${ELASTIC_DOCS}query-dsl.html`,
kueryQuerySyntax: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kuery-query.html`,
},
date: {
- dateMath: `${ELASTIC_DOCS}common-options.html#date-math`
+ dateMath: `${ELASTIC_DOCS}common-options.html#date-math`,
},
};
diff --git a/src/ui/public/index_patterns/static_utils/index.d.ts b/src/ui/public/index_patterns/static_utils/index.d.ts
new file mode 100644
index 0000000000000..6d387bb95882f
--- /dev/null
+++ b/src/ui/public/index_patterns/static_utils/index.d.ts
@@ -0,0 +1,29 @@
+/*
+ * 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 { StaticIndexPattern } from 'ui/index_patterns';
+
+interface SavedObject {
+ attributes: {
+ fields: string;
+ title: string;
+ };
+}
+
+export function getFromLegacyIndexPattern(indexPatterns: any[]): StaticIndexPattern[];
diff --git a/src/ui/public/index_patterns/static_utils/index.js b/src/ui/public/index_patterns/static_utils/index.js
index 09ff813bd6737..2cf43c319b10a 100644
--- a/src/ui/public/index_patterns/static_utils/index.js
+++ b/src/ui/public/index_patterns/static_utils/index.js
@@ -19,9 +19,7 @@
import { KBN_FIELD_TYPES } from '../../../../utils/kbn_field_types';
-const filterableTypes = KBN_FIELD_TYPES.filter(type => type.filterable).map(
- type => type.name
-);
+const filterableTypes = KBN_FIELD_TYPES.filter(type => type.filterable).map(type => type.name);
export function isFilterable(field) {
return filterableTypes.includes(field.type);
diff --git a/src/ui/public/metadata.d.ts b/src/ui/public/metadata.d.ts
new file mode 100644
index 0000000000000..d604838bd046b
--- /dev/null
+++ b/src/ui/public/metadata.d.ts
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+
+declare class Metadata {
+ public branch: string;
+ public version: string;
+}
+
+declare const metadata: Metadata;
+
+export { metadata };
diff --git a/src/ui/public/parse_query/index.ts b/src/ui/public/parse_query/index.ts
new file mode 100644
index 0000000000000..75c311e9e6f05
--- /dev/null
+++ b/src/ui/public/parse_query/index.ts
@@ -0,0 +1,23 @@
+/*
+ * 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 './parse_query';
+
+export * from './lib/from_user';
+export * from './lib/to_user';
diff --git a/src/ui/public/parse_query/lib/from_user.js b/src/ui/public/parse_query/lib/from_user.ts
similarity index 64%
rename from src/ui/public/parse_query/lib/from_user.js
rename to src/ui/public/parse_query/lib/from_user.ts
index 46d6ca96bcc80..bd2cb08667a07 100644
--- a/src/ui/public/parse_query/lib/from_user.js
+++ b/src/ui/public/parse_query/lib/from_user.ts
@@ -19,27 +19,29 @@
import _ from 'lodash';
-export function ParseQueryLibFromUserProvider() {
+/**
+ * Take userInput from the user and make it into a query object
+ * @returns {object}
+ * @param userInput
+ */
- /**
- * Take userInput from the user and make it into a query object
- * @param {userInput} user's query input
- * @returns {object}
- */
- return function (userInput) {
- const matchAll = '';
+export function fromUser(userInput: object | string) {
+ const matchAll = '';
- if (_.isObject(userInput)) {
- // If we get an empty object, treat it as a *
- if (!Object.keys(userInput).length) {
- return matchAll;
- }
- return userInput;
+ if (_.isObject(userInput)) {
+ // If we get an empty object, treat it as a *
+ if (!Object.keys(userInput).length) {
+ return matchAll;
}
+ return userInput;
+ }
- // Nope, not an object.
- userInput = (userInput || '').trim();
- if (userInput.length === 0) return matchAll;
+ userInput = userInput || '';
+ if (typeof userInput === 'string') {
+ userInput = userInput.trim();
+ if (userInput.length === 0) {
+ return matchAll;
+ }
if (userInput[0] === '{') {
try {
@@ -50,6 +52,5 @@ export function ParseQueryLibFromUserProvider() {
} else {
return userInput;
}
- };
+ }
}
-
diff --git a/src/ui/public/parse_query/lib/to_user.js b/src/ui/public/parse_query/lib/to_user.ts
similarity index 71%
rename from src/ui/public/parse_query/lib/to_user.js
rename to src/ui/public/parse_query/lib/to_user.ts
index a6bea74b9e0aa..dfae965d64344 100644
--- a/src/ui/public/parse_query/lib/to_user.js
+++ b/src/ui/public/parse_query/lib/to_user.ts
@@ -17,7 +17,6 @@
* under the License.
*/
-import _ from 'lodash';
import angular from 'angular';
/**
@@ -25,12 +24,27 @@ import angular from 'angular';
* @param {text} model value
* @returns {string}
*/
-export function toUser(text) {
- if (text == null) return '';
- if (_.isObject(text)) {
- if (text.match_all) return '';
- if (text.query_string) return toUser(text.query_string.query);
+export function toUser(text: ToUserQuery | string): string {
+ if (text == null) {
+ return '';
+ }
+ if (typeof text === 'object') {
+ if (text.match_all) {
+ return '';
+ }
+ if (text.query_string) {
+ return toUser(text.query_string.query);
+ }
return angular.toJson(text);
}
return '' + text;
}
+
+interface ToUserQuery {
+ match_all: object;
+ query_string: ToUserQueryString;
+}
+
+interface ToUserQueryString {
+ query: string;
+}
diff --git a/src/ui/public/parse_query/parse_query.js b/src/ui/public/parse_query/parse_query.js
index 2c8252f9dc9a0..4b3f9d24f72de 100644
--- a/src/ui/public/parse_query/parse_query.js
+++ b/src/ui/public/parse_query/parse_query.js
@@ -18,13 +18,12 @@
*/
import { toUser } from './lib/to_user';
-import { ParseQueryLibFromUserProvider } from './lib/from_user';
+import { fromUser } from './lib/from_user';
import { uiModules } from '../modules';
uiModules
.get('kibana')
- .directive('parseQuery', function (Private) {
- const fromUser = Private(ParseQueryLibFromUserProvider);
+ .directive('parseQuery', function () {
return {
restrict: 'A',
diff --git a/src/ui/public/persisted_log/directive.js b/src/ui/public/persisted_log/directive.js
new file mode 100644
index 0000000000000..29f2f93093aeb
--- /dev/null
+++ b/src/ui/public/persisted_log/directive.js
@@ -0,0 +1,26 @@
+/*
+ * 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 { uiModules } from '../modules';
+import { PersistedLog } from './persisted_log';
+
+uiModules.get('kibana/persisted_log')
+ .factory('PersistedLog', function () {
+ return PersistedLog;
+ });
diff --git a/src/ui/public/persisted_log/index.d.ts b/src/ui/public/persisted_log/index.d.ts
new file mode 100644
index 0000000000000..8d22b28c7d3ec
--- /dev/null
+++ b/src/ui/public/persisted_log/index.d.ts
@@ -0,0 +1,20 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export { PersistedLog } from './persisted_log';
diff --git a/src/ui/public/persisted_log/index.js b/src/ui/public/persisted_log/index.js
index bb2ffadbd11c6..3d8bec771dc74 100644
--- a/src/ui/public/persisted_log/index.js
+++ b/src/ui/public/persisted_log/index.js
@@ -17,7 +17,7 @@
* under the License.
*/
-import './persisted_log';
+import './directive';
export { PersistedLog } from './persisted_log';
export { recentlyAccessed } from './recently_accessed';
diff --git a/src/ui/public/persisted_log/persisted_log.test.js b/src/ui/public/persisted_log/persisted_log.test.ts
similarity index 52%
rename from src/ui/public/persisted_log/persisted_log.test.js
rename to src/ui/public/persisted_log/persisted_log.test.ts
index ee9c26d573573..e0bc8f2c3525f 100644
--- a/src/ui/public/persisted_log/persisted_log.test.js
+++ b/src/ui/public/persisted_log/persisted_log.test.ts
@@ -17,14 +17,28 @@
* under the License.
*/
+import { PersistedLog } from './persisted_log';
+
+const createMockWebStorage = () => ({
+ clear: jest.fn(),
+ getItem: jest.fn(),
+ key: jest.fn(),
+ removeItem: jest.fn(),
+ setItem: jest.fn(),
+ length: 0,
+});
-import sinon from 'sinon';
-import expect from 'expect.js';
-import { PersistedLog } from './';
+const createMockStorage = () => ({
+ store: createMockWebStorage(),
+ get: jest.fn(),
+ set: jest.fn(),
+ remove: jest.fn(),
+ clear: jest.fn(),
+});
jest.mock('ui/chrome', () => {
return {
- getBasePath: () => `/some/base/path`
+ getBasePath: () => `/some/base/path`,
};
});
@@ -33,107 +47,102 @@ const historyLimit = 10;
const payload = [
{ first: 'clark', last: 'kent' },
{ first: 'peter', last: 'parker' },
- { first: 'bruce', last: 'wayne' }
+ { first: 'bruce', last: 'wayne' },
];
-describe('PersistedLog', function () {
-
- let storage;
- beforeEach(function () {
- storage = {
- get: sinon.stub(),
- set: sinon.stub(),
- remove: sinon.spy(),
- clear: sinon.spy()
- };
+describe('PersistedLog', () => {
+ let storage = createMockStorage();
+ beforeEach(() => {
+ storage = createMockStorage();
});
- describe('expected API', function () {
- test('has expected methods', function () {
- const log = new PersistedLog(historyName);
+ describe('expected API', () => {
+ test('has expected methods', () => {
+ const log = new PersistedLog(historyName, {}, storage);
- expect(log.add).to.be.a('function');
- expect(log.get).to.be.a('function');
+ expect(typeof log.add).toBe('function');
+ expect(typeof log.get).toBe('function');
});
});
- describe('internal functionality', function () {
- test('reads from storage', function () {
- new PersistedLog(historyName, {}, storage);
+ describe('internal functionality', () => {
+ test('reads from storage', () => {
+ // @ts-ignore
+ const log = new PersistedLog(historyName, {}, storage);
- expect(storage.get.calledOnce).to.be(true);
- expect(storage.get.calledWith(historyName)).to.be(true);
+ expect(storage.get).toHaveBeenCalledTimes(1);
+ expect(storage.get).toHaveBeenCalledWith(historyName);
});
- test('writes to storage', function () {
+ test('writes to storage', () => {
const log = new PersistedLog(historyName, {}, storage);
const newItem = { first: 'diana', last: 'prince' };
const data = log.add(newItem);
- expect(storage.set.calledOnce).to.be(true);
- expect(data).to.eql([newItem]);
+ expect(storage.set).toHaveBeenCalledTimes(1);
+ expect(data).toEqual([newItem]);
});
});
- describe('persisting data', function () {
- test('fetches records from storage', function () {
- storage.get.returns(payload);
+ describe('persisting data', () => {
+ test('fetches records from storage', () => {
+ storage.get.mockReturnValue(payload);
const log = new PersistedLog(historyName, {}, storage);
const items = log.get();
- expect(items.length).to.equal(3);
- expect(items).to.eql(payload);
+ expect(items.length).toBe(3);
+ expect(items).toEqual(payload);
});
- test('prepends new records', function () {
- storage.get.returns(payload.slice(0));
+ test('prepends new records', () => {
+ storage.get.mockReturnValue(payload.slice(0));
const log = new PersistedLog(historyName, {}, storage);
const newItem = { first: 'selina', last: 'kyle' };
const items = log.add(newItem);
- expect(items.length).to.equal(payload.length + 1);
- expect(items[0]).to.eql(newItem);
+ expect(items.length).toBe(payload.length + 1);
+ expect(items[0]).toEqual(newItem);
});
});
- describe('stack options', function () {
- test('should observe the maxLength option', function () {
+ describe('stack options', () => {
+ test('should observe the maxLength option', () => {
const bulkData = [];
for (let i = 0; i < historyLimit; i++) {
bulkData.push(['record ' + i]);
}
- storage.get.returns(bulkData);
+ storage.get.mockReturnValue(bulkData);
const log = new PersistedLog(historyName, { maxLength: historyLimit }, storage);
log.add(['new array 1']);
const items = log.add(['new array 2']);
- expect(items.length).to.equal(historyLimit);
+ expect(items.length).toBe(historyLimit);
});
- test('should observe the filterDuplicates option', function () {
- storage.get.returns(payload.slice(0));
+ test('should observe the filterDuplicates option', () => {
+ storage.get.mockReturnValue(payload.slice(0));
const log = new PersistedLog(historyName, { filterDuplicates: true }, storage);
const newItem = payload[1];
const items = log.add(newItem);
- expect(items.length).to.equal(payload.length);
+ expect(items.length).toBe(payload.length);
});
test('should truncate the list upon initialization if too long', () => {
- storage.get.returns(payload.slice(0));
+ storage.get.mockReturnValue(payload.slice(0));
const log = new PersistedLog(historyName, { maxLength: 1 }, storage);
const items = log.get();
- expect(items.length).to.equal(1);
+ expect(items.length).toBe(1);
});
test('should allow a maxLength of 0', () => {
- storage.get.returns(payload.slice(0));
+ storage.get.mockReturnValue(payload.slice(0));
const log = new PersistedLog(historyName, { maxLength: 0 }, storage);
const items = log.get();
- expect(items.length).to.equal(0);
+ expect(items.length).toBe(0);
});
});
});
diff --git a/src/ui/public/persisted_log/persisted_log.js b/src/ui/public/persisted_log/persisted_log.ts
similarity index 61%
rename from src/ui/public/persisted_log/persisted_log.js
rename to src/ui/public/persisted_log/persisted_log.ts
index 01ad38b815b2c..eee1d4b9a03ec 100644
--- a/src/ui/public/persisted_log/persisted_log.js
+++ b/src/ui/public/persisted_log/persisted_log.ts
@@ -17,35 +17,46 @@
* under the License.
*/
-import { uiModules } from '../modules';
import _ from 'lodash';
-import { Storage } from '../storage';
+import { Storage } from 'ui/storage';
const localStorage = new Storage(window.localStorage);
-const defaultIsDuplicate = (oldItem, newItem) => {
+const defaultIsDuplicate = (oldItem: string, newItem: string) => {
return _.isEqual(oldItem, newItem);
};
export class PersistedLog {
- constructor(name, options = {}, storage = localStorage) {
+ public name: string;
+ public maxLength?: number;
+ public filterDuplicates?: boolean;
+ public isDuplicate: (oldItem: any, newItem: any) => boolean;
+ public storage: Storage;
+ public items: any[];
+
+ constructor(name: string, options: PersistedLogOptions = {}, storage = localStorage) {
this.name = name;
- this.maxLength = parseInt(options.maxLength, 10);
+ this.maxLength =
+ typeof options.maxLength === 'string'
+ ? (this.maxLength = parseInt(options.maxLength, 10))
+ : options.maxLength;
this.filterDuplicates = options.filterDuplicates || false;
this.isDuplicate = options.isDuplicate || defaultIsDuplicate;
this.storage = storage;
this.items = this.storage.get(this.name) || [];
- if (!isNaN(this.maxLength)) this.items = _.take(this.items, this.maxLength);
+ if (this.maxLength !== undefined && !isNaN(this.maxLength)) {
+ this.items = _.take(this.items, this.maxLength);
+ }
}
- add(val) {
+ public add(val: any) {
if (val == null) {
return this.items;
}
// remove any matching items from the stack if option is set
if (this.filterDuplicates) {
- _.remove(this.items, (item) => {
+ _.remove(this.items, item => {
return this.isDuplicate(item, val);
});
}
@@ -53,19 +64,22 @@ export class PersistedLog {
this.items.unshift(val);
// if maxLength is set, truncate the stack
- if (!isNaN(this.maxLength)) this.items = _.take(this.items, this.maxLength);
+ if (this.maxLength && !isNaN(this.maxLength)) {
+ this.items = _.take(this.items, this.maxLength);
+ }
// persist the stack
this.storage.set(this.name, this.items);
return this.items;
}
- get() {
+ public get() {
return _.cloneDeep(this.items);
}
}
-uiModules.get('kibana/persisted_log')
- .factory('PersistedLog', function () {
- return PersistedLog;
- });
+interface PersistedLogOptions {
+ maxLength?: number | string;
+ filterDuplicates?: boolean;
+ isDuplicate?: (oldItem: string, newItem: string) => boolean;
+}
diff --git a/src/ui/public/query_bar/_index.scss b/src/ui/public/query_bar/_index.scss
new file mode 100644
index 0000000000000..81a69fd89db99
--- /dev/null
+++ b/src/ui/public/query_bar/_index.scss
@@ -0,0 +1,4 @@
+// SASSTODO: Formalize this color in Kibana's styling constants
+$typeaheadConjunctionColor: #7800A6;
+
+@import 'components/typeahead/index';
\ No newline at end of file
diff --git a/src/ui/public/query_bar/components/__snapshots__/language_switcher.test.tsx.snap b/src/ui/public/query_bar/components/__snapshots__/language_switcher.test.tsx.snap
new file mode 100644
index 0000000000000..d0c5fd332de2c
--- /dev/null
+++ b/src/ui/public/query_bar/components/__snapshots__/language_switcher.test.tsx.snap
@@ -0,0 +1,189 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`LanguageSwitcher should toggle off if language is lucene 1`] = `
+
+ Options
+
+ }
+ closePopover={[Function]}
+ hasArrow={true}
+ id="popover"
+ isOpen={false}
+ ownFocus={true}
+ panelPaddingSize="m"
+ withTitle={true}
+>
+
+ Syntax options
+
+
+
+
+ Our experimental autocomplete and simple syntax features can help you create your queries. Just start typing and you’ll see matches related to your data. See docs
+
+
+ here
+
+ .
+
+
+
+
+
+
+
+
+
+
+
+ Not ready yet? Find our lucene docs
+
+
+ here
+
+ .
+
+
+
+
+`;
+
+exports[`LanguageSwitcher should toggle on if language is kuery 1`] = `
+
+ Options
+
+ }
+ closePopover={[Function]}
+ hasArrow={true}
+ id="popover"
+ isOpen={false}
+ ownFocus={true}
+ panelPaddingSize="m"
+ withTitle={true}
+>
+
+ Syntax options
+
+
+
+
+ Our experimental autocomplete and simple syntax features can help you create your queries. Just start typing and you’ll see matches related to your data. See docs
+
+
+ here
+
+ .
+
+
+
+
+
+
+
+
+
+
+
+ Not ready yet? Find our lucene docs
+
+
+ here
+
+ .
+
+
+
+
+`;
diff --git a/src/ui/public/query_bar/components/__snapshots__/query_bar.test.tsx.snap b/src/ui/public/query_bar/components/__snapshots__/query_bar.test.tsx.snap
new file mode 100644
index 0000000000000..dd74731f5817b
--- /dev/null
+++ b/src/ui/public/query_bar/components/__snapshots__/query_bar.test.tsx.snap
@@ -0,0 +1,217 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`QueryBar Should disable autoFocus on EuiFieldText when disableAutoFocus prop is true 1`] = `
+
+
+
+
+
+
+`;
+
+exports[`QueryBar Should pass the query language to the language switcher 1`] = `
+
+
+
+
+
+
+`;
+
+exports[`QueryBar Should render the given query 1`] = `
+
+
+
+
+
+
+`;
diff --git a/src/ui/public/query_bar/index.js b/src/ui/public/query_bar/components/index.ts
similarity index 95%
rename from src/ui/public/query_bar/index.js
rename to src/ui/public/query_bar/components/index.ts
index 23566906b6487..ed4266589478e 100644
--- a/src/ui/public/query_bar/index.js
+++ b/src/ui/public/query_bar/components/index.ts
@@ -17,4 +17,4 @@
* under the License.
*/
-import './directive/query_bar';
+export { QueryBar } from './query_bar';
diff --git a/src/ui/public/query_bar/components/language_switcher.test.tsx b/src/ui/public/query_bar/components/language_switcher.test.tsx
new file mode 100644
index 0000000000000..1bc43920f4cc6
--- /dev/null
+++ b/src/ui/public/query_bar/components/language_switcher.test.tsx
@@ -0,0 +1,65 @@
+/*
+ * 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.
+ */
+
+jest.mock('../../metadata', () => ({
+ metadata: {
+ branch: 'foo',
+ },
+}));
+
+import { shallow } from 'enzyme';
+import React from 'react';
+import { QueryLanguageSwitcher } from './language_switcher';
+
+describe('LanguageSwitcher', () => {
+ it('should toggle off if language is lucene', () => {
+ const component = shallow(
+ {
+ return;
+ }}
+ />
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ it('should toggle on if language is kuery', () => {
+ const component = shallow(
+ {
+ return;
+ }}
+ />
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ it('call onSelectLanguage when the toggle is clicked', () => {
+ const callback = jest.fn();
+ const component = shallow(
+
+ );
+ component.find('[data-test-subj="languageToggle"]').simulate('change');
+ expect(callback).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/ui/public/query_bar/components/language_switcher.tsx b/src/ui/public/query_bar/components/language_switcher.tsx
new file mode 100644
index 0000000000000..5c5283520424a
--- /dev/null
+++ b/src/ui/public/query_bar/components/language_switcher.tsx
@@ -0,0 +1,137 @@
+/*
+ * 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.
+ */
+
+declare module '@elastic/eui' {
+ export const EuiPopoverTitle: React.SFC;
+}
+
+import {
+ EuiButtonEmpty,
+ EuiForm,
+ EuiFormRow,
+ EuiHorizontalRule,
+ EuiLink,
+ EuiPopover,
+ EuiPopoverTitle,
+ EuiSpacer,
+ EuiSwitch,
+ EuiText,
+} from '@elastic/eui';
+import React, { Component } from 'react';
+import { documentationLinks } from '../../documentation_links/documentation_links';
+
+const luceneQuerySyntaxDocs = documentationLinks.query.luceneQuerySyntax;
+const kueryQuerySyntaxDocs = documentationLinks.query.kueryQuerySyntax;
+
+interface State {
+ isPopoverOpen: boolean;
+}
+
+interface Props {
+ language: string;
+ onSelectLanguage: (newLanguage: string) => void;
+}
+
+export class QueryLanguageSwitcher extends Component {
+ public state = {
+ isPopoverOpen: false,
+ };
+
+ public render() {
+ const button = (
+
+ Options
+
+ );
+
+ return (
+
+ Syntax options
+
+
+
+ Our experimental autocomplete and simple syntax features can help you create your
+ queries. Just start typing and you’ll see matches related to your data. See docs{' '}
+ {
+
+ here
+
+ }
+ .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Not ready yet? Find our lucene docs{' '}
+ {
+
+ here
+
+ }
+ .
+
+
+
+
+ );
+ }
+
+ private togglePopover = () => {
+ this.setState({
+ isPopoverOpen: !this.state.isPopoverOpen,
+ });
+ };
+
+ private closePopover = () => {
+ this.setState({
+ isPopoverOpen: false,
+ });
+ };
+
+ private onSwitchChange = () => {
+ const newLanguage = this.props.language === 'lucene' ? 'kuery' : 'lucene';
+ this.props.onSelectLanguage(newLanguage);
+ };
+}
diff --git a/src/ui/public/query_bar/components/query_bar.test.tsx b/src/ui/public/query_bar/components/query_bar.test.tsx
new file mode 100644
index 0000000000000..7309cc13a1b23
--- /dev/null
+++ b/src/ui/public/query_bar/components/query_bar.test.tsx
@@ -0,0 +1,276 @@
+/*
+ * 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.
+ */
+
+const mockChromeFactory = jest.fn(() => {
+ return {
+ getBasePath: () => `foo`,
+ getUiSettingsClient: () => {
+ return {
+ get: (key: string) => {
+ switch (key) {
+ case 'history:limit':
+ return 10;
+ default:
+ throw new Error(`Unexpected config key: ${key}`);
+ }
+ },
+ };
+ },
+ };
+});
+
+const mockPersistedLog = {
+ add: jest.fn(),
+ get: jest.fn(() => ['response:200']),
+};
+
+const mockPersistedLogFactory = jest.fn(() => {
+ return mockPersistedLog;
+});
+
+const mockGetAutocompleteSuggestions = jest.fn(() => Promise.resolve([]));
+const mockAutocompleteProvider = jest.fn(() => mockGetAutocompleteSuggestions);
+const mockGetAutocompleteProvider = jest.fn(() => mockAutocompleteProvider);
+
+jest.mock('ui/chrome', () => mockChromeFactory());
+jest.mock('../../chrome', () => mockChromeFactory());
+jest.mock('ui/persisted_log', () => ({
+ PersistedLog: mockPersistedLogFactory,
+}));
+jest.mock('../../metadata', () => ({
+ metadata: {
+ branch: 'foo',
+ },
+}));
+jest.mock('../../autocomplete_providers', () => ({
+ getAutocompleteProvider: mockGetAutocompleteProvider,
+}));
+
+import _ from 'lodash';
+// Using doMock to avoid hoisting so that I can override only the debounce method in lodash
+jest.doMock('lodash', () => ({
+ ..._,
+ debounce: (func: () => any) => func,
+}));
+
+import { EuiFieldText } from '@elastic/eui';
+import { mount, shallow } from 'enzyme';
+import React from 'react';
+import { QueryBar } from 'ui/query_bar';
+import { QueryLanguageSwitcher } from 'ui/query_bar/components/language_switcher';
+
+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 = {
+ title: 'logstash-*',
+ fields: {
+ raw: [
+ {
+ name: 'response',
+ type: 'number',
+ aggregatable: true,
+ searchable: true,
+ },
+ ],
+ },
+};
+
+describe('QueryBar', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Should render the given query', () => {
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ it('Should pass the query language to the language switcher', () => {
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ it('Should disable autoFocus on EuiFieldText when disableAutoFocus prop is true', () => {
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ it('Should create a unique PersistedLog based on the appName and query language', () => {
+ shallow(
+
+ );
+
+ 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 = shallow(
+
+ );
+
+ component.find(QueryLanguageSwitcher).simulate('selectLanguage', 'lucene');
+ expect(mockStorage.set).toHaveBeenCalledWith('kibana.userQueryLanguage', 'lucene');
+ expect(mockCallback).toHaveBeenCalledWith({
+ 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 = mount(
+
+ );
+
+ const instance = component.instance() as QueryBar;
+ 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({
+ query: 'extension:jpg',
+ language: 'kuery',
+ });
+ });
+
+ it('Should use PersistedLog for recent search suggestions', async () => {
+ const component = mount(
+
+ );
+
+ const instance = component.instance() as QueryBar;
+ 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', () => {
+ mount(
+
+ );
+
+ expect(mockGetAutocompleteProvider).toHaveBeenCalledWith('kuery');
+ expect(mockGetAutocompleteSuggestions).toHaveBeenCalled();
+ });
+});
diff --git a/src/ui/public/query_bar/components/query_bar.tsx b/src/ui/public/query_bar/components/query_bar.tsx
new file mode 100644
index 0000000000000..bbffb5ab755f8
--- /dev/null
+++ b/src/ui/public/query_bar/components/query_bar.tsx
@@ -0,0 +1,502 @@
+/*
+ * 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 { IndexPattern } from 'ui/index_patterns';
+
+declare module '@elastic/eui' {
+ export const EuiOutsideClickDetector: SFC;
+}
+
+import { debounce } from 'lodash';
+import React, { Component, SFC } from 'react';
+import { getFromLegacyIndexPattern } from 'ui/index_patterns/static_utils';
+import { kfetch } from 'ui/kfetch';
+import { PersistedLog } from 'ui/persisted_log';
+import { Storage } from 'ui/storage';
+import {
+ AutocompleteSuggestion,
+ AutocompleteSuggestionType,
+ getAutocompleteProvider,
+} from '../../autocomplete_providers';
+import chrome from '../../chrome';
+import { fromUser, toUser } from '../../parse_query';
+import { matchPairs } from '../lib/match_pairs';
+import { QueryLanguageSwitcher } from './language_switcher';
+import { SuggestionsComponent } from './typeahead/suggestions_component';
+
+import { EuiFieldText, EuiOutsideClickDetector } from '@elastic/eui';
+
+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';
+
+interface Query {
+ query: string;
+ language: string;
+}
+
+interface Props {
+ query: Query;
+ onSubmit: (query: { query: string | object; language: string }) => void;
+ disableAutoFocus?: boolean;
+ appName: string;
+ indexPatterns: IndexPattern[];
+ store: Storage;
+}
+
+interface State {
+ query: Query;
+ inputIsPristine: boolean;
+ isSuggestionsVisible: boolean;
+ index: number | null;
+ suggestions: AutocompleteSuggestion[];
+ suggestionLimit: number;
+}
+
+export class QueryBar extends Component {
+ public static getDerivedStateFromProps(nextProps: Props, prevState: State) {
+ if (nextProps.query.query !== prevState.query.query) {
+ return {
+ query: {
+ query: toUser(nextProps.query.query),
+ language: nextProps.query.language,
+ },
+ };
+ } else if (nextProps.query.language !== prevState.query.language) {
+ return {
+ query: {
+ query: '',
+ language: nextProps.query.language,
+ },
+ };
+ }
+
+ return null;
+ }
+
+ /*
+ Keep the "draft" value in local state until the user actually submits the query. There are a couple advantages:
+
+ 1. Each app doesn't have to maintain its own "draft" value if it wants to put off updating the query in app state
+ until the user manually submits their changes. Most apps have watches on the query value in app state so we don't
+ want to trigger those on every keypress. Also, some apps (e.g. dashboard) already juggle multiple query values,
+ each with slightly different semantics and I'd rather not add yet another variable to the mix.
+
+ 2. Changes to the local component state won't trigger an Angular digest cycle. Triggering digest cycles on every
+ keypress has been a major source of performance issues for us in previous implementations of the query bar.
+ See https://github.com/elastic/kibana/issues/14086
+ */
+ public state = {
+ query: {
+ query: toUser(this.props.query.query),
+ language: this.props.query.language,
+ },
+ inputIsPristine: true,
+ isSuggestionsVisible: false,
+ index: null,
+ suggestions: [],
+ suggestionLimit: 50,
+ };
+
+ 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;
+
+ 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) {
+ return recentSearchSuggestions;
+ }
+
+ const indexPatterns = getFromLegacyIndexPattern(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 = () => {
+ this.setState({ isSuggestionsVisible: false, index: null });
+ };
+
+ public onClickInput = (event: React.MouseEvent) => {
+ if (event.target instanceof HTMLInputElement) {
+ this.onInputChange(event.target.value);
+ }
+ };
+
+ 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());
+
+ this.setState({
+ query: {
+ query: value,
+ language: this.state.query.language,
+ },
+ inputIsPristine: false,
+ isSuggestionsVisible: hasValue,
+ index: null,
+ suggestionLimit: 50,
+ });
+ };
+
+ public onChange = (event: React.ChangeEvent) => {
+ this.updateSuggestions();
+ this.onInputChange(event.target.value);
+ };
+
+ 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();
+ }
+
+ if (this.persistedLog) {
+ this.persistedLog.add(this.state.query.query);
+ }
+
+ this.props.onSubmit({
+ query: fromUser(this.state.query.query),
+ language: this.state.query.language,
+ });
+ 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: '',
+ language,
+ });
+ };
+
+ public componentDidMount() {
+ this.persistedLog = new PersistedLog(
+ `typeahead:${this.props.appName}-${this.state.query.language}`,
+ {
+ maxLength: config.get('history:limit'),
+ filterDuplicates: true,
+ }
+ );
+ this.updateSuggestions();
+ }
+
+ 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();
+ }
+ }
+
+ public componentWillUnmount() {
+ this.updateSuggestions.cancel();
+ this.componentIsUnmounting = true;
+ }
+
+ public render() {
+ return (
+
+ {/* position:relative required on container so the suggestions appear under the query bar*/}
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/src/ui/public/query_bar/components/typeahead/__snapshots__/suggestion_component.test.tsx.snap b/src/ui/public/query_bar/components/typeahead/__snapshots__/suggestion_component.test.tsx.snap
new file mode 100644
index 0000000000000..0e3ce952d69e2
--- /dev/null
+++ b/src/ui/public/query_bar/components/typeahead/__snapshots__/suggestion_component.test.tsx.snap
@@ -0,0 +1,73 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SuggestionComponent Should display the suggestion and use the provided ariaId 1`] = `
+
+
+
+
+
+
+ as promised, not helpful
+
+
+
+
+`;
+
+exports[`SuggestionComponent Should make the element active if the selected prop is true 1`] = `
+
+
+
+
+
+
+ as promised, not helpful
+
+
+
+
+`;
diff --git a/src/ui/public/query_bar/components/typeahead/__snapshots__/suggestions_component.test.tsx.snap b/src/ui/public/query_bar/components/typeahead/__snapshots__/suggestions_component.test.tsx.snap
new file mode 100644
index 0000000000000..1b8fc29858c83
--- /dev/null
+++ b/src/ui/public/query_bar/components/typeahead/__snapshots__/suggestions_component.test.tsx.snap
@@ -0,0 +1,113 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SuggestionsComponent Passing the index should control which suggestion is selected 1`] = `
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`SuggestionsComponent Should display given suggestions if the show prop is true 1`] = `
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/ui/public/query_bar/components/typeahead/_index.scss b/src/ui/public/query_bar/components/typeahead/_index.scss
new file mode 100644
index 0000000000000..8ff2965158ad9
--- /dev/null
+++ b/src/ui/public/query_bar/components/typeahead/_index.scss
@@ -0,0 +1 @@
+@import 'suggestion';
\ No newline at end of file
diff --git a/src/ui/public/query_bar/components/typeahead/_suggestion.scss b/src/ui/public/query_bar/components/typeahead/_suggestion.scss
new file mode 100644
index 0000000000000..5fbb4a791ffcd
--- /dev/null
+++ b/src/ui/public/query_bar/components/typeahead/_suggestion.scss
@@ -0,0 +1,195 @@
+.typeahead {
+ position: relative;
+
+ .typeahead-popover {
+ @include euiBottomShadow($adjustBorders: true);
+ border: 1px solid;
+ border-color: $euiBorderColor;
+ color: $euiTextColor;
+ background-color: $euiColorEmptyShade;
+ position: absolute;
+ top: -10px;
+ z-index: $euiZContentMenu;
+ width: 100%;
+ border-radius: $euiBorderRadius;
+
+ .typeahead-items {
+ max-height: 60vh;
+ overflow-y: auto;
+ }
+
+ .typeahead-item {
+ height: $euiSizeXL;
+ white-space: nowrap;
+ font-size: $euiFontSizeXS;
+ vertical-align: middle;
+ padding: 0;
+ border-bottom: none;
+ line-height: normal;
+ }
+
+ .typeahead-item:hover {
+ cursor: pointer;
+ }
+
+ .typeahead-item:last-child {
+ border-bottom: 0px;
+ border-radius: 0 0 $euiBorderRadius $euiBorderRadius;
+ }
+
+ .typeahead-item:first-child {
+ border-bottom: 0px;
+ border-radius: $euiBorderRadius $euiBorderRadius 0 0;
+ }
+
+ .typeahead-item.active {
+ background-color: $euiColorLightestShade;
+
+
+ .suggestionItem__callout {
+ background: $euiColorEmptyShade;
+ }
+
+ .suggestionItem__text {
+ color: $euiColorFullShade;
+ }
+
+ .suggestionItem__type {
+ color: $euiColorFullShade;
+ }
+
+ .suggestionItem--field {
+ .suggestionItem__type {
+ background-color: tint($euiColorWarning, 80%);
+ }
+ }
+
+ .suggestionItem--value {
+ .suggestionItem__type {
+ background-color: tint($euiColorSecondary, 80%);
+ }
+ }
+
+ .suggestionItem--operator {
+ .suggestionItem__type {
+ background-color: tint($euiColorPrimary, 80%);
+ }
+ }
+
+ .suggestionItem--conjunction {
+ .suggestionItem__type {
+ background-color: tint($typeaheadConjunctionColor, 80%);
+ }
+ }
+
+ }
+ }
+}
+
+.inline-form .typeahead.visible .input-group {
+ > :first-child {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+ > :last-child {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+}
+
+.suggestionItem {
+ display: flex;
+ align-items: stretch;
+ flex-grow: 1;
+ align-items: center;
+ font-size: $euiFontSizeXS;
+ white-space: nowrap;
+ &.suggestionItem--field {
+ .suggestionItem__type {
+ background-color: tint($euiColorWarning, 90%);
+ color: makeHighContrastColor($euiColorWarning, tint($euiColorWarning, 90%));
+ }
+ }
+
+ &.suggestionItem--value {
+ .suggestionItem__type {
+ background-color: tint($euiColorSecondary, 90%);
+ color: makeHighContrastColor($euiColorSecondary, tint($euiColorSecondary, 90%));
+ }
+
+ .suggestionItem__text {
+ width: auto;
+ }
+ }
+
+ &.suggestionItem--operator {
+ .suggestionItem__type {
+ background-color: tint($euiColorPrimary, 90%);
+ color: makeHighContrastColor($euiColorPrimary, tint($euiColorSecondary, 90%));
+ }
+ }
+
+ &.suggestionItem--conjunction {
+ .suggestionItem__type {
+ background-color: tint($typeaheadConjunctionColor, 90%);
+ color: makeHighContrastColor($typeaheadConjunctionColor, tint($typeaheadConjunctionColor, 90%));
+ }
+ }
+
+ &.suggestionItem--recentSearch {
+ .suggestionItem__type {
+ background-color: $euiColorLightShade;
+ color: $euiColorMediumShade;
+ }
+
+ .suggestionItem__text {
+ width: auto;
+ }
+ }
+}
+
+.suggestionItem__text, .suggestionItem__type, .suggestionItem__description {
+ flex-grow: 1;
+ flex-basis: 0%;
+ display: flex;
+ flex-direction: column;
+}
+
+.suggestionItem__type {
+ flex-grow: 0;
+ flex-basis: auto;
+ width: $euiSizeXL;
+ height: $euiSizeXL;
+ text-align: center;
+ overflow: hidden;
+ padding: $euiSizeXS;
+ justify-content: center;
+ align-items: center;
+}
+
+
+.suggestionItem__text {
+ flex-grow: 0; /* 2 */
+ flex-basis: auto; /* 2 */
+ font-family: $euiCodeFontFamily;
+ margin-right: $euiSizeXL;
+ width: 250px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ padding: $euiSizeXS $euiSizeS;
+ color: #111;
+}
+
+.suggestionItem__description {
+ color: $euiColorDarkShade;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.suggestionItem__callout {
+ font-family: $euiCodeFontFamily;
+ background: $euiColorLightestShade;
+ color: $euiColorFullShade;
+ padding: 0 $euiSizeXS;
+ display: inline-block;
+}
diff --git a/src/ui/public/query_bar/components/typeahead/suggestion_component.test.tsx b/src/ui/public/query_bar/components/typeahead/suggestion_component.test.tsx
new file mode 100644
index 0000000000000..ee6eb994f32f4
--- /dev/null
+++ b/src/ui/public/query_bar/components/typeahead/suggestion_component.test.tsx
@@ -0,0 +1,122 @@
+/*
+ * 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 { mount, shallow } from 'enzyme';
+import React from 'react';
+import { AutocompleteSuggestion } from 'ui/autocomplete_providers';
+import { SuggestionComponent } from 'ui/query_bar/components/typeahead/suggestion_component';
+
+const noop = () => {
+ return;
+};
+
+const mockSuggestion: AutocompleteSuggestion = {
+ description: 'This is not a helpful suggestion',
+ end: 0,
+ start: 42,
+ text: 'as promised, not helpful',
+ type: 'value',
+};
+
+describe('SuggestionComponent', () => {
+ it('Should display the suggestion and use the provided ariaId', () => {
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ it('Should make the element active if the selected prop is true', () => {
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ it('Should call innerRef with a reference to the root div element', () => {
+ const innerRefCallback = (ref: HTMLDivElement) => {
+ expect(ref.className).toBe('typeahead-item');
+ expect(ref.id).toBe('suggestion-1');
+ };
+
+ mount(
+
+ );
+ });
+
+ it('Should call onClick with the provided suggestion', () => {
+ const mockHandler = jest.fn();
+
+ const component = shallow(
+
+ );
+
+ component.simulate('click');
+ expect(mockHandler).toHaveBeenCalledTimes(1);
+ expect(mockHandler).toHaveBeenCalledWith(mockSuggestion);
+ });
+
+ it('Should call onMouseEnter when user mouses over the element', () => {
+ const mockHandler = jest.fn();
+
+ const component = shallow(
+
+ );
+
+ component.simulate('mouseenter');
+ expect(mockHandler).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/ui/public/query_bar/components/typeahead/suggestion_component.tsx b/src/ui/public/query_bar/components/typeahead/suggestion_component.tsx
new file mode 100644
index 0000000000000..424afa2974773
--- /dev/null
+++ b/src/ui/public/query_bar/components/typeahead/suggestion_component.tsx
@@ -0,0 +1,80 @@
+/*
+ * 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 { EuiIcon } from '@elastic/eui';
+import classNames from 'classnames';
+import React, { SFC } from 'react';
+import { AutocompleteSuggestion } from 'ui/autocomplete_providers';
+
+function getEuiIconType(type: string) {
+ switch (type) {
+ case 'field':
+ return 'kqlField';
+ case 'value':
+ return 'kqlValue';
+ case 'recentSearch':
+ return 'search';
+ case 'conjunction':
+ return 'kqlSelector';
+ case 'operator':
+ return 'kqlOperand';
+ default:
+ throw new Error(`Unknown type: ${type}`);
+ }
+}
+
+interface Props {
+ onClick: (suggestion: AutocompleteSuggestion) => void;
+ onMouseEnter: () => void;
+ selected: boolean;
+ suggestion: AutocompleteSuggestion;
+ innerRef: (node: HTMLDivElement) => void;
+ ariaId: string;
+}
+
+export const SuggestionComponent: SFC = props => {
+ return (
+
+ );
+};
diff --git a/src/ui/public/query_bar/components/typeahead/suggestions_component.test.tsx b/src/ui/public/query_bar/components/typeahead/suggestions_component.test.tsx
new file mode 100644
index 0000000000000..910633a8c5afc
--- /dev/null
+++ b/src/ui/public/query_bar/components/typeahead/suggestions_component.test.tsx
@@ -0,0 +1,150 @@
+/*
+ * 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 { mount, shallow } from 'enzyme';
+import React from 'react';
+import { AutocompleteSuggestion } from 'ui/autocomplete_providers';
+import { SuggestionComponent } from 'ui/query_bar/components/typeahead/suggestion_component';
+import { SuggestionsComponent } from 'ui/query_bar/components/typeahead/suggestions_component';
+
+const noop = () => {
+ return;
+};
+
+const mockSuggestions: AutocompleteSuggestion[] = [
+ {
+ description: 'This is not a helpful suggestion',
+ end: 0,
+ start: 42,
+ text: 'as promised, not helpful',
+ type: 'value',
+ },
+ {
+ description: 'This is another unhelpful suggestion',
+ end: 0,
+ start: 42,
+ text: 'yep',
+ type: 'field',
+ },
+];
+
+describe('SuggestionsComponent', () => {
+ it('Should not display anything if the show prop is false', () => {
+ const component = shallow(
+
+ );
+
+ expect(component.isEmptyRender()).toBe(true);
+ });
+
+ it('Should not display anything if there are no suggestions', () => {
+ const component = shallow(
+
+ );
+
+ expect(component.isEmptyRender()).toBe(true);
+ });
+
+ it('Should display given suggestions if the show prop is true', () => {
+ const component = shallow(
+
+ );
+
+ expect(component.isEmptyRender()).toBe(false);
+ expect(component).toMatchSnapshot();
+ });
+
+ it('Passing the index should control which suggestion is selected', () => {
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ it('Should call onClick with the selected suggestion when it is clicked', () => {
+ const mockCallback = jest.fn();
+ const component = mount(
+
+ );
+
+ component
+ .find(SuggestionComponent)
+ .at(1)
+ .simulate('click');
+ expect(mockCallback).toHaveBeenCalledTimes(1);
+ expect(mockCallback).toHaveBeenCalledWith(mockSuggestions[1]);
+ });
+
+ it('Should call onMouseEnter with the index of the suggestion that was entered', () => {
+ const mockCallback = jest.fn();
+ const component = mount(
+
+ );
+
+ component
+ .find(SuggestionComponent)
+ .at(1)
+ .simulate('mouseenter');
+ expect(mockCallback).toHaveBeenCalledTimes(1);
+ expect(mockCallback).toHaveBeenCalledWith(1);
+ });
+});
diff --git a/src/ui/public/query_bar/components/typeahead/suggestions_component.tsx b/src/ui/public/query_bar/components/typeahead/suggestions_component.tsx
new file mode 100644
index 0000000000000..c4fb9a8de283c
--- /dev/null
+++ b/src/ui/public/query_bar/components/typeahead/suggestions_component.tsx
@@ -0,0 +1,118 @@
+/*
+ * 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 { isEmpty } from 'lodash';
+import React, { Component } from 'react';
+import { AutocompleteSuggestion } from 'ui/autocomplete_providers';
+import { SuggestionComponent } from './suggestion_component';
+
+interface Props {
+ index: number | null;
+ onClick: (suggestion: AutocompleteSuggestion) => void;
+ onMouseEnter: (index: number) => void;
+ show: boolean;
+ suggestions: AutocompleteSuggestion[];
+ loadMore: () => void;
+}
+
+export class SuggestionsComponent extends Component {
+ private childNodes: HTMLDivElement[] = [];
+ private parentNode: HTMLDivElement | null = null;
+
+ public render() {
+ if (!this.props.show || isEmpty(this.props.suggestions)) {
+ return null;
+ }
+
+ const suggestions = this.props.suggestions.map((suggestion, index) => {
+ return (
+ (this.childNodes[index] = node)}
+ selected={index === this.props.index}
+ suggestion={suggestion}
+ onClick={this.props.onClick}
+ onMouseEnter={() => this.props.onMouseEnter(index)}
+ ariaId={'suggestion-' + index}
+ key={`${suggestion.type} - ${suggestion.text}`}
+ />
+ );
+ });
+
+ return (
+
+ );
+ }
+
+ public componentDidUpdate(prevProps: Props) {
+ if (prevProps.index !== this.props.index) {
+ this.scrollIntoView();
+ }
+ }
+
+ private scrollIntoView = () => {
+ if (this.props.index === null) {
+ return;
+ }
+ const parent = this.parentNode;
+ const child = this.childNodes[this.props.index];
+
+ if (this.props.index == null || !parent || !child) {
+ return;
+ }
+
+ const scrollTop = Math.max(
+ Math.min(parent.scrollTop, child.offsetTop),
+ child.offsetTop + child.offsetHeight - parent.offsetHeight
+ );
+
+ parent.scrollTop = scrollTop;
+ };
+
+ private handleScroll = () => {
+ if (!this.props.loadMore || !this.parentNode) {
+ return;
+ }
+
+ const position = this.parentNode.scrollTop + this.parentNode.offsetHeight;
+ const height = this.parentNode.scrollHeight;
+ const remaining = height - position;
+ const margin = 50;
+
+ if (!height || !position) {
+ return;
+ }
+ if (remaining <= margin) {
+ this.props.loadMore();
+ }
+ };
+}
diff --git a/src/ui/public/query_bar/directive/__tests__/query_bar.js b/src/ui/public/query_bar/directive/__tests__/query_bar.js
deleted file mode 100644
index 66f2b0c0207a0..0000000000000
--- a/src/ui/public/query_bar/directive/__tests__/query_bar.js
+++ /dev/null
@@ -1,110 +0,0 @@
-/*
- * 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 angular from 'angular';
-import sinon from 'sinon';
-import expect from 'expect.js';
-import ngMock from 'ng_mock';
-import { expectDeepEqual } from '../../../../../test_utils/expect_deep_equal.js';
-
-let $parentScope;
-let $elem;
-
-const markup = ``;
-const cleanup = [];
-
-function init(query, name) {
- ngMock.module('kibana');
-
- ngMock.inject(function ($injector, $controller, $rootScope, $compile) {
- $parentScope = $rootScope;
-
- $parentScope.submitHandler = sinon.stub();
- $parentScope.name = name;
- $parentScope.query = query;
- $elem = angular.element(markup);
- angular.element('body').append($elem);
- cleanup.push(() => $elem.remove());
-
- $compile($elem)($parentScope);
- $elem.scope().$digest();
- });
-}
-
-
-describe('queryBar directive', function () {
- afterEach(() => {
- cleanup.forEach(fn => fn());
- cleanup.length = 0;
- });
-
- describe('query string input', function () {
-
- it('should reflect the query passed into the directive', function () {
- init({ query: 'foo', language: 'lucene' }, 'discover');
- const queryInput = $elem.find('.kuiLocalSearchInput');
- expect(queryInput.val()).to.be('foo');
- });
-
- it('changes to the input text should not modify the parent scope\'s query', function () {
- init({ query: 'foo', language: 'lucene' }, 'discover');
- const queryInput = $elem.find('.kuiLocalSearchInput');
- queryInput.val('bar').trigger('input');
-
- expect($elem.isolateScope().queryBar.localQuery.query).to.be('bar');
- expect($parentScope.query.query).to.be('foo');
- });
-
- it('should not call onSubmit until the form is submitted', function () {
- init({ query: 'foo', language: 'lucene' }, 'discover');
- const queryInput = $elem.find('.kuiLocalSearchInput');
- queryInput.val('bar').trigger('input');
- expect($parentScope.submitHandler.notCalled).to.be(true);
-
- const submitButton = $elem.find('.kuiLocalSearchButton');
- submitButton.click();
- expect($parentScope.submitHandler.called).to.be(true);
- });
-
- it('should call onSubmit with the current input text when the form is submitted', function () {
- init({ query: 'foo', language: 'lucene' }, 'discover');
- const queryInput = $elem.find('.kuiLocalSearchInput');
- queryInput.val('bar').trigger('input');
- const submitButton = $elem.find('.kuiLocalSearchButton');
- submitButton.click();
- expectDeepEqual($parentScope.submitHandler.getCall(0).args[0], { query: 'bar', language: 'lucene' });
- });
-
- });
-
- describe('typeahead key', function () {
-
- it('should use a unique typeahead key for each appName/language combo', function () {
- init({ query: 'foo', language: 'lucene' }, 'discover');
- expect($elem.isolateScope().queryBar.persistedLog.name).to.be('typeahead:discover-lucene');
-
- $parentScope.query = { query: 'foo', language: 'kuery' };
- $parentScope.$digest();
- expect($elem.isolateScope().queryBar.persistedLog.name).to.be('typeahead:discover-kuery');
- });
-
- });
-
-
-});
diff --git a/src/ui/public/query_bar/directive/index.js b/src/ui/public/query_bar/directive/index.js
new file mode 100644
index 0000000000000..ed00cfd272a35
--- /dev/null
+++ b/src/ui/public/query_bar/directive/index.js
@@ -0,0 +1,37 @@
+/*
+ * 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 'ngreact';
+import { uiModules } from '../../modules';
+import { QueryBar } from '../components';
+
+const app = uiModules.get('app/kibana', ['react']);
+
+app.directive('queryBar', (reactDirective, localStorage) => {
+ return reactDirective(
+ QueryBar,
+ undefined,
+ {},
+ {
+ store: localStorage,
+ }
+ );
+});
diff --git a/src/ui/public/query_bar/directive/query_bar.html b/src/ui/public/query_bar/directive/query_bar.html
deleted file mode 100644
index e8205825ff53a..0000000000000
--- a/src/ui/public/query_bar/directive/query_bar.html
+++ /dev/null
@@ -1,83 +0,0 @@
-
diff --git a/src/ui/public/query_bar/directive/query_bar.js b/src/ui/public/query_bar/directive/query_bar.js
deleted file mode 100644
index 7179c1294afe6..0000000000000
--- a/src/ui/public/query_bar/directive/query_bar.js
+++ /dev/null
@@ -1,156 +0,0 @@
-/*
- * 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 { compact } from 'lodash';
-import { uiModules } from '../../modules';
-import { callAfterBindingsWorkaround } from '../../compat';
-import template from './query_bar.html';
-import suggestionTemplate from './suggestion.html';
-import { getAutocompleteProvider } from '../../autocomplete_providers';
-import './suggestion.less';
-import '../../directives/match_pairs';
-import './query_popover';
-import { getFromLegacyIndexPattern } from '../../index_patterns/static_utils';
-
-const module = uiModules.get('kibana');
-
-module.directive('queryBar', function () {
-
- return {
- restrict: 'E',
- template: template,
- scope: {
- query: '=',
- appName: '=?',
- onSubmit: '&',
- disableAutoFocus: '=',
- indexPatterns: '='
- },
- controllerAs: 'queryBar',
- bindToController: true,
-
- controller: callAfterBindingsWorkaround(function ($scope, $element, $http, $timeout, config, PersistedLog, indexPatterns, debounce) {
- this.appName = this.appName || 'global';
- this.focusedTypeaheadItemID = '';
-
- this.getIndexPatterns = () => {
- if (compact(this.indexPatterns).length) return Promise.resolve(this.indexPatterns);
- return Promise.all([indexPatterns.getDefault()]);
- };
-
- this.submit = () => {
- if (this.localQuery.query) {
- this.persistedLog.add(this.localQuery.query);
- }
- this.onSubmit({ $query: this.localQuery });
- this.suggestions = [];
- };
-
- this.selectLanguage = (language) => {
- this.localQuery.language = language;
- this.localQuery.query = '';
- this.submit();
- };
-
- this.suggestionTemplate = suggestionTemplate;
-
- this.handleKeyDown = (event) => {
- if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
- this.updateSuggestions();
- }
- };
-
- this.updateSuggestions = debounce(async () => {
- const suggestions = await this.getSuggestions();
- if (!this._isScopeDestroyed) {
- $scope.$apply(() => this.suggestions = suggestions);
- }
- }, 100);
-
- this.getSuggestions = async () => {
- const { localQuery: { query, language } } = this;
- const recentSearchSuggestions = this.getRecentSearchSuggestions(query);
-
- const autocompleteProvider = getAutocompleteProvider(language);
- if (!autocompleteProvider) return recentSearchSuggestions;
-
- const legacyIndexPatterns = await this.getIndexPatterns();
- const indexPatterns = getFromLegacyIndexPattern(legacyIndexPatterns);
- const getAutocompleteSuggestions = autocompleteProvider({ config, indexPatterns });
-
- const { selectionStart, selectionEnd } = $element.find('input')[0];
- const suggestions = await getAutocompleteSuggestions({ query, selectionStart, selectionEnd });
- return [...suggestions, ...recentSearchSuggestions];
- };
-
- // TODO: Figure out a better way to set selection
- this.onSuggestionSelect = ({ type, text, start, end }) => {
- const { query } = this.localQuery;
- const inputEl = $element.find('input')[0];
- const { selectionStart, selectionEnd } = inputEl;
- const value = query.substr(0, selectionStart) + query.substr(selectionEnd);
-
- this.localQuery.query = inputEl.value = value.substr(0, start) + text + value.substr(end);
- inputEl.setSelectionRange(start + text.length, start + text.length);
-
- if (type === 'recentSearch') {
- this.submit();
- } else {
- this.updateSuggestions();
- }
- };
-
- this.getRecentSearchSuggestions = (query) => {
- if (!this.persistedLog) return [];
- const recentSearches = this.persistedLog.get();
- const matchingRecentSearches = recentSearches.filter(search => search.includes(query));
- return matchingRecentSearches.map(recentSearch => {
- const text = recentSearch;
- const start = 0;
- const end = query.length;
- return { type: 'recentSearch', text, start, end };
- });
- };
-
- $scope.$watch('queryBar.localQuery.language', (language) => {
- if (!language) return;
- this.persistedLog = new PersistedLog(`typeahead:${this.appName}-${language}`, {
- maxLength: config.get('history:limit'),
- filterDuplicates: true
- });
- this.updateSuggestions();
- });
-
- $scope.$watch('queryBar.query', (newQuery) => {
- this.localQuery = {
- ...newQuery
- };
- }, true);
-
- $scope.$watch('queryBar.indexPatterns', () => {
- this.updateSuggestions();
- });
-
- $scope.$on('$destroy', () => {
- this.updateSuggestions.cancel();
- this._isScopeDestroyed = true;
- });
- })
- };
-});
diff --git a/src/ui/public/query_bar/directive/query_popover.js b/src/ui/public/query_bar/directive/query_popover.js
deleted file mode 100644
index 14be6f41b3c16..0000000000000
--- a/src/ui/public/query_bar/directive/query_popover.js
+++ /dev/null
@@ -1,163 +0,0 @@
-/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-import React from 'react';
-import ReactDOM from 'react-dom';
-import { uiModules } from '../../modules';
-import { documentationLinks } from '../../documentation_links/documentation_links';
-import { kfetch } from 'ui/kfetch';
-import {
- EuiPopover,
- EuiButtonEmpty,
- EuiForm,
- EuiFormRow,
- EuiSwitch,
- EuiLink,
- EuiText,
- EuiSpacer,
- EuiHorizontalRule,
- EuiPopoverTitle,
-} from '@elastic/eui';
-
-const luceneQuerySyntaxDocs = documentationLinks.query.luceneQuerySyntax;
-const kueryQuerySyntaxDocs = documentationLinks.query.kueryQuerySyntax;
-
-const module = uiModules.get('app/kibana', ['react']);
-module.directive('queryPopover', function (localStorage) {
-
- return {
- restrict: 'E',
- scope: {
- language: '<',
- onSelectLanguage: '&',
- },
- link: function ($scope, $element) {
- $scope.isPopoverOpen = false;
-
- function togglePopover() {
- $scope.$evalAsync(() => {
- $scope.isPopoverOpen = !$scope.isPopoverOpen;
- });
- }
-
- function closePopover() {
- $scope.$evalAsync(() => {
- $scope.isPopoverOpen = false;
- });
- }
-
- function onSwitchChange() {
- const newLanguage = $scope.language === 'lucene' ? 'kuery' : 'lucene';
-
- // 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: newLanguage === 'kuery' }),
- });
-
- $scope.$evalAsync(() => {
- localStorage.set('kibana.userQueryLanguage', newLanguage);
- $scope.onSelectLanguage({ $language: newLanguage });
- });
- }
-
- function render() {
- const button = (
-
- Options
-
- );
-
- const popover = (
-
- Syntax options
-
-
-
- Our experimental autocomplete and simple syntax features can help you create your queries. Just start
- typing and you’ll see matches related to your data.
-
- See docs {(
-
- here
-
- )}.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Not ready yet? Find our lucene docs {(
-
- here
-
- )}.
-