Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: zeripath <art27@cantab.net>tags/v1.15.0-dev
@@ -8,6 +8,7 @@ import ( | |||
"code.gitea.io/gitea/modules/setting" | |||
"code.gitea.io/gitea/modules/timeutil" | |||
"xorm.io/builder" | |||
"xorm.io/xorm" | |||
) | |||
@@ -164,22 +165,43 @@ func UpdateProjectBoard(board *ProjectBoard) error { | |||
func updateProjectBoard(e Engine, board *ProjectBoard) error { | |||
_, err := e.ID(board.ID).Cols( | |||
"title", | |||
"default", | |||
).Update(board) | |||
return err | |||
} | |||
// GetProjectBoards fetches all boards related to a project | |||
func GetProjectBoards(projectID int64) ([]*ProjectBoard, error) { | |||
// if no default board set, first board is a temporary "Uncategorized" board | |||
func GetProjectBoards(projectID int64) (ProjectBoardList, error) { | |||
return getProjectBoards(x, projectID) | |||
} | |||
func getProjectBoards(e Engine, projectID int64) ([]*ProjectBoard, error) { | |||
var boards = make([]*ProjectBoard, 0, 5) | |||
sess := x.Where("project_id=?", projectID) | |||
return boards, sess.Find(&boards) | |||
if err := e.Where("project_id=? AND `default`=?", projectID, false).Find(&boards); err != nil { | |||
return nil, err | |||
} | |||
defaultB, err := getDefaultBoard(e, projectID) | |||
if err != nil { | |||
return nil, err | |||
} | |||
return append([]*ProjectBoard{defaultB}, boards...), nil | |||
} | |||
// GetUncategorizedBoard represents a board for issues not assigned to one | |||
func GetUncategorizedBoard(projectID int64) (*ProjectBoard, error) { | |||
// getDefaultBoard return default board and create a dummy if none exist | |||
func getDefaultBoard(e Engine, projectID int64) (*ProjectBoard, error) { | |||
var board ProjectBoard | |||
exist, err := e.Where("project_id=? AND `default`=?", projectID, true).Get(&board) | |||
if err != nil { | |||
return nil, err | |||
} | |||
if exist { | |||
return &board, nil | |||
} | |||
// represents a board for issues not assigned to one | |||
return &ProjectBoard{ | |||
ProjectID: projectID, | |||
Title: "Uncategorized", | |||
@@ -187,22 +209,55 @@ func GetUncategorizedBoard(projectID int64) (*ProjectBoard, error) { | |||
}, nil | |||
} | |||
// SetDefaultBoard represents a board for issues not assigned to one | |||
// if boardID is 0 unset default | |||
func SetDefaultBoard(projectID, boardID int64) error { | |||
sess := x | |||
_, err := sess.Where(builder.Eq{ | |||
"project_id": projectID, | |||
"`default`": true, | |||
}).Cols("`default`").Update(&ProjectBoard{Default: false}) | |||
if err != nil { | |||
return err | |||
} | |||
if boardID > 0 { | |||
_, err = sess.ID(boardID).Where(builder.Eq{"project_id": projectID}). | |||
Cols("`default`").Update(&ProjectBoard{Default: true}) | |||
} | |||
return err | |||
} | |||
// LoadIssues load issues assigned to this board | |||
func (b *ProjectBoard) LoadIssues() (IssueList, error) { | |||
var boardID int64 | |||
if !b.Default { | |||
boardID = b.ID | |||
} else { | |||
// Issues without ProjectBoardID | |||
boardID = -1 | |||
} | |||
issues, err := Issues(&IssuesOptions{ | |||
ProjectBoardID: boardID, | |||
ProjectID: b.ProjectID, | |||
}) | |||
b.Issues = issues | |||
return issues, err | |||
issueList := make([]*Issue, 0, 10) | |||
if b.ID != 0 { | |||
issues, err := Issues(&IssuesOptions{ | |||
ProjectBoardID: b.ID, | |||
ProjectID: b.ProjectID, | |||
}) | |||
if err != nil { | |||
return nil, err | |||
} | |||
issueList = issues | |||
} | |||
if b.Default { | |||
issues, err := Issues(&IssuesOptions{ | |||
ProjectBoardID: -1, // Issues without ProjectBoardID | |||
ProjectID: b.ProjectID, | |||
}) | |||
if err != nil { | |||
return nil, err | |||
} | |||
issueList = append(issueList, issues...) | |||
} | |||
b.Issues = issueList | |||
return issueList, nil | |||
} | |||
// LoadIssues load issues assigned to the boards | |||
@@ -945,6 +945,8 @@ projects.board.edit_title = "New Board Name" | |||
projects.board.new_title = "New Board Name" | |||
projects.board.new_submit = "Submit" | |||
projects.board.new = "New Board" | |||
projects.board.set_default = "Set Default" | |||
projects.board.set_default_desc = "Set this board as default for uncategorized issues and pulls" | |||
projects.board.delete = "Delete Board" | |||
projects.board.deletion_desc = "Deleting a project board moves all related issues to 'Uncategorized'. Continue?" | |||
projects.open = Open | |||
@@ -270,23 +270,17 @@ func ViewProject(ctx *context.Context) { | |||
return | |||
} | |||
uncategorizedBoard, err := models.GetUncategorizedBoard(project.ID) | |||
uncategorizedBoard.Title = ctx.Tr("repo.projects.type.uncategorized") | |||
if err != nil { | |||
ctx.ServerError("GetUncategorizedBoard", err) | |||
return | |||
} | |||
boards, err := models.GetProjectBoards(project.ID) | |||
if err != nil { | |||
ctx.ServerError("GetProjectBoards", err) | |||
return | |||
} | |||
allBoards := models.ProjectBoardList{uncategorizedBoard} | |||
allBoards = append(allBoards, boards...) | |||
if boards[0].ID == 0 { | |||
boards[0].Title = ctx.Tr("repo.projects.type.uncategorized") | |||
} | |||
if ctx.Data["Issues"], err = allBoards.LoadIssues(); err != nil { | |||
if ctx.Data["Issues"], err = boards.LoadIssues(); err != nil { | |||
ctx.ServerError("LoadIssuesOfBoards", err) | |||
return | |||
} | |||
@@ -295,7 +289,7 @@ func ViewProject(ctx *context.Context) { | |||
ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(models.UnitTypeProjects) | |||
ctx.Data["Project"] = project | |||
ctx.Data["Boards"] = allBoards | |||
ctx.Data["Boards"] = boards | |||
ctx.Data["PageIsProjects"] = true | |||
ctx.Data["RequiresDraggable"] = true | |||
@@ -416,21 +410,19 @@ func AddBoardToProjectPost(ctx *context.Context, form auth.EditProjectBoardTitle | |||
}) | |||
} | |||
// EditProjectBoardTitle allows a project board's title to be updated | |||
func EditProjectBoardTitle(ctx *context.Context, form auth.EditProjectBoardTitleForm) { | |||
func checkProjectBoardChangePermissions(ctx *context.Context) (*models.Project, *models.ProjectBoard) { | |||
if ctx.User == nil { | |||
ctx.JSON(403, map[string]string{ | |||
"message": "Only signed in users are allowed to perform this action.", | |||
}) | |||
return | |||
return nil, nil | |||
} | |||
if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(models.AccessModeWrite, models.UnitTypeProjects) { | |||
ctx.JSON(403, map[string]string{ | |||
"message": "Only authorized users are allowed to perform this action.", | |||
}) | |||
return | |||
return nil, nil | |||
} | |||
project, err := models.GetProjectByID(ctx.ParamsInt64(":id")) | |||
@@ -440,25 +432,35 @@ func EditProjectBoardTitle(ctx *context.Context, form auth.EditProjectBoardTitle | |||
} else { | |||
ctx.ServerError("GetProjectByID", err) | |||
} | |||
return | |||
return nil, nil | |||
} | |||
board, err := models.GetProjectBoard(ctx.ParamsInt64(":boardID")) | |||
if err != nil { | |||
ctx.ServerError("GetProjectBoard", err) | |||
return | |||
return nil, nil | |||
} | |||
if board.ProjectID != ctx.ParamsInt64(":id") { | |||
ctx.JSON(422, map[string]string{ | |||
"message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", board.ID, project.ID), | |||
}) | |||
return | |||
return nil, nil | |||
} | |||
if project.RepoID != ctx.Repo.Repository.ID { | |||
ctx.JSON(422, map[string]string{ | |||
"message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", board.ID, ctx.Repo.Repository.ID), | |||
}) | |||
return nil, nil | |||
} | |||
return project, board | |||
} | |||
// EditProjectBoardTitle allows a project board's title to be updated | |||
func EditProjectBoardTitle(ctx *context.Context, form auth.EditProjectBoardTitleForm) { | |||
_, board := checkProjectBoardChangePermissions(ctx) | |||
if ctx.Written() { | |||
return | |||
} | |||
@@ -476,6 +478,24 @@ func EditProjectBoardTitle(ctx *context.Context, form auth.EditProjectBoardTitle | |||
}) | |||
} | |||
// SetDefaultProjectBoard set default board for uncategorized issues/pulls | |||
func SetDefaultProjectBoard(ctx *context.Context) { | |||
project, board := checkProjectBoardChangePermissions(ctx) | |||
if ctx.Written() { | |||
return | |||
} | |||
if err := models.SetDefaultBoard(project.ID, board.ID); err != nil { | |||
ctx.ServerError("SetDefaultBoard", err) | |||
return | |||
} | |||
ctx.JSON(200, map[string]interface{}{ | |||
"ok": true, | |||
}) | |||
} | |||
// MoveIssueAcrossBoards move a card from one board to another in a project | |||
func MoveIssueAcrossBoards(ctx *context.Context) { | |||
@@ -0,0 +1,28 @@ | |||
// Copyright 2020 The Gitea Authors. All rights reserved. | |||
// Use of this source code is governed by a MIT-style | |||
// license that can be found in the LICENSE file. | |||
package repo | |||
import ( | |||
"testing" | |||
"code.gitea.io/gitea/models" | |||
"code.gitea.io/gitea/modules/test" | |||
"github.com/stretchr/testify/assert" | |||
) | |||
func TestCheckProjectBoardChangePermissions(t *testing.T) { | |||
models.PrepareTestEnv(t) | |||
ctx := test.MockContext(t, "user2/repo1/projects/1/2") | |||
test.LoadUser(t, ctx, 2) | |||
test.LoadRepo(t, ctx, 1) | |||
ctx.SetParams(":id", "1") | |||
ctx.SetParams(":boardID", "2") | |||
project, board := checkProjectBoardChangePermissions(ctx) | |||
assert.NotNil(t, project) | |||
assert.NotNil(t, board) | |||
assert.False(t, ctx.Written()) | |||
} |
@@ -800,6 +800,7 @@ func RegisterMacaronRoutes(m *macaron.Macaron) { | |||
m.Group("/:boardID", func() { | |||
m.Put("", bindIgnErr(auth.EditProjectBoardTitleForm{}), repo.EditProjectBoardTitle) | |||
m.Delete("", repo.DeleteProjectBoard) | |||
m.Post("/default", repo.SetDefaultProjectBoard) | |||
m.Post("/:index", repo.MoveIssueAcrossBoards) | |||
}) | |||
@@ -85,6 +85,12 @@ | |||
{{svg "octicon-pencil"}} | |||
{{$.i18n.Tr "repo.projects.board.edit"}} | |||
</a> | |||
{{if not .Default}} | |||
<a class="item show-modal button" data-modal="#set-default-project-board-modal-{{.ID}}"> | |||
{{svg "octicon-pin"}} | |||
{{$.i18n.Tr "repo.projects.board.set_default"}} | |||
</a> | |||
{{end}} | |||
<a class="item show-modal button" data-modal="#delete-board-modal-{{.ID}}"> | |||
{{svg "octicon-trashcan"}} | |||
{{$.i18n.Tr "repo.projects.board.delete"}} | |||
@@ -109,24 +115,34 @@ | |||
</div> | |||
</div> | |||
<div class="ui basic modal" id="set-default-project-board-modal-{{.ID}}"> | |||
<div class="ui icon header"> | |||
{{$.i18n.Tr "repo.projects.board.set_default"}} | |||
</div> | |||
<div class="content center"> | |||
<label> | |||
{{$.i18n.Tr "repo.projects.board.set_default_desc"}} | |||
</label> | |||
</div> | |||
<div class="text right actions"> | |||
<div class="ui cancel button">{{$.i18n.Tr "settings.cancel"}}</div> | |||
<button class="ui red button set-default-project-board" data-url="{{$.RepoLink}}/projects/{{$.Project.ID}}/{{.ID}}/default">{{$.i18n.Tr "repo.projects.board.set_default"}}</button> | |||
</div> | |||
</div> | |||
<div class="ui basic modal" id="delete-board-modal-{{.ID}}"> | |||
<div class="ui icon header"> | |||
{{$.i18n.Tr "repo.projects.board.delete"}} | |||
</div> | |||
<div class="content center"> | |||
<input type="hidden" name="action" value="delete"> | |||
<div class="field"> | |||
<label> | |||
{{$.i18n.Tr "repo.projects.board.deletion_desc"}} | |||
</label> | |||
</div> | |||
<label> | |||
{{$.i18n.Tr "repo.projects.board.deletion_desc"}} | |||
</label> | |||
</div> | |||
<div class="text right actions"> | |||
<div class="ui cancel button">{{$.i18n.Tr "settings.cancel"}}</div> | |||
<button class="ui red button delete-project-board" data-url="{{$.RepoLink}}/projects/{{$.Project.ID}}/{{.ID}}">{{$.i18n.Tr "repo.projects.board.delete"}}</button> | |||
</div> | |||
<form class="ui form" method="post"> | |||
<div class="text right actions"> | |||
<div class="ui cancel button">{{$.i18n.Tr "settings.cancel"}}</div> | |||
<button class="ui red button delete-project-board" data-url="{{$.RepoLink}}/projects/{{$.Project.ID}}/{{.ID}}">{{$.i18n.Tr "repo.projects.board.delete"}}</button> | |||
</div> | |||
</form> | |||
</div> | |||
</div> | |||
</div> | |||
@@ -27,14 +27,14 @@ export default async function initProject() { | |||
}, | |||
}); | |||
}, | |||
} | |||
}, | |||
); | |||
} | |||
$('.edit-project-board').each(function () { | |||
const projectTitleLabel = $(this).closest('.board-column-header').find('.board-label'); | |||
const projectTitleInput = $(this).find( | |||
'.content > .form > .field > .project-board-title' | |||
'.content > .form > .field > .project-board-title', | |||
); | |||
$(this) | |||
@@ -59,6 +59,21 @@ export default async function initProject() { | |||
}); | |||
}); | |||
$(document).on('click', '.set-default-project-board', async function (e) { | |||
e.preventDefault(); | |||
await $.ajax({ | |||
method: 'POST', | |||
url: $(this).data('url'), | |||
headers: { | |||
'X-Csrf-Token': csrf, | |||
'X-Remote': true, | |||
}, | |||
contentType: 'application/json', | |||
}); | |||
window.location.reload(); | |||
}); | |||
$('.delete-project-board').each(function () { | |||
$(this).click(function (e) { | |||
e.preventDefault(); | |||
@@ -72,7 +87,7 @@ export default async function initProject() { | |||
contentType: 'application/json', | |||
method: 'DELETE', | |||
}).done(() => { | |||
setTimeout(window.location.reload(true), 2000); | |||
window.location.reload(); | |||
}); | |||
}); | |||
}); | |||
@@ -93,7 +108,7 @@ export default async function initProject() { | |||
method: 'POST', | |||
}).done(() => { | |||
boardTitle.closest('form').removeClass('dirty'); | |||
setTimeout(window.location.reload(true), 2000); | |||
window.location.reload(); | |||
}); | |||
}); | |||
} |