diff --git a/services/web/app/coffee/Features/Contacts/ContactController.coffee b/services/web/app/coffee/Features/Contacts/ContactController.coffee
new file mode 100644
index 0000000000..3fc2160f59
--- /dev/null
+++ b/services/web/app/coffee/Features/Contacts/ContactController.coffee
@@ -0,0 +1,37 @@
+AuthenticationController = require "../Authentication/AuthenticationController"
+ContactManager = require "./ContactManager"
+UserGetter = require "../User/UserGetter"
+logger = require "logger-sharelatex"
+
+module.exports = ContactsController =
+ getContacts: (req, res, next) ->
+ AuthenticationController.getLoggedInUserId req, (error, user_id) ->
+ return next(error) if error?
+ ContactManager.getContactIds user_id, {limit: 50}, (error, contact_ids) ->
+ return next(error) if error?
+ UserGetter.getUsers contact_ids, {
+ email: 1, first_name: 1, last_name: 1, holdingAccount: 1
+ }, (error, contacts) ->
+ return next(error) if error?
+
+ # UserGetter.getUsers may not preserve order so put them back in order
+ positions = {}
+ for contact_id, i in contact_ids
+ positions[contact_id] = i
+ contacts.sort (a,b) -> positions[a._id?.toString()] - positions[b._id?.toString()]
+
+ # Don't count holding accounts to discourage users from repeating mistakes (mistyped or wrong emails, etc)
+ contacts = contacts.filter (c) -> !c.holdingAccount
+
+ contacts = contacts.map(ContactsController._formatContact)
+ res.send({
+ contacts: contacts
+ })
+
+ _formatContact: (contact) ->
+ return {
+ id: contact._id?.toString()
+ email: contact.email
+ first_name: contact.first_name
+ last_name: contact.last_name
+ }
diff --git a/services/web/app/coffee/Features/Contacts/ContactManager.coffee b/services/web/app/coffee/Features/Contacts/ContactManager.coffee
new file mode 100644
index 0000000000..8f08b5ea41
--- /dev/null
+++ b/services/web/app/coffee/Features/Contacts/ContactManager.coffee
@@ -0,0 +1,39 @@
+request = require "request"
+settings = require "settings-sharelatex"
+logger = require "logger-sharelatex"
+
+module.exports = ContactManager =
+ getContactIds: (user_id, options = { limits: 50 }, callback = (error, contacts) ->) ->
+ logger.log {user_id}, "getting user contacts"
+ url = "#{settings.apis.contacts.url}/user/#{user_id}/contacts"
+ request.get {
+ url: url
+ qs: options
+ json: true
+ jar: false
+ }, (error, res, data) ->
+ return callback(error) if error?
+ if 200 <= res.statusCode < 300
+ callback(null, data?.contact_ids or [])
+ else
+ error = new Error("contacts api responded with non-success code: #{res.statusCode}")
+ logger.error {err: error, user_id}, "error getting contacts for user"
+ callback(error)
+
+ addContact: (user_id, contact_id, callback = (error) ->) ->
+ logger.log {user_id, contact_id}, "add user contact"
+ url = "#{settings.apis.contacts.url}/user/#{user_id}/contacts"
+ request.post {
+ url: url
+ json: {
+ contact_id: contact_id
+ }
+ jar: false
+ }, (error, res, data) ->
+ return callback(error) if error?
+ if 200 <= res.statusCode < 300
+ callback(null, data?.contact_ids or [])
+ else
+ error = new Error("contacts api responded with non-success code: #{res.statusCode}")
+ logger.error {err: error, user_id, contact_id}, "error adding contact for user"
+ callback(error)
\ No newline at end of file
diff --git a/services/web/app/coffee/Features/Contacts/ContactRouter.coffee b/services/web/app/coffee/Features/Contacts/ContactRouter.coffee
new file mode 100644
index 0000000000..211a83c18f
--- /dev/null
+++ b/services/web/app/coffee/Features/Contacts/ContactRouter.coffee
@@ -0,0 +1,9 @@
+AuthenticationController = require('../Authentication/AuthenticationController')
+ContactController = require "./ContactController"
+
+module.exports =
+ apply: (webRouter, apiRouter) ->
+ webRouter.get '/user/contacts',
+ AuthenticationController.requireLogin(),
+ ContactController.getContacts
+
diff --git a/services/web/app/coffee/Features/User/UserGetter.coffee b/services/web/app/coffee/Features/User/UserGetter.coffee
index a0b2b766b7..c90cff38bc 100644
--- a/services/web/app/coffee/Features/User/UserGetter.coffee
+++ b/services/web/app/coffee/Features/User/UserGetter.coffee
@@ -16,3 +16,11 @@ module.exports = UserGetter =
query = _id: query
db.users.findOne query, projection, callback
+
+ getUsers: (user_ids, projection, callback = (error, users) ->) ->
+ try
+ user_ids = user_ids.map (u) -> ObjectId(u.toString())
+ catch error
+ return callback error
+
+ db.users.find { _id: { $in: user_ids} }, projection, callback
\ No newline at end of file
diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee
index aab3ea6f58..a0c1b50d21 100644
--- a/services/web/app/coffee/router.coffee
+++ b/services/web/app/coffee/router.coffee
@@ -36,6 +36,7 @@ Modules = require "./infrastructure/Modules"
RateLimiterMiddlewear = require('./Features/Security/RateLimiterMiddlewear')
RealTimeProxyRouter = require('./Features/RealTimeProxy/RealTimeProxyRouter')
InactiveProjectController = require("./Features/InactiveData/InactiveProjectController")
+ContactRouter = require("./Features/Contacts/ContactRouter")
logger = require("logger-sharelatex")
_ = require("underscore")
@@ -65,6 +66,7 @@ module.exports = class Router
PasswordResetRouter.apply(webRouter, apiRouter)
StaticPagesRouter.apply(webRouter, apiRouter)
RealTimeProxyRouter.apply(webRouter, apiRouter)
+ ContactRouter.apply(webRouter, apiRouter)
Modules.applyRouter(webRouter, apiRouter)
diff --git a/services/web/app/views/project/editor/share.jade b/services/web/app/views/project/editor/share.jade
index 4b73c414c4..00a3c6f9b4 100644
--- a/services/web/app/views/project/editor/share.jade
+++ b/services/web/app/views/project/editor/share.jade
@@ -47,12 +47,21 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
form(ng-show="canAddCollaborators")
.small #{translate("share_with_your_collabs")}
.form-group
- input.form-control(
- type="text"
+ tags-input(
+ template="shareTagTemplate"
placeholder="joe@example.com, sue@example.com, ..."
- ng-model="inputs.email"
+ ng-model="inputs.contacts"
focus-on="open"
+ display-property="email"
+ key-property="id"
+ add-on-paste="true"
)
+ auto-complete(
+ source="filterAutocompleteUsers($query)"
+ template="shareAutocompleteTemplate"
+ display-property="email"
+ min-length="0"
+ )
.form-group
.pull-right
select.privileges.form-control(
@@ -123,3 +132,21 @@ script(type="text/ng-template", id="makePrivateModalTemplate")
button.btn.btn-info(
ng-click="makePrivate()"
) #{translate("make_private")}
+
+script(type="text/ng-template", id="shareTagTemplate")
+ .tag-template
+ span(ng-if="data.type")
+ i.fa.fa-fw(ng-class="{'fa-user': data.type == 'user', 'fa-group': data.type == 'group'}")
+ |
+ span {{$getDisplayText()}}
+ |
+ a(href, ng-click="$removeTag()").remove-button
+ i.fa.fa-fw.fa-close
+
+script(type="text/ng-template", id="shareAutocompleteTemplate")
+ .autocomplete-template
+ i.fa(ng-class="{'fa-user': data.type == 'user', 'fa-group': data.type == 'group'}")
+ |
+ span(ng-bind-html="$highlight(data.email)")
+ div.subdued.small(ng-show="data.name", ng-bind-html="$highlight(data.name)")
+ div.subdued.small(ng-show="data.memberCount") {{ data.memberCount }} members
diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee
index 2b367871f0..85d25f4e9e 100644
--- a/services/web/config/settings.defaults.coffee
+++ b/services/web/config/settings.defaults.coffee
@@ -102,6 +102,8 @@ module.exports =
url: "http://localhost:8080/json"
realTime:
url: "http://localhost:3026"
+ contacts:
+ url: "http://localhost:3036"
templates:
user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2"
diff --git a/services/web/public/coffee/base.coffee b/services/web/public/coffee/base.coffee
index a58fb02f31..69242b1099 100644
--- a/services/web/public/coffee/base.coffee
+++ b/services/web/public/coffee/base.coffee
@@ -15,6 +15,7 @@ define [
"ipCookie"
"ErrorCatcher"
"localStorage"
+ "ngTagsInput"
])
return App
diff --git a/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee
index e569a2fee8..74243948ab 100644
--- a/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee
+++ b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee
@@ -1,7 +1,7 @@
define [
"base"
], (App) ->
- App.controller "ShareProjectModalController", ["$scope", "$modalInstance", "$timeout", "projectMembers", "$modal", ($scope, $modalInstance, $timeout, projectMembers, $modal) ->
+ App.controller "ShareProjectModalController", ($scope, $modalInstance, $timeout, projectMembers, $modal, $http) ->
$scope.inputs = {
privileges: "readAndWrite"
email: ""
@@ -22,34 +22,54 @@ define [
allowedNoOfMembers = $scope.project.features.collaborators
$scope.canAddCollaborators = noOfMembers < allowedNoOfMembers or allowedNoOfMembers == INFINITE_COLLABORATORS
+ $scope.$watchCollection "inputs.contacts", (value) ->
+ console.log "EMAILS", value
+
+ $scope.autocompleteContacts = []
+ do loadAutocompleteUsers = () ->
+ $http.get "/user/contacts"
+ .success (data) ->
+ console.log "Got contacts", data
+ $scope.autocompleteContacts = data.contacts or []
+
+ $scope.filterAutocompleteUsers = ($query) ->
+ return $scope.autocompleteContacts.filter (user) ->
+ for text in [user.name, user.email]
+ if text?.toLowerCase().indexOf($query.toLowerCase()) > -1
+ return true
+ return false
+
$scope.addMembers = () ->
- return if !$scope.inputs.email? or $scope.inputs.email == ""
+ $timeout -> # Give email list a chance to update
+ return if $scope.inputs.contacts.length == 0
- emails = $scope.inputs.email.split(/,\s*/)
- $scope.inputs.email = ""
- $scope.state.error = null
- $scope.state.inflight = true
-
- do addNextMember = () ->
- if emails.length == 0 or !$scope.canAddCollaborators
- $scope.state.inflight = false
- $scope.$apply()
- return
+ emails = $scope.inputs.contacts.map (contact) -> contact.email
+ $scope.inputs.contacts = []
+ $scope.state.error = null
+ $scope.state.inflight = true
- email = emails.shift()
- projectMembers
- .addMember(email, $scope.inputs.privileges)
- .success (data) ->
- if data?.user # data.user is false if collaborator limit is hit.
- $scope.project.members.push data.user
- setTimeout () ->
- # Give $scope a chance to update $scope.canAddCollaborators
- # with new collaborator information.
- addNextMember()
- , 0
- .error () ->
+ console.log "Adding emails", emails
+
+ do addNextMember = () ->
+ if emails.length == 0 or !$scope.canAddCollaborators
$scope.state.inflight = false
- $scope.state.error = "Sorry, something went wrong :("
+ $scope.$apply()
+ return
+
+ email = emails.shift()
+ projectMembers
+ .addMember(email, $scope.inputs.privileges)
+ .success (data) ->
+ if data?.user # data.user is false if collaborator limit is hit.
+ $scope.project.members.push data.user
+ setTimeout () ->
+ # Give $scope a chance to update $scope.canAddCollaborators
+ # with new collaborator information.
+ addNextMember()
+ , 0
+ .error () ->
+ $scope.state.inflight = false
+ $scope.state.error = "Sorry, something went wrong :("
$scope.removeMember = (member) ->
@@ -85,7 +105,6 @@ define [
$scope.cancel = () ->
$modalInstance.dismiss()
- ]
App.controller "MakePublicModalController", ["$scope", "$modalInstance", "settings", ($scope, $modalInstance, settings) ->
$scope.inputs = {
diff --git a/services/web/public/coffee/libs.coffee b/services/web/public/coffee/libs.coffee
index 99c8603ebc..7a809f8a06 100644
--- a/services/web/public/coffee/libs.coffee
+++ b/services/web/public/coffee/libs.coffee
@@ -10,4 +10,5 @@ define [
"libs/angular-sanitize-1.2.17"
"libs/angular-cookie"
"libs/passfield"
+ "libs/ng-tags-input-3.0.0"
], () ->
diff --git a/services/web/public/js/libs/ng-tags-input-3.0.0.js b/services/web/public/js/libs/ng-tags-input-3.0.0.js
new file mode 100644
index 0000000000..fb5f2d7fb4
--- /dev/null
+++ b/services/web/public/js/libs/ng-tags-input-3.0.0.js
@@ -0,0 +1,1151 @@
+/*!
+ * ngTagsInput v3.0.0
+ * http://mbenford.github.io/ngTagsInput
+ *
+ * Copyright (c) 2013-2015 Michael Benford
+ * License: MIT
+ *
+ * Generated at 2015-07-13 02:08:11 -0300
+ */
+(function() {
+'use strict';
+
+var KEYS = {
+ backspace: 8,
+ tab: 9,
+ enter: 13,
+ escape: 27,
+ space: 32,
+ up: 38,
+ down: 40,
+ left: 37,
+ right: 39,
+ delete: 46,
+ comma: 188
+};
+
+var MAX_SAFE_INTEGER = 9007199254740991;
+var SUPPORTED_INPUT_TYPES = ['text', 'email', 'url'];
+
+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=} [template=NA] URL or id of a custom template for rendering each tag.
+ * @param {string=} [displayProperty=text] Property to be rendered as the tag label.
+ * @param {string=} [keyProperty=text] Property to be used as a unique identifier for the tag.
+ * @param {string=} [type=text] Type of the input element. Only 'text', 'email' and 'url' are supported values.
+ * @param {string=} [text=NA] Assignable Angular expression for data-binding to the element's text.
+ * @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=MAX_SAFE_INTEGER] Maximum length allowed for a new tag.
+ * @param {number=} [minTags=0] Sets minTags validation error key if the number of tags added is less than minTags.
+ * @param {number=} [maxTags=MAX_SAFE_INTEGER] 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=×] (Obsolete) 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=} [addOnPaste=false] Flag indicating that the text pasted into the input field will be split into tags.
+ * @param {string=} [pasteSplitPattern=,] Regular expression used to split the pasted text into tags.
+ * @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 and addOnBlur values are ignored.
+ * @param {boolean=} [spellcheck=true] Flag indicating whether the browser's spellcheck is enabled for the input field or not.
+ * @param {expression=} [onTagAdding=NA] Expression to evaluate that will be invoked before adding a new tag. The new
+ * tag is available as $tag. This method must return either true or false. If false, the tag will not be added.
+ * @param {expression=} [onTagAdded=NA] Expression to evaluate upon adding a new tag. The new tag is available as $tag.
+ * @param {expression=} [onInvalidTag=NA] Expression to evaluate when a tag is invalid. The invalid tag is available as $tag.
+ * @param {expression=} [onTagRemoving=NA] Expression to evaluate that will be invoked before removing a tag. The tag
+ * is available as $tag. This method must return either true or false. If false, the tag will not be removed.
+ * @param {expression=} [onTagRemoved=NA] Expression to evaluate upon removing an existing tag. The removed tag is
+ * available as $tag.
+ * @param {expression=} [onTagClicked=NA] Expression to evaluate upon clicking an existing tag. The clicked tag is available as $tag.
+ */
+tagsInput.directive('tagsInput', ["$timeout", "$document", "$window", "tagsInputConfig", "tiUtil", function($timeout, $document, $window, tagsInputConfig, tiUtil) {
+ function TagList(options, events, onTagAdding, onTagRemoving) {
+ var self = {}, getTagText, setTagText, tagIsValid;
+
+ getTagText = function(tag) {
+ return tiUtil.safeToString(tag[options.displayProperty]);
+ };
+
+ setTagText = function(tag, text) {
+ tag[options.displayProperty] = text;
+ };
+
+ tagIsValid = function(tag) {
+ var tagText = getTagText(tag);
+
+ return tagText &&
+ tagText.length >= options.minLength &&
+ tagText.length <= options.maxLength &&
+ options.allowedTagsPattern.test(tagText) &&
+ !tiUtil.findInObjectArray(self.items, tag, options.keyProperty || options.displayProperty) &&
+ onTagAdding({ $tag: tag });
+ };
+
+ self.items = [];
+
+ self.addText = function(text) {
+ var tag = {};
+ setTagText(tag, text);
+ return self.add(tag);
+ };
+
+ self.add = function(tag) {
+ var tagText = getTagText(tag);
+
+ if (options.replaceSpacesWithDashes) {
+ tagText = tiUtil.replaceSpacesWithDashes(tagText);
+ }
+
+ setTagText(tag, tagText);
+
+ if (tagIsValid(tag)) {
+ self.items.push(tag);
+ events.trigger('tag-added', { $tag: tag });
+ }
+ else if (tagText) {
+ events.trigger('invalid-tag', { $tag: tag });
+ }
+
+ return tag;
+ };
+
+ self.remove = function(index) {
+ var tag = self.items[index];
+
+ if (onTagRemoving({ $tag: tag })) {
+ self.items.splice(index, 1);
+ self.clearSelection();
+ events.trigger('tag-removed', { $tag: tag });
+ return tag;
+ }
+ };
+
+ 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.selectPrior = function() {
+ self.select(--self.index);
+ };
+
+ self.selectNext = function() {
+ self.select(++self.index);
+ };
+
+ self.removeSelected = function() {
+ return self.remove(self.index);
+ };
+
+ self.clearSelection = function() {
+ self.selected = null;
+ self.index = -1;
+ };
+
+ self.clearSelection();
+
+ return self;
+ }
+
+ function validateType(type) {
+ return SUPPORTED_INPUT_TYPES.indexOf(type) !== -1;
+ }
+
+ return {
+ restrict: 'E',
+ require: 'ngModel',
+ scope: {
+ tags: '=ngModel',
+ text: '=?',
+ onTagAdding: '&',
+ onTagAdded: '&',
+ onInvalidTag: '&',
+ onTagRemoving: '&',
+ onTagRemoved: '&',
+ onTagClicked: '&'
+ },
+ replace: false,
+ transclude: true,
+ templateUrl: 'ngTagsInput/tags-input.html',
+ controller: ["$scope", "$attrs", "$element", function($scope, $attrs, $element) {
+ $scope.events = tiUtil.simplePubSub();
+
+ tagsInputConfig.load('tagsInput', $scope, $attrs, {
+ template: [String, 'ngTagsInput/tag-item.html'],
+ type: [String, 'text', validateType],
+ placeholder: [String, 'Add a tag'],
+ tabindex: [Number, null],
+ removeTagSymbol: [String, String.fromCharCode(215)],
+ replaceSpacesWithDashes: [Boolean, true],
+ minLength: [Number, 3],
+ maxLength: [Number, MAX_SAFE_INTEGER],
+ addOnEnter: [Boolean, true],
+ addOnSpace: [Boolean, false],
+ addOnComma: [Boolean, true],
+ addOnBlur: [Boolean, true],
+ addOnPaste: [Boolean, false],
+ pasteSplitPattern: [RegExp, /,/],
+ allowedTagsPattern: [RegExp, /.+/],
+ enableEditingLastTag: [Boolean, false],
+ minTags: [Number, 0],
+ maxTags: [Number, MAX_SAFE_INTEGER],
+ displayProperty: [String, 'text'],
+ keyProperty: [String, ''],
+ allowLeftoverText: [Boolean, false],
+ addFromAutocompleteOnly: [Boolean, false],
+ spellcheck: [Boolean, true]
+ });
+
+ $scope.tagList = new TagList($scope.options, $scope.events,
+ tiUtil.handleUndefinedResult($scope.onTagAdding, true),
+ tiUtil.handleUndefinedResult($scope.onTagRemoving, true));
+
+ this.registerAutocomplete = function() {
+ var input = $element.find('input');
+
+ return {
+ addTag: function(tag) {
+ return $scope.tagList.add(tag);
+ },
+ focusInput: function() {
+ input[0].focus();
+ },
+ getTags: function() {
+ return $scope.tagList.items;
+ },
+ getCurrentTagText: function() {
+ return $scope.newTag.text();
+ },
+ getOptions: function() {
+ return $scope.options;
+ },
+ on: function(name, handler) {
+ $scope.events.on(name, handler);
+ return this;
+ }
+ };
+ };
+
+ this.registerTagItem = function() {
+ return {
+ getOptions: function() {
+ return $scope.options;
+ },
+ removeTag: function(index) {
+ if ($scope.disabled) {
+ return;
+ }
+ $scope.tagList.remove(index);
+ }
+ };
+ };
+ }],
+ link: function(scope, element, attrs, ngModelCtrl) {
+ var hotkeys = [KEYS.enter, KEYS.comma, KEYS.space, KEYS.backspace, KEYS.delete, KEYS.left, KEYS.right],
+ tagList = scope.tagList,
+ events = scope.events,
+ options = scope.options,
+ input = element.find('input'),
+ validationOptions = ['minTags', 'maxTags', 'allowLeftoverText'],
+ setElementValidity;
+
+ setElementValidity = function() {
+ ngModelCtrl.$setValidity('maxTags', tagList.items.length <= options.maxTags);
+ ngModelCtrl.$setValidity('minTags', tagList.items.length >= options.minTags);
+ ngModelCtrl.$setValidity('leftoverText', scope.hasFocus || options.allowLeftoverText ? true : !scope.newTag.text());
+ };
+
+ ngModelCtrl.$isEmpty = function(value) {
+ return !value || !value.length;
+ };
+
+ scope.newTag = {
+ text: function(value) {
+ if (angular.isDefined(value)) {
+ scope.text = value;
+ events.trigger('input-change', value);
+ }
+ else {
+ return scope.text || '';
+ }
+ },
+ invalid: null
+ };
+
+ scope.track = function(tag) {
+ return tag[options.keyProperty || options.displayProperty];
+ };
+
+ scope.$watch('tags', function(value) {
+ if (value) {
+ tagList.items = tiUtil.makeObjectArray(value, options.displayProperty);
+ scope.tags = tagList.items;
+ }
+ else {
+ tagList.items = [];
+ }
+ });
+
+ scope.$watch('tags.length', function() {
+ setElementValidity();
+
+ // ngModelController won't trigger validators when the model changes (because it's an array),
+ // so we need to do it ourselves. Unfortunately this won't trigger any registered formatter.
+ ngModelCtrl.$validate();
+ });
+
+ attrs.$observe('disabled', function(value) {
+ scope.disabled = value;
+ });
+
+ scope.eventHandlers = {
+ input: {
+ keydown: function($event) {
+ events.trigger('input-keydown', $event);
+ },
+ focus: function() {
+ if (scope.hasFocus) {
+ return;
+ }
+
+ scope.hasFocus = true;
+ events.trigger('input-focus');
+ },
+ 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');
+ }
+ });
+ },
+ paste: function($event) {
+ $event.getTextData = function() {
+ var clipboardData = $event.clipboardData || ($event.originalEvent && $event.originalEvent.clipboardData);
+ return clipboardData ? clipboardData.getData('text/plain') : $window.clipboardData.getData('Text');
+ };
+ events.trigger('input-paste', $event);
+ }
+ },
+ host: {
+ click: function() {
+ if (scope.disabled) {
+ return;
+ }
+ input[0].focus();
+ }
+ },
+ tag: {
+ click: function(tag) {
+ events.trigger('tag-clicked', { $tag: tag });
+ }
+ }
+ };
+
+ events
+ .on('tag-added', scope.onTagAdded)
+ .on('invalid-tag', scope.onInvalidTag)
+ .on('tag-removed', scope.onTagRemoved)
+ .on('tag-clicked', scope.onTagClicked)
+ .on('tag-added', function() {
+ scope.newTag.text('');
+ })
+ .on('tag-added tag-removed', function() {
+ scope.tags = tagList.items;
+ // Ideally we should be able call $setViewValue here and let it in turn call $setDirty and $validate
+ // automatically, but since the model is an array, $setViewValue does nothing and it's up to us to do it.
+ // Unfortunately this won't trigger any registered $parser and there's no safe way to do it.
+ ngModelCtrl.$setDirty();
+ })
+ .on('invalid-tag', function() {
+ scope.newTag.invalid = true;
+ })
+ .on('option-change', function(e) {
+ if (validationOptions.indexOf(e.name) !== -1) {
+ setElementValidity();
+ }
+ })
+ .on('input-change', function() {
+ tagList.clearSelection();
+ scope.newTag.invalid = null;
+ })
+ .on('input-focus', function() {
+ element.triggerHandler('focus');
+ ngModelCtrl.$setValidity('leftoverText', true);
+ })
+ .on('input-blur', function() {
+ if (options.addOnBlur && !options.addFromAutocompleteOnly) {
+ tagList.addText(scope.newTag.text());
+ }
+ element.triggerHandler('blur');
+ setElementValidity();
+ })
+ .on('input-keydown', function(event) {
+ var key = event.keyCode,
+ addKeys = {},
+ shouldAdd, shouldRemove, shouldSelect, shouldEditLastTag;
+
+ if (tiUtil.isModifierOn(event) || 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 = (key === KEYS.backspace || key === KEYS.delete) && tagList.selected;
+ shouldEditLastTag = key === KEYS.backspace && scope.newTag.text().length === 0 && options.enableEditingLastTag;
+ shouldSelect = (key === KEYS.backspace || key === KEYS.left || key === KEYS.right) && scope.newTag.text().length === 0 && !options.enableEditingLastTag;
+
+ if (shouldAdd) {
+ tagList.addText(scope.newTag.text());
+ }
+ else if (shouldEditLastTag) {
+ var tag;
+
+ tagList.selectPrior();
+ tag = tagList.removeSelected();
+
+ if (tag) {
+ scope.newTag.text(tag[options.displayProperty]);
+ }
+ }
+ else if (shouldRemove) {
+ tagList.removeSelected();
+ }
+ else if (shouldSelect) {
+ if (key === KEYS.left || key === KEYS.backspace) {
+ tagList.selectPrior();
+ }
+ else if (key === KEYS.right) {
+ tagList.selectNext();
+ }
+ }
+
+ if (shouldAdd || shouldSelect || shouldRemove || shouldEditLastTag) {
+ event.preventDefault();
+ }
+ })
+ .on('input-paste', function(event) {
+ if (options.addOnPaste) {
+ var data = event.getTextData();
+ var tags = data.split(options.pasteSplitPattern);
+
+ if (tags.length > 1) {
+ tags.forEach(function(tag) {
+ tagList.addText(tag);
+ });
+ event.preventDefault();
+ }
+ }
+ });
+ }
+ };
+}]);
+
+
+/**
+ * @ngdoc directive
+ * @name tiTagItem
+ * @module ngTagsInput
+ *
+ * @description
+ * Represents a tag item. Used internally by the tagsInput directive.
+ */
+tagsInput.directive('tiTagItem', ["tiUtil", function(tiUtil) {
+ return {
+ restrict: 'E',
+ require: '^tagsInput',
+ template: '