From 8cc7869b03b931bd691d45f8b6f2b9c44fefe959 Mon Sep 17 00:00:00 2001 From: James Allen Date: Wed, 7 Oct 2015 17:32:35 +0100 Subject: [PATCH 01/12] Pull in contacts from contacts api and send to tag-input for autocomplete --- .../Contacts/ContactController.coffee | 37 + .../Features/Contacts/ContactManager.coffee | 39 + .../Features/Contacts/ContactRouter.coffee | 9 + .../coffee/Features/User/UserGetter.coffee | 8 + services/web/app/coffee/router.coffee | 2 + .../web/app/views/project/editor/share.jade | 33 +- services/web/config/settings.defaults.coffee | 2 + services/web/public/coffee/base.coffee | 1 + .../ShareProjectModalController.coffee | 71 +- services/web/public/coffee/libs.coffee | 1 + .../web/public/js/libs/ng-tags-input-3.0.0.js | 1151 +++++++++++++++++ .../stylesheets/components/tags-input.less | 143 ++ services/web/public/stylesheets/style.less | 3 + .../Contact/ContactControllerTests.coffee | 57 + .../coffee/Contact/ContactManagerTests.coffee | 91 ++ 15 files changed, 1619 insertions(+), 29 deletions(-) create mode 100644 services/web/app/coffee/Features/Contacts/ContactController.coffee create mode 100644 services/web/app/coffee/Features/Contacts/ContactManager.coffee create mode 100644 services/web/app/coffee/Features/Contacts/ContactRouter.coffee create mode 100644 services/web/public/js/libs/ng-tags-input-3.0.0.js create mode 100644 services/web/public/stylesheets/components/tags-input.less create mode 100644 services/web/test/UnitTests/coffee/Contact/ContactControllerTests.coffee create mode 100644 services/web/test/UnitTests/coffee/Contact/ContactManagerTests.coffee 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: '', + scope: { data: '=' }, + link: function(scope, element, attrs, tagsInputCtrl) { + var tagsInput = tagsInputCtrl.registerTagItem(), + options = tagsInput.getOptions(); + + scope.$$template = options.template; + scope.$$removeTagSymbol = options.removeTagSymbol; + + scope.$getDisplayText = function() { + return tiUtil.safeToString(scope.data[options.displayProperty]); + }; + scope.$removeTag = function() { + tagsInput.removeTag(scope.$index); + }; + + scope.$watch('$parent.$index', function(value) { + scope.$index = value; + }); + } + }; +}]); + + +/** + * @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 {string=} [template=NA] URL or id of a custom template for rendering each element of the autocomplete list. + * @param {string=} [displayProperty=tagsInput.displayText] Property to be rendered as the autocomplete label. + * @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. + * @param {boolean=} [loadOnDownArrow=false] Flag indicating that the source option will be evaluated when the down arrow + * key is pressed and the suggestion list is closed. The current input value is available as $query. + * @param {boolean=} [loadOnEmpty=false] Flag indicating that the source option will be evaluated when the input content + * becomes empty. The $query variable will be passed to the expression as an empty string. + * @param {boolean=} [loadOnFocus=false] Flag indicating that the source option will be evaluated when the input element + * gains focus. The current input value is available as $query. + * @param {boolean=} [selectFirstMatch=true] Flag indicating that the first match will be automatically selected once + * the suggestion list is shown. + */ +tagsInput.directive('autoComplete', ["$document", "$timeout", "$sce", "$q", "tagsInputConfig", "tiUtil", function($document, $timeout, $sce, $q, tagsInputConfig, tiUtil) { + function SuggestionList(loadFn, options, events) { + var self = {}, getDifference, lastPromise, getTagId; + + getTagId = function() { + return options.tagsInput.keyProperty || options.tagsInput.displayProperty; + }; + + getDifference = function(array1, array2) { + return array1.filter(function(item) { + return !tiUtil.findInObjectArray(array2, item, getTagId(), function(a, b) { + if (options.tagsInput.replaceSpacesWithDashes) { + a = tiUtil.replaceSpacesWithDashes(a); + b = tiUtil.replaceSpacesWithDashes(b); + } + return tiUtil.defaultComparer(a, b); + }); + }); + }; + + self.reset = function() { + lastPromise = null; + + self.items = []; + self.visible = false; + self.index = -1; + self.selected = null; + self.query = null; + }; + self.show = function() { + if (options.selectFirstMatch) { + self.select(0); + } + else { + self.selected = null; + } + self.visible = true; + }; + self.load = tiUtil.debounce(function(query, tags) { + self.query = query; + + var promise = $q.when(loadFn({ $query: query })); + lastPromise = promise; + + promise.then(function(items) { + if (promise !== lastPromise) { + return; + } + + items = tiUtil.makeObjectArray(items.data || items, getTagId()); + items = getDifference(items, tags); + self.items = items.slice(0, options.maxResultsToShow); + + if (self.items.length > 0) { + self.show(); + } + else { + self.reset(); + } + }); + }, options.debounceDelay); + + 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]; + events.trigger('suggestion-selected', index); + }; + + self.reset(); + + return self; + } + + function scrollToElement(root, index) { + var element = root.find('li').eq(index), + parent = element.parent(), + elementTop = element.prop('offsetTop'), + elementHeight = element.prop('offsetHeight'), + parentHeight = parent.prop('clientHeight'), + parentScrollTop = parent.prop('scrollTop'); + + if (elementTop < parentScrollTop) { + parent.prop('scrollTop', elementTop); + } + else if (elementTop + elementHeight > parentHeight + parentScrollTop) { + parent.prop('scrollTop', elementTop + elementHeight - parentHeight); + } + } + + return { + restrict: 'E', + require: '^tagsInput', + scope: { source: '&' }, + templateUrl: 'ngTagsInput/auto-complete.html', + controller: ["$scope", "$element", "$attrs", function($scope, $element, $attrs) { + $scope.events = tiUtil.simplePubSub(); + + tagsInputConfig.load('autoComplete', $scope, $attrs, { + template: [String, 'ngTagsInput/auto-complete-match.html'], + debounceDelay: [Number, 100], + minLength: [Number, 3], + highlightMatchedText: [Boolean, true], + maxResultsToShow: [Number, 10], + loadOnDownArrow: [Boolean, false], + loadOnEmpty: [Boolean, false], + loadOnFocus: [Boolean, false], + selectFirstMatch: [Boolean, true], + displayProperty: [String, ''] + }); + + $scope.suggestionList = new SuggestionList($scope.source, $scope.options, $scope.events); + + this.registerAutocompleteMatch = function() { + return { + getOptions: function() { + return $scope.options; + }, + getQuery: function() { + return $scope.suggestionList.query; + } + }; + }; + }], + link: function(scope, element, attrs, tagsInputCtrl) { + var hotkeys = [KEYS.enter, KEYS.tab, KEYS.escape, KEYS.up, KEYS.down], + suggestionList = scope.suggestionList, + tagsInput = tagsInputCtrl.registerAutocomplete(), + options = scope.options, + events = scope.events, + shouldLoadSuggestions; + + options.tagsInput = tagsInput.getOptions(); + + shouldLoadSuggestions = function(value) { + return value && value.length >= options.minLength || !value && options.loadOnEmpty; + }; + + scope.addSuggestionByIndex = function(index) { + suggestionList.select(index); + scope.addSuggestion(); + }; + + scope.addSuggestion = function() { + var added = false; + + if (suggestionList.selected) { + tagsInput.addTag(angular.copy(suggestionList.selected)); + suggestionList.reset(); + tagsInput.focusInput(); + + added = true; + } + return added; + }; + + scope.track = function(item) { + return item[options.tagsInput.keyProperty || options.tagsInput.displayProperty]; + }; + + tagsInput + .on('tag-added tag-removed invalid-tag input-blur', function() { + suggestionList.reset(); + }) + .on('input-change', function(value) { + if (shouldLoadSuggestions(value)) { + suggestionList.load(value, tagsInput.getTags()); + } + else { + suggestionList.reset(); + } + }) + .on('input-focus', function() { + var value = tagsInput.getCurrentTagText(); + if (options.loadOnFocus && shouldLoadSuggestions(value)) { + suggestionList.load(value, tagsInput.getTags()); + } + }) + .on('input-keydown', function(event) { + var key = event.keyCode, + handled = false; + + if (tiUtil.isModifierOn(event) || hotkeys.indexOf(key) === -1) { + return; + } + + if (suggestionList.visible) { + + 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(); + } + } + else { + if (key === KEYS.down && scope.options.loadOnDownArrow) { + suggestionList.load(tagsInput.getCurrentTagText(), tagsInput.getTags()); + handled = true; + } + } + + if (handled) { + event.preventDefault(); + event.stopImmediatePropagation(); + return false; + } + }); + + events.on('suggestion-selected', function(index) { + scrollToElement(element, index); + }); + } + }; +}]); + + +/** + * @ngdoc directive + * @name tiAutocompleteMatch + * @module ngTagsInput + * + * @description + * Represents an autocomplete match. Used internally by the autoComplete directive. + */ +tagsInput.directive('tiAutocompleteMatch', ["$sce", "tiUtil", function($sce, tiUtil) { + return { + restrict: 'E', + require: '^autoComplete', + template: '', + scope: { data: '=' }, + link: function(scope, element, attrs, autoCompleteCtrl) { + var autoComplete = autoCompleteCtrl.registerAutocompleteMatch(), + options = autoComplete.getOptions(); + + scope.$$template = options.template; + scope.$index = scope.$parent.$index; + + scope.$highlight = function(text) { + if (options.highlightMatchedText) { + text = tiUtil.safeHighlight(text, autoComplete.getQuery()); + } + return $sce.trustAsHtml(text); + }; + scope.$getDisplayText = function() { + return tiUtil.safeToString(scope.data[options.displayProperty || options.tagsInput.displayProperty]); + }; + } + }; +}]); + + +/** + * @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', ["tagsInputConfig", function(tagsInputConfig) { + return { + restrict: 'A', + require: 'ngModel', + link: function(scope, element, attrs, ctrl) { + var threshold = tagsInputConfig.getTextAutosizeThreshold(), + span, resize; + + span = angular.element(''); + span.css('display', 'none') + .css('visibility', 'hidden') + .css('width', 'auto') + .css('white-space', 'pre'); + + element.parent().append(span); + + resize = function(originalValue) { + var value = originalValue, width; + + if (angular.isString(value) && value.length === 0) { + value = attrs.placeholder; + } + + if (value) { + span.text(value); + span.css('display', ''); + width = span.prop('offsetWidth'); + span.css('display', 'none'); + } + + element.css('width', width ? width + threshold + 'px' : ''); + + return originalValue; + }; + + ctrl.$parsers.unshift(resize); + ctrl.$formatters.unshift(resize); + + attrs.$observe('placeholder', function(value) { + if (!ctrl.$modelValue) { + resize(value); + } + }); + } + }; +}]); + +/** + * @ngdoc directive + * @name tiBindAttrs + * @module ngTagsInput + * + * @description + * Binds attributes to expressions. Used internally by tagsInput directive. + */ +tagsInput.directive('tiBindAttrs', function() { + return function(scope, element, attrs) { + scope.$watch(attrs.tiBindAttrs, function(value) { + angular.forEach(value, function(value, key) { + attrs.$set(key, value); + }); + }, true); + }; +}); + +/** + * @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 = {}, + autosizeThreshold = 3; + + /** + * @ngdoc method + * @name tagsInputConfig#setDefaults + * @description Sets the default configuration option for a directive. + * + * @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 tagsInputConfig#setActiveInterpolation + * @description Sets active interpolation for a set of options. + * + * @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; + }; + + /** + * @ngdoc method + * @name tagsInputConfig#setTextAutosizeThreshold + * @description Sets the threshold used by the tagsInput directive to re-size the inner input field element based on its contents. + * + * @param {number} threshold Threshold value, in pixels. + * + * @returns {object} The service itself for chaining purposes. + */ + this.setTextAutosizeThreshold = function(threshold) { + autosizeThreshold = threshold; + 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) { + var defaultValidator = function() { return true; }; + + scope.options = {}; + + angular.forEach(options, function(value, key) { + var type, localDefault, validator, converter, getDefault, updateValue; + + type = value[0]; + localDefault = value[1]; + validator = value[2] || defaultValidator; + 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 && validator(value) ? converter(value) : getDefault(); + }; + + if (interpolationStatus[directive] && interpolationStatus[directive][key]) { + attrs.$observe(key, function(value) { + updateValue(value); + scope.events.trigger('option-change', { name: key, newValue: value }); + }); + } + else { + updateValue(attrs[key] && $interpolate(attrs[key])(scope.$parent)); + } + }); + }, + getTextAutosizeThreshold: function() { + return autosizeThreshold; + } + }; + }]; +}); + + +/*** + * @ngdoc service + * @name tiUtil + * @module ngTagsInput + * + * @description + * Helper methods used internally by the directive. Should not be called directly from user code. + */ +tagsInput.factory('tiUtil', ["$timeout", function($timeout) { + var self = {}; + + self.debounce = function(fn, delay) { + var timeoutId; + return function() { + var args = arguments; + $timeout.cancel(timeoutId); + timeoutId = $timeout(function() { fn.apply(null, args); }, delay); + }; + }; + + self.makeObjectArray = function(array, key) { + if (!angular.isArray(array) || array.length === 0 || angular.isObject(array[0])) { + return array; + } + + var newArray = []; + array.forEach(function(item) { + var obj = {}; + obj[key] = item; + newArray.push(obj); + }); + return newArray; + }; + + self.findInObjectArray = function(array, obj, key, comparer) { + var item = null; + comparer = comparer || self.defaultComparer; + + array.some(function(element) { + if (comparer(element[key], obj[key])) { + item = element; + return true; + } + }); + + return item; + }; + + self.defaultComparer = function(a, b) { + // I'm aware of the internationalization issues regarding toLowerCase() + // but I couldn't come up with a better solution right now + return self.safeToString(a).toLowerCase() === self.safeToString(b).toLowerCase(); + }; + + self.safeHighlight = function(str, value) { + if (!value) { + return str; + } + + function escapeRegexChars(str) { + return str.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1'); + } + + str = self.encodeHTML(str); + value = self.encodeHTML(value); + + var expression = new RegExp('&[^;]+;|' + escapeRegexChars(value), 'gi'); + return str.replace(expression, function(match) { + return match.toLowerCase() === value.toLowerCase() ? '' + match + '' : match; + }); + }; + + self.safeToString = function(value) { + return angular.isUndefined(value) || value == null ? '' : value.toString().trim(); + }; + + self.encodeHTML = function(value) { + return self.safeToString(value) + .replace(/&/g, '&') + .replace(//g, '>'); + }; + + self.handleUndefinedResult = function(fn, valueIfUndefined) { + return function() { + var result = fn.apply(null, arguments); + return angular.isUndefined(result) ? valueIfUndefined : result; + }; + }; + + self.replaceSpacesWithDashes = function(str) { + return self.safeToString(str).replace(/\s/g, '-'); + }; + + self.isModifierOn = function(event) { + return event.shiftKey || event.ctrlKey || event.altKey || event.metaKey; + }; + + self.simplePubSub = function() { + 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) { + var handlers = events[name] || []; + handlers.every(function(handler) { + return self.handleUndefinedResult(handler, true)(args); + }); + return this; + } + }; + }; + + return self; +}]); + +/* HTML templates */ +tagsInput.run(["$templateCache", function($templateCache) { + $templateCache.put('ngTagsInput/tags-input.html', + "
" + ); + + $templateCache.put('ngTagsInput/tag-item.html', + " " + ); + + $templateCache.put('ngTagsInput/auto-complete.html', + "
" + ); + + $templateCache.put('ngTagsInput/auto-complete-match.html', + "" + ); +}]); + +}()); \ No newline at end of file diff --git a/services/web/public/stylesheets/components/tags-input.less b/services/web/public/stylesheets/components/tags-input.less new file mode 100644 index 0000000000..48b03aa742 --- /dev/null +++ b/services/web/public/stylesheets/components/tags-input.less @@ -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 */ diff --git a/services/web/public/stylesheets/style.less b/services/web/public/stylesheets/style.less index b2691a5bcc..5613671db9 100755 --- a/services/web/public/stylesheets/style.less +++ b/services/web/public/stylesheets/style.less @@ -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"; diff --git a/services/web/test/UnitTests/coffee/Contact/ContactControllerTests.coffee b/services/web/test/UnitTests/coffee/Contact/ContactControllerTests.coffee new file mode 100644 index 0000000000..468a97e8e7 --- /dev/null +++ b/services/web/test/UnitTests/coffee/Contact/ContactControllerTests.coffee @@ -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" } + ] diff --git a/services/web/test/UnitTests/coffee/Contact/ContactManagerTests.coffee b/services/web/test/UnitTests/coffee/Contact/ContactManagerTests.coffee new file mode 100644 index 0000000000..054a7ec56f --- /dev/null +++ b/services/web/test/UnitTests/coffee/Contact/ContactManagerTests.coffee @@ -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 From d11d53699463eba0a0809f02dfaa8f4112d73322 Mon Sep 17 00:00:00 2001 From: James Allen Date: Thu, 8 Oct 2015 14:15:36 +0100 Subject: [PATCH 02/12] Refactor adding and removing collaborators to not go through EditorController --- .../CollaboratorsController.coffee | 54 +++++-- .../CollaboratorsEmailHandler.coffee | 25 +++ .../Collaborators/CollaboratorsHandler.coffee | 92 ++++------- .../Collaborators/CollaboratorsRouter.coffee | 2 +- .../Features/Editor/EditorController.coffee | 30 ---- .../CollaboratorsControllerTests.coffee | 148 ++++++++++++++---- .../CollaboratorsHandlerTests.coffee | 124 +++++++++++---- .../Editor/EditorControllerTests.coffee | 65 -------- 8 files changed, 305 insertions(+), 235 deletions(-) create mode 100644 services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee index d51a604637..8c53c278cb 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee @@ -1,6 +1,11 @@ ProjectGetter = require "../Project/ProjectGetter" CollaboratorsHandler = require "./CollaboratorsHandler" -EditorController = require "../Editor/EditorController" +CollaboratorsEmailHandler = require "./CollaboratorsEmailHandler" +ProjectEditorHandler = require "../Project/ProjectEditorHandler" +EditorRealTimeController = require "../Editor/EditorRealTimeController" +UserGetter = require "../User/UserGetter" +LimitationsManager = require "../Subscription/LimitationsManager" +mimelib = require("mimelib") module.exports = CollaboratorsController = getCollaborators: (req, res, next = (error) ->) -> @@ -11,28 +16,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.isCollaboratorLimitReached project_id, (error, limit_reached) => return next(error) if error? - res.json user: user - + + if limit_reached + 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") + + CollaboratorsHandler.addEmailToProject project_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) + CollaboratorsEmailHandler.notifyUserOfProjectShare project_id, user.email + 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..9aac44f3bb 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee @@ -1,78 +1,44 @@ -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") - -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, 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) + 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.addUserToProject project_id, user._id, privilegeLevel, (error) -> + return callback(error) if error? + return callback null, user._id - _createHoldingAccount: (email, callback)-> - user = new User 'email':email, holdingAccount:true - user.save (err)-> - callback(err, user) - - _updateProjectWithUserPrivileges: (project_id, user, privilegeLevel, callback)-> + addUserToProject: (project_id, user_id, privilegeLevel, callback = (error) ->)-> if privilegeLevel == 'readAndWrite' - level = {"collaberator_refs":user} - logger.log privileges: "readAndWrite", user: user, project_id: project_id, "adding user" + level = {"collaberator_refs":user_id} + logger.log {privileges: "readAndWrite", user_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 - - - + level = {"readOnly_refs":user_id} + logger.log {privileges: "readOnly", user_id, project_id}, "adding user" + else + return callback(new Error("unknown privilegeLevel: #{privilegeLevel}")) + Project.update { _id: project_id }, { $push: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/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/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee index c2e8f45685..3a6e032456 100644 --- a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee @@ -11,15 +11,18 @@ ObjectId = require("mongojs").ObjectId describe "CollaboratorsController", -> beforeEach -> - @CollaboratorsHandler = - removeUserFromProject:sinon.stub() @CollaboratorsController = SandboxedModule.require modulePath, requires: "../Project/ProjectGetter": @ProjectGetter = {} - "./CollaboratorsHandler": @CollaboratorsHandler - "../Editor/EditorController": @EditorController = {} + "./CollaboratorsHandler": @CollaboratorsHandler = {} + "./CollaboratorsEmailHandler": @CollaboratorsEmailHandler = {} + "../User/UserGetter": @UserGetter = {} + "../Editor/EditorRealTimeController": @EditorRealTimeController = {} + '../Subscription/LimitationsManager' : @LimitationsManager = {} + '../Project/ProjectEditorHandler' : @ProjectEditorHandler = {} @res = new MockResponse() @req = new MockRequest() + @project_id = "project-id-123" @callback = sinon.stub() describe "getCollaborators", -> @@ -51,40 +54,89 @@ describe "CollaboratorsController", -> it "should return the formatted collaborators", -> @res.body.should.equal JSON.stringify(@collaborators) - describe "removeSelfFromProject", -> - beforeEach -> - @req.session = - user: _id: @user_id = "user-id-123" - destroy:-> - @req.params = project_id: @project_id - @CollaboratorsHandler.removeUserFromProject = sinon.stub().callsArg(2) - - @CollaboratorsController.removeSelfFromProject(@req, @res) - - it "should remove the logged in user from the project", -> - @CollaboratorsHandler.removeUserFromProject.calledWith(@project_id, @user_id) - - it "should return a success code", -> - @res.statusCode.should.equal 204 - describe "addUserToProject", -> beforeEach -> @req.params = - Project_id: @project_id = "project-id-123" + Project_id: @project_id @req.body = - email: @email = "joe@example.com" - privileges: @privileges = "readAndWrite" + email: @email = "Joe@example.com" + privileges: @privileges = "readOnly" @res.json = sinon.stub() - @EditorController.addUserToProject = sinon.stub().callsArgWith(3, null, @user = {"mock": "user"}) - @CollaboratorsController.addUserToProject @req, @res + @user_id = "mock-user-id" + @raw_user = { + _id: @user_id, email: "joe@example.com", first_name: "Joe", last_name: "Example", unused: "foo" + } + @user_view = { + id: @user_id, first_name: "Joe", last_name: "Example", email: "joe@example.com" + } + @LimitationsManager.isCollaboratorLimitReached = sinon.stub().callsArgWith(1, null, false) + @ProjectEditorHandler.buildUserModelView = sinon.stub().returns(@user_view) + @CollaboratorsHandler.addEmailToProject = sinon.stub().callsArgWith(3, null, @user_id) + @UserGetter.getUser = sinon.stub().callsArgWith(1, null, @user) + @CollaboratorsEmailHandler.notifyUserOfProjectShare = sinon.stub() + @EditorRealTimeController.emitToRoom = sinon.stub() + @callback = sinon.stub() + + describe "when the project can accept more collaborators", -> + beforeEach -> + @CollaboratorsController.addUserToProject @req, @res, @next + + it "should add the user to the project", -> + @CollaboratorsHandler.addEmailToProject + .calledWith(@project_id, @email.toLowerCase(), @privileges) + .should.equal true + + it "should emit a userAddedToProject event", -> + @EditorRealTimeController.emitToRoom + .calledWith(@project_id, "userAddedToProject", @user_view, @privileges) + .should.equal true - it "should add the user to the project", -> - @EditorController.addUserToProject - .calledWith(@project_id, @email, @privileges) - .should.equal true - - it "should send the back the added user", -> - @res.json.calledWith(user: @user).should.equal true + it "should send an email to the shared-with user", -> + @CollaboratorsEmailHandler.notifyUserOfProjectShare + .calledWith(@project_id, @email.toLowerCase()) + .should.equal true + + it "should send the user as the response body", -> + @res.json + .calledWith({ + user: @user_view + }) + .should.equal true + + describe "when the project cannot accept more collaborators", -> + beforeEach -> + @LimitationsManager.isCollaboratorLimitReached = sinon.stub().callsArgWith(1, null, true) + @CollaboratorsController.addUserToProject @req, @res, @next + + it "should not add the user to the project", -> + @CollaboratorsHandler.addEmailToProject.called.should.equal false + + it "should not emit a userAddedToProject event", -> + @EditorRealTimeController.emitToRoom.called.should.equal false + + it "should send user: false as the response body", -> + @res.json + .calledWith({ + user: false + }) + .should.equal true + + describe "when the email is not valid", -> + beforeEach -> + @req.body.email = "not-valid" + @res.status = sinon.stub().returns @res + @res.send = sinon.stub() + @CollaboratorsController.addUserToProject @req, @res, @next + + it "should not add the user to the project", -> + @CollaboratorsHandler.addEmailToProject.called.should.equal false + + it "should not emit a userAddedToProject event", -> + @EditorRealTimeController.emitToRoom.called.should.equal false + + it "should return a 400 response", -> + @res.status.calledWith(400).should.equal true + @res.send.calledWith("invalid email address").should.equal true describe "removeUserFromProject", -> beforeEach -> @@ -92,17 +144,45 @@ describe "CollaboratorsController", -> Project_id: @project_id = "project-id-123" user_id: @user_id = "user-id-123" @res.sendStatus = sinon.stub() - @EditorController.removeUserFromProject = sinon.stub().callsArg(2) + @EditorRealTimeController.emitToRoom = sinon.stub() + @CollaboratorsHandler.removeUserFromProject = sinon.stub().callsArg(2) @CollaboratorsController.removeUserFromProject @req, @res it "should from the user from the project", -> - @EditorController.removeUserFromProject + @CollaboratorsHandler.removeUserFromProject .calledWith(@project_id, @user_id) .should.equal true + + it "should emit a userRemovedFromProject event to the proejct", -> + @EditorRealTimeController.emitToRoom + .calledWith(@project_id, 'userRemovedFromProject', @user_id) + .should.equal true it "should send the back a success response", -> @res.sendStatus.calledWith(204).should.equal true + describe "removeSelfFromProject", -> + beforeEach -> + @req.session = + user: _id: @user_id = "user-id-123" + @req.params = Project_id: @project_id + @res.sendStatus = sinon.stub() + @EditorRealTimeController.emitToRoom = sinon.stub() + @CollaboratorsHandler.removeUserFromProject = sinon.stub().callsArg(2) + @CollaboratorsController.removeSelfFromProject(@req, @res) + + it "should remove the logged in user from the project", -> + @CollaboratorsHandler.removeUserFromProject + .calledWith(@project_id, @user_id) + .should.equal true + + it "should emit a userRemovedFromProject event to the proejct", -> + @EditorRealTimeController.emitToRoom + .calledWith(@project_id, 'userRemovedFromProject', @user_id) + .should.equal true + + it "should return a success code", -> + @res.sendStatus.calledWith(204).should.equal true describe "_formatCollaborators", -> beforeEach -> diff --git a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee index c2439a7771..2ddbddd5f0 100644 --- a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee +++ b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee @@ -7,42 +7,110 @@ modulePath = path.join __dirname, "../../../../app/js/Features/Collaborators/Col expect = require("chai").expect describe "CollaboratorsHandler", -> - beforeEach -> - - @user = - email:"bob@bob.com" - @UserModel = - findById:sinon.stub().callsArgWith(1, null, @user) - update: sinon.stub() - - @settings = {} - @ProjectModel = - update: sinon.stub().callsArgWith(1) @CollaboratorHandler = SandboxedModule.require modulePath, requires: - "settings-sharelatex":@settings - "logger-sharelatex": - log:-> - err:-> - '../../models/User': User:@UserModel - "../../models/Project": Project:@ProjectModel - "../Email/EmailHandler": {} + "logger-sharelatex": @logger = { log: sinon.stub(), err: sinon.stub() } + '../User/UserCreator': @UserCreator = {} + "../../models/Project": Project: @Project = {} + "../Project/ProjectEntityHandler": @ProjectEntityHandler = {} - @project_id = "123l2j13lkj" - @user_id = "132kj1lk2j" + @project_id = "mock-project-id" + @user_id = "mock-user-id" + @callback = sinon.stub() describe "removeUserFromProject", -> - beforeEach -> - @ProjectModel.update.callsArgWith(2) + @Project.update = sinon.stub().callsArg(2) + @CollaboratorHandler.removeUserFromProject @project_id, @user_id, @callback - it "should remove the user from mongo", (done)-> - - @CollaboratorHandler.removeUserFromProject @project_id, @user_id, => - update = @ProjectModel.update.args[0][1] - assert.deepEqual update, "$pull":{collaberator_refs:@user_id, readOnly_refs:@user_id} - done() + it "should remove the user from mongo", -> + @Project.update + .calledWith({ + _id: @project_id + }, { + "$pull":{collaberator_refs:@user_id, readOnly_refs:@user_id} + }) + .should.equal true + + describe "addUserToProject", -> + beforeEach -> + @Project.update = sinon.stub().callsArg(2) + @ProjectEntityHandler.flushProjectToThirdPartyDataStore = sinon.stub().callsArg(1) + + describe "as readOnly", -> + beforeEach -> + @CollaboratorHandler.addUserToProject @project_id, @user_id, "readOnly", @callback + it "should add the user to the readOnly_refs", -> + @Project.update + .calledWith({ + _id: @project_id + }, { + "$push":{ readOnly_refs: @user_id } + }) + .should.equal true + + it "should flush the project to the TPDS", -> + @ProjectEntityHandler.flushProjectToThirdPartyDataStore + .calledWith(@project_id) + .should.equal true + + describe "as readAndWrite", -> + beforeEach -> + @CollaboratorHandler.addUserToProject @project_id, @user_id, "readAndWrite", @callback + + it "should add the user to the collaberator_refs", -> + @Project.update + .calledWith({ + _id: @project_id + }, { + "$push":{ collaberator_refs: @user_id } + }) + .should.equal true + + it "should flush the project to the TPDS", -> + @ProjectEntityHandler.flushProjectToThirdPartyDataStore + .calledWith(@project_id) + .should.equal true + + describe "with invalid privilegeLevel", -> + beforeEach -> + @CollaboratorHandler.addUserToProject @project_id, @user_id, "notValid", @callback + + it "should call the callback with an error", -> + @callback.calledWith(new Error()).should.equal true + + describe "addEmailToProject", -> + beforeEach -> + @UserCreator.getUserOrCreateHoldingAccount = sinon.stub().callsArgWith(1, null, @user = {_id: @user_id}) + @CollaboratorHandler.addUserToProject = sinon.stub().callsArg(3) + + describe "with a valid email", -> + beforeEach -> + @CollaboratorHandler.addEmailToProject @project_id, (@email = "Joe@example.com"), (@privilegeLevel = "readAndWrite"), @callback + + it "should get the user with the lowercased email", -> + @UserCreator.getUserOrCreateHoldingAccount + .calledWith(@email.toLowerCase()) + .should.equal true + + it "should add the user to the project by id", -> + @CollaboratorHandler.addUserToProject + .calledWith(@project_id, @user_id, @privilegeLevel) + .should.equal true + + it "should return the callback with the user_id", -> + @callback.calledWith(null, @user_id).should.equal true + + describe "with an invalid email", -> + beforeEach -> + @CollaboratorHandler.addEmailToProject @project_id, "not-and-email", (@privilegeLevel = "readAndWrite"), @callback + + it "should call the callback with an error", -> + @callback.calledWith(new Error()).should.equal true + + it "should not add any users to the proejct", -> + @CollaboratorHandler.addUserToProject.called.should.equal false diff --git a/services/web/test/UnitTests/coffee/Editor/EditorControllerTests.coffee b/services/web/test/UnitTests/coffee/Editor/EditorControllerTests.coffee index adf30d781a..c4b4935170 100644 --- a/services/web/test/UnitTests/coffee/Editor/EditorControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Editor/EditorControllerTests.coffee @@ -33,10 +33,8 @@ describe "EditorController", -> setSpellCheckLanguage: sinon.spy() @ProjectEntityHandler = flushProjectToThirdPartyDataStore:sinon.stub() - @ProjectEditorHandler = {} @Project = findPopulatedById: sinon.stub().callsArgWith(1, null, @project) - @LimitationsManager = {} @client = new MockClient() @settings = @@ -56,14 +54,12 @@ describe "EditorController", -> releaseLock : sinon.stub() @EditorController = SandboxedModule.require modulePath, requires: "../../infrastructure/Server" : io : @io - '../Project/ProjectEditorHandler' : @ProjectEditorHandler '../Project/ProjectEntityHandler' : @ProjectEntityHandler '../Project/ProjectOptionsHandler' : @ProjectOptionsHandler '../Project/ProjectDetailsHandler': @ProjectDetailsHandler '../Project/ProjectDeleter' : @ProjectDeleter '../Collaborators/CollaboratorsHandler': @CollaboratorsHandler '../DocumentUpdater/DocumentUpdaterHandler' : @DocumentUpdaterHandler - '../Subscription/LimitationsManager' : @LimitationsManager '../../models/Project' : Project: @Project "settings-sharelatex":@settings '../Dropbox/DropboxProjectLinker':@dropboxProjectLinker @@ -76,67 +72,6 @@ describe "EditorController", -> log: sinon.stub() err: sinon.stub() - describe "addUserToProject", -> - beforeEach -> - @email = "Jane.Doe@example.com" - @priveleges = "readOnly" - @addedUser = { _id: "added-user" } - @ProjectEditorHandler.buildUserModelView = sinon.stub().returns(@addedUser) - @CollaboratorsHandler.addUserToProject = sinon.stub().callsArgWith(3, null, @addedUser) - @EditorRealTimeController.emitToRoom = sinon.stub() - @callback = sinon.stub() - - describe "when the project can accept more collaborators", -> - beforeEach -> - @LimitationsManager.isCollaboratorLimitReached = sinon.stub().callsArgWith(1, null, false) - - it "should add the user to the project", (done)-> - @EditorController.addUserToProject @project_id, @email, @priveleges, => - @CollaboratorsHandler.addUserToProject.calledWith(@project_id, @email.toLowerCase(), @priveleges).should.equal true - done() - - it "should emit a userAddedToProject event", (done)-> - @EditorController.addUserToProject @project_id, @email, @priveleges, => - @EditorRealTimeController.emitToRoom.calledWith(@project_id, "userAddedToProject", @addedUser).should.equal true - done() - - it "should return the user to the callback", (done)-> - @EditorController.addUserToProject @project_id, @email, @priveleges, (err, result)=> - result.should.equal @addedUser - done() - - - describe "when the project cannot accept more collaborators", -> - beforeEach -> - @LimitationsManager.isCollaboratorLimitReached = sinon.stub().callsArgWith(1, null, true) - @EditorController.addUserToProject(@project_id, @email, @priveleges, @callback) - - it "should not add the user to the project", -> - @CollaboratorsHandler.addUserToProject.called.should.equal false - - it "should not emit a userAddedToProject event", -> - @EditorRealTimeController.emitToRoom.called.should.equal false - - it "should return false to the callback", -> - @callback.calledWith(null, false).should.equal true - - - describe "removeUserFromProject", -> - beforeEach -> - @removed_user_id = "removed-user-id" - @CollaboratorsHandler.removeUserFromProject = sinon.stub().callsArgWith(2) - @EditorRealTimeController.emitToRoom = sinon.stub() - - @EditorController.removeUserFromProject(@project_id, @removed_user_id) - - it "remove the user from the project", -> - @CollaboratorsHandler.removeUserFromProject - .calledWith(@project_id, @removed_user_id) - .should.equal true - - it "should emit a userRemovedFromProject event", -> - @EditorRealTimeController.emitToRoom.calledWith(@project_id, "userRemovedFromProject", @removed_user_id).should.equal true - describe "updating compiler used for project", -> it "should send the new compiler and project id to the project options handler", (done)-> compiler = "latex" From 78c5741d06f596df3a983300fc094515a5f0b8ed Mon Sep 17 00:00:00 2001 From: James Allen Date: Thu, 8 Oct 2015 16:42:23 +0100 Subject: [PATCH 03/12] Add contact when adding collaborator --- .../Collaborators/CollaboratorsController.coffee | 6 ++++++ .../Collaborators/CollaboratorsControllerTests.coffee | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee index 8c53c278cb..6999b16bc5 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee @@ -5,6 +5,7 @@ ProjectEditorHandler = require "../Project/ProjectEditorHandler" EditorRealTimeController = require "../Editor/EditorRealTimeController" UserGetter = require "../User/UserGetter" LimitationsManager = require "../Subscription/LimitationsManager" +ContactManager = require "../Contacts/ContactManager" mimelib = require("mimelib") module.exports = CollaboratorsController = @@ -36,8 +37,13 @@ module.exports = CollaboratorsController = UserGetter.getUser user_id, (error, raw_user) -> return next(error) if error? user = ProjectEditorHandler.buildUserModelView(raw_user, privileges) + + # These things can all be done in the background + adding_user_id = req.session?.user?._id CollaboratorsEmailHandler.notifyUserOfProjectShare project_id, user.email EditorRealTimeController.emitToRoom(project_id, 'userAddedToProject', user, privileges) + ContactManager.addContact adding_user_id, user_id + return res.json { user: user } removeUserFromProject: (req, res, next) -> diff --git a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee index 3a6e032456..def122044a 100644 --- a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee @@ -19,6 +19,7 @@ describe "CollaboratorsController", -> "../Editor/EditorRealTimeController": @EditorRealTimeController = {} '../Subscription/LimitationsManager' : @LimitationsManager = {} '../Project/ProjectEditorHandler' : @ProjectEditorHandler = {} + "../Contacts/ContactManager": @ContactManager = {} @res = new MockResponse() @req = new MockRequest() @@ -61,6 +62,8 @@ describe "CollaboratorsController", -> @req.body = email: @email = "Joe@example.com" privileges: @privileges = "readOnly" + @req.session = + user: _id: @adding_user_id = "adding-user-id" @res.json = sinon.stub() @user_id = "mock-user-id" @raw_user = { @@ -75,6 +78,7 @@ describe "CollaboratorsController", -> @UserGetter.getUser = sinon.stub().callsArgWith(1, null, @user) @CollaboratorsEmailHandler.notifyUserOfProjectShare = sinon.stub() @EditorRealTimeController.emitToRoom = sinon.stub() + @ContactManager.addContact = sinon.stub() @callback = sinon.stub() describe "when the project can accept more collaborators", -> @@ -95,6 +99,11 @@ describe "CollaboratorsController", -> @CollaboratorsEmailHandler.notifyUserOfProjectShare .calledWith(@project_id, @email.toLowerCase()) .should.equal true + + it "should add the user as a contact for the adding user", -> + @ContactManager.addContact + .calledWith(@adding_user_id, @user_id) + .should.equal true it "should send the user as the response body", -> @res.json From c4e4f2c77af356614df993fbdc353ae3ab46446f Mon Sep 17 00:00:00 2001 From: James Allen Date: Thu, 8 Oct 2015 18:17:53 +0100 Subject: [PATCH 04/12] Add modules hook for contacts and support groups in auto complete --- .../Contacts/ContactController.coffee | 12 +++++++--- .../app/coffee/infrastructure/Modules.coffee | 23 +++++++++++++++++++ .../web/app/views/project/editor/share.jade | 17 +++++++++----- .../ShareProjectModalController.coffee | 23 +++++++++++++++---- .../Contact/ContactControllerTests.coffee | 11 +++++++-- 5 files changed, 70 insertions(+), 16 deletions(-) diff --git a/services/web/app/coffee/Features/Contacts/ContactController.coffee b/services/web/app/coffee/Features/Contacts/ContactController.coffee index 3fc2160f59..a62c22bbf2 100644 --- a/services/web/app/coffee/Features/Contacts/ContactController.coffee +++ b/services/web/app/coffee/Features/Contacts/ContactController.coffee @@ -2,6 +2,7 @@ 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) -> @@ -24,9 +25,13 @@ module.exports = ContactsController = contacts = contacts.filter (c) -> !c.holdingAccount contacts = contacts.map(ContactsController._formatContact) - res.send({ - contacts: contacts - }) + + 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 { @@ -34,4 +39,5 @@ module.exports = ContactsController = email: contact.email first_name: contact.first_name last_name: contact.last_name + type: "user" } diff --git a/services/web/app/coffee/infrastructure/Modules.coffee b/services/web/app/coffee/infrastructure/Modules.coffee index 720819e503..f45e29ea8e 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,26 @@ 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) -> + console.log "attaching hook", 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/views/project/editor/share.jade b/services/web/app/views/project/editor/share.jade index 00a3c6f9b4..fa771f6141 100644 --- a/services/web/app/views/project/editor/share.jade +++ b/services/web/app/views/project/editor/share.jade @@ -52,7 +52,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate') placeholder="joe@example.com, sue@example.com, ..." ng-model="inputs.contacts" focus-on="open" - display-property="email" + display-property="display" key-property="id" add-on-paste="true" ) @@ -145,8 +145,13 @@ script(type="text/ng-template", id="shareTagTemplate") 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 + div(ng-if="data.type == 'user'") + i.fa.fa-user + | + span(ng-bind-html="$highlight(data.email)") + div.subdued.small(ng-show="data.name", ng-bind-html="$highlight(data.name)") + div(ng-if="data.type == 'group'") + i.fa.fa-group + | + span(ng-bind-html="$highlight(data.name)") + div.subdued.small(ng-show="data.member_count") {{ data.memberCount }} members diff --git a/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee index 74243948ab..d502ff4caa 100644 --- a/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee +++ b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee @@ -29,12 +29,23 @@ define [ do loadAutocompleteUsers = () -> $http.get "/user/contacts" .success (data) -> - console.log "Got contacts", 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.name = "" + 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 + $scope.filterAutocompleteUsers = ($query) -> - return $scope.autocompleteContacts.filter (user) -> - for text in [user.name, user.email] + return $scope.autocompleteContacts.filter (contact) -> + for text in [contact.name, contact.email] if text?.toLowerCase().indexOf($query.toLowerCase()) > -1 return true return false @@ -43,7 +54,9 @@ define [ $timeout -> # Give email list a chance to update return if $scope.inputs.contacts.length == 0 - emails = $scope.inputs.contacts.map (contact) -> contact.email + console.warn "Ignoring groups for now" + emails = $scope.inputs.contacts.filter (contact) -> contact.type == "user" + emails = emails.map (contact) -> contact.email $scope.inputs.contacts = [] $scope.state.error = null $scope.state.inflight = true diff --git a/services/web/test/UnitTests/coffee/Contact/ContactControllerTests.coffee b/services/web/test/UnitTests/coffee/Contact/ContactControllerTests.coffee index 468a97e8e7..aa01ff1a0f 100644 --- a/services/web/test/UnitTests/coffee/Contact/ContactControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Contact/ContactControllerTests.coffee @@ -13,6 +13,7 @@ describe "ContactController", -> "../User/UserGetter": @UserGetter = {} "./ContactManager": @ContactManager = {} "../Authentication/AuthenticationController": @AuthenticationController = {} + "../../infrastructure/Modules": @Modules = { hooks: {} } @next = sinon.stub() @req = {} @@ -32,6 +33,7 @@ describe "ContactController", -> @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) + @Modules.hooks.fire = sinon.stub().callsArg(3) @ContactController.getContacts @req, @res, @next @@ -50,8 +52,13 @@ describe "ContactController", -> .calledWith(@contact_ids, { email: 1, first_name: 1, last_name: 1, holdingAccount: 1 }) .should.equal true + it "should fire the getContact module hook", -> + @Modules.hooks.fire + .calledWith("getContacts", @user_id) + .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" } + { id: "contact-1", email: "joe@example.com", first_name: "Joe", last_name: "Example", type: "user" } + { id: "contact-3", email: "jim@example.com", first_name: "Jim", last_name: "Example", type: "user" } ] From d996ed6e474fd3f5bd7d431d727e59735e8be62f Mon Sep 17 00:00:00 2001 From: James Allen Date: Wed, 14 Oct 2015 17:29:58 +0100 Subject: [PATCH 05/12] Refactor addUserToProject for better access by groups --- .../CollaboratorsController.coffee | 17 +++------ .../Collaborators/CollaboratorsHandler.coffee | 17 +++++++-- .../Subscription/LimitationsManager.coffee | 8 ++-- .../app/coffee/infrastructure/Modules.coffee | 1 - .../web/app/views/project/editor/share.jade | 4 +- .../CollaboratorsControllerTests.coffee | 24 +++--------- .../CollaboratorsHandlerTests.coffee | 37 ++++++++++++++----- .../LimitationsManagerTests.coffee | 25 +++++++++---- 8 files changed, 75 insertions(+), 58 deletions(-) diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee index 6999b16bc5..788b782eb2 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee @@ -1,11 +1,9 @@ ProjectGetter = require "../Project/ProjectGetter" CollaboratorsHandler = require "./CollaboratorsHandler" -CollaboratorsEmailHandler = require "./CollaboratorsEmailHandler" ProjectEditorHandler = require "../Project/ProjectEditorHandler" EditorRealTimeController = require "../Editor/EditorRealTimeController" -UserGetter = require "../User/UserGetter" LimitationsManager = require "../Subscription/LimitationsManager" -ContactManager = require "../Contacts/ContactManager" +UserGetter = require "../User/UserGetter" mimelib = require("mimelib") module.exports = CollaboratorsController = @@ -20,10 +18,10 @@ module.exports = CollaboratorsController = addUserToProject: (req, res, next) -> project_id = req.params.Project_id - LimitationsManager.isCollaboratorLimitReached project_id, (error, limit_reached) => + LimitationsManager.canAddXCollaborators project_id, 1, (error, allowed) => return next(error) if error? - if limit_reached + if !allowed return res.json { user: false } else {email, privileges} = req.body @@ -32,18 +30,13 @@ module.exports = CollaboratorsController = if !email? or email == "" return res.status(400).send("invalid email address") - CollaboratorsHandler.addEmailToProject project_id, email, privileges, (error, user_id) => + 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) - - # These things can all be done in the background - adding_user_id = req.session?.user?._id - CollaboratorsEmailHandler.notifyUserOfProjectShare project_id, user.email EditorRealTimeController.emitToRoom(project_id, 'userAddedToProject', user, privileges) - ContactManager.addContact adding_user_id, user_id - return res.json { user: user } removeUserFromProject: (req, res, next) -> diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee index 9aac44f3bb..a88ee55653 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee @@ -3,6 +3,9 @@ Project = require("../../models/Project").Project ProjectEntityHandler = require("../Project/ProjectEntityHandler") mimelib = require("mimelib") logger = require('logger-sharelatex') +UserGetter = require "../User/UserGetter" +ContactManager = require "../Contacts/ContactManager" +CollaboratorsEmailHandler = require "./CollaboratorsEmailHandler" module.exports = CollaboratorsHandler = removeUserFromProject: (project_id, user_id, callback = (error) ->)-> @@ -15,18 +18,18 @@ module.exports = CollaboratorsHandler = logger.error err: err, "problem removing user from project collaberators" callback(err) - addEmailToProject: (project_id, unparsed_email, privilegeLevel, callback = (error, user) ->) -> + addEmailToProject: (project_id, adding_user_id, unparsed_email, privilegeLevel, callback = (error, user) ->) -> emails = mimelib.parseAddresses(unparsed_email) email = emails[0]?.address?.toLowerCase() 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.addUserToProject project_id, user._id, privilegeLevel, (error) -> + CollaboratorsHandler.addUserIdToProject project_id, adding_user_id, user._id, privilegeLevel, (error) -> return callback(error) if error? return callback null, user._id - addUserToProject: (project_id, user_id, privilegeLevel, callback = (error) ->)-> + addUserIdToProject: (project_id, adding_user_id, user_id, privilegeLevel, callback = (error) ->)-> if privilegeLevel == 'readAndWrite' level = {"collaberator_refs":user_id} logger.log {privileges: "readAndWrite", user_id, project_id}, "adding user" @@ -35,6 +38,14 @@ module.exports = CollaboratorsHandler = 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 }, { $push:level }, (error) -> return callback(error) if error? # Flush to TPDS in background to add files to collaborator's Dropbox 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/infrastructure/Modules.coffee b/services/web/app/coffee/infrastructure/Modules.coffee index f45e29ea8e..2df8907f7e 100644 --- a/services/web/app/coffee/infrastructure/Modules.coffee +++ b/services/web/app/coffee/infrastructure/Modules.coffee @@ -47,7 +47,6 @@ module.exports = Modules = hooks: _hooks: {} attach: (name, method) -> - console.log "attaching hook", name, method @_hooks[name] ?= [] @_hooks[name].push method diff --git a/services/web/app/views/project/editor/share.jade b/services/web/app/views/project/editor/share.jade index fa771f6141..510d03e0b2 100644 --- a/services/web/app/views/project/editor/share.jade +++ b/services/web/app/views/project/editor/share.jade @@ -146,12 +146,12 @@ script(type="text/ng-template", id="shareTagTemplate") script(type="text/ng-template", id="shareAutocompleteTemplate") .autocomplete-template div(ng-if="data.type == 'user'") - i.fa.fa-user + i.fa.fa-fw.fa-user | span(ng-bind-html="$highlight(data.email)") div.subdued.small(ng-show="data.name", ng-bind-html="$highlight(data.name)") div(ng-if="data.type == 'group'") - i.fa.fa-group + i.fa.fa-fw.fa-group | span(ng-bind-html="$highlight(data.name)") div.subdued.small(ng-show="data.member_count") {{ data.memberCount }} members diff --git a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee index def122044a..6930707249 100644 --- a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee @@ -14,12 +14,10 @@ describe "CollaboratorsController", -> @CollaboratorsController = SandboxedModule.require modulePath, requires: "../Project/ProjectGetter": @ProjectGetter = {} "./CollaboratorsHandler": @CollaboratorsHandler = {} - "./CollaboratorsEmailHandler": @CollaboratorsEmailHandler = {} - "../User/UserGetter": @UserGetter = {} "../Editor/EditorRealTimeController": @EditorRealTimeController = {} '../Subscription/LimitationsManager' : @LimitationsManager = {} '../Project/ProjectEditorHandler' : @ProjectEditorHandler = {} - "../Contacts/ContactManager": @ContactManager = {} + '../User/UserGetter': @UserGetter = {} @res = new MockResponse() @req = new MockRequest() @@ -72,13 +70,11 @@ describe "CollaboratorsController", -> @user_view = { id: @user_id, first_name: "Joe", last_name: "Example", email: "joe@example.com" } - @LimitationsManager.isCollaboratorLimitReached = sinon.stub().callsArgWith(1, null, false) + @LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, true) @ProjectEditorHandler.buildUserModelView = sinon.stub().returns(@user_view) - @CollaboratorsHandler.addEmailToProject = sinon.stub().callsArgWith(3, null, @user_id) + @CollaboratorsHandler.addEmailToProject = sinon.stub().callsArgWith(4, null, @user_id) @UserGetter.getUser = sinon.stub().callsArgWith(1, null, @user) - @CollaboratorsEmailHandler.notifyUserOfProjectShare = sinon.stub() @EditorRealTimeController.emitToRoom = sinon.stub() - @ContactManager.addContact = sinon.stub() @callback = sinon.stub() describe "when the project can accept more collaborators", -> @@ -87,23 +83,13 @@ describe "CollaboratorsController", -> it "should add the user to the project", -> @CollaboratorsHandler.addEmailToProject - .calledWith(@project_id, @email.toLowerCase(), @privileges) + .calledWith(@project_id, @adding_user_id, @email.toLowerCase(), @privileges) .should.equal true it "should emit a userAddedToProject event", -> @EditorRealTimeController.emitToRoom .calledWith(@project_id, "userAddedToProject", @user_view, @privileges) .should.equal true - - it "should send an email to the shared-with user", -> - @CollaboratorsEmailHandler.notifyUserOfProjectShare - .calledWith(@project_id, @email.toLowerCase()) - .should.equal true - - it "should add the user as a contact for the adding user", -> - @ContactManager.addContact - .calledWith(@adding_user_id, @user_id) - .should.equal true it "should send the user as the response body", -> @res.json @@ -114,7 +100,7 @@ describe "CollaboratorsController", -> describe "when the project cannot accept more collaborators", -> beforeEach -> - @LimitationsManager.isCollaboratorLimitReached = sinon.stub().callsArgWith(1, null, true) + @LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, false) @CollaboratorsController.addUserToProject @req, @res, @next it "should not add the user to the project", -> diff --git a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee index 2ddbddd5f0..32d1aa5a5e 100644 --- a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee +++ b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee @@ -11,11 +11,16 @@ describe "CollaboratorsHandler", -> @CollaboratorHandler = SandboxedModule.require modulePath, requires: "logger-sharelatex": @logger = { log: sinon.stub(), err: sinon.stub() } '../User/UserCreator': @UserCreator = {} + '../User/UserGetter': @UserGetter = {} + "../Contacts/ContactManager": @ContactManager = {} "../../models/Project": Project: @Project = {} "../Project/ProjectEntityHandler": @ProjectEntityHandler = {} + "./CollaboratorsEmailHandler": @CollaboratorsEmailHandler = {} @project_id = "mock-project-id" @user_id = "mock-user-id" + @adding_user_id = "adding-user-id" + @email = "joe@sharelatex.com" @callback = sinon.stub() describe "removeUserFromProject", -> @@ -36,10 +41,14 @@ describe "CollaboratorsHandler", -> beforeEach -> @Project.update = sinon.stub().callsArg(2) @ProjectEntityHandler.flushProjectToThirdPartyDataStore = sinon.stub().callsArg(1) + @CollaboratorHandler.addEmailToProject = sinon.stub().callsArgWith(4, null, @user_id) + @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user = { _id: @user_id, email: @email }) + @CollaboratorsEmailHandler.notifyUserOfProjectShare = sinon.stub() + @ContactManager.addContact = sinon.stub() describe "as readOnly", -> beforeEach -> - @CollaboratorHandler.addUserToProject @project_id, @user_id, "readOnly", @callback + @CollaboratorHandler.addUserIdToProject @project_id, @adding_user_id, @user_id, "readOnly", @callback it "should add the user to the readOnly_refs", -> @Project.update @@ -54,10 +63,20 @@ describe "CollaboratorsHandler", -> @ProjectEntityHandler.flushProjectToThirdPartyDataStore .calledWith(@project_id) .should.equal true + + it "should send an email to the shared-with user", -> + @CollaboratorsEmailHandler.notifyUserOfProjectShare + .calledWith(@project_id, @email) + .should.equal true + + it "should add the user as a contact for the adding user", -> + @ContactManager.addContact + .calledWith(@adding_user_id, @user_id) + .should.equal true describe "as readAndWrite", -> beforeEach -> - @CollaboratorHandler.addUserToProject @project_id, @user_id, "readAndWrite", @callback + @CollaboratorHandler.addUserIdToProject @project_id, @adding_user_id, @user_id, "readAndWrite", @callback it "should add the user to the collaberator_refs", -> @Project.update @@ -75,7 +94,7 @@ describe "CollaboratorsHandler", -> describe "with invalid privilegeLevel", -> beforeEach -> - @CollaboratorHandler.addUserToProject @project_id, @user_id, "notValid", @callback + @CollaboratorHandler.addUserIdToProject @project_id, @adding_user_id, @user_id, "notValid", @callback it "should call the callback with an error", -> @callback.calledWith(new Error()).should.equal true @@ -83,11 +102,11 @@ describe "CollaboratorsHandler", -> describe "addEmailToProject", -> beforeEach -> @UserCreator.getUserOrCreateHoldingAccount = sinon.stub().callsArgWith(1, null, @user = {_id: @user_id}) - @CollaboratorHandler.addUserToProject = sinon.stub().callsArg(3) + @CollaboratorHandler.addUserIdToProject = sinon.stub().callsArg(4) describe "with a valid email", -> beforeEach -> - @CollaboratorHandler.addEmailToProject @project_id, (@email = "Joe@example.com"), (@privilegeLevel = "readAndWrite"), @callback + @CollaboratorHandler.addEmailToProject @project_id, @adding_user_id, (@email = "Joe@example.com"), (@privilegeLevel = "readAndWrite"), @callback it "should get the user with the lowercased email", -> @UserCreator.getUserOrCreateHoldingAccount @@ -95,8 +114,8 @@ describe "CollaboratorsHandler", -> .should.equal true it "should add the user to the project by id", -> - @CollaboratorHandler.addUserToProject - .calledWith(@project_id, @user_id, @privilegeLevel) + @CollaboratorHandler.addUserIdToProject + .calledWith(@project_id, @adding_user_id, @user_id, @privilegeLevel) .should.equal true it "should return the callback with the user_id", -> @@ -104,13 +123,13 @@ describe "CollaboratorsHandler", -> describe "with an invalid email", -> beforeEach -> - @CollaboratorHandler.addEmailToProject @project_id, "not-and-email", (@privilegeLevel = "readAndWrite"), @callback + @CollaboratorHandler.addEmailToProject @project_id, @adding_user_id, "not-and-email", (@privilegeLevel = "readAndWrite"), @callback it "should call the callback with an error", -> @callback.calledWith(new Error()).should.equal true it "should not add any users to the proejct", -> - @CollaboratorHandler.addUserToProject.called.should.equal false + @CollaboratorHandler.addUserIdToProject.called.should.equal false diff --git a/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee b/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee index f12bf06a87..df1edca280 100644 --- a/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee +++ b/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee @@ -62,7 +62,7 @@ describe "LimitationsManager", -> it "should return the total number of collaborators", -> @callback.calledWith(null, 3).should.equal true - describe "isCollaboratorLimitReached", -> + describe "canAddXCollaborators", -> beforeEach -> sinon.stub @LimitationsManager, "currentNumberOfCollaboratorsInProject", @@ -76,7 +76,16 @@ describe "LimitationsManager", -> beforeEach -> @current_number = 1 @allowed_number = 2 - @LimitationsManager.isCollaboratorLimitReached(@project_id, @callback) + @LimitationsManager.canAddXCollaborators(@project_id, 1, @callback) + + it "should return true", -> + @callback.calledWith(null, true).should.equal true + + describe "when the project has fewer collaborators than allowed but I want to add more than allowed", -> + beforeEach -> + @current_number = 1 + @allowed_number = 2 + @LimitationsManager.canAddXCollaborators(@project_id, 2, @callback) it "should return false", -> @callback.calledWith(null, false).should.equal true @@ -85,19 +94,19 @@ describe "LimitationsManager", -> beforeEach -> @current_number = 3 @allowed_number = 2 - @LimitationsManager.isCollaboratorLimitReached(@project_id, @callback) + @LimitationsManager.canAddXCollaborators(@project_id, 1, @callback) - it "should return true", -> - @callback.calledWith(null, true).should.equal true + it "should return false", -> + @callback.calledWith(null, false).should.equal true describe "when the project has infinite collaborators", -> beforeEach -> @current_number = 100 @allowed_number = -1 - @LimitationsManager.isCollaboratorLimitReached(@project_id, @callback) + @LimitationsManager.canAddXCollaborators(@project_id, 1, @callback) - it "should return false", -> - @callback.calledWith(null, false).should.equal true + it "should return true", -> + @callback.calledWith(null, true).should.equal true describe "userHasSubscription", -> From b0895cc6aae4b988615cd535c1598feba5bb25a4 Mon Sep 17 00:00:00 2001 From: James Allen Date: Thu, 15 Oct 2015 13:53:46 +0100 Subject: [PATCH 06/12] Support adding groups to projects via share modal --- .../ShareProjectModalController.coffee | 40 +++++++++++-------- .../ide/share/services/projectMembers.coffee | 8 ++++ 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee index d502ff4caa..4d09f7e0b0 100644 --- a/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee +++ b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee @@ -4,7 +4,7 @@ define [ App.controller "ShareProjectModalController", ($scope, $modalInstance, $timeout, projectMembers, $modal, $http) -> $scope.inputs = { privileges: "readAndWrite" - email: "" + contacts: [] } $scope.state = { error: null @@ -54,32 +54,40 @@ define [ $timeout -> # Give email list a chance to update return if $scope.inputs.contacts.length == 0 - console.warn "Ignoring groups for now" - emails = $scope.inputs.contacts.filter (contact) -> contact.type == "user" - emails = emails.map (contact) -> contact.email + members = $scope.inputs.contacts $scope.inputs.contacts = [] $scope.state.error = null $scope.state.inflight = true - console.log "Adding emails", emails + console.log "Adding members", members do addNextMember = () -> - if emails.length == 0 or !$scope.canAddCollaborators + if members.length == 0 or !$scope.canAddCollaborators $scope.state.inflight = false $scope.$apply() return - email = emails.shift() - projectMembers - .addMember(email, $scope.inputs.privileges) + member = members.shift() + 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) + + request .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 + 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 :(" 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 From fb42489803cd41689701383e87c18fa3db988c5e Mon Sep 17 00:00:00 2001 From: James Allen Date: Thu, 15 Oct 2015 16:43:53 +0100 Subject: [PATCH 07/12] Improve formatting of users, groups and tags --- services/web/app/views/project/editor/share.jade | 6 +++--- .../share/controllers/ShareProjectModalController.coffee | 6 ------ services/web/public/stylesheets/components/tags-input.less | 3 +-- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/services/web/app/views/project/editor/share.jade b/services/web/app/views/project/editor/share.jade index 510d03e0b2..837ad60c03 100644 --- a/services/web/app/views/project/editor/share.jade +++ b/services/web/app/views/project/editor/share.jade @@ -55,6 +55,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate') display-property="display" key-property="id" add-on-paste="true" + replace-spaces-with-dashes="false" ) auto-complete( source="filterAutocompleteUsers($query)" @@ -148,10 +149,9 @@ script(type="text/ng-template", id="shareAutocompleteTemplate") div(ng-if="data.type == 'user'") i.fa.fa-fw.fa-user | - span(ng-bind-html="$highlight(data.email)") - div.subdued.small(ng-show="data.name", ng-bind-html="$highlight(data.name)") + 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)") - div.subdued.small(ng-show="data.member_count") {{ data.memberCount }} members + span.subdued.small(ng-show="data.member_count") ({{ data.member_count }} members) diff --git a/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee index 4d09f7e0b0..1b0ecd06bb 100644 --- a/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee +++ b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee @@ -22,9 +22,6 @@ 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" @@ -34,7 +31,6 @@ define [ 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.name = "" contact.display = contact.email else contact.name = "#{contact.first_name} #{contact.last_name}" @@ -59,8 +55,6 @@ define [ $scope.state.error = null $scope.state.inflight = true - console.log "Adding members", members - do addNextMember = () -> if members.length == 0 or !$scope.canAddCollaborators $scope.state.inflight = false diff --git a/services/web/public/stylesheets/components/tags-input.less b/services/web/public/stylesheets/components/tags-input.less index 48b03aa742..49ad1b2de3 100644 --- a/services/web/public/stylesheets/components/tags-input.less +++ b/services/web/public/stylesheets/components/tags-input.less @@ -48,8 +48,7 @@ tags-input .tags .tag-item { 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%); + background-color: @gray-lighter; } tags-input .tags .tag-item .remove-button { color: @gray-light; From 144f1396b762ef0b861b42bc310222130a309549 Mon Sep 17 00:00:00 2001 From: James Allen Date: Thu, 15 Oct 2015 17:03:22 +0100 Subject: [PATCH 08/12] Fix problem with pasting multiple emails --- services/web/app/views/project/editor/share.jade | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/app/views/project/editor/share.jade b/services/web/app/views/project/editor/share.jade index 837ad60c03..bce5674ad6 100644 --- a/services/web/app/views/project/editor/share.jade +++ b/services/web/app/views/project/editor/share.jade @@ -53,9 +53,9 @@ script(type='text/ng-template', id='shareProjectModalTemplate') ng-model="inputs.contacts" focus-on="open" display-property="display" - key-property="id" add-on-paste="true" replace-spaces-with-dashes="false" + type="email" ) auto-complete( source="filterAutocompleteUsers($query)" @@ -137,7 +137,7 @@ script(type="text/ng-template", id="makePrivateModalTemplate") 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'}") + i.fa.fa-fw(ng-class="{'fa-user': data.type != 'group', 'fa-group': data.type == 'group'}") | span {{$getDisplayText()}} | From 7359fc16eec3f3daa2be48305430479a9e0d68c5 Mon Sep 17 00:00:00 2001 From: James Allen Date: Mon, 2 Nov 2015 13:57:50 +0000 Subject: [PATCH 09/12] Add in show-on-hover CSS classes --- services/web/public/stylesheets/components/hover.less | 11 +++++++++++ services/web/public/stylesheets/style.less | 1 + 2 files changed, 12 insertions(+) create mode 100644 services/web/public/stylesheets/components/hover.less diff --git a/services/web/public/stylesheets/components/hover.less b/services/web/public/stylesheets/components/hover.less new file mode 100644 index 0000000000..8bb9190a49 --- /dev/null +++ b/services/web/public/stylesheets/components/hover.less @@ -0,0 +1,11 @@ +.hover-container { + .show-on-hover { + display: none; + } + + &:hover { + .show-on-hover { + display: initial; + } + } +} \ No newline at end of file diff --git a/services/web/public/stylesheets/style.less b/services/web/public/stylesheets/style.less index 5613671db9..be7367a08b 100755 --- a/services/web/public/stylesheets/style.less +++ b/services/web/public/stylesheets/style.less @@ -40,6 +40,7 @@ // @import "components/wells.less"; @import "components/close.less"; @import "components/fineupload.less"; +@import "components/hover.less"; // Components w/ JavaScript @import "components/modals.less"; From c46f62cfc121176e43ff1a1153a9affdf561715c Mon Sep 17 00:00:00 2001 From: James Allen Date: Mon, 2 Nov 2015 15:21:41 +0000 Subject: [PATCH 10/12] Ensure that a user can only be added to project once --- .../Collaborators/CollaboratorsHandler.coffee | 50 +++++++++++-------- .../ShareProjectModalController.coffee | 13 +++++ .../CollaboratorsHandlerTests.coffee | 13 ++++- 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee index a88ee55653..4ab855af80 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee @@ -30,26 +30,34 @@ module.exports = CollaboratorsHandler = return callback null, user._id addUserIdToProject: (project_id, adding_user_id, user_id, privilegeLevel, callback = (error) ->)-> - 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 }, { $push:level }, (error) -> + Project.findOne { _id: project_id }, { collaberator_refs: 1, readOnly_refs: 1 }, (error, project) -> return callback(error) if error? - # Flush to TPDS in background to add files to collaborator's Dropbox - ProjectEntityHandler.flushProjectToThirdPartyDataStore project_id, (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 flushing to TPDS after adding collaborator" - callback() + 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/public/coffee/ide/share/controllers/ShareProjectModalController.coffee b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee index 1b0ecd06bb..b195a03afb 100644 --- a/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee +++ b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee @@ -38,9 +38,15 @@ define [ 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 @@ -55,6 +61,7 @@ define [ $scope.state.error = null $scope.state.inflight = true + currentMemberEmails = getCurrentMemberEmails() do addNextMember = () -> if members.length == 0 or !$scope.canAddCollaborators $scope.state.inflight = false @@ -62,10 +69,16 @@ define [ 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) -> diff --git a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee index 32d1aa5a5e..1fd3a0c2d5 100644 --- a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee +++ b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee @@ -40,6 +40,7 @@ describe "CollaboratorsHandler", -> describe "addUserToProject", -> beforeEach -> @Project.update = sinon.stub().callsArg(2) + @Project.findOne = sinon.stub().callsArgWith(2, null, @project = {}) @ProjectEntityHandler.flushProjectToThirdPartyDataStore = sinon.stub().callsArg(1) @CollaboratorHandler.addEmailToProject = sinon.stub().callsArgWith(4, null, @user_id) @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user = { _id: @user_id, email: @email }) @@ -55,7 +56,7 @@ describe "CollaboratorsHandler", -> .calledWith({ _id: @project_id }, { - "$push":{ readOnly_refs: @user_id } + "$addToSet":{ readOnly_refs: @user_id } }) .should.equal true @@ -83,7 +84,7 @@ describe "CollaboratorsHandler", -> .calledWith({ _id: @project_id }, { - "$push":{ collaberator_refs: @user_id } + "$addToSet":{ collaberator_refs: @user_id } }) .should.equal true @@ -98,6 +99,14 @@ describe "CollaboratorsHandler", -> it "should call the callback with an error", -> @callback.calledWith(new Error()).should.equal true + + describe "when user already exists as a collaborator", -> + beforeEach -> + @project.collaberator_refs = [@user_id] + @CollaboratorHandler.addUserIdToProject @project_id, @adding_user_id, @user_id, "readAndWrite", @callback + + it "should not add the user again", -> + @Project.update.called.should.equal false describe "addEmailToProject", -> beforeEach -> From 34d13d5b2d1f196ae28c5043e659555b0cd0845d Mon Sep 17 00:00:00 2001 From: James Allen Date: Tue, 3 Nov 2015 12:00:57 +0000 Subject: [PATCH 11/12] Include latest translations --- services/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/package.json b/services/web/package.json index d1c139500f..86cfd3f583 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -48,7 +48,7 @@ "session.socket.io": "0.1.4", "settings-sharelatex": "git+https://github.com/sharelatex/settings-sharelatex.git#v1.0.0", "socket.io": "0.9.16", - "translations-sharelatex": "git+https://github.com/sharelatex/translations-sharelatex.git#v0.2.0", + "translations-sharelatex": "git+https://github.com/sharelatex/translations-sharelatex.git#master", "underscore": "1.6.0", "underscore.string": "^3.0.2", "v8-profiler": "^5.2.3", From 21f702a42e9e9c86e8f0c337ce10b447f93b37b3 Mon Sep 17 00:00:00 2001 From: James Allen Date: Wed, 4 Nov 2015 14:33:27 +0000 Subject: [PATCH 12/12] Fix bug with click on share button registering --- services/web/app/views/project/editor/share.jade | 7 +++++-- .../share/controllers/ShareProjectModalController.coffee | 8 +++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/services/web/app/views/project/editor/share.jade b/services/web/app/views/project/editor/share.jade index bce5674ad6..f93519e657 100644 --- a/services/web/app/views/project/editor/share.jade +++ b/services/web/app/views/project/editor/share.jade @@ -72,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")}. @@ -86,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")} diff --git a/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee index b195a03afb..bb945a9ebc 100644 --- a/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee +++ b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee @@ -53,7 +53,7 @@ define [ return false $scope.addMembers = () -> - $timeout -> # Give email list a chance to update + addMembers = () -> return if $scope.inputs.contacts.length == 0 members = $scope.inputs.contacts @@ -97,8 +97,10 @@ define [ , 0 .error () -> $scope.state.inflight = false - $scope.state.error = "Sorry, something went wrong :(" - + $scope.state.error = true + + + $timeout addMembers, 50 # Give email list a chance to update $scope.removeMember = (member) -> $scope.state.error = null