US #49 (taiga-subscriptions): Transfer project ownership

stable
Alejandro Alonso 2016-03-15 12:23:13 +01:00 committed by David Barragán Merino
parent f3d93e7a0f
commit 2e4a63fe75
38 changed files with 1179 additions and 21 deletions

View File

@ -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.

View File

@ -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"})

View File

@ -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])

View File

@ -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)

View File

@ -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])

View File

@ -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 = $('<img>').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 = $('<img>').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

View File

@ -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"

View File

@ -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": "<strong>{{owner}}</strong>, the current owner of the project <strong>{{project}}</strong> 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 <strong>{{maxProjects}}</strong> private projects and you already own <strong>{{currentProjects}}</strong> private projects",
"PUBLIC": "Remember, you can own <strong>{{maxProjects}}</strong> public projects and you already own <strong>{{currentProjects}}</strong> 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": {

View File

@ -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)

View File

@ -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()

View File

@ -0,0 +1,25 @@
###
# Copyright (C) 2014-2016 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: cant-own-project-explanation.directive.coffee
###
CantOwnProjectExplanationDirective = () ->
return {
templateUrl: "projects/transfer/cant-own-project-explanation.html"
}
angular.module("taigaProjects").directive("tgCantOwnProjectExplanation", CantOwnProjectExplanationDirective)

View File

@ -0,0 +1,3 @@
p(
translate="ADMIN.PROJECT_TRANSFER.CANT_BE_OWNED"
)

View File

@ -0,0 +1,4 @@
tg-transfer-project.transfer-project(
ng-if="vm.project"
project = "vm.project"
)

View File

@ -0,0 +1,98 @@
###
# Copyright (C) 2014-2016 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# 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)

View File

@ -0,0 +1,33 @@
###
# Copyright (C) 2014-2016 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# 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)

View File

@ -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)

View File

@ -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;
}
}
}
}

View File

@ -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}

View File

@ -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)

View File

@ -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'
})

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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"
)

View File

@ -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")

View File

@ -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;
}
}

View File

@ -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); }
}

View File

@ -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;

View File

@ -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;

View File

@ -96,6 +96,10 @@ em {
font-style: italic;
}
small {
@extend %xsmall;
}
strong {
font-weight: bold;
}

View File

@ -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 {

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
};

View File

@ -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);
});
});

View File

@ -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();
}
};

View File

@ -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;