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(); +}