Support sorting for project board issuses (#17152)

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Anbraten 2021-12-08 07:57:18 +01:00 committed by GitHub
parent 4cbe792562
commit 0ff18a808c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 115 additions and 58 deletions

View File

@ -1219,6 +1219,8 @@ func sortIssuesSession(sess *xorm.Session, sortType string, priorityRepoID int64
"ELSE issue.deadline_unix END DESC") "ELSE issue.deadline_unix END DESC")
case "priorityrepo": case "priorityrepo":
sess.OrderBy("CASE WHEN issue.repo_id = " + strconv.FormatInt(priorityRepoID, 10) + " THEN 1 ELSE 2 END, issue.created_unix DESC") 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: default:
sess.Desc("issue.created_unix") sess.Desc("issue.created_unix")
} }

View File

@ -359,6 +359,8 @@ var migrations = []Migration{
NewMigration("Drop table remote_version (if exists)", dropTableRemoteVersion), NewMigration("Drop table remote_version (if exists)", dropTableRemoteVersion),
// v202 -> v203 // v202 -> v203
NewMigration("Create key/value table for user settings", createUserSettingsTable), NewMigration("Create key/value table for user settings", createUserSettingsTable),
// v203 -> v204
NewMigration("Add Sorting to ProjectIssue table", addProjectIssueSorting),
} }
// GetCurrentDBVersion returns the current db version // GetCurrentDBVersion returns the current db version

18
models/migrations/v203.go Normal file
View File

@ -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))
}

View File

