diff --git a/.travis.yml b/.travis.yml index 9aaf96e9..f5617c42 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,17 @@ language: node_js +dist: trusty node_js: - "node" before_install: - - export CHROME_BIN=chromium-browser - - export DISPLAY=:99.0 - - sh -e /etc/init.d/xvfb start - - travis_retry npm install -g gulp + - sudo apt-get update + - sudo apt-get install -y libappindicator1 fonts-liberation + - wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb + - sudo dpkg -i google-chrome*.deb install: - travis_retry npm install before_script: + - export CHROME_BIN=/usr/bin/google-chrome + - export DISPLAY=:99.0 + - sh -e /etc/init.d/xvfb start + - travis_retry npm install -g gulp - gulp deploy diff --git a/CHANGELOG.md b/CHANGELOG.md index ed9df575..bbb9a4ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Add rich text custom fields (with a wysiwyg editor like descreption or comments). - Add thumbnails and preview for PSD files. - Add thumbnails and preview for SVG files. +- Improve add-members form: Now users can select between their contacts or type an email. - i18n: - Add japanese (ja) translation. - Add korean (ko) translation. diff --git a/app/coffee/modules/admin/lightboxes.coffee b/app/coffee/modules/admin/lightboxes.coffee index 9802d72a..5f8d632a 100644 --- a/app/coffee/modules/admin/lightboxes.coffee +++ b/app/coffee/modules/admin/lightboxes.coffee @@ -27,112 +27,6 @@ debounce = @.taiga.debounce module = angular.module("taigaKanban") -############################################################################# -## Create Members Lightbox Directive -############################################################################# - -class LightboxAddMembersController - @.$inject = [ - "$scope", - "lightboxService", - "tgLoader", - "$tgConfirm", - "$tgResources", - "$rootScope", - ] - - constructor: (@scope, @lightboxService, @tgLoader, @confirm, @rs, @rootScope) -> - @._defaultMaxInvites = 4 - @._defaultRole = @.project.roles[0].id - @.form = null - @.submitInvites = false - @.canAddUsers = true - @.memberInvites = [] - - if @.project.max_memberships == null - @.membersLimit = @._defaultMaxInvites - else - pendingMembersCount = Math.max(@.project.max_memberships - @.project.total_memberships, 0) - @.membersLimit = Math.min(pendingMembersCount, @._defaultMaxInvites) - - @.addSingleMember() - - addSingleMember: () -> - @.memberInvites.push({email:'', role_id: @._defaultRole}) - - if @.memberInvites.length >= @.membersLimit - @.canAddUsers = false - @.showWarningMessage = (!@.canAddUsers && - @.project.total_memberships + @.memberInvites.length == @.project.max_memberships) - - removeSingleMember: (index) -> - @.memberInvites.splice(index, 1) - - @.canAddUsers = true - @.showWarningMessage = @.membersLimit == 1 - - submit: () -> - # Need to reset the form constrains - @.form.initializeFields() - @.form.reset() - return if not @.form.validate() - - @.memberInvites = _.filter(@.memberInvites, (invites) -> - invites.email != "") - - @.submitInvites = true - promise = @rs.memberships.bulkCreateMemberships( - @.project.id, - @.memberInvites, - @.invitationText - ) - promise.then( - @._onSuccessInvite.bind(this), - @._onErrorInvite.bind(this) - ) - - _onSuccessInvite: () -> - @.submitInvites = false - @rootScope.$broadcast("membersform:new:success") - @lightboxService.closeAll() - @confirm.notify("success") - - _onErrorInvite: (response) -> - @.submitInvites = false - errors = {} - _.each response.data.bulk_memberships, (value, index) => - if value.email - errors["email-#{index}"] = value.email[0] - if value.role - errors["role-#{index}"] = value.role[0] - - @.form.setErrors(errors) - if response.data._error_message - @confirm.notify("error", response.data._error_message) - -module.controller("LbAddMembersController", LightboxAddMembersController) - - - -LightboxAddMembersDirective = (lightboxService) -> - link = (scope, el, attrs, ctrl) -> - lightboxService.open(el) - ctrl.form = el.find("form").checksley() - - return { - scope: {}, - bindToController: { - project: '=', - }, - controller: 'LbAddMembersController', - controllerAs: 'vm', - templateUrl: 'admin/lightbox-add-members.html', - link: link - } - -module.directive("tgLbAddMembers", ["lightboxService", LightboxAddMembersDirective]) - - ############################################################################# ## Warning message directive ############################################################################# diff --git a/app/coffee/utils.coffee b/app/coffee/utils.coffee index 7524f0c3..8a8f0b72 100644 --- a/app/coffee/utils.coffee +++ b/app/coffee/utils.coffee @@ -222,6 +222,8 @@ _.mixin isImage = (name) -> return name.match(/\.(jpe?g|png|gif|gifv|webm|svg|psd)/i) != null +isEmail = (name) -> + return name? and name.match(/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/) != null isPdf = (name) -> return name.match(/\.(pdf)/i) != null @@ -286,6 +288,7 @@ taiga.stripTags = stripTags taiga.replaceTags = replaceTags taiga.defineImmutableProperty = defineImmutableProperty taiga.isImage = isImage +taiga.isEmail = isEmail taiga.isPdf = isPdf taiga.patch = patch taiga.getRandomDefaultColor = getRandomDefaultColor diff --git a/app/locales/taiga/locale-en.json b/app/locales/taiga/locale-en.json index 60e1d524..ecf924b5 100644 --- a/app/locales/taiga/locale-en.json +++ b/app/locales/taiga/locale-en.json @@ -990,6 +990,12 @@ }, "ADD_MEMBER": { "TITLE": "New Member", + "PLACEHOLDER": "Filter users or write an email to invite", + "ADD_EMAIL": "Add email", + "REMOVE": "Remove", + "INVITE": "Invite", + "CHOOSE_ROLE": "Choose a role", + "PLACEHOLDER_INVITATION_TEXT": "(Optional) Add a personalized text to the invitation. Tell something lovely to your new members ;-)", "HELP_TEXT": "If users are already registered on Taiga, they will be added automatically. Otherwise they will receive an invitation." }, "CREATE_ISSUE": { diff --git a/app/modules/invite-members/invite-members-form/invite-members-form.controller.coffee b/app/modules/invite-members/invite-members-form/invite-members-form.controller.coffee new file mode 100644 index 00000000..263cb6ab --- /dev/null +++ b/app/modules/invite-members/invite-members-form/invite-members-form.controller.coffee @@ -0,0 +1,80 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: add-members.controller.coffee +### + +taiga = @.taiga + + +class InviteMembersFormController + @.$inject = [ + "tgProjectService", + "$tgResources", + "lightboxService", + "$tgConfirm", + "$rootScope" + ] + + constructor: (@projectService, @rs, @lightboxService, @confirm, @rootScope) -> + @.project = @projectService.project + @.roles = @projectService.project.get('roles') + @.rolesValues = {} + @.loading = false + @.defaultMaxInvites = 4 + + _areRolesValidated: () -> + Object.defineProperty @, 'areRolesValidated', { + get: () => + roleIds = _.filter Object.values(@.rolesValues), (it) -> return it + return roleIds.length == @.contactsToInvite.size + @.emailsToInvite.size + } + + _checkLimitMemberships: () -> + if @.project.get('max_memberships') == null + @.membersLimit = @.defaultMaxInvites + else + pendingMembersCount = Math.max(@.project.get('max_memberships') - @.project.get('total_memberships'), 0) + @.membersLimit = Math.min(pendingMembersCount, @.defaultMaxInvites) + + @.showWarningMessage = @.membersLimit < @.defaultMaxInvites + + sendInvites: () -> + @.setInvitedContacts = [] + _.forEach(@.rolesValues, (key, value) => + @.setInvitedContacts.push({ + 'role_id': key + 'username': value + }) + ) + @.loading = true + @rs.memberships.bulkCreateMemberships( + @.project.get('id'), + @.setInvitedContacts, + @.inviteContactsMessage + ) + .then (response) => # On success + @.loading = false + @lightboxService.closeAll() + @rootScope.$broadcast("membersform:new:success") + @confirm.notify('success') + .catch (response) => # On error + @.loading = false + if response.data._error_message + @confirm.notify("error", response.data._error_message) + + +angular.module("taigaAdmin").controller("InviteMembersFormCtrl", InviteMembersFormController) diff --git a/app/modules/invite-members/invite-members-form/invite-members-form.controller.spec.coffee b/app/modules/invite-members/invite-members-form/invite-members-form.controller.spec.coffee new file mode 100644 index 00000000..09e809f9 --- /dev/null +++ b/app/modules/invite-members/invite-members-form/invite-members-form.controller.spec.coffee @@ -0,0 +1,134 @@ +### +# Copyright (C) 2014-2016 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: invite-members-form.controller.spec.coffee +### + +describe "InviteMembersFormController", -> + inviteMembersFormCtrl = null + provide = null + controller = null + mocks = {} + + _mockProjectService = () -> + mocks.projectService = { + project: sinon.stub() + } + + provide.value "tgProjectService", mocks.projectService + + _mockTgResources = () -> + mocks.tgResources = { + memberships: { + bulkCreateMemberships: sinon.stub() + } + } + + provide.value "$tgResources", mocks.tgResources + + _mockLightboxService = () -> + mocks.lightboxService = { + closeAll: sinon.stub() + } + + provide.value "lightboxService", mocks.lightboxService + + _mockTgConfirm = () -> + mocks.tgConfirm = { + notify: sinon.stub() + } + + provide.value "$tgConfirm", mocks.tgConfirm + + _mockRootScope = -> + mocks.rootScope = { + $broadcast: sinon.stub() + } + + provide.value("$rootScope", mocks.rootScope) + + _mocks = () -> + module ($provide) -> + provide = $provide + _mockProjectService() + _mockTgResources() + _mockLightboxService() + _mockTgConfirm() + _mockRootScope() + return null + + beforeEach -> + module "taigaAdmin" + + _mocks() + + inject ($controller) -> + controller = $controller + + mocks.projectService.project = Immutable.fromJS([{ + 'roles': 'role1' + }]) + + it "check limit memberships - no limit", () -> + inviteMembersFormCtrl = controller "InviteMembersFormCtrl" + + inviteMembersFormCtrl.project = Immutable.fromJS({ + 'max_memberships': null, + }) + + inviteMembersFormCtrl.defaultMaxInvites = 4 + + inviteMembersFormCtrl._checkLimitMemberships() + expect(inviteMembersFormCtrl.membersLimit).to.be.equal(4) + expect(inviteMembersFormCtrl.showWarningMessage).to.be.false + + it "check limit memberships", () -> + inviteMembersFormCtrl = controller "InviteMembersFormCtrl" + + inviteMembersFormCtrl.project = Immutable.fromJS({ + 'max_memberships': 15, + 'total_memberships': 13 + }) + inviteMembersFormCtrl.defaultMaxInvites = 4 + + inviteMembersFormCtrl._checkLimitMemberships() + expect(inviteMembersFormCtrl.membersLimit).to.be.equal(2) + expect(inviteMembersFormCtrl.showWarningMessage).to.be.true + + + it "send invites", (done) -> + inviteMembersFormCtrl = controller "InviteMembersFormCtrl" + inviteMembersFormCtrl.project = Immutable.fromJS( + {'id': 1} + ) + inviteMembersFormCtrl.rolesValues = {'user1': 1} + inviteMembersFormCtrl.inviteContactsMessage = 'Message' + inviteMembersFormCtrl.loading = true + + promise = mocks.tgResources.memberships.bulkCreateMemberships.withArgs( + 1, + [{ + 'role_id': 1 + 'username': 'user1' + }], + 'Message' + ).promise().resolve() + + inviteMembersFormCtrl.sendInvites().then () -> + expect(inviteMembersFormCtrl.loading).to.be.false + expect(mocks.rootScope.$broadcast).to.have.been.calledWith("membersform:new:success") + expect(mocks.tgConfirm.notify).to.have.been.calledWith("success") + done() diff --git a/app/modules/invite-members/invite-members-form/invite-members-form.directive.coffee b/app/modules/invite-members/invite-members-form/invite-members-form.directive.coffee new file mode 100644 index 00000000..4afd40bd --- /dev/null +++ b/app/modules/invite-members/invite-members-form/invite-members-form.directive.coffee @@ -0,0 +1,41 @@ +### +# Copyright (C) 2014-2016 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: invite-members.directive.coffee +### + +InviteMembersFormDirective = () -> + link = (scope, el, attrs, ctrl) -> + ctrl._areRolesValidated() + ctrl._checkLimitMemberships() + + return { + scope: {}, + templateUrl:"invite-members/invite-members-form/invite-members-form.html", + controller: "InviteMembersFormCtrl", + controllerAs: "vm", + bindToController: { + contactsToInvite: '<', + emailsToInvite: '=', + onDisplayContactList: '&', + onRemoveInvitedContact: '&', + onRemoveInvitedEmail: '&', + onSendInvites: '&' + }, + link: link + } + +angular.module("taigaAdmin").directive("tgInviteMembersForm", InviteMembersFormDirective) diff --git a/app/modules/invite-members/invite-members-form/invite-members-form.jade b/app/modules/invite-members/invite-members-form/invite-members-form.jade new file mode 100644 index 00000000..b2642606 --- /dev/null +++ b/app/modules/invite-members/invite-members-form/invite-members-form.jade @@ -0,0 +1,69 @@ +form.invite-members-form(ng-submit="vm.sendInvites(vm.inviteContacts)") + ul.invite-members-form-list + li.invite-members-single.e2e-invite-members-single( + ng-repeat="contact in vm.contactsToInvite | toMutable track by contact.id" + ) + .invite-members-single-data + img.invite-members-single-avatar( + tg-avatar="contact" + alt="{{contact.full_name}}" + ) + span.invite-members-single-name {{contact.full_name}} + a.invite-members-single-remove.e2e-invite-members-single-remove( + href="" + ng-click="vm.onRemoveInvitedContact({contact: contact})" + translate="LIGHTBOX.ADD_MEMBER.REMOVE" + ) + select.invite-members-single-role.e2e-invite-members-single-role( + ng-model="vm.rolesValues[contact.username]" + id="add-member-suggest-role-dropdown" + ng-options="role.id as role.name for role in vm.roles | toMutable track by role.id" + required + ) + option( + value="" + selected="selected" + translate="LIGHTBOX.ADD_MEMBER.CHOOSE_ROLE" + ) + li.invite-members-single.e2e-invite-members-single( + ng-repeat="userMail in vm.emailsToInvite | toMutable" + ) + .invite-members-single-data + span.invite-members-single-email {{userMail.email}} + a.invite-members-single-remove.e2e-invite-members-single-remove( + href="" + ng-click="vm.onRemoveInvitedEmail({email: userMail})" + translate="LIGHTBOX.ADD_MEMBER.REMOVE" + ) + select.invite-members-single-role.e2e-invite-members-single-role( + ng-model="vm.rolesValues[userMail.email]" + id="add-email-suggest-role-dropdown" + ng-options="role.id as role.name for role in vm.roles | toMutable track by role.id" + required + ) + option( + value="" + translate="LIGHTBOX.ADD_MEMBER.CHOOSE_ROLE" + ) + .invite-members-single-new.e2e-invite-members-single-new( + ng-if="vm.contactsToInvite.size + vm.emailsToInvite.size < vm.membersLimit" + ) + tg-svg.invite-members-single-new-btn( + svg-icon="icon-add" + ng-click="vm.onDisplayContactList()" + ) + tg-lightbox-add-members-warning-message( + ng-if="vm.showWarningMessage" + project="vm.project" + ) + textarea.invite-members-single-msg( + ng-model="vm.inviteContactsMessage" + placeholder="{{'LIGHTBOX.ADD_MEMBER.PLACEHOLDER_INVITATION_TEXT' | translate}}" + ) + button.button-green.invite-members-single-send.e2e-invite-members-single-send( + type="submit" + translate="LIGHTBOX.ADD_MEMBER.INVITE" + ng-disabled="!vm.areRolesValidated" + tg-loading="vm.loading" + ) + p.invite-members-single-help(translate="LIGHTBOX.ADD_MEMBER.HELP_TEXT") diff --git a/app/modules/invite-members/invite-members-form/invite-members-form.scss b/app/modules/invite-members/invite-members-form/invite-members-form.scss new file mode 100644 index 00000000..9f5099f9 --- /dev/null +++ b/app/modules/invite-members/invite-members-form/invite-members-form.scss @@ -0,0 +1,67 @@ +.invite-members-form { + border-top: 1px solid $whitish; + margin: 0 5rem; + .invite-members-form-list { + margin: 0; + margin-bottom: 1rem; + } + .invite-members-single { + align-items: center; + border-bottom: 1px solid $whitish; + display: flex; + justify-content: space-between; + padding: 1rem; + } + .invite-members-single-data { + align-items: center; + display: flex; + flex: 1; + } + .invite-members-single-avatar { + height: 4rem; + margin-right: 1rem; + width: 4rem; + } + .invite-members-single-remove { + color: $red-light; + margin-left: 1rem; + transition: color .2s; + &:hover { + color: $red; + } + } + .invite-members-single-role { + flex-basis: 40%; + flex-shrink: 0; + } + .invite-members-single-new { + align-items: center; + display: flex; + justify-content: center; + padding: 1rem 0; + .invite-members-single-new-btn { + cursor: pointer; + } + .icon-add { + @include svg-size(2rem); + fill: $grayer; + transition: fill .2s; + } + &:hover { + .icon-add { + fill: $primary-light; + } + } + } + .invite-members-single-send { + @include font-size(large); + display: block; + margin: 1.5rem 0 1rem; + padding: 1rem; + width: 100%; + } + .invite-members-single-help { + @include font-size(small); + @include font-type(light); + } +} diff --git a/app/modules/invite-members/lightbox-add-members.controller.coffee b/app/modules/invite-members/lightbox-add-members.controller.coffee new file mode 100644 index 00000000..5ad60891 --- /dev/null +++ b/app/modules/invite-members/lightbox-add-members.controller.coffee @@ -0,0 +1,74 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: add-members.controller.coffee +### + +taiga = @.taiga + +class AddMembersController + @.$inject = [ + "tgUserService", + "tgCurrentUserService", + "tgProjectService", + ] + + constructor: (@userService, @currentUserService, @projectService) -> + @.contactsToInvite = Immutable.List() + @.emailsToInvite = Immutable.List() + @.displayContactList = false + + _getContacts: () -> + userId = @currentUserService.getUser().get("id") + excludeProjectId = @projectService.project.get("id") + + @userService.getContacts(userId, excludeProjectId).then (contacts) => + @.contacts = contacts + + _filterContacts: (invited) -> + @.contacts = @.contacts.filter( (contact) => + contact.get('id') != invited.get('id') + ) + + inviteSuggested: (contact) -> + @.contactsToInvite = @.contactsToInvite.push(contact) + @._filterContacts(contact) + @.displayContactList = true + + removeContact: (invited) -> + @.contactsToInvite = @.contactsToInvite.filter( (contact) => + return contact.get('id') != invited.id + ) + invited = Immutable.fromJS(invited) + @.contacts = @.contacts.push(invited) + @.testEmptyContacts() + + inviteEmail: (email) -> + emailData = Immutable.Map({'email': email}) + @.emailsToInvite = @.emailsToInvite.push(emailData) + @.displayContactList = true + + removeEmail: (invited) -> + @.emailsToInvite = @.emailsToInvite.filter( (email) => + return email.get('email') != invited.email + ) + @.testEmptyContacts() + + testEmptyContacts: () -> + if @.emailsToInvite.size + @.contactsToInvite.size == 0 + @.displayContactList = false + +angular.module("taigaAdmin").controller("AddMembersCtrl", AddMembersController) diff --git a/app/modules/invite-members/lightbox-add-members.controller.spec.coffee b/app/modules/invite-members/lightbox-add-members.controller.spec.coffee new file mode 100644 index 00000000..edec3801 --- /dev/null +++ b/app/modules/invite-members/lightbox-add-members.controller.spec.coffee @@ -0,0 +1,180 @@ +### +# Copyright (C) 2014-2016 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: lightbox-add-members.controller.spec.coffee +### + +describe "AddMembersController", -> + addMembersCtrl = null + provide = null + controller = null + mocks = {} + + _mockUserService = () -> + mocks.userService = { + getContacts: sinon.stub() + } + + provide.value "tgUserService", mocks.userService + + _mockCurrentUser = () -> + mocks.currentUser = { + getUser: sinon.stub() + } + + provide.value "tgCurrentUserService", mocks.currentUser + + _mockProjectService = () -> + mocks.projectService = { + project: sinon.stub() + } + + provide.value "tgProjectService", mocks.projectService + + _mocks = () -> + module ($provide) -> + provide = $provide + _mockCurrentUser() + _mockUserService() + _mockProjectService() + return null + + beforeEach -> + module "taigaAdmin" + + _mocks() + + inject ($controller) -> + controller = $controller + + + it "get user contacts", (done) -> + + userId = 1 + excludeProjectId = 1 + + mocks.currentUser.getUser.returns(Immutable.fromJS({ + id: userId + })) + mocks.projectService.project = Immutable.fromJS({ + id: excludeProjectId + }) + + contacts = Immutable.fromJS({ + username: "username", + full_name_display: "full-name-display", + bio: "bio" + }) + + mocks.userService.getContacts.withArgs(userId, excludeProjectId).promise().resolve(contacts) + + addMembersCtrl = controller "AddMembersCtrl" + + addMembersCtrl._getContacts().then () -> + expect(addMembersCtrl.contacts).to.be.equal(contacts) + done() + + it "filterContacts", () -> + + addMembersCtrl = controller "AddMembersCtrl" + addMembersCtrl.contacts = Immutable.fromJS([ + {id: 1} + {id: 2} + ]) + invited = Immutable.fromJS({id: 1}) + + addMembersCtrl._filterContacts(invited) + + expect(addMembersCtrl.contacts.size).to.be.equal(1) + + it "invite suggested", () -> + addMembersCtrl = controller "AddMembersCtrl" + addMembersCtrl.contactsToInvite = Immutable.List() + addMembersCtrl.displayContactList = false + + contact = Immutable.fromJS({id: 1}) + + addMembersCtrl._filterContacts = sinon.stub() + + addMembersCtrl.inviteSuggested(contact) + expect(addMembersCtrl.contactsToInvite.size).to.be.equal(1) + expect(addMembersCtrl._filterContacts).to.be.calledWith(contact) + expect(addMembersCtrl.displayContactList).to.be.true + + it "remove contact", () -> + addMembersCtrl = controller "AddMembersCtrl" + addMembersCtrl.contactsToInvite = Immutable.fromJS([ + {id: 1} + {id: 2} + ]) + invited = {id: 1} + addMembersCtrl.contacts = Immutable.fromJS([]) + + addMembersCtrl.testEmptyContacts = sinon.stub() + + addMembersCtrl.removeContact(invited) + expect(addMembersCtrl.contactsToInvite.size).to.be.equal(1) + expect(addMembersCtrl.contacts.size).to.be.equal(1) + expect(addMembersCtrl.testEmptyContacts).to.be.called + + it "invite email", () -> + addMembersCtrl = controller "AddMembersCtrl" + email = 'email@example.com' + emailData = Immutable.Map({'email': email}) + addMembersCtrl.displayContactList = false + + addMembersCtrl.emailsToInvite = Immutable.fromJS([]) + + addMembersCtrl.inviteEmail(email) + expect(emailData.get('email')).to.be.equal(email) + expect(addMembersCtrl.emailsToInvite.size).to.be.equal(1) + expect(addMembersCtrl.displayContactList).to.be.true + + it "remove email", () -> + addMembersCtrl = controller "AddMembersCtrl" + invited = {email: 'email@example.com'} + addMembersCtrl.emailsToInvite = Immutable.fromJS([ + {'email': 'email@example.com'} + {'email': 'email@example2.com'} + ]) + + addMembersCtrl.testEmptyContacts = sinon.stub() + + addMembersCtrl.removeEmail(invited) + expect(addMembersCtrl.emailsToInvite.size).to.be.equal(1) + expect(addMembersCtrl.testEmptyContacts).to.be.called + + it "test empty contacts - not empty", () -> + addMembersCtrl = controller "AddMembersCtrl" + addMembersCtrl.displayContactList = true + addMembersCtrl.emailsToInvite = Immutable.fromJS([ + {'email': 'email@example.com'} + {'email': 'email@example2.com'} + ]) + addMembersCtrl.contactsToInvite = Immutable.fromJS([ + {'id': 1} + {'id': 1} + ]) + addMembersCtrl.testEmptyContacts() + expect(addMembersCtrl.displayContactList).to.be.true + + it "test empty contacts - empty", () -> + addMembersCtrl = controller "AddMembersCtrl" + addMembersCtrl.displayContactList = true + addMembersCtrl.emailsToInvite = Immutable.fromJS([]) + addMembersCtrl.contactsToInvite = Immutable.fromJS([]) + addMembersCtrl.testEmptyContacts() + expect(addMembersCtrl.displayContactList).to.be.false diff --git a/app/modules/invite-members/lightbox-add-members.directive.coffee b/app/modules/invite-members/lightbox-add-members.directive.coffee new file mode 100644 index 00000000..a3eb9932 --- /dev/null +++ b/app/modules/invite-members/lightbox-add-members.directive.coffee @@ -0,0 +1,33 @@ +### +# Copyright (C) 2014-2016 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: add-member.directive.coffee +### + +LightboxAddMembersDirective = (lightboxService) -> + link = (scope, el, attrs, ctrl) -> + lightboxService.open(el) + ctrl._getContacts() + + return { + scope: {}, + templateUrl:"invite-members/lightbox-add-members.html", + controller: "AddMembersCtrl", + controllerAs: "vm", + link: link + } + +angular.module("taigaAdmin").directive("tgLbAddMembers", ["lightboxService", LightboxAddMembersDirective]) diff --git a/app/modules/invite-members/lightbox-add-members.jade b/app/modules/invite-members/lightbox-add-members.jade new file mode 100644 index 00000000..c88bebdb --- /dev/null +++ b/app/modules/invite-members/lightbox-add-members.jade @@ -0,0 +1,18 @@ +tg-lightbox-close +.add-members-wrapper + h2.title(translate="LIGHTBOX.ADD_MEMBER.TITLE") + tg-suggest-add-members( + ng-show="!vm.displayContactList" + contacts="vm.contacts" + on-invite-suggested="vm.inviteSuggested(contact)" + on-invite-email="vm.inviteEmail(email)" + ) + tg-invite-members-form( + ng-show="vm.displayContactList" + on-display-contact-list="vm.displayContactList = false" + contacts-to-invite="vm.contactsToInvite" + emails-to-invite="vm.emailsToInvite" + on-remove-invited-contact="vm.removeContact(contact)" + on-remove-invited-email="vm.removeEmail(email)" + on-send-invites="vm.submit(invites)" + ) diff --git a/app/modules/invite-members/lightbox-add-members.scss b/app/modules/invite-members/lightbox-add-members.scss new file mode 100644 index 00000000..3b0d3076 --- /dev/null +++ b/app/modules/invite-members/lightbox-add-members.scss @@ -0,0 +1,6 @@ +.lightbox-add-member { + .add-members-wrapper { + max-width: 900px; + width: 90%; + } +} diff --git a/app/modules/invite-members/suggest-add-members/suggest-add-members.controller.coffee b/app/modules/invite-members/suggest-add-members/suggest-add-members.controller.coffee new file mode 100644 index 00000000..03bde373 --- /dev/null +++ b/app/modules/invite-members/suggest-add-members/suggest-add-members.controller.coffee @@ -0,0 +1,39 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: suggest-add-members.controller.coffee +### + +taiga = @.taiga + +class SuggestAddMembersController + @.$inject = [] + + constructor: () -> + @.contactQuery = "" + + isEmail: () -> + return taiga.isEmail(@.contactQuery) + + filterContacts: () -> + @.filteredContacts = @.contacts.filter( (contact) => + contact.get('full_name_display').toLowerCase().includes(@.contactQuery.toLowerCase()) || contact.get('username').toLowerCase().includes(@.contactQuery.toLowerCase()); + ) + + setInvited: (contact) -> + @.onInviteSuggested({'contact': contact}) + +angular.module("taigaAdmin").controller("SuggestAddMembersCtrl", SuggestAddMembersController) diff --git a/app/modules/invite-members/suggest-add-members/suggest-add-members.controller.spec.coffee b/app/modules/invite-members/suggest-add-members/suggest-add-members.controller.spec.coffee new file mode 100644 index 00000000..42e5e356 --- /dev/null +++ b/app/modules/invite-members/suggest-add-members/suggest-add-members.controller.spec.coffee @@ -0,0 +1,79 @@ +### +# Copyright (C) 2014-2016 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: suggest-add-members.controller.spec.coffee +### + +describe "SuggestAddMembersController", -> + suggestAddMembersCtrl = null + provide = null + controller = null + mocks = {} + + _mocks = () -> + module ($provide) -> + provide = $provide + return null + + beforeEach -> + module "taigaAdmin" + + _mocks() + + inject ($controller) -> + controller = $controller + + it "is email - wrong", () -> + suggestAddMembersCtrl = controller "SuggestAddMembersCtrl" + suggestAddMembersCtrl.contactQuery = 'lololo' + + result = suggestAddMembersCtrl.isEmail() + expect(result).to.be.false + + it "is email - true", () -> + suggestAddMembersCtrl = controller "SuggestAddMembersCtrl" + suggestAddMembersCtrl.contactQuery = 'lololo@lolo.com' + + result = suggestAddMembersCtrl.isEmail() + expect(result).to.be.true + + it "filter contacts", () -> + suggestAddMembersCtrl = controller "SuggestAddMembersCtrl" + suggestAddMembersCtrl.contacts = Immutable.fromJS([ + { + full_name_display: 'Abel Sonofadan' + username: 'abel' + }, + { + full_name_display: 'Cain Sonofadan' + username: 'cain' + } + ]) + + suggestAddMembersCtrl.contactQuery = 'Cain Sonofadan' + + suggestAddMembersCtrl.filterContacts() + expect(suggestAddMembersCtrl.filteredContacts.size).to.be.equal(1) + + it "set invited", () -> + suggestAddMembersCtrl = controller "SuggestAddMembersCtrl" + + contact = 'contact' + + suggestAddMembersCtrl.onInviteSuggested = sinon.stub() + + suggestAddMembersCtrl.setInvited(contact) + expect(suggestAddMembersCtrl.onInviteSuggested).has.been.calledWith({'contact': contact}) diff --git a/app/modules/invite-members/suggest-add-members/suggest-add-members.directive.coffee b/app/modules/invite-members/suggest-add-members/suggest-add-members.directive.coffee new file mode 100644 index 00000000..9a4126d7 --- /dev/null +++ b/app/modules/invite-members/suggest-add-members/suggest-add-members.directive.coffee @@ -0,0 +1,34 @@ +### +# Copyright (C) 2014-2016 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: suggest-add-member.directive.coffee +### + +SuggestAddMembersDirective = (lightboxService) -> + return { + scope: {}, + templateUrl:"invite-members/suggest-add-members/suggest-add-members.html", + controller: "SuggestAddMembersCtrl", + controllerAs: "vm", + bindToController: { + contacts: '=', + filteredContacts: ' .then (result) -> return Immutable.fromJS(result.data) - service.getContacts = (userId) -> + service.getContacts = (userId, excludeProjectId) -> url = urlsService.resolve("user-contacts", userId) + params = {} + params.exclude_project = excludeProjectId if excludeProjectId? + httpOptions = { headers: { "x-disable-pagination": "1" } } - return http.get(url, {}, httpOptions) + return http.get(url, params, httpOptions) .then (result) -> return Immutable.fromJS(result.data) diff --git a/app/modules/services/user.service.coffee b/app/modules/services/user.service.coffee index b986c1f4..85be5bd3 100644 --- a/app/modules/services/user.service.coffee +++ b/app/modules/services/user.service.coffee @@ -30,8 +30,8 @@ class UserService extends taiga.Service getUserByUserName: (username) -> return @rs.users.getUserByUsername(username) - getContacts: (userId) -> - return @rs.users.getContacts(userId) + getContacts: (userId, excludeProjectId) -> + return @rs.users.getContacts(userId, excludeProjectId) getLiked: (userId, pageNumber, objectType, textQuery) -> return @rs.users.getLiked(userId, pageNumber, objectType, textQuery) diff --git a/app/partials/admin/memberships-warning-message.jade b/app/partials/admin/memberships-warning-message.jade index 304e5f09..6f46ef10 100644 --- a/app/partials/admin/memberships-warning-message.jade +++ b/app/partials/admin/memberships-warning-message.jade @@ -1,11 +1,11 @@ p.member-limit-warning( - ng-if="project.i_am_owner == true" + ng-if="project.get('i_am_owner') == true" translate="LIGHTBOX.CREATE_MEMBER.LIMIT_USERS_WARNING_MESSAGE_FOR_OWNER" - translate-values="{maxMembers: project.max_memberships}" + translate-values="{maxMembers: project.get('max_memberships')}" ) p.member-limit-warning( - ng-if="project.i_am_owner == false" + ng-if="project.get('i_am_owner') == false" translate="LIGHTBOX.CREATE_MEMBER.LIMIT_USERS_WARNING_MESSAGE" - translate-values="{maxMembers: project.max_memberships}" + translate-values="{maxMembers: project.get('max_memberships')}" ) diff --git a/app/styles/modules/common/lightbox.scss b/app/styles/modules/common/lightbox.scss index 6df94cbc..255254d3 100644 --- a/app/styles/modules/common/lightbox.scss +++ b/app/styles/modules/common/lightbox.scss @@ -143,83 +143,6 @@ margin-bottom: 1rem; } } - -.lightbox-add-member { - .add-member-wrapper { - max-width: 600px; - width: 90%; - } - .add-single-member { - align-items: center; - display: flex; - justify-content: space-between; - margin-bottom: .5rem; - &:last-child { - margin-bottom: 0; - } - fieldset { - display: inline-block; - flex: 1; - margin: 0 .5rem 0 0; - &:last-child { - flex-basis: 30px; - flex-grow: 0; - flex-shrink: 0; - } - &:first-child { - flex-basis: 20%; - } - - } - } - .icon { - @include svg-size(1.25rem); - fill: $gray; - margin-left: .5rem; - } - .icon-add { - &:hover { - fill: $primary; - transition: fill .2s; - } - } - .icon-trash { - fill: $red-light; - &:hover { - fill: $red; - transition: fill .2s; - } - } - .member-limit-warning { - @include font-size(small); - background: $mass-white; - color: $grayer; - margin: 1rem 0; - padding: 1rem 2rem; - text-align: center; - a { - color: $primary; - &:hover { - color: $primary-light; - } - } - } - .help-text { - @include font-size(small); - @include font-type(light); - margin-top: 1rem; - } - .checksley-error-list { - right: .5rem; - li { - display: none; - &:first-child { - display: block; - } - } - } -} - .lightbox-sprint-add-edit { form { flex-basis: 600px; diff --git a/e2e/helpers/admin-memberships.js b/e2e/helpers/admin-memberships.js index e22bc3f5..e0e9759b 100644 --- a/e2e/helpers/admin-memberships.js +++ b/e2e/helpers/admin-memberships.js @@ -18,17 +18,28 @@ helper.getNewMemberLightbox = function() { return utils.lightbox.close(el); }, newEmail: function(email) { - el.$$('input').last().sendKeys(email); - el.$('.add-fieldset').click(); + el.$$('input').clear(); + el.$$('input').sendKeys(email); + el.$('.e2e-add-member-suggest-filter-addmail').click(); }, - getRows: function() { - return el.$$('.add-single-member'); + addSuggested: function(index) { + el.$$('.e2e-add-member-suggest-single').get(index).click(); }, - deleteRow: function(index) { - el.$$('.remove-fieldset').get(index).click(); + addNew: function() { + return el.$$('.e2e-invite-members-single-new').click(); + }, + setRole: function(index) { + let select = el.$$('.e2e-invite-members-single-role').get(index); + select.$('option:last-child').click(); + }, + getInviteds: function() { + return el.$$('.e2e-invite-members-single') + }, + deleteInvited: function(index) { + el.$$('.e2e-invite-members-single-remove').get(index).click(); }, submit: function() { - return el.$('.submit-button').click(); + return el.$('.e2e-invite-members-single-send').click(); } }; diff --git a/e2e/suites/admin/members.e2e.js b/e2e/suites/admin/members.e2e.js index 5178b139..d01d8b75 100644 --- a/e2e/suites/admin/members.e2e.js +++ b/e2e/suites/admin/members.e2e.js @@ -28,25 +28,30 @@ describe('admin - members', function() { adminMembershipsHelper.openNewMemberLightbox(); await newMemberLightbox.waitOpen(); - utils.common.takeScreenshot('memberships', 'new-member'); + utils.common.takeScreenshot('memberships', 'add-new-member'); }); - it('add members row', async function() { + it('add contacts', async function() { + newMemberLightbox.addSuggested(0); + newMemberLightbox.addNew(); newMemberLightbox.newEmail('xxx' + new Date().getTime() + '@xx.es'); + newMemberLightbox.addNew(); newMemberLightbox.newEmail('xxx' + new Date().getTime() + '@xx.es'); - newMemberLightbox.newEmail('xxx' + new Date().getTime() + '@xx.es'); - - let membersRows = await newMemberLightbox.getRows().count(); - - expect(membersRows).to.be.equal(3 + 1); + utils.common.takeScreenshot('memberships', 'add-new-member-form'); }); it('delete members row', async function() { - newMemberLightbox.deleteRow(2); + newMemberLightbox.deleteInvited(2); - let membersRows = await newMemberLightbox.getRows().count(); + let invitedRows = await newMemberLightbox.getInviteds().count(); - expect(membersRows).to.be.equal(2 + 1); + expect(invitedRows).to.be.equal(2); + }); + + it('set roles', async function() { + newMemberLightbox.setRole(0); + newMemberLightbox.setRole(1); + utils.common.takeScreenshot('memberships', 'add-new-member-form-active'); }); it('submit', async function() {