Pull in contacts from contacts api and send to tag-input for autocomplete

This commit is contained in:
James Allen
2015-10-07 17:32:35 +01:00
parent 64ffcde3df
commit 8cc7869b03
15 changed files with 1619 additions and 29 deletions
@@ -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
}
@@ -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)
@@ -0,0 +1,9 @@
AuthenticationController = require('../Authentication/AuthenticationController')
ContactController = require "./ContactController"
module.exports =
apply: (webRouter, apiRouter) ->
webRouter.get '/user/contacts',
AuthenticationController.requireLogin(),
ContactController.getContacts
@@ -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
+2
View File
@@ -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)
@@ -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
@@ -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"
+1
View File
@@ -15,6 +15,7 @@ define [
"ipCookie"
"ErrorCatcher"
"localStorage"
"ngTagsInput"
])
return App
@@ -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 = {
+1
View File
@@ -10,4 +10,5 @@ define [
"libs/angular-sanitize-1.2.17"
"libs/angular-cookie"
"libs/passfield"
"libs/ng-tags-input-3.0.0"
], () ->
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,143 @@
tags-input {
display: block;
}
tags-input *, tags-input *:before, tags-input *:after {
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
tags-input .host {
position: relative;
height: 100%;
}
tags-input .host:active {
outline: none;
}
tags-input .tags {
.form-control;
-moz-appearance: textfield;
-webkit-appearance: textfield;
padding: 2px 5px;
overflow: hidden;
word-wrap: break-word;
cursor: text;
background-color: #fff;
height: 100%;
}
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 7px;
display: inline-block;
float: left;
height: 26px;
line-height: 25px;
border: 1px solid @gray-light;
background-color: @gray-lightest;
border-radius: 3px;
}
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 {
color: @gray-light;
text-decoration: none;
}
tags-input .tags .tag-item .remove-button:active {
color: @brand-primary;
}
tags-input .tags .input {
border: 0;
outline: none;
margin: 2px;
padding: 0;
padding-left: 5px;
float: left;
height: 26px;
}
tags-input .tags .input.invalid-tag {
color: @brand-danger;
}
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[disabled] .host:focus {
outline: none;
}
tags-input[disabled] .tags {
background-color: #eee;
cursor: default;
}
tags-input[disabled] .tags .tag-item {
opacity: 0.65;
background: -webkit-linear-gradient(top, #f0f9ff 0%, rgba(203, 235, 255, 0.75) 47%, rgba(161, 219, 255, 0.62) 100%);
background: linear-gradient(to bottom, #f0f9ff 0%, rgba(203, 235, 255, 0.75) 47%, rgba(161, 219, 255, 0.62) 100%);
}
tags-input[disabled] .tags .tag-item .remove-button {
cursor: default;
}
tags-input[disabled] .tags .tag-item .remove-button:active {
color: @brand-primary;
}
tags-input[disabled] .tags .input {
background-color: #eee;
cursor: default;
}
tags-input .autocomplete {
margin-top: 5px;
position: absolute;
padding: 5px 0;
z-index: 999;
width: 100%;
background-color: #fff;
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;
max-height: 280px;
overflow-y: auto;
position: relative;
}
tags-input .autocomplete .suggestion-item {
padding: 5px 10px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
tags-input .autocomplete .suggestion-item.selected {
color: white;
background-color: @brand-primary;
.subdued {
color: white;
}
}
tags-input .autocomplete .suggestion-item em {
font-weight: bold;
font-style: normal;
}
/*# sourceMappingURL=ng-tags-input.css.map */
@@ -47,6 +47,9 @@
@import "components/popovers.less";
@import "components/carousel.less";
// ngTagsInput
@import "components/tags-input.less";
// Utility classes
@import "core/utilities.less";
@import "core/responsive-utilities.less";
@@ -0,0 +1,57 @@
sinon = require('sinon')
chai = require('chai')
should = chai.should()
assert = chai.assert
expect = chai.expect
modulePath = "../../../../app/js/Features/Contacts/ContactController.js"
SandboxedModule = require('sandboxed-module')
describe "ContactController", ->
beforeEach ->
@ContactController = SandboxedModule.require modulePath, requires:
"logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() }
"../User/UserGetter": @UserGetter = {}
"./ContactManager": @ContactManager = {}
"../Authentication/AuthenticationController": @AuthenticationController = {}
@next = sinon.stub()
@req = {}
@res = {}
@res.status = sinon.stub().returns @req
@res.send = sinon.stub()
describe "getContacts", ->
beforeEach ->
@user_id = "mock-user-id"
@contact_ids = ["contact-1", "contact-2", "contact-3"]
@contacts = [
{ _id: "contact-1", email: "joe@example.com", first_name: "Joe", last_name: "Example", unsued: "foo" }
{ _id: "contact-2", email: "jane@example.com", first_name: "Jane", last_name: "Example", unsued: "foo", holdingAccount: true }
{ _id: "contact-3", email: "jim@example.com", first_name: "Jim", last_name: "Example", unsued: "foo" }
]
@AuthenticationController.getLoggedInUserId = sinon.stub().callsArgWith(1, null, @user_id)
@ContactManager.getContactIds = sinon.stub().callsArgWith(2, null, @contact_ids)
@UserGetter.getUsers = sinon.stub().callsArgWith(2, null, @contacts)
@ContactController.getContacts @req, @res, @next
it "should look up the logged in user id", ->
@AuthenticationController.getLoggedInUserId
.calledWith(@req)
.should.equal true
it "should get the users contact ids", ->
@ContactManager.getContactIds
.calledWith(@user_id, { limit: 50 })
.should.equal true
it "should populate the users contacts ids", ->
@UserGetter.getUsers
.calledWith(@contact_ids, { email: 1, first_name: 1, last_name: 1, holdingAccount: 1 })
.should.equal true
it "should return a formatted list of contacts in contact list order, without holding accounts", ->
@res.send.args[0][0].contacts.should.deep.equal [
{ id: "contact-1", email: "joe@example.com", first_name: "Joe", last_name: "Example" }
{ id: "contact-3", email: "jim@example.com", first_name: "Jim", last_name: "Example" }
]
@@ -0,0 +1,91 @@
chai = require('chai')
chai.should()
sinon = require("sinon")
modulePath = "../../../../app/js/Features/Contacts/ContactManager"
SandboxedModule = require('sandboxed-module')
describe "ContactManager", ->
beforeEach ->
@ContactManager = SandboxedModule.require modulePath, requires:
"request" : @request = sinon.stub()
"settings-sharelatex": @settings =
apis:
contacts:
url: "contacts.sharelatex.com"
"logger-sharelatex": @logger = {log: sinon.stub(), error: sinon.stub(), err:->}
@user_id = "user-id-123"
@contact_id = "contact-id-123"
@callback = sinon.stub()
describe "getContacts", ->
describe "with a successful response code", ->
beforeEach ->
@request.get = sinon.stub().callsArgWith(1, null, statusCode: 204, { contact_ids: @contact_ids = ["mock", "contact_ids"]})
@ContactManager.getContactIds @user_id, { limit: 42 }, @callback
it "should get the contacts from the contacts api", ->
@request.get
.calledWith({
url: "#{@settings.apis.contacts.url}/user/#{@user_id}/contacts"
qs: { limit: 42 }
json: true
jar: false
})
.should.equal true
it "should call the callback with the contatcs", ->
@callback.calledWith(null, @contact_ids).should.equal true
describe "with a failed response code", ->
beforeEach ->
@request.get = sinon.stub().callsArgWith(1, null, statusCode: 500, null)
@ContactManager.getContactIds @user_id, { limit: 42 }, @callback
it "should call the callback with an error", ->
@callback.calledWith(new Error("contacts api responded with non-success code: 500")).should.equal true
it "should log the error", ->
@logger.error
.calledWith({
err: new Error("contacts api responded with a non-success code: 500")
user_id: @user_id
}, "error getting contacts for user")
.should.equal true
describe "addContact", ->
describe "with a successful response code", ->
beforeEach ->
@request.post = sinon.stub().callsArgWith(1, null, statusCode: 200, null)
@ContactManager.addContact @user_id, @contact_id, @callback
it "should add the contacts for the user in the contacts api", ->
@request.post
.calledWith({
url: "#{@settings.apis.contacts.url}/user/#{@user_id}/contacts"
json: {
contact_id: @contact_id
}
jar: false
})
.should.equal true
it "should call the callback", ->
@callback.called.should.equal true
describe "with a failed response code", ->
beforeEach ->
@request.post = sinon.stub().callsArgWith(1, null, statusCode: 500, null)
@ContactManager.addContact @user_id, @contact_id, @callback
it "should call the callback with an error", ->
@callback.calledWith(new Error("contacts api responded with non-success code: 500")).should.equal true
it "should log the error", ->
@logger.error
.calledWith({
err: new Error("contacts api responded with a non-success code: 500")
user_id: @user_id
contact_id: @contact_id
}, "error adding contact for user")
.should.equal true