@ -265,6 +265,7 @@ func (b *ProjectBoard) LoadIssues() (IssueList, error) {
issues, err := Issues(&IssuesOptions{ issues, err := Issues(&IssuesOptions{
ProjectBoardID: b.ID, ProjectBoardID: b.ID,
ProjectID: b.ProjectID, ProjectID: b.ProjectID,
SortType: "project-column-sorting",
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -276,6 +277,7 @@ func (b *ProjectBoard) LoadIssues() (IssueList, error) {
issues, err := Issues(&IssuesOptions{ issues, err := Issues(&IssuesOptions{
ProjectBoardID: -1, // Issues without ProjectBoardID ProjectBoardID: -1, // Issues without ProjectBoardID
ProjectID: b.ProjectID, ProjectID: b.ProjectID,
SortType: "project-column-sorting",
}) })
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -20,6 +20,7 @@ type ProjectIssue struct {
// If 0, then it has not been added to a specific board in the project // If 0, then it has not been added to a specific board in the project
ProjectBoardID int64 `xorm:"INDEX"` ProjectBoardID int64 `xorm:"INDEX"`
Sorting int64 `xorm:"NOT NULL DEFAULT 0"`
} }
func init() { 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 // MoveIssuesOnProjectBoard moves or keeps issues in a column and sorts them inside that column
func MoveIssueAcrossProjectBoards(issue *Issue, board *ProjectBoard) error { func MoveIssuesOnProjectBoard(board *ProjectBoard, sortedIssueIDs map[int64]int64) error {
ctx, committer, err := db.TxContext() return db.WithTx(func(ctx context.Context) error {
if err != nil { sess := db.GetEngine(ctx)
return err
}
defer committer.Close()
sess := db.GetEngine(ctx)
var pis ProjectIssue issueIDs := make([]int64, 0, len(sortedIssueIDs))
has, err := sess.Where("issue_id=?", issue.ID).Get(&pis) for _, issueID := range sortedIssueIDs {
if err != nil { issueIDs = append(issueIDs, issueID)
return err }
} 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")
}
if !has { for sorting, issueID := range sortedIssueIDs {
return fmt.Errorf("issue has to be added to a project first") _, err = sess.Exec("UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", board.ID, sorting, issueID)
} if err != nil {
return err
pis.ProjectBoardID = board.ID }
if _, err := sess.ID(pis.ID).Cols("project_board_id").Update(&pis); err != nil { }
return err return nil
} })
return committer.Commit()
} }
func (pb *ProjectBoard) removeIssues(e db.Engine) error { 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 return err
} }

View File

@ -5,6 +5,7 @@
package repo package repo
import ( import (
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
@ -299,7 +300,6 @@ func ViewProject(ctx *context.Context) {
ctx.ServerError("LoadIssuesOfBoards", err) ctx.ServerError("LoadIssuesOfBoards", err)
return return
} }
ctx.Data["Issues"] = issueList
linkedPrsMap := make(map[int64][]*models.Issue) linkedPrsMap := make(map[int64][]*models.Issue)
for _, issue := range issueList { 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 // MoveIssues moves or keeps issues in a column and sorts them inside that column
func MoveIssueAcrossBoards(ctx *context.Context) { func MoveIssues(ctx *context.Context) {
if ctx.User == nil { if ctx.User == nil {
ctx.JSON(http.StatusForbidden, map[string]string{ ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only signed in users are allowed to perform this action.", "message": "Only signed in users are allowed to perform this action.",
@ -564,59 +563,80 @@ func MoveIssueAcrossBoards(ctx *context.Context) {
return return
} }
p, err := models.GetProjectByID(ctx.ParamsInt64(":id")) project, err := models.GetProjectByID(ctx.ParamsInt64(":id"))
if err != nil { if err != nil {
if models.IsErrProjectNotExist(err) { if models.IsErrProjectNotExist(err) {
ctx.NotFound("", nil) ctx.NotFound("ProjectNotExist", nil)
} else { } else {
ctx.ServerError("GetProjectByID", err) ctx.ServerError("GetProjectByID", err)
} }
return return
} }
if p.RepoID != ctx.Repo.Repository.ID { if project.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound("", nil) ctx.NotFound("InvalidRepoID", nil)
return return
} }
var board *models.ProjectBoard var board *models.ProjectBoard
if ctx.ParamsInt64(":boardID") == 0 { if ctx.ParamsInt64(":boardID") == 0 {
board = &models.ProjectBoard{ board = &models.ProjectBoard{
ID: 0, ID: 0,
ProjectID: 0, ProjectID: project.ID,
Title: ctx.Tr("repo.projects.type.uncategorized"), Title: ctx.Tr("repo.projects.type.uncategorized"),
} }
} else { } else {
// column
board, err = models.GetProjectBoard(ctx.ParamsInt64(":boardID")) board, err = models.GetProjectBoard(ctx.ParamsInt64(":boardID"))
if err != nil { if err != nil {
if models.IsErrProjectBoardNotExist(err) { if models.IsErrProjectBoardNotExist(err) {
ctx.NotFound("", nil) ctx.NotFound("ProjectBoardNotExist", nil)
} else { } else {
ctx.ServerError("GetProjectBoard", err) ctx.ServerError("GetProjectBoard", err)
} }
return return
} }
if board.ProjectID != p.ID { if board.ProjectID != project.ID {
ctx.NotFound("", nil) ctx.NotFound("BoardNotInProject", nil)
return 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 err != nil {
if models.IsErrIssueNotExist(err) { if models.IsErrIssueNotExist(err) {
ctx.NotFound("", nil) ctx.NotFound("IssueNotExisting", nil)
} else { } else {
ctx.ServerError("GetIssueByID", err) ctx.ServerError("GetIssueByID", err)
} }
return return
} }
if err := models.MoveIssueAcrossProjectBoards(issue, board); err != nil { if len(movedIssues) != len(form.Issues) {
ctx.ServerError("MoveIssueAcrossProjectBoards", err) ctx.ServerError("IssuesNotFound", err)
return
}
if err = models.MoveIssuesOnProjectBoard(board, sortedIssueIDs); err != nil {
ctx.ServerError("MoveIssuesOnProjectBoard", err)
return return
} }

View File

@ -897,7 +897,7 @@ func RegisterRoutes(m *web.Route) {
m.Delete("", repo.DeleteProjectBoard) m.Delete("", repo.DeleteProjectBoard)
m.Post("/default", repo.SetDefaultProjectBoard) m.Post("/default", repo.SetDefaultProjectBoard)
m.Post("/{index}", repo.MoveIssueAcrossBoards) m.Post("/move", repo.MoveIssues)
}) })
}) })
}, reqRepoProjectsWriter, context.RepoMustNotBeArchived()) }, reqRepoProjectsWriter, context.RepoMustNotBeArchived())

View File

@ -1,5 +1,29 @@
const {csrfToken} = window.config; 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() { async function initRepoProjectSortable() {
const els = document.querySelectorAll('#project-board > .board'); const els = document.querySelectorAll('#project-board > .board');
if (!els.length) return; if (!els.length) return;
@ -40,20 +64,8 @@ async function initRepoProjectSortable() {
group: 'shared', group: 'shared',
animation: 150, animation: 150,
ghostClass: 'card-ghost', ghostClass: 'card-ghost',
onAdd: ({item, from, to, oldIndex}) => { onAdd: moveIssue,
const url = to.getAttribute('data-url'); onUpdate: moveIssue,
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]);
},
});
},
}); });
} }
} }