mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-12-24 13:09:23 -05:00
Fix various problems around projects board view (#30696)
The previous implementation will start multiple POST requests from the
frontend when moving a column and another bug is moving the default
column will never be remembered in fact.
- [x] This PR will allow the default column to move to a non-first
position
- [x] And it also uses one request instead of multiple requests when
moving the columns
- [x] Use a star instead of a pin as the icon for setting the default
column action
- [x] Inserted new column will be append to the end
- [x] Fix #30701 the newly added issue will be append to the end of the
default column
- [x] Fix when deleting a column, all issues in it will be displayed
from UI but database records exist.
- [x] Add a limitation for columns in a project to 20. So the sorting
will not be overflow because it's int8.
---------
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
(cherry picked from commit a303c973e0264dab45a787c4afa200e183e0d953)
Conflicts:
routers/web/web.go
e91733468ef726fc9365aa4820cdd5f2ddfdaa23 Add missing database transaction for new issue (#29490) was not cherry-picked
services/issue/issue.go
fe6792dff3 Enable/disable owner and repo projects independently (#28805) was not cherry-picked
(cherry picked from commit 7d3ca90dfe
)
(cherry picked from commit 084bec89ed7ae0816fc2d8db6784ad22523d1fc4)
This commit is contained in:
parent
9934931f1f
commit
d91839692f
16 changed files with 428 additions and 169 deletions
|
@ -59,6 +59,7 @@ type Engine interface {
|
||||||
SumInt(bean any, columnName string) (res int64, err error)
|
SumInt(bean any, columnName string) (res int64, err error)
|
||||||
Sync(...any) error
|
Sync(...any) error
|
||||||
Select(string) *xorm.Session
|
Select(string) *xorm.Session
|
||||||
|
SetExpr(string, any) *xorm.Session
|
||||||
NotIn(string, ...any) *xorm.Session
|
NotIn(string, ...any) *xorm.Session
|
||||||
OrderBy(any, ...any) *xorm.Session
|
OrderBy(any, ...any) *xorm.Session
|
||||||
Exist(...any) (bool, error)
|
Exist(...any) (bool, error)
|
||||||
|
|
|
@ -5,11 +5,11 @@ package issues
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
project_model "code.gitea.io/gitea/models/project"
|
project_model "code.gitea.io/gitea/models/project"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoadProject load the project the issue was assigned to
|
// LoadProject load the project the issue was assigned to
|
||||||
|
@ -90,58 +90,73 @@ func LoadIssuesFromBoardList(ctx context.Context, bs project_model.BoardList) (m
|
||||||
return issuesMap, nil
|
return issuesMap, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChangeProjectAssign changes the project associated with an issue
|
// IssueAssignOrRemoveProject changes the project associated with an issue
|
||||||
func ChangeProjectAssign(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error {
|
// If newProjectID is 0, the issue is removed from the project
|
||||||
ctx, committer, err := db.TxContext(ctx)
|
func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID, newColumnID int64) error {
|
||||||
if err != nil {
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
return err
|
oldProjectID := issue.projectID(ctx)
|
||||||
}
|
|
||||||
defer committer.Close()
|
|
||||||
|
|
||||||
if err := addUpdateIssueProject(ctx, issue, doer, newProjectID); err != nil {
|
if err := issue.LoadRepo(ctx); err != nil {
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return committer.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error {
|
|
||||||
oldProjectID := issue.projectID(ctx)
|
|
||||||
|
|
||||||
if err := issue.LoadRepo(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only check if we add a new project and not remove it.
|
|
||||||
if newProjectID > 0 {
|
|
||||||
newProject, err := project_model.GetProjectByID(ctx, newProjectID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if newProject.RepoID != issue.RepoID && newProject.OwnerID != issue.Repo.OwnerID {
|
|
||||||
return fmt.Errorf("issue's repository is not the same as project's repository")
|
// Only check if we add a new project and not remove it.
|
||||||
|
if newProjectID > 0 {
|
||||||
|
newProject, err := project_model.GetProjectByID(ctx, newProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !newProject.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) {
|
||||||
|
return util.NewPermissionDeniedErrorf("issue %d can't be accessed by project %d", issue.ID, newProject.ID)
|
||||||
|
}
|
||||||
|
if newColumnID == 0 {
|
||||||
|
newDefaultColumn, err := newProject.GetDefaultBoard(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newColumnID = newDefaultColumn.ID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); err != nil {
|
if _, err := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); err != nil {
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if oldProjectID > 0 || newProjectID > 0 {
|
|
||||||
if _, err := CreateComment(ctx, &CreateCommentOptions{
|
|
||||||
Type: CommentTypeProject,
|
|
||||||
Doer: doer,
|
|
||||||
Repo: issue.Repo,
|
|
||||||
Issue: issue,
|
|
||||||
OldProjectID: oldProjectID,
|
|
||||||
ProjectID: newProjectID,
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return db.Insert(ctx, &project_model.ProjectIssue{
|
if oldProjectID > 0 || newProjectID > 0 {
|
||||||
IssueID: issue.ID,
|
if _, err := CreateComment(ctx, &CreateCommentOptions{
|
||||||
ProjectID: newProjectID,
|
Type: CommentTypeProject,
|
||||||
|
Doer: doer,
|
||||||
|
Repo: issue.Repo,
|
||||||
|
Issue: issue,
|
||||||
|
OldProjectID: oldProjectID,
|
||||||
|
ProjectID: newProjectID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if newProjectID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if newColumnID == 0 {
|
||||||
|
panic("newColumnID must not be zero") // shouldn't happen
|
||||||
|
}
|
||||||
|
|
||||||
|
res := struct {
|
||||||
|
MaxSorting int64
|
||||||
|
IssueCount int64
|
||||||
|
}{}
|
||||||
|
if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue").
|
||||||
|
Where("project_id=?", newProjectID).
|
||||||
|
And("project_board_id=?", newColumnID).
|
||||||
|
Get(&res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
|
||||||
|
return db.Insert(ctx, &project_model.ProjectIssue{
|
||||||
|
IssueID: issue.ID,
|
||||||
|
ProjectID: newProjectID,
|
||||||
|
ProjectBoardID: newColumnID,
|
||||||
|
Sorting: newSorting,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,12 +5,14 @@ package project
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/timeutil"
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
|
||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
@ -82,6 +84,17 @@ func (b *Board) NumIssues(ctx context.Context) int {
|
||||||
return int(c)
|
return int(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Board) GetIssues(ctx context.Context) ([]*ProjectIssue, error) {
|
||||||
|
issues := make([]*ProjectIssue, 0, 5)
|
||||||
|
if err := db.GetEngine(ctx).Where("project_id=?", b.ProjectID).
|
||||||
|
And("project_board_id=?", b.ID).
|
||||||
|
OrderBy("sorting, id").
|
||||||
|
Find(&issues); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return issues, nil
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
db.RegisterModel(new(Board))
|
db.RegisterModel(new(Board))
|
||||||
}
|
}
|
||||||
|
@ -152,12 +165,27 @@ func createBoardsForProjectsType(ctx context.Context, project *Project) error {
|
||||||
return db.Insert(ctx, boards)
|
return db.Insert(ctx, boards)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxProjectColumns max columns allowed in a project, this should not bigger than 127
|
||||||
|
// because sorting is int8 in database
|
||||||
|
const maxProjectColumns = 20
|
||||||
|
|
||||||
// NewBoard adds a new project board to a given project
|
// NewBoard adds a new project board to a given project
|
||||||
func NewBoard(ctx context.Context, board *Board) error {
|
func NewBoard(ctx context.Context, board *Board) error {
|
||||||
if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) {
|
if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) {
|
||||||
return fmt.Errorf("bad color code: %s", board.Color)
|
return fmt.Errorf("bad color code: %s", board.Color)
|
||||||
}
|
}
|
||||||
|
res := struct {
|
||||||
|
MaxSorting int64
|
||||||
|
ColumnCount int64
|
||||||
|
}{}
|
||||||
|
if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as column_count").Table("project_board").
|
||||||
|
Where("project_id=?", board.ProjectID).Get(&res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if res.ColumnCount >= maxProjectColumns {
|
||||||
|
return fmt.Errorf("NewBoard: maximum number of columns reached")
|
||||||
|
}
|
||||||
|
board.Sorting = int8(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0))
|
||||||
_, err := db.GetEngine(ctx).Insert(board)
|
_, err := db.GetEngine(ctx).Insert(board)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -191,7 +219,17 @@ func deleteBoardByID(ctx context.Context, boardID int64) error {
|
||||||
return fmt.Errorf("deleteBoardByID: cannot delete default board")
|
return fmt.Errorf("deleteBoardByID: cannot delete default board")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = board.removeIssues(ctx); err != nil {
|
// move all issues to the default column
|
||||||
|
project, err := GetProjectByID(ctx, board.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defaultColumn, err := project.GetDefaultBoard(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = board.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -244,21 +282,15 @@ func UpdateBoard(ctx context.Context, board *Board) error {
|
||||||
// GetBoards fetches all boards related to a project
|
// GetBoards fetches all boards related to a project
|
||||||
func (p *Project) GetBoards(ctx context.Context) (BoardList, error) {
|
func (p *Project) GetBoards(ctx context.Context) (BoardList, error) {
|
||||||
boards := make([]*Board, 0, 5)
|
boards := make([]*Board, 0, 5)
|
||||||
|
if err := db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Find(&boards); err != nil {
|
||||||
if err := db.GetEngine(ctx).Where("project_id=? AND `default`=?", p.ID, false).OrderBy("sorting").Find(&boards); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultB, err := p.getDefaultBoard(ctx)
|
return boards, nil
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return append([]*Board{defaultB}, boards...), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDefaultBoard return default board and ensure only one exists
|
// GetDefaultBoard return default board and ensure only one exists
|
||||||
func (p *Project) getDefaultBoard(ctx context.Context) (*Board, error) {
|
func (p *Project) GetDefaultBoard(ctx context.Context) (*Board, error) {
|
||||||
var board Board
|
var board Board
|
||||||
has, err := db.GetEngine(ctx).
|
has, err := db.GetEngine(ctx).
|
||||||
Where("project_id=? AND `default` = ?", p.ID, true).
|
Where("project_id=? AND `default` = ?", p.ID, true).
|
||||||
|
@ -318,3 +350,42 @@ func UpdateBoardSorting(ctx context.Context, bs BoardList) error {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (BoardList, error) {
|
||||||
|
columns := make([]*Board, 0, 5)
|
||||||
|
if err := db.GetEngine(ctx).
|
||||||
|
Where("project_id =?", projectID).
|
||||||
|
In("id", columnsIDs).
|
||||||
|
OrderBy("sorting").Find(&columns); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return columns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveColumnsOnProject sorts columns in a project
|
||||||
|
func MoveColumnsOnProject(ctx context.Context, project *Project, sortedColumnIDs map[int64]int64) error {
|
||||||
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
|
sess := db.GetEngine(ctx)
|
||||||
|
columnIDs := util.ValuesOfMap(sortedColumnIDs)
|
||||||
|
movedColumns, err := GetColumnsByIDs(ctx, project.ID, columnIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(movedColumns) != len(sortedColumnIDs) {
|
||||||
|
return errors.New("some columns do not exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, column := range movedColumns {
|
||||||
|
if column.ProjectID != project.ID {
|
||||||
|
return fmt.Errorf("column[%d]'s projectID is not equal to project's ID [%d]", column.ProjectID, project.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for sorting, columnID := range sortedColumnIDs {
|
||||||
|
if _, err := sess.Exec("UPDATE `project_board` SET sorting=? WHERE id=?", sorting, columnID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -4,6 +4,8 @@
|
||||||
package project
|
package project
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
|
@ -19,7 +21,7 @@ func TestGetDefaultBoard(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// check if default board was added
|
// check if default board was added
|
||||||
board, err := projectWithoutDefault.getDefaultBoard(db.DefaultContext)
|
board, err := projectWithoutDefault.GetDefaultBoard(db.DefaultContext)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, int64(5), board.ProjectID)
|
assert.Equal(t, int64(5), board.ProjectID)
|
||||||
assert.Equal(t, "Uncategorized", board.Title)
|
assert.Equal(t, "Uncategorized", board.Title)
|
||||||
|
@ -28,7 +30,7 @@ func TestGetDefaultBoard(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// check if multiple defaults were removed
|
// check if multiple defaults were removed
|
||||||
board, err = projectWithMultipleDefaults.getDefaultBoard(db.DefaultContext)
|
board, err = projectWithMultipleDefaults.GetDefaultBoard(db.DefaultContext)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, int64(6), board.ProjectID)
|
assert.Equal(t, int64(6), board.ProjectID)
|
||||||
assert.Equal(t, int64(9), board.ID)
|
assert.Equal(t, int64(9), board.ID)
|
||||||
|
@ -42,3 +44,84 @@ func TestGetDefaultBoard(t *testing.T) {
|
||||||
assert.Equal(t, int64(6), board.ProjectID)
|
assert.Equal(t, int64(6), board.ProjectID)
|
||||||
assert.False(t, board.Default)
|
assert.False(t, board.Default)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_moveIssuesToAnotherColumn(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
column1 := unittest.AssertExistsAndLoadBean(t, &Board{ID: 1, ProjectID: 1})
|
||||||
|
|
||||||
|
issues, err := column1.GetIssues(db.DefaultContext)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, issues, 1)
|
||||||
|
assert.EqualValues(t, 1, issues[0].ID)
|
||||||
|
|
||||||
|
column2 := unittest.AssertExistsAndLoadBean(t, &Board{ID: 2, ProjectID: 1})
|
||||||
|
issues, err = column2.GetIssues(db.DefaultContext)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, issues, 1)
|
||||||
|
assert.EqualValues(t, 3, issues[0].ID)
|
||||||
|
|
||||||
|
err = column1.moveIssuesToAnotherColumn(db.DefaultContext, column2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
issues, err = column1.GetIssues(db.DefaultContext)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, issues, 0)
|
||||||
|
|
||||||
|
issues, err = column2.GetIssues(db.DefaultContext)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, issues, 2)
|
||||||
|
assert.EqualValues(t, 3, issues[0].ID)
|
||||||
|
assert.EqualValues(t, 0, issues[0].Sorting)
|
||||||
|
assert.EqualValues(t, 1, issues[1].ID)
|
||||||
|
assert.EqualValues(t, 1, issues[1].Sorting)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_MoveColumnsOnProject(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
|
||||||
|
columns, err := project1.GetBoards(db.DefaultContext)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, columns, 3)
|
||||||
|
assert.EqualValues(t, 0, columns[0].Sorting) // even if there is no default sorting, the code should also work
|
||||||
|
assert.EqualValues(t, 0, columns[1].Sorting)
|
||||||
|
assert.EqualValues(t, 0, columns[2].Sorting)
|
||||||
|
|
||||||
|
err = MoveColumnsOnProject(db.DefaultContext, project1, map[int64]int64{
|
||||||
|
0: columns[1].ID,
|
||||||
|
1: columns[2].ID,
|
||||||
|
2: columns[0].ID,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
columnsAfter, err := project1.GetBoards(db.DefaultContext)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, columnsAfter, 3)
|
||||||
|
assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID)
|
||||||
|
assert.EqualValues(t, columns[2].ID, columnsAfter[1].ID)
|
||||||
|
assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_NewBoard(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
|
||||||
|
columns, err := project1.GetBoards(db.DefaultContext)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, columns, 3)
|
||||||
|
|
||||||
|
for i := 0; i < maxProjectColumns-3; i++ {
|
||||||
|
err := NewBoard(db.DefaultContext, &Board{
|
||||||
|
Title: fmt.Sprintf("board-%d", i+4),
|
||||||
|
ProjectID: project1.ID,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
err = NewBoard(db.DefaultContext, &Board{
|
||||||
|
Title: "board-21",
|
||||||
|
ProjectID: project1.ID,
|
||||||
|
})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.True(t, strings.Contains(err.Error(), "maximum number of columns reached"))
|
||||||
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProjectIssue saves relation from issue to a project
|
// ProjectIssue saves relation from issue to a project
|
||||||
|
@ -17,7 +18,7 @@ type ProjectIssue struct { //revive:disable-line:exported
|
||||||
IssueID int64 `xorm:"INDEX"`
|
IssueID int64 `xorm:"INDEX"`
|
||||||
ProjectID int64 `xorm:"INDEX"`
|
ProjectID int64 `xorm:"INDEX"`
|
||||||
|
|
||||||
// If 0, then it has not been added to a specific board in the project
|
// ProjectBoardID should not be zero since 1.22. If it's zero, the issue will not be displayed on UI and it might result in errors.
|
||||||
ProjectBoardID int64 `xorm:"INDEX"`
|
ProjectBoardID int64 `xorm:"INDEX"`
|
||||||
|
|
||||||
// the sorting order on the board
|
// the sorting order on the board
|
||||||
|
@ -79,11 +80,8 @@ func (p *Project) NumOpenIssues(ctx context.Context) int {
|
||||||
func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs map[int64]int64) error {
|
func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs map[int64]int64) error {
|
||||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
sess := db.GetEngine(ctx)
|
sess := db.GetEngine(ctx)
|
||||||
|
issueIDs := util.ValuesOfMap(sortedIssueIDs)
|
||||||
|
|
||||||
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()
|
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
|
||||||
|
@ -102,7 +100,44 @@ func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Board) removeIssues(ctx context.Context) error {
|
func (b *Board) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Board) error {
|
||||||
_, err := db.GetEngine(ctx).Exec("UPDATE `project_issue` SET project_board_id = 0 WHERE project_board_id = ? ", b.ID)
|
if b.ProjectID != newColumn.ProjectID {
|
||||||
return err
|
return fmt.Errorf("columns have to be in the same project")
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.ID == newColumn.ID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res := struct {
|
||||||
|
MaxSorting int64
|
||||||
|
IssueCount int64
|
||||||
|
}{}
|
||||||
|
if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").
|
||||||
|
Table("project_issue").
|
||||||
|
Where("project_id=?", newColumn.ProjectID).
|
||||||
|
And("project_board_id=?", newColumn.ID).
|
||||||
|
Get(&res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
issues, err := b.GetIssues(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
|
||||||
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
|
for i, issue := range issues {
|
||||||
|
issue.ProjectBoardID = newColumn.ID
|
||||||
|
issue.Sorting = nextSorting + int64(i)
|
||||||
|
if _, err := db.GetEngine(ctx).ID(issue.ID).Cols("project_board_id", "sorting").Update(issue); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -161,6 +161,13 @@ func (p *Project) IsRepositoryProject() bool {
|
||||||
return p.Type == TypeRepository
|
return p.Type == TypeRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Project) CanBeAccessedByOwnerRepo(ownerID int64, repo *repo_model.Repository) bool {
|
||||||
|
if p.Type == TypeRepository {
|
||||||
|
return repo != nil && p.RepoID == repo.ID // if a project belongs to a repository, then its OwnerID is 0 and can be ignored
|
||||||
|
}
|
||||||
|
return p.OwnerID == ownerID && p.RepoID == 0
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
db.RegisterModel(new(Project))
|
db.RegisterModel(new(Project))
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
|
@ -390,74 +389,6 @@ func ViewProject(ctx *context.Context) {
|
||||||
ctx.HTML(http.StatusOK, tplProjectsView)
|
ctx.HTML(http.StatusOK, tplProjectsView)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getActionIssues(ctx *context.Context) issues_model.IssueList {
|
|
||||||
commaSeparatedIssueIDs := ctx.FormString("issue_ids")
|
|
||||||
if len(commaSeparatedIssueIDs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
issueIDs := make([]int64, 0, 10)
|
|
||||||
for _, stringIssueID := range strings.Split(commaSeparatedIssueIDs, ",") {
|
|
||||||
issueID, err := strconv.ParseInt(stringIssueID, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
ctx.ServerError("ParseInt", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
issueIDs = append(issueIDs, issueID)
|
|
||||||
}
|
|
||||||
issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
|
|
||||||
if err != nil {
|
|
||||||
ctx.ServerError("GetIssuesByIDs", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Check access rights for all issues
|
|
||||||
issueUnitEnabled := ctx.Repo.CanRead(unit.TypeIssues)
|
|
||||||
prUnitEnabled := ctx.Repo.CanRead(unit.TypePullRequests)
|
|
||||||
for _, issue := range issues {
|
|
||||||
if issue.RepoID != ctx.Repo.Repository.ID {
|
|
||||||
ctx.NotFound("some issue's RepoID is incorrect", errors.New("some issue's RepoID is incorrect"))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if issue.IsPull && !prUnitEnabled || !issue.IsPull && !issueUnitEnabled {
|
|
||||||
ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err = issue.LoadAttributes(ctx); err != nil {
|
|
||||||
ctx.ServerError("LoadAttributes", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return issues
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateIssueProject change an issue's project
|
|
||||||
func UpdateIssueProject(ctx *context.Context) {
|
|
||||||
issues := getActionIssues(ctx)
|
|
||||||
if ctx.Written() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := issues.LoadProjects(ctx); err != nil {
|
|
||||||
ctx.ServerError("LoadProjects", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
projectID := ctx.FormInt64("id")
|
|
||||||
for _, issue := range issues {
|
|
||||||
if issue.Project != nil {
|
|
||||||
if issue.Project.ID == projectID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil {
|
|
||||||
ctx.ServerError("ChangeProjectAssign", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSONOK()
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteProjectBoard allows for the deletion of a project board
|
// DeleteProjectBoard allows for the deletion of a project board
|
||||||
func DeleteProjectBoard(ctx *context.Context) {
|
func DeleteProjectBoard(ctx *context.Context) {
|
||||||
if ctx.Doer == nil {
|
if ctx.Doer == nil {
|
||||||
|
|
|
@ -1267,8 +1267,8 @@ func NewIssuePost(ctx *context.Context) {
|
||||||
ctx.Error(http.StatusBadRequest, "user hasn't permissions to read projects")
|
ctx.Error(http.StatusBadRequest, "user hasn't permissions to read projects")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil {
|
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil {
|
||||||
ctx.ServerError("ChangeProjectAssign", err)
|
ctx.ServerError("IssueAssignOrRemoveProject", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ import (
|
||||||
"code.gitea.io/gitea/modules/markup/markdown"
|
"code.gitea.io/gitea/modules/markup/markdown"
|
||||||
"code.gitea.io/gitea/modules/optional"
|
"code.gitea.io/gitea/modules/optional"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
"code.gitea.io/gitea/modules/web"
|
"code.gitea.io/gitea/modules/web"
|
||||||
"code.gitea.io/gitea/services/context"
|
"code.gitea.io/gitea/services/context"
|
||||||
"code.gitea.io/gitea/services/forms"
|
"code.gitea.io/gitea/services/forms"
|
||||||
|
@ -382,17 +383,21 @@ func UpdateIssueProject(ctx *context.Context) {
|
||||||
ctx.ServerError("LoadProjects", err)
|
ctx.ServerError("LoadProjects", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if _, err := issues.LoadRepositories(ctx); err != nil {
|
||||||
|
ctx.ServerError("LoadProjects", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
projectID := ctx.FormInt64("id")
|
projectID := ctx.FormInt64("id")
|
||||||
for _, issue := range issues {
|
for _, issue := range issues {
|
||||||
if issue.Project != nil {
|
if issue.Project != nil && issue.Project.ID == projectID {
|
||||||
if issue.Project.ID == projectID {
|
continue
|
||||||
|
}
|
||||||
|
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil {
|
||||||
|
if errors.Is(err, util.ErrPermissionDenied) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
ctx.ServerError("IssueAssignOrRemoveProject", err)
|
||||||
|
|
||||||
if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil {
|
|
||||||
ctx.ServerError("ChangeProjectAssign", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1541,14 +1541,12 @@ func CompareAndPullRequestPost(ctx *context.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if projectID > 0 {
|
if projectID > 0 && ctx.Repo.CanWrite(unit.TypeProjects) {
|
||||||
if !ctx.Repo.CanWrite(unit.TypeProjects) {
|
if err := issues_model.IssueAssignOrRemoveProject(ctx, pullIssue, ctx.Doer, projectID, 0); err != nil {
|
||||||
ctx.Error(http.StatusBadRequest, "user hasn't the permission to write to projects")
|
if !errors.Is(err, util.ErrPermissionDenied) {
|
||||||
return
|
ctx.ServerError("IssueAssignOrRemoveProject", err)
|
||||||
}
|
return
|
||||||
if err := issues_model.ChangeProjectAssign(ctx, pullIssue, ctx.Doer, projectID); err != nil {
|
}
|
||||||
ctx.ServerError("ChangeProjectAssign", err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
48
routers/web/shared/project/column.go
Normal file
48
routers/web/shared/project/column.go
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package project
|
||||||
|
|
||||||
|
import (
|
||||||
|
project_model "code.gitea.io/gitea/models/project"
|
||||||
|
"code.gitea.io/gitea/modules/json"
|
||||||
|
"code.gitea.io/gitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MoveColumns moves or keeps columns in a project and sorts them inside that project
|
||||||
|
func MoveColumns(ctx *context.Context) {
|
||||||
|
project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) {
|
||||||
|
ctx.NotFound("CanBeAccessedByOwnerRepo", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type movedColumnsForm struct {
|
||||||
|
Columns []struct {
|
||||||
|
ColumnID int64 `json:"columnID"`
|
||||||
|
Sorting int64 `json:"sorting"`
|
||||||
|
} `json:"columns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
form := &movedColumnsForm{}
|
||||||
|
if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
|
||||||
|
ctx.ServerError("DecodeMovedColumnsForm", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sortedColumnIDs := make(map[int64]int64)
|
||||||
|
for _, column := range form.Columns {
|
||||||
|
sortedColumnIDs[column.Sorting] = column.ColumnID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil {
|
||||||
|
ctx.ServerError("MoveColumnsOnProject", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSONOK()
|
||||||
|
}
|
|
@ -39,6 +39,7 @@ import (
|
||||||
"code.gitea.io/gitea/routers/web/repo/badges"
|
"code.gitea.io/gitea/routers/web/repo/badges"
|
||||||
repo_flags "code.gitea.io/gitea/routers/web/repo/flags"
|
repo_flags "code.gitea.io/gitea/routers/web/repo/flags"
|
||||||
repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
|
repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
|
||||||
|
"code.gitea.io/gitea/routers/web/shared/project"
|
||||||
"code.gitea.io/gitea/routers/web/user"
|
"code.gitea.io/gitea/routers/web/user"
|
||||||
user_setting "code.gitea.io/gitea/routers/web/user/setting"
|
user_setting "code.gitea.io/gitea/routers/web/user/setting"
|
||||||
"code.gitea.io/gitea/routers/web/user/setting/security"
|
"code.gitea.io/gitea/routers/web/user/setting/security"
|
||||||
|
@ -976,6 +977,7 @@ func registerRoutes(m *web.Route) {
|
||||||
m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost)
|
m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost)
|
||||||
m.Group("/{id}", func() {
|
m.Group("/{id}", func() {
|
||||||
m.Post("", web.Bind(forms.EditProjectBoardForm{}), org.AddBoardToProjectPost)
|
m.Post("", web.Bind(forms.EditProjectBoardForm{}), org.AddBoardToProjectPost)
|
||||||
|
m.Post("/move", project.MoveColumns)
|
||||||
m.Post("/delete", org.DeleteProject)
|
m.Post("/delete", org.DeleteProject)
|
||||||
|
|
||||||
m.Get("/edit", org.RenderEditProject)
|
m.Get("/edit", org.RenderEditProject)
|
||||||
|
@ -1349,6 +1351,7 @@ func registerRoutes(m *web.Route) {
|
||||||
m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost)
|
m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost)
|
||||||
m.Group("/{id}", func() {
|
m.Group("/{id}", func() {
|
||||||
m.Post("", web.Bind(forms.EditProjectBoardForm{}), repo.AddBoardToProjectPost)
|
m.Post("", web.Bind(forms.EditProjectBoardForm{}), repo.AddBoardToProjectPost)
|
||||||
|
m.Post("/move", project.MoveColumns)
|
||||||
m.Post("/delete", repo.DeleteProject)
|
m.Post("/delete", repo.DeleteProject)
|
||||||
|
|
||||||
m.Get("/edit", repo.RenderEditProject)
|
m.Get("/edit", repo.RenderEditProject)
|
||||||
|
|
|
@ -64,7 +64,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="project-board">
|
<div id="project-board">
|
||||||
<div class="board {{if .CanWriteProjects}}sortable{{end}}">
|
<div class="board {{if .CanWriteProjects}}sortable{{end}}"{{if .CanWriteProjects}} data-url="{{$.Link}}/move"{{end}}>
|
||||||
{{range .Columns}}
|
{{range .Columns}}
|
||||||
<div class="ui segment project-column"{{if .Color}} style="background: {{.Color}} !important; color: {{ContrastColor .Color}} !important"{{end}} data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
|
<div class="ui segment project-column"{{if .Color}} style="background: {{.Color}} !important; color: {{ContrastColor .Color}} !important"{{end}} data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
|
||||||
<div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}">
|
<div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}">
|
||||||
|
|
|
@ -5,17 +5,17 @@ package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
unit_model "code.gitea.io/gitea/models/unit"
|
unit_model "code.gitea.io/gitea/models/unit"
|
||||||
|
"code.gitea.io/gitea/modules/test"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOrgProjectAccess(t *testing.T) {
|
func TestOrgProjectAccess(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(t)()
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
defer test.MockVariableValue(&unit_model.DisabledRepoUnits, append(slices.Clone(unit_model.DisabledRepoUnits), unit_model.TypeProjects))()
|
||||||
// disable repo project unit
|
|
||||||
unit_model.DisabledRepoUnits = []unit_model.Type{unit_model.TypeProjects}
|
|
||||||
|
|
||||||
// repo project, 404
|
// repo project, 404
|
||||||
req := NewRequest(t, "GET", "/user2/repo1/projects")
|
req := NewRequest(t, "GET", "/user2/repo1/projects")
|
||||||
|
|
|
@ -4,10 +4,17 @@
|
||||||
package integration
|
package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
|
project_model "code.gitea.io/gitea/models/project"
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPrivateRepoProject(t *testing.T) {
|
func TestPrivateRepoProject(t *testing.T) {
|
||||||
|
@ -21,3 +28,56 @@ func TestPrivateRepoProject(t *testing.T) {
|
||||||
req = NewRequest(t, "GET", "/user31/-/projects")
|
req = NewRequest(t, "GET", "/user31/-/projects")
|
||||||
sess.MakeRequest(t, req, http.StatusOK)
|
sess.MakeRequest(t, req, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMoveRepoProjectColumns(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||||
|
|
||||||
|
project1 := project_model.Project{
|
||||||
|
Title: "new created project",
|
||||||
|
RepoID: repo2.ID,
|
||||||
|
Type: project_model.TypeRepository,
|
||||||
|
BoardType: project_model.BoardTypeNone,
|
||||||
|
}
|
||||||
|
err := project_model.NewProject(db.DefaultContext, &project1)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
err = project_model.NewBoard(db.DefaultContext, &project_model.Board{
|
||||||
|
Title: fmt.Sprintf("column %d", i+1),
|
||||||
|
ProjectID: project1.ID,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
columns, err := project1.GetBoards(db.DefaultContext)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, columns, 3)
|
||||||
|
assert.EqualValues(t, 0, columns[0].Sorting)
|
||||||
|
assert.EqualValues(t, 1, columns[1].Sorting)
|
||||||
|
assert.EqualValues(t, 2, columns[2].Sorting)
|
||||||
|
|
||||||
|
sess := loginUser(t, "user1")
|
||||||
|
req := NewRequest(t, "GET", fmt.Sprintf("/%s/projects/%d", repo2.FullName(), project1.ID))
|
||||||
|
resp := sess.MakeRequest(t, req, http.StatusOK)
|
||||||
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||||
|
|
||||||
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/projects/%d/move?_csrf="+htmlDoc.GetCSRF(), repo2.FullName(), project1.ID), map[string]any{
|
||||||
|
"columns": []map[string]any{
|
||||||
|
{"columnID": columns[1].ID, "sorting": 0},
|
||||||
|
{"columnID": columns[2].ID, "sorting": 1},
|
||||||
|
{"columnID": columns[0].ID, "sorting": 2},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
sess.MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
columnsAfter, err := project1.GetBoards(db.DefaultContext)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, columns, 3)
|
||||||
|
assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID)
|
||||||
|
assert.EqualValues(t, columns[2].ID, columnsAfter[1].ID)
|
||||||
|
assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID)
|
||||||
|
|
||||||
|
assert.NoError(t, project_model.DeleteProjectByID(db.DefaultContext, project1.ID))
|
||||||
|
}
|
||||||
|
|
|
@ -2,7 +2,6 @@ import $ from 'jquery';
|
||||||
import {contrastColor} from '../utils/color.js';
|
import {contrastColor} from '../utils/color.js';
|
||||||
import {createSortable} from '../modules/sortable.js';
|
import {createSortable} from '../modules/sortable.js';
|
||||||
import {POST, DELETE, PUT} from '../modules/fetch.js';
|
import {POST, DELETE, PUT} from '../modules/fetch.js';
|
||||||
import tinycolor from 'tinycolor2';
|
|
||||||
|
|
||||||
function updateIssueCount(cards) {
|
function updateIssueCount(cards) {
|
||||||
const parent = cards.parentElement;
|
const parent = cards.parentElement;
|
||||||
|
@ -63,17 +62,20 @@ async function initRepoProjectSortable() {
|
||||||
delay: 500,
|
delay: 500,
|
||||||
onSort: async () => {
|
onSort: async () => {
|
||||||
boardColumns = mainBoard.getElementsByClassName('project-column');
|
boardColumns = mainBoard.getElementsByClassName('project-column');
|
||||||
for (let i = 0; i < boardColumns.length; i++) {
|
|
||||||
const column = boardColumns[i];
|
const columnSorting = {
|
||||||
if (parseInt(column.getAttribute('data-sorting')) !== i) {
|
columns: Array.from(boardColumns, (column, i) => ({
|
||||||
try {
|
columnID: parseInt(column.getAttribute('data-id')),
|
||||||
const bgColor = column.style.backgroundColor; // will be rgb() string
|
sorting: i,
|
||||||
const color = bgColor ? tinycolor(bgColor).toHexString() : '';
|
})),
|
||||||
await PUT(column.getAttribute('data-url'), {data: {sorting: i, color}});
|
};
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
try {
|
||||||
}
|
await POST(mainBoard.getAttribute('data-url'), {
|
||||||
}
|
data: columnSorting,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue