From a26f22c9f6c769f811b9d2302adf62a6b18bd7c9 Mon Sep 17 00:00:00 2001 From: Rob Raymond Date: Thu, 26 Jun 2014 10:05:05 -0600 Subject: [PATCH] Implement dimension chooser angular field --- enabled/_50_admin_add_monitoring_panel.py | 10 +- monitoring/alarms/forms.py | 51 +- .../alarms/templates/alarms/_create.html | 10 +- monitoring/alarms/views.py | 16 + .../static/monitoring/css/ng-tags-input.css | 129 +++ monitoring/static/monitoring/js/app.js | 2 +- .../static/monitoring/js/controllers.js | 53 +- .../static/monitoring/js/ng-tags-input.js | 776 ++++++++++++++++++ 8 files changed, 1007 insertions(+), 40 deletions(-) create mode 100755 monitoring/static/monitoring/css/ng-tags-input.css create mode 100755 monitoring/static/monitoring/js/ng-tags-input.js 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