loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix various problems around projects board view (#30696)

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.

- [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: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
(cherry picked from commit a303c973e0264dab45a787c4afa200e183e0d953)

Conflicts:
routers/web/web.go
e91733468ef726fc9365aa4820cdd5f2ddfdaa23 Add missing database transaction for new issue (#29490) was not cherry-picked
services/issue/issue.go
fe6792dff3 Enable/disable owner and repo projects independently (#28805) was not cherry-picked

authored by

Lunny Xiao
silverwind
wxiaoguang
and committed by
Earl Warren
7d3ca90d 9bc39125

+427 -168
+1
models/db/engine.go
··· 58 58 SumInt(bean any, columnName string) (res int64, err error) 59 59 Sync(...any) error 60 60 Select(string) *xorm.Session 61 + SetExpr(string, any) *xorm.Session 61 62 NotIn(string, ...any) *xorm.Session 62 63 OrderBy(any, ...any) *xorm.Session 63 64 Exist(...any) (bool, error)
+60 -45
models/issues/issue_project.go
··· 5 5 6 6 import ( 7 7 "context" 8 - "fmt" 9 8 10 9 "code.gitea.io/gitea/models/db" 11 10 project_model "code.gitea.io/gitea/models/project" 12 11 user_model "code.gitea.io/gitea/models/user" 12 + "code.gitea.io/gitea/modules/util" 13 13 ) 14 14 15 15 // LoadProject load the project the issue was assigned to ··· 90 90 return issuesMap, nil 91 91 } 92 92 93 - // ChangeProjectAssign changes the project associated with an issue 94 - func ChangeProjectAssign(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error { 95 - ctx, committer, err := db.TxContext(ctx) 96 - if err != nil { 97 - return err 98 - } 99 - defer committer.Close() 93 + // IssueAssignOrRemoveProject changes the project associated with an issue 94 + // If newProjectID is 0, the issue is removed from the project 95 + func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID, newColumnID int64) error { 96 + return db.WithTx(ctx, func(ctx context.Context) error { 97 + oldProjectID := issue.projectID(ctx) 100 98 101 - if err := addUpdateIssueProject(ctx, issue, doer, newProjectID); err != nil { 102 - return err 103 - } 99 + if err := issue.LoadRepo(ctx); err != nil { 100 + return err 101 + } 104 102 105 - return committer.Commit() 106 - } 103 + // Only check if we add a new project and not remove it. 104 + if newProjectID > 0 { 105 + newProject, err := project_model.GetProjectByID(ctx, newProjectID) 106 + if err != nil { 107 + return err 108 + } 109 + if !newProject.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) { 110 + return util.NewPermissionDeniedErrorf("issue %d can't be accessed by project %d", issue.ID, newProject.ID) 111 + } 112 + if newColumnID == 0 { 113 + newDefaultColumn, err := newProject.GetDefaultBoard(ctx) 114 + if err != nil { 115 + return err 116 + } 117 + newColumnID = newDefaultColumn.ID 118 + } 119 + } 107 120 108 - func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error { 109 - oldProjectID := issue.projectID(ctx) 121 + if _, err := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); err != nil { 122 + return err 123 + } 110 124 111 - if err := issue.LoadRepo(ctx); err != nil { 112 - return err 113 - } 114 - 115 - // Only check if we add a new project and not remove it. 116 - if newProjectID > 0 { 117 - newProject, err := project_model.GetProjectByID(ctx, newProjectID) 118 - if err != nil { 119 - return err 125 + if oldProjectID > 0 || newProjectID > 0 { 126 + if _, err := CreateComment(ctx, &CreateCommentOptions{ 127 + Type: CommentTypeProject, 128 + Doer: doer, 129 + Repo: issue.Repo, 130 + Issue: issue, 131 + OldProjectID: oldProjectID, 132 + ProjectID: newProjectID, 133 + }); err != nil { 134 + return err 135 + } 120 136 } 121 - if newProject.RepoID != issue.RepoID && newProject.OwnerID != issue.Repo.OwnerID { 122 - return fmt.Errorf("issue's repository is not the same as project's repository") 137 + if newProjectID == 0 { 138 + return nil 139 + } 140 + if newColumnID == 0 { 141 + panic("newColumnID must not be zero") // shouldn't happen 123 142 } 124 - } 125 143 126 - if _, err := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); err != nil { 127 - return err 128 - } 129 - 130 - if oldProjectID > 0 || newProjectID > 0 { 131 - if _, err := CreateComment(ctx, &CreateCommentOptions{ 132 - Type: CommentTypeProject, 133 - Doer: doer, 134 - Repo: issue.Repo, 135 - Issue: issue, 136 - OldProjectID: oldProjectID, 137 - ProjectID: newProjectID, 138 - }); err != nil { 144 + res := struct { 145 + MaxSorting int64 146 + IssueCount int64 147 + }{} 148 + if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue"). 149 + Where("project_id=?", newProjectID). 150 + And("project_board_id=?", newColumnID). 151 + Get(&res); err != nil { 139 152 return err 140 153 } 141 - } 142 - 143 - return db.Insert(ctx, &project_model.ProjectIssue{ 144 - IssueID: issue.ID, 145 - ProjectID: newProjectID, 154 + newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) 155 + return db.Insert(ctx, &project_model.ProjectIssue{ 156 + IssueID: issue.ID, 157 + ProjectID: newProjectID, 158 + ProjectBoardID: newColumnID, 159 + Sorting: newSorting, 160 + }) 146 161 }) 147 162 }
+83 -12
models/project/board.go
··· 5 5 6 6 import ( 7 7 "context" 8 + "errors" 8 9 "fmt" 9 10 "regexp" 10 11 11 12 "code.gitea.io/gitea/models/db" 12 13 "code.gitea.io/gitea/modules/setting" 13 14 "code.gitea.io/gitea/modules/timeutil" 15 + "code.gitea.io/gitea/modules/util" 14 16 15 17 "xorm.io/builder" 16 18 ) ··· 82 84 return int(c) 83 85 } 84 86 87 + func (b *Board) GetIssues(ctx context.Context) ([]*ProjectIssue, error) { 88 + issues := make([]*ProjectIssue, 0, 5) 89 + if err := db.GetEngine(ctx).Where("project_id=?", b.ProjectID). 90 + And("project_board_id=?", b.ID). 91 + OrderBy("sorting, id"). 92 + Find(&issues); err != nil { 93 + return nil, err 94 + } 95 + return issues, nil 96 + } 97 + 85 98 func init() { 86 99 db.RegisterModel(new(Board)) 87 100 } ··· 150 163 return db.Insert(ctx, boards) 151 164 } 152 165 166 + // maxProjectColumns max columns allowed in a project, this should not bigger than 127 167 + // because sorting is int8 in database 168 + const maxProjectColumns = 20 169 + 153 170 // NewBoard adds a new project board to a given project 154 171 func NewBoard(ctx context.Context, board *Board) error { 155 172 if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) { 156 173 return fmt.Errorf("bad color code: %s", board.Color) 157 174 } 158 - 175 + res := struct { 176 + MaxSorting int64 177 + ColumnCount int64 178 + }{} 179 + if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as column_count").Table("project_board"). 180 + Where("project_id=?", board.ProjectID).Get(&res); err != nil { 181 + return err 182 + } 183 + if res.ColumnCount >= maxProjectColumns { 184 + return fmt.Errorf("NewBoard: maximum number of columns reached") 185 + } 186 + board.Sorting = int8(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0)) 159 187 _, err := db.GetEngine(ctx).Insert(board) 160 188 return err 161 189 } ··· 189 217 return fmt.Errorf("deleteBoardByID: cannot delete default board") 190 218 } 191 219 192 - if err = board.removeIssues(ctx); err != nil { 220 + // move all issues to the default column 221 + project, err := GetProjectByID(ctx, board.ProjectID) 222 + if err != nil { 223 + return err 224 + } 225 + defaultColumn, err := project.GetDefaultBoard(ctx) 226 + if err != nil { 227 + return err 228 + } 229 + 230 + if err = board.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil { 193 231 return err 194 232 } 195 233 ··· 242 280 // GetBoards fetches all boards related to a project 243 281 func (p *Project) GetBoards(ctx context.Context) (BoardList, error) { 244 282 boards := make([]*Board, 0, 5) 245 - 246 - if err := db.GetEngine(ctx).Where("project_id=? AND `default`=?", p.ID, false).OrderBy("sorting").Find(&boards); err != nil { 283 + if err := db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Find(&boards); err != nil { 247 284 return nil, err 248 285 } 249 286 250 - defaultB, err := p.getDefaultBoard(ctx) 251 - if err != nil { 252 - return nil, err 253 - } 254 - 255 - return append([]*Board{defaultB}, boards...), nil 287 + return boards, nil 256 288 } 257 289 258 - // getDefaultBoard return default board and ensure only one exists 259 - func (p *Project) getDefaultBoard(ctx context.Context) (*Board, error) { 290 + // GetDefaultBoard return default board and ensure only one exists 291 + func (p *Project) GetDefaultBoard(ctx context.Context) (*Board, error) { 260 292 var board Board 261 293 has, err := db.GetEngine(ctx). 262 294 Where("project_id=? AND `default` = ?", p.ID, true). ··· 316 348 return nil 317 349 }) 318 350 } 351 + 352 + func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (BoardList, error) { 353 + columns := make([]*Board, 0, 5) 354 + if err := db.GetEngine(ctx). 355 + Where("project_id =?", projectID). 356 + In("id", columnsIDs). 357 + OrderBy("sorting").Find(&columns); err != nil { 358 + return nil, err 359 + } 360 + return columns, nil 361 + } 362 + 363 + // MoveColumnsOnProject sorts columns in a project 364 + func MoveColumnsOnProject(ctx context.Context, project *Project, sortedColumnIDs map[int64]int64) error { 365 + return db.WithTx(ctx, func(ctx context.Context) error { 366 + sess := db.GetEngine(ctx) 367 + columnIDs := util.ValuesOfMap(sortedColumnIDs) 368 + movedColumns, err := GetColumnsByIDs(ctx, project.ID, columnIDs) 369 + if err != nil { 370 + return err 371 + } 372 + if len(movedColumns) != len(sortedColumnIDs) { 373 + return errors.New("some columns do not exist") 374 + } 375 + 376 + for _, column := range movedColumns { 377 + if column.ProjectID != project.ID { 378 + return fmt.Errorf("column[%d]'s projectID is not equal to project's ID [%d]", column.ProjectID, project.ID) 379 + } 380 + } 381 + 382 + for sorting, columnID := range sortedColumnIDs { 383 + if _, err := sess.Exec("UPDATE `project_board` SET sorting=? WHERE id=?", sorting, columnID); err != nil { 384 + return err 385 + } 386 + } 387 + return nil 388 + }) 389 + }
+85 -2
models/project/board_test.go
··· 4 4 package project 5 5 6 6 import ( 7 + "fmt" 8 + "strings" 7 9 "testing" 8 10 9 11 "code.gitea.io/gitea/models/db" ··· 19 21 assert.NoError(t, err) 20 22 21 23 // check if default board was added 22 - board, err := projectWithoutDefault.getDefaultBoard(db.DefaultContext) 24 + board, err := projectWithoutDefault.GetDefaultBoard(db.DefaultContext) 23 25 assert.NoError(t, err) 24 26 assert.Equal(t, int64(5), board.ProjectID) 25 27 assert.Equal(t, "Uncategorized", board.Title) ··· 28 30 assert.NoError(t, err) 29 31 30 32 // check if multiple defaults were removed 31 - board, err = projectWithMultipleDefaults.getDefaultBoard(db.DefaultContext) 33 + board, err = projectWithMultipleDefaults.GetDefaultBoard(db.DefaultContext) 32 34 assert.NoError(t, err) 33 35 assert.Equal(t, int64(6), board.ProjectID) 34 36 assert.Equal(t, int64(9), board.ID) ··· 42 44 assert.Equal(t, int64(6), board.ProjectID) 43 45 assert.False(t, board.Default) 44 46 } 47 + 48 + func Test_moveIssuesToAnotherColumn(t *testing.T) { 49 + assert.NoError(t, unittest.PrepareTestDatabase()) 50 + 51 + column1 := unittest.AssertExistsAndLoadBean(t, &Board{ID: 1, ProjectID: 1}) 52 + 53 + issues, err := column1.GetIssues(db.DefaultContext) 54 + assert.NoError(t, err) 55 + assert.Len(t, issues, 1) 56 + assert.EqualValues(t, 1, issues[0].ID) 57 + 58 + column2 := unittest.AssertExistsAndLoadBean(t, &Board{ID: 2, ProjectID: 1}) 59 + issues, err = column2.GetIssues(db.DefaultContext) 60 + assert.NoError(t, err) 61 + assert.Len(t, issues, 1) 62 + assert.EqualValues(t, 3, issues[0].ID) 63 + 64 + err = column1.moveIssuesToAnotherColumn(db.DefaultContext, column2) 65 + assert.NoError(t, err) 66 + 67 + issues, err = column1.GetIssues(db.DefaultContext) 68 + assert.NoError(t, err) 69 + assert.Len(t, issues, 0) 70 + 71 + issues, err = column2.GetIssues(db.DefaultContext) 72 + assert.NoError(t, err) 73 + assert.Len(t, issues, 2) 74 + assert.EqualValues(t, 3, issues[0].ID) 75 + assert.EqualValues(t, 0, issues[0].Sorting) 76 + assert.EqualValues(t, 1, issues[1].ID) 77 + assert.EqualValues(t, 1, issues[1].Sorting) 78 + } 79 + 80 + func Test_MoveColumnsOnProject(t *testing.T) { 81 + assert.NoError(t, unittest.PrepareTestDatabase()) 82 + 83 + project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1}) 84 + columns, err := project1.GetBoards(db.DefaultContext) 85 + assert.NoError(t, err) 86 + assert.Len(t, columns, 3) 87 + assert.EqualValues(t, 0, columns[0].Sorting) // even if there is no default sorting, the code should also work 88 + assert.EqualValues(t, 0, columns[1].Sorting) 89 + assert.EqualValues(t, 0, columns[2].Sorting) 90 + 91 + err = MoveColumnsOnProject(db.DefaultContext, project1, map[int64]int64{ 92 + 0: columns[1].ID, 93 + 1: columns[2].ID, 94 + 2: columns[0].ID, 95 + }) 96 + assert.NoError(t, err) 97 + 98 + columnsAfter, err := project1.GetBoards(db.DefaultContext) 99 + assert.NoError(t, err) 100 + assert.Len(t, columnsAfter, 3) 101 + assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID) 102 + assert.EqualValues(t, columns[2].ID, columnsAfter[1].ID) 103 + assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID) 104 + } 105 + 106 + func Test_NewBoard(t *testing.T) { 107 + assert.NoError(t, unittest.PrepareTestDatabase()) 108 + 109 + project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1}) 110 + columns, err := project1.GetBoards(db.DefaultContext) 111 + assert.NoError(t, err) 112 + assert.Len(t, columns, 3) 113 + 114 + for i := 0; i < maxProjectColumns-3; i++ { 115 + err := NewBoard(db.DefaultContext, &Board{ 116 + Title: fmt.Sprintf("board-%d", i+4), 117 + ProjectID: project1.ID, 118 + }) 119 + assert.NoError(t, err) 120 + } 121 + err = NewBoard(db.DefaultContext, &Board{ 122 + Title: "board-21", 123 + ProjectID: project1.ID, 124 + }) 125 + assert.Error(t, err) 126 + assert.True(t, strings.Contains(err.Error(), "maximum number of columns reached")) 127 + }
+43 -8
models/project/issue.go
··· 9 9 10 10 "code.gitea.io/gitea/models/db" 11 11 "code.gitea.io/gitea/modules/log" 12 + "code.gitea.io/gitea/modules/util" 12 13 ) 13 14 14 15 // ProjectIssue saves relation from issue to a project ··· 17 18 IssueID int64 `xorm:"INDEX"` 18 19 ProjectID int64 `xorm:"INDEX"` 19 20 20 - // If 0, then it has not been added to a specific board in the project 21 + // 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. 21 22 ProjectBoardID int64 `xorm:"INDEX"` 22 23 23 24 // the sorting order on the board ··· 79 80 func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs map[int64]int64) error { 80 81 return db.WithTx(ctx, func(ctx context.Context) error { 81 82 sess := db.GetEngine(ctx) 83 + issueIDs := util.ValuesOfMap(sortedIssueIDs) 82 84 83 - issueIDs := make([]int64, 0, len(sortedIssueIDs)) 84 - for _, issueID := range sortedIssueIDs { 85 - issueIDs = append(issueIDs, issueID) 86 - } 87 85 count, err := sess.Table(new(ProjectIssue)).Where("project_id=?", board.ProjectID).In("issue_id", issueIDs).Count() 88 86 if err != nil { 89 87 return err ··· 102 100 }) 103 101 } 104 102 105 - func (b *Board) removeIssues(ctx context.Context) error { 106 - _, err := db.GetEngine(ctx).Exec("UPDATE `project_issue` SET project_board_id = 0 WHERE project_board_id = ? ", b.ID) 107 - return err 103 + func (b *Board) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Board) error { 104 + if b.ProjectID != newColumn.ProjectID { 105 + return fmt.Errorf("columns have to be in the same project") 106 + } 107 + 108 + if b.ID == newColumn.ID { 109 + return nil 110 + } 111 + 112 + res := struct { 113 + MaxSorting int64 114 + IssueCount int64 115 + }{} 116 + if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count"). 117 + Table("project_issue"). 118 + Where("project_id=?", newColumn.ProjectID). 119 + And("project_board_id=?", newColumn.ID). 120 + Get(&res); err != nil { 121 + return err 122 + } 123 + 124 + issues, err := b.GetIssues(ctx) 125 + if err != nil { 126 + return err 127 + } 128 + if len(issues) == 0 { 129 + return nil 130 + } 131 + 132 + nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) 133 + return db.WithTx(ctx, func(ctx context.Context) error { 134 + for i, issue := range issues { 135 + issue.ProjectBoardID = newColumn.ID 136 + issue.Sorting = nextSorting + int64(i) 137 + if _, err := db.GetEngine(ctx).ID(issue.ID).Cols("project_board_id", "sorting").Update(issue); err != nil { 138 + return err 139 + } 140 + } 141 + return nil 142 + }) 108 143 }
+7
models/project/project.go
··· 161 161 return p.Type == TypeRepository 162 162 } 163 163 164 + func (p *Project) CanBeAccessedByOwnerRepo(ownerID int64, repo *repo_model.Repository) bool { 165 + if p.Type == TypeRepository { 166 + return repo != nil && p.RepoID == repo.ID // if a project belongs to a repository, then its OwnerID is 0 and can be ignored 167 + } 168 + return p.OwnerID == ownerID && p.RepoID == 0 169 + } 170 + 164 171 func init() { 165 172 db.RegisterModel(new(Project)) 166 173 }
-69
routers/web/org/projects.go
··· 7 7 "errors" 8 8 "fmt" 9 9 "net/http" 10 - "strconv" 11 10 "strings" 12 11 13 12 "code.gitea.io/gitea/models/db" ··· 388 387 } 389 388 390 389 ctx.HTML(http.StatusOK, tplProjectsView) 391 - } 392 - 393 - func getActionIssues(ctx *context.Context) issues_model.IssueList { 394 - commaSeparatedIssueIDs := ctx.FormString("issue_ids") 395 - if len(commaSeparatedIssueIDs) == 0 { 396 - return nil 397 - } 398 - issueIDs := make([]int64, 0, 10) 399 - for _, stringIssueID := range strings.Split(commaSeparatedIssueIDs, ",") { 400 - issueID, err := strconv.ParseInt(stringIssueID, 10, 64) 401 - if err != nil { 402 - ctx.ServerError("ParseInt", err) 403 - return nil 404 - } 405 - issueIDs = append(issueIDs, issueID) 406 - } 407 - issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) 408 - if err != nil { 409 - ctx.ServerError("GetIssuesByIDs", err) 410 - return nil 411 - } 412 - // Check access rights for all issues 413 - issueUnitEnabled := ctx.Repo.CanRead(unit.TypeIssues) 414 - prUnitEnabled := ctx.Repo.CanRead(unit.TypePullRequests) 415 - for _, issue := range issues { 416 - if issue.RepoID != ctx.Repo.Repository.ID { 417 - ctx.NotFound("some issue's RepoID is incorrect", errors.New("some issue's RepoID is incorrect")) 418 - return nil 419 - } 420 - if issue.IsPull && !prUnitEnabled || !issue.IsPull && !issueUnitEnabled { 421 - ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) 422 - return nil 423 - } 424 - if err = issue.LoadAttributes(ctx); err != nil { 425 - ctx.ServerError("LoadAttributes", err) 426 - return nil 427 - } 428 - } 429 - return issues 430 - } 431 - 432 - // UpdateIssueProject change an issue's project 433 - func UpdateIssueProject(ctx *context.Context) { 434 - issues := getActionIssues(ctx) 435 - if ctx.Written() { 436 - return 437 - } 438 - 439 - if err := issues.LoadProjects(ctx); err != nil { 440 - ctx.ServerError("LoadProjects", err) 441 - return 442 - } 443 - 444 - projectID := ctx.FormInt64("id") 445 - for _, issue := range issues { 446 - if issue.Project != nil { 447 - if issue.Project.ID == projectID { 448 - continue 449 - } 450 - } 451 - 452 - if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil { 453 - ctx.ServerError("ChangeProjectAssign", err) 454 - return 455 - } 456 - } 457 - 458 - ctx.JSONOK() 459 390 } 460 391 461 392 // DeleteProjectBoard allows for the deletion of a project board
+2 -2
routers/web/repo/issue.go
··· 1266 1266 ctx.Error(http.StatusBadRequest, "user hasn't permissions to read projects") 1267 1267 return 1268 1268 } 1269 - if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil { 1270 - ctx.ServerError("ChangeProjectAssign", err) 1269 + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil { 1270 + ctx.ServerError("IssueAssignOrRemoveProject", err) 1271 1271 return 1272 1272 } 1273 1273 }
+11 -6
routers/web/repo/projects.go
··· 21 21 "code.gitea.io/gitea/modules/markup/markdown" 22 22 "code.gitea.io/gitea/modules/optional" 23 23 "code.gitea.io/gitea/modules/setting" 24 + "code.gitea.io/gitea/modules/util" 24 25 "code.gitea.io/gitea/modules/web" 25 26 "code.gitea.io/gitea/services/context" 26 27 "code.gitea.io/gitea/services/forms" ··· 382 383 ctx.ServerError("LoadProjects", err) 383 384 return 384 385 } 386 + if _, err := issues.LoadRepositories(ctx); err != nil { 387 + ctx.ServerError("LoadProjects", err) 388 + return 389 + } 385 390 386 391 projectID := ctx.FormInt64("id") 387 392 for _, issue := range issues { 388 - if issue.Project != nil { 389 - if issue.Project.ID == projectID { 393 + if issue.Project != nil && issue.Project.ID == projectID { 394 + continue 395 + } 396 + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil { 397 + if errors.Is(err, util.ErrPermissionDenied) { 390 398 continue 391 399 } 392 - } 393 - 394 - if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil { 395 - ctx.ServerError("ChangeProjectAssign", err) 400 + ctx.ServerError("IssueAssignOrRemoveProject", err) 396 401 return 397 402 } 398 403 }
+6 -8
routers/web/repo/pull.go
··· 1537 1537 return 1538 1538 } 1539 1539 1540 - if projectID > 0 { 1541 - if !ctx.Repo.CanWrite(unit.TypeProjects) { 1542 - ctx.Error(http.StatusBadRequest, "user hasn't the permission to write to projects") 1543 - return 1544 - } 1545 - if err := issues_model.ChangeProjectAssign(ctx, pullIssue, ctx.Doer, projectID); err != nil { 1546 - ctx.ServerError("ChangeProjectAssign", err) 1547 - return 1540 + if projectID > 0 && ctx.Repo.CanWrite(unit.TypeProjects) { 1541 + if err := issues_model.IssueAssignOrRemoveProject(ctx, pullIssue, ctx.Doer, projectID, 0); err != nil { 1542 + if !errors.Is(err, util.ErrPermissionDenied) { 1543 + ctx.ServerError("IssueAssignOrRemoveProject", err) 1544 + return 1545 + } 1548 1546 } 1549 1547 } 1550 1548
+48
routers/web/shared/project/column.go
··· 1 + // Copyright 2024 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package project 5 + 6 + import ( 7 + project_model "code.gitea.io/gitea/models/project" 8 + "code.gitea.io/gitea/modules/json" 9 + "code.gitea.io/gitea/services/context" 10 + ) 11 + 12 + // MoveColumns moves or keeps columns in a project and sorts them inside that project 13 + func MoveColumns(ctx *context.Context) { 14 + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) 15 + if err != nil { 16 + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) 17 + return 18 + } 19 + if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) { 20 + ctx.NotFound("CanBeAccessedByOwnerRepo", nil) 21 + return 22 + } 23 + 24 + type movedColumnsForm struct { 25 + Columns []struct { 26 + ColumnID int64 `json:"columnID"` 27 + Sorting int64 `json:"sorting"` 28 + } `json:"columns"` 29 + } 30 + 31 + form := &movedColumnsForm{} 32 + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { 33 + ctx.ServerError("DecodeMovedColumnsForm", err) 34 + return 35 + } 36 + 37 + sortedColumnIDs := make(map[int64]int64) 38 + for _, column := range form.Columns { 39 + sortedColumnIDs[column.Sorting] = column.ColumnID 40 + } 41 + 42 + if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { 43 + ctx.ServerError("MoveColumnsOnProject", err) 44 + return 45 + } 46 + 47 + ctx.JSONOK() 48 + }
+3
routers/web/web.go
··· 39 39 "code.gitea.io/gitea/routers/web/repo/badges" 40 40 repo_flags "code.gitea.io/gitea/routers/web/repo/flags" 41 41 repo_setting "code.gitea.io/gitea/routers/web/repo/setting" 42 + "code.gitea.io/gitea/routers/web/shared/project" 42 43 "code.gitea.io/gitea/routers/web/user" 43 44 user_setting "code.gitea.io/gitea/routers/web/user/setting" 44 45 "code.gitea.io/gitea/routers/web/user/setting/security" ··· 976 977 m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost) 977 978 m.Group("/{id}", func() { 978 979 m.Post("", web.Bind(forms.EditProjectBoardForm{}), org.AddBoardToProjectPost) 980 + m.Post("/move", project.MoveColumns) 979 981 m.Post("/delete", org.DeleteProject) 980 982 981 983 m.Get("/edit", org.RenderEditProject) ··· 1349 1351 m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost) 1350 1352 m.Group("/{id}", func() { 1351 1353 m.Post("", web.Bind(forms.EditProjectBoardForm{}), repo.AddBoardToProjectPost) 1354 + m.Post("/move", project.MoveColumns) 1352 1355 m.Post("/delete", repo.DeleteProject) 1353 1356 1354 1357 m.Get("/edit", repo.RenderEditProject)
+1 -1
templates/projects/view.tmpl
··· 64 64 </div> 65 65 66 66 <div id="project-board"> 67 - <div class="board {{if .CanWriteProjects}}sortable{{end}}"> 67 + <div class="board {{if .CanWriteProjects}}sortable{{end}}"{{if .CanWriteProjects}} data-url="{{$.Link}}/move"{{end}}> 68 68 {{range .Columns}} 69 69 <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}}"> 70 70 <div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}">
+3 -3
tests/integration/org_project_test.go
··· 5 5 6 6 import ( 7 7 "net/http" 8 + "slices" 8 9 "testing" 9 10 10 11 unit_model "code.gitea.io/gitea/models/unit" 12 + "code.gitea.io/gitea/modules/test" 11 13 "code.gitea.io/gitea/tests" 12 14 ) 13 15 14 16 func TestOrgProjectAccess(t *testing.T) { 15 17 defer tests.PrepareTestEnv(t)() 16 - 17 - // disable repo project unit 18 - unit_model.DisabledRepoUnits = []unit_model.Type{unit_model.TypeProjects} 18 + defer test.MockVariableValue(&unit_model.DisabledRepoUnits, append(slices.Clone(unit_model.DisabledRepoUnits), unit_model.TypeProjects))() 19 19 20 20 // repo project, 404 21 21 req := NewRequest(t, "GET", "/user2/repo1/projects")
+60
tests/integration/project_test.go
··· 4 4 package integration 5 5 6 6 import ( 7 + "fmt" 7 8 "net/http" 8 9 "testing" 9 10 11 + "code.gitea.io/gitea/models/db" 12 + project_model "code.gitea.io/gitea/models/project" 13 + repo_model "code.gitea.io/gitea/models/repo" 14 + "code.gitea.io/gitea/models/unittest" 10 15 "code.gitea.io/gitea/tests" 16 + 17 + "github.com/stretchr/testify/assert" 11 18 ) 12 19 13 20 func TestPrivateRepoProject(t *testing.T) { ··· 21 28 req = NewRequest(t, "GET", "/user31/-/projects") 22 29 sess.MakeRequest(t, req, http.StatusOK) 23 30 } 31 + 32 + func TestMoveRepoProjectColumns(t *testing.T) { 33 + defer tests.PrepareTestEnv(t)() 34 + 35 + repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) 36 + 37 + project1 := project_model.Project{ 38 + Title: "new created project", 39 + RepoID: repo2.ID, 40 + Type: project_model.TypeRepository, 41 + BoardType: project_model.BoardTypeNone, 42 + } 43 + err := project_model.NewProject(db.DefaultContext, &project1) 44 + assert.NoError(t, err) 45 + 46 + for i := 0; i < 3; i++ { 47 + err = project_model.NewBoard(db.DefaultContext, &project_model.Board{ 48 + Title: fmt.Sprintf("column %d", i+1), 49 + ProjectID: project1.ID, 50 + }) 51 + assert.NoError(t, err) 52 + } 53 + 54 + columns, err := project1.GetBoards(db.DefaultContext) 55 + assert.NoError(t, err) 56 + assert.Len(t, columns, 3) 57 + assert.EqualValues(t, 0, columns[0].Sorting) 58 + assert.EqualValues(t, 1, columns[1].Sorting) 59 + assert.EqualValues(t, 2, columns[2].Sorting) 60 + 61 + sess := loginUser(t, "user1") 62 + req := NewRequest(t, "GET", fmt.Sprintf("/%s/projects/%d", repo2.FullName(), project1.ID)) 63 + resp := sess.MakeRequest(t, req, http.StatusOK) 64 + htmlDoc := NewHTMLParser(t, resp.Body) 65 + 66 + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/projects/%d/move?_csrf="+htmlDoc.GetCSRF(), repo2.FullName(), project1.ID), map[string]any{ 67 + "columns": []map[string]any{ 68 + {"columnID": columns[1].ID, "sorting": 0}, 69 + {"columnID": columns[2].ID, "sorting": 1}, 70 + {"columnID": columns[0].ID, "sorting": 2}, 71 + }, 72 + }) 73 + sess.MakeRequest(t, req, http.StatusOK) 74 + 75 + columnsAfter, err := project1.GetBoards(db.DefaultContext) 76 + assert.NoError(t, err) 77 + assert.Len(t, columns, 3) 78 + assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID) 79 + assert.EqualValues(t, columns[2].ID, columnsAfter[1].ID) 80 + assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID) 81 + 82 + assert.NoError(t, project_model.DeleteProjectByID(db.DefaultContext, project1.ID)) 83 + }
+14 -12
web_src/js/features/repo-projects.js
··· 2 2 import {contrastColor} from '../utils/color.js'; 3 3 import {createSortable} from '../modules/sortable.js'; 4 4 import {POST, DELETE, PUT} from '../modules/fetch.js'; 5 - import tinycolor from 'tinycolor2'; 6 5 7 6 function updateIssueCount(cards) { 8 7 const parent = cards.parentElement; ··· 63 62 delay: 500, 64 63 onSort: async () => { 65 64 boardColumns = mainBoard.getElementsByClassName('project-column'); 66 - for (let i = 0; i < boardColumns.length; i++) { 67 - const column = boardColumns[i]; 68 - if (parseInt(column.getAttribute('data-sorting')) !== i) { 69 - try { 70 - const bgColor = column.style.backgroundColor; // will be rgb() string 71 - const color = bgColor ? tinycolor(bgColor).toHexString() : ''; 72 - await PUT(column.getAttribute('data-url'), {data: {sorting: i, color}}); 73 - } catch (error) { 74 - console.error(error); 75 - } 76 - } 65 + 66 + const columnSorting = { 67 + columns: Array.from(boardColumns, (column, i) => ({ 68 + columnID: parseInt(column.getAttribute('data-id')), 69 + sorting: i, 70 + })), 71 + }; 72 + 73 + try { 74 + await POST(mainBoard.getAttribute('data-url'), { 75 + data: columnSorting, 76 + }); 77 + } catch (error) { 78 + console.error(error); 77 79 } 78 80 }, 79 81 });