diff --git a/models/project_board.go b/models/project_board.go index 260fc8304b..8ffa218377 100644 --- a/models/project_board.go +++ b/models/project_board.go @@ -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: b.ID, + ProjectID: b.ProjectID, + }) + if err != nil { + return nil, err + } + issueList = issues } - issues, err := Issues(&IssuesOptions{ - ProjectBoardID: boardID, - ProjectID: b.ProjectID, - }) - b.Issues = issues - return issues, err + + 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 diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 4264d260da..4546a06e81 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -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 diff --git a/routers/repo/projects.go b/routers/repo/projects.go index 08746aad98..d3cdab6b75 100644 --- a/routers/repo/projects.go +++ b/routers/repo/projects.go @@ -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) { diff --git a/routers/repo/projects_test.go b/routers/repo/projects_test.go new file mode 100644 index 0000000000..c43cf6d952 --- /dev/null +++ b/routers/repo/projects_test.go @@ -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()) +} diff --git a/routers/routes/macaron.go b/routers/routes/macaron.go index d331e4ca83..34978724a8 100644 --- a/routers/routes/macaron.go +++ b/routers/routes/macaron.go @@ -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) }) diff --git a/templates/repo/projects/view.tmpl b/templates/repo/projects/view.tmpl index b27773f710..a3606c169c 100644 --- a/templates/repo/projects/view.tmpl +++ b/templates/repo/projects/view.tmpl @@ -85,6 +85,12 @@ {{svg "octicon-pencil"}} {{$.i18n.Tr "repo.projects.board.edit"}} + {{if not .Default}} + + {{svg "octicon-pin"}} + {{$.i18n.Tr "repo.projects.board.set_default"}} + + {{end}} {{svg "octicon-trashcan"}} {{$.i18n.Tr "repo.projects.board.delete"}} @@ -109,24 +115,34 @@ + + + {{$.i18n.Tr "repo.projects.board.set_default"}} + + + + {{$.i18n.Tr "repo.projects.board.set_default_desc"}} + + + + {{$.i18n.Tr "settings.cancel"}} + {{$.i18n.Tr "repo.projects.board.set_default"}} + + + {{$.i18n.Tr "repo.projects.board.delete"}} - - - - {{$.i18n.Tr "repo.projects.board.deletion_desc"}} - - + + {{$.i18n.Tr "repo.projects.board.deletion_desc"}} + + + + {{$.i18n.Tr "settings.cancel"}} + {{$.i18n.Tr "repo.projects.board.delete"}} - - - {{$.i18n.Tr "settings.cancel"}} - {{$.i18n.Tr "repo.projects.board.delete"}} - - diff --git a/web_src/js/features/projects.js b/web_src/js/features/projects.js index 13318c9f89..b5f52f7443 100644 --- a/web_src/js/features/projects.js +++ b/web_src/js/features/projects.js @@ -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(); }); }); }