Implement dimension chooser angular field
This commit is contained in:
parent
5ca6b92830
commit
a26f22c9f6
@ -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,),
|
||||
}
|
@ -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 += ', '
|
||||
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:
|
||||
args = ''
|
||||
args += "%s=%s" % (name, value)
|
||||
return "%s{%s}" % (expr, args)
|
||||
dim = next(("%s=%s" % (k, v) for k, v in self.initial.items()), '')
|
||||
final_attrs['service'] = dim
|
||||
output = '''
|
||||
<div ng-controller="alarmEditController" ng-init="init('%(service)s')">
|
||||
<input type="hidden" name="%(name)s" id="dimension"/>
|
||||
<select id="metric-chooser" ng-model="currentMetric" ng-options="metric.name for metric in metrics | orderBy:'name'" ng-change="metricChanged()"></select>
|
||||
<tags-input id="dimension-chooser" ng-model="tags" placeholder="%(placeholder)s" add-from-autocomplete-only="true" max-results-to-show="20" on-tag-added="saveDimension()" on-tag-removed="saveDimension()">
|
||||
<auto-complete source="possibleDimensions()" min-length="1">
|
||||
</auto-complete>
|
||||
</tags-input>
|
||||
</div>
|
||||
''' % 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
|
||||
|
@ -40,18 +40,14 @@ $('#notification_table').on('click', '#remove_notification_button', (function(ev
|
||||
target.remove();
|
||||
return false;
|
||||
}));
|
||||
metricsList = {{ metrics | safe }}
|
||||
</script>
|
||||
<style>
|
||||
#id_expression_0 {
|
||||
width: 65px;
|
||||
}
|
||||
#id_expression_2 {
|
||||
width: 60px;
|
||||
}
|
||||
#id_expression_3 {
|
||||
width: 80px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
<link href='{{ STATIC_URL }}/monitoring/css/ng-tags-input.css' type="text/css" rel="stylesheet"/>
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
|
@ -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):
|
||||
|
129
monitoring/static/monitoring/css/ng-tags-input.css
Executable file
129
monitoring/static/monitoring/css/ng-tags-input.css
Executable file
@ -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;
|
||||
}
|
@ -2,5 +2,5 @@
|
||||
|
||||
// Declare app level module which depends on filters, and services
|
||||
angular.module('monitoringApp', [
|
||||
'monitoring.controllers'
|
||||
'monitoring.controllers', 'ngTagsInput'
|
||||
]);
|
||||
|
@ -32,7 +32,44 @@ angular.module('monitoring.controllers', [])
|
||||
$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();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
function getIcon(status) {
|
||||
if (status === 'chicklet-error')
|
||||
return '/static/monitoring/img/critical-icon.png'
|
||||
|
776
monitoring/static/monitoring/js/ng-tags-input.js
Executable file
776
monitoring/static/monitoring/js/ng-tags-input.js
Executable file
@ -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, '<')
|
||||
.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), '<em>$&</em>');
|
||||
}
|
||||
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 class="input"></span>');
|
||||
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',
|
||||
"<div class=\"host\" tabindex=\"-1\" ti-transclude-append=\"\"><div class=\"tags\" ng-class=\"{focused: hasFocus}\"><ul class=\"tag-list\"><li class=\"tag-item\" ng-repeat=\"tag in tagList.items track by track(tag)\" ng-class=\"{ selected: tag == tagList.selected }\"><span>{{getDisplayText(tag)}}</span> <a class=\"remove-button\" ng-click=\"tagList.remove($index)\">{{options.removeTagSymbol}}</a></li></ul><input class=\"input\" placeholder=\"{{options.placeholder}}\" tabindex=\"{{options.tabindex}}\" ng-model=\"newTag.text\" ng-change=\"newTagChange()\" ng-trim=\"false\" ng-class=\"{'invalid-tag': newTag.invalid}\" ti-autosize=\"\"></div></div>"
|
||||
);
|
||||
|
||||
$templateCache.put('ngTagsInput/auto-complete.html',
|
||||
"<div class=\"autocomplete\" ng-show=\"suggestionList.visible\"><ul class=\"suggestion-list\"><li class=\"suggestion-item\" ng-repeat=\"item in suggestionList.items track by track(item)\" ng-class=\"{selected: item == suggestionList.selected}\" ng-click=\"addSuggestion()\" ng-mouseenter=\"suggestionList.select($index)\" ng-bind-html=\"highlight(item)\"></li></ul></div>"
|
||||
);
|
||||
}]);
|
||||
|
||||
}());
|
Loading…
Reference in New Issue
Block a user