diff --git a/enabled/_50_admin_add_monitoring_panel.py b/enabled/_50_admin_add_monitoring_panel.py
index 427d0f1c..b09c4ab5 100644
--- a/enabled/_50_admin_add_monitoring_panel.py
+++ b/enabled/_50_admin_add_monitoring_panel.py
@@ -19,5 +19,13 @@ ADD_ANGULAR_MODULES = ['monitoringApp']
# A list of javascript files to be included for all pages
ADD_JS_FILES = ['monitoring/js/app.js',
- 'monitoring/js/controllers.js']
+ 'monitoring/js/controllers.js',
+ 'monitoring/js/ng-tags-input.js']
+from monclient import exc
+# A dictionary of exception classes to be added to HORIZON['exceptions'].
+ADD_EXCEPTIONS = {
+ 'recoverable': (exc.HTTPUnProcessable,),
+ 'not_found': (exc.HTTPNotFound,),
+ 'unauthorized': (exc.HTTPUnauthorized,),
+}
\ No newline at end of file
diff --git a/monitoring/alarms/forms.py b/monitoring/alarms/forms.py
index 01a15835..f6567600 100644
--- a/monitoring/alarms/forms.py
+++ b/monitoring/alarms/forms.py
@@ -26,30 +26,42 @@ from horizon import messages
from monitoring import api
-def get_expression(meter):
- expr = meter['name']
- args = None
- for name, value in meter['dimensions'].items():
- if name != 'detail':
- if args:
- args += ', '
- else:
- args = ''
- args += "%s=%s" % (name, value)
- return "%s{%s}" % (expr, args)
+class ExpressionWidget(forms.Widget):
+ def __init__(self, initial, attrs):
+ super(ExpressionWidget, self).__init__(attrs)
+ self.initial = initial
+
+ def render(self, name, value, attrs):
+ final_attrs = self.build_attrs(attrs, name=name)
+ final_attrs['placeholder'] = _('Add a dimension')
+ if 'all' in self.initial['service']:
+ dim = ''
+ else:
+ dim = next(("%s=%s" % (k, v) for k, v in self.initial.items()), '')
+ final_attrs['service'] = dim
+ output = '''
+
+ ''' % final_attrs
+ return format_html(output)
class SimpleExpressionWidget(django_forms.MultiWidget):
- def __init__(self, meters=None, attrs=None):
- choices = [(get_expression(m), get_expression(m)) for m in meters]
+ def __init__(self, initial, attrs=None):
comparators = [('>', '>'), ('>=', '>='), ('<', '<'), ('<=', '<=')]
func = [('min', _('min')), ('max', _('max')), ('sum', _('sum')),
('count', _('count')), ('avg', _('avg'))]
_widgets = (
django_forms.widgets.Select(attrs=attrs, choices=func),
- django_forms.widgets.Select(attrs=attrs, choices=choices),
+ ExpressionWidget(initial, attrs={}),
django_forms.widgets.Select(attrs=attrs, choices=comparators),
- django_forms.widgets.TextInput(attrs=attrs),
+ django_forms.widgets.TextInput(),
)
super(SimpleExpressionWidget, self).__init__(_widgets, attrs)
@@ -199,14 +211,7 @@ class BaseAlarmForm(forms.SelfHandlingForm):
('address', _('Address')), ])
else:
if create:
- meters = api.monitor.metrics_list(self.request)
- if initial and 'service' in initial and \
- initial['service'] != 'all':
- service = initial['service']
- meters = [m for m in meters
- if m.setdefault('dimensions', {}).
- setdefault('service', '') == service]
- expressionWidget = SimpleExpressionWidget(meters=meters)
+ expressionWidget = SimpleExpressionWidget(initial)
notificationWidget = NotificationCreateWidget()
else:
expressionWidget = textAreaWidget
diff --git a/monitoring/alarms/templates/alarms/_create.html b/monitoring/alarms/templates/alarms/_create.html
index b2af278e..efa77d3e 100644
--- a/monitoring/alarms/templates/alarms/_create.html
+++ b/monitoring/alarms/templates/alarms/_create.html
@@ -40,18 +40,14 @@ $('#notification_table').on('click', '#remove_notification_button', (function(ev
target.remove();
return false;
}));
+metricsList = {{ metrics | safe }}
+
+
{% endblock %}
{% block modal-footer %}
diff --git a/monitoring/alarms/views.py b/monitoring/alarms/views.py
index 26d723ce..c0fbca9a 100644
--- a/monitoring/alarms/views.py
+++ b/monitoring/alarms/views.py
@@ -18,6 +18,7 @@ import datetime
import logging
import json
import random
+from collections import defaultdict
from django.contrib import messages
from django.core.urlresolvers import reverse_lazy, reverse # noqa
@@ -206,6 +207,21 @@ class AlarmCreateView(forms.ModalFormView):
context["cancel_url"] = self.get_success_url()
context["action_url"] = reverse(constants.URL_PREFIX + 'alarm_create',
args=(self.service,))
+ metrics = api.monitor.metrics_list(self.request)
+ # Filter out metrics for other services
+ if self.service != 'all':
+ meters = [m for m in metrics
+ if m.setdefault('dimensions', {}).
+ setdefault('service', '') == self.service]
+
+ # Aggregate all dimensions for each metric name
+ d = defaultdict(set)
+ for metric in metrics:
+ dim_list = ['%s=%s' % (n, l) for n, l in metric["dimensions"].items()]
+ d[metric["name"]].update(dim_list)
+ unique_metrics = [{'name': k, 'dimensions': sorted(list(v))}
+ for k, v in d.items()]
+ context["metrics"] = json.dumps(unique_metrics)
return context
def get_success_url(self):
diff --git a/monitoring/static/monitoring/css/ng-tags-input.css b/monitoring/static/monitoring/css/ng-tags-input.css
new file mode 100755
index 00000000..009ec7f2
--- /dev/null
+++ b/monitoring/static/monitoring/css/ng-tags-input.css
@@ -0,0 +1,129 @@
+tags-input *, *:before, *:after {
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+}
+
+tags-input .host {
+ position: relative;
+ /*margin-top: 5px;*/
+ margin-bottom: 8px;
+}
+tags-input .host:active {
+ outline: none;
+}
+
+tags-input .tags {
+ -moz-appearance: textfield;
+ -webkit-appearance: textfield;
+ padding: 1px;
+ overflow: hidden;
+ word-wrap: break-word;
+ cursor: text;
+ background-color: white;
+ border: 1px solid darkgray;
+ box-shadow: 1px 1px 1px 0 lightgray inset;
+}
+tags-input .tags.focused {
+ outline: none;
+ -webkit-box-shadow: 0 0 3px 1px rgba(5, 139, 242, 0.6);
+ -moz-box-shadow: 0 0 3px 1px rgba(5, 139, 242, 0.6);
+ box-shadow: 0 0 3px 1px rgba(5, 139, 242, 0.6);
+}
+tags-input .tags .tag-list {
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+}
+tags-input .tags .tag-item {
+ margin: 2px;
+ padding: 0 5px;
+ display: inline-block;
+ float: left;
+ /*font: 14px "Helvetica Neue", Helvetica, Arial, sans-serif;*/
+ height: 26px;
+ line-height: 25px;
+ border: 1px solid #acacac;
+ border-radius: 3px;
+ background: -webkit-linear-gradient(top, #f0f9ff 0%, #cbebff 47%, #a1dbff 100%);
+ background: linear-gradient(to bottom, #f0f9ff 0%, #cbebff 47%, #a1dbff 100%);
+}
+tags-input .tags .tag-item.selected {
+ background: -webkit-linear-gradient(top, #febbbb 0%, #fe9090 45%, #ff5c5c 100%);
+ background: linear-gradient(to bottom, #febbbb 0%, #fe9090 45%, #ff5c5c 100%);
+}
+tags-input .tags .tag-item .remove-button {
+ margin: 0 0 0 5px;
+ padding: 0;
+ border: none;
+ background: none;
+ cursor: pointer;
+ vertical-align: middle;
+ font: bold 16px Arial, sans-serif;
+ color: #585858;
+}
+tags-input .tags .tag-item .remove-button:active {
+ color: red;
+}
+tags-input .tags .input {
+ border: 0;
+ outline: none;
+ margin: 2px;
+ padding: 0;
+ padding-left: 5px;
+ float: left;
+ height: 26px;
+ /*font: 14px "Helvetica Neue", Helvetica, Arial, sans-serif;*/
+}
+tags-input .tags .input.invalid-tag {
+ color: red;
+}
+tags-input .tags .input::-ms-clear {
+ display: none;
+}
+tags-input.ng-invalid .tags {
+ -webkit-box-shadow: 0 0 3px 1px rgba(255, 0, 0, 0.6);
+ -moz-box-shadow: 0 0 3px 1px rgba(255, 0, 0, 0.6);
+ box-shadow: 0 0 3px 1px rgba(255, 0, 0, 0.6);
+}
+
+tags-input .autocomplete {
+ margin-top: 5px;
+ position: absolute;
+ padding: 5px 0;
+ z-index: 999;
+ width: 100%;
+ background-color: white;
+ border: 1px solid rgba(0, 0, 0, 0.2);
+ -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
+ -moz-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
+ box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
+}
+tags-input .autocomplete .suggestion-list {
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+}
+tags-input .autocomplete .suggestion-item {
+ padding: 5px 10px;
+ cursor: pointer;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font: 16px "Helvetica Neue", Helvetica, Arial, sans-serif;
+ color: black;
+ background-color: white;
+}
+tags-input .autocomplete .suggestion-item.selected {
+ color: white;
+ background-color: #0097cf;
+}
+tags-input .autocomplete .suggestion-item.selected em {
+ color: white;
+ background-color: #0097cf;
+}
+tags-input .autocomplete .suggestion-item em {
+ font: normal bold 16px "Helvetica Neue", Helvetica, Arial, sans-serif;
+ color: black;
+ background-color: white;
+}
diff --git a/monitoring/static/monitoring/js/app.js b/monitoring/static/monitoring/js/app.js
index 69bbe302..54c28e61 100644
--- a/monitoring/static/monitoring/js/app.js
+++ b/monitoring/static/monitoring/js/app.js
@@ -2,5 +2,5 @@
// Declare app level module which depends on filters, and services
angular.module('monitoringApp', [
- 'monitoring.controllers'
+ 'monitoring.controllers', 'ngTagsInput'
]);
diff --git a/monitoring/static/monitoring/js/controllers.js b/monitoring/static/monitoring/js/controllers.js
index f7b5c05f..1a36b586 100644
--- a/monitoring/static/monitoring/js/controllers.js
+++ b/monitoring/static/monitoring/js/controllers.js
@@ -23,16 +23,53 @@ angular.module('monitoring.controllers', [])
window.top.location.reload(true)
});
}
- $scope.onTimeout = function(){
- mytimeout = $timeout($scope.onTimeout,10000);
- $scope.fetchStatus()
- }
- var mytimeout = $timeout($scope.onTimeout,10000);
+ $scope.onTimeout = function(){
+ mytimeout = $timeout($scope.onTimeout,10000);
+ $scope.fetchStatus()
+ }
+ var mytimeout = $timeout($scope.onTimeout,10000);
+
+ $scope.stop = function(){
+ $timeout.cancel(mytimeout);
+ }
+ })
+ .controller('alarmEditController', function ($scope, $http, $timeout, $q) {
+ $scope.metrics = metricsList;
+ $scope.currentMetric = $scope.metrics[0];
+ $scope.possibleDimensions = function() {
+ var deferred = $q.defer();
+ deferred.resolve($scope.currentMetric["dimensions"]);
+ return deferred.promise;
+ };
+ $scope.metricChanged = function() {
+ if ($scope.defaultTag.length > 0) {
+ $scope.tags = [{text: $scope.defaultTag}];
+ }
+ $scope.saveDimension();
+ }
+ $scope.saveDimension = function() {
+ $('#dimension').val($scope.formatDimension());
+ }
+ $scope.formatDimension = function() {
+ var dim = ''
+ angular.forEach($scope.tags, function(value, key) {
+ if (dim.length) {
+ dim += ','
+ }
+ dim += value['text']
+ })
+ return $scope.currentMetric['name'] + '{' + dim + '}';
+ }
+ $scope.init = function(defaultTag) {
+ if (defaultTag.length > 0) {
+ $scope.defaultTag = defaultTag;
+ $scope.tags = [{text: $scope.defaultTag}];
+ }
+ $scope.saveDimension();
+ }
- $scope.stop = function(){
- $timeout.cancel(mytimeout);
- }
});
+
function getIcon(status) {
if (status === 'chicklet-error')
return '/static/monitoring/img/critical-icon.png'
diff --git a/monitoring/static/monitoring/js/ng-tags-input.js b/monitoring/static/monitoring/js/ng-tags-input.js
new file mode 100755
index 00000000..66dbea94
--- /dev/null
+++ b/monitoring/static/monitoring/js/ng-tags-input.js
@@ -0,0 +1,776 @@
+/*!
+ * ngTagsInput v2.0.1
+ * http://mbenford.github.io/ngTagsInput
+ *
+ * Copyright (c) 2013-2014 Michael Benford
+ * License: MIT
+ *
+ * Generated at 2014-04-13 21:25:38 -0300
+ */
+(function() {
+'use strict';
+
+var KEYS = {
+ backspace: 8,
+ tab: 9,
+ enter: 13,
+ escape: 27,
+ space: 32,
+ up: 38,
+ down: 40,
+ comma: 188
+};
+
+function SimplePubSub() {
+ var events = {};
+ return {
+ on: function(names, handler) {
+ names.split(' ').forEach(function(name) {
+ if (!events[name]) {
+ events[name] = [];
+ }
+ events[name].push(handler);
+ });
+ return this;
+ },
+ trigger: function(name, args) {
+ angular.forEach(events[name], function(handler) {
+ handler.call(null, args);
+ });
+ return this;
+ }
+ };
+}
+
+function makeObjectArray(array, key) {
+ array = array || [];
+ if (array.length > 0 && !angular.isObject(array[0])) {
+ array.forEach(function(item, index) {
+ array[index] = {};
+ array[index][key] = item;
+ });
+ }
+ return array;
+}
+
+function findInObjectArray(array, obj, key) {
+ var item = null;
+ for (var i = 0; i < array.length; i++) {
+ // I'm aware of the internationalization issues regarding toLowerCase()
+ // but I couldn't come up with a better solution right now
+ if (array[i][key].toLowerCase() === obj[key].toLowerCase()) {
+ item = array[i];
+ break;
+ }
+ }
+ return item;
+}
+
+function replaceAll(str, substr, newSubstr) {
+ var expression = substr.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1');
+ return str.replace(new RegExp(expression, 'gi'), newSubstr);
+}
+
+var tagsInput = angular.module('ngTagsInput', []);
+
+/**
+ * @ngdoc directive
+ * @name tagsInput
+ * @module ngTagsInput
+ *
+ * @description
+ * Renders an input box with tag editing support.
+ *
+ * @param {string} ngModel Assignable angular expression to data-bind to.
+ * @param {string=} [displayProperty=text] Property to be rendered as the tag label.
+ * @param {number=} tabindex Tab order of the control.
+ * @param {string=} [placeholder=Add a tag] Placeholder text for the control.
+ * @param {number=} [minLength=3] Minimum length for a new tag.
+ * @param {number=} maxLength Maximum length allowed for a new tag.
+ * @param {number=} minTags Sets minTags validation error key if the number of tags added is less than minTags.
+ * @param {number=} maxTags Sets maxTags validation error key if the number of tags added is greater than maxTags.
+ * @param {boolean=} [allowLeftoverText=false] Sets leftoverText validation error key if there is any leftover text in
+ * the input element when the directive loses focus.
+ * @param {string=} [removeTagSymbol=×] Symbol character for the remove tag button.
+ * @param {boolean=} [addOnEnter=true] Flag indicating that a new tag will be added on pressing the ENTER key.
+ * @param {boolean=} [addOnSpace=false] Flag indicating that a new tag will be added on pressing the SPACE key.
+ * @param {boolean=} [addOnComma=true] Flag indicating that a new tag will be added on pressing the COMMA key.
+ * @param {boolean=} [addOnBlur=true] Flag indicating that a new tag will be added when the input field loses focus.
+ * @param {boolean=} [replaceSpacesWithDashes=true] Flag indicating that spaces will be replaced with dashes.
+ * @param {string=} [allowedTagsPattern=.+] Regular expression that determines whether a new tag is valid.
+ * @param {boolean=} [enableEditingLastTag=false] Flag indicating that the last tag will be moved back into
+ * the new tag input box instead of being removed when the backspace key
+ * is pressed and the input box is empty.
+ * @param {boolean=} [addFromAutocompleteOnly=false] Flag indicating that only tags coming from the autocomplete list will be allowed.
+ * When this flag is true, addOnEnter, addOnComma, addOnSpace, addOnBlur and
+ * allowLeftoverText values are ignored.
+ * @param {expression} onTagAdded Expression to evaluate upon adding a new tag. The new tag is available as $tag.
+ * @param {expression} onTagRemoved Expression to evaluate upon removing an existing tag. The removed tag is available as $tag.
+ */
+tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", function($timeout, $document, tagsInputConfig) {
+ function TagList(options, events) {
+ var self = {}, getTagText, setTagText, tagIsValid;
+
+ getTagText = function(tag) {
+ return tag[options.displayProperty];
+ };
+
+ setTagText = function(tag, text) {
+ tag[options.displayProperty] = text;
+ };
+
+ tagIsValid = function(tag) {
+ var tagText = getTagText(tag);
+
+ return tagText.length >= options.minLength &&
+ tagText.length <= (options.maxLength || tagText.length) &&
+ options.allowedTagsPattern.test(tagText) &&
+ !findInObjectArray(self.items, tag, options.displayProperty);
+ };
+
+ self.items = [];
+
+ self.addText = function(text) {
+ var tag = {};
+ setTagText(tag, text);
+ return self.add(tag);
+ };
+
+ self.add = function(tag) {
+ var tagText = getTagText(tag).trim();
+
+ if (options.replaceSpacesWithDashes) {
+ tagText = tagText.replace(/\s/g, '-');
+ }
+
+ setTagText(tag, tagText);
+
+ if (tagIsValid(tag)) {
+ self.items.push(tag);
+ events.trigger('tag-added', { $tag: tag });
+ }
+ else {
+ events.trigger('invalid-tag', { $tag: tag });
+ }
+
+ return tag;
+ };
+
+ self.remove = function(index) {
+ var tag = self.items.splice(index, 1)[0];
+ events.trigger('tag-removed', { $tag: tag });
+ return tag;
+ };
+
+ self.removeLast = function() {
+ var tag, lastTagIndex = self.items.length - 1;
+
+ if (options.enableEditingLastTag || self.selected) {
+ self.selected = null;
+ tag = self.remove(lastTagIndex);
+ }
+ else if (!self.selected) {
+ self.selected = self.items[lastTagIndex];
+ }
+
+ return tag;
+ };
+
+ return self;
+ }
+
+ return {
+ restrict: 'E',
+ require: 'ngModel',
+ scope: {
+ tags: '=ngModel',
+ onTagAdded: '&',
+ onTagRemoved: '&'
+ },
+ replace: false,
+ transclude: true,
+ templateUrl: 'ngTagsInput/tags-input.html',
+ controller: ["$scope","$attrs","$element", function($scope, $attrs, $element) {
+ tagsInputConfig.load('tagsInput', $scope, $attrs, {
+ placeholder: [String, 'Add a tag'],
+ tabindex: [Number],
+ removeTagSymbol: [String, String.fromCharCode(215)],
+ replaceSpacesWithDashes: [Boolean, true],
+ minLength: [Number, 3],
+ maxLength: [Number],
+ addOnEnter: [Boolean, true],
+ addOnSpace: [Boolean, false],
+ addOnComma: [Boolean, true],
+ addOnBlur: [Boolean, true],
+ allowedTagsPattern: [RegExp, /.+/],
+ enableEditingLastTag: [Boolean, false],
+ minTags: [Number],
+ maxTags: [Number],
+ displayProperty: [String, 'text'],
+ allowLeftoverText: [Boolean, false],
+ addFromAutocompleteOnly: [Boolean, false]
+ });
+
+ $scope.events = new SimplePubSub();
+ $scope.tagList = new TagList($scope.options, $scope.events);
+
+ this.registerAutocomplete = function() {
+ var input = $element.find('input');
+ input.on('keydown', function(e) {
+ $scope.events.trigger('input-keydown', e);
+ });
+
+ return {
+ addTag: function(tag) {
+ return $scope.tagList.add(tag);
+ },
+ focusInput: function() {
+ input[0].focus();
+ },
+ getTags: function() {
+ return $scope.tags;
+ },
+ getOptions: function() {
+ return $scope.options;
+ },
+ on: function(name, handler) {
+ $scope.events.on(name, handler);
+ return this;
+ }
+ };
+ };
+ }],
+ link: function(scope, element, attrs, ngModelCtrl) {
+ var hotkeys = [KEYS.enter, KEYS.comma, KEYS.space, KEYS.backspace],
+ tagList = scope.tagList,
+ events = scope.events,
+ options = scope.options,
+ input = element.find('input');
+
+ events
+ .on('tag-added', scope.onTagAdded)
+ .on('tag-removed', scope.onTagRemoved)
+ .on('tag-added', function() {
+ scope.newTag.text = '';
+ })
+ .on('tag-added tag-removed', function() {
+ ngModelCtrl.$setViewValue(scope.tags);
+ })
+ .on('invalid-tag', function() {
+ scope.newTag.invalid = true;
+ })
+ .on('input-change', function() {
+ tagList.selected = null;
+ scope.newTag.invalid = null;
+ })
+ .on('input-focus', function() {
+ ngModelCtrl.$setValidity('leftoverText', true);
+ })
+ .on('input-blur', function() {
+ if (!options.addFromAutocompleteOnly) {
+ if (options.addOnBlur) {
+ tagList.addText(scope.newTag.text);
+ }
+
+ ngModelCtrl.$setValidity('leftoverText', options.allowLeftoverText ? true : !scope.newTag.text);
+ }
+ });
+
+ scope.newTag = { text: '', invalid: null };
+
+ scope.getDisplayText = function(tag) {
+ return tag[options.displayProperty].trim();
+ };
+
+ scope.track = function(tag) {
+ return tag[options.displayProperty];
+ };
+
+ scope.newTagChange = function() {
+ events.trigger('input-change', scope.newTag.text);
+ };
+
+ scope.$watch('tags', function(value) {
+ scope.tags = makeObjectArray(value, options.displayProperty);
+ tagList.items = scope.tags;
+ });
+
+ scope.$watch('tags.length', function(value) {
+ ngModelCtrl.$setValidity('maxTags', angular.isUndefined(options.maxTags) || value <= options.maxTags);
+ ngModelCtrl.$setValidity('minTags', angular.isUndefined(options.minTags) || value >= options.minTags);
+ });
+
+ input
+ .on('keydown', function(e) {
+ // This hack is needed because jqLite doesn't implement stopImmediatePropagation properly.
+ // I've sent a PR to Angular addressing this issue and hopefully it'll be fixed soon.
+ // https://github.com/angular/angular.js/pull/4833
+ if (e.isImmediatePropagationStopped && e.isImmediatePropagationStopped()) {
+ return;
+ }
+
+ var key = e.keyCode,
+ isModifier = e.shiftKey || e.altKey || e.ctrlKey || e.metaKey,
+ addKeys = {},
+ shouldAdd, shouldRemove;
+
+ if (isModifier || hotkeys.indexOf(key) === -1) {
+ return;
+ }
+
+ addKeys[KEYS.enter] = options.addOnEnter;
+ addKeys[KEYS.comma] = options.addOnComma;
+ addKeys[KEYS.space] = options.addOnSpace;
+
+ shouldAdd = !options.addFromAutocompleteOnly && addKeys[key];
+ shouldRemove = !shouldAdd && key === KEYS.backspace && scope.newTag.text.length === 0;
+
+ if (shouldAdd) {
+ tagList.addText(scope.newTag.text);
+
+ scope.$apply();
+ e.preventDefault();
+ }
+ else if (shouldRemove) {
+ var tag = tagList.removeLast();
+ if (tag && options.enableEditingLastTag) {
+ scope.newTag.text = tag[options.displayProperty];
+ }
+
+ scope.$apply();
+ e.preventDefault();
+ }
+ })
+ .on('focus', function() {
+ if (scope.hasFocus) {
+ return;
+ }
+ scope.hasFocus = true;
+ events.trigger('input-focus');
+
+ scope.$apply();
+ })
+ .on('blur', function() {
+ $timeout(function() {
+ var activeElement = $document.prop('activeElement'),
+ lostFocusToBrowserWindow = activeElement === input[0],
+ lostFocusToChildElement = element[0].contains(activeElement);
+
+ if (lostFocusToBrowserWindow || !lostFocusToChildElement) {
+ scope.hasFocus = false;
+ events.trigger('input-blur');
+ }
+ });
+ });
+
+ element.find('div').on('click', function() {
+ input[0].focus();
+ });
+ }
+ };
+}]);
+
+/**
+ * @ngdoc directive
+ * @name autoComplete
+ * @module ngTagsInput
+ *
+ * @description
+ * Provides autocomplete support for the tagsInput directive.
+ *
+ * @param {expression} source Expression to evaluate upon changing the input content. The input value is available as
+ * $query. The result of the expression must be a promise that eventually resolves to an
+ * array of strings.
+ * @param {number=} [debounceDelay=100] Amount of time, in milliseconds, to wait before evaluating the expression in
+ * the source option after the last keystroke.
+ * @param {number=} [minLength=3] Minimum number of characters that must be entered before evaluating the expression
+ * in the source option.
+ * @param {boolean=} [highlightMatchedText=true] Flag indicating that the matched text will be highlighted in the
+ * suggestions list.
+ * @param {number=} [maxResultsToShow=10] Maximum number of results to be displayed at a time.
+ */
+tagsInput.directive('autoComplete', ["$document","$timeout","$sce","tagsInputConfig", function($document, $timeout, $sce, tagsInputConfig) {
+ function SuggestionList(loadFn, options) {
+ var self = {}, debouncedLoadId, getDifference, lastPromise;
+
+ getDifference = function(array1, array2) {
+ return array1.filter(function(item) {
+ return !findInObjectArray(array2, item, options.tagsInput.displayProperty);
+ });
+ };
+
+ self.reset = function() {
+ lastPromise = null;
+
+ self.items = [];
+ self.visible = false;
+ self.index = -1;
+ self.selected = null;
+ self.query = null;
+
+ $timeout.cancel(debouncedLoadId);
+ };
+ self.show = function() {
+ self.selected = null;
+ self.visible = true;
+ };
+ self.load = function(query, tags) {
+ if (query.length < options.minLength) {
+ self.reset();
+ return;
+ }
+
+ $timeout.cancel(debouncedLoadId);
+ debouncedLoadId = $timeout(function() {
+ self.query = query;
+
+ var promise = loadFn({ $query: query });
+ lastPromise = promise;
+
+ promise.then(function(items) {
+ if (promise !== lastPromise) {
+ return;
+ }
+
+ items = makeObjectArray(items.data || items, options.tagsInput.displayProperty);
+ items = getDifference(items, tags);
+ self.items = items.slice(0, options.maxResultsToShow);
+
+ if (self.items.length > 0) {
+ self.show();
+ }
+ else {
+ self.reset();
+ }
+ });
+ }, options.debounceDelay, false);
+ };
+ self.selectNext = function() {
+ self.select(++self.index);
+ };
+ self.selectPrior = function() {
+ self.select(--self.index);
+ };
+ self.select = function(index) {
+ if (index < 0) {
+ index = self.items.length - 1;
+ }
+ else if (index >= self.items.length) {
+ index = 0;
+ }
+ self.index = index;
+ self.selected = self.items[index];
+ };
+
+ self.reset();
+
+ return self;
+ }
+
+ function encodeHTML(value) {
+ return value.replace(/&/g, '&')
+ .replace(//g, '>');
+ }
+
+ return {
+ restrict: 'E',
+ require: '^tagsInput',
+ scope: { source: '&' },
+ templateUrl: 'ngTagsInput/auto-complete.html',
+ link: function(scope, element, attrs, tagsInputCtrl) {
+ var hotkeys = [KEYS.enter, KEYS.tab, KEYS.escape, KEYS.up, KEYS.down],
+ suggestionList, tagsInput, options, getItemText, documentClick;
+
+ tagsInputConfig.load('autoComplete', scope, attrs, {
+ debounceDelay: [Number, 100],
+ minLength: [Number, 3],
+ highlightMatchedText: [Boolean, true],
+ maxResultsToShow: [Number, 10]
+ });
+
+ options = scope.options;
+
+ tagsInput = tagsInputCtrl.registerAutocomplete();
+ options.tagsInput = tagsInput.getOptions();
+
+ suggestionList = new SuggestionList(scope.source, options);
+
+ getItemText = function(item) {
+ return item[options.tagsInput.displayProperty];
+ };
+
+ scope.suggestionList = suggestionList;
+
+ scope.addSuggestion = function() {
+ var added = false;
+
+ if (suggestionList.selected) {
+ tagsInput.addTag(suggestionList.selected);
+ suggestionList.reset();
+ tagsInput.focusInput();
+
+ added = true;
+ }
+ return added;
+ };
+
+ scope.highlight = function(item) {
+ var text = getItemText(item);
+ text = encodeHTML(text);
+ if (options.highlightMatchedText) {
+ text = replaceAll(text, encodeHTML(suggestionList.query), '$&');
+ }
+ return $sce.trustAsHtml(text);
+ };
+
+ scope.track = function(item) {
+ return getItemText(item);
+ };
+
+ tagsInput
+ .on('tag-added invalid-tag', function() {
+ suggestionList.reset();
+ })
+ .on('input-change', function(value) {
+ if (value) {
+ suggestionList.load(value, tagsInput.getTags());
+ } else {
+ suggestionList.reset();
+ }
+ })
+ .on('input-keydown', function(e) {
+ var key, handled;
+
+ if (hotkeys.indexOf(e.keyCode) === -1) {
+ return;
+ }
+
+ // This hack is needed because jqLite doesn't implement stopImmediatePropagation properly.
+ // I've sent a PR to Angular addressing this issue and hopefully it'll be fixed soon.
+ // https://github.com/angular/angular.js/pull/4833
+ var immediatePropagationStopped = false;
+ e.stopImmediatePropagation = function() {
+ immediatePropagationStopped = true;
+ e.stopPropagation();
+ };
+ e.isImmediatePropagationStopped = function() {
+ return immediatePropagationStopped;
+ };
+
+ if (suggestionList.visible) {
+ key = e.keyCode;
+ handled = false;
+
+ if (key === KEYS.down) {
+ suggestionList.selectNext();
+ handled = true;
+ }
+ else if (key === KEYS.up) {
+ suggestionList.selectPrior();
+ handled = true;
+ }
+ else if (key === KEYS.escape) {
+ suggestionList.reset();
+ handled = true;
+ }
+ else if (key === KEYS.enter || key === KEYS.tab) {
+ handled = scope.addSuggestion();
+ }
+
+ if (handled) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ scope.$apply();
+ }
+ }
+ })
+ .on('input-blur', function() {
+ suggestionList.reset();
+ });
+
+ documentClick = function() {
+ if (suggestionList.visible) {
+ suggestionList.reset();
+ scope.$apply();
+ }
+ };
+
+ $document.on('click', documentClick);
+
+ scope.$on('$destroy', function() {
+ $document.off('click', documentClick);
+ });
+ }
+ };
+}]);
+
+/**
+ * @ngdoc directive
+ * @name tiTranscludeAppend
+ * @module ngTagsInput
+ *
+ * @description
+ * Re-creates the old behavior of ng-transclude. Used internally by tagsInput directive.
+ */
+tagsInput.directive('tiTranscludeAppend', function() {
+ return function(scope, element, attrs, ctrl, transcludeFn) {
+ transcludeFn(function(clone) {
+ element.append(clone);
+ });
+ };
+});
+
+/**
+ * @ngdoc directive
+ * @name tiAutosize
+ * @module ngTagsInput
+ *
+ * @description
+ * Automatically sets the input's width so its content is always visible. Used internally by tagsInput directive.
+ */
+tagsInput.directive('tiAutosize', function() {
+ return {
+ restrict: 'A',
+ require: 'ngModel',
+ link: function(scope, element, attrs, ctrl) {
+ var THRESHOLD = 3,
+ span, resize;
+
+ span = angular.element('');
+ span.css('display', 'none')
+ .css('visibility', 'hidden')
+ .css('width', 'auto')
+ .css('white-space', 'pre');
+
+ element.parent().append(span);
+
+ resize = function(originalValue) {
+ var value = originalValue, width;
+
+ if (angular.isString(value) && value.length === 0) {
+ value = attrs.placeholder;
+ }
+
+ if (value) {
+ span.text(value);
+ span.css('display', '');
+ width = span.prop('offsetWidth');
+ span.css('display', 'none');
+ }
+
+ element.css('width', width ? width + THRESHOLD + 'px' : '');
+
+ return originalValue;
+ };
+
+ ctrl.$parsers.unshift(resize);
+ ctrl.$formatters.unshift(resize);
+
+ attrs.$observe('placeholder', function(value) {
+ if (!ctrl.$modelValue) {
+ resize(value);
+ }
+ });
+ }
+ };
+});
+
+/**
+ * @ngdoc service
+ * @name tagsInputConfig
+ * @module ngTagsInput
+ *
+ * @description
+ * Sets global configuration settings for both tagsInput and autoComplete directives. It's also used internally to parse and
+ * initialize options from HTML attributes.
+ */
+tagsInput.provider('tagsInputConfig', function() {
+ var globalDefaults = {}, interpolationStatus = {};
+
+ /**
+ * @ngdoc method
+ * @name setDefaults
+ * @description Sets the default configuration option for a directive.
+ * @methodOf tagsInputConfig
+ *
+ * @param {string} directive Name of the directive to be configured. Must be either 'tagsInput' or 'autoComplete'.
+ * @param {object} defaults Object containing options and their values.
+ *
+ * @returns {object} The service itself for chaining purposes.
+ */
+ this.setDefaults = function(directive, defaults) {
+ globalDefaults[directive] = defaults;
+ return this;
+ };
+
+ /***
+ * @ngdoc method
+ * @name setActiveInterpolation
+ * @description Sets active interpolation for a set of options.
+ * @methodOf tagsInputConfig
+ *
+ * @param {string} directive Name of the directive to be configured. Must be either 'tagsInput' or 'autoComplete'.
+ * @param {object} options Object containing which options should have interpolation turned on at all times.
+ *
+ * @returns {object} The service itself for chaining purposes.
+ */
+ this.setActiveInterpolation = function(directive, options) {
+ interpolationStatus[directive] = options;
+ return this;
+ };
+
+ this.$get = ["$interpolate", function($interpolate) {
+ var converters = {};
+ converters[String] = function(value) { return value; };
+ converters[Number] = function(value) { return parseInt(value, 10); };
+ converters[Boolean] = function(value) { return value.toLowerCase() === 'true'; };
+ converters[RegExp] = function(value) { return new RegExp(value); };
+
+ return {
+ load: function(directive, scope, attrs, options) {
+ scope.options = {};
+
+ angular.forEach(options, function(value, key) {
+ var type, localDefault, converter, getDefault, updateValue;
+
+ type = value[0];
+ localDefault = value[1];
+ converter = converters[type];
+
+ getDefault = function() {
+ var globalValue = globalDefaults[directive] && globalDefaults[directive][key];
+ return angular.isDefined(globalValue) ? globalValue : localDefault;
+ };
+
+ updateValue = function(value) {
+ scope.options[key] = value ? converter(value) : getDefault();
+ };
+
+ if (interpolationStatus[directive] && interpolationStatus[directive][key]) {
+ attrs.$observe(key, function(value) {
+ updateValue(value);
+ });
+ }
+ else {
+ updateValue(attrs[key] && $interpolate(attrs[key])(scope.$parent));
+ }
+ });
+ }
+ };
+ }];
+});
+
+
+/* HTML templates */
+tagsInput.run(["$templateCache", function($templateCache) {
+ $templateCache.put('ngTagsInput/tags-input.html',
+ ""
+ );
+
+ $templateCache.put('ngTagsInput/auto-complete.html',
+ ""
+ );
+}]);
+
+}());
\ No newline at end of file