]> source.dussan.org Git - gitea.git/commitdiff
Fix various problems around projects board view (#30696) (#30902)
authorGiteabot <teabot@gitea.io>
Wed, 8 May 2024 15:46:21 +0000 (23:46 +0800)
committerGitHub <noreply@github.com>
Wed, 8 May 2024 15:46:21 +0000 (15:46 +0000)
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>
16 files changed:
models/db/engine.go
models/issues/issue_project.go
models/project/board.go
models/project/board_test.go
models/project/issue.go
models/project/project.go
routers/web/org/projects.go
routers/web/repo/projects.go
routers/web/repo/pull.go
routers/web/shared/project/column.go [new file with mode: 0644]
routers/web/web.go
services/issue/issue.go
templates/projects/view.tmpl
tests/integration/org_project_test.go
tests/integration/project_test.go
web_src/js/features/repo-projects.js

index 25f4066ea1380bf10189d4e11275f7ec025c1039..847ba58c2675c6c7aec35d936fa7220b81be88c6 100755 (executable)
@@ -57,6 +57,7 @@ type Engine interface {
        SumInt(bean any, columnName string) (res int64, err error)
        Sync(...any) error
        Select(string) *xorm.Session
+       SetExpr(string, any) *xorm.Session
        NotIn(string, ...any) *xorm.Session
        OrderBy(any, ...any) *xorm.Session
        Exist(...any) (bool, error)
index 907a5a17b9f20b65d417363323c61e530f2c2d05..e31d2ef1516234d7e5255ba3f0d72acaf02b7140 100644 (file)
@@ -5,11 +5,11 @@ package issues
 
 import (
        "context"
-       "fmt"
 
        "code.gitea.io/gitea/models/db"
        project_model "code.gitea.io/gitea/models/project"
        user_model "code.gitea.io/gitea/models/user"
+       "code.gitea.io/gitea/modules/util"
 )
 
 // LoadProject load the project the issue was assigned to
@@ -90,58 +90,73 @@ func LoadIssuesFromBoardList(ctx context.Context, bs project_model.BoardList) (m
        return issuesMap, nil
 }
 
-// ChangeProjectAssign changes the project associated with an issue
-func ChangeProjectAssign(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error {
-       ctx, committer, err := db.TxContext(ctx)
-       if err != nil {
-               return err
-       }
-       defer committer.Close()
-
-       if err := addUpdateIssueProject(ctx, issue, doer, newProjectID); err != nil {
-               return err
-       }
-
-       return committer.Commit()
-}
+// IssueAssignOrRemoveProject changes the project associated with an issue
+// If newProjectID is 0, the issue is removed from the project
+func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID, newColumnID int64) error {
+       return db.WithTx(ctx, func(ctx context.Context) error {
+               oldProjectID := issue.projectID(ctx)
 
-func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error {
-       oldProjectID := issue.projectID(ctx)
+               if err := issue.LoadRepo(ctx); err != nil {
+                       return err
+               }
 
-       if err := issue.LoadRepo(ctx); err != nil {
-               return err
-       }
+               // Only check if we add a new project and not remove it.
+               if newProjectID > 0 {
+                       newProject, err := project_model.GetProjectByID(ctx, newProjectID)
+                       if err != nil {
+                               return err
+                       }
+                       if !newProject.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) {
+                               return util.NewPermissionDeniedErrorf("issue %d can't be accessed by project %d", issue.ID, newProject.ID)
+                       }
+                       if newColumnID == 0 {
+                               newDefaultColumn, err := newProject.GetDefaultBoard(ctx)
+                               if err != nil {
+                                       return err
+                               }
+                               newColumnID = newDefaultColumn.ID
+                       }
+               }
 
-       // Only check if we add a new project and not remove it.
-       if newProjectID > 0 {
-               newProject, err := project_model.GetProjectByID(ctx, newProjectID)
-               if err != nil {
+               if _, err := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); err != nil {
                        return err
                }
-               if newProject.RepoID != issue.RepoID && newProject.OwnerID != issue.Repo.OwnerID {
-                       return fmt.Errorf("issue's repository is not the same as project's repository")
-               }
-       }
 
-       if _, err := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); err != nil {
-               return err
-       }
+               if oldProjectID > 0 || newProjectID > 0 {
+                       if _, err := CreateComment(ctx, &CreateCommentOptions{
+                               Type:         CommentTypeProject,
+                               Doer:         doer,
+                               Repo:         issue.Repo,
+                               Issue:        issue,
+                               OldProjectID: oldProjectID,
+                               ProjectID:    newProjectID,
+                       }); err != nil {
+                               return err
+                       }
+               }
+               if newProjectID == 0 {
+                       return nil
+               }
+               if newColumnID == 0 {
+                       panic("newColumnID must not be zero") // shouldn't happen
+               }
 
-       if oldProjectID > 0 || newProjectID > 0 {
-               if _, err := CreateComment(ctx, &CreateCommentOptions{
-                       Type:         CommentTypeProject,
-                       Doer:         doer,
-                       Repo:         issue.Repo,
-                       Issue:        issue,
-                       OldProjectID: oldProjectID,
-                       ProjectID:    newProjectID,
-               }); err != nil {
+               res := struct {
+                       MaxSorting int64
+                       IssueCount int64
+               }{}
+               if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue").
+                       Where("project_id=?", newProjectID).
+                       And("project_board_id=?", newColumnID).
+                       Get(&res); err != nil {
                        return err
                }
-       }
-
-       return db.Insert(ctx, &project_model.ProjectIssue{
-               IssueID:   issue.ID,
-               ProjectID: newProjectID,
+               newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
+               return db.Insert(ctx, &project_model.ProjectIssue{
+                       IssueID:        issue.ID,
+                       ProjectID:      newProjectID,
+                       ProjectBoardID: newColumnID,
+                       Sorting:        newSorting,
+               })
        })
 }
index 7faabc52c58bf0f7ee1017ce3a1cd14acef27c43..a52baa0c185f27519c6db1dc658674f1ce3e2500 100644 (file)
@@ -5,12 +5,14 @@ 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"
 )
@@ -82,6 +84,17 @@ func (b *Board) NumIssues(ctx context.Context) int {
        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))
 }
@@ -150,12 +163,27 @@ func createBoardsForProjectsType(ctx context.Context, project *Project) error {
        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
 }
@@ -189,7 +217,17 @@ func deleteBoardByID(ctx context.Context, boardID int64) error {
                return fmt.Errorf("deleteBoardByID: cannot delete default board")
        }
 
-       if err = board.removeIssues(ctx); err != nil {
+       // 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
        }
 
@@ -242,21 +280,15 @@ func UpdateBoard(ctx context.Context, board *Board) error {
 // 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=? AND `default`=?", p.ID, false).OrderBy("sorting").Find(&boards); err != nil {
+       if err := db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Find(&boards); err != nil {
                return nil, err
        }
 
-       defaultB, err := p.getDefaultBoard(ctx)
-       if err != nil {
-               return nil, err
-       }
-
-       return append([]*Board{defaultB}, boards...), nil
+       return boards, nil
 }
 
-// getDefaultBoard return default board and ensure only one exists
-func (p *Project) getDefaultBoard(ctx context.Context) (*Board, error) {
+// 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).
@@ -316,3 +348,42 @@ func UpdateBoardSorting(ctx context.Context, bs BoardList) error {
                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
+       })
+}
index 71ba29a5896dcbbd978726268fb8db5381cbf4f4..da922ff7adaaa6b760333d2c56565d86b432c035 100644 (file)
@@ -4,6 +4,8 @@
 package project
 
 import (
+       "fmt"
+       "strings"
        "testing"
 
        "code.gitea.io/gitea/models/db"
@@ -19,7 +21,7 @@ func TestGetDefaultBoard(t *testing.T) {
        assert.NoError(t, err)
 
        // check if default board was added
-       board, err := projectWithoutDefault.getDefaultBoard(db.DefaultContext)
+       board, err := projectWithoutDefault.GetDefaultBoard(db.DefaultContext)
        assert.NoError(t, err)
        assert.Equal(t, int64(5), board.ProjectID)
        assert.Equal(t, "Uncategorized", board.Title)
@@ -28,7 +30,7 @@ func TestGetDefaultBoard(t *testing.T) {
        assert.NoError(t, err)
 
        // check if multiple defaults were removed
-       board, err = projectWithMultipleDefaults.getDefaultBoard(db.DefaultContext)
+       board, err = projectWithMultipleDefaults.GetDefaultBoard(db.DefaultContext)
        assert.NoError(t, err)
        assert.Equal(t, int64(6), board.ProjectID)
        assert.Equal(t, int64(9), board.ID)
@@ -42,3 +44,84 @@ func TestGetDefaultBoard(t *testing.T) {
        assert.Equal(t, int64(6), board.ProjectID)
        assert.False(t, board.Default)
 }
+
+func Test_moveIssuesToAnotherColumn(t *testing.T) {
+       assert.NoError(t, unittest.PrepareTestDatabase())
+
+       column1 := unittest.AssertExistsAndLoadBean(t, &Board{ID: 1, ProjectID: 1})
+
+       issues, err := column1.GetIssues(db.DefaultContext)
+       assert.NoError(t, err)
+       assert.Len(t, issues, 1)
+       assert.EqualValues(t, 1, issues[0].ID)
+
+       column2 := unittest.AssertExistsAndLoadBean(t, &Board{ID: 2, ProjectID: 1})
+       issues, err = column2.GetIssues(db.DefaultContext)
+       assert.NoError(t, err)
+       assert.Len(t, issues, 1)
+       assert.EqualValues(t, 3, issues[0].ID)
+
+       err = column1.moveIssuesToAnotherColumn(db.DefaultContext, column2)
+       assert.NoError(t, err)
+
+       issues, err = column1.GetIssues(db.DefaultContext)
+       assert.NoError(t, err)
+       assert.Len(t, issues, 0)
+
+       issues, err = column2.GetIssues(db.DefaultContext)
+       assert.NoError(t, err)
+       assert.Len(t, issues, 2)
+       assert.EqualValues(t, 3, issues[0].ID)
+       assert.EqualValues(t, 0, issues[0].Sorting)
+       assert.EqualValues(t, 1, issues[1].ID)
+       assert.EqualValues(t, 1, issues[1].Sorting)
+}
+
+func Test_MoveColumnsOnProject(t *testing.T) {
+       assert.NoError(t, unittest.PrepareTestDatabase())
+
+       project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
+       columns, err := project1.GetBoards(db.DefaultContext)
+       assert.NoError(t, err)
+       assert.Len(t, columns, 3)
+       assert.EqualValues(t, 0, columns[0].Sorting) // even if there is no default sorting, the code should also work
+       assert.EqualValues(t, 0, columns[1].Sorting)
+       assert.EqualValues(t, 0, columns[2].Sorting)
+
+       err = MoveColumnsOnProject(db.DefaultContext, project1, map[int64]int64{
+               0: columns[1].ID,
+               1: columns[2].ID,
+               2: columns[0].ID,
+       })
+       assert.NoError(t, err)
+
+       columnsAfter, err := project1.GetBoards(db.DefaultContext)
+       assert.NoError(t, err)
+       assert.Len(t, columnsAfter, 3)
+       assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID)
+       assert.EqualValues(t, columns[2].ID, columnsAfter[1].ID)
+       assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID)
+}
+
+func Test_NewBoard(t *testing.T) {
+       assert.NoError(t, unittest.PrepareTestDatabase())
+
+       project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
+       columns, err := project1.GetBoards(db.DefaultContext)
+       assert.NoError(t, err)
+       assert.Len(t, columns, 3)
+
+       for i := 0; i < maxProjectColumns-3; i++ {
+               err := NewBoard(db.DefaultContext, &Board{
+                       Title:     fmt.Sprintf("board-%d", i+4),
+                       ProjectID: project1.ID,
+               })
+               assert.NoError(t, err)
+       }
+       err = NewBoard(db.DefaultContext, &Board{
+               Title:     "board-21",
+               ProjectID: project1.ID,
+       })
+       assert.Error(t, err)
+       assert.True(t, strings.Contains(err.Error(), "maximum number of columns reached"))
+}
index ebc9719de55d0fd318fa8e844eb5077fbb463fd8..32e72e909d5ca57349eec2f1a3b91fb405a43688 100644 (file)
@@ -9,6 +9,7 @@ import (
 
        "code.gitea.io/gitea/models/db"
        "code.gitea.io/gitea/modules/log"
+       "code.gitea.io/gitea/modules/util"
 )
 
 // ProjectIssue saves relation from issue to a project
@@ -17,7 +18,7 @@ type ProjectIssue struct { //revive:disable-line:exported
        IssueID   int64 `xorm:"INDEX"`
        ProjectID int64 `xorm:"INDEX"`
 
-       // If 0, then it has not been added to a specific board in the project
+       // ProjectBoardID should not be zero since 1.22. If it's zero, the issue will not be displayed on UI and it might result in errors.
        ProjectBoardID int64 `xorm:"INDEX"`
 
        // the sorting order on the board
@@ -79,11 +80,8 @@ func (p *Project) NumOpenIssues(ctx context.Context) int {
 func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs map[int64]int64) error {
        return db.WithTx(ctx, func(ctx context.Context) error {
                sess := db.GetEngine(ctx)
+               issueIDs := util.ValuesOfMap(sortedIssueIDs)
 
-               issueIDs := make([]int64, 0, len(sortedIssueIDs))
-               for _, issueID := range sortedIssueIDs {
-                       issueIDs = append(issueIDs, issueID)
-               }
                count, err := sess.Table(new(ProjectIssue)).Where("project_id=?", board.ProjectID).In("issue_id", issueIDs).Count()
                if err != nil {
                        return err
@@ -102,7 +100,44 @@ func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs
        })
 }
 
-func (b *Board) removeIssues(ctx context.Context) error {
-       _, err := db.GetEngine(ctx).Exec("UPDATE `project_issue` SET project_board_id = 0 WHERE project_board_id = ? ", b.ID)
-       return err
+func (b *Board) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Board) error {
+       if b.ProjectID != newColumn.ProjectID {
+               return fmt.Errorf("columns have to be in the same project")
+       }
+
+       if b.ID == newColumn.ID {
+               return nil
+       }
+
+       res := struct {
+               MaxSorting int64
+               IssueCount int64
+       }{}
+       if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").
+               Table("project_issue").
+               Where("project_id=?", newColumn.ProjectID).
+               And("project_board_id=?", newColumn.ID).
+               Get(&res); err != nil {
+               return err
+       }
+
+       issues, err := b.GetIssues(ctx)
+       if err != nil {
+               return err
+       }
+       if len(issues) == 0 {
+               return nil
+       }
+
+       nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
+       return db.WithTx(ctx, func(ctx context.Context) error {
+               for i, issue := range issues {
+                       issue.ProjectBoardID = newColumn.ID
+                       issue.Sorting = nextSorting + int64(i)
+                       if _, err := db.GetEngine(ctx).ID(issue.ID).Cols("project_board_id", "sorting").Update(issue); err != nil {
+                               return err
+                       }
+               }
+               return nil
+       })
 }
index 8f9ee2a99e9c7ff49964db0986c9628a173fb048..8be38694c52238f7f01310d067910f09fd002ff5 100644 (file)
@@ -161,6 +161,13 @@ func (p *Project) IsRepositoryProject() bool {
        return p.Type == TypeRepository
 }
 
+func (p *Project) CanBeAccessedByOwnerRepo(ownerID int64, repo *repo_model.Repository) bool {
+       if p.Type == TypeRepository {
+               return repo != nil && p.RepoID == repo.ID // if a project belongs to a repository, then its OwnerID is 0 and can be ignored
+       }
+       return p.OwnerID == ownerID && p.RepoID == 0
+}
+
 func init() {
        db.RegisterModel(new(Project))
 }
index 7f78d1c830b7f36e16ab865b8cbc2a850d8d4e7f..50effbe9633f38a1b7a8b08bb0eaa9511708b69d 100644 (file)
@@ -7,7 +7,6 @@ import (
        "errors"
        "fmt"
        "net/http"
-       "strconv"
        "strings"
 
        "code.gitea.io/gitea/models/db"
@@ -390,74 +389,6 @@ func ViewProject(ctx *context.Context) {
        ctx.HTML(http.StatusOK, tplProjectsView)
 }
 
-func getActionIssues(ctx *context.Context) issues_model.IssueList {
-       commaSeparatedIssueIDs := ctx.FormString("issue_ids")
-       if len(commaSeparatedIssueIDs) == 0 {
-               return nil
-       }
-       issueIDs := make([]int64, 0, 10)
-       for _, stringIssueID := range strings.Split(commaSeparatedIssueIDs, ",") {
-               issueID, err := strconv.ParseInt(stringIssueID, 10, 64)
-               if err != nil {
-                       ctx.ServerError("ParseInt", err)
-                       return nil
-               }
-               issueIDs = append(issueIDs, issueID)
-       }
-       issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
-       if err != nil {
-               ctx.ServerError("GetIssuesByIDs", err)
-               return nil
-       }
-       // Check access rights for all issues
-       issueUnitEnabled := ctx.Repo.CanRead(unit.TypeIssues)
-       prUnitEnabled := ctx.Repo.CanRead(unit.TypePullRequests)
-       for _, issue := range issues {
-               if issue.RepoID != ctx.Repo.Repository.ID {
-                       ctx.NotFound("some issue's RepoID is incorrect", errors.New("some issue's RepoID is incorrect"))
-                       return nil
-               }
-               if issue.IsPull && !prUnitEnabled || !issue.IsPull && !issueUnitEnabled {
-                       ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil)
-                       return nil
-               }
-               if err = issue.LoadAttributes(ctx); err != nil {
-                       ctx.ServerError("LoadAttributes", err)
-                       return nil
-               }
-       }
-       return issues
-}
-
-// UpdateIssueProject change an issue's project
-func UpdateIssueProject(ctx *context.Context) {
-       issues := getActionIssues(ctx)
-       if ctx.Written() {
-               return
-       }
-
-       if err := issues.LoadProjects(ctx); err != nil {
-               ctx.ServerError("LoadProjects", err)
-               return
-       }
-
-       projectID := ctx.FormInt64("id")
-       for _, issue := range issues {
-               if issue.Project != nil {
-                       if issue.Project.ID == projectID {
-                               continue
-                       }
-               }
-
-               if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil {
-                       ctx.ServerError("ChangeProjectAssign", err)
-                       return
-               }
-       }
-
-       ctx.JSONOK()
-}
-
 // DeleteProjectBoard allows for the deletion of a project board
 func DeleteProjectBoard(ctx *context.Context) {
        if ctx.Doer == nil {
index 9b765e89e877fd06ce54931d3b6ba347d26962a9..6186ee150cbf24363245ebb6eef5f111772104df 100644 (file)
@@ -21,6 +21,7 @@ import (
        "code.gitea.io/gitea/modules/markup/markdown"
        "code.gitea.io/gitea/modules/optional"
        "code.gitea.io/gitea/modules/setting"
+       "code.gitea.io/gitea/modules/util"
        "code.gitea.io/gitea/modules/web"
        "code.gitea.io/gitea/services/context"
        "code.gitea.io/gitea/services/forms"
@@ -383,17 +384,21 @@ func UpdateIssueProject(ctx *context.Context) {
                ctx.ServerError("LoadProjects", err)
                return
        }
+       if _, err := issues.LoadRepositories(ctx); err != nil {
+               ctx.ServerError("LoadProjects", err)
+               return
+       }
 
        projectID := ctx.FormInt64("id")
        for _, issue := range issues {
-               if issue.Project != nil {
-                       if issue.Project.ID == projectID {
+               if issue.Project != nil && issue.Project.ID == projectID {
+                       continue
+               }
+               if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil {
+                       if errors.Is(err, util.ErrPermissionDenied) {
                                continue
                        }
-               }
-
-               if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil {
-                       ctx.ServerError("ChangeProjectAssign", err)
+                       ctx.ServerError("IssueAssignOrRemoveProject", err)
                        return
                }
        }
index 7f131f2e984bcc1e15c5284bcab5d7bbdf726852..7041175d9ae16e8e24f9251bc5ba44768d7197cf 100644 (file)
@@ -1329,14 +1329,12 @@ func CompareAndPullRequestPost(ctx *context.Context) {
                return
        }
 
-       if projectID > 0 {
-               if !ctx.Repo.CanWrite(unit.TypeProjects) {
-                       ctx.Error(http.StatusBadRequest, "user hasn't the permission to write to projects")
-                       return
-               }
-               if err := issues_model.ChangeProjectAssign(ctx, pullIssue, ctx.Doer, projectID); err != nil {
-                       ctx.ServerError("ChangeProjectAssign", err)
-                       return
+       if projectID > 0 && ctx.Repo.CanWrite(unit.TypeProjects) {
+               if err := issues_model.IssueAssignOrRemoveProject(ctx, pullIssue, ctx.Doer, projectID, 0); err != nil {
+                       if !errors.Is(err, util.ErrPermissionDenied) {
+                               ctx.ServerError("IssueAssignOrRemoveProject", err)
+                               return
+                       }
                }
        }
 
diff --git a/routers/web/shared/project/column.go b/routers/web/shared/project/column.go
new file mode 100644 (file)
index 0000000..599842e
--- /dev/null
@@ -0,0 +1,48 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package project
+
+import (
+       project_model "code.gitea.io/gitea/models/project"
+       "code.gitea.io/gitea/modules/json"
+       "code.gitea.io/gitea/services/context"
+)
+
+// MoveColumns moves or keeps columns in a project and sorts them inside that project
+func MoveColumns(ctx *context.Context) {
+       project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+       if err != nil {
+               ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
+               return
+       }
+       if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) {
+               ctx.NotFound("CanBeAccessedByOwnerRepo", nil)
+               return
+       }
+
+       type movedColumnsForm struct {
+               Columns []struct {
+                       ColumnID int64 `json:"columnID"`
+                       Sorting  int64 `json:"sorting"`
+               } `json:"columns"`
+       }
+
+       form := &movedColumnsForm{}
+       if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
+               ctx.ServerError("DecodeMovedColumnsForm", err)
+               return
+       }
+
+       sortedColumnIDs := make(map[int64]int64)
+       for _, column := range form.Columns {
+               sortedColumnIDs[column.Sorting] = column.ColumnID
+       }
+
+       if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil {
+               ctx.ServerError("MoveColumnsOnProject", err)
+               return
+       }
+
+       ctx.JSONOK()
+}
index 91ab378d97c52257844ac13a4ce481f01b87afe8..e1482c1e4ada5287f9770526c4bd72ce2f708edc 100644 (file)
@@ -37,6 +37,7 @@ import (
        "code.gitea.io/gitea/routers/web/repo"
        "code.gitea.io/gitea/routers/web/repo/actions"
        repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
+       "code.gitea.io/gitea/routers/web/shared/project"
        "code.gitea.io/gitea/routers/web/user"
        user_setting "code.gitea.io/gitea/routers/web/user/setting"
        "code.gitea.io/gitea/routers/web/user/setting/security"
@@ -999,6 +1000,7 @@ func registerRoutes(m *web.Route) {
                                m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost)
                                m.Group("/{id}", func() {
                                        m.Post("", web.Bind(forms.EditProjectBoardForm{}), org.AddBoardToProjectPost)
+                                       m.Post("/move", project.MoveColumns)
                                        m.Post("/delete", org.DeleteProject)
 
                                        m.Get("/edit", org.RenderEditProject)
@@ -1354,6 +1356,7 @@ func registerRoutes(m *web.Route) {
                        m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost)
                        m.Group("/{id}", func() {
                                m.Post("", web.Bind(forms.EditProjectBoardForm{}), repo.AddBoardToProjectPost)
+                               m.Post("/move", project.MoveColumns)
                                m.Post("/delete", repo.DeleteProject)
 
                                m.Get("/edit", repo.RenderEditProject)
index b0e50f2b89a1b2376ec2fe28d52bfbd31bb5e87e..72ea66c8d98c5a5499ee19d7b840573c8eaf5e18 100644 (file)
@@ -42,7 +42,7 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo
                        }
                }
                if projectID > 0 {
-                       if err := issues_model.ChangeProjectAssign(ctx, issue, issue.Poster, projectID); err != nil {
+                       if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectID, 0); err != nil {
                                return err
                        }
                }
index 3e000660e2859964b62e071da6edba32e27c61bc..47f214a44e0d4ca14cc90fd86687a799898a53a5 100644 (file)
@@ -64,7 +64,7 @@
 </div>
 
 <div id="project-board">
-       <div class="board {{if .CanWriteProjects}}sortable{{end}}">
+       <div class="board {{if .CanWriteProjects}}sortable{{end}}"{{if .CanWriteProjects}} data-url="{{$.Link}}/move"{{end}}>
                {{range .Columns}}
                        <div class="ui segment project-column"{{if .Color}} style="background: {{.Color}} !important; color: {{ContrastColor .Color}} !important"{{end}} data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
                                <div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}">
index a14004f6b02881f10832f89a55c789f9b85598cb..ca39cf51302085d1fc132b66172a774d110e2232 100644 (file)
@@ -5,17 +5,17 @@ package integration
 
 import (
        "net/http"
+       "slices"
        "testing"
 
        unit_model "code.gitea.io/gitea/models/unit"
+       "code.gitea.io/gitea/modules/test"
        "code.gitea.io/gitea/tests"
 )
 
 func TestOrgProjectAccess(t *testing.T) {
        defer tests.PrepareTestEnv(t)()
-
-       // disable repo project unit
-       unit_model.DisabledRepoUnits = []unit_model.Type{unit_model.TypeProjects}
+       defer test.MockVariableValue(&unit_model.DisabledRepoUnits, append(slices.Clone(unit_model.DisabledRepoUnits), unit_model.TypeProjects))()
 
        // repo project, 404
        req := NewRequest(t, "GET", "/user2/repo1/projects")
index 45061c5b24f1f637214a9b19ad2b873269ab7b93..1d9c3aae53613739df90246d9831c66ed24ce241 100644 (file)
@@ -4,10 +4,18 @@
 package integration
 
 import (
+       "fmt"
        "net/http"
        "testing"
 
+       "code.gitea.io/gitea/models/db"
+       project_model "code.gitea.io/gitea/models/project"
+       repo_model "code.gitea.io/gitea/models/repo"
+       "code.gitea.io/gitea/models/unit"
+       "code.gitea.io/gitea/models/unittest"
        "code.gitea.io/gitea/tests"
+
+       "github.com/stretchr/testify/assert"
 )
 
 func TestPrivateRepoProject(t *testing.T) {
@@ -21,3 +29,59 @@ func TestPrivateRepoProject(t *testing.T) {
        req = NewRequest(t, "GET", "/user31/-/projects")
        sess.MakeRequest(t, req, http.StatusOK)
 }
+
+func TestMoveRepoProjectColumns(t *testing.T) {
+       defer tests.PrepareTestEnv(t)()
+
+       repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+
+       projectsUnit := repo2.MustGetUnit(db.DefaultContext, unit.TypeProjects)
+       assert.True(t, projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo))
+
+       project1 := project_model.Project{
+               Title:     "new created project",
+               RepoID:    repo2.ID,
+               Type:      project_model.TypeRepository,
+               BoardType: project_model.BoardTypeNone,
+       }
+       err := project_model.NewProject(db.DefaultContext, &project1)
+       assert.NoError(t, err)
+
+       for i := 0; i < 3; i++ {
+               err = project_model.NewBoard(db.DefaultContext, &project_model.Board{
+                       Title:     fmt.Sprintf("column %d", i+1),
+                       ProjectID: project1.ID,
+               })
+               assert.NoError(t, err)
+       }
+
+       columns, err := project1.GetBoards(db.DefaultContext)
+       assert.NoError(t, err)
+       assert.Len(t, columns, 3)
+       assert.EqualValues(t, 0, columns[0].Sorting)
+       assert.EqualValues(t, 1, columns[1].Sorting)
+       assert.EqualValues(t, 2, columns[2].Sorting)
+
+       sess := loginUser(t, "user1")
+       req := NewRequest(t, "GET", fmt.Sprintf("/%s/projects/%d", repo2.FullName(), project1.ID))
+       resp := sess.MakeRequest(t, req, http.StatusOK)
+       htmlDoc := NewHTMLParser(t, resp.Body)
+
+       req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/projects/%d/move?_csrf="+htmlDoc.GetCSRF(), repo2.FullName(), project1.ID), map[string]any{
+               "columns": []map[string]any{
+                       {"columnID": columns[1].ID, "sorting": 0},
+                       {"columnID": columns[2].ID, "sorting": 1},
+                       {"columnID": columns[0].ID, "sorting": 2},
+               },
+       })
+       sess.MakeRequest(t, req, http.StatusOK)
+
+       columnsAfter, err := project1.GetBoards(db.DefaultContext)
+       assert.NoError(t, err)
+       assert.Len(t, columns, 3)
+       assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID)
+       assert.EqualValues(t, columns[2].ID, columnsAfter[1].ID)
+       assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID)
+
+       assert.NoError(t, project_model.DeleteProjectByID(db.DefaultContext, project1.ID))
+}
index a869c24c823adb4879ab8656c2a019eb278b86ee..a1cc4b346bb8b22fb87b951919786fd47bbdfe6d 100644 (file)
@@ -2,7 +2,6 @@ import $ from 'jquery';
 import {contrastColor} from '../utils/color.js';
 import {createSortable} from '../modules/sortable.js';
 import {POST, DELETE, PUT} from '../modules/fetch.js';
-import tinycolor from 'tinycolor2';
 
 function updateIssueCount(cards) {
   const parent = cards.parentElement;
@@ -63,17 +62,20 @@ async function initRepoProjectSortable() {
     delay: 500,
     onSort: async () => {
       boardColumns = mainBoard.getElementsByClassName('project-column');
-      for (let i = 0; i < boardColumns.length; i++) {
-        const column = boardColumns[i];
-        if (parseInt(column.getAttribute('data-sorting')) !== i) {
-          try {
-            const bgColor = column.style.backgroundColor; // will be rgb() string
-            const color = bgColor ? tinycolor(bgColor).toHexString() : '';
-            await PUT(column.getAttribute('data-url'), {data: {sorting: i, color}});
-          } catch (error) {
-            console.error(error);
-          }
-        }
+
+      const columnSorting = {
+        columns: Array.from(boardColumns, (column, i) => ({
+          columnID: parseInt(column.getAttribute('data-id')),
+          sorting: i,
+        })),
+      };
+
+      try {
+        await POST(mainBoard.getAttribute('data-url'), {
+          data: columnSorting,
+        });
+      } catch (error) {
+        console.error(error);
       }
     },
   });