From 96230be1934b8ef1b745fafce90eef08a7294e4e Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Sun, 5 Nov 2017 12:29:23 -0700 Subject: [PATCH 01/10] timelion argument value suggestions for legend function --- src/core_plugins/timelion/public/chain.peg | 26 +++++++++++++---- .../directives/timelion_expression_input.js | 20 +++++++++++-- .../timelion_expression_input_helpers.js | 19 +++++++++++-- .../arg_value_suggestions.js | 28 +++++++++++++++++++ .../timelion_expression_suggestions.html | 9 ++++++ .../server/series_functions/legend.js | 20 ++++++++++++- 6 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js diff --git a/src/core_plugins/timelion/public/chain.peg b/src/core_plugins/timelion/public/chain.peg index 769e4f76bf7e08..bba12430ebd2ad 100644 --- a/src/core_plugins/timelion/public/chain.peg +++ b/src/core_plugins/timelion/public/chain.peg @@ -18,6 +18,9 @@ } } + var currentFunction; + var currentArgs = []; + var functions = []; var args = []; var variables = {}; @@ -40,18 +43,22 @@ arg_list } argument - = name:function_name space? '=' space? value:arg_type { - return { + = name:argument_name space? '=' space? value:arg_type { + var arg = { type: 'namedArg', name: name, value: value, location: simpleLocation(location()), text: text() - } + }; + currentArgs.push(arg); + return arg; } - / name:function_name space? '=' { + / name:argument_name space? '=' { var exception = { type: 'incompleteArgument', + currentArgs: currentArgs, + currentFunction: currentFunction, name: name, location: simpleLocation(location()), text: text() @@ -71,7 +78,7 @@ arg_type } variable_get - = '$' name:function_name { + = '$' name:argument_name { if (variables[name]) { return variables[name]; } else { @@ -80,7 +87,7 @@ variable_get } variable_set - = '$' name:function_name space? '=' space? value:arg_type { + = '$' name:argument_name space? '=' space? value:arg_type { variables[name] = value; } @@ -97,6 +104,13 @@ series } function_name + = first:[a-zA-Z]+ rest:[.a-zA-Z0-9_-]* { + currentFunction = first.join('') + rest.join(''); + currentArgs = []; + return currentFunction; +} + +argument_name = first:[a-zA-Z]+ rest:[.a-zA-Z0-9_-]* { return first.join('') + rest.join('') } function "function" diff --git a/src/core_plugins/timelion/public/directives/timelion_expression_input.js b/src/core_plugins/timelion/public/directives/timelion_expression_input.js index 43c39a34711895..9102267d2ec107 100644 --- a/src/core_plugins/timelion/public/directives/timelion_expression_input.js +++ b/src/core_plugins/timelion/public/directives/timelion_expression_input.js @@ -35,11 +35,12 @@ import { insertAtLocation, } from './timelion_expression_input_helpers'; import { comboBoxKeyCodes } from 'ui_framework/services'; +import { ArgValueSuggestionsProvider } from './timelion_expression_suggestions/arg_value_suggestions'; const Parser = PEG.buildParser(grammar); const app = require('ui/modules').get('apps/timelion', []); -app.directive('timelionExpressionInput', function ($document, $http, $interval, $timeout) { +app.directive('timelionExpressionInput', function ($document, $http, $interval, $timeout, Private) { return { restrict: 'E', scope: { @@ -51,6 +52,7 @@ app.directive('timelionExpressionInput', function ($document, $http, $interval, replace: true, template: timelionExpressionInputTemplate, link: function (scope, elem) { + const getArgValueSuggestions = Private(ArgValueSuggestionsProvider); const expressionInput = elem.find('[data-expression-input]'); const functionReference = {}; let suggestibleFunctionLocation = {}; @@ -104,6 +106,19 @@ app.directive('timelionExpressionInput', function ($document, $http, $interval, const updatedExpression = insertAtLocation(argumentName, scope.sheet, min, max); scope.sheet = updatedExpression; + // Position the caret after the '=' + const newCaretOffset = min + argumentName.length; + setCaretOffset(newCaretOffset); + break; + } + case SUGGESTION_TYPE.ARGUMENT_VALUE: { + const argumentName = `${scope.suggestions.list[suggestionIndex].name}`; + const { min, max } = suggestibleFunctionLocation; + + // Update the expression with the function. + const updatedExpression = insertAtLocation(argumentName, scope.sheet, min, max); + scope.sheet = updatedExpression; + // Position the caret after the '=' const newCaretOffset = min + argumentName.length; setCaretOffset(newCaretOffset); @@ -132,7 +147,8 @@ app.directive('timelionExpressionInput', function ($document, $http, $interval, scope.sheet, functionReference.list, Parser, - getCursorPosition() + getCursorPosition(), + getArgValueSuggestions ).then(suggestions => { // We're using ES6 Promises, not $q, so we have to wrap this in $apply. scope.$apply(() => { diff --git a/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js b/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js index ac7d6750d99fcb..c46e55daf9a9da 100644 --- a/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js +++ b/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js @@ -125,7 +125,7 @@ function extractSuggestionsFromParsedResult(result, cursorPosition, functionList return { list: argumentSuggestions, location: location, type: SUGGESTION_TYPE.ARGUMENTS }; } -export function suggest(expression, functionList, Parser, cursorPosition) { +export function suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions) { return new Promise((resolve, reject) => { try { // We rely on the grammar to throw an error in order to suggest function(s). @@ -160,8 +160,21 @@ export function suggest(expression, functionList, Parser, cursorPosition) { return resolve({ list, location, type: SUGGESTION_TYPE.FUNCTIONS }); } else if (message.type === 'incompleteArgument') { - // TODO - provide argument value suggestions once function list contains required data - return resolve({ list: [], location, type: SUGGESTION_TYPE.ARGUMENT_VALUE }); + + const functionHelp = functionList.find((func) => { + return func.name === message.currentFunction; + }); + let argHelp; + if (functionHelp) { + argHelp = functionHelp.args.find((arg) => { + return arg.name === message.name; + }); + } + + return resolve({ + list: getArgValueSuggestions(null, argHelp, message.currentFunction, message.currentArgs, message.name), + location: { min: cursorPosition, max: cursorPosition }, + type: SUGGESTION_TYPE.ARGUMENT_VALUE }); } } catch (e) { diff --git a/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js b/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js new file mode 100644 index 00000000000000..61368c59681c8a --- /dev/null +++ b/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js @@ -0,0 +1,28 @@ +import _ from 'lodash'; + +export function ArgValueSuggestionsProvider(Private) { + + /** + * @param {string} partial - user provided argument value + * @param {object} help - arugment help definition object fetched from '/api/timelion/functions' + * @param {string} functionName - user provided function name containing argument + * @param {object} functionArgs - user provided function arguments parsed ahead of current argument + * @param {string} argName - user provided argument name + * @return {array} array of suggestions + */ + function getSuggestions(partial, help, functionName, functionArgs, argName) { + if (_.has(help, 'suggestions')) { + if (partial) { + return help.suggestions.filter(suggestion => { + return suggestion.name.includes(partial); + }); + } + + return help.suggestions; + } + + return []; + } + + return getSuggestions; +} diff --git a/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/timelion_expression_suggestions.html b/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/timelion_expression_suggestions.html index 829443643a55ed..49c5a511330411 100644 --- a/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/timelion_expression_suggestions.html +++ b/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/timelion_expression_suggestions.html @@ -70,6 +70,15 @@

+
+

+ {{suggestion.name}} + + {{suggestion.help}} + +

+
+ diff --git a/src/core_plugins/timelion/server/series_functions/legend.js b/src/core_plugins/timelion/server/series_functions/legend.js index 0cbef9620a6263..712bb69954d9f4 100644 --- a/src/core_plugins/timelion/server/series_functions/legend.js +++ b/src/core_plugins/timelion/server/series_functions/legend.js @@ -11,7 +11,25 @@ export default new Chainable('legend', { { name: 'position', types: ['string', 'boolean', 'null'], - help: 'Corner to place the legend in: nw, ne, se, or sw. You can also pass false to disable the legend' + help: 'Corner to place the legend in: nw, ne, se, or sw. You can also pass false to disable the legend', + suggestions: [ + { + name: 'false', + help: 'disable legend', + }, { + name: 'nw', + help: 'place legend in north west corner' + }, { + name: 'ne', + help: 'place legend in north east corner' + }, { + name: 'se', + help: 'place legend in south east corner' + }, { + name: 'sw', + help: 'place legend in south west corner' + } + ] }, { name: 'columns', From ad8af48a000552b183988d017c541b9a55b9e3d6 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Sun, 5 Nov 2017 13:02:31 -0700 Subject: [PATCH 02/10] update functions with argument value suggestions --- .../server/series_functions/condition.js | 30 +++++++++++++++++-- .../timelion/server/series_functions/fit.js | 5 +++- .../server/series_functions/legend.js | 12 +++++--- .../server/series_functions/movingaverage.js | 15 ++++++++-- .../server/series_functions/points.js | 19 ++++++++---- .../server/series_functions/trend/index.js | 5 +++- .../timelion/server/series_functions/yaxis.js | 5 +++- 7 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/core_plugins/timelion/server/series_functions/condition.js b/src/core_plugins/timelion/server/series_functions/condition.js index 5d930ef18a157d..28a6f31e4e418f 100644 --- a/src/core_plugins/timelion/server/series_functions/condition.js +++ b/src/core_plugins/timelion/server/series_functions/condition.js @@ -12,8 +12,34 @@ export default new Chainable('condition', { { name: 'operator', // <, <=, >, >=, ==, != types: ['string'], - help: 'Operator to use for comparison, valid operators are eq (equal), ne (not equal), lt (less than), lte ' + - '(less than equal), gt (greater than), gte (greater than equal)' + help: 'comparision operator to use for comparison, valid operators are eq (equal), ne (not equal), lt (less than), lte ' + + '(less than equal), gt (greater than), gte (greater than equal)', + suggestions: [ + { + name: 'eq', + help: 'equal', + }, + { + name: 'ne', + help: 'not equal' + }, + { + name: 'lt', + help: 'less than' + }, + { + name: 'lte', + help: 'less than equal' + }, + { + name: 'gt', + help: 'greater than' + }, + { + name: 'gte', + help: 'greater than equal' + } + ] }, { name: 'if', diff --git a/src/core_plugins/timelion/server/series_functions/fit.js b/src/core_plugins/timelion/server/series_functions/fit.js index f1492a77616127..6d25aa3e25b5c1 100644 --- a/src/core_plugins/timelion/server/series_functions/fit.js +++ b/src/core_plugins/timelion/server/series_functions/fit.js @@ -13,7 +13,10 @@ export default new Chainable('fit', { { name: 'mode', types: ['string'], - help: 'The algorithm to use for fitting the series to the target. One of: ' + _.keys(fitFunctions).join(', ') + help: 'The algorithm to use for fitting the series to the target. One of: ' + _.keys(fitFunctions).join(', '), + suggestions: _.keys(fitFunctions).map(key => { + return { name: key }; + }) } ], help: 'Fills null values using a defined fit function', diff --git a/src/core_plugins/timelion/server/series_functions/legend.js b/src/core_plugins/timelion/server/series_functions/legend.js index 712bb69954d9f4..894f2b972a6d71 100644 --- a/src/core_plugins/timelion/server/series_functions/legend.js +++ b/src/core_plugins/timelion/server/series_functions/legend.js @@ -16,16 +16,20 @@ export default new Chainable('legend', { { name: 'false', help: 'disable legend', - }, { + }, + { name: 'nw', help: 'place legend in north west corner' - }, { + }, + { name: 'ne', help: 'place legend in north east corner' - }, { + }, + { name: 'se', help: 'place legend in south east corner' - }, { + }, + { name: 'sw', help: 'place legend in south west corner' } diff --git a/src/core_plugins/timelion/server/series_functions/movingaverage.js b/src/core_plugins/timelion/server/series_functions/movingaverage.js index c36a75763ce1b2..5e2bd01d254569 100644 --- a/src/core_plugins/timelion/server/series_functions/movingaverage.js +++ b/src/core_plugins/timelion/server/series_functions/movingaverage.js @@ -3,6 +3,9 @@ import _ from 'lodash'; import Chainable from '../lib/classes/chainable'; import toMS from '../lib/to_milliseconds.js'; +const validPositions = ['left', 'right', 'center']; +const defaultPosition = 'center'; + export default new Chainable('movingaverage', { args: [ { @@ -19,7 +22,14 @@ export default new Chainable('movingaverage', { { name: 'position', types: ['string', 'null'], - help: 'Position of the averaged points relative to the result time. Options are left, right, and center (default).' + help: `Position of the averaged points relative to the result time. One of: ${validPositions.join(', ')}`, + suggestions: validPositions.map(position => { + const suggestion = { name: position }; + if (position === defaultPosition) { + suggestion.help = 'default'; + } + return suggestion; + }) } ], aliases: ['mvavg'], @@ -39,8 +49,7 @@ export default new Chainable('movingaverage', { _window = Math.round(windowMilliseconds / intervalMilliseconds) || 1; } - _position = _position || 'center'; - const validPositions = ['left', 'right', 'center']; + _position = _position || defaultPosition; if (!_.contains(validPositions, _position)) throw new Error('Valid positions are: ' + validPositions.join(', ')); const pairs = eachSeries.data; diff --git a/src/core_plugins/timelion/server/series_functions/points.js b/src/core_plugins/timelion/server/series_functions/points.js index c8499d6a456ca8..b2f5b65ff956e3 100644 --- a/src/core_plugins/timelion/server/series_functions/points.js +++ b/src/core_plugins/timelion/server/series_functions/points.js @@ -2,6 +2,9 @@ import alter from '../lib/alter.js'; import _ from 'lodash'; import Chainable from '../lib/classes/chainable'; +const validSymbols = ['triangle', 'cross', 'square', 'diamond', 'circle']; +const defaultSymbol = 'circle'; + export default new Chainable('points', { args: [ { @@ -30,8 +33,15 @@ export default new Chainable('points', { }, { name: 'symbol', - help: 'cross, circle, triangle, square or diamond', - types: ['string', 'null'] + help: `point symbol. One of: ${validSymbols.join(', ')}`, + types: ['string', 'null'], + suggestions: validSymbols.map(symbol => { + const suggestion = { name: symbol }; + if (symbol === defaultSymbol) { + suggestion.help = 'default'; + } + return suggestion; + }) }, { name: 'show', @@ -57,9 +67,8 @@ export default new Chainable('points', { eachSeries.points.lineWidth = weight; } - symbol = symbol || 'circle'; - const validSymbols = ['triangle', 'cross', 'square', 'diamond', 'circle']; - if (!_.contains(['triangle', 'cross', 'square', 'diamond', 'circle'], symbol)) { + symbol = symbol || defaultSymbol; + if (!_.contains(validSymbols, symbol)) { throw new Error('Valid symbols are: ' + validSymbols.join(', ')); } diff --git a/src/core_plugins/timelion/server/series_functions/trend/index.js b/src/core_plugins/timelion/server/series_functions/trend/index.js index 336cd534d13cde..7e692b31c11c18 100644 --- a/src/core_plugins/timelion/server/series_functions/trend/index.js +++ b/src/core_plugins/timelion/server/series_functions/trend/index.js @@ -16,7 +16,10 @@ export default new Chainable('trend', { { name: 'mode', types: ['string'], - help: 'The algorithm to use for generating the trend line. One of: ' + _.keys(validRegressions).join(', ') + help: 'The algorithm to use for generating the trend line. One of: ' + _.keys(validRegressions).join(', '), + suggestions: _.keys(validRegressions).map(key => { + return { name: key, help: validRegressions[key] }; + }) }, { name: 'start', diff --git a/src/core_plugins/timelion/server/series_functions/yaxis.js b/src/core_plugins/timelion/server/series_functions/yaxis.js index b816f65ef87f3a..16bb4b28df9a6d 100644 --- a/src/core_plugins/timelion/server/series_functions/yaxis.js +++ b/src/core_plugins/timelion/server/series_functions/yaxis.js @@ -50,7 +50,10 @@ export default new Chainable('yaxis', { { name: 'units', types: ['string', 'null'], - help: 'The function to use for formatting y-axis labels. One of: ' + _.values(tickFormatters).join(', ') + help: 'The function to use for formatting y-axis labels. One of: ' + _.values(tickFormatters).join(', '), + suggestions: _.keys(tickFormatters).map(key => { + return { name: key, help: tickFormatters[key] }; + }) }, { name: 'tickDecimals', From 8e9711d2faeac262689f7f1aa2cee8986bd719b2 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 6 Nov 2017 06:31:40 -0700 Subject: [PATCH 03/10] use async/await instead of promise resolve/reject for suggestion generation --- .../directives/timelion_expression_input.js | 23 ++--- .../timelion_expression_input_helpers.js | 99 +++++++++---------- 2 files changed, 61 insertions(+), 61 deletions(-) diff --git a/src/core_plugins/timelion/public/directives/timelion_expression_input.js b/src/core_plugins/timelion/public/directives/timelion_expression_input.js index 9102267d2ec107..2d04e6f2cd91a9 100644 --- a/src/core_plugins/timelion/public/directives/timelion_expression_input.js +++ b/src/core_plugins/timelion/public/directives/timelion_expression_input.js @@ -142,16 +142,18 @@ app.directive('timelionExpressionInput', function ($document, $http, $interval, return null; } - function getSuggestions() { - suggest( + async function getSuggestions() { + const suggestions = await suggest( scope.sheet, functionReference.list, Parser, getCursorPosition(), getArgValueSuggestions - ).then(suggestions => { - // We're using ES6 Promises, not $q, so we have to wrap this in $apply. - scope.$apply(() => { + ); + + // We're using ES6 Promises, not $q, so we have to wrap this in $apply. + scope.$apply(() => { + if (suggestions) { scope.suggestions.setList(suggestions.list, suggestions.type); scope.suggestions.show(); suggestibleFunctionLocation = suggestions.location; @@ -159,12 +161,11 @@ app.directive('timelionExpressionInput', function ($document, $http, $interval, const suggestionsList = $('[data-suggestions-list]'); suggestionsList.scrollTop(0); }, 0); - }); - }, (noSuggestions = {}) => { - scope.$apply(() => { - suggestibleFunctionLocation = noSuggestions.location; - scope.suggestions.reset(); - }); + return; + } + + suggestibleFunctionLocation = undefined; + scope.suggestions.reset(); }); } diff --git a/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js b/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js index c46e55daf9a9da..75770541c22f2a 100644 --- a/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js +++ b/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js @@ -125,64 +125,63 @@ function extractSuggestionsFromParsedResult(result, cursorPosition, functionList return { list: argumentSuggestions, location: location, type: SUGGESTION_TYPE.ARGUMENTS }; } -export function suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions) { - return new Promise((resolve, reject) => { +export async function suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions) { + try { + // We rely on the grammar to throw an error in order to suggest function(s). + const result = await Parser.parse(expression); + + const suggestions = extractSuggestionsFromParsedResult(result, cursorPosition, functionList); + if (suggestions) { + return suggestions; + } + + return; + } catch (e) { try { - // We rely on the grammar to throw an error in order to suggest function(s). - const result = Parser.parse(expression); + // The grammar will throw an error containing a message if the expression is formatted + // correctly and is prepared to accept suggestions. If the expression is not formmated + // correctly the grammar will just throw a regular PEG SyntaxError, and this JSON.parse + // attempt will throw an error. + const message = JSON.parse(e.message); + const location = message.location; + + if (message.type === 'incompleteFunction') { + let list; + + if (message.function) { + // The user has start typing a function name, so we'll filter the list down to only + // possible matches. + list = functionList.filter(func => _.startsWith(func.name, message.function)); + } else { + // The user hasn't typed anything yet, so we'll just return the entire list. + list = functionList; + } - const suggestions = extractSuggestionsFromParsedResult(result, cursorPosition, functionList); - if (suggestions) { - return resolve(suggestions); - } + return { list, location, type: SUGGESTION_TYPE.FUNCTIONS }; + } else if (message.type === 'incompleteArgument') { - return reject(); - } catch (e) { - try { - // The grammar will throw an error containing a message if the expression is formatted - // correctly and is prepared to accept suggestions. If the expression is not formmated - // correctly the grammar will just throw a regular PEG SyntaxError, and this JSON.parse - // attempt will throw an error. - const message = JSON.parse(e.message); - const location = message.location; - - if (message.type === 'incompleteFunction') { - let list; - - if (message.function) { - // The user has start typing a function name, so we'll filter the list down to only - // possible matches. - list = functionList.filter(func => _.startsWith(func.name, message.function)); - } else { - // The user hasn't typed anything yet, so we'll just return the entire list. - list = functionList; - } - - return resolve({ list, location, type: SUGGESTION_TYPE.FUNCTIONS }); - } else if (message.type === 'incompleteArgument') { - - const functionHelp = functionList.find((func) => { - return func.name === message.currentFunction; + const functionHelp = functionList.find((func) => { + return func.name === message.currentFunction; + }); + let argHelp; + if (functionHelp) { + argHelp = functionHelp.args.find((arg) => { + return arg.name === message.name; }); - let argHelp; - if (functionHelp) { - argHelp = functionHelp.args.find((arg) => { - return arg.name === message.name; - }); - } - - return resolve({ - list: getArgValueSuggestions(null, argHelp, message.currentFunction, message.currentArgs, message.name), - location: { min: cursorPosition, max: cursorPosition }, - type: SUGGESTION_TYPE.ARGUMENT_VALUE }); } - } catch (e) { - // The expression isn't correctly formatted, so JSON.parse threw an error. - return reject(); + const valueSuggestions = await getArgValueSuggestions(null, argHelp, message.currentFunction, message.currentArgs, message.name); + return { + list: valueSuggestions, + location: { min: cursorPosition, max: cursorPosition }, + type: SUGGESTION_TYPE.ARGUMENT_VALUE }; } + + } catch (e) { + // The expression isn't correctly formatted, so JSON.parse threw an error. + return; } - }); + } } export function insertAtLocation(valueToInsert, destination, replacementRangeStart, replacementRangeEnd) { From b31313a25c78414ce5f04fe50188ceebd6b0e9dc Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 6 Nov 2017 08:20:51 -0700 Subject: [PATCH 04/10] lookup value suggestions for es index and timefield arguments --- src/core_plugins/timelion/index.js | 1 + .../timelion_expression_input_helpers.js | 2 +- .../arg_value_suggestions.js | 75 ++++++++++++++++++- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/core_plugins/timelion/index.js b/src/core_plugins/timelion/index.js index 0b4724997c16fc..109abcb59dcfc0 100644 --- a/src/core_plugins/timelion/index.js +++ b/src/core_plugins/timelion/index.js @@ -28,6 +28,7 @@ export default function (kibana) { }; }, uses: [ + 'fieldFormats', 'savedObjectTypes' ] }, diff --git a/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js b/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js index 75770541c22f2a..99fc2f18891be0 100644 --- a/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js +++ b/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js @@ -170,7 +170,7 @@ export async function suggest(expression, functionList, Parser, cursorPosition, }); } - const valueSuggestions = await getArgValueSuggestions(null, argHelp, message.currentFunction, message.currentArgs, message.name); + const valueSuggestions = await getArgValueSuggestions(message.name, null, argHelp, message.currentFunction, message.currentArgs); return { list: valueSuggestions, location: { min: cursorPosition, max: cursorPosition }, diff --git a/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js b/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js index 61368c59681c8a..f84a5522cc98a2 100644 --- a/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js +++ b/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js @@ -1,16 +1,85 @@ import _ from 'lodash'; +import { SavedObjectsClientProvider } from 'ui/saved_objects'; -export function ArgValueSuggestionsProvider(Private) { +export function ArgValueSuggestionsProvider(Private, indexPatterns) { + + const savedObjectsClient = Private(SavedObjectsClientProvider); + + async function getIndexPattern(functionArgs) { + const indexPatternArg = functionArgs.find(argument => { + return argument.name === 'index'; + }); + if (!indexPatternArg) { + // index argument not provided + return; + } + const indexPatternTitle = _.get(indexPatternArg, 'value.text'); + + const resp = await savedObjectsClient.find({ + type: 'index-pattern', + fields: ['title'], + search: `"${indexPatternTitle}"`, + search_fields: ['title'], + perPage: 10 + }); + const indexPatternSavedObject = resp.savedObjects.find(savedObject => { + return savedObject.attributes.title === indexPatternTitle; + }); + if (!indexPatternSavedObject) { + // index argument does not match an index pattern + return; + } + + return await indexPatterns.get(indexPatternSavedObject.id); + } + + // Argument value suggestion handlers requiring custom client side code + // Could not put with function definition since functions are defined on server + const customHandlers = { + es: { + index: async function (partial) { + const search = partial ? `"${partial}"` : '*'; + const resp = await savedObjectsClient.find({ + type: 'index-pattern', + fields: ['title'], + search: `${search}`, + search_fields: ['title'], + perPage: 10 + }); + return resp.savedObjects.map(savedObject => { + return { name: savedObject.attributes.title }; + }); + }, + timefield: async function (partial, functionArgs) { + const indexPattern = await getIndexPattern(functionArgs); + if (!indexPattern) { + return []; + } + + return indexPattern.fields + .filter(field => { + return 'date' === field.type; + }) + .map(field => { + return { name: field.name }; + }); + } + } + }; /** + * @param {string} argName - user provided argument name * @param {string} partial - user provided argument value * @param {object} help - arugment help definition object fetched from '/api/timelion/functions' * @param {string} functionName - user provided function name containing argument * @param {object} functionArgs - user provided function arguments parsed ahead of current argument - * @param {string} argName - user provided argument name * @return {array} array of suggestions */ - function getSuggestions(partial, help, functionName, functionArgs, argName) { + async function getSuggestions(argName, partial, help, functionName, functionArgs) { + if (_.has(customHandlers, [functionName, argName])) { + return await customHandlers[functionName][argName](partial, functionArgs); + } + if (_.has(help, 'suggestions')) { if (partial) { return help.suggestions.filter(suggestion => { From bb71325e09a27ea568d5dfd78de8a4fe44abcd98 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 6 Nov 2017 09:02:15 -0700 Subject: [PATCH 05/10] custom handlers for es metric and split arguments --- .../timelion_expression_input_helpers.js | 28 +++++----- .../arg_value_suggestions.js | 55 ++++++++++++++++++- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js b/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js index 99fc2f18891be0..4096b72b1a1a74 100644 --- a/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js +++ b/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js @@ -73,7 +73,7 @@ function inLocation(cursorPosition, location) { return cursorPosition >= location.min && cursorPosition <= location.max; } -function extractSuggestionsFromParsedResult(result, cursorPosition, functionList) { +async function extractSuggestionsFromParsedResult(result, cursorPosition, functionList, getArgValueSuggestions) { const activeFunc = result.functions.find((func) => { return cursorPosition >= func.location.min && cursorPosition < func.location.max; }); @@ -82,7 +82,7 @@ function extractSuggestionsFromParsedResult(result, cursorPosition, functionList return; } - const funcDefinition = functionList.find((func) => { + const functionHelp = functionList.find((func) => { return func.name === activeFunc.function; }); const providedArguments = activeFunc.arguments.map((arg) => { @@ -93,19 +93,26 @@ function extractSuggestionsFromParsedResult(result, cursorPosition, functionList // location range includes '.', function name, and '('. const openParen = activeFunc.location.min + activeFunc.function.length + 2; if (cursorPosition < openParen) { - return { list: [funcDefinition], location: activeFunc.location, type: SUGGESTION_TYPE.FUNCTIONS }; + return { list: [functionHelp], location: activeFunc.location, type: SUGGESTION_TYPE.FUNCTIONS }; } // Do not provide 'inputSeries' as argument suggestion for chainable functions - const args = funcDefinition.chainable ? funcDefinition.args.slice(1) : funcDefinition.args.slice(0); + const args = functionHelp.chainable ? functionHelp.args.slice(1) : functionHelp.args.slice(0); const activeArg = activeFunc.arguments.find((argument) => { return inLocation(cursorPosition, argument.location); }); // return argument_value suggestions when cursor is inside agrument value if (activeArg && activeArg.type === 'namedArg' && inLocation(cursorPosition, activeArg.value.location)) { - // TODO - provide argument value suggestions once function list contains required data - return { list: [], location: activeArg.value.location, type: SUGGESTION_TYPE.ARGUMENT_VALUE }; + const valueSuggestions = await getArgValueSuggestions( + activeArg.name, + activeArg.value.text, + functionHelp.args.find((arg) => { + return arg.name === activeArg.name; + }), + activeFunc.function, + activeFunc.arguments); + return { list: valueSuggestions, location: activeArg.value.location, type: SUGGESTION_TYPE.ARGUMENT_VALUE }; } const argumentSuggestions = args.filter(arg => { @@ -127,15 +134,8 @@ function extractSuggestionsFromParsedResult(result, cursorPosition, functionList export async function suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions) { try { - // We rely on the grammar to throw an error in order to suggest function(s). const result = await Parser.parse(expression); - - const suggestions = extractSuggestionsFromParsedResult(result, cursorPosition, functionList); - if (suggestions) { - return suggestions; - } - - return; + return await extractSuggestionsFromParsedResult(result, cursorPosition, functionList, getArgValueSuggestions); } catch (e) { try { // The grammar will throw an error containing a message if the expression is formatted diff --git a/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js b/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js index f84a5522cc98a2..a84e51807c358f 100644 --- a/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js +++ b/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js @@ -33,23 +33,72 @@ export function ArgValueSuggestionsProvider(Private, indexPatterns) { return await indexPatterns.get(indexPatternSavedObject.id); } + function containsFieldName(partial, field) { + if (!partial) { + return true; + } + return field.name.includes(partial); + } + // Argument value suggestion handlers requiring custom client side code // Could not put with function definition since functions are defined on server const customHandlers = { es: { index: async function (partial) { - const search = partial ? `"${partial}"` : '*'; + const search = partial ? `${partial}*` : '*'; const resp = await savedObjectsClient.find({ type: 'index-pattern', fields: ['title'], search: `${search}`, search_fields: ['title'], - perPage: 10 + perPage: 25 }); return resp.savedObjects.map(savedObject => { return { name: savedObject.attributes.title }; }); }, + metric: async function (partial, functionArgs) { + if (!partial || !partial.includes(':')) { + return [ + { name: 'avg:' }, + { name: 'cardinality:' }, + { name: 'max:' }, + { name: 'min:' }, + { name: 'sum:' } + ]; + } + + const indexPattern = await getIndexPattern(functionArgs); + if (!indexPattern) { + return []; + } + + const valueSplit = partial.split(':'); + return indexPattern.fields + .filter(field => { + return field.aggregatable && 'number' === field.type && containsFieldName(valueSplit[1], field); + }) + .map(field => { + return { name: `${valueSplit[0]}:${field.name}`, help: field.type }; + }); + + }, + split: async function (partial, functionArgs) { + const indexPattern = await getIndexPattern(functionArgs); + if (!indexPattern) { + return []; + } + + return indexPattern.fields + .filter(field => { + return field.aggregatable + && ['number', 'boolean', 'date', 'ip', 'string'].includes(field.type) + && containsFieldName(partial, field); + }) + .map(field => { + return { name: field.name, help: field.type }; + }); + }, timefield: async function (partial, functionArgs) { const indexPattern = await getIndexPattern(functionArgs); if (!indexPattern) { @@ -58,7 +107,7 @@ export function ArgValueSuggestionsProvider(Private, indexPatterns) { return indexPattern.fields .filter(field => { - return 'date' === field.type; + return 'date' === field.type && containsFieldName(partial, field); }) .map(field => { return { name: field.name }; From 9b521b3df3c1b4e140feee6524ea9f4518f4fef7 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 6 Nov 2017 10:29:59 -0700 Subject: [PATCH 06/10] update suggestions unit test --- .../timelion_expression_input_helpers.js | 69 +++++++++++++++---- .../arg_value_suggestions.js | 1 + 2 files changed, 55 insertions(+), 15 deletions(-) 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 1e8808eca97ce8..7667a5a0e615cb 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 @@ -5,6 +5,7 @@ import { SUGGESTION_TYPE, suggest } from '../timelion_expression_input_helpers'; +import { ArgValueSuggestionsProvider } from '../timelion_expression_suggestions/arg_value_suggestions'; describe('Timelion expression suggestions', () => { @@ -15,7 +16,10 @@ describe('Timelion expression suggestions', () => { args: [ { name: 'inputSeries' }, { name: 'argA' }, - { name: 'argAB' } + { + name: 'argAB', + suggestions: [{ name: 'value1' }] + } ] }; const myFunc2 = { @@ -29,6 +33,13 @@ describe('Timelion expression suggestions', () => { }; const functionList = [func1, myFunc2]; let Parser; + const privateStub = () => { + return {}; + }; + const indexPatternsStub = { + + }; + const getArgValueSuggestions = ArgValueSuggestionsProvider(privateStub, indexPatternsStub); // eslint-disable-line new-cap beforeEach(function () { Parser = PEG.buildParser(grammar); }); @@ -39,7 +50,7 @@ describe('Timelion expression suggestions', () => { it('should return function suggestions', async () => { const expression = '.'; const cursorPosition = 1; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); expect(suggestions).to.eql({ 'list': [func1, myFunc2], 'location': { @@ -52,7 +63,7 @@ describe('Timelion expression suggestions', () => { it('should filter function suggestions by function name', async () => { const expression = '.myF'; const cursorPosition = 4; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); expect(suggestions).to.eql({ 'list': [myFunc2], 'location': { @@ -65,15 +76,29 @@ describe('Timelion expression suggestions', () => { }); describe('incompleteArgument', () => { - it('should return argument value suggestions', async () => { + it('should return no argument value suggestions when not provided by help', async () => { const expression = '.func1(argA=)'; const cursorPosition = 11; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); expect(suggestions).to.eql({ 'list': [], 'location': { - 'min': 7, - 'max': 12 + 'min': 11, + 'max': 11 + }, + 'type': 'argument_value' + }); + }); + + it('should return argument value suggestions when provided by help', async () => { + const expression = '.func1(argAB=)'; + const cursorPosition = 11; + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); + expect(suggestions).to.eql({ + 'list': [{ name: 'value1' }], + 'location': { + 'min': 11, + 'max': 11 }, 'type': 'argument_value' }); @@ -87,7 +112,7 @@ describe('Timelion expression suggestions', () => { it('should return function suggestion', async () => { const expression = '.func1()'; const cursorPosition = 1; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); expect(suggestions).to.eql({ 'list': [func1], 'location': { @@ -104,7 +129,7 @@ describe('Timelion expression suggestions', () => { it('should return argument suggestions', async () => { const expression = '.myFunc2()'; const cursorPosition = 9; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); expect(suggestions).to.eql({ 'list': myFunc2.args, 'location': { @@ -117,7 +142,7 @@ describe('Timelion expression suggestions', () => { it('should not provide argument suggestions for argument that is all ready set in function def', async () => { const expression = '.myFunc2(argAB=provided,)'; const cursorPosition = 24; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); expect(suggestions.type).to.equal(SUGGESTION_TYPE.ARGUMENTS); expect(suggestions).to.eql({ 'list': [{ name: 'argA' }, { name: 'argABC' }], @@ -131,7 +156,7 @@ describe('Timelion expression suggestions', () => { it('should filter argument suggestions by argument name', async () => { const expression = '.myFunc2(argAB,)'; const cursorPosition = 14; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); expect(suggestions).to.eql({ 'list': [{ name: 'argAB' }, { name: 'argABC' }], 'location': { @@ -144,9 +169,9 @@ describe('Timelion expression suggestions', () => { it('should not show first argument for chainable functions', async () => { const expression = '.func1()'; const cursorPosition = 7; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); expect(suggestions).to.eql({ - 'list': [{ name: 'argA' }, { name: 'argAB' }], + 'list': [{ name: 'argA' }, { name: 'argAB', suggestions: [{ name: 'value1' }] }], 'location': { 'min': 7, 'max': 7 @@ -156,10 +181,10 @@ describe('Timelion expression suggestions', () => { }); }); describe('cursor in argument value', () => { - it('should return argument value suggestions', async () => { + it('should return no argument value suggestions when not provided by help', async () => { const expression = '.myFunc2(argA=42)'; const cursorPosition = 14; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); expect(suggestions).to.eql({ 'list': [], 'location': { @@ -169,6 +194,20 @@ describe('Timelion expression suggestions', () => { 'type': 'argument_value' }); }); + + it('should return no argument value suggestions when provided by help', async () => { + const expression = '.func1(argAB=val)'; + const cursorPosition = 16; + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); + expect(suggestions).to.eql({ + 'list': [{ name: 'value1' }], + 'location': { + 'min': 13, + 'max': 16 + }, + 'type': 'argument_value' + }); + }); }); }); }); diff --git a/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js b/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js index a84e51807c358f..13edcbe6e9e07b 100644 --- a/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js +++ b/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js @@ -62,6 +62,7 @@ export function ArgValueSuggestionsProvider(Private, indexPatterns) { return [ { name: 'avg:' }, { name: 'cardinality:' }, + { name: 'count' }, { name: 'max:' }, { name: 'min:' }, { name: 'sum:' } From e464355e4a436ee8bcd9477d354b1837283c624c Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 6 Nov 2017 20:44:32 -0700 Subject: [PATCH 07/10] remove duplicate code from insertSuggestion switch --- .../directives/timelion_expression_input.js | 44 +++++++------------ 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/src/core_plugins/timelion/public/directives/timelion_expression_input.js b/src/core_plugins/timelion/public/directives/timelion_expression_input.js index 2d04e6f2cd91a9..f8e132ed1f20c3 100644 --- a/src/core_plugins/timelion/public/directives/timelion_expression_input.js +++ b/src/core_plugins/timelion/public/directives/timelion_expression_input.js @@ -83,48 +83,36 @@ app.directive('timelionExpressionInput', function ($document, $http, $interval, return; } + const { min, max } = suggestibleFunctionLocation; + let insertedValue; + let insertPositionMinOffset = 0; + switch (scope.suggestions.type) { case SUGGESTION_TYPE.FUNCTIONS: { - const functionName = `${scope.suggestions.list[suggestionIndex].name}()`; - const { min, max } = suggestibleFunctionLocation; + // Position the caret inside of the function parentheses. + insertedValue = `${scope.suggestions.list[suggestionIndex].name}()`; - // Update the expression with the function. // min advanced one to not replace function '.' - const updatedExpression = insertAtLocation(functionName, scope.sheet, min + 1, max); - scope.sheet = updatedExpression; - - // Position the caret inside of the function parentheses. - const newCaretOffset = min + functionName.length; - setCaretOffset(newCaretOffset); + insertPositionMinOffset = 1; break; } case SUGGESTION_TYPE.ARGUMENTS: { - const argumentName = `${scope.suggestions.list[suggestionIndex].name}=`; - const { min, max } = suggestibleFunctionLocation; - - // Update the expression with the function. - const updatedExpression = insertAtLocation(argumentName, scope.sheet, min, max); - scope.sheet = updatedExpression; - // Position the caret after the '=' - const newCaretOffset = min + argumentName.length; - setCaretOffset(newCaretOffset); + insertedValue = `${scope.suggestions.list[suggestionIndex].name}=`; break; } case SUGGESTION_TYPE.ARGUMENT_VALUE: { - const argumentName = `${scope.suggestions.list[suggestionIndex].name}`; - const { min, max } = suggestibleFunctionLocation; - - // Update the expression with the function. - const updatedExpression = insertAtLocation(argumentName, scope.sheet, min, max); - scope.sheet = updatedExpression; - - // Position the caret after the '=' - const newCaretOffset = min + argumentName.length; - setCaretOffset(newCaretOffset); + // Position the caret after the argument value + insertedValue = `${scope.suggestions.list[suggestionIndex].name}`; break; } } + + const updatedExpression = insertAtLocation(insertedValue, scope.sheet, min + insertPositionMinOffset, max); + scope.sheet = updatedExpression; + + const newCaretOffset = min + insertedValue.length; + setCaretOffset(newCaretOffset); } function scrollToSuggestionAt(index) { From 2deb1a27d0f3316bd8b24f93e85a26b0b22c88c2 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Tue, 7 Nov 2017 21:02:50 -0700 Subject: [PATCH 08/10] refactor arg_value_suggestions to provide three methods instead of single getSuggetions method --- .../timelion_expression_input_helpers.js | 24 ++--- .../directives/timelion_expression_input.js | 4 +- .../timelion_expression_input_helpers.js | 102 +++++++++++------- .../arg_value_suggestions.js | 57 +++++----- 4 files changed, 108 insertions(+), 79 deletions(-) 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 7667a5a0e615cb..4b5987e56046fa 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 @@ -39,7 +39,7 @@ describe('Timelion expression suggestions', () => { const indexPatternsStub = { }; - const getArgValueSuggestions = ArgValueSuggestionsProvider(privateStub, indexPatternsStub); // eslint-disable-line new-cap + const argValueSuggestions = ArgValueSuggestionsProvider(privateStub, indexPatternsStub); // eslint-disable-line new-cap beforeEach(function () { Parser = PEG.buildParser(grammar); }); @@ -50,7 +50,7 @@ describe('Timelion expression suggestions', () => { it('should return function suggestions', async () => { const expression = '.'; const cursorPosition = 1; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions); expect(suggestions).to.eql({ 'list': [func1, myFunc2], 'location': { @@ -63,7 +63,7 @@ describe('Timelion expression suggestions', () => { it('should filter function suggestions by function name', async () => { const expression = '.myF'; const cursorPosition = 4; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions); expect(suggestions).to.eql({ 'list': [myFunc2], 'location': { @@ -79,7 +79,7 @@ describe('Timelion expression suggestions', () => { it('should return no argument value suggestions when not provided by help', async () => { const expression = '.func1(argA=)'; const cursorPosition = 11; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions); expect(suggestions).to.eql({ 'list': [], 'location': { @@ -93,7 +93,7 @@ describe('Timelion expression suggestions', () => { it('should return argument value suggestions when provided by help', async () => { const expression = '.func1(argAB=)'; const cursorPosition = 11; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions); expect(suggestions).to.eql({ 'list': [{ name: 'value1' }], 'location': { @@ -112,7 +112,7 @@ describe('Timelion expression suggestions', () => { it('should return function suggestion', async () => { const expression = '.func1()'; const cursorPosition = 1; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions); expect(suggestions).to.eql({ 'list': [func1], 'location': { @@ -129,7 +129,7 @@ describe('Timelion expression suggestions', () => { it('should return argument suggestions', async () => { const expression = '.myFunc2()'; const cursorPosition = 9; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions); expect(suggestions).to.eql({ 'list': myFunc2.args, 'location': { @@ -142,7 +142,7 @@ describe('Timelion expression suggestions', () => { it('should not provide argument suggestions for argument that is all ready set in function def', async () => { const expression = '.myFunc2(argAB=provided,)'; const cursorPosition = 24; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions); expect(suggestions.type).to.equal(SUGGESTION_TYPE.ARGUMENTS); expect(suggestions).to.eql({ 'list': [{ name: 'argA' }, { name: 'argABC' }], @@ -156,7 +156,7 @@ describe('Timelion expression suggestions', () => { it('should filter argument suggestions by argument name', async () => { const expression = '.myFunc2(argAB,)'; const cursorPosition = 14; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions); expect(suggestions).to.eql({ 'list': [{ name: 'argAB' }, { name: 'argABC' }], 'location': { @@ -169,7 +169,7 @@ describe('Timelion expression suggestions', () => { it('should not show first argument for chainable functions', async () => { const expression = '.func1()'; const cursorPosition = 7; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions); expect(suggestions).to.eql({ 'list': [{ name: 'argA' }, { name: 'argAB', suggestions: [{ name: 'value1' }] }], 'location': { @@ -184,7 +184,7 @@ describe('Timelion expression suggestions', () => { it('should return no argument value suggestions when not provided by help', async () => { const expression = '.myFunc2(argA=42)'; const cursorPosition = 14; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions); expect(suggestions).to.eql({ 'list': [], 'location': { @@ -198,7 +198,7 @@ describe('Timelion expression suggestions', () => { it('should return no argument value suggestions when provided by help', async () => { const expression = '.func1(argAB=val)'; const cursorPosition = 16; - const suggestions = await suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions); + const suggestions = await suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions); expect(suggestions).to.eql({ 'list': [{ name: 'value1' }], 'location': { diff --git a/src/core_plugins/timelion/public/directives/timelion_expression_input.js b/src/core_plugins/timelion/public/directives/timelion_expression_input.js index f8e132ed1f20c3..36240b10450773 100644 --- a/src/core_plugins/timelion/public/directives/timelion_expression_input.js +++ b/src/core_plugins/timelion/public/directives/timelion_expression_input.js @@ -52,7 +52,7 @@ app.directive('timelionExpressionInput', function ($document, $http, $interval, replace: true, template: timelionExpressionInputTemplate, link: function (scope, elem) { - const getArgValueSuggestions = Private(ArgValueSuggestionsProvider); + const argValueSuggestions = Private(ArgValueSuggestionsProvider); const expressionInput = elem.find('[data-expression-input]'); const functionReference = {}; let suggestibleFunctionLocation = {}; @@ -136,7 +136,7 @@ app.directive('timelionExpressionInput', function ($document, $http, $interval, functionReference.list, Parser, getCursorPosition(), - getArgValueSuggestions + argValueSuggestions ); // We're using ES6 Promises, not $q, so we have to wrap this in $apply. diff --git a/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js b/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js index 4096b72b1a1a74..802d51f7821d7a 100644 --- a/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js +++ b/src/core_plugins/timelion/public/directives/timelion_expression_input_helpers.js @@ -73,7 +73,7 @@ function inLocation(cursorPosition, location) { return cursorPosition >= location.min && cursorPosition <= location.max; } -async function extractSuggestionsFromParsedResult(result, cursorPosition, functionList, getArgValueSuggestions) { +async function extractSuggestionsFromParsedResult(result, cursorPosition, functionList, argValueSuggestions) { const activeFunc = result.functions.find((func) => { return cursorPosition >= func.location.min && cursorPosition < func.location.max; }); @@ -85,36 +85,49 @@ async function extractSuggestionsFromParsedResult(result, cursorPosition, functi const functionHelp = functionList.find((func) => { return func.name === activeFunc.function; }); - const providedArguments = activeFunc.arguments.map((arg) => { - return arg.name; - }); - // return function suggestion if cursor is outside of parentheses + // return function suggestion when cursor is outside of parentheses // location range includes '.', function name, and '('. const openParen = activeFunc.location.min + activeFunc.function.length + 2; if (cursorPosition < openParen) { return { list: [functionHelp], location: activeFunc.location, type: SUGGESTION_TYPE.FUNCTIONS }; } - // Do not provide 'inputSeries' as argument suggestion for chainable functions - const args = functionHelp.chainable ? functionHelp.args.slice(1) : functionHelp.args.slice(0); - + // return argument value suggestions when cursor is inside agrument value const activeArg = activeFunc.arguments.find((argument) => { return inLocation(cursorPosition, argument.location); }); - // return argument_value suggestions when cursor is inside agrument value if (activeArg && activeArg.type === 'namedArg' && inLocation(cursorPosition, activeArg.value.location)) { - const valueSuggestions = await getArgValueSuggestions( - activeArg.name, - activeArg.value.text, - functionHelp.args.find((arg) => { + const { + function: functionName, + arguments: functionArgs, + } = activeFunc; + + const { + name: argName, + value: { text: partialInput }, + } = activeArg; + + let valueSuggestions; + if (argValueSuggestions.hasDynamicSuggestionsForArgument(functionName, argName)) { + valueSuggestions = await argValueSuggestions.getDynamicSuggestionsForArgument(functionName, argName, functionArgs, partialInput); + } else { + const { + suggestions: staticSuggestions, + } = functionHelp.args.find((arg) => { return arg.name === activeArg.name; - }), - activeFunc.function, - activeFunc.arguments); + }); + valueSuggestions = argValueSuggestions.getStaticSuggestionsForInput(partialInput, staticSuggestions); + } return { list: valueSuggestions, location: activeArg.value.location, type: SUGGESTION_TYPE.ARGUMENT_VALUE }; } + // 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)) { @@ -132,22 +145,27 @@ async function extractSuggestionsFromParsedResult(result, cursorPosition, functi return { list: argumentSuggestions, location: location, type: SUGGESTION_TYPE.ARGUMENTS }; } -export async function suggest(expression, functionList, Parser, cursorPosition, getArgValueSuggestions) { +export async function suggest(expression, functionList, Parser, cursorPosition, argValueSuggestions) { try { const result = await Parser.parse(expression); - return await extractSuggestionsFromParsedResult(result, cursorPosition, functionList, getArgValueSuggestions); + return await extractSuggestionsFromParsedResult(result, cursorPosition, functionList, argValueSuggestions); } catch (e) { + + let message; try { // The grammar will throw an error containing a message if the expression is formatted // correctly and is prepared to accept suggestions. If the expression is not formmated // correctly the grammar will just throw a regular PEG SyntaxError, and this JSON.parse // attempt will throw an error. - const message = JSON.parse(e.message); - const location = message.location; + message = JSON.parse(e.message); + } catch (e) { + // The expression isn't correctly formatted, so JSON.parse threw an error. + return; + } - if (message.type === 'incompleteFunction') { + switch (message.type) { + case 'incompleteFunction': { let list; - if (message.function) { // The user has start typing a function name, so we'll filter the list down to only // possible matches. @@ -156,30 +174,32 @@ export async function suggest(expression, functionList, Parser, cursorPosition, // The user hasn't typed anything yet, so we'll just return the entire list. list = functionList; } - - return { list, location, type: SUGGESTION_TYPE.FUNCTIONS }; - } else if (message.type === 'incompleteArgument') { - - const functionHelp = functionList.find((func) => { - return func.name === message.currentFunction; - }); - let argHelp; - if (functionHelp) { - argHelp = functionHelp.args.find((arg) => { - return arg.name === message.name; - }); + return { list, location: message.location, type: SUGGESTION_TYPE.FUNCTIONS }; + } + case 'incompleteArgument': { + const { + name: argName, + currentFunction: functionName, + currentArgs: functionArgs, + } = message; + let valueSuggestions = []; + if (argValueSuggestions.hasDynamicSuggestionsForArgument(functionName, argName)) { + valueSuggestions = await argValueSuggestions.getDynamicSuggestionsForArgument(functionName, argName, functionArgs); + } else { + const functionHelp = functionList.find(func => func.name === functionName); + if (functionHelp) { + const argHelp = functionHelp.args.find(arg => arg.name === argName); + if (argHelp && argHelp.suggestions) { + valueSuggestions = argHelp.suggestions; + } + } } - - const valueSuggestions = await getArgValueSuggestions(message.name, null, argHelp, message.currentFunction, message.currentArgs); return { list: valueSuggestions, location: { min: cursorPosition, max: cursorPosition }, - type: SUGGESTION_TYPE.ARGUMENT_VALUE }; + type: SUGGESTION_TYPE.ARGUMENT_VALUE + }; } - - } catch (e) { - // The expression isn't correctly formatted, so JSON.parse threw an error. - return; } } } diff --git a/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js b/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js index 13edcbe6e9e07b..a6d3069239d9b2 100644 --- a/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js +++ b/src/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js @@ -117,31 +117,40 @@ export function ArgValueSuggestionsProvider(Private, indexPatterns) { } }; - /** - * @param {string} argName - user provided argument name - * @param {string} partial - user provided argument value - * @param {object} help - arugment help definition object fetched from '/api/timelion/functions' - * @param {string} functionName - user provided function name containing argument - * @param {object} functionArgs - user provided function arguments parsed ahead of current argument - * @return {array} array of suggestions - */ - async function getSuggestions(argName, partial, help, functionName, functionArgs) { - if (_.has(customHandlers, [functionName, argName])) { - return await customHandlers[functionName][argName](partial, functionArgs); - } - - if (_.has(help, 'suggestions')) { - if (partial) { - return help.suggestions.filter(suggestion => { - return suggestion.name.includes(partial); + return { + /** + * @param {string} functionName - user provided function name containing argument + * @param {string} argName - user provided argument name + * @return {boolean} true when dynamic suggestion handler provided for function argument + */ + hasDynamicSuggestionsForArgument: (functionName, argName) => { + return (customHandlers[functionName] && customHandlers[functionName][argName]); + }, + + /** + * @param {string} functionName - user provided function name containing argument + * @param {string} argName - user provided argument name + * @param {object} functionArgs - user provided function arguments parsed ahead of current argument + * @param {string} partial - user provided argument value + * @return {array} array of dynamic suggestions matching partial + */ + getDynamicSuggestionsForArgument: async (functionName, argName, functionArgs, partialInput = '') => { + return await customHandlers[functionName][argName](partialInput, functionArgs); + }, + + /** + * @param {string} partial - user provided argument value + * @param {array} staticSuggestions - arugment value suggestions + * @return {array} array of static suggestions matching partial + */ + getStaticSuggestionsForInput: (partialInput = '', staticSuggestions = []) => { + if (partialInput) { + return staticSuggestions.filter(suggestion => { + return suggestion.name.includes(partialInput); }); } - return help.suggestions; - } - - return []; - } - - return getSuggestions; + return staticSuggestions; + }, + }; } From 99f7f60c1633ec7c93722212e9483605ee328233 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 13 Nov 2017 07:58:54 -0700 Subject: [PATCH 09/10] template literal --- src/core_plugins/timelion/server/series_functions/fit.js | 2 +- .../timelion/server/series_functions/trend/index.js | 2 +- src/core_plugins/timelion/server/series_functions/yaxis.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core_plugins/timelion/server/series_functions/fit.js b/src/core_plugins/timelion/server/series_functions/fit.js index 6d25aa3e25b5c1..ae3d116a5441eb 100644 --- a/src/core_plugins/timelion/server/series_functions/fit.js +++ b/src/core_plugins/timelion/server/series_functions/fit.js @@ -13,7 +13,7 @@ export default new Chainable('fit', { { name: 'mode', types: ['string'], - help: 'The algorithm to use for fitting the series to the target. One of: ' + _.keys(fitFunctions).join(', '), + help: `The algorithm to use for fitting the series to the target. One of: ${_.keys(fitFunctions).join(', ')}`, suggestions: _.keys(fitFunctions).map(key => { return { name: key }; }) diff --git a/src/core_plugins/timelion/server/series_functions/trend/index.js b/src/core_plugins/timelion/server/series_functions/trend/index.js index 7e692b31c11c18..f9a25dc606d9c6 100644 --- a/src/core_plugins/timelion/server/series_functions/trend/index.js +++ b/src/core_plugins/timelion/server/series_functions/trend/index.js @@ -16,7 +16,7 @@ export default new Chainable('trend', { { name: 'mode', types: ['string'], - help: 'The algorithm to use for generating the trend line. One of: ' + _.keys(validRegressions).join(', '), + help: `The algorithm to use for generating the trend line. One of: ${_.keys(validRegressions).join(', ')}`, suggestions: _.keys(validRegressions).map(key => { return { name: key, help: validRegressions[key] }; }) diff --git a/src/core_plugins/timelion/server/series_functions/yaxis.js b/src/core_plugins/timelion/server/series_functions/yaxis.js index 16bb4b28df9a6d..03ba2361594424 100644 --- a/src/core_plugins/timelion/server/series_functions/yaxis.js +++ b/src/core_plugins/timelion/server/series_functions/yaxis.js @@ -50,7 +50,7 @@ export default new Chainable('yaxis', { { name: 'units', types: ['string', 'null'], - help: 'The function to use for formatting y-axis labels. One of: ' + _.values(tickFormatters).join(', '), + help: `The function to use for formatting y-axis labels. One of: ${_.values(tickFormatters).join(', ')}`, suggestions: _.keys(tickFormatters).map(key => { return { name: key, help: tickFormatters[key] }; }) From d833bdcf93a15f3cfa641e2ac76919be4177f7bf Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 13 Nov 2017 08:03:58 -0700 Subject: [PATCH 10/10] update es index argument desc to include note about type ahead support --- src/core_plugins/timelion/server/series_functions/es/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core_plugins/timelion/server/series_functions/es/index.js b/src/core_plugins/timelion/server/series_functions/es/index.js index 8b1fa214bee9d9..2208fe0fdb709b 100644 --- a/src/core_plugins/timelion/server/series_functions/es/index.js +++ b/src/core_plugins/timelion/server/series_functions/es/index.js @@ -27,7 +27,8 @@ export default new Datasource('es', { { name: 'index', types: ['string', 'null'], - help: 'Index to query, wildcards accepted. Provide Index Pattern name for scripted field support.' + help: 'Index to query, wildcards accepted. Provide Index Pattern name for scripted fields and ' + + 'field name type ahead suggestions for metrics, split, and timefield arguments.' }, { name: 'timefield',