diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ba53a2d..9a528eec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@
- Add badge to project owners
- Limit of user per project.
- Redesign of the create project wizard
+- Transfer project ownership
### Misc
- Lots of small and not so small bugfixes.
diff --git a/app/coffee/app.coffee b/app/coffee/app.coffee
index d0a750f5..8d0b88de 100644
--- a/app/coffee/app.coffee
+++ b/app/coffee/app.coffee
@@ -333,6 +333,16 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven
$routeProvider.when("/project/:pslug/admin/contrib/:plugin",
{templateUrl: "contrib/main.html"})
+ # Transfer project
+ $routeProvider.when("/project/:pslug/transfer/:token",
+ {
+ templateUrl: "projects/transfer/transfer-page.html",
+ loader: true,
+ controller: "Project",
+ controllerAs: "vm"
+ }
+ )
+
# User settings
$routeProvider.when("/user-settings/user-profile",
{templateUrl: "user/user-profile.html"})
diff --git a/app/coffee/modules/admin/lightboxes.coffee b/app/coffee/modules/admin/lightboxes.coffee
index 8030262f..f28aba65 100644
--- a/app/coffee/modules/admin/lightboxes.coffee
+++ b/app/coffee/modules/admin/lightboxes.coffee
@@ -136,3 +136,144 @@ LightboxAddMembersWarningMessageDirective = () ->
}
module.directive("tgLightboxAddMembersWarningMessage", [LightboxAddMembersWarningMessageDirective])
+
+
+#############################################################################
+## Transfer project ownership
+#############################################################################
+
+LbRequestOwnershipDirective = (lightboxService, rs, confirmService, $translate) ->
+ return {
+ link: (scope, el) ->
+ lightboxService.open(el)
+
+ scope.request = () ->
+ scope.loading = true
+
+ rs.projects.transferRequest(scope.projectId).then () ->
+ scope.loading = false
+
+ lightboxService.close(el)
+
+ confirmService.notify("success", $translate.instant("ADMIN.PROJECT_PROFILE.REQUEST_OWNERSHIP_SUCCESS"))
+
+ templateUrl: "common/lightbox/lightbox-request-ownership.html"
+ }
+
+module.directive('tgLbRequestOwnership', [
+ "lightboxService",
+ "tgResources",
+ "$tgConfirm",
+ "$translate",
+ LbRequestOwnershipDirective])
+
+class ChangeOwnerLightboxController
+ constructor: (@rs, @lightboxService, @confirm, @translate) ->
+ @.users = []
+ @.q = ""
+ @.commentOpen = false
+
+ limit: 3
+
+ normalizeString: (normalizedString) ->
+ normalizedString = normalizedString.replace("Á", "A").replace("Ä", "A").replace("À", "A")
+ normalizedString = normalizedString.replace("É", "E").replace("Ë", "E").replace("È", "E")
+ normalizedString = normalizedString.replace("Í", "I").replace("Ï", "I").replace("Ì", "I")
+ normalizedString = normalizedString.replace("Ó", "O").replace("Ö", "O").replace("Ò", "O")
+ normalizedString = normalizedString.replace("Ú", "U").replace("Ü", "U").replace("Ù", "U")
+ return normalizedString
+
+ filterUsers: (user) ->
+ username = user.full_name_display.toUpperCase()
+ username = @.normalizeString(username)
+ text = @.q.toUpperCase()
+ text = @.normalizeString(text)
+
+ return _.includes(username, text)
+
+ getUsers: () ->
+ if !@.users.length && !@.q.length
+ users = @.activeUsers
+ else
+ users = @.users
+
+ users = users.slice(0, @.limit)
+ users = _.reject(users, {"selected": true})
+
+ return _.reject(users, {"id": @.currentOwnerId})
+
+ userSearch: () ->
+ @.users = @.activeUsers
+
+ @.selected = _.find(@.users, {"selected": true})
+
+ @.users = _.filter(@.users, @.filterUsers.bind(this)) if @.q
+
+ selectUser: (user) ->
+ @.activeUsers = _.map @.activeUsers, (user) ->
+ user.selected = false
+
+ return user
+
+ user.selected = true
+
+ @.userSearch()
+
+ submit: () ->
+ @.loading = true
+ @rs.projects.transferStart(@.projectId, @.selected.id, @.comment)
+ .then () =>
+ @.loading = false
+ @lightboxService.closeAll()
+
+ title = @translate.instant("ADMIN.PROJECT_PROFILE.CHANGE_OWNER_SUCCESS_TITLE")
+ desc = @translate.instant("ADMIN.PROJECT_PROFILE.CHANGE_OWNER_SUCCESS_DESC")
+
+ @confirm.success(title, desc, {
+ type: "svg",
+ name: "icon-speak-up"
+ })
+
+ChangeOwnerLightboxController.$inject = [
+ "tgResources",
+ "lightboxService",
+ "$tgConfirm",
+ "$translate"
+]
+
+module.controller('ChangeOwnerLightbox', ChangeOwnerLightboxController)
+
+ChangeOwnerLightboxDirective = (lightboxService, lightboxKeyboardNavigationService, $template, $compile) ->
+ link = (scope, el) ->
+ lightboxService.open(el)
+
+ return {
+ scope: true,
+ controller: "ChangeOwnerLightbox",
+ controllerAs: "vm",
+ bindToController: {
+ currentOwnerId: "=",
+ projectId: "=",
+ activeUsers: "="
+ },
+ templateUrl: "common/lightbox/lightbox-change-owner.html"
+ link:link
+ }
+
+
+module.directive("tgLbChangeOwner", ["lightboxService", "lightboxKeyboardNavigationService", "$tgTemplate", "$compile", ChangeOwnerLightboxDirective])
+
+TransferProjectStartSuccessDirective = (lightboxService) ->
+ link = (scope, el) ->
+ scope.close = () ->
+ lightboxService.close(el)
+
+ lightboxService.open(el)
+
+ return {
+ templateUrl: "common/lightbox/lightbox-transfer-project-start-success.html"
+ link:link
+ }
+
+
+module.directive("tgLbTransferProjectStartSuccess", ["lightboxService", TransferProjectStartSuccessDirective])
diff --git a/app/coffee/modules/admin/memberships.coffee b/app/coffee/modules/admin/memberships.coffee
index be2a7ad9..a85ae6e2 100644
--- a/app/coffee/modules/admin/memberships.coffee
+++ b/app/coffee/modules/admin/memberships.coffee
@@ -129,7 +129,10 @@ class MembershipsController extends mixOf(taiga.Controller, taiga.PageMixin, tai
members: @scope.project.max_memberships
})
icon = "/" + window._version + "/svg/icons/team-question.svg"
- @confirm.success(title, message,icon)
+ @confirm.success(title, message, {
+ name: icon,
+ type: "img"
+ })
module.controller("MembershipsController", MembershipsController)
diff --git a/app/coffee/modules/admin/project-profile.coffee b/app/coffee/modules/admin/project-profile.coffee
index 2d385c6e..8b0d64e4 100644
--- a/app/coffee/modules/admin/project-profile.coffee
+++ b/app/coffee/modules/admin/project-profile.coffee
@@ -69,6 +69,8 @@ class ProjectProfileController extends mixOf(taiga.Controller, taiga.PageMixin)
description = @scope.project.description
@appMetaService.setAll(title, description)
+ @.fillUsersAndRoles(@scope.project.members, @scope.project.roles)
+
promise.then null, @.onInitialDataError.bind(@)
@scope.$on "project:loaded", =>
@@ -535,3 +537,47 @@ AdminProjectRestrictionsDirective = () ->
}
module.directive('tgAdminProjectRestrictions', [AdminProjectRestrictionsDirective])
+
+AdminProjectRequestOwnershipDirective = (lightboxFactory) ->
+ return {
+ link: (scope) ->
+ scope.requestOwnership = () ->
+ lightboxFactory.create("tg-lb-request-ownership", {
+ "class": "lightbox lightbox-request-ownership"
+ }, {
+ projectId: scope.projectId
+ })
+
+ scope: {
+ "projectId": "=",
+ "owner": "="
+ },
+ templateUrl: "admin/admin-project-request-ownership.html"
+ }
+
+module.directive('tgAdminProjectRequestOwnership', ["tgLightboxFactory", AdminProjectRequestOwnershipDirective])
+
+AdminProjectChangeOwnerDirective = (lightboxFactory) ->
+ return {
+ link: (scope) ->
+ scope.changeOwner = () ->
+ lightboxFactory.create("tg-lb-change-owner", {
+ "class": "lightbox lightbox-select-user",
+ "project-id": "projectId",
+ "active-users": "activeUsers",
+ "current-owner-id": "currentOwnerId"
+ }, {
+ projectId: scope.projectId,
+ activeUsers: scope.activeUsers,
+ currentOwnerId: scope.owner.id
+ })
+
+ scope: {
+ "activeUsers": "="
+ "projectId": "="
+ "owner": "="
+ },
+ templateUrl: "admin/admin-project-change-owner.html"
+ }
+
+module.directive('tgAdminProjectChangeOwner', ["tgLightboxFactory", AdminProjectChangeOwnerDirective])
diff --git a/app/coffee/modules/common/confirm.coffee b/app/coffee/modules/common/confirm.coffee
index 868920bc..713162d9 100644
--- a/app/coffee/modules/common/confirm.coffee
+++ b/app/coffee/modules/common/confirm.coffee
@@ -167,8 +167,20 @@ class ConfirmService extends taiga.Service
el = angular.element(".lightbox-generic-success")
el.find("img").remove()
+ el.find("svg").remove()
+
+ if icon.type == "img"
+ detailImage = $('
').addClass('lb-icon').attr('src', icon.name)
+ else if icon.type == "svg"
+ useSVG = document.createElementNS('http://www.w3.org/2000/svg', 'use')
+ useSVG.setAttributeNS('http://www.w3.org/1999/xlink','href', '#' + icon.name)
+
+ detailImage = document.createElementNS("http://www.w3.org/2000/svg", "svg")
+ detailImage.classList.add("icon")
+ detailImage.classList.add("lb-icon")
+ detailImage.classList.add(icon.name)
+ detailImage.appendChild(useSVG)
- detailImage = $('
').addClass('lb-icon').attr('src', icon)
if detailImage
el.find('section').prepend(detailImage)
@@ -254,6 +266,7 @@ class ConfirmService extends taiga.Service
body.find(selector)
.removeClass('active')
.addClass('inactive')
+ .one 'animationend', () -> $(this).removeClass('inactive')
delete @.tsem
diff --git a/app/coffee/modules/resources.coffee b/app/coffee/modules/resources.coffee
index a4a45eb9..c32a9f56 100644
--- a/app/coffee/modules/resources.coffee
+++ b/app/coffee/modules/resources.coffee
@@ -74,6 +74,11 @@ urls = {
"project-unlike": "/projects/%s/unlike"
"project-watch": "/projects/%s/watch"
"project-unwatch": "/projects/%s/unwatch"
+ "project-transfer-validate-token": "/projects/%s/transfer_validate_token"
+ "project-transfer-accept": "/projects/%s/transfer_accept"
+ "project-transfer-reject": "/projects/%s/transfer_reject"
+ "project-transfer-request": "/projects/%s/transfer_request"
+ "project-transfer-start": "/projects/%s/transfer_start"
# Project Values - Choises
"userstory-statuses": "/userstory-statuses"
diff --git a/app/locales/taiga/locale-en.json b/app/locales/taiga/locale-en.json
index 2283333c..936d704f 100644
--- a/app/locales/taiga/locale-en.json
+++ b/app/locales/taiga/locale-en.json
@@ -472,7 +472,16 @@
"MAX_PRIVATE_PROJECTS": "You've reached the maximum number of private projects",
"MAX_PRIVATE_PROJECTS_MEMBERS": "The project exceeds the maximum members number in private projects",
"MAX_PUBLIC_PROJECTS": "You've reached the maximum number of public projects",
- "MAX_PUBLIC_PROJECTS_MEMBERS": "The project exceeds the maximum members number in public projects"
+ "MAX_PUBLIC_PROJECTS_MEMBERS": "The project exceeds the maximum members number in public projects",
+ "PROJECT_OWNER": "Project owner",
+ "REQUEST_OWNERSHIP": "Request ownership",
+ "REQUEST_OWNERSHIP_CONFIRMATION_TITLE": "Do you want to be the project owner?",
+ "REQUEST_OWNERSHIP_DESC": "Ask the owner {{name}} to transfert to you the project ownership.",
+ "REQUEST_OWNERSHIP_BUTTON": "Request",
+ "REQUEST_OWNERSHIP_SUCCESS": "We'll notify the project owner",
+ "CHANGE_OWNER": "Change owner",
+ "CHANGE_OWNER_SUCCESS_TITLE": "Ok, your request has been sent",
+ "CHANGE_OWNER_SUCCESS_DESC": "We will notify you by email whether it accepts or rejects the ownership of the project."
},
"REPORTS": {
"TITLE": "Reports",
@@ -680,6 +689,24 @@
},
"SUBMENU_THIDPARTIES": {
"TITLE": "Services"
+ },
+ "PROJECT_TRANSFER": {
+ "DO_YOU_ACCEPT_PROJECT_OWNERNSHIP": "Do you want to be the new project owner?",
+ "PRIVATE": "Private",
+ "ACCEPTED_PROJECT_OWNERNSHIP": "OK. Now you are the new owner of the project.",
+ "REJECTED_PROJECT_OWNERNSHIP": "OK. We will contact with the owner",
+ "ACCEPT": "Accept",
+ "REJECT": "Reject",
+ "PROPOSE_OWNERSHIP": "{{owner}}, the current owner of the project {{project}} wants you to be the new owner of the project.",
+ "ADD_COMMENT_QUESTION": "Do you want to add a comment for the owner?",
+ "ADD_COMMENT": "Do you want to add a comment for the owner?",
+ "UNLIMITED_PROJECTS": "Unlimited",
+ "OWNER_MESSAGE": {
+ "PRIVATE": "Remember, you can own up to {{maxProjects}} private projects and you already own {{currentProjects}} private projects",
+ "PUBLIC": "Remember, you can own {{maxProjects}} public projects and you already own {{currentProjects}} public projects"
+ },
+ "CANT_BE_OWNED": "Right now you can't be the owner of a project with this characteristics. To be the owner of this project you should contact the admin staff and change your account conditions.",
+ "CHANGE_MY_PLAN": "Change my plan"
}
},
"USER": {
@@ -932,6 +959,11 @@
"DESC": "You can't delete the project owner, you must request a new owner before deleting the user.",
"BUTTON": "Request change project owner"
}
+ },
+ "CHANGE_OWNER": {
+ "TITLE": "Who do you want to be the new owner?",
+ "ADD_COMMENT": "Add comment",
+ "BUTTON": "Ask this teammate to be the owner"
}
},
"US": {
diff --git a/app/modules/projects/projects.service.coffee b/app/modules/projects/projects.service.coffee
index 5aca481e..ab3973a3 100644
--- a/app/modules/projects/projects.service.coffee
+++ b/app/modules/projects/projects.service.coffee
@@ -63,4 +63,14 @@ class ProjectsService extends taiga.Service
bulkUpdateProjectsOrder: (sortData) ->
return @rs.projects.bulkUpdateOrder(sortData)
+ transferValidateToken: (projectId, token) ->
+ return @rs.projects.transferValidateToken(projectId, token)
+
+ transferAccept: (projectId, token, reason) ->
+ return @rs.projects.transferAccept(projectId, token, reason)
+
+ transferReject: (projectId, token, reason) ->
+ return @rs.projects.transferReject(projectId, token, reason)
+
+
angular.module("taigaProjects").service("tgProjectsService", ProjectsService)
diff --git a/app/modules/projects/projects.service.spec.coffee b/app/modules/projects/projects.service.spec.coffee
index 6b49303b..1829e8a8 100644
--- a/app/modules/projects/projects.service.spec.coffee
+++ b/app/modules/projects/projects.service.spec.coffee
@@ -163,3 +163,16 @@ describe "tgProjectsService", ->
])
done()
+
+ it "validateTransferToken", (done) ->
+ projectId = 3
+
+ tokenValidation = Immutable.fromJS({})
+
+ mocks.resources.projects = {}
+ mocks.resources.projects.transferValidateToken = sinon.stub()
+ mocks.resources.projects.transferValidateToken.withArgs(projectId).promise().resolve(tokenValidation)
+
+ projectsService.transferValidateToken(projectId).then (projects) ->
+ expect(projects.toJS()).to.be.eql({})
+ done()
diff --git a/app/modules/projects/transfer/cant-own-project-explanation.directive.coffee b/app/modules/projects/transfer/cant-own-project-explanation.directive.coffee
new file mode 100644
index 00000000..a6ea655c
--- /dev/null
+++ b/app/modules/projects/transfer/cant-own-project-explanation.directive.coffee
@@ -0,0 +1,25 @@
+###
+# 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: cant-own-project-explanation.directive.coffee
+###
+
+CantOwnProjectExplanationDirective = () ->
+ return {
+ templateUrl: "projects/transfer/cant-own-project-explanation.html"
+ }
+
+angular.module("taigaProjects").directive("tgCantOwnProjectExplanation", CantOwnProjectExplanationDirective)
diff --git a/app/modules/projects/transfer/cant-own-project-explanation.jade b/app/modules/projects/transfer/cant-own-project-explanation.jade
new file mode 100644
index 00000000..6f6be740
--- /dev/null
+++ b/app/modules/projects/transfer/cant-own-project-explanation.jade
@@ -0,0 +1,3 @@
+p(
+ translate="ADMIN.PROJECT_TRANSFER.CANT_BE_OWNED"
+)
diff --git a/app/modules/projects/transfer/transfer-page.jade b/app/modules/projects/transfer/transfer-page.jade
new file mode 100644
index 00000000..bb9dad96
--- /dev/null
+++ b/app/modules/projects/transfer/transfer-page.jade
@@ -0,0 +1,4 @@
+tg-transfer-project.transfer-project(
+ ng-if="vm.project"
+ project = "vm.project"
+)
diff --git a/app/modules/projects/transfer/transfer-project.controller.coffee b/app/modules/projects/transfer/transfer-project.controller.coffee
new file mode 100644
index 00000000..41fbd976
--- /dev/null
+++ b/app/modules/projects/transfer/transfer-project.controller.coffee
@@ -0,0 +1,98 @@
+###
+# 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: transfer-project.directive.coffee
+###
+
+module = angular.module('taigaProjects')
+
+class TransferProject
+ @.$inject = [
+ "$routeParams",
+ "tgProjectsService"
+ "$location",
+ "$tgAuth",
+ "tgCurrentUserService",
+ "$tgNavUrls",
+ "$translate",
+ "$tgConfirm"
+ ]
+
+ constructor: (@routeParams, @projectService, @location, @authService, @currentUserService, @navUrls, @translate, @confirmService) ->
+ @.projectId = @.project.get("id")
+ @.token = @routeParams.token
+ @._refreshUserData()
+ @.showAddComment = false
+
+ _validateToken: () ->
+ @projectService.transferValidateToken(@.projectId, @.token).error (data, status) =>
+ @location.path(@navUrls.resolve("not-found"))
+
+ _refreshUserData: () ->
+ @authService.refresh().then () =>
+ @._validateToken()
+ @._setProjectData()
+ @._checkOwnerData()
+
+ _setProjectData: () ->
+ @.canBeOwnedByUser = @currentUserService.canOwnProject(@.project)
+
+ _checkOwnerData: () ->
+ currentUser = @currentUserService.getUser()
+ if(@.project.get('is_private'))
+ @.ownerMessage = 'ADMIN.PROJECT_TRANSFER.OWNER_MESSAGE.PRIVATE'
+ @.maxProjects = currentUser.get('max_private_projects')
+ if @.maxProjects == null
+ @.maxProjects = @translate.instant('ADMIN.PROJECT_TRANSFER.UNLIMITED_PROJECTS')
+ @.currentProjects = currentUser.get('total_private_projects')
+ maxMemberships = currentUser.get('max_memberships_private_projects')
+
+ else
+ @.ownerMessage = 'ADMIN.PROJECT_TRANSFER.OWNER_MESSAGE.PUBLIC'
+ @.maxProjects = currentUser.get('max_public_projects')
+ if @.maxProjects == null
+ @.maxProjects = @translate.instant('ADMIN.PROJECT_TRANSFER.UNLIMITED_PROJECTS')
+ @.currentProjects = currentUser.get('total_public_projects')
+ maxMemberships = currentUser.get('max_memberships_public_projects')
+
+ @.validNumberOfMemberships = maxMemberships == null || @.project.get('total_memberships') <= maxMemberships
+
+ transferAccept: (token, reason) ->
+ @projectService.transferAccept(@.project.get("id"), token, reason).success () =>
+ newUrl = @navUrls.resolve("project-admin-project-profile-details", {
+ project: @.project.get("slug")
+ })
+ @location.path(newUrl)
+ @confirmService.notify("success", @translate.instant("ADMIN.PROJECT_TRANSFER.ACCEPTED_PROJECT_OWNERNSHIP"), '', 5000)
+
+ transferReject: (token, reason) ->
+ @projectService.transferReject(@.project.get("id"), token, reason).success () =>
+ newUrl = @navUrls.resolve("project-admin-project-profile-details", {
+ project: @project.get("slug")
+ })
+ @location.path(newUrl)
+ @confirmService.notify("success", @translate.instant("ADMIN.PROJECT_TRANSFER.REJECTED_PROJECT_OWNERNSHIP"), '', 5000)
+
+ addComment: () ->
+ @.showAddComment = true
+
+ hideComment: () ->
+ @.showAddComment = false
+ @.reason = ''
+
+
+
+module.controller("TransferProjectController", TransferProject)
diff --git a/app/modules/projects/transfer/transfer-project.directive.coffee b/app/modules/projects/transfer/transfer-project.directive.coffee
new file mode 100644
index 00000000..d6b35f76
--- /dev/null
+++ b/app/modules/projects/transfer/transfer-project.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: transfer-project.directive.coffee
+###
+
+module = angular.module('taigaProjects')
+
+TransferProjectDirective = () ->
+ return {
+ scope: {},
+ bindToController: {
+ project: "="
+ },
+ templateUrl: "projects/transfer/transfer-project.html",
+ controller: 'TransferProjectController',
+ controllerAs: 'vm'
+ }
+
+module.directive('tgTransferProject', TransferProjectDirective)
diff --git a/app/modules/projects/transfer/transfer-project.jade b/app/modules/projects/transfer/transfer-project.jade
new file mode 100644
index 00000000..cc184317
--- /dev/null
+++ b/app/modules/projects/transfer/transfer-project.jade
@@ -0,0 +1,69 @@
+.transfer-project-wrapper
+ h2.transfer-title(translate="ADMIN.PROJECT_TRANSFER.DO_YOU_ACCEPT_PROJECT_OWNERNSHIP")
+ .transfer-project-detail
+ img.transfer-project-image(
+ tg-project-logo-small-src="vm.project"
+ alt="{{vm.project.get('name')}}"
+ )
+ .transfer-project-data
+ h3.transfer-project-title {{::vm.project.get("name")}}
+ .transfer-project-statistics
+ span.transfer-project-private(ng-if="vm.project.get('is_private')")
+ svg.icon.icon-lock
+ use(xlink:href="#icon-lock")
+ span(translate="ADMIN.PROJECT_TRANSFER.PRIVATE")
+ span.transfer-project-members
+ svg.icon.icon-team
+ use(xlink:href="#icon-team")
+ span {{::vm.project.get("members").size}}
+
+ p(
+ translate="ADMIN.PROJECT_TRANSFER.PROPOSE_OWNERSHIP"
+ translate-values="{owner: vm.project.getIn(['owner', 'full_name_display']), project: vm.project.get('name')}"
+ )
+
+ div(ng-if="vm.canBeOwnedByUser.valid")
+
+ p(
+ translate="{{vm.ownerMessage}}"
+ translate-values="{maxProjects: vm.maxProjects, currentProjects: vm.currentProjects}"
+ )
+
+ a.transfer-project-comment-link.ng-animate-disabled(
+ href=""
+ ng-click="vm.addComment()"
+ ng-if="!vm.showAddComment"
+ translate="ADMIN.PROJECT_TRANSFER.ADD_COMMENT_QUESTION"
+ )
+
+ fieldset.transfer-project-comment-form(
+ ng-if="vm.showAddComment"
+ ng-class="{'open': vm.showAddComment}"
+ )
+ .transfer-project-comment-header
+ label.transfer-project-comment-label(
+ translate="ADMIN.PROJECT_TRANSFER.ADD_COMMENT"
+ )
+ svg.icon.icon-close(ng-click="vm.hideComment()")
+ use(xlink:href="#icon-close")
+ textarea.transfer-project-comment(
+ name="reason"
+ ng-model="vm.reason"
+ )
+
+ .transfer-project-options
+ a.button.button-gray(
+ ng-click="vm.transferReject(vm.token, vm.reason)"
+ href="#"
+ title="{{'ADMIN.PROJECT_TRANSFER.REJECT' | translate}}"
+ translate="ADMIN.PROJECT_TRANSFER.REJECT"
+ )
+
+ a.button.button-green(
+ ng-click="vm.transferAccept(vm.token, vm.reason)"
+ href="#"
+ title="{{'ADMIN.PROJECT_TRANSFER.ACCEPT' | translate}}"
+ translate="ADMIN.PROJECT_TRANSFER.ACCEPT"
+ )
+
+ div(ng-if="!vm.canBeOwnedByUser.valid", tg-cant-own-project-explanation)
diff --git a/app/modules/projects/transfer/transfer-project.scss b/app/modules/projects/transfer/transfer-project.scss
new file mode 100644
index 00000000..99921eb5
--- /dev/null
+++ b/app/modules/projects/transfer/transfer-project.scss
@@ -0,0 +1,93 @@
+.transfer-project-wrapper {
+ max-width: 500px;
+ width: 90%;
+}
+
+.transfer-project {
+ align-items: center;
+ background: url('../images/discover.png') bottom center repeat-x;
+ display: flex;
+ justify-content: center;
+ min-height: calc(100vh - 40px);
+ .transfer-title {
+ @extend %light;
+ }
+ &-detail {
+ align-items: center;
+ border-bottom: 1px solid $whitish;
+ border-top: 1px solid $whitish;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ margin: 1rem 0 3rem;
+ padding: 1rem 0;
+ }
+ &-image {
+ margin-right: 1rem;
+ width: 4rem;
+ }
+ &-title {
+ @extend %light;
+ @extend %larger;
+ margin-bottom: .25rem;
+ }
+ &-statistics {
+ span {
+ color: $gray-light;
+ margin-right: .5rem;
+ }
+ svg {
+ fill: $gray-light;
+ margin-right: .25rem;
+ }
+ }
+ &-private {
+ text-transform: uppercase;
+ }
+ &-comment-link {
+ color: $primary;
+ cursor: pointer;
+ display: block;
+ margin-bottom: 1rem;
+ &:hover {
+ color: $primary-light;
+ }
+ }
+ &-comment-header {
+ display: flex;
+ justify-content: space-between;
+ .icon-close {
+ cursor: pointer;
+ fill: $gray-light;
+ &:hover {
+ fill: $red-light;
+ transition: fill .2s;
+ }
+ }
+ }
+ &-comment-form {
+ &.ng-enter {
+ animation: dropdownFade .2s;
+ }
+ }
+ &-comment-label {
+ display: block;
+ margin-bottom: .5rem;
+ }
+ &-comment {
+ margin-bottom: 1rem;
+ min-height: 6rem;
+ }
+ &-options {
+ display: flex;
+ a {
+ @extend %large;
+ display: block;
+ flex: 1;
+ padding: .75rem;
+ &:first-child {
+ margin-right: .5rem;
+ }
+ }
+ }
+}
diff --git a/app/modules/resources/projects-resource.service.coffee b/app/modules/resources/projects-resource.service.coffee
index d18fc96e..226534ff 100644
--- a/app/modules/resources/projects-resource.service.coffee
+++ b/app/modules/resources/projects-resource.service.coffee
@@ -108,6 +108,42 @@ Resource = (urlsService, http, paginateResponseService) ->
url = urlsService.resolve("project-unwatch", projectId)
return http.post(url)
+ service.transferValidateToken = (projectId, token) ->
+ data = {
+ token: token
+ }
+ url = urlsService.resolve("project-transfer-validate-token", projectId)
+ return http.post(url, data)
+
+ service.transferAccept = (projectId, token, reason) ->
+ data = {
+ token: token
+ reason: reason
+ }
+ url = urlsService.resolve("project-transfer-accept", projectId)
+ return http.post(url, data)
+
+ service.transferReject = (projectId, token, reason) ->
+ data = {
+ token: token
+ reason: reason
+ }
+ url = urlsService.resolve("project-transfer-reject", projectId)
+ return http.post(url, data)
+
+ service.transferRequest = (projectId) ->
+ url = urlsService.resolve("project-transfer-request", projectId)
+ return http.post(url)
+
+ service.transferStart = (projectId, userId, reason) ->
+ data = {
+ user: userId,
+ reason: reason
+ }
+
+ url = urlsService.resolve("project-transfer-start", projectId)
+ return http.post(url, data)
+
return () ->
return {"projects": service}
diff --git a/app/modules/services/current-user.service.coffee b/app/modules/services/current-user.service.coffee
index 57b0db0f..60985801 100644
--- a/app/modules/services/current-user.service.coffee
+++ b/app/modules/services/current-user.service.coffee
@@ -120,8 +120,7 @@ class CurrentUserService
canCreatePrivateProjects: () ->
user = @.getUser()
-
- if user.get('max_private_projects') != null && user.get('max_private_projects') <= user.get('total_private_projects')
+ if user.get('max_private_projects') != null && user.get('total_private_projects') >= user.get('max_private_projects')
return {valid: false, reason: 'max_private_projects', type: 'private_project'}
return {valid: true}
@@ -129,9 +128,27 @@ class CurrentUserService
canCreatePublicProjects: () ->
user = @.getUser()
- if user.get('max_public_projects') != null && user.get('max_public_projects') <= user.get('total_public_projects')
+ if user.get('max_public_projects') != null && user.get('total_public_projects') >= user.get('max_public_projects')
return {valid: false, reason: 'max_public_projects', type: 'public_project'}
return {valid: true}
+ canOwnProject: (project) ->
+ user = @.getUser()
+ if project.get('is_private')
+ result = @.canCreatePrivateProjects()
+ return result if !result.valid
+
+ if user.get('max_memberships_private_projects') != null && project.get('total_memberships') > user.get('max_memberships_private_projects')
+ return {valid: false, reason: 'max_members_private_projects', type: 'private_project'}
+
+ else
+ result = @.canCreatePublicProjects()
+ return result if !result.valid
+
+ if user.get('max_memberships_public_projects') != null && project.get('total_memberships') > user.get('max_memberships_public_projects')
+ return {valid: false, reason: 'max_members_public_projects', type: 'public_project'}
+
+ return {valid: true}
+
angular.module("taigaCommon").service("tgCurrentUserService", CurrentUserService)
diff --git a/app/modules/services/current-user.service.spec.coffee b/app/modules/services/current-user.service.spec.coffee
index 36646f48..fba99a7f 100644
--- a/app/modules/services/current-user.service.spec.coffee
+++ b/app/modules/services/current-user.service.spec.coffee
@@ -273,3 +273,157 @@ describe "tgCurrentUserService", ->
expect(result).to.be.eql({
valid: true
})
+
+ it "the user can own public project", () ->
+ user = Immutable.fromJS({
+ id: 1,
+ name: "fake1",
+ max_public_projects: 10,
+ total_public_projects: 1,
+ max_memberships_public_projects: 20
+ })
+
+ currentUserService._user = user
+
+ project = Immutable.fromJS({
+ id: 2,
+ name: "fake2",
+ total_memberships: 5,
+ is_private: false
+ })
+
+ result = currentUserService.canOwnProject(project)
+
+ expect(result).to.be.eql({
+ valid: true
+ })
+
+ it "the user can't own public project because of max projects", () ->
+ user = Immutable.fromJS({
+ id: 1,
+ name: "fake1",
+ max_public_projects: 1,
+ total_public_projects: 1,
+ max_memberships_public_projects: 20
+ })
+
+ currentUserService._user = user
+
+ project = Immutable.fromJS({
+ id: 2,
+ name: "fake2",
+ total_memberships: 5,
+ is_private: false
+ })
+
+ result = currentUserService.canOwnProject(project)
+
+ expect(result).to.be.eql({
+ valid: false
+ reason: 'max_public_projects'
+ type: 'public_project'
+ })
+
+
+ it "the user can't own public project because of max memberships", () ->
+ user = Immutable.fromJS({
+ id: 1,
+ name: "fake1",
+ max_public_projects: 5,
+ total_public_projects: 1,
+ max_memberships_public_projects: 4
+ })
+
+ currentUserService._user = user
+
+ project = Immutable.fromJS({
+ id: 2,
+ name: "fake2",
+ total_memberships: 5,
+ is_private: false
+ })
+
+ result = currentUserService.canOwnProject(project)
+
+ expect(result).to.be.eql({
+ valid: false
+ reason: 'max_members_public_projects'
+ type: 'public_project'
+ })
+
+ it "the user can own private project", () ->
+ user = Immutable.fromJS({
+ id: 1,
+ name: "fake1",
+ max_private_projects: 10,
+ total_private_projects: 1,
+ max_memberships_private_projects: 20
+ })
+
+ currentUserService._user = user
+
+ project = Immutable.fromJS({
+ id: 2,
+ name: "fake2",
+ total_memberships: 5,
+ is_private: true
+ })
+
+ result = currentUserService.canOwnProject(project)
+
+ expect(result).to.be.eql({
+ valid: true
+ })
+
+ it "the user can't own private project because of max projects", () ->
+ user = Immutable.fromJS({
+ id: 1,
+ name: "fake1",
+ max_private_projects: 1,
+ total_private_projects: 1,
+ max_memberships_private_projects: 20
+ })
+
+ currentUserService._user = user
+
+ project = Immutable.fromJS({
+ id: 2,
+ name: "fake2",
+ total_memberships: 5,
+ is_private: true
+ })
+
+ result = currentUserService.canOwnProject(project)
+
+ expect(result).to.be.eql({
+ valid: false
+ reason: 'max_private_projects'
+ type: 'private_project'
+ })
+
+
+ it "the user can't own private project because of max memberships", () ->
+ user = Immutable.fromJS({
+ id: 1,
+ name: "fake1",
+ max_private_projects: 10,
+ total_private_projects: 1,
+ max_memberships_private_projects: 4
+ })
+
+ currentUserService._user = user
+
+ project = Immutable.fromJS({
+ id: 2,
+ name: "fake2",
+ total_memberships: 5,
+ is_private: true
+ })
+
+ result = currentUserService.canOwnProject(project)
+
+ expect(result).to.be.eql({
+ valid: false
+ reason: 'max_members_private_projects'
+ type: 'private_project'
+ })
diff --git a/app/partials/admin/admin-project-change-owner.jade b/app/partials/admin/admin-project-change-owner.jade
new file mode 100644
index 00000000..25c442ab
--- /dev/null
+++ b/app/partials/admin/admin-project-change-owner.jade
@@ -0,0 +1,8 @@
+.owner-avatar
+ img(ng-src="{{::owner.photo || '/#{v}/images/user-noimage.png'}}", alt="{{::owner.full_name_display}}")
+
+.owner-info
+ .owner-info-title {{ 'ADMIN.PROJECT_PROFILE.PROJECT_OWNER' | translate }}
+ .owner-name {{::owner.full_name_display}}
+
+a.request(href="", ng-click="changeOwner()") {{ 'ADMIN.PROJECT_PROFILE.CHANGE_OWNER' | translate }}
diff --git a/app/partials/admin/admin-project-profile.jade b/app/partials/admin/admin-project-profile.jade
index b85cec4f..36cf992d 100644
--- a/app/partials/admin/admin-project-profile.jade
+++ b/app/partials/admin/admin-project-profile.jade
@@ -78,6 +78,19 @@ div.wrapper(
ng-model="project.tags"
)
+ fieldset(ng-if="project.owner.id != user.id")
+ tg-admin-project-request-ownership.admin-project-profile-owner-actions(
+ owner="project.owner",
+ project-id="project.id"
+ )
+
+ fieldset(ng-if="project.owner.id == user.id")
+ tg-admin-project-change-owner.admin-project-profile-owner-actions(
+ owner="project.owner",
+ project-id="project.id"
+ active-users="activeUsers"
+ )
+
fieldset.looking-for-people
.looking-for-people-selector
span {{ 'ADMIN.PROJECT_PROFILE.RECRUITING' | translate }}
diff --git a/app/partials/admin/admin-project-request-ownership.jade b/app/partials/admin/admin-project-request-ownership.jade
new file mode 100644
index 00000000..796f9cc3
--- /dev/null
+++ b/app/partials/admin/admin-project-request-ownership.jade
@@ -0,0 +1,8 @@
+.owner-avatar
+ img(ng-src="{{::owner.photo || '/#{v}/images/user-noimage.png'}}", alt="{{::owner.full_name_display}}")
+
+.owner-info
+ .title {{ 'ADMIN.PROJECT_PROFILE.PROJECT_OWNER' | translate }}
+ .owner-name {{::owner.full_name_display}}
+
+a.request(href="", ng-click="requestOwnership()") {{ 'ADMIN.PROJECT_PROFILE.REQUEST_OWNERSHIP' | translate }}
\ No newline at end of file
diff --git a/app/partials/common/lightbox/lightbox-assigned-to.jade b/app/partials/common/lightbox/lightbox-assigned-to.jade
index b3947857..d97ab443 100644
--- a/app/partials/common/lightbox/lightbox-assigned-to.jade
+++ b/app/partials/common/lightbox/lightbox-assigned-to.jade
@@ -1,6 +1,6 @@
svg.close.icon.icon-close(href="", title="{{'COMMON.CLOSE' | translate}}")
use(xlink:href="#icon-close")
-
+
div.form
h2.title(translate="LIGHTBOX.ASSIGNED_TO.SELECT")
fieldset
diff --git a/app/partials/common/lightbox/lightbox-change-owner.jade b/app/partials/common/lightbox/lightbox-change-owner.jade
new file mode 100644
index 00000000..8fe0a128
--- /dev/null
+++ b/app/partials/common/lightbox/lightbox-change-owner.jade
@@ -0,0 +1,72 @@
+svg.close.icon.icon-close(href="", title="{{'COMMON.CLOSE' | translate}}")
+ use(xlink:href="#icon-close")
+
+.form
+ h2.title(translate="LIGHTBOX.CHANGE_OWNER.TITLE")
+ fieldset
+ input(
+ type="text",
+ data-maxlength="500",
+ placeholder="{{'LIGHTBOX.ASSIGNED_TO.SEARCH' | translate}}",
+ ng-model="vm.q",
+ ng-change="vm.userSearch()"
+ )
+
+ .assigned-to-list
+ .user-list-single.is-active(ng-if="vm.selected")
+ .user-list-avatar
+ a(
+ href="#"
+ title="{{'COMMON.ASSIGNED_TO.TITLE' | translate}}"
+ )
+ img(ng-src="{{vm.selected.photo}}")
+ a.user-list-name(
+ href=""
+ title="{{vm.selected.full_name_display}}"
+ ) {{vm.selected.full_name_display}}
+
+ .user-list-single.ng-animate-disabled(
+ ng-repeat="user in vm.getUsers()",
+ ng-click="vm.selectUser(user)"
+ )
+ .user-list-avatar
+ a(
+ href="#"
+ title="{{'COMMON.ASSIGNED_TO.TITLE' | translate}}"
+ )
+ img(ng-src="{{user.photo}}")
+ a.user-list-name(
+ href=""
+ title="{{user.full_name_display}}"
+ ) {{user.full_name_display}}
+
+ .more-watchers(ng-if="!vm.q.length")
+ span(translate="COMMON.ASSIGNED_TO.TOO_MANY")
+
+ .add-comment
+ a(
+ href="",
+ class="ng-animate-disabled"
+ ng-if="!vm.commentOpen",
+ ng-click="vm.commentOpen = true"
+ translate="LIGHTBOX.CHANGE_OWNER.ADD_COMMENT"
+ )
+
+ fieldset(ng-if="vm.commentOpen")
+ svg.icon.icon-close(
+ ng-click="vm.commentOpen = false"
+ href="",
+ title="{{'COMMON.CLOSE' | translate}}"
+ )
+ use(xlink:href="#icon-close")
+ label(translate="LIGHTBOX.CHANGE_OWNER.ADD_COMMENT")
+ textarea(ng-model="vm.comment")
+
+ button.button-green.submit-button(
+ tg-loading="vm.loading",
+ ng-click="vm.submit()",
+ ng-disabled="!vm.selected",
+ type="submit",
+ title="{{'LIGHTBOX.CHANGE_OWNER.BUTTON' | translate}}",
+ translate="LIGHTBOX.CHANGE_OWNER.BUTTON"
+ )
diff --git a/app/partials/common/lightbox/lightbox-request-ownership.jade b/app/partials/common/lightbox/lightbox-request-ownership.jade
new file mode 100644
index 00000000..54247951
--- /dev/null
+++ b/app/partials/common/lightbox/lightbox-request-ownership.jade
@@ -0,0 +1,14 @@
+a.close(href="", title="{{'COMMON.CLOSE' | translate}}")
+ svg.icon.icon-close
+ use(xlink:href="#icon-close")
+
+.content
+ h2.title(translate="ADMIN.PROJECT_PROFILE.REQUEST_OWNERSHIP_CONFIRMATION_TITLE")
+ p(translate="ADMIN.PROJECT_PROFILE.REQUEST_OWNERSHIP_DESC")
+
+ a.button-green(
+ href="",
+ ng-click="request()",
+ tg-loading="loading"
+ )
+ span(translate="ADMIN.PROJECT_PROFILE.REQUEST_OWNERSHIP_BUTTON")
diff --git a/app/styles/components/buttons.scss b/app/styles/components/buttons.scss
index 1be18ede..66d0b13a 100755
--- a/app/styles/components/buttons.scss
+++ b/app/styles/components/buttons.scss
@@ -33,13 +33,13 @@
}
&.disabled,
&[disabled] {
- background: lighten($whitish, 10%);
+ background: $whitish;
box-shadow: none;
color: $gray-light;
cursor: not-allowed;
opacity: .65;
&:hover {
- background: lighten($whitish, 10%);
+ background: $whitish;
color: $gray-light;
}
}
diff --git a/app/styles/components/notification-message.scss b/app/styles/components/notification-message.scss
index 4a79a15d..33585da6 100644
--- a/app/styles/components/notification-message.scss
+++ b/app/styles/components/notification-message.scss
@@ -1,15 +1,22 @@
.notification-message-success {
background: rgba($primary-light, .95);
box-shadow: 0 25px 10px -15px rgba($black, .05);
- opacity: 1;
right: -370px;
top: 2%;
transition: opacity .2s ease-in;
width: 370px;
&.active {
- animation: animSlide 2000ms linear both;
+ animation: animSlide 2000ms;
+ animation-fill-mode: forwards;
+ animation-iteration-count: 1;
opacity: 1;
}
+ &.inactive {
+ animation: animSlideOut .5s;
+ opacity: 0;
+ transform: none;
+ }
+
p {
margin: 0;
}
@@ -40,8 +47,11 @@
20% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -413.214, 0, 0, 1); }
27.23% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -403.135, 0, 0, 1); }
38.34% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -399.585, 0, 0, 1); }
- 60.56% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -400.01, 0, 0, 1); }
- 82.78% { opacity: 1; transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -400, 0, 0, 1); }
+ 100% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -400, 0, 0, 1); }
+}
+
+@keyframes animSlideOut {
+ 0% { opacity: 1; transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -400, 0, 0, 1); }
100% { opacity: 0; transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -400, 0, 0, 1); }
}
diff --git a/app/styles/components/user-list.scss b/app/styles/components/user-list.scss
index 01771bc1..e39f8016 100644
--- a/app/styles/components/user-list.scss
+++ b/app/styles/components/user-list.scss
@@ -33,7 +33,7 @@
.user-list-single {
&:hover,
&.selected {
- background: lighten($primary, 58%);
+ background: rgba(lighten($primary-light, 30%), .3);
cursor: pointer;
}
&:hover {
@@ -41,7 +41,7 @@
transition-delay: .2s;
}
&.is-active {
- background: lighten($primary, 55%);
+ background: rgba(lighten($primary-light, 30%), .3);
cursor: pointer;
margin-bottom: 1rem;
position: relative;
diff --git a/app/styles/core/animation.scss b/app/styles/core/animation.scss
index 50bc216c..74d4cd56 100644
--- a/app/styles/core/animation.scss
+++ b/app/styles/core/animation.scss
@@ -59,6 +59,21 @@
}
}
+// Drop Down element animations
+
+@keyframes dropdownFade {
+ 0% {
+ opacity: 0;
+ transform: translateY(-.25rem);
+ }
+ 60% {
+ opacity: 1;
+ }
+ 100% {
+ transform: translateY(0);
+ }
+}
+
@keyframes blink {
85% {
opacity: 1;
diff --git a/app/styles/core/typography.scss b/app/styles/core/typography.scss
index 18b984e4..85c74703 100755
--- a/app/styles/core/typography.scss
+++ b/app/styles/core/typography.scss
@@ -96,6 +96,10 @@ em {
font-style: italic;
}
+small {
+ @extend %xsmall;
+}
+
strong {
font-weight: bold;
}
diff --git a/app/styles/dependencies/helpers.scss b/app/styles/dependencies/helpers.scss
index d6d4d8aa..f9080da8 100644
--- a/app/styles/dependencies/helpers.scss
+++ b/app/styles/dependencies/helpers.scss
@@ -25,6 +25,7 @@
top: 0;
z-index: 99910;
.close {
+ @include svg-size(2rem);
cursor: pointer;
fill: $gray;
position: absolute;
@@ -53,6 +54,7 @@
.lb-icon {
@include svg-size(6rem);
display: block;
+ fill: $whitish;
margin: 1rem auto;
}
.title {
diff --git a/app/styles/modules/admin/admin-project-profile.scss b/app/styles/modules/admin/admin-project-profile.scss
index 50562431..c15a8f18 100644
--- a/app/styles/modules/admin/admin-project-profile.scss
+++ b/app/styles/modules/admin/admin-project-profile.scss
@@ -1,5 +1,4 @@
@import '../dependencies/mixins/profile-form';
-
.project-details {
@include profile-form;
.looking-for-people {
@@ -31,7 +30,6 @@
animation-delay: .1s;
}
}
-
.delete-project {
@extend %xsmall;
display: block;
@@ -52,9 +50,7 @@
vertical-align: middle;
}
}
-
}
-
.project-privacy-settings {
display: flex;
margin-bottom: .5rem;
@@ -117,7 +113,6 @@
}
}
}
-
tg-admin-project-restrictions {
p {
@extend %xsmall;
@@ -147,3 +142,36 @@ tg-admin-project-restrictions {
}
}
}
+.admin-project-profile-owner-actions {
+ align-items: center;
+ border-top: 1px solid $whitish;
+ display: flex;
+ justify-content: space-between;
+ padding-top: 1rem;
+ a {
+ color: $primary;
+ &:hover {
+ color: $primary-light;
+ transition: color .2s;
+ }
+ }
+ img {
+ width: 100%;
+ }
+ .owner-info {
+ flex: 1;
+ padding-left: .5rem;
+ }
+ .owner-info-title {
+ color: $gray-light;
+ }
+ .owner-name {
+ @extend %bold;
+ }
+ .owner-avatar {
+ width: 2.5rem;
+ }
+ .request {
+ flex-shrink: 0;
+ }
+}
diff --git a/app/styles/modules/common/lightbox.scss b/app/styles/modules/common/lightbox.scss
index f4d63c68..26bf2945 100644
--- a/app/styles/modules/common/lightbox.scss
+++ b/app/styles/modules/common/lightbox.scss
@@ -504,7 +504,7 @@
.user-list-single {
&:hover,
&.selected {
- background: lighten($primary, 58%);
+ background: rgba(lighten($primary-light, 30%), .3);
cursor: pointer;
}
&:hover {
@@ -518,6 +518,33 @@
padding: .5rem;
text-align: center;
}
+ .submit-button {
+ margin-top: 1rem;
+ }
+ .add-comment {
+ position: relative;
+ text-align: center;
+ .icon-close {
+ cursor: pointer;
+ fill: $gray;
+ position: absolute;
+ right: 0;
+ top: 0;
+ transition: fill .2s;
+ &:hover {
+ fill: $red-light;
+ }
+ svg {
+ @include svg-size(2rem);
+ }
+ }
+ textarea {
+ margin-top: 1rem;
+ }
+ a {
+ color: $primary;
+ }
+ }
}
.lb-create-edit-userstory {
@@ -573,3 +600,10 @@
width: 500px;
}
}
+
+.lightbox-request-ownership {
+ text-align: center;
+ .content {
+ width: 500px;
+ }
+}
diff --git a/e2e/helpers/project-detail-helper.js b/e2e/helpers/project-detail-helper.js
index 096dab63..efc676f5 100644
--- a/e2e/helpers/project-detail-helper.js
+++ b/e2e/helpers/project-detail-helper.js
@@ -25,3 +25,52 @@ helper.editLogo = function() {
helper.getLogoSrc = function() {
return $('.image-container .image');
};
+
+helper.requestOwnershipLb = function() {
+ return $('div[tg-lb-request-ownership]');
+};
+
+helper.requestOwnership = function() {
+ $('tg-admin-project-request-ownership .request').click();
+};
+
+helper.changeOwner = function() {
+ $('tg-admin-project-change-owner .request').click();
+};
+
+helper.acceptRequestOwnership = function() {
+ helper.requestOwnershipLb().$('.button-green').click();
+};
+
+helper.changeOwnerSuccessLb = function() {
+ return $('.lightbox-generic-success');
+};
+
+helper.getChangeOwnerLb = function() {
+ let el = $('div[tg-lb-change-owner]');
+
+ let obj = {
+ el: el,
+ waitOpen: function() {
+ return utils.lightbox.open(el);
+ },
+ waitClose: function() {
+ return utils.lightbox.close(el);
+ },
+ search: function(q) {
+ el.$$('input').get(0).sendKeys(q);
+ },
+ select: function(index) {
+ el.$$('.user-list-single').get(index).click();
+ },
+ addComment: function(text) {
+ el.$('.add-comment a').click();
+ el.$('textarea').sendKeys(text);
+ },
+ send: function() {
+ el.$('.submit-button').click();
+ }
+ };
+
+ return obj;
+};
diff --git a/e2e/suites/admin/project/project-detail.e2e.js b/e2e/suites/admin/project/project-detail.e2e.js
index 2ebb94a1..90e1a468 100644
--- a/e2e/suites/admin/project/project-detail.e2e.js
+++ b/e2e/suites/admin/project/project-detail.e2e.js
@@ -78,4 +78,43 @@ describe('project detail', function() {
expect(src).to.contains('upload-image-test.png');
});
+
+ it('request ownership', async function() {
+ adminHelper.requestOwnership();
+
+ await utils.lightbox.open(adminHelper.requestOwnershipLb());
+
+ expect(utils.notifications.success.open()).to.be.eventually.true;
+ });
+
+ it('change ownership', async function() {
+ await utils.common.createProject(['user5@taigaio.demo']);
+
+ await utils.nav
+ .init()
+ .admin()
+ .go();
+
+ adminHelper.changeOwner();
+
+ let lb = adminHelper.getChangeOwnerLb();
+
+ await lb.waitOpen();
+
+ lb.search('Alicia Flores');
+ lb.select(0);
+ lb.addComment('text');
+
+ utils.common.takeScreenshot('admin', 'project-transfer-lb');
+
+ lb.send();
+
+ let changeOwnerSuccessLb = adminHelper.changeOwnerSuccessLb();
+
+ await utils.lightbox.open(changeOwnerSuccessLb);
+
+ changeOwnerSuccessLb.$('.button-green').click();
+
+ await utils.lightbox.close(changeOwnerSuccessLb);
+ });
});
diff --git a/e2e/utils/common.js b/e2e/utils/common.js
index ae8846ec..9f2efdd7 100644
--- a/e2e/utils/common.js
+++ b/e2e/utils/common.js
@@ -435,3 +435,50 @@ common.closeJoyride = async function() {
await browser.sleep(600);
}
};
+
+common.createProject = async function(members = []) {
+ var createProject = require('../helpers').createProject;
+ var notifications = require('./notifications');
+
+ browser.get(browser.params.glob.host + 'projects/');
+ await common.waitLoader();
+
+ let lb = createProject.createProjectLightbox();
+
+ createProject.openWizard();
+
+ await lb.waitOpen();
+
+ lb.name().sendKeys('aaa');
+
+ lb.description().sendKeys('bbb');
+
+ await lb.submit();
+
+ await notifications.success.open();
+ await notifications.success.close();
+
+ if (members.length) {
+ var adminMembershipsHelper = require('../helpers').adminMemberships;
+
+ let url = await browser.getCurrentUrl();
+ url = url.split('/');
+ url = browser.params.glob.host + '/project/' + url[4] + '/admin/memberships';
+
+ browser.get(url);
+ await common.waitLoader();
+
+ let newMemberLightbox = adminMembershipsHelper.getNewMemberLightbox();
+ adminMembershipsHelper.openNewMemberLightbox();
+
+ await newMemberLightbox.waitOpen();
+
+ for(var i = 0; i < members.length; i++) {
+ newMemberLightbox.newEmail(members[i]);
+ }
+
+ newMemberLightbox.submit();
+
+ await newMemberLightbox.waitClose();
+ }
+};
diff --git a/e2e/utils/nav.js b/e2e/utils/nav.js
index ba663509..b5733c56 100644
--- a/e2e/utils/nav.js
+++ b/e2e/utils/nav.js
@@ -46,6 +46,11 @@ var actions = {
browser.get(browser.params.glob.host);
return common.waitLoader();
},
+ admin: async function() {
+ await common.link($('#nav-admin a'));
+
+ return common.waitLoader();
+ },
taskboard: async function(index) {
let link = $$('.sprints .button-gray').get(index);
@@ -92,6 +97,10 @@ var nav = {
this.actions.push(actions.home.bind(null));
return this;
},
+ admin: function() {
+ this.actions.push(actions.admin.bind(null));
+ return this;
+ },
taskboard: function(index) {
this.actions.push(actions.taskboard.bind(null, index));
return this;