mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	Fix various problems around projects board view (#30696)
# The problem 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. # What's changed - [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>
This commit is contained in:
		| @@ -57,6 +57,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)) | ||||||
| } | } | ||||||
| @@ -150,12 +163,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 | ||||||
| } | } | ||||||
| @@ -189,7 +217,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 | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -242,21 +280,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). | ||||||
| @@ -316,3 +348,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 { | ||||||
|   | |||||||
| @@ -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" | ||||||
| @@ -383,17 +384,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 | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -1329,14 +1329,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() | ||||||
|  | } | ||||||
| @@ -37,6 +37,7 @@ import ( | |||||||
| 	"code.gitea.io/gitea/routers/web/repo" | 	"code.gitea.io/gitea/routers/web/repo" | ||||||
| 	"code.gitea.io/gitea/routers/web/repo/actions" | 	"code.gitea.io/gitea/routers/web/repo/actions" | ||||||
| 	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" | ||||||
| @@ -999,6 +1000,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) | ||||||
| @@ -1354,6 +1356,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) | ||||||
|   | |||||||
| @@ -42,7 +42,7 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo | |||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		if projectID > 0 { | 		if projectID > 0 { | ||||||
| 			if err := issues_model.ChangeProjectAssign(ctx, issue, issue.Poster, projectID); err != nil { | 			if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectID, 0); err != nil { | ||||||
| 				return err | 				return err | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -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,18 @@ | |||||||
| 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/unit" | ||||||
|  | 	"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 +29,59 @@ 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}) | ||||||
|  |  | ||||||
|  | 	projectsUnit := repo2.MustGetUnit(db.DefaultContext, unit.TypeProjects) | ||||||
|  | 	assert.True(t, projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo)) | ||||||
|  |  | ||||||
|  | 	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); | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user