Support sorting for project board issuses (#17152)
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
parent
4cbe792562
commit
0ff18a808c
8 changed files with 115 additions and 58 deletions
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
18
models/migrations/v203.go
Normal 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))
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer committer.Close()
|
|
||||||
sess := db.GetEngine(ctx)
|
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 {
|
||||||
|
issueIDs = append(issueIDs, issueID)
|
||||||
|
}
|
||||||
|
count, err := sess.Table(new(ProjectIssue)).Where("project_id=?", board.ProjectID).In("issue_id", issueIDs).Count()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if int(count) != len(sortedIssueIDs) {
|
||||||
if !has {
|
return fmt.Errorf("all issues have to be added to a project first")
|
||||||
return fmt.Errorf("issue has to be added to a project first")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pis.ProjectBoardID = board.ID
|
for sorting, issueID := range sortedIssueIDs {
|
||||||
if _, err := sess.ID(pis.ID).Cols("project_board_id").Update(&pis); err != nil {
|
_, err = sess.Exec("UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", board.ID, sorting, issueID)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return committer.Commit()
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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]);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Reference in a new issue