diff --git a/public/app/core/components/sql_part/sql_part.ts b/public/app/core/components/sql_part/sql_part.ts
new file mode 100644
index 00000000000..77366e6d09b
--- /dev/null
+++ b/public/app/core/components/sql_part/sql_part.ts
@@ -0,0 +1,118 @@
+import _ from 'lodash';
+
+export class SqlPartDef {
+ type: string;
+ params: any[];
+ defaultParams: any[];
+ renderer: any;
+ category: any;
+ addStrategy: any;
+
+ constructor(options: any) {
+ this.type = options.type;
+ this.params = options.params;
+ this.defaultParams = options.defaultParams;
+ this.renderer = options.renderer;
+ this.category = options.category;
+ this.addStrategy = options.addStrategy;
+ }
+}
+
+export class SqlPart {
+ part: any;
+ def: SqlPartDef;
+ params: any[];
+ text: string;
+
+ constructor(part: any, def: any) {
+ this.part = part;
+ this.def = def;
+ if (!this.def) {
+ throw { message: 'Could not find query part ' + part.type };
+ }
+
+ part.params = part.params || _.clone(this.def.defaultParams);
+ this.params = part.params;
+ this.updateText();
+ }
+
+ render(innerExpr: string) {
+ return this.def.renderer(this, innerExpr);
+ }
+
+ hasMultipleParamsInString(strValue, index) {
+ if (strValue.indexOf(',') === -1) {
+ return false;
+ }
+
+ return this.def.params[index + 1] && this.def.params[index + 1].optional;
+ }
+
+ updateParam(strValue, index) {
+ // handle optional parameters
+ // if string contains ',' and next param is optional, split and update both
+ if (this.hasMultipleParamsInString(strValue, index)) {
+ _.each(strValue.split(','), (partVal, idx) => {
+ this.updateParam(partVal.trim(), idx);
+ });
+ return;
+ }
+
+ if (strValue === '' && this.def.params[index].optional) {
+ this.params.splice(index, 1);
+ } else {
+ this.params[index] = strValue;
+ }
+
+ this.part.params = this.params;
+ this.updateText();
+ }
+
+ updateText() {
+ if (this.params.length === 0) {
+ this.text = this.def.type + '()';
+ return;
+ }
+
+ var text = this.def.type + '(';
+ text += this.params.join(', ');
+ text += ')';
+ this.text = text;
+ }
+}
+
+export function functionRenderer(part, innerExpr) {
+ var str = part.def.type + '(';
+ var parameters = _.map(part.params, (value, index) => {
+ var paramType = part.def.params[index];
+ if (paramType.type === 'time') {
+ if (value === 'auto') {
+ value = '$__interval';
+ }
+ }
+ if (paramType.quote === 'single') {
+ return "'" + value + "'";
+ } else if (paramType.quote === 'double') {
+ return '"' + value + '"';
+ }
+
+ return value;
+ });
+
+ if (innerExpr) {
+ parameters.unshift(innerExpr);
+ }
+ return str + parameters.join(', ') + ')';
+}
+
+export function suffixRenderer(part, innerExpr) {
+ return innerExpr + ' ' + part.params[0];
+}
+
+export function identityRenderer(part, innerExpr) {
+ return part.params[0];
+}
+
+export function quotedIdentityRenderer(part, innerExpr) {
+ return '"' + part.params[0] + '"';
+}
diff --git a/public/app/core/components/sql_part/sql_part_editor.ts b/public/app/core/components/sql_part/sql_part_editor.ts
new file mode 100644
index 00000000000..837f280e66d
--- /dev/null
+++ b/public/app/core/components/sql_part/sql_part_editor.ts
@@ -0,0 +1,185 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import coreModule from 'app/core/core_module';
+
+var template = `
+
+
{{part.def.type}}
+
()
+
+`;
+
+/** @ngInject */
+export function sqlPartEditorDirective($compile, templateSrv) {
+ var paramTemplate = '
';
+
+ return {
+ restrict: 'E',
+ template: template,
+ scope: {
+ part: '=',
+ handleEvent: '&',
+ debounce: '@',
+ },
+ link: function postLink($scope, elem) {
+ var part = $scope.part;
+ var partDef = part.def;
+ var $paramsContainer = elem.find('.query-part-parameters');
+ var debounceLookup = $scope.debounce;
+
+ $scope.partActions = [];
+
+ function clickFuncParam(paramIndex) {
+ /*jshint validthis:true */
+ var $link = $(this);
+ var $input = $link.next();
+
+ $input.val(part.params[paramIndex]);
+ $input.css('width', $link.width() + 16 + 'px');
+
+ $link.hide();
+ $input.show();
+ $input.focus();
+ $input.select();
+
+ var typeahead = $input.data('typeahead');
+ if (typeahead) {
+ $input.val('');
+ typeahead.lookup();
+ }
+ }
+
+ function inputBlur(paramIndex) {
+ /*jshint validthis:true */
+ var $input = $(this);
+ var $link = $input.prev();
+ var newValue = $input.val();
+
+ if (newValue !== '' || part.def.params[paramIndex].optional) {
+ $link.html(templateSrv.highlightVariablesAsHtml(newValue));
+
+ part.updateParam($input.val(), paramIndex);
+ $scope.$apply(() => {
+ $scope.handleEvent({ $event: { name: 'part-param-changed' } });
+ });
+ }
+
+ $input.hide();
+ $link.show();
+ }
+
+ function inputKeyPress(paramIndex, e) {
+ /*jshint validthis:true */
+ if (e.which === 13) {
+ inputBlur.call(this, paramIndex);
+ }
+ }
+
+ function inputKeyDown() {
+ /*jshint validthis:true */
+ this.style.width = (3 + this.value.length) * 8 + 'px';
+ }
+
+ function addTypeahead($input, param, paramIndex) {
+ if (!param.options && !param.dynamicLookup) {
+ return;
+ }
+
+ var typeaheadSource = function(query, callback) {
+ if (param.options) {
+ var options = param.options;
+ if (param.type === 'int') {
+ options = _.map(options, function(val) {
+ return val.toString();
+ });
+ }
+ return options;
+ }
+
+ $scope.$apply(function() {
+ $scope.handleEvent({ $event: { name: 'get-param-options' } }).then(function(result) {
+ var dynamicOptions = _.map(result, function(op) {
+ return op.value;
+ });
+ callback(dynamicOptions);
+ });
+ });
+ };
+
+ $input.attr('data-provide', 'typeahead');
+
+ $input.typeahead({
+ source: typeaheadSource,
+ minLength: 0,
+ items: 1000,
+ updater: function(value) {
+ setTimeout(function() {
+ inputBlur.call($input[0], paramIndex);
+ }, 0);
+ return value;
+ },
+ });
+
+ var typeahead = $input.data('typeahead');
+ typeahead.lookup = function() {
+ this.query = this.$element.val() || '';
+ var items = this.source(this.query, $.proxy(this.process, this));
+ return items ? this.process(items) : items;
+ };
+
+ if (debounceLookup) {
+ typeahead.lookup = _.debounce(typeahead.lookup, 500, { leading: true });
+ }
+ }
+
+ $scope.showActionsMenu = function() {
+ $scope.handleEvent({ $event: { name: 'get-part-actions' } }).then(res => {
+ $scope.partActions = res;
+ });
+ };
+
+ $scope.triggerPartAction = function(action) {
+ $scope.handleEvent({ $event: { name: 'action', action: action } });
+ };
+
+ function addElementsAndCompile() {
+ _.each(partDef.params, function(param, index) {
+ if (param.optional && part.params.length <= index) {
+ return;
+ }
+
+ if (index > 0) {
+ $('
, ').appendTo($paramsContainer);
+ }
+
+ var paramValue = templateSrv.highlightVariablesAsHtml(part.params[index]);
+ var $paramLink = $('
' + paramValue + '');
+ var $input = $(paramTemplate);
+
+ $paramLink.appendTo($paramsContainer);
+ $input.appendTo($paramsContainer);
+
+ $input.blur(_.partial(inputBlur, index));
+ $input.keyup(inputKeyDown);
+ $input.keypress(_.partial(inputKeyPress, index));
+ $paramLink.click(_.partial(clickFuncParam, index));
+
+ addTypeahead($input, param, index);
+ });
+ }
+
+ function relink() {
+ $paramsContainer.empty();
+ addElementsAndCompile();
+ }
+
+ relink();
+ },
+ };
+}
+
+coreModule.directive('sqlPartEditor', sqlPartEditorDirective);
diff --git a/public/app/core/core.ts b/public/app/core/core.ts
index fb7021fe883..2bfe84a74df 100644
--- a/public/app/core/core.ts
+++ b/public/app/core/core.ts
@@ -31,6 +31,7 @@ import { layoutSelector } from './components/layout_selector/layout_selector';
import { switchDirective } from './components/switch';
import { dashboardSelector } from './components/dashboard_selector';
import { queryPartEditorDirective } from './components/query_part/query_part_editor';
+import { sqlPartEditorDirective } from './components/sql_part/sql_part_editor';
import { formDropdownDirective } from './components/form_dropdown/form_dropdown';
import 'app/core/controllers/all';
import 'app/core/services/all';
@@ -74,6 +75,7 @@ export {
appEvents,
dashboardSelector,
queryPartEditorDirective,
+ sqlPartEditorDirective,
colors,
formDropdownDirective,
assignModelProperties,