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 {
 | 
							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]);
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue