mirror of
				https://github.com/go-gitea/gitea
				synced 2025-09-28 03:28:13 +00:00 
			
		
		
		
	Backport #30696 by @lunny # The problem The previous implementation will start multiple POST requests from the frontend when moving a column and another bug is moving the default column will never be remembered in fact. # What's changed - [x] This PR will allow the default column to move to a non-first position - [x] And it also uses one request instead of multiple requests when moving the columns - [x] Use a star instead of a pin as the icon for setting the default column action - [x] Inserted new column will be append to the end - [x] Fix #30701 the newly added issue will be append to the end of the default column - [x] Fix when deleting a column, all issues in it will be displayed from UI but database records exist. - [x] Add a limitation for columns in a project to 20. So the sorting will not be overflow because it's int8. Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
		
			
				
	
	
		
			390 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			390 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| // Copyright 2020 The Gitea Authors. All rights reserved.
 | |
| // SPDX-License-Identifier: MIT
 | |
| 
 | |
| package project
 | |
| 
 | |
| import (
 | |
| 	"context"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"regexp"
 | |
| 
 | |
| 	"code.gitea.io/gitea/models/db"
 | |
| 	"code.gitea.io/gitea/modules/setting"
 | |
| 	"code.gitea.io/gitea/modules/timeutil"
 | |
| 	"code.gitea.io/gitea/modules/util"
 | |
| 
 | |
| 	"xorm.io/builder"
 | |
| )
 | |
| 
 | |
| type (
 | |
| 	// BoardType is used to represent a project board type
 | |
| 	BoardType uint8
 | |
| 
 | |
| 	// CardType is used to represent a project board card type
 | |
| 	CardType uint8
 | |
| 
 | |
| 	// BoardList is a list of all project boards in a repository
 | |
| 	BoardList []*Board
 | |
| )
 | |
| 
 | |
| const (
 | |
| 	// BoardTypeNone is a project board type that has no predefined columns
 | |
| 	BoardTypeNone BoardType = iota
 | |
| 
 | |
| 	// BoardTypeBasicKanban is a project board type that has basic predefined columns
 | |
| 	BoardTypeBasicKanban
 | |
| 
 | |
| 	// BoardTypeBugTriage is a project board type that has predefined columns suited to hunting down bugs
 | |
| 	BoardTypeBugTriage
 | |
| )
 | |
| 
 | |
| const (
 | |
| 	// CardTypeTextOnly is a project board card type that is text only
 | |
| 	CardTypeTextOnly CardType = iota
 | |
| 
 | |
| 	// CardTypeImagesAndText is a project board card type that has images and text
 | |
| 	CardTypeImagesAndText
 | |
| )
 | |
| 
 | |
| // BoardColorPattern is a regexp witch can validate BoardColor
 | |
| var BoardColorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$")
 | |
| 
 | |
| // Board is used to represent boards on a project
 | |
