@@ -57,6 +57,7 @@ type Engine interface { | |||
SumInt(bean any, columnName string) (res int64, err error) | |||
Sync(...any) error | |||
Select(string) *xorm.Session | |||
SetExpr(string, any) *xorm.Session | |||
NotIn(string, ...any) *xorm.Session | |||
OrderBy(any, ...any) *xorm.Session | |||
Exist(...any) (bool, error) |
@@ -90,22 +90,16 @@ func LoadIssuesFromBoardList(ctx context.Context, bs project_model.BoardList) (m | |||
return issuesMap, nil | |||
} | |||
// ChangeProjectAssign changes the project associated with an issue | |||
func ChangeProjectAssign(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error { | |||
ctx, committer, err := db.TxContext(ctx) | |||
if err != nil { | |||
return err | |||
} | |||
defer committer.Close() | |||
if err := addUpdateIssueProject(ctx, issue, doer, newProjectID); err != nil { | |||
return err | |||
} | |||
return committer.Commit() | |||
// ChangeProjectAssign changes the project associated with an issue, if newProjectID is 0, the issue is removed from the project | |||
func ChangeProjectAssign(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID, newColumnID int64) error { | |||
return db.WithTx(ctx, func(ctx context.Context) error { | |||
return addUpdateIssueProject(ctx, issue, doer, newProjectID, newColumnID) | |||
}) | |||
} | |||
func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error { | |||
// addUpdateIssueProject adds or updates the project the default column associated with an issue | |||
// If newProjectID is 0, the issue is removed from the project | |||
func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID, newColumnID int64) error { | |||
oldProjectID := issue.projectID(ctx) | |||
if err := issue.LoadRepo(ctx); err != nil { | |||
@@ -139,9 +133,25 @@ func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.U | |||
return err | |||
} | |||
} | |||
if newProjectID == 0 || newColumnID == 0 { | |||
return nil | |||
} | |||
var maxSorting int64 | |||
if _, err := db.GetEngine(ctx).Select("Max(sorting)").Table("project_issue"). | |||
Where("project_id=?", newProjectID). | |||
And("project_board_id=?", newColumnID). | |||
Get(&maxSorting); err != nil { | |||
return err | |||
} | |||
if maxSorting > 0 { | |||
maxSorting++ | |||
} | |||
return db.Insert(ctx, &project_model.ProjectIssue{ | |||
IssueID: issue.ID, | |||
ProjectID: newProjectID, | |||
IssueID: issue.ID, | |||
ProjectID: newProjectID, | |||
ProjectBoardID: newColumnID, | |||
Sorting: maxSorting, | |||
}) | |||
} |
@@ -156,6 +156,15 @@ func NewBoard(ctx context.Context, board *Board) error { | |||
return fmt.Errorf("bad color code: %s", board.Color) | |||
} | |||
var maxSorting int8 | |||
if _, err := db.GetEngine(ctx).Select("Max(sorting)").Table("project_board"). | |||
Where("project_id=?", board.ProjectID).Get(&maxSorting); err != nil { | |||
return err | |||
} | |||
if maxSorting > 0 { | |||
board.Sorting = maxSorting | |||
} | |||
_, err := db.GetEngine(ctx).Insert(board) | |||
return err | |||
} | |||
@@ -189,7 +198,17 @@ func deleteBoardByID(ctx context.Context, boardID int64) error { | |||
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 | |||
} | |||
defaultBoard, err := project.GetDefaultBoard(ctx) | |||
if err != nil { | |||
return err | |||
} | |||
if err = board.moveIssuesToDefault(ctx, defaultBoard.ID); err != nil { | |||
return err | |||
} | |||
@@ -242,21 +261,15 @@ func UpdateBoard(ctx context.Context, board *Board) error { | |||
// GetBoards fetches all boards related to a project | |||
func (p *Project) GetBoards(ctx context.Context) (BoardList, error) { | |||
boards := make([]*Board, 0, 5) | |||
if err := db.GetEngine(ctx).Where("project_id=? AND `default`=?", p.ID, false).OrderBy("sorting").Find(&boards); err != nil { | |||
return nil, err | |||
} | |||
defaultB, err := p.getDefaultBoard(ctx) | |||
if err != nil { | |||
if err := db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting").Find(&boards); err != nil { | |||
return nil, err | |||
} | |||
return append([]*Board{defaultB}, boards...), nil | |||
return boards, nil | |||
} | |||
// getDefaultBoard return default board and ensure only one exists | |||
func (p *Project) getDefaultBoard(ctx context.Context) (*Board, error) { | |||
// GetDefaultBoard return default board and ensure only one exists | |||
func (p *Project) GetDefaultBoard(ctx context.Context) (*Board, error) { | |||
var board Board | |||
has, err := db.GetEngine(ctx). | |||
Where("project_id=? AND `default` = ?", p.ID, true). | |||
@@ -316,3 +329,12 @@ func UpdateBoardSorting(ctx context.Context, bs BoardList) error { | |||
return nil | |||
}) | |||
} | |||
func GetColumnsByIDs(ctx context.Context, columnsIDs []int64) (BoardList, error) { | |||
columns := make([]*Board, 0, 5) | |||
if err := db.GetEngine(ctx).In("id", columnsIDs).OrderBy("sorting").Find(&columns); err != nil { | |||
return nil, err | |||
} | |||
return columns, nil | |||
} |
@@ -17,7 +17,7 @@ type ProjectIssue struct { //revive:disable-line:exported | |||
IssueID int64 `xorm:"INDEX"` | |||
ProjectID int64 `xorm:"INDEX"` | |||
// If 0, then it has not been added to a specific board in the project | |||
// If this should not be zero from 1.22. If it's zero, it will not be displayed on UI and maybe result in errors. | |||
ProjectBoardID int64 `xorm:"INDEX"` | |||
// the sorting order on the board | |||
@@ -102,7 +102,34 @@ func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs | |||
}) | |||
} | |||
func (b *Board) removeIssues(ctx context.Context) error { | |||
_, err := db.GetEngine(ctx).Exec("UPDATE `project_issue` SET project_board_id = 0 WHERE project_board_id = ? ", b.ID) | |||
func (b *Board) moveIssuesToDefault(ctx context.Context, defaultBoardID int64) error { | |||
_, err := db.GetEngine(ctx).Exec("UPDATE `project_issue` SET project_board_id = ? WHERE project_board_id = ? ", defaultBoardID, b.ID) | |||
return err | |||
} | |||
// MoveColumnsOnProject moves or keeps issues in a column and sorts them inside that column | |||
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 := make([]int64, 0, len(sortedColumnIDs)) | |||
for _, columnID := range sortedColumnIDs { | |||
columnIDs = append(columnIDs, columnID) | |||
} | |||
count, err := sess.Table(new(Board)).Where("project_id=?", project.ID).In("id", columnIDs).Count() | |||
if err != nil { | |||
return err | |||
} | |||
if int(count) != len(sortedColumnIDs) { | |||
return fmt.Errorf("all issues have to be added to a project first") | |||
} | |||
for sorting, columnID := range sortedColumnIDs { | |||
_, err = sess.Exec("UPDATE `project_board` SET sorting=? WHERE id=?", sorting, columnID) | |||
if err != nil { | |||
return err | |||
} | |||
} | |||
return nil | |||
}) | |||
} |
@@ -442,6 +442,21 @@ func UpdateIssueProject(ctx *context.Context) { | |||
} | |||
projectID := ctx.FormInt64("id") | |||
var dstColumnID int64 | |||
if projectID > 0 { | |||
dstProject, err := project_model.GetProjectByID(ctx, projectID) | |||
if err != nil { | |||
ctx.ServerError("GetProjectByID", err) | |||
return | |||
} | |||
dstDefaultColumn, err := dstProject.GetDefaultBoard(ctx) | |||
if err != nil { | |||
ctx.ServerError("GetDefaultBoard", err) | |||
return | |||
} | |||
dstColumnID = dstDefaultColumn.ID | |||
} | |||
for _, issue := range issues { | |||
if issue.Project != nil { | |||
if issue.Project.ID == projectID { | |||
@@ -449,7 +464,7 @@ func UpdateIssueProject(ctx *context.Context) { | |||
} | |||
} | |||
if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil { | |||
if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID, dstColumnID); err != nil { | |||
ctx.ServerError("ChangeProjectAssign", err) | |||
return | |||
} | |||
@@ -678,3 +693,66 @@ func MoveIssues(ctx *context.Context) { | |||
ctx.JSONOK() | |||
} | |||
// MoveColumns moves or keeps columns in a project and sorts them inside that project | |||
func MoveColumns(ctx *context.Context) { | |||
if ctx.Doer == nil { | |||
ctx.JSON(http.StatusForbidden, map[string]string{ | |||
"message": "Only signed in users are allowed to perform this action.", | |||
}) | |||
return | |||
} | |||
project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) | |||
if err != nil { | |||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) | |||
return | |||
} | |||
if project.OwnerID != ctx.ContextUser.ID { | |||
ctx.NotFound("InvalidRepoID", 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) | |||
} | |||
columnIDs := make([]int64, 0, len(form.Columns)) | |||
sortedColumnIDs := make(map[int64]int64) | |||
for _, column := range form.Columns { | |||
columnIDs = append(columnIDs, column.ColumnID) | |||
sortedColumnIDs[column.Sorting] = column.ColumnID | |||
} | |||
movedColumns, err := project_model.GetColumnsByIDs(ctx, columnIDs) | |||
if err != nil { | |||
ctx.NotFoundOrServerError("GetColumnsByIDs", issues_model.IsErrIssueNotExist, err) | |||
return | |||
} | |||
if len(movedColumns) != len(form.Columns) { | |||
ctx.ServerError("some columns do not exist", errors.New("some columns do not exist")) | |||
return | |||
} | |||
for _, column := range movedColumns { | |||
if column.ProjectID != project.ID { | |||
ctx.ServerError("Some column's projectID is not equal to project's ID", errors.New("Some column's projectID is not equal to project's ID")) | |||
return | |||
} | |||
} | |||
if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { | |||
ctx.ServerError("MoveColumnsOnProject", err) | |||
return | |||
} | |||
ctx.JSONOK() | |||
} |
@@ -385,6 +385,21 @@ func UpdateIssueProject(ctx *context.Context) { | |||
} | |||
projectID := ctx.FormInt64("id") | |||
var dstColumnID int64 | |||
if projectID > 0 { | |||
dstProject, err := project_model.GetProjectByID(ctx, projectID) | |||
if err != nil { | |||
ctx.ServerError("GetProjectByID", err) | |||
return | |||
} | |||
dstDefaultColumn, err := dstProject.GetDefaultBoard(ctx) | |||
if err != nil { | |||
ctx.ServerError("GetDefaultBoard", err) | |||
return | |||
} | |||
dstColumnID = dstDefaultColumn.ID | |||
} | |||
for _, issue := range issues { | |||
if issue.Project != nil { | |||
if issue.Project.ID == projectID { | |||
@@ -392,7 +407,7 @@ func UpdateIssueProject(ctx *context.Context) { | |||
} | |||
} | |||
if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil { | |||
if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID, dstColumnID); err != nil { | |||
ctx.ServerError("ChangeProjectAssign", err) | |||
return | |||
} | |||
@@ -666,3 +681,70 @@ func MoveIssues(ctx *context.Context) { | |||
ctx.JSONOK() | |||
} | |||
// MoveColumns moves or keeps columns in a project and sorts them inside that project | |||
func MoveColumns(ctx *context.Context) { | |||
if ctx.Doer == nil { | |||
ctx.JSON(http.StatusForbidden, map[string]string{ | |||
"message": "Only signed in users are allowed to perform this action.", | |||
}) | |||
return | |||
} | |||
project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) | |||
if err != nil { | |||
if project_model.IsErrProjectNotExist(err) { | |||
ctx.NotFound("ProjectNotExist", nil) | |||
} else { | |||
ctx.ServerError("GetProjectByID", err) | |||
} | |||
return | |||
} | |||
if project.RepoID != ctx.Repo.Repository.ID { | |||
ctx.NotFound("InvalidRepoID", 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) | |||
} | |||
columnIDs := make([]int64, 0, len(form.Columns)) | |||
sortedColumnIDs := make(map[int64]int64) | |||
for _, column := range form.Columns { | |||
columnIDs = append(columnIDs, column.ColumnID) | |||
sortedColumnIDs[column.Sorting] = column.ColumnID | |||
} | |||
movedColumns, err := project_model.GetColumnsByIDs(ctx, columnIDs) | |||
if err != nil { | |||
ctx.NotFoundOrServerError("GetColumnsByIDs", issues_model.IsErrIssueNotExist, err) | |||
return | |||
} | |||
if len(movedColumns) != len(form.Columns) { | |||
ctx.ServerError("some columns do not exist", errors.New("some columns do not exist")) | |||
return | |||
} | |||
for _, column := range movedColumns { | |||
if column.ProjectID != project.ID { | |||
ctx.ServerError("Some column's projectID is not equal to project's ID", errors.New("Some column's projectID is not equal to project's ID")) | |||
return | |||
} | |||
} | |||
if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { | |||
ctx.ServerError("MoveColumnsOnProject", err) | |||
return | |||
} | |||
ctx.JSONOK() | |||
} |
@@ -20,6 +20,7 @@ import ( | |||
git_model "code.gitea.io/gitea/models/git" | |||
issues_model "code.gitea.io/gitea/models/issues" | |||
access_model "code.gitea.io/gitea/models/perm/access" | |||
project_model "code.gitea.io/gitea/models/project" | |||
pull_model "code.gitea.io/gitea/models/pull" | |||
repo_model "code.gitea.io/gitea/models/repo" | |||
"code.gitea.io/gitea/models/unit" | |||
@@ -1331,10 +1332,20 @@ func CompareAndPullRequestPost(ctx *context.Context) { | |||
if projectID > 0 { | |||
if !ctx.Repo.CanWrite(unit.TypeProjects) { | |||
ctx.Error(http.StatusBadRequest, "user hasn't the permission to write to projects") | |||
log.Error("user hasn't the permission to write to projects") | |||
return | |||
} | |||
if err := issues_model.ChangeProjectAssign(ctx, pullIssue, ctx.Doer, projectID); err != nil { | |||
dstProject, err := project_model.GetProjectByID(ctx, projectID) | |||
if err != nil { | |||
ctx.ServerError("GetProjectByID", err) | |||
return | |||
} | |||
dstDefaultColumn, err := dstProject.GetDefaultBoard(ctx) | |||
if err != nil { | |||
ctx.ServerError("GetDefaultBoard", err) | |||
return | |||
} | |||
if err := issues_model.ChangeProjectAssign(ctx, pullIssue, ctx.Doer, projectID, dstDefaultColumn.ID); err != nil { | |||
ctx.ServerError("ChangeProjectAssign", err) | |||
return | |||
} |
@@ -999,6 +999,7 @@ func registerRoutes(m *web.Route) { | |||
m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost) | |||
m.Group("/{id}", func() { | |||
m.Post("", web.Bind(forms.EditProjectBoardForm{}), org.AddBoardToProjectPost) | |||
m.Post("/move", org.MoveColumns) | |||
m.Post("/delete", org.DeleteProject) | |||
m.Get("/edit", org.RenderEditProject) | |||
@@ -1354,6 +1355,7 @@ func registerRoutes(m *web.Route) { | |||
m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost) | |||
m.Group("/{id}", func() { | |||
m.Post("", web.Bind(forms.EditProjectBoardForm{}), repo.AddBoardToProjectPost) | |||
m.Post("/move", repo.MoveColumns) | |||
m.Post("/delete", repo.DeleteProject) | |||
m.Get("/edit", repo.RenderEditProject) |
@@ -42,7 +42,15 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo | |||
} | |||
} | |||
if projectID > 0 { | |||
if err := issues_model.ChangeProjectAssign(ctx, issue, issue.Poster, projectID); err != nil { | |||
project, err := project_model.GetProjectByID(ctx, projectID) | |||
if err != nil { | |||
return err | |||
} | |||
defaultBoard, err := project.GetDefaultBoard(ctx) | |||
if err != nil { | |||
return err | |||
} | |||
if err := issues_model.ChangeProjectAssign(ctx, issue, issue.Poster, projectID, defaultBoard.ID); err != nil { | |||
return err | |||
} | |||
} |
@@ -64,7 +64,7 @@ | |||
</div> | |||
<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}} | |||
<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}}"> | |||
@@ -90,7 +90,7 @@ | |||
data-modal-default-project-column-header="{{ctx.Locale.Tr "repo.projects.column.set_default"}}" | |||
data-modal-default-project-column-content="{{ctx.Locale.Tr "repo.projects.column.set_default_desc"}}" | |||
data-url="{{$.Link}}/{{.ID}}/default"> | |||
{{svg "octicon-pin"}} | |||
{{svg "octicon-star"}} | |||
{{ctx.Locale.Tr "repo.projects.column.set_default"}} | |||
</a> | |||
<a class="item show-modal button show-delete-project-column-modal" |
@@ -2,7 +2,6 @@ import $ from 'jquery'; | |||
import {contrastColor} from '../utils/color.js'; | |||
import {createSortable} from '../modules/sortable.js'; | |||
import {POST, DELETE, PUT} from '../modules/fetch.js'; | |||
import tinycolor from 'tinycolor2'; | |||
function updateIssueCount(cards) { | |||
const parent = cards.parentElement; | |||
@@ -63,17 +62,20 @@ async function initRepoProjectSortable() { | |||
delay: 500, | |||
onSort: async () => { | |||
boardColumns = mainBoard.getElementsByClassName('project-column'); | |||
for (let i = 0; i < boardColumns.length; i++) { | |||
const column = boardColumns[i]; | |||
if (parseInt(column.getAttribute('data-sorting')) !== i) { | |||
try { | |||
const bgColor = column.style.backgroundColor; // will be rgb() string | |||
const color = bgColor ? tinycolor(bgColor).toHexString() : ''; | |||
await PUT(column.getAttribute('data-url'), {data: {sorting: i, color}}); | |||
} catch (error) { | |||
console.error(error); | |||
} | |||
} | |||
const columnSorting = { | |||
columns: Array.from(boardColumns, (column, i) => ({ | |||
columnID: parseInt(column.getAttribute('data-id')), | |||
sorting: i, | |||
})), | |||
}; | |||
try { | |||
await POST(mainBoard.getAttribute('data-url'), { | |||
data: columnSorting, | |||
}); | |||
} catch (error) { | |||
console.error(error); | |||
} | |||
}, | |||
}); |