Files
gitea-MIRROR/routers/web/shared/project/project.go
Lunny Xiao 3561478109 fix(projects): move only the dragged card on project boards (#40182)
The board reported every card of the target column on each drag, and the
server wrote all of them into that column. A stale board in another
browser therefore pulled cards moved by someone else back to their old
column.

The client now sends only the dragged issue and its predecessor, and the
server computes the position in one transaction:

- Only the dragged issue changes column, the others are just renumbered.
- A predecessor that is no longer in the column puts the card at the
end.

Added an e2e test.

Fixes https://github.com/go-gitea/gitea/issues/21243
Fixes https://github.com/go-gitea/gitea/issues/32070
Fixes https://github.com/go-gitea/gitea/issues/13498

---
Assisted-by: Codet:claude

---------

Co-authored-by: silverwind <me@silverwind.io>
2026-11-09 21:39:22 +00:00

186 lines
4.3 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"errors"
issues_model "gitea.dev/models/issues"
project_model "gitea.dev/models/project"
"gitea.dev/modules/json"
"gitea.dev/modules/web"
"gitea.dev/services/context"
"gitea.dev/services/forms"
project_service "gitea.dev/services/projects"
)
// findProject loads the "id" path param, scoped to whichever owner the route assigned:
// anyone else's ID reads as not found. Write permission is enforced by the route.
func findProject(ctx *context.Context) *project_model.Project {
var project *project_model.Project
var err error
if ctx.Repo != nil && ctx.Repo.Repository != nil {
project, err = project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
} else {
project, err = project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
}
if err != nil {
ctx.ServerError("GetProject", err)
return nil
}
return project
}
func findColumn(ctx *context.Context) (*project_model.Project, *project_model.Column) {
project := findProject(ctx)
if ctx.Written() {
return nil, nil
}
column, err := project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID)
if err != nil {
ctx.ServerError("GetColumnByIDAndProjectID", err)
return nil, nil
}
return project, column
}
func MoveColumns(ctx *context.Context) {
project := findProject(ctx)
if ctx.Written() {
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()
}
func AddColumnToProjectPost(ctx *context.Context) {
form := web.GetForm[*forms.EditProjectColumnForm](ctx)
project := findProject(ctx)
if ctx.Written() {
return
}
if err := project_model.NewColumn(ctx, &project_model.Column{
ProjectID: project.ID,
Title: form.Title,
Color: form.Color,
CreatorID: ctx.Doer.ID,
}); err != nil {
ctx.JSONErrorAuto(err)
return
}
ctx.JSONOK()
}
func EditProjectColumn(ctx *context.Context) {
form := web.GetForm[*forms.EditProjectColumnForm](ctx)
_, column := findColumn(ctx)
if ctx.Written() {
return
}
if form.Title != "" {
column.Title = form.Title
}
column.Color = form.Color
if form.Sorting != 0 {
column.Sorting = form.Sorting
}
if err := project_model.UpdateColumn(ctx, column); err != nil {
ctx.JSONErrorAuto(err)
return
}
ctx.JSONOK()
}
func DeleteProjectColumn(ctx *context.Context) {
_, column := findColumn(ctx)
if ctx.Written() {
return
}
if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil {
ctx.ServerError("DeleteProjectColumnByID", err)
return
}
ctx.JSONOK()
}
func SetDefaultProjectColumn(ctx *context.Context) {
project, column := findColumn(ctx)
if ctx.Written() {
return
}
if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil {
ctx.ServerError("SetDefaultColumn", err)
return
}
ctx.JSONOK()
}
func MoveIssues(ctx *context.Context) {
project, column := findColumn(ctx)
if ctx.Written() {
return
}
form := &struct {
IssueID int64 `json:"issueID"`
PrevIssueID int64 `json:"prevIssueID"`
}{}
if err := json.NewDecoder(ctx.Req.Body).Decode(form); err != nil {
ctx.ServerError("DecodeMovedIssueForm", err)
return
}
issue, err := issues_model.GetIssueByID(ctx, form.IssueID)
if err != nil {
ctx.ServerError("GetIssueByID", err)
return
}
if err = issue.LoadRepo(ctx); err != nil {
ctx.ServerError("LoadRepo", err)
return
}
if !project.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) {
ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID"))
return
}
if err = project_service.MoveIssueAfter(ctx, ctx.Doer, column, issue, form.PrevIssueID); err != nil {
ctx.ServerError("MoveIssueAfter", err)
return
}
ctx.JSONOK()
}