mirror of
				https://gitee.com/gitea/gitea
				synced 2025-11-04 16:40:24 +08:00 
			
		
		
		
	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:
		@@ -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();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user