| type Board struct {
 | |
| 	ID      int64 `xorm:"pk autoincr"`
 | |
| 	Title   string
 | |
| 	Default bool   `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific board will be assigned to this board
 | |
| 	Sorting int8   `xorm:"NOT NULL DEFAULT 0"`
 | |
| 	Color   string `xorm:"VARCHAR(7)"`
 | |
| 
 | |
| 	ProjectID int64 `xorm:"INDEX NOT NULL"`
 | |
| 	CreatorID int64 `xorm:"NOT NULL"`
 | |
| 
 | |
| 	CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
 | |
| 	UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
 | |
| }
 | |
| 
 | |
| // TableName return the real table name
 | |
| func (Board) TableName() string {
 | |
| 	return "project_board"
 | |
| }
 | |
| 
 | |
| // NumIssues return counter of all issues assigned to the board
 | |
| func (b *Board) NumIssues(ctx context.Context) int {
 | |
| 	c, err := db.GetEngine(ctx).Table("project_issue").
 | |
| 		Where("project_id=?", b.ProjectID).
 | |
| 		And("project_board_id=?", b.ID).
 | |
| 		GroupBy("issue_id").
 | |
| 		Cols("issue_id").
 | |
| 		Count()
 | |
| 	if err != nil {
 | |
| 		return 0
 | |
| 	}
 | |
| 	return int(c)
 | |
| }
 | |
| 
 | |
| func (b *Board) GetIssues(ctx context.Context) ([]*ProjectIssue, error) {
 | |
| 	issues := make([]*ProjectIssue, 0, 5)
 | |
| 	if err := db.GetEngine(ctx).Where("project_id=?", b.ProjectID).
 | |
| 		And("project_board_id=?", b.ID).
 | |
| 		OrderBy("sorting, id").
 | |
| 		Find(&issues); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	return issues, nil
 | |
| }
 | |
| 
 | |
| func init() {
 | |
| 	db.RegisterModel(new(Board))
 | |
| }
 | |
| 
 | |
| // IsBoardTypeValid checks if the project board type is valid
 | |
| func IsBoardTypeValid(p BoardType) bool {
 | |
| 	switch p {
 | |
| 	case BoardTypeNone, BoardTypeBasicKanban, BoardTypeBugTriage:
 | |
| 		return true
 | |
| 	default:
 | |
| 		return false
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // IsCardTypeValid checks if the project board card type is valid
 | |
| func IsCardTypeValid(p CardType) bool {
 | |
| 	switch p {
 | |
| 	case CardTypeTextOnly, CardTypeImagesAndText:
 | |
| 		return true
 | |
| 	default:
 | |
| 		return false
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func createBoardsForProjectsType(ctx context.Context, project *Project) error {
 | |
| 	var items []string
 | |
| 
 | |
| 	switch project.BoardType {
 | |
| 	case BoardTypeBugTriage:
 | |
| 		items = setting.Project.ProjectBoardBugTriageType
 | |
| 
 | |
| 	case BoardTypeBasicKanban:
 | |
| 		items = setting.Project.ProjectBoardBasicKanbanType
 | |
| 	case BoardTypeNone:
 | |
| 		fallthrough
 | |
| 	default:
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	board := Board{
 | |
| 		CreatedUnix: timeutil.TimeStampNow(),
 | |
| 		CreatorID:   project.CreatorID,
 | |
| 		Title:       "Backlog",
 | |
| 		ProjectID:   project.ID,
 | |
| 		Default:     true,
 | |
| 	}
 | |
| 	if err := db.Insert(ctx, board); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	if len(items) == 0 {
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	boards := make([]Board, 0, len(items))
 | |
| 
 | |
| 	for _, v := range items {
 | |
| 		boards = append(boards, Board{
 | |
| 			CreatedUnix: timeutil.TimeStampNow(),
 | |
| 			CreatorID:   project.CreatorID,
 | |
| 			Title:       v,
 | |
| 			ProjectID:   project.ID,
 | |
| 		})
 | |
| 	}
 | |
| 
 | |
| 	return db.Insert(ctx, boards)
 | |
| }
 | |
| 
 | |
| // maxProjectColumns max columns allowed in a project, this should not bigger than 127
 | |
| // because sorting is int8 in database
 | |
| const maxProjectColumns = 20
 | |
| 
 | |
| // NewBoard adds a new project board to a given project
 | |
| func NewBoard(ctx context.Context, board *Board) error {
 | |
| 	if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) {
 | |
| 		return fmt.Errorf("bad color code: %s", board.Color)
 | |
| 	}
 | |
| 	res := struct {
 | |
| 		MaxSorting  int64
 | |
| 		ColumnCount int64
 | |
| 	}{}
 | |
| 	if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as column_count").Table("project_board").
 | |
| 		Where("project_id=?", board.ProjectID).Get(&res); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	if res.ColumnCount >= maxProjectColumns {
 | |
| 		return fmt.Errorf("NewBoard: maximum number of columns reached")
 | |
| 	}
 | |
| 	board.Sorting = int8(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0))
 | |
| 	_, err := db.GetEngine(ctx).Insert(board)
 | |
| 	return err
 | |
| }
 | |
| 
 | |
| // DeleteBoardByID removes all issues references to the project board.
 | |
| func DeleteBoardByID(ctx context.Context, boardID int64) error {
 | |
| 	ctx, committer, err := db.TxContext(ctx)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	defer committer.Close()
 | |
| 
 | |
| 	if err := deleteBoardByID(ctx, boardID); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	return committer.Commit()
 | |
| }
 | |
| 
 | |
| func deleteBoardByID(ctx context.Context, boardID int64) error {
 | |
| 	board, err := GetBoard(ctx, boardID)
 | |
| 	if err != nil {
 | |
| 		if IsErrProjectBoardNotExist(err) {
 | |
| 			return nil
 | |
| 		}
 | |
| 
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	if board.Default {
 | |
| 		return fmt.Errorf("deleteBoardByID: cannot delete default board")
 | |
| 	}
 | |
| 
 | |
| 	// move all issues to the default column
 | |
| 	project, err := GetProjectByID(ctx, board.ProjectID)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	defaultColumn, err := project.GetDefaultBoard(ctx)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	if err = board.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	if _, err := db.GetEngine(ctx).ID(board.ID).NoAutoCondition().Delete(board); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func deleteBoardByProjectID(ctx context.Context, projectID int64) error {
 | |
| 	_, err := db.GetEngine(ctx).Where("project_id=?", projectID).Delete(&Board{})
 | |
| 	return err
 | |
| }
 | |
| 
 | |
| // GetBoard fetches the current board of a project
 | |
| func GetBoard(ctx context.Context, boardID int64) (*Board, error) {
 | |
| 	board := new(Board)
 | |
| 	has, err := db.GetEngine(ctx).ID(boardID).Get(board)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	} else if !has {
 | |
| 		return nil, ErrProjectBoardNotExist{BoardID: boardID}
 | |
| 	}
 | |
| 
 | |
| 	return board, nil
 | |
| }
 | |
| 
 | |
| // UpdateBoard updates a project board
 | |
| func UpdateBoard(ctx context.Context, board *Board) error {
 | |
| 	var fieldToUpdate []string
 | |
| 
 | |
| 	if board.Sorting != 0 {
 | |
| 		fieldToUpdate = append(fieldToUpdate, "sorting")
 | |
| 	}
 | |
| 
 | |
| 	if board.Title != "" {
 | |
| 		fieldToUpdate = append(fieldToUpdate, "title")
 | |
| 	}
 | |
| 
 | |
| 	if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) {
 | |
| 		return fmt.Errorf("bad color code: %s", board.Color)
 | |
| 	}
 | |
| 	fieldToUpdate = append(fieldToUpdate, "color")
 | |
| 
 | |
| 	_, err := db.GetEngine(ctx).ID(board.ID).Cols(fieldToUpdate...).Update(board)
 | |
| 
 | |
| 	return err
 | |
| }
 | |
| 
 | |
| // GetBoards fetches all boards related to a project
 | |
| func (p *Project) GetBoards(ctx context.Context) (BoardList, error) {
 | |
| 	boards := make([]*Board, 0, 5)
 | |
| 	if err := db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Find(&boards); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	return boards, nil
 | |
| }
 | |
| 
 | |
| // GetDefaultBoard return default board and ensure only one exists
 | |
| func (p *Project) GetDefaultBoard(ctx context.Context) (*Board, error) {
 | |
| 	var board Board
 | |
| 	has, err := db.GetEngine(ctx).
 | |
| 		Where("project_id=? AND `default` = ?", p.ID, true).
 | |
| 		Desc("id").Get(&board)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	if has {
 | |
| 		return &board, nil
 | |
| 	}
 | |
| 
 | |
| 	// create a default board if none is found
 | |
| 	board = Board{
 | |
| 		ProjectID: p.ID,
 | |
| 		Default:   true,
 | |
| 		Title:     "Uncategorized",
 | |
| 		CreatorID: p.CreatorID,
 | |
| 	}
 | |
| 	if _, err := db.GetEngine(ctx).Insert(&board); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	return &board, nil
 | |
| }
 | |
| 
 | |
| // SetDefaultBoard represents a board for issues not assigned to one
 | |
| func SetDefaultBoard(ctx context.Context, projectID, boardID int64) error {
 | |
| 	return db.WithTx(ctx, func(ctx context.Context) error {
 | |
| 		if _, err := GetBoard(ctx, boardID); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 
 | |
| 		if _, err := db.GetEngine(ctx).Where(builder.Eq{
 | |
| 			"project_id": projectID,
 | |
| 			"`default`":  true,
 | |
| 		}).Cols("`default`").Update(&Board{Default: false}); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 
 | |
| 		_, err := db.GetEngine(ctx).ID(boardID).
 | |
| 			Where(builder.Eq{"project_id": projectID}).
 | |
| 			Cols("`default`").Update(&Board{Default: true})
 | |
| 		return err
 | |
| 	})
 | |
| }
 | |
| 
 | |
| // UpdateBoardSorting update project board sorting
 | |
| func UpdateBoardSorting(ctx context.Context, bs BoardList) error {
 | |
| 	return db.WithTx(ctx, func(ctx context.Context) error {
 | |
| 		for i := range bs {
 | |
| 			if _, err := db.GetEngine(ctx).ID(bs[i].ID).Cols(
 | |
| 				"sorting",
 | |
| 			).Update(bs[i]); err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 		}
 | |
| 		return nil
 | |
| 	})
 | |
| }
 | |
| 
 | |
| func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (BoardList, error) {
 | |
| 	columns := make([]*Board, 0, 5)
 | |
| 	if err := db.GetEngine(ctx).
 | |
| 		Where("project_id =?", projectID).
 | |
| 		In("id", columnsIDs).
 | |
| 		OrderBy("sorting").Find(&columns); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	return columns, nil
 | |
| }
 | |
| 
 | |
| // MoveColumnsOnProject sorts columns in a project
 | |
| func MoveColumnsOnProject(ctx context.Context, project *Project, sortedColumnIDs map[int64]int64) error {
 | |
| 	return db.WithTx(ctx, func(ctx context.Context) error {
 | |
| 		sess := db.GetEngine(ctx)
 | |
| 		columnIDs := util.ValuesOfMap(sortedColumnIDs)
 | |
| 		movedColumns, err := GetColumnsByIDs(ctx, project.ID, columnIDs)
 | |
| 		if err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 		if len(movedColumns) != len(sortedColumnIDs) {
 | |
| 			return errors.New("some columns do not exist")
 | |
| 		}
 | |
| 
 | |
| 		for _, column := range movedColumns {
 | |
| 			if column.ProjectID != project.ID {
 | |
| 				return fmt.Errorf("column[%d]'s projectID is not equal to project's ID [%d]", column.ProjectID, project.ID)
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		for sorting, columnID := range sortedColumnIDs {
 | |
| 			if _, err := sess.Exec("UPDATE `project_board` SET sorting=? WHERE id=?", sorting, columnID); err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 		}
 | |
| 		return nil
 | |
| 	})
 | |
| }
 |