diff --git a/models/issue.go b/models/issue.go index 324f4eaa8..58649c754 100644 --- a/models/issue.go +++ b/models/issue.go @@ -1219,6 +1219,8 @@ func sortIssuesSession(sess *xorm.Session, sortType string, priorityRepoID int64 "ELSE issue.deadline_unix END DESC") case "priorityrepo": sess.OrderBy("CASE WHEN issue.repo_id = " + strconv.FormatInt(priorityRepoID, 10) + " THEN 1 ELSE 2 END, issue.created_unix DESC") + case "project-column-sorting": + sess.Asc("project_issue.sorting") default: sess.Desc("issue.created_unix") } diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 6b7caba89..a5bacd0d9 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -359,6 +359,8 @@ var migrations = []Migration{ NewMigration("Drop table remote_version (if exists)", dropTableRemoteVersion), // v202 -> v203 NewMigration("Create key/value table for user settings", createUserSettingsTable), + // v203 -> v204 + NewMigration("Add Sorting to ProjectIssue table", addProjectIssueSorting), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v203.go b/models/migrations/v203.go new file mode 100644 index 000000000..2e1dd7289 --- /dev/null +++ b/models/migrations/v203.go @@ -0,0 +1,18 @@ +// Copyright 2021 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 migrations + +import ( + "xorm.io/xorm" +) + +func addProjectIssueSorting(x *xorm.Engine) error { + // ProjectIssue saves relation from issue to a project + type ProjectIssue struct { + Sorting int64 `xorm:"NOT NULL DEFAULT 0"` + } + + return x.Sync2(new(ProjectIssue)) +} diff --git a/models/project_board.go b/models/project_board.go index 2d422a203..d40cfd06f 100644 --- a/models/project_board.go +++ b/models/project_board.go @@ -265,6 +265,7 @@ func (b *ProjectBoard) LoadIssues() (IssueList, error) { issues, err := Issues(&IssuesOptions{ ProjectBoardID: b.ID, ProjectID: b.ProjectID, + SortType: "project-column-sorting", }) if err != nil { return nil, err @@ -276,6 +277,7 @@ func (b *ProjectBoard) LoadIssues() (IssueList, error) { issues, err := Issues(&IssuesOptions{ ProjectBoardID: -1, // Issues without ProjectBoardID ProjectID: b.ProjectID, + SortType: "project-column-sorting", }) if err != nil { return nil, err diff --git a/models/project_issue.go b/models/project_issue.go index fb08efa99..c1421485b 100644 --- a/models/project_issue.go +++ b/models/project_issue.go @@ -20,6 +20,7 @@ type ProjectIssue struct { // If 0, then it has not been added to a specific board in the project ProjectBoardID int64 `xorm:"INDEX"` + Sorting int64 `xorm:"NOT NULL DEFAULT 0"` } func init() { @@ -184,34 +185,34 @@ func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.U // |_| |_| \___// |\___|\___|\__|____/ \___/ \__,_|_| \__,_| // |__/ -// MoveIssueAcrossProjectBoards move a card from one board to another -func MoveIssueAcrossProjectBoards(issue *Issue, board *ProjectBoard) error { - ctx, committer, err := db.TxContext() - if err != nil { - return err - } - defer committer.Close() - sess := db.GetEngine(ctx) - - var pis ProjectIssue - has, err := sess.Where("issue_id=?", issue.ID).Get(&pis) - if err != nil { - return err - } - - if !has { - return fmt.Errorf("issue has to be added to a project first") - } +// MoveIssuesOnProjectBoard moves or keeps issues in a column and sorts them inside that column +func MoveIssuesOnProjectBoard(board *ProjectBoard, sortedIssueIDs map[int64]int64) error { + return db.WithTx(func(ctx context.Context) error { + sess := db.GetEngine(ctx) - pis.ProjectBoardID = board.ID - if _, err := sess.ID(pis.ID).Cols("project_board_id").Update(&pis); err != nil { - return err - } + 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 + } + if int(count) != len(sortedIssueIDs) { + return fmt.Errorf("all issues have to be added to a project first") + } - return committer.Commit() + for sorting, issueID := range sortedIssueIDs { + _, err = sess.Exec("UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", board.ID, sorting, issueID) + if err != nil { + return err + } + } + return nil + }) } func (pb *ProjectBoard) removeIssues(e db.Engine) error { - _, err := e.Exec("UPDATE `project_issue` SET project_board_id = 0 WHERE project_board_id = ? ", pb.ID) + _, err := e.Exec("UPDATE `project_issue` SET project_board_id = 0, sorting = 0 WHERE project_board_id = ? ", pb.ID) return err } diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index c23754438..a8b2a7a5c 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -5,6 +5,7 @@ package repo import ( + "encoding/json" "fmt" "net/http" "net/url" @@ -299,7 +300,6 @@ func ViewProject(ctx *context.Context) { ctx.ServerError("LoadIssuesOfBoards", err) return } - ctx.Data["Issues"] = issueList linkedPrsMap := make(map[int64][]*models.Issue) for _, issue := range issueList { @@ -547,9 +547,8 @@ func SetDefaultProjectBoard(ctx *context.Context) { }) } -// MoveIssueAcrossBoards move a card from one board to another in a project -func MoveIssueAcrossBoards(ctx *context.Context) { - +// MoveIssues moves or keeps issues in a column and sorts them inside that column +func MoveIssues(ctx *context.Context) { if ctx.User == nil { ctx.JSON(http.StatusForbidden, map[string]string{ "message": "Only signed in users are allowed to perform this action.", @@ -564,59 +563,80 @@ func MoveIssueAcrossBoards(ctx *context.Context) { return } - p, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + project, err := models.GetProjectByID(ctx.ParamsInt64(":id")) if err != nil { if models.IsErrProjectNotExist(err) { - ctx.NotFound("", nil) + ctx.NotFound("ProjectNotExist", nil) } else { ctx.ServerError("GetProjectByID", err) } return } - if p.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("", nil) + if project.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("InvalidRepoID", nil) return } var board *models.ProjectBoard if ctx.ParamsInt64(":boardID") == 0 { - board = &models.ProjectBoard{ ID: 0, - ProjectID: 0, + ProjectID: project.ID, Title: ctx.Tr("repo.projects.type.uncategorized"), } - } else { + // column board, err = models.GetProjectBoard(ctx.ParamsInt64(":boardID")) if err != nil { if models.IsErrProjectBoardNotExist(err) { - ctx.NotFound("", nil) + ctx.NotFound("ProjectBoardNotExist", nil) } else { ctx.ServerError("GetProjectBoard", err) } return } - if board.ProjectID != p.ID { - ctx.NotFound("", nil) + if board.ProjectID != project.ID { + ctx.NotFound("BoardNotInProject", nil) return } } - issue, err := models.GetIssueByID(ctx.ParamsInt64(":index")) + type movedIssuesForm struct { + Issues []struct { + IssueID int64 `json:"issueID"` + Sorting int64 `json:"sorting"` + } `json:"issues"` + } + + form := &movedIssuesForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedIssuesForm", err) + } + + issueIDs := make([]int64, 0, len(form.Issues)) + sortedIssueIDs := make(map[int64]int64) + for _, issue := range form.Issues { + issueIDs = append(issueIDs, issue.IssueID) + sortedIssueIDs[issue.Sorting] = issue.IssueID + } + movedIssues, err := models.GetIssuesByIDs(issueIDs) if err != nil { if models.IsErrIssueNotExist(err) { - ctx.NotFound("", nil) + ctx.NotFound("IssueNotExisting", nil) } else { ctx.ServerError("GetIssueByID", err) } + return + } + if len(movedIssues) != len(form.Issues) { + ctx.ServerError("IssuesNotFound", err) return } - if err := models.MoveIssueAcrossProjectBoards(issue, board); err != nil { - ctx.ServerError("MoveIssueAcrossProjectBoards", err) + if err = models.MoveIssuesOnProjectBoard(board, sortedIssueIDs); err != nil { + ctx.ServerError("MoveIssuesOnProjectBoard", err) return } diff --git a/routers/web/web.go b/routers/web/web.go index c52d3483f..0d4d3bd90 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -897,7 +897,7 @@ func RegisterRoutes(m *web.Route) { m.Delete("", repo.DeleteProjectBoard) m.Post("/default", repo.SetDefaultProjectBoard) - m.Post("/{index}", repo.MoveIssueAcrossBoards) + m.Post("/move", repo.MoveIssues) }) }) }, reqRepoProjectsWriter, context.RepoMustNotBeArchived()) diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js index 986ada295..5b3f54f8a 100644 --- a/web_src/js/features/repo-projects.js +++ b/web_src/js/features/repo-projects.js @@ -1,5 +1,29 @@ const {csrfToken} = window.config; +function moveIssue({item, from, to, oldIndex}) { + const columnCards = to.getElementsByClassName('board-card'); + + const columnSorting = { + issues: [...columnCards].map((card, i) => ({ + issueID: parseInt($(card).attr('data-issue')), + sorting: i + })) + }; + + $.ajax({ + url: `${to.getAttribute('data-url')}/move`, + data: JSON.stringify(columnSorting), + headers: { + 'X-Csrf-Token': csrfToken, + }, + contentType: 'application/json', + type: 'POST', + error: () => { + from.insertBefore(item, from.children[oldIndex]); + } + }); +} + async function initRepoProjectSortable() { const els = document.querySelectorAll('#project-board > .board'); if (!els.length) return; @@ -40,20 +64,8 @@ async function initRepoProjectSortable() { group: 'shared', animation: 150, ghostClass: 'card-ghost', - onAdd: ({item, from, to, oldIndex}) => { - const url = to.getAttribute('data-url'); - const issue = item.getAttribute('data-issue'); - $.ajax(`${url}/${issue}`, { - headers: { - 'X-Csrf-Token': csrfToken, - }, - contentType: 'application/json', - type: 'POST', - error: () => { - from.insertBefore(item, from.children[oldIndex]); - }, - }); - }, + onAdd: moveIssue, + onUpdate: moveIssue, }); } }