mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
Euify and Reactify Query Bar Component (#23704)
Implements query bar portion of https://elastic.github.io/eui/#/layout/header. Filter bar will come in another PR. Fixes #14086 Re-implements our query bar component in React using some EUI components. Existing typeahead and suggestion styles were copied over 1:1 for now after talking with Dave about it. In this PR I focused on reaching feature parity with the existing query bar. Some additional work would be needed before we could move this into EUI as a generic component that could be consumed by other plugins. Still needs some new tests and I suspect some old tests will need to be updated, but other than that this PR is functionally complete and ready for reviews.
This commit is contained in:
parent
836b1a16d2
commit
b99c516700
59 changed files with 2700 additions and 1416 deletions
|
@ -28,10 +28,9 @@
|
|||
<query-bar
|
||||
query="model.query"
|
||||
app-name="'dashboard'"
|
||||
on-submit="updateQueryAndFetch($query)"
|
||||
on-submit="updateQueryAndFetch"
|
||||
index-patterns="indexPatterns"
|
||||
>
|
||||
</query-bar>
|
||||
></query-bar>
|
||||
</div>
|
||||
</div>
|
||||
</kbn-top-nav>
|
||||
|
|
|
@ -63,7 +63,6 @@ const app = uiModules.get('app/dashboard', [
|
|||
'react',
|
||||
'kibana/courier',
|
||||
'kibana/config',
|
||||
'kibana/typeahead',
|
||||
]);
|
||||
|
||||
app.directive('dashboardViewportProvider', function (reactDirective) {
|
||||
|
|
|
@ -26,11 +26,10 @@
|
|||
<div data-transclude-slot="bottomRow" class="fullWidth">
|
||||
<query-bar
|
||||
query="state.query"
|
||||
on-submit="updateQueryAndFetch"
|
||||
app-name="'discover'"
|
||||
on-submit="updateQueryAndFetch($query)"
|
||||
index-patterns="[indexPattern]"
|
||||
>
|
||||
</query-bar>
|
||||
></query-bar>
|
||||
</div>
|
||||
</div>
|
||||
</kbn-top-nav>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
@import 'ui/public/styles/styling_constants';
|
||||
|
||||
@import 'ui/public/query_bar/index';
|
||||
|
||||
// Context styles
|
||||
@import './context/index';
|
||||
|
||||
|
|
|
@ -40,11 +40,10 @@
|
|||
<query-bar
|
||||
query="state.query"
|
||||
app-name="'visualize'"
|
||||
on-submit="updateQueryAndFetch($query)"
|
||||
on-submit="updateQueryAndFetch"
|
||||
disable-auto-focus="true"
|
||||
index-patterns="[indexPattern]"
|
||||
>
|
||||
</query-bar>
|
||||
></query-bar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
11
src/ui/public/autocomplete_providers/index.d.ts
vendored
11
src/ui/public/autocomplete_providers/index.d.ts
vendored
|
@ -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<AutocompleteSuggestion[]>;
|
||||
|
||||
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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 = '<input ng-model="mockModel" parse-query input-focus type="text">';
|
||||
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', {});
|
||||
});
|
||||
|
||||
|
|
|
@ -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`,
|
||||
},
|
||||
};
|
29
src/ui/public/index_patterns/static_utils/index.d.ts
vendored
Normal file
29
src/ui/public/index_patterns/static_utils/index.d.ts
vendored
Normal file
|
@ -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[];
|
|
@ -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);
|
||||
|
|
27
src/ui/public/metadata.d.ts
vendored
Normal file
27
src/ui/public/metadata.d.ts
vendored
Normal file
|
@ -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 };
|
23
src/ui/public/parse_query/index.ts
Normal file
23
src/ui/public/parse_query/index.ts
Normal file
|
@ -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';
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
|
@ -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',
|
||||
|
|
26
src/ui/public/persisted_log/directive.js
Normal file
26
src/ui/public/persisted_log/directive.js
Normal file
|
@ -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;
|
||||
});
|
20
src/ui/public/persisted_log/index.d.ts
vendored
Normal file
20
src/ui/public/persisted_log/index.d.ts
vendored
Normal file
|
@ -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';
|
|
@ -17,7 +17,7 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import './persisted_log';
|
||||
import './directive';
|
||||
|
||||
export { PersistedLog } from './persisted_log';
|
||||
export { recentlyAccessed } from './recently_accessed';
|
||||
|
|
|
@ -17,14 +17,28 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { PersistedLog } from './persisted_log';
|
||||
|
||||
import sinon from 'sinon';
|
||||
import expect from 'expect.js';
|
||||
import { PersistedLog } from './';
|
||||
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(),
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
4
src/ui/public/query_bar/_index.scss
Normal file
4
src/ui/public/query_bar/_index.scss
Normal file
|
@ -0,0 +1,4 @@
|
|||
// SASSTODO: Formalize this color in Kibana's styling constants
|
||||
$typeaheadConjunctionColor: #7800A6;
|
||||
|
||||
@import 'components/typeahead/index';
|
|
@ -0,0 +1,189 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`LanguageSwitcher should toggle off if language is lucene 1`] = `
|
||||
<EuiPopover
|
||||
anchorPosition="downRight"
|
||||
button={
|
||||
<EuiButtonEmpty
|
||||
color="primary"
|
||||
iconSide="left"
|
||||
onClick={[Function]}
|
||||
size="xs"
|
||||
type="button"
|
||||
>
|
||||
Options
|
||||
</EuiButtonEmpty>
|
||||
}
|
||||
closePopover={[Function]}
|
||||
hasArrow={true}
|
||||
id="popover"
|
||||
isOpen={false}
|
||||
ownFocus={true}
|
||||
panelPaddingSize="m"
|
||||
withTitle={true}
|
||||
>
|
||||
<EuiPopoverTitle>
|
||||
Syntax options
|
||||
</EuiPopoverTitle>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"width": "350px",
|
||||
}
|
||||
}
|
||||
>
|
||||
<EuiText
|
||||
grow={true}
|
||||
>
|
||||
<p>
|
||||
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
|
||||
|
||||
<EuiLink
|
||||
color="primary"
|
||||
href="https://www.elastic.co/guide/en/kibana/foo/kuery-query.html"
|
||||
target="_blank"
|
||||
type="button"
|
||||
>
|
||||
here
|
||||
</EuiLink>
|
||||
.
|
||||
</p>
|
||||
</EuiText>
|
||||
<EuiSpacer
|
||||
size="m"
|
||||
/>
|
||||
<EuiForm>
|
||||
<EuiFormRow
|
||||
describedByIds={Array []}
|
||||
fullWidth={false}
|
||||
hasEmptyLabelSpace={false}
|
||||
>
|
||||
<EuiSwitch
|
||||
checked={false}
|
||||
data-test-subj="languageToggle"
|
||||
id="queryEnhancementOptIn"
|
||||
label="Turn on query features"
|
||||
name="popswitch"
|
||||
onChange={[Function]}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
<EuiHorizontalRule
|
||||
margin="s"
|
||||
size="full"
|
||||
/>
|
||||
<EuiText
|
||||
grow={true}
|
||||
size="xs"
|
||||
>
|
||||
<p>
|
||||
Not ready yet? Find our lucene docs
|
||||
|
||||
<EuiLink
|
||||
color="primary"
|
||||
href="https://www.elastic.co/guide/en/elasticsearch/reference/foo/query-dsl-query-string-query.html#query-string-syntax"
|
||||
target="_blank"
|
||||
type="button"
|
||||
>
|
||||
here
|
||||
</EuiLink>
|
||||
.
|
||||
</p>
|
||||
</EuiText>
|
||||
</div>
|
||||
</EuiPopover>
|
||||
`;
|
||||
|
||||
exports[`LanguageSwitcher should toggle on if language is kuery 1`] = `
|
||||
<EuiPopover
|
||||
anchorPosition="downRight"
|
||||
button={
|
||||
<EuiButtonEmpty
|
||||
color="primary"
|
||||
iconSide="left"
|
||||
onClick={[Function]}
|
||||
size="xs"
|
||||
type="button"
|
||||
>
|
||||
Options
|
||||
</EuiButtonEmpty>
|
||||
}
|
||||
closePopover={[Function]}
|
||||
hasArrow={true}
|
||||
id="popover"
|
||||
isOpen={false}
|
||||
ownFocus={true}
|
||||
panelPaddingSize="m"
|
||||
withTitle={true}
|
||||
>
|
||||
<EuiPopoverTitle>
|
||||
Syntax options
|
||||
</EuiPopoverTitle>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"width": "350px",
|
||||
}
|
||||
}
|
||||
>
|
||||
<EuiText
|
||||
grow={true}
|
||||
>
|
||||
<p>
|
||||
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
|
||||
|
||||
<EuiLink
|
||||
color="primary"
|
||||
href="https://www.elastic.co/guide/en/kibana/foo/kuery-query.html"
|
||||
target="_blank"
|
||||
type="button"
|
||||
>
|
||||
here
|
||||
</EuiLink>
|
||||
.
|
||||
</p>
|
||||
</EuiText>
|
||||
<EuiSpacer
|
||||
size="m"
|
||||
/>
|
||||
<EuiForm>
|
||||
<EuiFormRow
|
||||
describedByIds={Array []}
|
||||
fullWidth={false}
|
||||
hasEmptyLabelSpace={false}
|
||||
>
|
||||
<EuiSwitch
|
||||
checked={true}
|
||||
data-test-subj="languageToggle"
|
||||
id="queryEnhancementOptIn"
|
||||
label="Turn on query features"
|
||||
name="popswitch"
|
||||
onChange={[Function]}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
<EuiHorizontalRule
|
||||
margin="s"
|
||||
size="full"
|
||||
/>
|
||||
<EuiText
|
||||
grow={true}
|
||||
size="xs"
|
||||
>
|
||||
<p>
|
||||
Not ready yet? Find our lucene docs
|
||||
|
||||
<EuiLink
|
||||
color="primary"
|
||||
href="https://www.elastic.co/guide/en/elasticsearch/reference/foo/query-dsl-query-string-query.html#query-string-syntax"
|
||||
target="_blank"
|
||||
type="button"
|
||||
>
|
||||
here
|
||||
</EuiLink>
|
||||
.
|
||||
</p>
|
||||
</EuiText>
|
||||
</div>
|
||||
</EuiPopover>
|
||||
`;
|
|
@ -0,0 +1,217 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`QueryBar Should disable autoFocus on EuiFieldText when disableAutoFocus prop is true 1`] = `
|
||||
<EuiOutsideClickDetector
|
||||
onOutsideClick={[Function]}
|
||||
>
|
||||
<div
|
||||
aria-expanded={false}
|
||||
aria-haspopup="true"
|
||||
aria-owns="typeahead-items"
|
||||
role="combobox"
|
||||
style={
|
||||
Object {
|
||||
"position": "relative",
|
||||
}
|
||||
}
|
||||
>
|
||||
<form
|
||||
name="queryBarForm"
|
||||
role="form"
|
||||
>
|
||||
<div
|
||||
className="kuiLocalSearch"
|
||||
role="search"
|
||||
>
|
||||
<div
|
||||
className="kuiLocalSearchAssistedInput"
|
||||
>
|
||||
<EuiFieldText
|
||||
aria-activedescendant=""
|
||||
aria-autocomplete="list"
|
||||
aria-controls="typeahead-items"
|
||||
aria-label="Search input"
|
||||
autoComplete="off"
|
||||
autoFocus={false}
|
||||
compressed={false}
|
||||
data-test-subj="queryInput"
|
||||
fullWidth={true}
|
||||
icon="console"
|
||||
inputRef={[Function]}
|
||||
isLoading={false}
|
||||
onChange={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onKeyUp={[Function]}
|
||||
placeholder="Search... (e.g. status:200 AND extension:PHP)"
|
||||
role="textbox"
|
||||
spellCheck={false}
|
||||
type="text"
|
||||
value="response:200"
|
||||
/>
|
||||
<div
|
||||
className="kuiLocalSearchAssistedInput__assistance"
|
||||
>
|
||||
<QueryLanguageSwitcher
|
||||
language="kuery"
|
||||
onSelectLanguage={[Function]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<SuggestionsComponent
|
||||
index={null}
|
||||
loadMore={[Function]}
|
||||
onClick={[Function]}
|
||||
onMouseEnter={[Function]}
|
||||
show={false}
|
||||
suggestions={Array []}
|
||||
/>
|
||||
</div>
|
||||
</EuiOutsideClickDetector>
|
||||
`;
|
||||
|
||||
exports[`QueryBar Should pass the query language to the language switcher 1`] = `
|
||||
<EuiOutsideClickDetector
|
||||
onOutsideClick={[Function]}
|
||||
>
|
||||
<div
|
||||
aria-expanded={false}
|
||||
aria-haspopup="true"
|
||||
aria-owns="typeahead-items"
|
||||
role="combobox"
|
||||
style={
|
||||
Object {
|
||||
"position": "relative",
|
||||
}
|
||||
}
|
||||
>
|
||||
<form
|
||||
name="queryBarForm"
|
||||
role="form"
|
||||
>
|
||||
<div
|
||||
className="kuiLocalSearch"
|
||||
role="search"
|
||||
>
|
||||
<div
|
||||
className="kuiLocalSearchAssistedInput"
|
||||
>
|
||||
<EuiFieldText
|
||||
aria-activedescendant=""
|
||||
aria-autocomplete="list"
|
||||
aria-controls="typeahead-items"
|
||||
aria-label="Search input"
|
||||
autoComplete="off"
|
||||
autoFocus={true}
|
||||
compressed={false}
|
||||
data-test-subj="queryInput"
|
||||
fullWidth={true}
|
||||
icon="console"
|
||||
inputRef={[Function]}
|
||||
isLoading={false}
|
||||
onChange={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onKeyUp={[Function]}
|
||||
placeholder="Search... (e.g. status:200 AND extension:PHP)"
|
||||
role="textbox"
|
||||
spellCheck={false}
|
||||
type="text"
|
||||
value="response:200"
|
||||
/>
|
||||
<div
|
||||
className="kuiLocalSearchAssistedInput__assistance"
|
||||
>
|
||||
<QueryLanguageSwitcher
|
||||
language="lucene"
|
||||
onSelectLanguage={[Function]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<SuggestionsComponent
|
||||
index={null}
|
||||
loadMore={[Function]}
|
||||
onClick={[Function]}
|
||||
onMouseEnter={[Function]}
|
||||
show={false}
|
||||
suggestions={Array []}
|
||||
/>
|
||||
</div>
|
||||
</EuiOutsideClickDetector>
|
||||
`;
|
||||
|
||||
exports[`QueryBar Should render the given query 1`] = `
|
||||
<EuiOutsideClickDetector
|
||||
onOutsideClick={[Function]}
|
||||
>
|
||||
<div
|
||||
aria-expanded={false}
|
||||
aria-haspopup="true"
|
||||
aria-owns="typeahead-items"
|
||||
role="combobox"
|
||||
style={
|
||||
Object {
|
||||
"position": "relative",
|
||||
}
|
||||
}
|
||||
>
|
||||
<form
|
||||
name="queryBarForm"
|
||||
role="form"
|
||||
>
|
||||
<div
|
||||
className="kuiLocalSearch"
|
||||
role="search"
|
||||
>
|
||||
<div
|
||||
className="kuiLocalSearchAssistedInput"
|
||||
>
|
||||
<EuiFieldText
|
||||
aria-activedescendant=""
|
||||
aria-autocomplete="list"
|
||||
aria-controls="typeahead-items"
|
||||
aria-label="Search input"
|
||||
autoComplete="off"
|
||||
autoFocus={true}
|
||||
compressed={false}
|
||||
data-test-subj="queryInput"
|
||||
fullWidth={true}
|
||||
icon="console"
|
||||
inputRef={[Function]}
|
||||
isLoading={false}
|
||||
onChange={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onKeyUp={[Function]}
|
||||
placeholder="Search... (e.g. status:200 AND extension:PHP)"
|
||||
role="textbox"
|
||||
spellCheck={false}
|
||||
type="text"
|
||||
value="response:200"
|
||||
/>
|
||||
<div
|
||||
className="kuiLocalSearchAssistedInput__assistance"
|
||||
>
|
||||
<QueryLanguageSwitcher
|
||||
language="kuery"
|
||||
onSelectLanguage={[Function]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<SuggestionsComponent
|
||||
index={null}
|
||||
loadMore={[Function]}
|
||||
onClick={[Function]}
|
||||
onMouseEnter={[Function]}
|
||||
show={false}
|
||||
suggestions={Array []}
|
||||
/>
|
||||
</div>
|
||||
</EuiOutsideClickDetector>
|
||||
`;
|
|
@ -17,4 +17,4 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import './directive/query_bar';
|
||||
export { QueryBar } from './query_bar';
|
|
@ -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(
|
||||
<QueryLanguageSwitcher
|
||||
language="lucene"
|
||||
onSelectLanguage={() => {
|
||||
return;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should toggle on if language is kuery', () => {
|
||||
const component = shallow(
|
||||
<QueryLanguageSwitcher
|
||||
language="kuery"
|
||||
onSelectLanguage={() => {
|
||||
return;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('call onSelectLanguage when the toggle is clicked', () => {
|
||||
const callback = jest.fn();
|
||||
const component = shallow(
|
||||
<QueryLanguageSwitcher language="kuery" onSelectLanguage={callback} />
|
||||
);
|
||||
component.find('[data-test-subj="languageToggle"]').simulate('change');
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
137
src/ui/public/query_bar/components/language_switcher.tsx
Normal file
137
src/ui/public/query_bar/components/language_switcher.tsx
Normal file
|
@ -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<any>;
|
||||
}
|
||||
|
||||
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<Props, State> {
|
||||
public state = {
|
||||
isPopoverOpen: false,
|
||||
};
|
||||
|
||||
public render() {
|
||||
const button = (
|
||||
<EuiButtonEmpty size="xs" onClick={this.togglePopover}>
|
||||
Options
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
id="popover"
|
||||
ownFocus
|
||||
anchorPosition="downRight"
|
||||
button={button}
|
||||
isOpen={this.state.isPopoverOpen}
|
||||
closePopover={this.closePopover}
|
||||
withTitle
|
||||
>
|
||||
<EuiPopoverTitle>Syntax options</EuiPopoverTitle>
|
||||
<div style={{ width: '350px' }}>
|
||||
<EuiText>
|
||||
<p>
|
||||
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{' '}
|
||||
{
|
||||
<EuiLink href={kueryQuerySyntaxDocs} target="_blank">
|
||||
here
|
||||
</EuiLink>
|
||||
}
|
||||
.
|
||||
</p>
|
||||
</EuiText>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiForm>
|
||||
<EuiFormRow>
|
||||
<EuiSwitch
|
||||
id="queryEnhancementOptIn"
|
||||
name="popswitch"
|
||||
label="Turn on query features"
|
||||
checked={this.props.language === 'kuery'}
|
||||
onChange={this.onSwitchChange}
|
||||
data-test-subj="languageToggle"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
|
||||
<EuiHorizontalRule margin="s" />
|
||||
|
||||
<EuiText size="xs">
|
||||
<p>
|
||||
Not ready yet? Find our lucene docs{' '}
|
||||
{
|
||||
<EuiLink href={luceneQuerySyntaxDocs} target="_blank">
|
||||
here
|
||||
</EuiLink>
|
||||
}
|
||||
.
|
||||
</p>
|
||||
</EuiText>
|
||||
</div>
|
||||
</EuiPopover>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
276
src/ui/public/query_bar/components/query_bar.test.tsx
Normal file
276
src/ui/public/query_bar/components/query_bar.test.tsx
Normal file
|
@ -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(
|
||||
<QueryBar
|
||||
query={kqlQuery}
|
||||
onSubmit={noop}
|
||||
appName={'discover'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should pass the query language to the language switcher', () => {
|
||||
const component = shallow(
|
||||
<QueryBar
|
||||
query={luceneQuery}
|
||||
onSubmit={noop}
|
||||
appName={'discover'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should disable autoFocus on EuiFieldText when disableAutoFocus prop is true', () => {
|
||||
const component = shallow(
|
||||
<QueryBar
|
||||
query={kqlQuery}
|
||||
onSubmit={noop}
|
||||
appName={'discover'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
disableAutoFocus={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should create a unique PersistedLog based on the appName and query language', () => {
|
||||
shallow(
|
||||
<QueryBar
|
||||
query={kqlQuery}
|
||||
onSubmit={noop}
|
||||
appName={'discover'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
disableAutoFocus={true}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<QueryBar
|
||||
query={kqlQuery}
|
||||
onSubmit={mockCallback}
|
||||
appName={'discover'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={mockStorage}
|
||||
disableAutoFocus={true}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<QueryBar
|
||||
query={kqlQuery}
|
||||
onSubmit={mockCallback}
|
||||
appName={'discover'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
disableAutoFocus={true}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<QueryBar
|
||||
query={kqlQuery}
|
||||
onSubmit={noop}
|
||||
appName={'discover'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
disableAutoFocus={true}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<QueryBar
|
||||
query={kqlQuery}
|
||||
onSubmit={noop}
|
||||
appName={'discover'}
|
||||
indexPatterns={[mockIndexPattern]}
|
||||
store={createMockStorage()}
|
||||
disableAutoFocus={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(mockGetAutocompleteProvider).toHaveBeenCalledWith('kuery');
|
||||
expect(mockGetAutocompleteSuggestions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
502
src/ui/public/query_bar/components/query_bar.tsx
Normal file
502
src/ui/public/query_bar/components/query_bar.tsx
Normal file
|
@ -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<any>;
|
||||
}
|
||||
|
||||
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<Props, State> {
|
||||
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<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
this.updateSuggestions();
|
||||
this.onInputChange(event.target.value);
|
||||
};
|
||||
|
||||
public onKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if ([KEY_CODES.LEFT, KEY_CODES.RIGHT, KEY_CODES.HOME, KEY_CODES.END].includes(event.keyCode)) {
|
||||
this.setState({ isSuggestionsVisible: true });
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
this.onInputChange(event.target.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
const { isSuggestionsVisible, index } = this.state;
|
||||
const preventDefault = event.preventDefault.bind(event);
|
||||
const { target, key, metaKey } = event;
|
||||
const { value, selectionStart, selectionEnd } = target;
|
||||
const updateQuery = (query: string, newSelectionStart: number, newSelectionEnd: number) => {
|
||||
this.setState(
|
||||
{
|
||||
query: {
|
||||
...this.state.query,
|
||||
query,
|
||||
},
|
||||
},
|
||||
() => {
|
||||
target.setSelectionRange(newSelectionStart, newSelectionEnd);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
switch (event.keyCode) {
|
||||
case KEY_CODES.DOWN:
|
||||
event.preventDefault();
|
||||
if (isSuggestionsVisible && index !== null) {
|
||||
this.incrementIndex(index);
|
||||
} else {
|
||||
this.setState({ isSuggestionsVisible: true, index: 0 });
|
||||
}
|
||||
break;
|
||||
case KEY_CODES.UP:
|
||||
event.preventDefault();
|
||||
if (isSuggestionsVisible && index !== null) {
|
||||
this.decrementIndex(index);
|
||||
}
|
||||
break;
|
||||
case KEY_CODES.ENTER:
|
||||
event.preventDefault();
|
||||
if (isSuggestionsVisible && index !== null && this.state.suggestions[index]) {
|
||||
this.selectSuggestion(this.state.suggestions[index]);
|
||||
} else {
|
||||
this.onSubmit(() => event.preventDefault());
|
||||
}
|
||||
break;
|
||||
case KEY_CODES.ESC:
|
||||
event.preventDefault();
|
||||
this.setState({ isSuggestionsVisible: false, index: null });
|
||||
break;
|
||||
case KEY_CODES.TAB:
|
||||
this.setState({ isSuggestionsVisible: false, index: null });
|
||||
break;
|
||||
default:
|
||||
if (selectionStart !== null && selectionEnd !== null) {
|
||||
matchPairs({
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
key,
|
||||
metaKey,
|
||||
updateQuery,
|
||||
preventDefault,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public onSubmit = (preventDefault?: () => void) => {
|
||||
if (preventDefault) {
|
||||
preventDefault();
|
||||
}
|
||||
|
||||
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 (
|
||||
<EuiOutsideClickDetector onOutsideClick={this.onOutsideClick}>
|
||||
{/* position:relative required on container so the suggestions appear under the query bar*/}
|
||||
<div
|
||||
style={{ position: 'relative' }}
|
||||
role="combobox"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={this.state.isSuggestionsVisible}
|
||||
aria-owns="typeahead-items"
|
||||
>
|
||||
<form role="form" name="queryBarForm">
|
||||
<div className="kuiLocalSearch" role="search">
|
||||
<div className="kuiLocalSearchAssistedInput">
|
||||
<EuiFieldText
|
||||
placeholder="Search... (e.g. status:200 AND extension:PHP)"
|
||||
value={this.state.query.query}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={this.onKeyUp}
|
||||
onChange={this.onChange}
|
||||
onClick={this.onClickInput}
|
||||
fullWidth
|
||||
autoFocus={!this.props.disableAutoFocus}
|
||||
inputRef={node => {
|
||||
if (node) {
|
||||
this.inputRef = node;
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
icon="console"
|
||||
aria-label="Search input"
|
||||
type="text"
|
||||
data-test-subj="queryInput"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="typeahead-items"
|
||||
aria-activedescendant={
|
||||
this.state.isSuggestionsVisible ? 'suggestion-' + this.state.index : ''
|
||||
}
|
||||
role="textbox"
|
||||
/>
|
||||
<div className="kuiLocalSearchAssistedInput__assistance">
|
||||
<QueryLanguageSwitcher
|
||||
language={this.state.query.language}
|
||||
onSelectLanguage={this.onSelectLanguage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<SuggestionsComponent
|
||||
show={this.state.isSuggestionsVisible}
|
||||
suggestions={this.state.suggestions.slice(0, this.state.suggestionLimit)}
|
||||
index={this.state.index}
|
||||
onClick={this.onClickSuggestion}
|
||||
onMouseEnter={this.onMouseEnterSuggestion}
|
||||
loadMore={this.increaseLimit}
|
||||
/>
|
||||
</div>
|
||||
</EuiOutsideClickDetector>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SuggestionComponent Should display the suggestion and use the provided ariaId 1`] = `
|
||||
<div
|
||||
className="typeahead-item"
|
||||
id="suggestion-1"
|
||||
onClick={[Function]}
|
||||
onMouseEnter={[Function]}
|
||||
role="option"
|
||||
>
|
||||
<div
|
||||
className="suggestionItem suggestionItem--value"
|
||||
>
|
||||
<div
|
||||
className="suggestionItem__type"
|
||||
>
|
||||
<EuiIcon
|
||||
size="m"
|
||||
type="kqlValue"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="suggestionItem__text"
|
||||
>
|
||||
as promised, not helpful
|
||||
</div>
|
||||
<div
|
||||
className="suggestionItem__description"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "This is not a helpful suggestion",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`SuggestionComponent Should make the element active if the selected prop is true 1`] = `
|
||||
<div
|
||||
className="typeahead-item active"
|
||||
id="suggestion-1"
|
||||
onClick={[Function]}
|
||||
onMouseEnter={[Function]}
|
||||
role="option"
|
||||
>
|
||||
<div
|
||||
className="suggestionItem suggestionItem--value"
|
||||
>
|
||||
<div
|
||||
className="suggestionItem__type"
|
||||
>
|
||||
<EuiIcon
|
||||
size="m"
|
||||
type="kqlValue"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="suggestionItem__text"
|
||||
>
|
||||
as promised, not helpful
|
||||
</div>
|
||||
<div
|
||||
className="suggestionItem__description"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "This is not a helpful suggestion",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,113 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SuggestionsComponent Passing the index should control which suggestion is selected 1`] = `
|
||||
<div
|
||||
className="reactSuggestionTypeahead"
|
||||
>
|
||||
<div
|
||||
className="typeahead"
|
||||
>
|
||||
<div
|
||||
className="typeahead-popover"
|
||||
>
|
||||
<div
|
||||
className="typeahead-items"
|
||||
id="typeahead-items"
|
||||
onScroll={[Function]}
|
||||
role="listbox"
|
||||
>
|
||||
<Component
|
||||
ariaId="suggestion-0"
|
||||
innerRef={[Function]}
|
||||
key="value - as promised, not helpful"
|
||||
onClick={[Function]}
|
||||
onMouseEnter={[Function]}
|
||||
selected={false}
|
||||
suggestion={
|
||||
Object {
|
||||
"description": "This is not a helpful suggestion",
|
||||
"end": 0,
|
||||
"start": 42,
|
||||
"text": "as promised, not helpful",
|
||||
"type": "value",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Component
|
||||
ariaId="suggestion-1"
|
||||
innerRef={[Function]}
|
||||
key="field - yep"
|
||||
onClick={[Function]}
|
||||
onMouseEnter={[Function]}
|
||||
selected={true}
|
||||
suggestion={
|
||||
Object {
|
||||
"description": "This is another unhelpful suggestion",
|
||||
"end": 0,
|
||||
"start": 42,
|
||||
"text": "yep",
|
||||
"type": "field",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`SuggestionsComponent Should display given suggestions if the show prop is true 1`] = `
|
||||
<div
|
||||
className="reactSuggestionTypeahead"
|
||||
>
|
||||
<div
|
||||
className="typeahead"
|
||||
>
|
||||
<div
|
||||
className="typeahead-popover"
|
||||
>
|
||||
<div
|
||||
className="typeahead-items"
|
||||
id="typeahead-items"
|
||||
onScroll={[Function]}
|
||||
role="listbox"
|
||||
>
|
||||
<Component
|
||||
ariaId="suggestion-0"
|
||||
innerRef={[Function]}
|
||||
key="value - as promised, not helpful"
|
||||
onClick={[Function]}
|
||||
onMouseEnter={[Function]}
|
||||
selected={true}
|
||||
suggestion={
|
||||
Object {
|
||||
"description": "This is not a helpful suggestion",
|
||||
"end": 0,
|
||||
"start": 42,
|
||||
"text": "as promised, not helpful",
|
||||
"type": "value",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Component
|
||||
ariaId="suggestion-1"
|
||||
innerRef={[Function]}
|
||||
key="field - yep"
|
||||
onClick={[Function]}
|
||||
onMouseEnter={[Function]}
|
||||
selected={false}
|
||||
suggestion={
|
||||
Object {
|
||||
"description": "This is another unhelpful suggestion",
|
||||
"end": 0,
|
||||
"start": 42,
|
||||
"text": "yep",
|
||||
"type": "field",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
1
src/ui/public/query_bar/components/typeahead/_index.scss
Normal file
1
src/ui/public/query_bar/components/typeahead/_index.scss
Normal file
|
@ -0,0 +1 @@
|
|||
@import 'suggestion';
|
195
src/ui/public/query_bar/components/typeahead/_suggestion.scss
Normal file
195
src/ui/public/query_bar/components/typeahead/_suggestion.scss
Normal file
|
@ -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;
|
||||
}
|
|
@ -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(
|
||||
<SuggestionComponent
|
||||
onClick={noop}
|
||||
onMouseEnter={noop}
|
||||
selected={false}
|
||||
suggestion={mockSuggestion}
|
||||
innerRef={noop}
|
||||
ariaId={'suggestion-1'}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should make the element active if the selected prop is true', () => {
|
||||
const component = shallow(
|
||||
<SuggestionComponent
|
||||
onClick={noop}
|
||||
onMouseEnter={noop}
|
||||
selected={true}
|
||||
suggestion={mockSuggestion}
|
||||
innerRef={noop}
|
||||
ariaId={'suggestion-1'}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SuggestionComponent
|
||||
onClick={noop}
|
||||
onMouseEnter={noop}
|
||||
selected={false}
|
||||
suggestion={mockSuggestion}
|
||||
innerRef={innerRefCallback}
|
||||
ariaId={'suggestion-1'}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
it('Should call onClick with the provided suggestion', () => {
|
||||
const mockHandler = jest.fn();
|
||||
|
||||
const component = shallow(
|
||||
<SuggestionComponent
|
||||
onClick={mockHandler}
|
||||
onMouseEnter={noop}
|
||||
selected={false}
|
||||
suggestion={mockSuggestion}
|
||||
innerRef={noop}
|
||||
ariaId={'suggestion-1'}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SuggestionComponent
|
||||
onClick={noop}
|
||||
onMouseEnter={mockHandler}
|
||||
selected={false}
|
||||
suggestion={mockSuggestion}
|
||||
innerRef={noop}
|
||||
ariaId={'suggestion-1'}
|
||||
/>
|
||||
);
|
||||
|
||||
component.simulate('mouseenter');
|
||||
expect(mockHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
|
@ -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> = props => {
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
'typeahead-item': true,
|
||||
active: props.selected,
|
||||
})}
|
||||
role="option"
|
||||
onClick={() => props.onClick(props.suggestion)}
|
||||
onMouseEnter={props.onMouseEnter}
|
||||
ref={props.innerRef}
|
||||
id={props.ariaId}
|
||||
>
|
||||
<div className={'suggestionItem suggestionItem--' + props.suggestion.type}>
|
||||
<div className="suggestionItem__type">
|
||||
<EuiIcon type={getEuiIconType(props.suggestion.type)} />
|
||||
</div>
|
||||
<div className="suggestionItem__text">{props.suggestion.text}</div>
|
||||
<div
|
||||
className="suggestionItem__description"
|
||||
// Description currently always comes from us and we escape any potential user input
|
||||
// at the time we're generating the description text
|
||||
// eslint-disable-next-line react/no-danger
|
||||
// @ts-ignore
|
||||
dangerouslySetInnerHTML={{ __html: props.suggestion.description }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -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(
|
||||
<SuggestionsComponent
|
||||
index={0}
|
||||
onClick={noop}
|
||||
onMouseEnter={noop}
|
||||
show={false}
|
||||
suggestions={mockSuggestions}
|
||||
loadMore={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component.isEmptyRender()).toBe(true);
|
||||
});
|
||||
|
||||
it('Should not display anything if there are no suggestions', () => {
|
||||
const component = shallow(
|
||||
<SuggestionsComponent
|
||||
index={0}
|
||||
onClick={noop}
|
||||
onMouseEnter={noop}
|
||||
show={true}
|
||||
suggestions={[]}
|
||||
loadMore={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component.isEmptyRender()).toBe(true);
|
||||
});
|
||||
|
||||
it('Should display given suggestions if the show prop is true', () => {
|
||||
const component = shallow(
|
||||
<SuggestionsComponent
|
||||
index={0}
|
||||
onClick={noop}
|
||||
onMouseEnter={noop}
|
||||
show={true}
|
||||
suggestions={mockSuggestions}
|
||||
loadMore={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component.isEmptyRender()).toBe(false);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Passing the index should control which suggestion is selected', () => {
|
||||
const component = shallow(
|
||||
<SuggestionsComponent
|
||||
index={1}
|
||||
onClick={noop}
|
||||
onMouseEnter={noop}
|
||||
show={true}
|
||||
suggestions={mockSuggestions}
|
||||
loadMore={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should call onClick with the selected suggestion when it is clicked', () => {
|
||||
const mockCallback = jest.fn();
|
||||
const component = mount(
|
||||
<SuggestionsComponent
|
||||
index={0}
|
||||
onClick={mockCallback}
|
||||
onMouseEnter={noop}
|
||||
show={true}
|
||||
suggestions={mockSuggestions}
|
||||
loadMore={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SuggestionsComponent
|
||||
index={0}
|
||||
onClick={noop}
|
||||
onMouseEnter={mockCallback}
|
||||
show={true}
|
||||
suggestions={mockSuggestions}
|
||||
loadMore={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
component
|
||||
.find(SuggestionComponent)
|
||||
.at(1)
|
||||
.simulate('mouseenter');
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
expect(mockCallback).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
|
@ -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<Props> {
|
||||
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 (
|
||||
<SuggestionComponent
|
||||
innerRef={node => (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 (
|
||||
<div className="reactSuggestionTypeahead">
|
||||
<div className="typeahead">
|
||||
<div className="typeahead-popover">
|
||||
<div
|
||||
id="typeahead-items"
|
||||
className="typeahead-items"
|
||||
role="listbox"
|
||||
ref={node => (this.parentNode = node)}
|
||||
onScroll={this.handleScroll}
|
||||
>
|
||||
{suggestions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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 = `<query-bar query="query" app-name="name" on-submit="submitHandler($query)"></query-bar>`;
|
||||
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');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
});
|
37
src/ui/public/query_bar/directive/index.js
Normal file
37
src/ui/public/query_bar/directive/index.js
Normal file
|
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
|
@ -1,83 +0,0 @@
|
|||
<form
|
||||
role="form"
|
||||
name="queryBarForm"
|
||||
ng-submit="queryBar.submit()"
|
||||
>
|
||||
<kbn-typeahead
|
||||
items="queryBar.suggestions"
|
||||
item-template="queryBar.suggestionTemplate"
|
||||
id="query-bar-suggestions"
|
||||
on-select="queryBar.onSuggestionSelect(item)"
|
||||
on-focus-change="queryBar.focusedTypeaheadItemID = $focusedItemID"
|
||||
class="suggestionTypeahead"
|
||||
>
|
||||
<div
|
||||
class="kuiLocalSearch"
|
||||
role="search"
|
||||
>
|
||||
|
||||
<div class="kuiLocalSearchAssistedInput">
|
||||
<!-- Lucene input -->
|
||||
<input
|
||||
ng-if="queryBar.localQuery.language === 'lucene'"
|
||||
parse-query
|
||||
input-focus
|
||||
disable-input-focus="queryBar.disableAutoFocus"
|
||||
kbn-typeahead-input
|
||||
ng-change="queryBar.updateSuggestions()"
|
||||
ng-model="queryBar.localQuery.query"
|
||||
placeholder="Search... (e.g. status:200 AND extension:PHP)"
|
||||
aria-label="Search input"
|
||||
type="text"
|
||||
class="kuiLocalSearchInput"
|
||||
ng-class="{'kuiLocalSearchInput-isInvalid': queryBarForm.$invalid}"
|
||||
data-test-subj="queryInput"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="query-bar-suggestions-typeahead-items"
|
||||
aria-activedescendant="{{queryBar.focusedTypeaheadItemID}}"
|
||||
role="textbox"
|
||||
>
|
||||
|
||||
<!-- kuery input -->
|
||||
<input
|
||||
ng-if="queryBar.localQuery.language === 'kuery'"
|
||||
ng-model="queryBar.localQuery.query"
|
||||
ng-trim="false"
|
||||
ng-keydown="queryBar.handleKeyDown($event)"
|
||||
ng-change="queryBar.updateSuggestions()"
|
||||
ng-click="queryBar.updateSuggestions()"
|
||||
input-focus
|
||||
match-pairs
|
||||
disable-input-focus="queryBar.disableAutoFocus"
|
||||
kbn-typeahead-input
|
||||
placeholder="Search... (e.g. status:200 AND extension:PHP)"
|
||||
aria-label="Search input"
|
||||
type="text"
|
||||
class="kuiLocalSearchInput"
|
||||
ng-class="{'kuiLocalSearchInput-isInvalid': queryBarForm.$invalid}"
|
||||
data-test-subj="queryInput"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="query-bar-suggestions-typeahead-items"
|
||||
aria-activedescendant="{{queryBar.focusedTypeaheadItemID}}"
|
||||
role="textbox"
|
||||
/>
|
||||
<div class="kuiLocalSearchAssistedInput__assistance">
|
||||
<query-popover
|
||||
language="queryBar.localQuery.language"
|
||||
on-select-language="queryBar.selectLanguage($language)"
|
||||
></query-popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Search"
|
||||
class="kuiLocalSearchButton"
|
||||
ng-disabled="queryBarForm.$invalid"
|
||||
data-test-subj="querySubmitButton"
|
||||
>
|
||||
<span class="fa fa-search" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</kbn-typeahead>
|
||||
</form>
|
|
@ -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;
|
||||
});
|
||||
})
|
||||
};
|
||||
});
|
|
@ -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 = (
|
||||
<EuiButtonEmpty
|
||||
size="xs"
|
||||
onClick={togglePopover}
|
||||
>
|
||||
Options
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
|
||||
const popover = (
|
||||
<EuiPopover
|
||||
id="popover"
|
||||
ownFocus
|
||||
anchorPosition="downRight"
|
||||
button={button}
|
||||
isOpen={$scope.isPopoverOpen}
|
||||
closePopover={closePopover}
|
||||
withTitle
|
||||
>
|
||||
<EuiPopoverTitle>Syntax options</EuiPopoverTitle>
|
||||
<div style={{ width: '350px' }}>
|
||||
<EuiText>
|
||||
<p>
|
||||
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 {(
|
||||
<EuiLink
|
||||
href={kueryQuerySyntaxDocs}
|
||||
target="_blank"
|
||||
>
|
||||
here
|
||||
</EuiLink>
|
||||
)}.
|
||||
</p>
|
||||
</EuiText>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiForm>
|
||||
<EuiFormRow>
|
||||
<EuiSwitch
|
||||
id="queryEnhancementOptIn"
|
||||
name="popswitch"
|
||||
label="Turn on query features"
|
||||
checked={$scope.language === 'kuery'}
|
||||
onChange={onSwitchChange}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
|
||||
<EuiHorizontalRule margin="s" />
|
||||
|
||||
<EuiText size="xs">
|
||||
<p>
|
||||
Not ready yet? Find our lucene docs {(
|
||||
<EuiLink
|
||||
href={luceneQuerySyntaxDocs}
|
||||
target="_blank"
|
||||
>
|
||||
here
|
||||
</EuiLink>
|
||||
)}.
|
||||
</p>
|
||||
</EuiText>
|
||||
</div>
|
||||
</EuiPopover>
|
||||
);
|
||||
|
||||
ReactDOM.render(popover, $element[0]);
|
||||
}
|
||||
|
||||
$scope.$watch('isPopoverOpen', render);
|
||||
$scope.$watch('language', render);
|
||||
}
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
<div class="suggestionItem suggestionItem--{{item.type}}">
|
||||
<div class="suggestionItem__type">
|
||||
<div ng-switch="item.type">
|
||||
<div ng-switch-when="field" aria-label="Field">
|
||||
<icon type="'kqlField'"></icon>
|
||||
</div>
|
||||
<div ng-switch-when="value" aria-label="Value">
|
||||
<icon type="'kqlValue'"></icon>
|
||||
</div>
|
||||
<div ng-switch-when="recentSearch" aria-label="Recent search">
|
||||
<icon type="'search'"></icon>
|
||||
</div>
|
||||
<div ng-switch-when="conjunction" aria-label="Conjunction">
|
||||
<icon type="'kqlSelector'"></icon>
|
||||
</div>
|
||||
<div ng-switch-when="operator" aria-label="Operator">
|
||||
<icon type="'kqlOperand'"></icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="suggestionItem__text">{{item.text}}</div>
|
||||
<div class="suggestionItem__description" ng-bind-html="item.description"></div>
|
||||
</div>
|
|
@ -1,155 +0,0 @@
|
|||
@import (reference) "~ui/styles/variables";
|
||||
|
||||
.suggestionItem {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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: 32px;
|
||||
height: 32px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&.suggestionItem--field {
|
||||
.suggestionItem__type {
|
||||
background-color: tint(@globalColorOrange, 90%);
|
||||
color: @globalColorOrange;
|
||||
}
|
||||
}
|
||||
|
||||
&.suggestionItem--value {
|
||||
.suggestionItem__type {
|
||||
background-color: tint(@globalColorTeal, 90%);
|
||||
color: @globalColorTeal;
|
||||
}
|
||||
|
||||
.suggestionItem__text {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&.suggestionItem--operator {
|
||||
.suggestionItem__type {
|
||||
background-color: tint(@globalColorBlue, 90%);
|
||||
color: @globalColorBlue;
|
||||
}
|
||||
}
|
||||
|
||||
&.suggestionItem--conjunction {
|
||||
.suggestionItem__type {
|
||||
background-color: tint(@globalColorPurple, 90%);
|
||||
color: @globalColorPurple;
|
||||
}
|
||||
}
|
||||
|
||||
&.suggestionItem--recentSearch {
|
||||
.suggestionItem__type {
|
||||
background-color: @globalColorLightGray;
|
||||
color: @globalColorMediumGray;
|
||||
}
|
||||
|
||||
.suggestionItem__text {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestionItem__text {
|
||||
flex-grow: 0; /* 2 */
|
||||
flex-basis: auto; /* 2 */
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
margin-right: 32px;
|
||||
width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 4px 8px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.suggestionItem__description {
|
||||
color: @globalColorDarkGray;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.suggestionItem__callout {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
background: @globalColorLightestGray;
|
||||
color: #000;
|
||||
padding: 0 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.suggestionTypeahead {
|
||||
.typeahead {
|
||||
.typeahead-items {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
|
||||
.typeahead-item {
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
line-height: normal;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: @globalColorLightestGray;
|
||||
|
||||
.suggestionItem__callout {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.suggestionItem__text {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.suggestionItem__type {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.suggestionItem--field {
|
||||
.suggestionItem__type {
|
||||
background-color: tint(@globalColorOrange, 80%);
|
||||
}
|
||||
}
|
||||
|
||||
.suggestionItem--value {
|
||||
.suggestionItem__type {
|
||||
background-color: tint(@globalColorTeal, 80%);
|
||||
}
|
||||
}
|
||||
|
||||
.suggestionItem--operator {
|
||||
.suggestionItem__type {
|
||||
background-color: tint(@globalColorBlue, 80%);
|
||||
}
|
||||
}
|
||||
|
||||
.suggestionItem--conjunction {
|
||||
.suggestionItem__type {
|
||||
background-color: tint(@globalColorPurple, 80%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,4 +17,4 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import './parse_query';
|
||||
export { QueryBar } from './components';
|
|
@ -17,11 +17,8 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { uiModules } from '../modules';
|
||||
const module = uiModules.get('kibana');
|
||||
|
||||
/**
|
||||
* This directively automatically handles matching pairs.
|
||||
* This helper automatically handles matching pairs.
|
||||
* Specifically, it does the following:
|
||||
*
|
||||
* 1. If the key is a closer, and the character in front of the cursor is the
|
||||
|
@ -37,69 +34,108 @@ const pairs = ['()', '[]', '{}', `''`, '""'];
|
|||
const openers = pairs.map(pair => pair[0]);
|
||||
const closers = pairs.map(pair => pair[1]);
|
||||
|
||||
module.directive('matchPairs', () => ({
|
||||
restrict: 'A',
|
||||
require: 'ngModel',
|
||||
link: function (scope, elem, attrs, ngModel) {
|
||||
elem.on('keydown', (e) => {
|
||||
const { target, key, metaKey } = e;
|
||||
const { value, selectionStart, selectionEnd } = target;
|
||||
interface MatchPairsOptions {
|
||||
value: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
key: string;
|
||||
metaKey: boolean;
|
||||
updateQuery: (query: string, selectionStart: number, selectionEnd: number) => void;
|
||||
preventDefault: () => void;
|
||||
}
|
||||
|
||||
if (shouldMoveCursorForward(key, value, selectionStart, selectionEnd)) {
|
||||
e.preventDefault();
|
||||
target.setSelectionRange(selectionStart + 1, selectionEnd + 1);
|
||||
} else if (shouldInsertMatchingCloser(key, value, selectionStart, selectionEnd)) {
|
||||
e.preventDefault();
|
||||
const newValue = value.substr(0, selectionStart) + key +
|
||||
value.substring(selectionStart, selectionEnd) + closers[openers.indexOf(key)] +
|
||||
value.substr(selectionEnd);
|
||||
target.value = newValue;
|
||||
target.setSelectionRange(selectionStart + 1, selectionEnd + 1);
|
||||
ngModel.$setViewValue(newValue);
|
||||
ngModel.$render();
|
||||
} else if (shouldRemovePair(key, metaKey, value, selectionStart, selectionEnd)) {
|
||||
e.preventDefault();
|
||||
const newValue = value.substr(0, selectionEnd - 1) + value.substr(selectionEnd + 1);
|
||||
target.value = newValue;
|
||||
target.setSelectionRange(selectionStart - 1, selectionEnd - 1);
|
||||
ngModel.$setViewValue(newValue);
|
||||
ngModel.$render();
|
||||
}
|
||||
});
|
||||
export function matchPairs({
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
key,
|
||||
metaKey,
|
||||
updateQuery,
|
||||
preventDefault,
|
||||
}: MatchPairsOptions) {
|
||||
if (shouldMoveCursorForward(key, value, selectionStart, selectionEnd)) {
|
||||
preventDefault();
|
||||
updateQuery(value, selectionStart + 1, selectionEnd + 1);
|
||||
} else if (shouldInsertMatchingCloser(key, value, selectionStart, selectionEnd)) {
|
||||
preventDefault();
|
||||
const newValue =
|
||||
value.substr(0, selectionStart) +
|
||||
key +
|
||||
value.substring(selectionStart, selectionEnd) +
|
||||
closers[openers.indexOf(key)] +
|
||||
value.substr(selectionEnd);
|
||||
updateQuery(newValue, selectionStart + 1, selectionEnd + 1);
|
||||
} else if (shouldRemovePair(key, metaKey, value, selectionStart, selectionEnd)) {
|
||||
preventDefault();
|
||||
const newValue = value.substr(0, selectionEnd - 1) + value.substr(selectionEnd + 1);
|
||||
updateQuery(newValue, selectionStart - 1, selectionEnd - 1);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function shouldMoveCursorForward(key, value, selectionStart, selectionEnd) {
|
||||
if (!closers.includes(key)) return false;
|
||||
function shouldMoveCursorForward(
|
||||
key: string,
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number
|
||||
) {
|
||||
if (!closers.includes(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Never move selection forward for multi-character selections
|
||||
if (selectionStart !== selectionEnd) return false;
|
||||
if (selectionStart !== selectionEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Move selection forward if the key is the same as the closer in front of the selection
|
||||
return value.charAt(selectionEnd) === key;
|
||||
}
|
||||
|
||||
function shouldInsertMatchingCloser(key, value, selectionStart, selectionEnd) {
|
||||
if (!openers.includes(key)) return false;
|
||||
function shouldInsertMatchingCloser(
|
||||
key: string,
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number
|
||||
) {
|
||||
if (!openers.includes(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Always insert for multi-character selections
|
||||
if (selectionStart !== selectionEnd) return true;
|
||||
if (selectionStart !== selectionEnd) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const precedingCharacter = value.charAt(selectionStart - 1);
|
||||
const followingCharacter = value.charAt(selectionStart + 1);
|
||||
|
||||
// Don't insert if the preceding character is a backslash
|
||||
if (precedingCharacter === '\\') return false;
|
||||
if (precedingCharacter === '\\') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't insert if it's a quote and the either of the preceding/following characters is alphanumeric
|
||||
return !(['"', `'`].includes(key) && (isAlphanumeric(precedingCharacter) || isAlphanumeric(followingCharacter)));
|
||||
return !(
|
||||
['"', `'`].includes(key) &&
|
||||
(isAlphanumeric(precedingCharacter) || isAlphanumeric(followingCharacter))
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRemovePair(key, metaKey, value, selectionStart, selectionEnd) {
|
||||
if (key !== 'Backspace' || metaKey) return false;
|
||||
function shouldRemovePair(
|
||||
key: string,
|
||||
metaKey: boolean,
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number
|
||||
) {
|
||||
if (key !== 'Backspace' || metaKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Never remove for multi-character selections
|
||||
if (selectionStart !== selectionEnd) return false;
|
||||
if (selectionStart !== selectionEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove if the preceding/following characters are a pair
|
||||
return pairs.includes(value.substr(selectionEnd - 1, 2));
|
|
@ -17,19 +17,16 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { uiModules } from '../modules';
|
||||
const typeahead = uiModules.get('kibana/typeahead');
|
||||
|
||||
typeahead.directive('kbnTypeaheadItem', function ($compile) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
item: '=',
|
||||
template: '='
|
||||
},
|
||||
link: (scope, element) => {
|
||||
element.html(scope.template || '{{item}}');
|
||||
$compile(element.contents())(scope);
|
||||
}
|
||||
import { uiModules } from '../modules';
|
||||
import { Storage } from './storage';
|
||||
|
||||
const createService = function (type) {
|
||||
return function ($window) {
|
||||
return new Storage($window[type]);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
uiModules.get('kibana/storage')
|
||||
.service('localStorage', createService('localStorage'))
|
||||
.service('sessionStorage', createService('sessionStorage'));
|
|
@ -17,4 +17,6 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import './directive';
|
||||
|
||||
export { Storage } from './storage';
|
|
@ -17,44 +17,50 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { uiModules } from '../modules';
|
||||
import angular from 'angular';
|
||||
|
||||
export function Storage(store) {
|
||||
const self = this;
|
||||
self.store = store;
|
||||
// This is really silly, but I wasn't prepared to rename the kibana Storage class everywhere it is used
|
||||
// and this is the only way I could figure out how to use the type definition for a built in object
|
||||
// in a file that creates a type with the same name as that built in object.
|
||||
import { WebStorage } from './web_storage';
|
||||
|
||||
export class Storage {
|
||||
public store: WebStorage;
|
||||
|
||||
constructor(store: WebStorage) {
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
public get = (key: string) => {
|
||||
if (!this.store) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storageItem = this.store.getItem(key);
|
||||
if (storageItem === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
self.get = function (key) {
|
||||
try {
|
||||
return JSON.parse(self.store.getItem(key));
|
||||
} catch (e) {
|
||||
return JSON.parse(storageItem);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
self.set = function (key, value) {
|
||||
public set = (key: string, value: any) => {
|
||||
try {
|
||||
return self.store.setItem(key, angular.toJson(value));
|
||||
return this.store.setItem(key, angular.toJson(value));
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
self.remove = function (key) {
|
||||
return self.store.removeItem(key);
|
||||
public remove = (key: string) => {
|
||||
return this.store.removeItem(key);
|
||||
};
|
||||
|
||||
self.clear = function () {
|
||||
return self.store.clear();
|
||||
public clear = () => {
|
||||
return this.store.clear();
|
||||
};
|
||||
}
|
||||
|
||||
const createService = function (type) {
|
||||
return function ($window) {
|
||||
return new Storage($window[type]);
|
||||
};
|
||||
};
|
||||
|
||||
uiModules.get('kibana/storage')
|
||||
.service('localStorage', createService('localStorage'))
|
||||
.service('sessionStorage', createService('sessionStorage'));
|
|
@ -17,4 +17,4 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import './typeahead';
|
||||
export type WebStorage = Storage;
|
|
@ -1,218 +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 expect from 'expect.js';
|
||||
import sinon from 'sinon';
|
||||
import ngMock from 'ng_mock';
|
||||
import '../typeahead';
|
||||
import { comboBoxKeyCodes } from '@elastic/eui';
|
||||
const { UP, DOWN, ENTER, TAB, ESCAPE } = comboBoxKeyCodes;
|
||||
|
||||
describe('Typeahead directive', function () {
|
||||
let $compile;
|
||||
let scope;
|
||||
let element;
|
||||
|
||||
beforeEach(ngMock.module('kibana'));
|
||||
|
||||
beforeEach(ngMock.inject(function (_$compile_, _$rootScope_) {
|
||||
$compile = _$compile_;
|
||||
scope = _$rootScope_.$new();
|
||||
const html = `
|
||||
<kbn-typeahead
|
||||
items="items"
|
||||
item-template="itemTemplate"
|
||||
on-select="onSelect(item)"
|
||||
>
|
||||
<input
|
||||
kbn-typeahead-input
|
||||
ng-model="value"
|
||||
type="text"
|
||||
/>
|
||||
</kbn-typeahead>
|
||||
`;
|
||||
element = $compile(html)(scope);
|
||||
scope.items = ['foo', 'bar', 'baz'];
|
||||
scope.onSelect = sinon.spy();
|
||||
scope.$digest();
|
||||
}));
|
||||
|
||||
describe('before focus', function () {
|
||||
it('should be hidden', function () {
|
||||
scope.$digest();
|
||||
expect(element.find('.typeahead-popover').hasClass('ng-hide')).to.be(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('after focus', function () {
|
||||
beforeEach(function () {
|
||||
element.find('input').triggerHandler('focus');
|
||||
scope.$digest();
|
||||
});
|
||||
|
||||
it('should still be hidden', function () {
|
||||
expect(element.find('.typeahead-popover').hasClass('ng-hide')).to.be(true);
|
||||
});
|
||||
|
||||
it('should show when a key is pressed unless there are no items', function () {
|
||||
element.find('.typeahead').triggerHandler({
|
||||
type: 'keypress',
|
||||
keyCode: 'A'.charCodeAt(0)
|
||||
});
|
||||
|
||||
scope.$digest();
|
||||
|
||||
expect(element.find('.typeahead-popover').hasClass('ng-hide')).to.be(false);
|
||||
|
||||
scope.items = [];
|
||||
scope.$digest();
|
||||
|
||||
expect(element.find('.typeahead-popover').hasClass('ng-hide')).to.be(true);
|
||||
});
|
||||
|
||||
it('should hide when escape is pressed', function () {
|
||||
element.find('.typeahead').triggerHandler({
|
||||
type: 'keydown',
|
||||
keyCode: ESCAPE
|
||||
});
|
||||
|
||||
scope.$digest();
|
||||
|
||||
expect(element.find('.typeahead-popover').hasClass('ng-hide')).to.be(true);
|
||||
});
|
||||
|
||||
it('should select the next option on arrow down', function () {
|
||||
let expectedActiveIndex = -1;
|
||||
for (let i = 0; i < scope.items.length + 1; i++) {
|
||||
expectedActiveIndex++;
|
||||
if (expectedActiveIndex > scope.items.length - 1) expectedActiveIndex = 0;
|
||||
|
||||
element.find('.typeahead').triggerHandler({
|
||||
type: 'keydown',
|
||||
keyCode: DOWN
|
||||
});
|
||||
|
||||
scope.$digest();
|
||||
|
||||
expect(element.find('.typeahead-item.active').length).to.be(1);
|
||||
expect(element.find('.typeahead-item').eq(expectedActiveIndex).hasClass('active')).to.be(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should select the previous option on arrow up', function () {
|
||||
let expectedActiveIndex = scope.items.length;
|
||||
for (let i = 0; i < scope.items.length + 1; i++) {
|
||||
expectedActiveIndex--;
|
||||
if (expectedActiveIndex < 0) expectedActiveIndex = scope.items.length - 1;
|
||||
|
||||
element.find('.typeahead').triggerHandler({
|
||||
type: 'keydown',
|
||||
keyCode: UP
|
||||
});
|
||||
|
||||
scope.$digest();
|
||||
|
||||
expect(element.find('.typeahead-item.active').length).to.be(1);
|
||||
expect(element.find('.typeahead-item').eq(expectedActiveIndex).hasClass('active')).to.be(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fire the onSelect handler with the selected item on enter', function () {
|
||||
const typeaheadEl = element.find('.typeahead');
|
||||
|
||||
typeaheadEl.triggerHandler({
|
||||
type: 'keydown',
|
||||
keyCode: DOWN
|
||||
});
|
||||
|
||||
typeaheadEl.triggerHandler({
|
||||
type: 'keydown',
|
||||
keyCode: ENTER
|
||||
});
|
||||
|
||||
scope.$digest();
|
||||
|
||||
sinon.assert.calledOnce(scope.onSelect);
|
||||
sinon.assert.calledWith(scope.onSelect, scope.items[0]);
|
||||
});
|
||||
|
||||
it('should fire the onSelect handler with the selected item on tab', function () {
|
||||
const typeaheadEl = element.find('.typeahead');
|
||||
|
||||
typeaheadEl.triggerHandler({
|
||||
type: 'keydown',
|
||||
keyCode: DOWN
|
||||
});
|
||||
|
||||
typeaheadEl.triggerHandler({
|
||||
type: 'keydown',
|
||||
keyCode: TAB
|
||||
});
|
||||
|
||||
scope.$digest();
|
||||
|
||||
sinon.assert.calledOnce(scope.onSelect);
|
||||
sinon.assert.calledWith(scope.onSelect, scope.items[0]);
|
||||
});
|
||||
|
||||
it('should select the option on hover', function () {
|
||||
const hoverIndex = 0;
|
||||
element.find('.typeahead-item').eq(hoverIndex).triggerHandler('mouseenter');
|
||||
|
||||
scope.$digest();
|
||||
|
||||
expect(element.find('.typeahead-item.active').length).to.be(1);
|
||||
expect(element.find('.typeahead-item').eq(hoverIndex).hasClass('active')).to.be(true);
|
||||
});
|
||||
|
||||
it('should fire the onSelect handler with the selected item on click', function () {
|
||||
const clickIndex = 1;
|
||||
const clickEl = element.find('.typeahead-item').eq(clickIndex);
|
||||
clickEl.triggerHandler('mouseenter');
|
||||
clickEl.triggerHandler('click');
|
||||
|
||||
scope.$digest();
|
||||
|
||||
sinon.assert.calledOnce(scope.onSelect);
|
||||
sinon.assert.calledWith(scope.onSelect, scope.items[clickIndex]);
|
||||
});
|
||||
|
||||
it('should update the list when the items change', function () {
|
||||
scope.items = ['qux'];
|
||||
scope.$digest();
|
||||
expect(expect(element.find('.typeahead-item').length).to.be(scope.items.length));
|
||||
});
|
||||
|
||||
it('should default to showing the item itself in the list', function () {
|
||||
scope.items.forEach((item, i) => {
|
||||
expect(element.find('kbn-typeahead-item').eq(i).html()).to.be(item);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use a custom template if specified to show the item in the list', function () {
|
||||
scope.items = [{
|
||||
label: 'foo',
|
||||
value: 1
|
||||
}];
|
||||
scope.itemTemplate = '<div class="label">{{item.label}}</div>';
|
||||
scope.$digest();
|
||||
expect(element.find('.label').html()).to.be(scope.items[0].label);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,40 +0,0 @@
|
|||
<div
|
||||
class="typeahead"
|
||||
ng-keydown="typeahead.onKeyDown($event)"
|
||||
ng-keypress="typeahead.onKeyPress($event)"
|
||||
role="combobox"
|
||||
aria-haspopup="true"
|
||||
aria-owns="{{typeahead.elementID}}-typeahead-items"
|
||||
aria-expanded="{{typeahead.isVisible() ? true : false}}"
|
||||
>
|
||||
<ng-transclude></ng-transclude>
|
||||
<div
|
||||
class="typeahead-popover"
|
||||
ng-show="typeahead.isVisible()"
|
||||
ng-mouseenter="typeahead.onMouseEnter()"
|
||||
ng-mouseleave="typeahead.onMouseLeave()"
|
||||
>
|
||||
<div
|
||||
class="typeahead-items"
|
||||
kbn-scroll-bottom="typeahead.increaseLimit()"
|
||||
role="listbox"
|
||||
id="{{typeahead.elementID}}-typeahead-items"
|
||||
>
|
||||
<div
|
||||
class="typeahead-item"
|
||||
ng-repeat="item in typeahead.items | limitTo: typeahead.limit"
|
||||
ng-class="{active: $index === typeahead.selectedIndex}"
|
||||
ng-click="typeahead.onItemClick()"
|
||||
ng-mouseenter="typeahead.selectedIndex = $index"
|
||||
role="option"
|
||||
id="{{typeahead.elementID}}-typeahead-item-{{$index}}"
|
||||
>
|
||||
<kbn-typeahead-item
|
||||
item="item"
|
||||
template="typeahead.itemTemplate"
|
||||
>
|
||||
</kbn-typeahead-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,148 +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 template from './typeahead.html';
|
||||
import { uiModules } from '../modules';
|
||||
import { comboBoxKeyCodes } from '@elastic/eui';
|
||||
import '../directives/scroll_bottom';
|
||||
import './typeahead.less';
|
||||
import './typeahead_input';
|
||||
import './typeahead_item';
|
||||
|
||||
const { UP, DOWN, ENTER, TAB, ESCAPE } = comboBoxKeyCodes;
|
||||
const typeahead = uiModules.get('kibana/typeahead');
|
||||
|
||||
typeahead.directive('kbnTypeahead', function () {
|
||||
return {
|
||||
template,
|
||||
transclude: true,
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
items: '=',
|
||||
itemTemplate: '=',
|
||||
onSelect: '&',
|
||||
onFocusChange: '&'
|
||||
},
|
||||
bindToController: true,
|
||||
controllerAs: 'typeahead',
|
||||
controller: function ($scope, $element) {
|
||||
this.isHidden = true;
|
||||
this.selectedIndex = null;
|
||||
this.elementID = $element.attr('id');
|
||||
|
||||
this.submit = () => {
|
||||
const item = this.items[this.selectedIndex];
|
||||
this.onSelect({ item });
|
||||
this.selectedIndex = null;
|
||||
};
|
||||
|
||||
this.selectPrevious = () => {
|
||||
if (this.selectedIndex !== null && this.selectedIndex > 0) {
|
||||
this.selectedIndex--;
|
||||
} else {
|
||||
this.selectedIndex = this.items.length - 1;
|
||||
}
|
||||
this.scrollSelectedIntoView();
|
||||
};
|
||||
|
||||
this.selectNext = () => {
|
||||
if (this.selectedIndex !== null && this.selectedIndex < this.items.length - 1) {
|
||||
this.selectedIndex++;
|
||||
} else {
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
this.scrollSelectedIntoView();
|
||||
};
|
||||
|
||||
this.scrollSelectedIntoView = () => {
|
||||
const parent = $element.find('.typeahead-items')[0];
|
||||
const child = $element.find('.typeahead-item').eq(this.selectedIndex)[0];
|
||||
parent.scrollTop = Math.min(parent.scrollTop, child.offsetTop);
|
||||
parent.scrollTop = Math.max(parent.scrollTop, child.offsetTop + child.offsetHeight - parent.offsetHeight);
|
||||
};
|
||||
|
||||
this.isVisible = () => {
|
||||
// Blur fires before click. If we only checked isFocused, then click events would never fire.
|
||||
const isFocusedOrMousedOver = this.isFocused || this.isMousedOver;
|
||||
return !this.isHidden && this.items && this.items.length > 0 && isFocusedOrMousedOver;
|
||||
};
|
||||
|
||||
this.resetLimit = () => {
|
||||
this.limit = 50;
|
||||
};
|
||||
|
||||
this.increaseLimit = () => {
|
||||
this.limit += 50;
|
||||
};
|
||||
|
||||
this.onKeyDown = (event) => {
|
||||
const { keyCode } = event;
|
||||
|
||||
if (keyCode === ESCAPE) this.isHidden = true;
|
||||
|
||||
if ([TAB, ENTER].includes(keyCode) && !this.hidden && this.selectedIndex !== null) {
|
||||
event.preventDefault();
|
||||
this.submit();
|
||||
} else if (keyCode === UP && this.items.length > 0) {
|
||||
event.preventDefault();
|
||||
this.isHidden = false;
|
||||
this.selectPrevious();
|
||||
} else if (keyCode === DOWN && this.items.length > 0) {
|
||||
event.preventDefault();
|
||||
this.isHidden = false;
|
||||
this.selectNext();
|
||||
} else {
|
||||
this.selectedIndex = null;
|
||||
}
|
||||
};
|
||||
|
||||
this.onKeyPress = () => {
|
||||
this.isHidden = false;
|
||||
};
|
||||
|
||||
this.onItemClick = () => {
|
||||
this.submit();
|
||||
$scope.$broadcast('focus');
|
||||
$scope.$evalAsync(() => this.isHidden = false);
|
||||
};
|
||||
|
||||
this.onFocus = () => {
|
||||
this.isFocused = true;
|
||||
this.isHidden = true;
|
||||
this.resetLimit();
|
||||
};
|
||||
|
||||
this.onBlur = () => {
|
||||
this.isFocused = false;
|
||||
};
|
||||
|
||||
this.onMouseEnter = () => {
|
||||
this.isMousedOver = true;
|
||||
};
|
||||
|
||||
this.onMouseLeave = () => {
|
||||
this.isMousedOver = false;
|
||||
};
|
||||
|
||||
$scope.$watch('typeahead.selectedIndex', (newIndex) => {
|
||||
this.onFocusChange({ $focusedItemID: newIndex !== null ? `${this.elementID}-typeahead-item-${newIndex}` : '' });
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
|
@ -1,55 +0,0 @@
|
|||
@import (reference) "~ui/styles/variables";
|
||||
@import (reference) "~ui/styles/mixins";
|
||||
|
||||
.typeahead {
|
||||
position: relative;
|
||||
|
||||
.typeahead-popover {
|
||||
border: 1px solid;
|
||||
border-color: @typeahead-item-border;
|
||||
color: @typeahead-item-color;
|
||||
background-color: @typeahead-item-bg;
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
z-index: @zindex-typeahead;
|
||||
box-shadow: 0px 4px 8px rgba(0,0,0,.1);
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
|
||||
.typeahead-items {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.typeahead-item {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.typeahead-item:last-child {
|
||||
border-bottom: 0px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
.typeahead-item:first-child {
|
||||
border-bottom: 0px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.typeahead-item.active {
|
||||
background-color: @globalColorLightestGray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inline-form .typeahead.visible .input-group {
|
||||
> :first-child {
|
||||
.border-bottom-radius(0);
|
||||
}
|
||||
> :last-child {
|
||||
.border-bottom-radius(0);
|
||||
}
|
||||
}
|
|
@ -1,49 +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 { uiModules } from '../modules';
|
||||
const typeahead = uiModules.get('kibana/typeahead');
|
||||
|
||||
typeahead.directive('kbnTypeaheadInput', function () {
|
||||
return {
|
||||
restrict: 'A',
|
||||
require: '^kbnTypeahead',
|
||||
link: function ($scope, $el, $attr, typeahead) {
|
||||
// disable browser autocomplete
|
||||
$el.attr('autocomplete', 'off');
|
||||
|
||||
$el.on('focus', () => {
|
||||
// For some reason if we don't have the $evalAsync in here, then blur events happen outside the angular lifecycle
|
||||
$scope.$evalAsync(() => typeahead.onFocus());
|
||||
});
|
||||
|
||||
$el.on('blur', () => {
|
||||
$scope.$evalAsync(() => typeahead.onBlur());
|
||||
});
|
||||
|
||||
$scope.$on('focus', () => {
|
||||
$el.focus();
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
$el.off();
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
|
@ -80,7 +80,7 @@ export default function ({ getService, getPageObjects }) {
|
|||
await dashboardExpect.vegaTextsDoNotExist(['5,000']);
|
||||
};
|
||||
|
||||
describe('dashboard embeddable rendering', function describeIndexTests() {
|
||||
describe.skip('dashboard embeddable rendering', function describeIndexTests() {
|
||||
before(async () => {
|
||||
await PageObjects.dashboard.clickNewDashboard();
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ export default function ({ getService, getPageObjects }) {
|
|||
const retry = getService('retry');
|
||||
const esArchiver = getService('esArchiver');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
const queryBar = getService('queryBar');
|
||||
const PageObjects = getPageObjects(['common', 'header', 'discover', 'visualize']);
|
||||
|
||||
describe('discover tab', function describeIndexTests() {
|
||||
|
@ -45,7 +46,8 @@ export default function ({ getService, getPageObjects }) {
|
|||
describe('field data', function () {
|
||||
it('search php should show the correct hit count', async function () {
|
||||
const expectedHitCount = '445';
|
||||
await PageObjects.discover.query('php');
|
||||
await queryBar.setQuery('php');
|
||||
await queryBar.submitQuery();
|
||||
|
||||
await retry.try(async function tryingForTime() {
|
||||
const hitCount = await PageObjects.discover.getHitCount();
|
||||
|
@ -63,7 +65,8 @@ export default function ({ getService, getPageObjects }) {
|
|||
|
||||
it('search type:apache should show the correct hit count', async function () {
|
||||
const expectedHitCount = '11,156';
|
||||
await PageObjects.discover.query('type:apache');
|
||||
await queryBar.setQuery('type:apache');
|
||||
await queryBar.submitQuery();
|
||||
await retry.try(async function tryingForTime() {
|
||||
const hitCount = await PageObjects.discover.getHitCount();
|
||||
expect(hitCount).to.be(expectedHitCount);
|
||||
|
@ -164,8 +167,9 @@ export default function ({ getService, getPageObjects }) {
|
|||
});
|
||||
|
||||
it('a bad syntax query should show an error message', async function () {
|
||||
const expectedError = 'Discover: Failed to parse query [xxx(yyy]';
|
||||
await PageObjects.discover.query('xxx(yyy');
|
||||
const expectedError = 'Discover: Failed to parse query [xxx(yyy))]';
|
||||
await queryBar.setQuery('xxx(yyy))');
|
||||
await queryBar.submitQuery();
|
||||
const toastMessage = await PageObjects.header.getToastMessage();
|
||||
expect(toastMessage).to.contain(expectedError);
|
||||
await PageObjects.header.clickToastOK();
|
||||
|
|
|
@ -23,6 +23,7 @@ export default function ({ getService, getPageObjects }) {
|
|||
const esArchiver = getService('esArchiver');
|
||||
const log = getService('log');
|
||||
const retry = getService('retry');
|
||||
const queryBar = getService('queryBar');
|
||||
const PageObjects = getPageObjects([
|
||||
'common',
|
||||
'home',
|
||||
|
@ -62,7 +63,8 @@ export default function ({ getService, getPageObjects }) {
|
|||
describe('test large data', function () {
|
||||
it('search Newsletter should show the correct hit count', async function () {
|
||||
const expectedHitCount = '1';
|
||||
await PageObjects.discover.query('Newsletter');
|
||||
await queryBar.setQuery('Newsletter');
|
||||
await queryBar.submitQuery();
|
||||
await retry.try(async function tryingForTime() {
|
||||
const hitCount = await PageObjects.discover.getHitCount();
|
||||
expect(hitCount).to.be(expectedHitCount);
|
||||
|
|
|
@ -21,7 +21,7 @@ export function QueryBarProvider({ getService, getPageObjects }) {
|
|||
const testSubjects = getService('testSubjects');
|
||||
const retry = getService('retry');
|
||||
const log = getService('log');
|
||||
const PageObjects = getPageObjects(['header']);
|
||||
const PageObjects = getPageObjects(['header', 'common']);
|
||||
|
||||
class QueryBar {
|
||||
|
||||
|
@ -44,7 +44,8 @@ export function QueryBarProvider({ getService, getPageObjects }) {
|
|||
|
||||
async submitQuery() {
|
||||
log.debug('QueryBar.submitQuery');
|
||||
await testSubjects.click('querySubmitButton');
|
||||
await testSubjects.click('queryInput');
|
||||
await PageObjects.common.pressEnterKey();
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue