On creation of an empty project (no template) a default board will be created instead of falling back to the uneditable pseudo-board. Every project now has to have exactly one default boards. As a consequence, you cannot unset a board as default, instead you have to set another board as default. Existing projects will be modified using a cron job, additionally this check will run every midnight by default. Deleting the default board is not allowed, you have to set another board as default to do it. Fixes #29873 Fixes #14679 along the way Fixes #29853 Co-authored-by: delvh <dev.lh@web.de>tags/v1.22.0-rc0
type: 2 | type: 2 | ||||
created_unix: 1688973000 | created_unix: 1688973000 | ||||
updated_unix: 1688973000 | updated_unix: 1688973000 | ||||
- | |||||
id: 5 | |||||
title: project without default column | |||||
owner_id: 2 | |||||
repo_id: 0 | |||||
is_closed: false | |||||
creator_id: 2 | |||||
board_type: 1 | |||||
type: 2 | |||||
created_unix: 1688973000 | |||||
updated_unix: 1688973000 | |||||
- | |||||
id: 6 | |||||
title: project with multiple default columns | |||||
owner_id: 2 | |||||
repo_id: 0 | |||||
is_closed: false | |||||
creator_id: 2 | |||||
board_type: 1 | |||||
type: 2 | |||||
created_unix: 1688973000 | |||||
updated_unix: 1688973000 |
project_id: 1 | project_id: 1 | ||||
title: To Do | title: To Do | ||||
creator_id: 2 | creator_id: 2 | ||||
default: true | |||||
created_unix: 1588117528 | created_unix: 1588117528 | ||||
updated_unix: 1588117528 | updated_unix: 1588117528 | ||||
creator_id: 2 | creator_id: 2 | ||||
created_unix: 1588117528 | created_unix: 1588117528 | ||||
updated_unix: 1588117528 | updated_unix: 1588117528 | ||||
- | |||||
id: 5 | |||||
project_id: 2 | |||||
title: Backlog | |||||
creator_id: 2 | |||||
default: true | |||||
created_unix: 1588117528 | |||||
updated_unix: 1588117528 | |||||
- | |||||
id: 6 | |||||
project_id: 4 | |||||
title: Backlog | |||||
creator_id: 2 | |||||
default: true | |||||
created_unix: 1588117528 | |||||
updated_unix: 1588117528 | |||||
- | |||||
id: 7 | |||||
project_id: 5 | |||||
title: Done | |||||
creator_id: 2 | |||||
default: false | |||||
created_unix: 1588117528 | |||||
updated_unix: 1588117528 | |||||
- | |||||
id: 8 | |||||
project_id: 6 | |||||
title: Backlog | |||||
creator_id: 2 | |||||
default: true | |||||
created_unix: 1588117528 | |||||
updated_unix: 1588117528 | |||||
- | |||||
id: 9 | |||||
project_id: 6 | |||||
title: Uncategorized | |||||
creator_id: 2 | |||||
default: true | |||||
created_unix: 1588117528 | |||||
updated_unix: 1588117528 |
// LoadIssuesFromBoard load issues assigned to this board | // LoadIssuesFromBoard load issues assigned to this board | ||||
func LoadIssuesFromBoard(ctx context.Context, b *project_model.Board) (IssueList, error) { | func LoadIssuesFromBoard(ctx context.Context, b *project_model.Board) (IssueList, error) { | ||||
issueList := make(IssueList, 0, 10) | |||||
if b.ID > 0 { | |||||
issues, err := Issues(ctx, &IssuesOptions{ | |||||
ProjectBoardID: b.ID, | |||||
ProjectID: b.ProjectID, | |||||
SortType: "project-column-sorting", | |||||
}) | |||||
if err != nil { | |||||
return nil, err | |||||
} | |||||
issueList = issues | |||||
issueList, err := Issues(ctx, &IssuesOptions{ | |||||
ProjectBoardID: b.ID, | |||||
ProjectID: b.ProjectID, | |||||
SortType: "project-column-sorting", | |||||
}) | |||||
if err != nil { | |||||
return nil, err | |||||
} | } | ||||
if b.Default { | if b.Default { |
- | |||||
id: 1 | |||||
title: project without default column | |||||
owner_id: 2 | |||||
repo_id: 0 | |||||
is_closed: false | |||||
creator_id: 2 | |||||
board_type: 1 | |||||
type: 2 | |||||
created_unix: 1688973000 | |||||
updated_unix: 1688973000 | |||||
- | |||||
id: 2 | |||||
title: project with multiple default columns | |||||
owner_id: 2 | |||||
repo_id: 0 | |||||
is_closed: false | |||||
creator_id: 2 | |||||
board_type: 1 | |||||
type: 2 | |||||
created_unix: 1688973000 | |||||
updated_unix: 1688973000 |
- | |||||
id: 1 | |||||
project_id: 1 | |||||
title: Done | |||||
creator_id: 2 | |||||
default: false | |||||
created_unix: 1588117528 | |||||
updated_unix: 1588117528 | |||||
- | |||||
id: 2 | |||||
project_id: 2 | |||||
title: Backlog | |||||
creator_id: 2 | |||||
default: true | |||||
created_unix: 1588117528 | |||||
updated_unix: 1588117528 | |||||
- | |||||
id: 3 | |||||
project_id: 2 | |||||
title: Uncategorized | |||||
creator_id: 2 | |||||
default: true | |||||
created_unix: 1588117528 | |||||
updated_unix: 1588117528 |
NewMigration("Add PayloadVersion to HookTask", v1_22.AddPayloadVersionToHookTaskTable), | NewMigration("Add PayloadVersion to HookTask", v1_22.AddPayloadVersionToHookTaskTable), | ||||
// v291 -> v292 | // v291 -> v292 | ||||
NewMigration("Add Index to attachment.comment_id", v1_22.AddCommentIDIndexofAttachment), | NewMigration("Add Index to attachment.comment_id", v1_22.AddCommentIDIndexofAttachment), | ||||
// v292 -> v293 | |||||
NewMigration("Ensure every project has exactly one default column", v1_22.CheckProjectColumnsConsistency), | |||||
} | } | ||||
// GetCurrentDBVersion returns the current db version | // GetCurrentDBVersion returns the current db version |
// Copyright 2024 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package v1_22 //nolint | |||||
import ( | |||||
"code.gitea.io/gitea/models/project" | |||||
"code.gitea.io/gitea/modules/setting" | |||||
"xorm.io/builder" | |||||
"xorm.io/xorm" | |||||
) | |||||
// CheckProjectColumnsConsistency ensures there is exactly one default board per project present | |||||
func CheckProjectColumnsConsistency(x *xorm.Engine) error { | |||||
sess := x.NewSession() | |||||
defer sess.Close() | |||||
if err := sess.Begin(); err != nil { | |||||
return err | |||||
} | |||||
limit := setting.Database.IterateBufferSize | |||||
if limit <= 0 { | |||||
limit = 50 | |||||
} | |||||
start := 0 | |||||
for { | |||||
var projects []project.Project | |||||
if err := sess.SQL("SELECT DISTINCT `p`.`id`, `p`.`creator_id` FROM `project` `p` WHERE (SELECT COUNT(*) FROM `project_board` `pb` WHERE `pb`.`project_id` = `p`.`id` AND `pb`.`default` = ?) != 1", true). | |||||
Limit(limit, start). | |||||
Find(&projects); err != nil { | |||||
return err | |||||
} | |||||
if len(projects) == 0 { | |||||
break | |||||
} | |||||
start += len(projects) | |||||
for _, p := range projects { | |||||
var boards []project.Board | |||||
if err := sess.Where("project_id=? AND `default` = ?", p.ID, true).OrderBy("sorting").Find(&boards); err != nil { | |||||
return err | |||||
} | |||||
if len(boards) == 0 { | |||||
if _, err := sess.Insert(project.Board{ | |||||
ProjectID: p.ID, | |||||
Default: true, | |||||
Title: "Uncategorized", | |||||
CreatorID: p.CreatorID, | |||||
}); err != nil { | |||||
return err | |||||
} | |||||
continue | |||||
} | |||||
var boardsToUpdate []int64 | |||||
for id, b := range boards { | |||||
if id > 0 { | |||||
boardsToUpdate = append(boardsToUpdate, b.ID) | |||||
} | |||||
} | |||||
if _, err := sess.Where(builder.Eq{"project_id": p.ID}.And(builder.In("id", boardsToUpdate))). | |||||
Cols("`default`").Update(&project.Board{Default: false}); err != nil { | |||||
return err | |||||
} | |||||
} | |||||
if start%1000 == 0 { | |||||
if err := sess.Commit(); err != nil { | |||||
return err | |||||
} | |||||
if err := sess.Begin(); err != nil { | |||||
return err | |||||
} | |||||
} | |||||
} | |||||
return sess.Commit() | |||||
} |
// Copyright 2024 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package v1_22 //nolint | |||||
import ( | |||||
"testing" | |||||
"code.gitea.io/gitea/models/db" | |||||
"code.gitea.io/gitea/models/migrations/base" | |||||
"code.gitea.io/gitea/models/project" | |||||
"github.com/stretchr/testify/assert" | |||||
) | |||||
func Test_CheckProjectColumnsConsistency(t *testing.T) { | |||||
// Prepare and load the testing database | |||||
x, deferable := base.PrepareTestEnv(t, 0, new(project.Project), new(project.Board)) | |||||
defer deferable() | |||||
if x == nil || t.Failed() { | |||||
return | |||||
} | |||||
assert.NoError(t, CheckProjectColumnsConsistency(x)) | |||||
// check if default board was added | |||||
var defaultBoard project.Board | |||||
has, err := x.Where("project_id=? AND `default` = ?", 1, true).Get(&defaultBoard) | |||||
assert.NoError(t, err) | |||||
assert.True(t, has) | |||||
assert.Equal(t, int64(1), defaultBoard.ProjectID) | |||||
assert.True(t, defaultBoard.Default) | |||||
// check if multiple defaults were removed | |||||
expectDefaultBoard, err := project.GetBoard(db.DefaultContext, 2) | |||||
assert.NoError(t, err) | |||||
assert.Equal(t, int64(2), expectDefaultBoard.ProjectID) | |||||
assert.True(t, expectDefaultBoard.Default) | |||||
expectNonDefaultBoard, err := project.GetBoard(db.DefaultContext, 3) | |||||
assert.NoError(t, err) | |||||
assert.Equal(t, int64(2), expectNonDefaultBoard.ProjectID) | |||||
assert.False(t, expectNonDefaultBoard.Default) | |||||
} |
return nil | return nil | ||||
} | } | ||||
board := Board{ | |||||
CreatedUnix: timeutil.TimeStampNow(), | |||||
CreatorID: project.CreatorID, | |||||
Title: "Backlog", | |||||
ProjectID: project.ID, | |||||
Default: true, | |||||
} | |||||
if err := db.Insert(ctx, board); err != nil { | |||||
return err | |||||
} | |||||
if len(items) == 0 { | if len(items) == 0 { | ||||
return nil | return nil | ||||
} | } | ||||
return err | return err | ||||
} | } | ||||
if board.Default { | |||||
return fmt.Errorf("deleteBoardByID: cannot delete default board") | |||||
} | |||||
if err = board.removeIssues(ctx); err != nil { | if err = board.removeIssues(ctx); err != nil { | ||||
return err | return err | ||||
} | } | ||||
} | } | ||||
// GetBoards fetches all boards related to a project | // GetBoards fetches all boards related to a project | ||||
// if no default board set, first board is a temporary "Uncategorized" board | |||||
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) | ||||
return append([]*Board{defaultB}, boards...), nil | return append([]*Board{defaultB}, boards...), nil | ||||
} | } | ||||
// getDefaultBoard return default board and create a dummy if none exist | |||||
// 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 | |||||
exist, err := db.GetEngine(ctx).Where("project_id=? AND `default`=?", p.ID, true).Get(&board) | |||||
if err != nil { | |||||
var boards []Board | |||||
if err := db.GetEngine(ctx).Where("project_id=? AND `default` = ?", p.ID, true).OrderBy("sorting").Find(&boards); err != nil { | |||||
return nil, err | return nil, err | ||||
} | } | ||||
if exist { | |||||
// create a default board if none is found | |||||
if len(boards) == 0 { | |||||
board := Board{ | |||||
ProjectID: p.ID, | |||||
Default: true, | |||||
Title: "Uncategorized", | |||||
CreatorID: p.CreatorID, | |||||
} | |||||
if _, err := db.GetEngine(ctx).Insert(); err != nil { | |||||
return nil, err | |||||
} | |||||
return &board, nil | return &board, nil | ||||
} | } | ||||
// represents a board for issues not assigned to one | |||||
return &Board{ | |||||
ProjectID: p.ID, | |||||
Title: "Uncategorized", | |||||
Default: true, | |||||
}, nil | |||||
// unset default boards where too many default boards exist | |||||
if len(boards) > 1 { | |||||
var boardsToUpdate []int64 | |||||
for id, b := range boards { | |||||
if id > 0 { | |||||
boardsToUpdate = append(boardsToUpdate, b.ID) | |||||
} | |||||
} | |||||
if _, err := db.GetEngine(ctx).Where(builder.Eq{"project_id": p.ID}.And(builder.In("id", boardsToUpdate))). | |||||
Cols("`default`").Update(&Board{Default: false}); err != nil { | |||||
return nil, err | |||||
} | |||||
} | |||||
return &boards[0], nil | |||||
} | } | ||||
// SetDefaultBoard represents a board for issues not assigned to one | // SetDefaultBoard represents a board for issues not assigned to one | ||||
// if boardID is 0 unset default | |||||
func SetDefaultBoard(ctx context.Context, projectID, boardID int64) error { | func SetDefaultBoard(ctx context.Context, projectID, boardID int64) error { | ||||
_, err := db.GetEngine(ctx).Where(builder.Eq{ | |||||
if _, err := GetBoard(ctx, boardID); err != nil { | |||||
return err | |||||
} | |||||
if _, err := db.GetEngine(ctx).Where(builder.Eq{ | |||||
"project_id": projectID, | "project_id": projectID, | ||||
"`default`": true, | "`default`": true, | ||||
}).Cols("`default`").Update(&Board{Default: false}) | |||||
if err != nil { | |||||
}).Cols("`default`").Update(&Board{Default: false}); err != nil { | |||||
return err | return err | ||||
} | } | ||||
if boardID > 0 { | |||||
_, err = db.GetEngine(ctx).ID(boardID).Where(builder.Eq{"project_id": projectID}). | |||||
Cols("`default`").Update(&Board{Default: true}) | |||||
} | |||||
_, err := db.GetEngine(ctx).ID(boardID).Where(builder.Eq{"project_id": projectID}). | |||||
Cols("`default`").Update(&Board{Default: true}) | |||||
return err | return err | ||||
} | } |
// Copyright 2020 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package project | |||||
import ( | |||||
"testing" | |||||
"code.gitea.io/gitea/models/db" | |||||
"code.gitea.io/gitea/models/unittest" | |||||
"github.com/stretchr/testify/assert" | |||||
) | |||||
func TestGetDefaultBoard(t *testing.T) { | |||||
assert.NoError(t, unittest.PrepareTestDatabase()) | |||||
projectWithoutDefault, err := GetProjectByID(db.DefaultContext, 5) | |||||
assert.NoError(t, err) | |||||
// check if default board was added | |||||
board, err := projectWithoutDefault.getDefaultBoard(db.DefaultContext) | |||||
assert.NoError(t, err) | |||||
assert.Equal(t, int64(5), board.ProjectID) | |||||
assert.Equal(t, "Uncategorized", board.Title) | |||||
projectWithMultipleDefaults, err := GetProjectByID(db.DefaultContext, 6) | |||||
assert.NoError(t, err) | |||||
// check if multiple defaults were removed | |||||
board, err = projectWithMultipleDefaults.getDefaultBoard(db.DefaultContext) | |||||
assert.NoError(t, err) | |||||
assert.Equal(t, int64(6), board.ProjectID) | |||||
assert.Equal(t, int64(8), board.ID) | |||||
board, err = GetBoard(db.DefaultContext, 9) | |||||
assert.NoError(t, err) | |||||
assert.Equal(t, int64(6), board.ProjectID) | |||||
assert.False(t, board.Default) | |||||
} |
}{ | }{ | ||||
{ | { | ||||
sortType: "default", | sortType: "default", | ||||
wants: []int64{1, 3, 2, 4}, | |||||
wants: []int64{1, 3, 2, 6, 5, 4}, | |||||
}, | }, | ||||
{ | { | ||||
sortType: "oldest", | sortType: "oldest", | ||||
wants: []int64{4, 2, 3, 1}, | |||||
wants: []int64{4, 5, 6, 2, 3, 1}, | |||||
}, | }, | ||||
{ | { | ||||
sortType: "recentupdate", | sortType: "recentupdate", | ||||
wants: []int64{1, 3, 2, 4}, | |||||
wants: []int64{1, 3, 2, 6, 5, 4}, | |||||
}, | }, | ||||
{ | { | ||||
sortType: "leastupdate", | sortType: "leastupdate", | ||||
wants: []int64{4, 2, 3, 1}, | |||||
wants: []int64{4, 5, 6, 2, 3, 1}, | |||||
}, | }, | ||||
} | } | ||||
OrderBy: GetSearchOrderByBySortType(tt.sortType), | OrderBy: GetSearchOrderByBySortType(tt.sortType), | ||||
}) | }) | ||||
assert.NoError(t, err) | assert.NoError(t, err) | ||||
assert.EqualValues(t, int64(4), count) | |||||
if assert.Len(t, projects, 4) { | |||||
assert.EqualValues(t, int64(6), count) | |||||
if assert.Len(t, projects, 6) { | |||||
for i := range projects { | for i := range projects { | ||||
assert.EqualValues(t, tt.wants[i], projects[i].ID) | assert.EqualValues(t, tt.wants[i], projects[i].ID) | ||||
} | } |
projects.type.bug_triage = "Bug Triage" | projects.type.bug_triage = "Bug Triage" | ||||
projects.template.desc = "Template" | projects.template.desc = "Template" | ||||
projects.template.desc_helper = "Select a project template to get started" | projects.template.desc_helper = "Select a project template to get started" | ||||
projects.type.uncategorized = Uncategorized | |||||
projects.column.edit = "Edit Column" | projects.column.edit = "Edit Column" | ||||
projects.column.edit_title = "Name" | projects.column.edit_title = "Name" | ||||
projects.column.new_title = "Name" | projects.column.new_title = "Name" | ||||
projects.column.new = "New Column" | projects.column.new = "New Column" | ||||
projects.column.set_default = "Set Default" | projects.column.set_default = "Set Default" | ||||
projects.column.set_default_desc = "Set this column as default for uncategorized issues and pulls" | projects.column.set_default_desc = "Set this column as default for uncategorized issues and pulls" | ||||
projects.column.unset_default = "Unset Default" | |||||
projects.column.unset_default_desc = "Unset this column as default" | |||||
projects.column.delete = "Delete Column" | projects.column.delete = "Delete Column" | ||||
projects.column.deletion_desc = "Deleting a project column moves all related issues to 'Uncategorized'. Continue?" | |||||
projects.column.deletion_desc = "Deleting a project column moves all related issues to the default column. Continue?" | |||||
projects.column.color = "Color" | projects.column.color = "Color" | ||||
projects.open = Open | projects.open = Open | ||||
projects.close = Close | projects.close = Close |
id := ctx.ParamsInt64(":id") | id := ctx.ParamsInt64(":id") | ||||
if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, id, toClose); err != nil { | if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, id, toClose); err != nil { | ||||
if project_model.IsErrProjectNotExist(err) { | |||||
ctx.NotFound("", err) | |||||
} else { | |||||
ctx.ServerError("ChangeProjectStatusByRepoIDAndID", err) | |||||
} | |||||
ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) | |||||
return | return | ||||
} | } | ||||
ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects?state=" + url.QueryEscape(ctx.Params(":action"))) | ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects?state=" + url.QueryEscape(ctx.Params(":action"))) | ||||
func DeleteProject(ctx *context.Context) { | func DeleteProject(ctx *context.Context) { | ||||
p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) | p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) | ||||
if err != nil { | if err != nil { | ||||
if project_model.IsErrProjectNotExist(err) { | |||||
ctx.NotFound("", nil) | |||||
} else { | |||||
ctx.ServerError("GetProjectByID", err) | |||||
} | |||||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) | |||||
return | return | ||||
} | } | ||||
if p.OwnerID != ctx.ContextUser.ID { | if p.OwnerID != ctx.ContextUser.ID { | ||||
p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) | p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) | ||||
if err != nil { | if err != nil { | ||||
if project_model.IsErrProjectNotExist(err) { | |||||
ctx.NotFound("", nil) | |||||
} else { | |||||
ctx.ServerError("GetProjectByID", err) | |||||
} | |||||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) | |||||
return | return | ||||
} | } | ||||
if p.OwnerID != ctx.ContextUser.ID { | if p.OwnerID != ctx.ContextUser.ID { | ||||
p, err := project_model.GetProjectByID(ctx, projectID) | p, err := project_model.GetProjectByID(ctx, projectID) | ||||
if err != nil { | if err != nil { | ||||
if project_model.IsErrProjectNotExist(err) { | |||||
ctx.NotFound("", nil) | |||||
} else { | |||||
ctx.ServerError("GetProjectByID", err) | |||||
} | |||||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) | |||||
return | return | ||||
} | } | ||||
if p.OwnerID != ctx.ContextUser.ID { | if p.OwnerID != ctx.ContextUser.ID { | ||||
func ViewProject(ctx *context.Context) { | func ViewProject(ctx *context.Context) { | ||||
project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) | project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) | ||||
if err != nil { | if err != nil { | ||||
if project_model.IsErrProjectNotExist(err) { | |||||
ctx.NotFound("", nil) | |||||
} else { | |||||
ctx.ServerError("GetProjectByID", err) | |||||
} | |||||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) | |||||
return | return | ||||
} | } | ||||
if project.OwnerID != ctx.ContextUser.ID { | if project.OwnerID != ctx.ContextUser.ID { | ||||
return | return | ||||
} | } | ||||
if boards[0].ID == 0 { | |||||
boards[0].Title = ctx.Locale.TrString("repo.projects.type.uncategorized") | |||||
} | |||||
issuesMap, err := issues_model.LoadIssuesFromBoardList(ctx, boards) | issuesMap, err := issues_model.LoadIssuesFromBoardList(ctx, boards) | ||||
if err != nil { | if err != nil { | ||||
ctx.ServerError("LoadIssuesOfBoards", err) | ctx.ServerError("LoadIssuesOfBoards", err) | ||||
project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) | project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) | ||||
if err != nil { | if err != nil { | ||||
if project_model.IsErrProjectNotExist(err) { | |||||
ctx.NotFound("", nil) | |||||
} else { | |||||
ctx.ServerError("GetProjectByID", err) | |||||
} | |||||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) | |||||
return | return | ||||
} | } | ||||
project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) | project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) | ||||
if err != nil { | if err != nil { | ||||
if project_model.IsErrProjectNotExist(err) { | |||||
ctx.NotFound("", nil) | |||||
} else { | |||||
ctx.ServerError("GetProjectByID", err) | |||||
} | |||||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) | |||||
return | return | ||||
} | } | ||||
project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) | project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) | ||||
if err != nil { | if err != nil { | ||||
if project_model.IsErrProjectNotExist(err) { | |||||
ctx.NotFound("", nil) | |||||
} else { | |||||
ctx.ServerError("GetProjectByID", err) | |||||
} | |||||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) | |||||
return nil, nil | return nil, nil | ||||
} | } | ||||
ctx.JSONOK() | ctx.JSONOK() | ||||
} | } | ||||
// UnsetDefaultProjectBoard unset default board for uncategorized issues/pulls | |||||
func UnsetDefaultProjectBoard(ctx *context.Context) { | |||||
project, _ := CheckProjectBoardChangePermissions(ctx) | |||||
if ctx.Written() { | |||||
return | |||||
} | |||||
if err := project_model.SetDefaultBoard(ctx, project.ID, 0); err != nil { | |||||
ctx.ServerError("SetDefaultBoard", err) | |||||
return | |||||
} | |||||
ctx.JSONOK() | |||||
} | |||||
// MoveIssues moves or keeps issues in a column and sorts them inside that column | // MoveIssues moves or keeps issues in a column and sorts them inside that column | ||||
func MoveIssues(ctx *context.Context) { | func MoveIssues(ctx *context.Context) { | ||||
if ctx.Doer == nil { | if ctx.Doer == nil { | ||||
project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) | project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) | ||||
if err != nil { | if err != nil { | ||||
if project_model.IsErrProjectNotExist(err) { | |||||
ctx.NotFound("ProjectNotExist", nil) | |||||
} else { | |||||
ctx.ServerError("GetProjectByID", err) | |||||
} | |||||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) | |||||
return | return | ||||
} | } | ||||
if project.OwnerID != ctx.ContextUser.ID { | if project.OwnerID != ctx.ContextUser.ID { | ||||
return | return | ||||
} | } | ||||
var board *project_model.Board | |||||
board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) | |||||
if err != nil { | |||||
ctx.NotFoundOrServerError("GetProjectBoard", project_model.IsErrProjectBoardNotExist, err) | |||||
return | |||||
} | |||||
if ctx.ParamsInt64(":boardID") == 0 { | |||||
board = &project_model.Board{ | |||||
ID: 0, | |||||
ProjectID: project.ID, | |||||
Title: ctx.Locale.TrString("repo.projects.type.uncategorized"), | |||||
} | |||||
} else { | |||||
board, err = project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) | |||||
if err != nil { | |||||
if project_model.IsErrProjectBoardNotExist(err) { | |||||
ctx.NotFound("ProjectBoardNotExist", nil) | |||||
} else { | |||||
ctx.ServerError("GetProjectBoard", err) | |||||
} | |||||
return | |||||
} | |||||
if board.ProjectID != project.ID { | |||||
ctx.NotFound("BoardNotInProject", nil) | |||||
return | |||||
} | |||||
if board.ProjectID != project.ID { | |||||
ctx.NotFound("BoardNotInProject", nil) | |||||
return | |||||
} | } | ||||
type movedIssuesForm struct { | type movedIssuesForm struct { | ||||
} | } | ||||
movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) | movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) | ||||
if err != nil { | if err != nil { | ||||
if issues_model.IsErrIssueNotExist(err) { | |||||
ctx.NotFound("IssueNotExisting", nil) | |||||
} else { | |||||
ctx.ServerError("GetIssueByID", err) | |||||
} | |||||
ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err) | |||||
return | return | ||||
} | } | ||||
return | return | ||||
} | } | ||||
if boards[0].ID == 0 { | |||||
boards[0].Title = ctx.Locale.TrString("repo.projects.type.uncategorized") | |||||
} | |||||
issuesMap, err := issues_model.LoadIssuesFromBoardList(ctx, boards) | issuesMap, err := issues_model.LoadIssuesFromBoardList(ctx, boards) | ||||
if err != nil { | if err != nil { | ||||
ctx.ServerError("LoadIssuesOfBoards", err) | ctx.ServerError("LoadIssuesOfBoards", err) | ||||
ctx.JSONOK() | ctx.JSONOK() | ||||
} | } | ||||
// UnSetDefaultProjectBoard unset default board for uncategorized issues/pulls | |||||
func UnSetDefaultProjectBoard(ctx *context.Context) { | |||||
project, _ := checkProjectBoardChangePermissions(ctx) | |||||
if ctx.Written() { | |||||
return | |||||
} | |||||
if err := project_model.SetDefaultBoard(ctx, project.ID, 0); err != nil { | |||||
ctx.ServerError("SetDefaultBoard", err) | |||||
return | |||||
} | |||||
ctx.JSONOK() | |||||
} | |||||
// MoveIssues moves or keeps issues in a column and sorts them inside that column | // MoveIssues moves or keeps issues in a column and sorts them inside that column | ||||
func MoveIssues(ctx *context.Context) { | func MoveIssues(ctx *context.Context) { | ||||
if ctx.Doer == nil { | if ctx.Doer == nil { | ||||
return | return | ||||
} | } | ||||
var board *project_model.Board | |||||
if ctx.ParamsInt64(":boardID") == 0 { | |||||
board = &project_model.Board{ | |||||
ID: 0, | |||||
ProjectID: project.ID, | |||||
Title: ctx.Locale.TrString("repo.projects.type.uncategorized"), | |||||
} | |||||
} else { | |||||
board, err = project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) | |||||
if err != nil { | |||||
if project_model.IsErrProjectBoardNotExist(err) { | |||||
ctx.NotFound("ProjectBoardNotExist", nil) | |||||
} else { | |||||
ctx.ServerError("GetProjectBoard", err) | |||||
} | |||||
return | |||||
} | |||||
if board.ProjectID != project.ID { | |||||
ctx.NotFound("BoardNotInProject", nil) | |||||
return | |||||
board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) | |||||
if err != nil { | |||||
if project_model.IsErrProjectBoardNotExist(err) { | |||||
ctx.NotFound("ProjectBoardNotExist", nil) | |||||
} else { | |||||
ctx.ServerError("GetProjectBoard", err) | |||||
} | } | ||||
return | |||||
} | |||||
if board.ProjectID != project.ID { | |||||
ctx.NotFound("BoardNotInProject", nil) | |||||
return | |||||
} | } | ||||
type movedIssuesForm struct { | type movedIssuesForm struct { |
m.Put("", web.Bind(forms.EditProjectBoardForm{}), org.EditProjectBoard) | m.Put("", web.Bind(forms.EditProjectBoardForm{}), org.EditProjectBoard) | ||||
m.Delete("", org.DeleteProjectBoard) | m.Delete("", org.DeleteProjectBoard) | ||||
m.Post("/default", org.SetDefaultProjectBoard) | m.Post("/default", org.SetDefaultProjectBoard) | ||||
m.Post("/unsetdefault", org.UnsetDefaultProjectBoard) | |||||
m.Post("/move", org.MoveIssues) | m.Post("/move", org.MoveIssues) | ||||
}) | }) | ||||
m.Put("", web.Bind(forms.EditProjectBoardForm{}), repo.EditProjectBoard) | m.Put("", web.Bind(forms.EditProjectBoardForm{}), repo.EditProjectBoard) | ||||
m.Delete("", repo.DeleteProjectBoard) | m.Delete("", repo.DeleteProjectBoard) | ||||
m.Post("/default", repo.SetDefaultProjectBoard) | m.Post("/default", repo.SetDefaultProjectBoard) | ||||
m.Post("/unsetdefault", repo.UnSetDefaultProjectBoard) | |||||
m.Post("/move", repo.MoveIssues) | m.Post("/move", repo.MoveIssues) | ||||
}) | }) |
</div> | </div> | ||||
{{.Title}} | {{.Title}} | ||||
</div> | </div> | ||||
{{if and $canWriteProject (ne .ID 0)}} | |||||
{{if $canWriteProject}} | |||||
<div class="ui dropdown jump item"> | <div class="ui dropdown jump item"> | ||||
<div class="tw-px-2"> | <div class="tw-px-2"> | ||||
{{svg "octicon-kebab-horizontal"}} | {{svg "octicon-kebab-horizontal"}} | ||||
</a> | </a> | ||||
{{if not .Default}} | {{if not .Default}} | ||||
<a class="item show-modal button default-project-column-show" | <a class="item show-modal button default-project-column-show" | ||||
data-modal="#default-project-column-modal-{{.ID}}" | |||||
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"> | |||||
data-modal="#default-project-column-modal-{{.ID}}" | |||||
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-pin"}} | ||||
{{ctx.Locale.Tr "repo.projects.column.set_default"}} | {{ctx.Locale.Tr "repo.projects.column.set_default"}} | ||||
</a> | </a> | ||||
{{else}} | |||||
<a class="item show-modal button default-project-column-show" | |||||
data-modal="#default-project-column-modal-{{.ID}}" | |||||
data-modal-default-project-column-header="{{ctx.Locale.Tr "repo.projects.column.unset_default"}}" | |||||
data-modal-default-project-column-content="{{ctx.Locale.Tr "repo.projects.column.unset_default_desc"}}" | |||||
data-url="{{$.Link}}/{{.ID}}/unsetdefault"> | |||||
{{svg "octicon-pin-slash"}} | |||||
{{ctx.Locale.Tr "repo.projects.column.unset_default"}} | |||||
<a class="item show-modal button show-delete-project-column-modal" | |||||
data-modal="#delete-project-column-modal-{{.ID}}" | |||||
data-url="{{$.Link}}/{{.ID}}"> | |||||
{{svg "octicon-trash"}} | |||||
{{ctx.Locale.Tr "repo.projects.column.delete"}} | |||||
</a> | </a> | ||||
{{end}} | {{end}} | ||||
<a class="item show-modal button show-delete-project-column-modal" | |||||
data-modal="#delete-project-column-modal-{{.ID}}" | |||||
data-url="{{$.Link}}/{{.ID}}"> | |||||
{{svg "octicon-trash"}} | |||||
{{ctx.Locale.Tr "repo.projects.column.delete"}} | |||||
</a> | |||||
<div class="ui small modal edit-project-column-modal" id="edit-project-column-modal-{{.ID}}"> | <div class="ui small modal edit-project-column-modal" id="edit-project-column-modal-{{.ID}}"> | ||||
<div class="header"> | <div class="header"> | ||||
<div class="divider"></div> | <div class="divider"></div> | ||||
<div class="ui cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}tw-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}"> | |||||
<div class="ui cards{{if $canWriteProject}} tw-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}"> | |||||
{{range (index $.IssuesMap .ID)}} | {{range (index $.IssuesMap .ID)}} | ||||
<div class="issue-card gt-word-break {{if $canWriteProject}}tw-cursor-grab{{end}}" data-issue="{{.ID}}"> | <div class="issue-card gt-word-break {{if $canWriteProject}}tw-cursor-grab{{end}}" data-issue="{{.ID}}"> | ||||
{{template "repo/issue/card" (dict "Issue" . "Page" $)}} | {{template "repo/issue/card" (dict "Issue" . "Page" $)}} |
createSortable(mainBoard, { | createSortable(mainBoard, { | ||||
group: 'project-column', | group: 'project-column', | ||||
draggable: '.project-column', | draggable: '.project-column', | ||||
filter: '[data-id="0"]', | |||||
animation: 150, | animation: 150, | ||||
ghostClass: 'card-ghost', | ghostClass: 'card-ghost', | ||||
delayOnTouchOnly: true, | delayOnTouchOnly: true, |