diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee
index d51a604637..788b782eb2 100644
--- a/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee
+++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee
@@ -1,6 +1,10 @@
ProjectGetter = require "../Project/ProjectGetter"
CollaboratorsHandler = require "./CollaboratorsHandler"
-EditorController = require "../Editor/EditorController"
+ProjectEditorHandler = require "../Project/ProjectEditorHandler"
+EditorRealTimeController = require "../Editor/EditorRealTimeController"
+LimitationsManager = require "../Subscription/LimitationsManager"
+UserGetter = require "../User/UserGetter"
+mimelib = require("mimelib")
module.exports = CollaboratorsController =
getCollaborators: (req, res, next = (error) ->) ->
@@ -11,28 +15,49 @@ module.exports = CollaboratorsController =
CollaboratorsController._formatCollaborators project, (error, collaborators) ->
return next(error) if error?
res.send(JSON.stringify(collaborators))
-
- removeSelfFromProject: (req, res, next = (error) ->) ->
- user_id = req.session?.user?._id
- if !user_id?
- return next(new Error("User should be logged in"))
- CollaboratorsHandler.removeUserFromProject req.params.project_id, user_id, (error) ->
- return next(error) if error?
- res.sendStatus 204
addUserToProject: (req, res, next) ->
project_id = req.params.Project_id
- {email, privileges} = req.body
- EditorController.addUserToProject project_id, email, privileges, (error, user) ->
+ LimitationsManager.canAddXCollaborators project_id, 1, (error, allowed) =>
return next(error) if error?
- res.json user: user
-
+
+ if !allowed
+ return res.json { user: false }
+ else
+ {email, privileges} = req.body
+
+ email = mimelib.parseAddresses(email or "")[0]?.address?.toLowerCase()
+ if !email? or email == ""
+ return res.status(400).send("invalid email address")
+
+ adding_user_id = req.session?.user?._id
+ CollaboratorsHandler.addEmailToProject project_id, adding_user_id, email, privileges, (error, user_id) =>
+ return next(error) if error?
+ UserGetter.getUser user_id, (error, raw_user) ->
+ return next(error) if error?
+ user = ProjectEditorHandler.buildUserModelView(raw_user, privileges)
+ EditorRealTimeController.emitToRoom(project_id, 'userAddedToProject', user, privileges)
+ return res.json { user: user }
+
removeUserFromProject: (req, res, next) ->
project_id = req.params.Project_id
user_id = req.params.user_id
- EditorController.removeUserFromProject project_id, user_id, (error)->
+ CollaboratorsController._removeUserIdFromProject project_id, user_id, (error) ->
return next(error) if error?
res.sendStatus 204
+
+ removeSelfFromProject: (req, res, next = (error) ->) ->
+ project_id = req.params.Project_id
+ user_id = req.session?.user?._id
+ CollaboratorsController._removeUserIdFromProject project_id, user_id, (error) ->
+ return next(error) if error?
+ res.sendStatus 204
+
+ _removeUserIdFromProject: (project_id, user_id, callback = (error) ->) ->
+ CollaboratorsHandler.removeUserFromProject project_id, user_id, (error)->
+ return callback(error) if error?
+ EditorRealTimeController.emitToRoom(project_id, 'userRemovedFromProject', user_id)
+ callback()
_formatCollaborators: (project, callback = (error, collaborators) ->) ->
collaborators = []
diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee
new file mode 100644
index 0000000000..e9beb1bb43
--- /dev/null
+++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee
@@ -0,0 +1,25 @@
+Project = require("../../models/Project").Project
+EmailHandler = require("../Email/EmailHandler")
+Settings = require "settings-sharelatex"
+
+module.exports =
+ notifyUserOfProjectShare: (project_id, email, callback)->
+ Project
+ .findOne(_id: project_id )
+ .select("name owner_ref")
+ .populate('owner_ref')
+ .exec (err, project)->
+ emailOptions =
+ to: email
+ replyTo: project.owner_ref.email
+ project:
+ name: project.name
+ url: "#{Settings.siteUrl}/project/#{project._id}?" + [
+ "project_name=#{encodeURIComponent(project.name)}"
+ "user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}"
+ "new_email=#{encodeURIComponent(email)}"
+ "r=#{project.owner_ref.referal_id}" # Referal
+ "rs=ci" # referral source = collaborator invite
+ ].join("&")
+ owner: project.owner_ref
+ EmailHandler.sendEmail "projectSharedWithYou", emailOptions, callback
\ No newline at end of file
diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee
index 1e0e6578af..4ab855af80 100644
--- a/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee
+++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee
@@ -1,78 +1,63 @@
-User = require('../../models/User').User
+UserCreator = require('../User/UserCreator')
Project = require("../../models/Project").Project
-Settings = require('settings-sharelatex')
-EmailHandler = require("../Email/EmailHandler")
+ProjectEntityHandler = require("../Project/ProjectEntityHandler")
mimelib = require("mimelib")
logger = require('logger-sharelatex')
-async = require("async")
+UserGetter = require "../User/UserGetter"
+ContactManager = require "../Contacts/ContactManager"
+CollaboratorsEmailHandler = require "./CollaboratorsEmailHandler"
-
-module.exports =
-
- removeUserFromProject: (project_id, user_id, callback = ->)->
+module.exports = CollaboratorsHandler =
+ removeUserFromProject: (project_id, user_id, callback = (error) ->)->
logger.log user_id: user_id, project_id: project_id, "removing user"
conditions = _id:project_id
update = $pull:{}
update["$pull"] = collaberator_refs:user_id, readOnly_refs:user_id
Project.update conditions, update, (err)->
if err?
- logger.err err: err, "problem removing user from project collaberators"
+ logger.error err: err, "problem removing user from project collaberators"
callback(err)
-
- addUserToProject: (project_id, email, privilegeLevel, callback)->
- emails = mimelib.parseAddresses(email)
+
+ addEmailToProject: (project_id, adding_user_id, unparsed_email, privilegeLevel, callback = (error, user) ->) ->
+ emails = mimelib.parseAddresses(unparsed_email)
email = emails[0]?.address?.toLowerCase()
- return callback(new Error("no valid email provided")) if !email?
- self = @
- User.findOne {'email':email}, (err, user)->
- async.waterfall [
- (cb)->
- if user?
- return cb(null, user)
- else
- self._createHoldingAccount email, cb
- (@user, cb)=>
- self._updateProjectWithUserPrivileges project_id, user, privilegeLevel, cb
- (cb)->
- self._notifyUserViaEmail project_id, email, cb
- ], (err)=>
- callback(err, @user)
-
- _createHoldingAccount: (email, callback)->
- user = new User 'email':email, holdingAccount:true
- user.save (err)->
- callback(err, user)
-
- _updateProjectWithUserPrivileges: (project_id, user, privilegeLevel, callback)->
- if privilegeLevel == 'readAndWrite'
- level = {"collaberator_refs":user}
- logger.log privileges: "readAndWrite", user: user, project_id: project_id, "adding user"
- else if privilegeLevel == 'readOnly'
- level = {"readOnly_refs":user}
- logger.log privileges: "readOnly", user: user, project_id: project_id, "adding user"
- Project.update {_id: project_id}, {$push:level}, (err)->
- callback(err)
-
-
- _notifyUserViaEmail: (project_id, email, callback)->
- Project.findOne(_id: project_id )
- .select("name owner_ref")
- .populate('owner_ref')
- .exec (err, project)->
- emailOptions =
- to : email
- replyTo : project.owner_ref.email
- project:
- name: project.name
- url: "#{Settings.siteUrl}/project/#{project._id}?" + [
- "project_name=#{encodeURIComponent(project.name)}"
- "user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}"
- "new_email=#{encodeURIComponent(email)}"
- "r=#{project.owner_ref.referal_id}" # Referal
- "rs=ci" # referral source = collaborator invite
- ].join("&")
- owner: project.owner_ref
- EmailHandler.sendEmail "projectSharedWithYou", emailOptions, callback
+ if !email? or email == ""
+ return callback(new Error("no valid email provided: '#{unparsed_email}'"))
+ UserCreator.getUserOrCreateHoldingAccount email, (error, user) ->
+ return callback(error) if error?
+ CollaboratorsHandler.addUserIdToProject project_id, adding_user_id, user._id, privilegeLevel, (error) ->
+ return callback(error) if error?
+ return callback null, user._id
+ addUserIdToProject: (project_id, adding_user_id, user_id, privilegeLevel, callback = (error) ->)->
+ Project.findOne { _id: project_id }, { collaberator_refs: 1, readOnly_refs: 1 }, (error, project) ->
+ return callback(error) if error?
+ existing_users = (project.collaberator_refs or [])
+ existing_users = existing_users.concat(project.readOnly_refs or [])
+ existing_users = existing_users.map (u) -> u.toString()
+ if existing_users.indexOf(user_id.toString()) > -1
+ return callback null # User already in Project
+
+ if privilegeLevel == 'readAndWrite'
+ level = {"collaberator_refs":user_id}
+ logger.log {privileges: "readAndWrite", user_id, project_id}, "adding user"
+ else if privilegeLevel == 'readOnly'
+ level = {"readOnly_refs":user_id}
+ logger.log {privileges: "readOnly", user_id, project_id}, "adding user"
+ else
+ return callback(new Error("unknown privilegeLevel: #{privilegeLevel}"))
+ # Do these in the background
+ UserGetter.getUser user_id, {email: 1}, (error, user) ->
+ if error?
+ logger.error {err: error, project_id, user_id}, "error getting user while adding to project"
+ CollaboratorsEmailHandler.notifyUserOfProjectShare project_id, user.email
+ ContactManager.addContact adding_user_id, user_id
+ Project.update { _id: project_id }, { $addToSet: level }, (error) ->
+ return callback(error) if error?
+ # Flush to TPDS in background to add files to collaborator's Dropbox
+ ProjectEntityHandler.flushProjectToThirdPartyDataStore project_id, (error) ->
+ if error?
+ logger.error {err: error, project_id, user_id}, "error flushing to TPDS after adding collaborator"
+ callback()
diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee
index b327c50e9d..d5abf23350 100644
--- a/services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee
+++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee
@@ -4,7 +4,7 @@ AuthenticationController = require('../Authentication/AuthenticationController')
module.exports =
apply: (webRouter, apiRouter) ->
- webRouter.post '/project/:project_id/leave', AuthenticationController.requireLogin(), CollaboratorsController.removeSelfFromProject
+ webRouter.post '/project/:Project_id/leave', AuthenticationController.requireLogin(), CollaboratorsController.removeSelfFromProject
apiRouter.get '/project/:Project_id/collaborators', SecurityManager.requestCanAccessProject(allow_auth_token: true), CollaboratorsController.getCollaborators
webRouter.post '/project/:Project_id/users', SecurityManager.requestIsOwner, CollaboratorsController.addUserToProject
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..a62c22bbf2
--- /dev/null
+++ b/services/web/app/coffee/Features/Contacts/ContactController.coffee
@@ -0,0 +1,43 @@
+AuthenticationController = require "../Authentication/AuthenticationController"
+ContactManager = require "./ContactManager"
+UserGetter = require "../User/UserGetter"
+logger = require "logger-sharelatex"
+Modules = require "../../infrastructure/Modules"
+
+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)
+
+ Modules.hooks.fire "getContacts", user_id, contacts, (error, additional_contacts) ->
+ return next(error) if error?
+ contacts = contacts.concat(additional_contacts...)
+ res.send({
+ contacts: contacts
+ })
+
+ _formatContact: (contact) ->
+ return {
+ id: contact._id?.toString()
+ email: contact.email
+ first_name: contact.first_name
+ last_name: contact.last_name
+ type: "user"
+ }
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/Editor/EditorController.coffee b/services/web/app/coffee/Features/Editor/EditorController.coffee
index cbd8d3b91d..e2ca000474 100644
--- a/services/web/app/coffee/Features/Editor/EditorController.coffee
+++ b/services/web/app/coffee/Features/Editor/EditorController.coffee
@@ -1,48 +1,18 @@
logger = require('logger-sharelatex')
Metrics = require('../../infrastructure/Metrics')
sanitize = require('sanitizer')
-ProjectEditorHandler = require('../Project/ProjectEditorHandler')
ProjectEntityHandler = require('../Project/ProjectEntityHandler')
ProjectOptionsHandler = require('../Project/ProjectOptionsHandler')
ProjectDetailsHandler = require('../Project/ProjectDetailsHandler')
ProjectDeleter = require("../Project/ProjectDeleter")
-CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler")
DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
-LimitationsManager = require("../Subscription/LimitationsManager")
EditorRealTimeController = require("./EditorRealTimeController")
TrackChangesManager = require("../TrackChanges/TrackChangesManager")
-Settings = require('settings-sharelatex')
async = require('async')
LockManager = require("../../infrastructure/LockManager")
_ = require('underscore')
-redis = require("redis-sharelatex")
-rclientPub = redis.createClient(Settings.redis.web)
-rclientSub = redis.createClient(Settings.redis.web)
module.exports = EditorController =
- addUserToProject: (project_id, email, privileges, callback = (error, collaborator_added)->)->
- email = email.toLowerCase()
- LimitationsManager.isCollaboratorLimitReached project_id, (error, limit_reached) =>
- if error?
- logger.error err:error, "error adding user to to project when checking if collaborator limit has been reached"
- return callback(new Error("Something went wrong"))
-
- if limit_reached
- callback null, false
- else
- CollaboratorsHandler.addUserToProject project_id, email, privileges, (err, user)=>
- return callback(err) if error?
- # Flush to TPDS to add files to collaborator's Dropbox
- ProjectEntityHandler.flushProjectToThirdPartyDataStore project_id, ->
- EditorRealTimeController.emitToRoom(project_id, 'userAddedToProject', user, privileges)
- callback null, ProjectEditorHandler.buildUserModelView(user, privileges)
-
- removeUserFromProject: (project_id, user_id, callback = (error) ->)->
- CollaboratorsHandler.removeUserFromProject project_id, user_id, (error) =>
- return callback(error) if error?
- EditorRealTimeController.emitToRoom(project_id, 'userRemovedFromProject', user_id)
- callback()
-
setDoc: (project_id, doc_id, docLines, source, callback = (err)->)->
DocumentUpdaterHandler.setDocument project_id, doc_id, docLines, source, (err)=>
logger.log project_id:project_id, doc_id:doc_id, "notifying users that the document has been updated"
diff --git a/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee b/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee
index 10a4de7edf..f0513fb590 100644
--- a/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee
+++ b/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee
@@ -19,15 +19,15 @@ module.exports =
return callback(error) if error?
callback null, (project.collaberator_refs.length + project.readOnly_refs.length)
- isCollaboratorLimitReached: (project_id, callback = (error, limit_reached)->) ->
+ canAddXCollaborators: (project_id, x_collaborators, callback = (error, allowed)->) ->
@allowedNumberOfCollaboratorsInProject project_id, (error, allowed_number) =>
return callback(error) if error?
@currentNumberOfCollaboratorsInProject project_id, (error, current_number) =>
return callback(error) if error?
- if current_number < allowed_number or allowed_number < 0
- callback null, false
- else
+ if current_number + x_collaborators <= allowed_number or allowed_number < 0
callback null, true
+ else
+ callback null, false
userHasSubscriptionOrIsGroupMember: (user, callback = (err, hasSubscriptionOrIsMember)->) ->
@userHasSubscription user, (err, hasSubscription, subscription)=>
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/infrastructure/Modules.coffee b/services/web/app/coffee/infrastructure/Modules.coffee
index 720819e503..2df8907f7e 100644
--- a/services/web/app/coffee/infrastructure/Modules.coffee
+++ b/services/web/app/coffee/infrastructure/Modules.coffee
@@ -1,6 +1,7 @@
fs = require "fs"
Path = require "path"
jade = require "jade"
+async = require "async"
MODULE_BASE_PATH = Path.resolve(__dirname + "/../../../modules")
@@ -12,6 +13,7 @@ module.exports = Modules =
loadedModule = require(Path.join(MODULE_BASE_PATH, moduleName, "index"))
loadedModule.name = moduleName
@modules.push loadedModule
+ Modules.attachHooks()
applyRouter: (webRouter, apiRouter) ->
for module in @modules
@@ -35,5 +37,25 @@ module.exports = Modules =
moduleIncludesAvailable: (view) ->
return (Modules.viewIncludes[view] or []).length > 0
+
+ attachHooks: () ->
+ for module in @modules
+ if module.hooks?
+ for hook, method of module.hooks
+ Modules.hooks.attach hook, method
+
+ hooks:
+ _hooks: {}
+ attach: (name, method) ->
+ @_hooks[name] ?= []
+ @_hooks[name].push method
+
+ fire: (name, args..., callback) ->
+ methods = @_hooks[name] or []
+ call_methods = methods.map (method) ->
+ return (cb) -> method(args..., cb)
+ async.series call_methods, (error, results) ->
+ return callback(error) if error?
+ return callback null, results
Modules.loadModules()
\ 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..f93519e657 100644
--- a/services/web/app/views/project/editor/share.jade
+++ b/services/web/app/views/project/editor/share.jade
@@ -47,12 +47,22 @@ 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="display"
+ add-on-paste="true"
+ replace-spaces-with-dashes="false"
+ type="email"
)
+ auto-complete(
+ source="filterAutocompleteUsers($query)"
+ template="shareAutocompleteTemplate"
+ display-property="email"
+ min-length="0"
+ )
.form-group
.pull-right
select.privileges.form-control(
@@ -62,9 +72,12 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
option(value="readAndWrite") #{translate("can_edit")}
option(value="readOnly") #{translate("read_only")}
|
+ //- We have to use mousedown here since click has issues with the
+ //- blur handler in tags-input sometimes changing its height and
+ //- moving this button, preventing the click registering.
button.btn.btn-info(
type="submit"
- ng-click="addMembers()"
+ ng-mousedown="addMembers()"
) #{translate("share")}
div.text-center(ng-hide="canAddCollaborators")
p #{translate("need_to_upgrade_for_more_collabs")}.
@@ -76,7 +89,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
.modal-footer
.modal-footer-left
i.fa.fa-refresh.fa-spin(ng-show="state.inflight")
- span.text-danger.error(ng-show="state.error") {{ state.error }}
+ span.text-danger.error(ng-show="state.error") #{translate("generic_something_went_wrong")}
button.btn.btn-primary(
ng-click="done()"
) #{translate("done")}
@@ -123,3 +136,25 @@ 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 != 'group', '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
+ div(ng-if="data.type == 'user'")
+ i.fa.fa-fw.fa-user
+ |
+ span(ng-bind-html="$highlight(data.display)")
+ div(ng-if="data.type == 'group'")
+ i.fa.fa-fw.fa-group
+ |
+ span(ng-bind-html="$highlight(data.name)")
+ span.subdued.small(ng-show="data.member_count") ({{ data.member_count }} members)
diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee
index a421c7596a..15e2a491c9 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 af16bcb03b..6c765af896 100644
--- a/services/web/public/coffee/base.coffee
+++ b/services/web/public/coffee/base.coffee
@@ -16,6 +16,7 @@ define [
"mvdSixpack"
"ErrorCatcher"
"localStorage"
+ "ngTagsInput"
]).config (sixpackProvider)->
sixpackProvider.setOptions({
debug: false
diff --git a/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee
index e569a2fee8..bb945a9ebc 100644
--- a/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee
+++ b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee
@@ -1,10 +1,10 @@
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: ""
+ contacts: []
}
$scope.state = {
error: null
@@ -22,35 +22,85 @@ define [
allowedNoOfMembers = $scope.project.features.collaborators
$scope.canAddCollaborators = noOfMembers < allowedNoOfMembers or allowedNoOfMembers == INFINITE_COLLABORATORS
+ $scope.autocompleteContacts = []
+ do loadAutocompleteUsers = () ->
+ $http.get "/user/contacts"
+ .success (data) ->
+ $scope.autocompleteContacts = data.contacts or []
+ for contact in $scope.autocompleteContacts
+ if contact.type == "user"
+ if contact.last_name == "" and contact.first_name = contact.email.split("@")[0]
+ # User has not set their proper name so use email as canonical display property
+ contact.display = contact.email
+ else
+ contact.name = "#{contact.first_name} #{contact.last_name}"
+ contact.display = "#{contact.name} <#{contact.email}>"
+ else
+ # Must be a group
+ contact.display = contact.name
+
+ getCurrentMemberEmails = () ->
+ $scope.project.members.map (u) -> u.email
+
+ $scope.filterAutocompleteUsers = ($query) ->
+ currentMemberEmails = getCurrentMemberEmails()
+ return $scope.autocompleteContacts.filter (contact) ->
+ if contact.email? and contact.email in currentMemberEmails
+ return false
+ for text in [contact.name, contact.email]
+ if text?.toLowerCase().indexOf($query.toLowerCase()) > -1
+ return true
+ return false
+
$scope.addMembers = () ->
- return if !$scope.inputs.email? or $scope.inputs.email == ""
+ addMembers = () ->
+ 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
+ members = $scope.inputs.contacts
+ $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
+ currentMemberEmails = getCurrentMemberEmails()
+ do addNextMember = () ->
+ if members.length == 0 or !$scope.canAddCollaborators
+ $scope.state.inflight = false
+ $scope.$apply()
+ return
+
+ member = members.shift()
+ if !member.type? and member.display in currentMemberEmails
+ # Skip this existing member
+ return addNextMember()
+
+ if member.type == "user"
+ request = projectMembers.addMember(member.email, $scope.inputs.privileges)
+ else if member.type == "group"
+ request = projectMembers.addGroup(member.id, $scope.inputs.privileges)
+ else # Not an auto-complete object, so email == display
+ request = projectMembers.addMember(member.display, $scope.inputs.privileges)
+
+ request
+ .success (data) ->
+ if data.users?
+ users = data.users
+ else if data.user?
+ users = [data.user]
+ else
+ users = []
+
+ $scope.project.members.push users...
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 :("
-
+ .error () ->
+ $scope.state.inflight = false
+ $scope.state.error = true
+
+
+ $timeout addMembers, 50 # Give email list a chance to update
$scope.removeMember = (member) ->
$scope.state.error = null
@@ -85,7 +135,6 @@ define [
$scope.cancel = () ->
$modalInstance.dismiss()
- ]
App.controller "MakePublicModalController", ["$scope", "$modalInstance", "settings", ($scope, $modalInstance, settings) ->
$scope.inputs = {
diff --git a/services/web/public/coffee/ide/share/services/projectMembers.coffee b/services/web/public/coffee/ide/share/services/projectMembers.coffee
index d6ef16187e..a51ea63e99 100644
--- a/services/web/public/coffee/ide/share/services/projectMembers.coffee
+++ b/services/web/public/coffee/ide/share/services/projectMembers.coffee
@@ -17,5 +17,13 @@ define [
privileges: privileges
_csrf: window.csrfToken
})
+
+ addGroup: (group_id, privileges) ->
+ $http.post("/project/#{ide.project_id}/group", {
+ group_id: group_id
+ privileges: privileges
+ _csrf: window.csrfToken
+ })
+
}
]
\ No newline at end of file
diff --git a/services/web/public/coffee/libs.coffee b/services/web/public/coffee/libs.coffee
index 807d6018b7..3c30dd63f3 100644
--- a/services/web/public/coffee/libs.coffee
+++ b/services/web/public/coffee/libs.coffee
@@ -13,4 +13,5 @@ define [
"libs/passfield"
"libs/sixpack"
"libs/angular-sixpack"
+ "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: '