KanBan: be able to set default board (#14147)
Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: zeripath <art27@cantab.net>
This commit is contained in:
		
							parent
							
								
									c09e11d018
								
							
						
					
					
						commit
						3091600cc8
					
				
					 7 changed files with 193 additions and 56 deletions
				
			
		|  | @ -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) { | ||||
| 
 | ||||
| 	var boards = make([]*ProjectBoard, 0, 5) | ||||
| 
 | ||||
| 	sess := x.Where("project_id=?", projectID) | ||||
| 	return boards, sess.Find(&boards) | ||||
| // if no default board set, first board is a temporary "Uncategorized" board
 | ||||
| func GetProjectBoards(projectID int64) (ProjectBoardList, error) { | ||||
| 	return getProjectBoards(x, projectID) | ||||
| } | ||||
| 
 | ||||
| // GetUncategorizedBoard represents a board for issues not assigned to one
 | ||||
| func GetUncategorizedBoard(projectID int64) (*ProjectBoard, error) { | ||||
| func getProjectBoards(e Engine, projectID int64) ([]*ProjectBoard, error) { | ||||
| 	var boards = make([]*ProjectBoard, 0, 5) | ||||
| 
 | ||||
| 	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 | ||||
| } | ||||
| 
 | ||||
| // 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 | ||||
| 	issueList := make([]*Issue, 0, 10) | ||||
| 
 | ||||
| 	} else { | ||||
| 		// Issues without ProjectBoardID
 | ||||
| 		boardID = -1 | ||||
| 	} | ||||
| 	if b.ID != 0 { | ||||
| 		issues, err := Issues(&IssuesOptions{ | ||||
| 		ProjectBoardID: boardID, | ||||
| 			ProjectBoardID: b.ID, | ||||
| 			ProjectID:      b.ProjectID, | ||||
| 		}) | ||||
| 	b.Issues = issues | ||||
| 	return issues, err | ||||
| 		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) { | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										28
									
								
								routers/repo/projects_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								routers/repo/projects_test.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -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> | ||||
| 									</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(); | ||||
|     }); | ||||
|   }); | ||||
| } | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue