diff --git a/src/core_plugins/timelion/public/app.less b/src/core_plugins/timelion/public/app.less index 0095eaa7104806..97428574bf3de2 100644 --- a/src/core_plugins/timelion/public/app.less +++ b/src/core_plugins/timelion/public/app.less @@ -24,6 +24,18 @@ } } +/** + * 1. Needs to be relative to contain absolutely-positioned typeahead suggestions. + */ +.timelionLocalSearch { + position: relative; /* 1 */ +} + +.timelionSearchInputContainer { + flex: 1 1 auto; + display: flex; +} + .timelion-container { margin: 20px; } @@ -38,10 +50,6 @@ background-color: @gray-lighter; } -.timelion-expression { - position: relative !important; -} - .timelion-subnav { background-color: @gray-lighter; margin: 0px @@ -88,33 +96,17 @@ opacity: 0.50; } -.timelion-interval { - width: 70px !important; - height: auto; - -webkit-appearance: none; - -moz-appearance: none; - padding: .5em; - padding-right: 1.5em; - line-height: 1; - border-top: 0; - border-right: 0; - border-bottom: 0; - border-radius: 0; - text-align: center; - - &:focus { - border-color: #ecf0f1; - } +.timelion-interval-custom { + width: 60px; } -.timelion-interval--select { - background-position: right 50%; - background-repeat: no-repeat; - background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAMCAYAAABSgIzaAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NDZFNDEwNjlGNzFEMTFFMkJEQ0VDRTM1N0RCMzMyMkIiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NDZFNDEwNkFGNzFEMTFFMkJEQ0VDRTM1N0RCMzMyMkIiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo0NkU0MTA2N0Y3MUQxMUUyQkRDRUNFMzU3REIzMzIyQiIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo0NkU0MTA2OEY3MUQxMUUyQkRDRUNFMzU3REIzMzIyQiIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PuGsgwQAAAA5SURBVHjaYvz//z8DOYCJgUxAf42MQIzTk0D/M+KzkRGPoQSdykiKJrBGpOhgJFYTWNEIiEeAAAMAzNENEOH+do8AAAAASUVORK5CYII=); +.timelion-interval-presets { + width: 90px; } -.timelion-interval-other { - width: 10px !important; +.timelion-interval-presets-compact { + width: 10px; + padding-left: 0; } timelion-interval { @@ -214,10 +206,3 @@ timelion-interval { margin-right: 5px; font-weight: bold; } - -/** - * 1. Needs to be relative to contain absolutely-positioned typeahead suggestions. - */ -.timelionLocalSearch { - position: relative; /* 1 */ -} diff --git a/src/core_plugins/timelion/public/directives/expression_directive.html b/src/core_plugins/timelion/public/directives/expression_directive.html new file mode 100644 index 00000000000000..314528c634042a --- /dev/null +++ b/src/core_plugins/timelion/public/directives/expression_directive.html @@ -0,0 +1,23 @@ +
+ + + +
diff --git a/src/core_plugins/timelion/public/directives/expression_directive.js b/src/core_plugins/timelion/public/directives/expression_directive.js index 9d90b36b5cc6bc..3f33918d7a3d19 100644 --- a/src/core_plugins/timelion/public/directives/expression_directive.js +++ b/src/core_plugins/timelion/public/directives/expression_directive.js @@ -2,10 +2,14 @@ import _ from 'lodash'; import $ from 'jquery'; import grammar from 'raw!../chain.peg'; import PEG from 'pegjs'; -const Parser = PEG.buildParser(grammar); -import template from './partials/suggestion.html'; -const app = require('ui/modules').get('apps/timelion', []); +import './partials/suggestion'; +import timelionExpressionInputTemplate from './expression_directive.html'; +import { + FunctionSuggestions, + suggest, + insertAtLocation, +} from './expression_directive_helpers'; /* Autocomplete proposal, this file doesn't actually work like this @@ -30,17 +34,22 @@ Only named arguments, necessarily provided optional by a plugin. Must be inside a function, and start must be adjacent to the argument name .function(arg=b|) +*/ +const Parser = PEG.buildParser(grammar); -*/ +const app = require('ui/modules').get('apps/timelion', []); -app.directive('timelionExpression', function ($compile, $http, $timeout, $rootScope) { +app.directive('timelionExpressionInput', function ($compile, $http, $timeout) { return { - restrict: 'A', - require: 'ngModel', - link: function ($scope, $elem, $attrs, ngModelCtrl) { - - const keys = { + restrict: 'E', + scope: { + sheet: '=', + }, + replace: true, + template: timelionExpressionInputTemplate, + link: function ($scope, $elem) { + const navigationalKeys = { ESC: 27, UP: 38, DOWN: 40, @@ -49,184 +58,144 @@ app.directive('timelionExpression', function ($compile, $http, $timeout, $rootSc }; const functionReference = {}; + const input = $elem.find('#timelionSearchInput'); + const caretLocation = {}; - function init() { - resetSuggestions(); - $elem.on('mouseup', function () { - suggest($attrs.timelionExpression); - digest(); - }); - $elem.on('keydown', keyDownHandler); - $elem.on('keyup', keyUpHandler); - $elem.on('blur', function () { - $timeout(function () { - $scope.suggestions.show = false; - }, 100); - }); + $scope.functionSuggestions = new FunctionSuggestions(); - $elem.after($compile(template)($scope)); + function init() { $http.get('../api/timelion/functions').then(function (resp) { - functionReference.byName = _.indexBy(resp.data, 'name'); - functionReference.list = resp.data; + Object.assign(functionReference, { + byName: _.indexBy(resp.data, 'name'), + list: resp.data, + }); }); } - $scope.$on('$destroy', function () { - $elem.off('mouseup'); - $elem.off('keydown'); - $elem.off('keyup'); - $elem.off('blur'); - }); - - function suggest(val) { - try { - // Inside an existing function providing suggestion only as a reference. Maybe suggest an argument? - const possible = findFunction(getCaretPos(), Parser.parse(val).functions); - // TODO: Reference suggestors. Only supporting completion right now; - resetSuggestions(); + function completeExpression(suggestionIndex) { + if ($scope.functionSuggestions.isEmpty()) { return; - - - if (functionReference.byName) { - if (functionReference.byName[possible.function]) { - $scope.suggestions.list = [functionReference.byName[possible.function]]; - $scope.suggestions.show = true; - } else { - resetSuggestions(); - } - } - } catch (e) { - try { // Is this a structured exception? - e = JSON.parse(e.message); - if (e.location.min > getCaretPos() || e.location.max <= getCaretPos()) { - resetSuggestions(); - return; - } - // TODO: Abstract structured exception handling; - if (e.type === 'incompleteFunction') { - if (e.function == null) { - $scope.suggestions.list = functionReference.list; - } else { - $scope.suggestions.list = _.compact(_.map(functionReference.list, function (func) { - if (_.startsWith(func.name, e.function)) { - return func; - } - })); - } - $scope.suggestions.show = true; - } - $scope.suggestions.location = e.location; - } catch (e) { - resetSuggestions(); - } } - digest(); - } - - function validateSelection() { - const maxSelection = $scope.suggestions.list.length - 1; - if ($scope.suggestions.selected > maxSelection) $scope.suggestions.selected = maxSelection; - else if ($scope.suggestions.selected < 0) $scope.suggestions.selected = 0; - } - - $scope.completeExpression = function (selected) { - if (!$scope.suggestions.list.length) return; - const expression = $attrs.timelionExpression; - const startOf = expression.slice(0, $scope.suggestions.location.min); - const endOf = expression.slice($scope.suggestions.location.max, expression.length); - const completeFunction = $scope.suggestions.list[selected].name + '()'; + const functionName = `${$scope.functionSuggestions.list[suggestionIndex].name}()`; + const expression = $scope.sheet; + const { min, max } = caretLocation; - const newVal = startOf + completeFunction + endOf; + const newExpression = insertAtLocation(functionName, expression, min, max); + input.val(newExpression); - $elem.val(newVal); - $elem[0].selectionStart = $elem[0].selectionEnd = - (startOf + completeFunction).length - 1; - ngModelCtrl.$setViewValue(newVal); + const newCaretPosition = min + functionName.length - 1; + input[0].selectionStart = input[0].selectionEnd = newCaretPosition; - resetSuggestions(); - }; + $scope.functionSuggestions.reset(); + } + function scrollTo(selected) { + const suggestionsListElem = $('[data-suggestions-list]'); + const suggestedElem = $($('[data-suggestion-list-item]')[selected]); - function keyDownHandler(e) { - if (_.contains(_.values(keys), e.keyCode)) e.preventDefault(); - switch (e.keyCode) { - case keys.UP: - if ($scope.suggestions.selected > 0) $scope.suggestions.selected--; - break; - case keys.DOWN: - $scope.suggestions.selected++; - break; - case keys.TAB: - $scope.completeExpression($scope.suggestions.selected); - break; - case keys.ENTER: - if ($scope.suggestions.list.length) { - $scope.completeExpression($scope.suggestions.selected); - } else { - $elem.submit(); - } - break; - case keys.ESC: - $scope.suggestions.show = false; - break; + if (!suggestedElem.position() || !suggestedElem.position().top) { + return; } - scrollTo($scope.suggestions); - digest(); - } - function keyUpHandler(e) { - if (_.contains(_.values(keys), e.keyCode)) return; + suggestionsListElem.scrollTop(suggestionsListElem.scrollTop() + suggestedElem.position().top); + } - suggest($attrs.timelionExpression); - validateSelection(); - digest(); + function getSuggestions() { + const caretPosition = input[0].selectionStart; + + suggest( + $scope.sheet, + caretPosition, + functionReference.list, + Parser + ).then(({ list, location }) => { + // We're using ES6 Promises, not $q, so we have to wrap this in $apply. + $scope.$apply(() => { + $scope.functionSuggestions.setList(list); + $scope.functionSuggestions.show(); + Object.assign(caretLocation, location); + }); + }, ({ location } = {}) => { + $scope.$apply(() => { + Object.assign(caretLocation, location); + $scope.functionSuggestions.reset(); + }); + }); } - function resetSuggestions() { - $scope.suggestions = { - selected: 0, - list: [], - position: {}, - show: false - }; - return $scope.suggestions; + function isNavigationalKey(keyCode) { + const keyCodes = _.values(navigationalKeys); + return keyCodes.includes(keyCode); } - function scrollTo(suggestions) { - validateSelection(); - const suggestionsListElem = $('.suggestions'); - const suggestedElem = $($('.suggestion')[suggestions.selected]); + $scope.mouseUpHandler = () => { + getSuggestions(); + }; - if (!suggestedElem.position() || !suggestedElem.position().top) return; + $scope.blurHandler = () => { + $timeout(() => { + $scope.functionSuggestions.hide(); + }, 100); + }; - suggestionsListElem.scrollTop(suggestionsListElem.scrollTop() + suggestedElem.position().top); - } + $scope.keyDownHandler = e => { + // If we've pressed any non-navigational keys, then the user has typed something and we + // can exit early without doing any navigation. + if (!isNavigationalKey(e.keyCode)) { + return; + } - function findFunction(position, functionList) { - let bestFunction; + switch (e.keyCode) { + case navigationalKeys.UP: + // Up and down keys navigate through suggestions. + e.preventDefault(); + $scope.functionSuggestions.stepForward(); + scrollTo($scope.functionSuggestions.index); + break; + + case navigationalKeys.DOWN: + // Up and down keys navigate through suggestions. + e.preventDefault(); + $scope.functionSuggestions.stepBackward(); + scrollTo($scope.functionSuggestions.index); + break; - _.each(functionList, function (func) { - if ((func.location.min) < position && position < (func.location.max)) { - if (!bestFunction || func.text.length < bestFunction.text.length) { - bestFunction = func; + case navigationalKeys.TAB: + // If there are no suggestions, the user tabs to the next input. + if ($scope.functionSuggestions.isEmpty()) { + return; } - } - }); - return bestFunction; - } + // If we have suggestions, complete the selected one. + e.preventDefault(); + completeExpression($scope.functionSuggestions.index); + break; - function getCaretPos() { - return $elem[0].selectionStart; - } + case navigationalKeys.ENTER: + // If the suggestions are open, complete the expression with the suggestion. + // Otherwise, the default action of submitting the input value will occur. + if (!$scope.functionSuggestions.isEmpty()) { + e.preventDefault(); + completeExpression($scope.functionSuggestions.index); + } + break; - function digest() { - $rootScope.$$phase || $scope.$digest(); - } + case navigationalKeys.ESC: + e.preventDefault(); + $scope.functionSuggestions.hide(); + break; + } + }; - init(); + $scope.keyUpHandler = e => { + // If the user isn't navigating, then we should update the suggestions based on their input. + if (!isNavigationalKey(e.keyCode)) { + getSuggestions(); + } + }; + init(); } }; }); diff --git a/src/core_plugins/timelion/public/directives/expression_directive_helpers.js b/src/core_plugins/timelion/public/directives/expression_directive_helpers.js new file mode 100644 index 00000000000000..7537f060ab666a --- /dev/null +++ b/src/core_plugins/timelion/public/directives/expression_directive_helpers.js @@ -0,0 +1,113 @@ +import _ from 'lodash'; + +export class FunctionSuggestions { + constructor() { + this.reset(); + } + + reset() { + this.index = 0; + this.list = []; + this.isVisible = false; + } + + setList(list) { + this.list = list; + + // We may get a shorter list than the one we have now, so we need to make sure our index doesn't + // fall outside of the new list's range. + this.index = Math.max(0, Math.min(this.index, this.list.length - 1)); + } + + getCount() { + return this.list.length; + } + + isEmpty() { + return this.list.length === 0; + } + + show() { + this.isVisible = true; + } + + hide() { + this.isVisible = false; + } + + stepForward() { + if (this.index > 0) { + this.index -= 1; + } + } + + stepBackward() { + if (this.index < this.list.length - 1) { + this.index += 1; + } + } +} + +export function findFunction(position, functionList) { + let matchingFunction; + + functionList.forEach(func => { + if ((func.location.min) < position && position < (func.location.max)) { + if (!matchingFunction || func.text.length < matchingFunction.text.length) { + matchingFunction = func; + } + } + }); + + return matchingFunction; +} + +export function suggest(val, caretPosition, functionList, Parser) { + return new Promise((resolve, reject) => { + try { + // Inside an existing function providing suggestion only as a reference. Maybe suggest an argument? + findFunction(caretPosition, Parser.parse(val).functions); + + // TODO: Reference suggestors. Only supporting completion right now; + + // We rely on peg to throw an error in order to suggest function(s). If peg doesn't + // throw an error, then we have no suggestions to offer. + return reject(); + } catch (e) { + // NOTE: This is a peg SyntaxError. + try { // Is this a structured exception? + const error = JSON.parse(e.message); + const location = error.location; + + if (location.min > caretPosition || location.max <= caretPosition) { + return reject({ location }); + } + // TODO: Abstract structured exception handling; + if (error.type === 'incompleteFunction') { + if (error.function == null) { + return resolve({ + list: functionList, + location, + }); + } else { + const list = _.compact(_.map(functionList, func => { + if (_.startsWith(func.name, error.function)) { + return func; + } + })); + return resolve({ list, location }); + } + } + } catch (e) { + return reject(); + } + } + }); +} + +export function insertAtLocation(value, destination, min, max) { + // Insert the value at a location caret within the destination. + const startOf = destination.slice(0, min); + const endOf = destination.slice(max, destination.length); + return `${startOf}${value}${endOf}`; +} diff --git a/src/core_plugins/timelion/public/directives/interval/interval.html b/src/core_plugins/timelion/public/directives/interval/interval.html index 557f84dd414179..4e7014ed0addbc 100644 --- a/src/core_plugins/timelion/public/directives/interval/interval.html +++ b/src/core_plugins/timelion/public/directives/interval/interval.html @@ -1,8 +1,13 @@ - + - + diff --git a/src/core_plugins/timelion/public/directives/interval/interval.js b/src/core_plugins/timelion/public/directives/interval/interval.js index 8787192a61aee6..d7fc3609132851 100644 --- a/src/core_plugins/timelion/public/directives/interval/interval.js +++ b/src/core_plugins/timelion/public/directives/interval/interval.js @@ -8,7 +8,11 @@ app.directive('timelionInterval', function ($compile, $timeout) { return { restrict: 'E', scope: { - model: '=', // The interval model + // The interval model + model: '=', + // Differentiates between contexts, e.g. when this directive is used in the header in the + // Timelion app or in the sidebar in the Visualize app. + inHeader: '=', }, template: html, link: function ($scope, $elem) { diff --git a/src/core_plugins/timelion/public/directives/partials/suggestion.html b/src/core_plugins/timelion/public/directives/partials/suggestion.html index 35bf083587a168..054bfc6cfaccea 100644 --- a/src/core_plugins/timelion/public/directives/partials/suggestion.html +++ b/src/core_plugins/timelion/public/directives/partials/suggestion.html @@ -1,8 +1,14 @@ -
-
+
+ ng-class="{active: $index === selectedIndex}" + ng-repeat="suggestion in suggestions track by $index | orderBy:'name'" + >

.{{suggestion.name}}() @@ -12,7 +18,7 @@

-
+
Arguments: {{arg.name}}=({{arg.types.join(' | ')}}) @@ -20,7 +26,7 @@

-
+
@@ -36,4 +42,4 @@

- \ No newline at end of file + diff --git a/src/core_plugins/timelion/public/directives/partials/suggestion.js b/src/core_plugins/timelion/public/directives/partials/suggestion.js new file mode 100644 index 00000000000000..f40bdacd9f0b88 --- /dev/null +++ b/src/core_plugins/timelion/public/directives/partials/suggestion.js @@ -0,0 +1,18 @@ +import timelionExpressionSuggestionsTemplate from './suggestion.html'; + +const app = require('ui/modules').get('apps/timelion', []); + +app.directive('timelionExpressionSuggestions', () => { + return { + restrict: 'E', + scope: { + suggestions: '=', + selectedIndex: '=', + onClickSuggestion: '&', + }, + replace: true, + template: timelionExpressionSuggestionsTemplate, + link: function () { + } + }; +}); diff --git a/src/core_plugins/timelion/public/index.html b/src/core_plugins/timelion/public/index.html index 2eed7996b7ad7d..cb372f382c0236 100644 --- a/src/core_plugins/timelion/public/index.html +++ b/src/core_plugins/timelion/public/index.html @@ -24,17 +24,14 @@ ng-submit="search()" >
- + - +

Argument Name