mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00:00 
			
		
		
		
	Support sorting for project board issuses (#17152)
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
		| @@ -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 { | 		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 | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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 New Issue
	
	Block a user
	 Anbraten
					Anbraten