diff --git a/src/core_plugins/timelion/public/chain.peg b/src/core_plugins/timelion/public/chain.peg
index bba12430ebd2..2592b7ffa140 100644
--- a/src/core_plugins/timelion/public/chain.peg
+++ b/src/core_plugins/timelion/public/chain.peg
@@ -54,11 +54,21 @@ argument
currentArgs.push(arg);
return arg;
}
- / name:argument_name space? '=' {
+ / space? '=' space? value:arg_type? {
var exception = {
type: 'incompleteArgument',
currentArgs: currentArgs,
currentFunction: currentFunction,
+ location: simpleLocation(location()),
+ text: text()
+ }
+ error(JSON.stringify(exception));
+ }
+ / name:argument_name space? '=' {
+ var exception = {
+ type: 'incompleteArgumentValue',
+ currentArgs: currentArgs,
+ currentFunction: currentFunction,
name: name,
location: simpleLocation(location()),
text: text()
diff --git a/src/core_plugins/timelion/public/directives/__tests__/timelion_expression_input_helpers.js b/src/core_plugins/timelion/public/directives/__tests__/timelion_expression_input_helpers.js
index 4b5987e56046..7715abc99691 100644
--- a/src/core_plugins/timelion/public/directives/__tests__/timelion_expression_input_helpers.js
+++ b/src/core_plugins/timelion/public/directives/__tests__/timelion_expression_input_helpers.js
@@ -75,7 +75,79 @@ describe('Timelion expression suggestions', () => {
});
});
- describe('incompleteArgument', () => {
+ describe('no argument name provided', () => {
+ it('should return no argument suggestions when none provided by help', async () => {
+ const expression = '.otherFunc(=)';
+ const cursorPosition = 0;
+ const suggestions = await suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions);
+ expect(suggestions).to.eql({
+ 'list': [],
+ 'location': {
+ 'min': 11,
+ 'max': 12
+ },
+ 'type': 'arguments'
+ });
+ });
+
+ it('should return argument suggestions when provided by help', async () => {
+ const expression = '.myFunc2(=)';
+ const cursorPosition = 0;
+ const suggestions = await suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions);
+ expect(suggestions).to.eql({
+ 'list': myFunc2.args,
+ 'location': {
+ 'min': 9,
+ 'max': 10
+ },
+ 'type': 'arguments'
+ });
+ });
+
+ it('should return argument suggestions when argument value provided', async () => {
+ const expression = '.myFunc2(=whatArgumentAmI)';
+ const cursorPosition = 0;
+ const suggestions = await suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions);
+ expect(suggestions).to.eql({
+ 'list': myFunc2.args,
+ 'location': {
+ 'min': 9,
+ 'max': 25
+ },
+ 'type': 'arguments'
+ });
+ });
+
+ it('should not show first argument for chainable functions', async () => {
+ const expression = '.func1(=)';
+ const cursorPosition = 0;
+ const suggestions = await suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions);
+ expect(suggestions).to.eql({
+ 'list': [{ name: 'argA' }, { name: 'argAB', suggestions: [{ name: 'value1' }] }],
+ 'location': {
+ 'min': 7,
+ 'max': 8
+ },
+ 'type': 'arguments'
+ });
+ });
+
+ it('should not provide argument suggestions for argument that is all ready set in function def', async () => {
+ const expression = '.myFunc2(argAB=provided,=)';
+ const cursorPosition = 0;
+ const suggestions = await suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions);
+ expect(suggestions).to.eql({
+ 'list': [{ name: 'argA' }, { name: 'argABC' }],
+ 'location': {
+ 'min': 24,
+ 'max': 25
+ },
+ 'type': 'arguments'
+ });
+ });
+ });
+
+ describe('no argument value provided', () => {
it('should return no argument value suggestions when not provided by help', async () => {
const expression = '.func1(argA=)';
const cursorPosition = 11;
diff --git a/src/core_plugins/timelion/public/directives/timelion_expression_input.html b/src/core_plugins/timelion/public/directives/timelion_expression_input.html
index e865db7b9ff5..3a06c37d136d 100644
--- a/src/core_plugins/timelion/public/directives/timelion_expression_input.html
+++ b/src/core_plugins/timelion/public/directives/timelion_expression_input.html
@@ -9,7 +9,6 @@
For some reasons it doesn't work without it (even though the default role of
the element is textbox anyway). -->
= location.min && cursorPosition <= location.max;
}
+function getArgumentsHelp(functionHelp, functionArgs = []) {
+ if (!functionHelp) {
+ return [];
+ }
+
+ // Do not provide 'inputSeries' as argument suggestion for chainable functions
+ const argsHelp = functionHelp.chainable ? functionHelp.args.slice(1) : functionHelp.args.slice(0);
+
+ // ignore arguments that are already provided in function declaration
+ const functionArgNames = functionArgs.map((arg) => {
+ return arg.name;
+ });
+ return argsHelp.filter(arg => {
+ return !functionArgNames.includes(arg.name);
+ });
+}
+
async function extractSuggestionsFromParsedResult(result, cursorPosition, functionList, argValueSuggestions) {
const activeFunc = result.functions.find((func) => {
return cursorPosition >= func.location.min && cursorPosition < func.location.max;
@@ -123,17 +140,8 @@ async function extractSuggestionsFromParsedResult(result, cursorPosition, functi
}
// return argument suggestions
- const providedArguments = activeFunc.arguments.map((arg) => {
- return arg.name;
- });
- // Do not provide 'inputSeries' as argument suggestion for chainable functions
- const args = functionHelp.chainable ? functionHelp.args.slice(1) : functionHelp.args.slice(0);
- const argumentSuggestions = args.filter(arg => {
- // ignore arguments that are all ready provided in function declaration
- if (providedArguments.includes(arg.name)) {
- return false;
- }
-
+ const argsHelp = getArgumentsHelp(functionHelp, activeFunc.arguments);
+ const argumentSuggestions = argsHelp.filter(arg => {
if (_.get(activeArg, 'type') === 'namedArg') {
return _.startsWith(arg.name, activeArg.name);
} else if (activeArg) {
@@ -177,6 +185,18 @@ export async function suggest(expression, functionList, Parser, cursorPosition,
return { list, location: message.location, type: SUGGESTION_TYPE.FUNCTIONS };
}
case 'incompleteArgument': {
+ const {
+ currentFunction: functionName,
+ currentArgs: functionArgs,
+ } = message;
+ const functionHelp = functionList.find(func => func.name === functionName);
+ return {
+ list: getArgumentsHelp(functionHelp, functionArgs),
+ location: message.location,
+ type: SUGGESTION_TYPE.ARGUMENTS
+ };
+ }
+ case 'incompleteArgumentValue': {
const {
name: argName,
currentFunction: functionName,
diff --git a/test/functional/apps/timelion/_expression_typeahead.js b/test/functional/apps/timelion/_expression_typeahead.js
new file mode 100644
index 000000000000..12b73a0f838b
--- /dev/null
+++ b/test/functional/apps/timelion/_expression_typeahead.js
@@ -0,0 +1,96 @@
+import expect from 'expect.js';
+
+export default function ({ getPageObjects }) {
+ const PageObjects = getPageObjects(['common', 'timelion', 'header', 'settings']);
+
+ describe('expression typeahead', () => {
+ before(async () => {
+ const fromTime = '2015-09-19 06:31:44.000';
+ const toTime = '2015-09-23 18:31:44.000';
+
+ await PageObjects.timelion.initTests();
+ await PageObjects.header.setAbsoluteRange(fromTime, toTime);
+
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ });
+
+ it('should display function suggestions filtered by function name', async () => {
+ await PageObjects.timelion.setExpression('.e');
+ const suggestions = await PageObjects.timelion.getSuggestionItemsText();
+ expect(suggestions.length).to.eql(2);
+ expect(suggestions[0].includes('.elasticsearch()')).to.eql(true);
+ expect(suggestions[1].includes('.es()')).to.eql(true);
+ });
+
+ it('should show argument suggestions when function suggestion is selected', async () => {
+ await PageObjects.timelion.setExpression('.es');
+ await PageObjects.timelion.clickSuggestion();
+ const suggestions = await PageObjects.timelion.getSuggestionItemsText();
+ expect(suggestions.length).to.eql(9);
+ expect(suggestions[0].includes('fit=')).to.eql(true);
+ });
+
+ it('should show argument value suggestions when argument is selected', async () => {
+ await PageObjects.timelion.setExpression('.legend');
+ await PageObjects.timelion.clickSuggestion();
+ const argumentSuggestions = await PageObjects.timelion.getSuggestionItemsText();
+ expect(argumentSuggestions.length).to.eql(4);
+ expect(argumentSuggestions[1].includes('position=')).to.eql(true);
+ await PageObjects.timelion.clickSuggestion(1);
+ const valueSuggestions = await PageObjects.timelion.getSuggestionItemsText();
+ expect(valueSuggestions.length).to.eql(5);
+ expect(valueSuggestions[0].includes('disable legend')).to.eql(true);
+ expect(valueSuggestions[1].includes('place legend in north east corner')).to.eql(true);
+ });
+
+ describe('dynamic suggestions for argument values', () => {
+ describe('.es()', () => {
+ before(async () => {
+ await PageObjects.timelion.setExpression('.es');
+ await PageObjects.timelion.clickSuggestion();
+ });
+
+ it('should show index pattern suggestions for index argument', async () => {
+ await PageObjects.timelion.updateExpression('index');
+ await PageObjects.timelion.clickSuggestion();
+ await PageObjects.common.sleep(500);
+ const suggestions = await PageObjects.timelion.getSuggestionItemsText();
+ expect(suggestions.length).to.eql(1);
+ expect(suggestions[0].includes('logstash-*')).to.eql(true);
+ await PageObjects.timelion.clickSuggestion();
+ });
+
+ it('should show field suggestions for timefield argument when index pattern set', async () => {
+ await PageObjects.timelion.updateExpression(',timefield');
+ await PageObjects.timelion.clickSuggestion();
+ await PageObjects.common.sleep(500);
+ const suggestions = await PageObjects.timelion.getSuggestionItemsText();
+ expect(suggestions.length).to.eql(4);
+ expect(suggestions[0].includes('@timestamp')).to.eql(true);
+ await PageObjects.timelion.clickSuggestion();
+ });
+
+ it('should show field suggestions for split argument when index pattern set', async () => {
+ await PageObjects.timelion.updateExpression(',split');
+ await PageObjects.timelion.clickSuggestion();
+ await PageObjects.common.sleep(500);
+ const suggestions = await PageObjects.timelion.getSuggestionItemsText();
+ expect(suggestions.length).to.eql(52);
+ expect(suggestions[0].includes('@message.raw')).to.eql(true);
+ await PageObjects.timelion.clickSuggestion(10);
+ });
+
+ it('should show field suggestions for metric argument when index pattern set', async () => {
+ await PageObjects.timelion.updateExpression(',metric');
+ await PageObjects.timelion.clickSuggestion();
+ await PageObjects.common.sleep(500);
+ await PageObjects.timelion.updateExpression('avg:');
+ await PageObjects.timelion.clickSuggestion();
+ const suggestions = await PageObjects.timelion.getSuggestionItemsText();
+ expect(suggestions.length).to.eql(2);
+ expect(suggestions[0].includes('avg:bytes')).to.eql(true);
+ });
+ });
+ });
+ });
+}
diff --git a/test/functional/apps/timelion/index.js b/test/functional/apps/timelion/index.js
new file mode 100644
index 000000000000..c05427ddef19
--- /dev/null
+++ b/test/functional/apps/timelion/index.js
@@ -0,0 +1,17 @@
+export default function ({ getService, loadTestFile }) {
+ const remote = getService('remote');
+ const log = getService('log');
+ const esArchiver = getService('esArchiver');
+ const kibanaServer = getService('kibanaServer');
+
+ describe('timelion app', function () {
+ before(async function () {
+ log.debug('Starting timelion before method');
+ remote.setWindowSize(1280, 800);
+ await esArchiver.loadIfNeeded('logstash_functional');
+ await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'UTC', 'defaultIndex': 'logstash-*' });
+ });
+
+ loadTestFile(require.resolve('./_expression_typeahead'));
+ });
+}
diff --git a/test/functional/config.js b/test/functional/config.js
index 23c0423d3241..39de985719e5 100644
--- a/test/functional/config.js
+++ b/test/functional/config.js
@@ -11,6 +11,7 @@ import {
MonitoringPageProvider,
PointSeriesPageProvider,
VisualBuilderPageProvider,
+ TimelionPageProvider,
} from './page_objects';
import {
@@ -36,6 +37,7 @@ export default async function ({ readConfigFile }) {
require.resolve('./apps/discover'),
require.resolve('./apps/management'),
require.resolve('./apps/status_page'),
+ require.resolve('./apps/timelion'),
require.resolve('./apps/visualize'),
require.resolve('./apps/xpack'),
],
@@ -52,6 +54,7 @@ export default async function ({ readConfigFile }) {
monitoring: MonitoringPageProvider,
pointSeries: PointSeriesPageProvider,
visualBuilder: VisualBuilderPageProvider,
+ timelion: TimelionPageProvider
},
services: {
es: commonConfig.get('services.es'),
@@ -93,6 +96,9 @@ export default async function ({ readConfigFile }) {
pathname: '/app/kibana',
hash: '/management',
},
+ timelion: {
+ pathname: '/app/timelion',
+ },
console: {
pathname: '/app/kibana',
hash: '/dev_tools/console',
diff --git a/test/functional/fixtures/es_archiver/timelion/data.json.gz b/test/functional/fixtures/es_archiver/timelion/data.json.gz
new file mode 100644
index 000000000000..6bf3a8ed214c
Binary files /dev/null and b/test/functional/fixtures/es_archiver/timelion/data.json.gz differ
diff --git a/test/functional/fixtures/es_archiver/timelion/mappings.json b/test/functional/fixtures/es_archiver/timelion/mappings.json
new file mode 100644
index 000000000000..9f9d9bf53aa4
--- /dev/null
+++ b/test/functional/fixtures/es_archiver/timelion/mappings.json
@@ -0,0 +1,246 @@
+{
+ "type": "index",
+ "value": {
+ "index": ".kibana",
+ "settings": {
+ "index": {
+ "number_of_shards": "1",
+ "number_of_replicas": "1"
+ }
+ },
+ "mappings": {
+ "doc": {
+ "properties": {
+ "type": {
+ "type": "keyword"
+ },
+ "index-pattern": {
+ "dynamic": "strict",
+ "properties": {
+ "fieldFormatMap": {
+ "type": "text"
+ },
+ "fields": {
+ "type": "text"
+ },
+ "intervalName": {
+ "type": "keyword"
+ },
+ "notExpandable": {
+ "type": "boolean"
+ },
+ "sourceFilters": {
+ "type": "text"
+ },
+ "timeFieldName": {
+ "type": "keyword"
+ },
+ "title": {
+ "type": "text"
+ }
+ }
+ },
+ "search": {
+ "dynamic": "strict",
+ "properties": {
+ "columns": {
+ "type": "keyword"
+ },
+ "description": {
+ "type": "text"
+ },
+ "hits": {
+ "type": "integer"
+ },
+ "kibanaSavedObjectMeta": {
+ "properties": {
+ "searchSourceJSON": {
+ "type": "text"
+ }
+ }
+ },
+ "sort": {
+ "type": "keyword"
+ },
+ "title": {
+ "type": "text"
+ },
+ "version": {
+ "type": "integer"
+ }
+ }
+ },
+ "timelion-sheet": {
+ "dynamic": "strict",
+ "properties": {
+ "description": {
+ "type": "text"
+ },
+ "hits": {
+ "type": "integer"
+ },
+ "kibanaSavedObjectMeta": {
+ "properties": {
+ "searchSourceJSON": {
+ "type": "text"
+ }
+ }
+ },
+ "timelion_chart_height": {
+ "type": "integer"
+ },
+ "timelion_columns": {
+ "type": "integer"
+ },
+ "timelion_interval": {
+ "type": "keyword"
+ },
+ "timelion_other_interval": {
+ "type": "keyword"
+ },
+ "timelion_rows": {
+ "type": "integer"
+ },
+ "timelion_sheet": {
+ "type": "text"
+ },
+ "title": {
+ "type": "text"
+ },
+ "version": {
+ "type": "integer"
+ }
+ }
+ },
+ "server": {
+ "dynamic": "strict",
+ "properties": {
+ "uuid": {
+ "type": "keyword"
+ }
+ }
+ },
+ "config": {
+ "dynamic": "true",
+ "properties": {
+ "buildNum": {
+ "type": "keyword"
+ }
+ }
+ },
+ "dashboard": {
+ "dynamic": "strict",
+ "properties": {
+ "description": {
+ "type": "text"
+ },
+ "hits": {
+ "type": "integer"
+ },
+ "kibanaSavedObjectMeta": {
+ "properties": {
+ "searchSourceJSON": {
+ "type": "text"
+ }
+ }
+ },
+ "optionsJSON": {
+ "type": "text"
+ },
+ "panelsJSON": {
+ "type": "text"
+ },
+ "refreshInterval": {
+ "properties": {
+ "display": {
+ "type": "keyword"
+ },
+ "pause": {
+ "type": "boolean"
+ },
+ "section": {
+ "type": "integer"
+ },
+ "value": {
+ "type": "integer"
+ }
+ }
+ },
+ "timeFrom": {
+ "type": "keyword"
+ },
+ "timeRestore": {
+ "type": "boolean"
+ },
+ "timeTo": {
+ "type": "keyword"
+ },
+ "title": {
+ "type": "text"
+ },
+ "uiStateJSON": {
+ "type": "text"
+ },
+ "version": {
+ "type": "integer"
+ }
+ }
+ },
+ "visualization": {
+ "dynamic": "strict",
+ "properties": {
+ "description": {
+ "type": "text"
+ },
+ "kibanaSavedObjectMeta": {
+ "properties": {
+ "searchSourceJSON": {
+ "type": "text"
+ }
+ }
+ },
+ "savedSearchId": {
+ "type": "keyword"
+ },
+ "title": {
+ "type": "text"
+ },
+ "uiStateJSON": {
+ "type": "text"
+ },
+ "version": {
+ "type": "integer"
+ },
+ "visState": {
+ "type": "text"
+ }
+ }
+ },
+ "url": {
+ "dynamic": "strict",
+ "properties": {
+ "accessCount": {
+ "type": "long"
+ },
+ "accessDate": {
+ "type": "date"
+ },
+ "createDate": {
+ "type": "date"
+ },
+ "url": {
+ "type": "text",
+ "fields": {
+ "keyword": {
+ "type": "keyword",
+ "ignore_above": 2048
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/functional/page_objects/index.js b/test/functional/page_objects/index.js
index 669775b73d56..495b977e13ae 100644
--- a/test/functional/page_objects/index.js
+++ b/test/functional/page_objects/index.js
@@ -10,3 +10,4 @@ export { SettingsPageProvider } from './settings_page';
export { MonitoringPageProvider } from './monitoring_page';
export { PointSeriesPageProvider } from './point_series_page';
export { VisualBuilderPageProvider } from './visual_builder_page';
+export { TimelionPageProvider } from './timelion_page';
diff --git a/test/functional/page_objects/timelion_page.js b/test/functional/page_objects/timelion_page.js
new file mode 100644
index 000000000000..93dfc91c5550
--- /dev/null
+++ b/test/functional/page_objects/timelion_page.js
@@ -0,0 +1,53 @@
+export function TimelionPageProvider({ getService, getPageObjects }) {
+ const testSubjects = getService('testSubjects');
+ const find = getService('find');
+ const log = getService('log');
+ const PageObjects = getPageObjects(['common', 'header']);
+ const esArchiver = getService('esArchiver');
+ const kibanaServer = getService('kibanaServer');
+
+ class TimelionPage {
+ async initTests() {
+ await kibanaServer.uiSettings.replace({
+ 'dateFormat:tz': 'UTC',
+ 'defaultIndex': 'logstash-*'
+ });
+
+ log.debug('load kibana index');
+ await esArchiver.load('timelion');
+
+ await PageObjects.common.navigateToApp('timelion');
+ }
+
+ async setExpression(expression) {
+ const input = await testSubjects.find('timelionExpressionTextArea');
+ await input.clearValue();
+ await input.type(expression);
+ }
+
+ async updateExpression(updates) {
+ const input = await testSubjects.find('timelionExpressionTextArea');
+ await input.type(updates);
+ }
+
+ async getExpression() {
+ const input = await testSubjects.find('timelionExpressionTextArea');
+ return input.getVisibleText();
+ }
+
+ async getSuggestionItemsText() {
+ const elements = await find.allByCssSelector('.suggestions .suggestion');
+ return await Promise.all(elements.map(async element => await element.getVisibleText()));
+ }
+
+ async clickSuggestion(suggestionIndex = 0) {
+ const elements = await find.allByCssSelector('.suggestions .suggestion');
+ if (suggestionIndex > elements.length) {
+ throw new Error(`Unable to select suggestion ${suggestionIndex}, only ${elements.length} suggestions available.`);
+ }
+ await elements[suggestionIndex].click();
+ }
+ }
+
+ return new TimelionPage();
+}