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 @@
-
+
Argument Name |
@@ -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()"
>
-
+
-
+