Rename project board -> column to make the UI less confusing (#30170)

This PR split the `Board` into two parts. One is the struct has been
renamed to `Column` and the second we have a `Template Type`.

But to make it easier to review, this PR will not change the database
schemas, they are just renames. The database schema changes could be in
future PRs.

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: yp05327 <576951401@qq.com>
This commit is contained in:
Lunny Xiao 2024-05-27 16:59:54 +08:00 committed by GitHub
parent 072b029b33
commit 98751108b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 725 additions and 775 deletions

View File

@ -828,7 +828,7 @@ and
## Project (`project`) ## Project (`project`)
Default templates for project boards: Default templates for project board view:
- `PROJECT_BOARD_BASIC_KANBAN_TYPE`: **To Do, In Progress, Done** - `PROJECT_BOARD_BASIC_KANBAN_TYPE`: **To Do, In Progress, Done**
- `PROJECT_BOARD_BUG_TRIAGE_TYPE`: **Needs Triage, High Priority, Low Priority, Closed** - `PROJECT_BOARD_BUG_TRIAGE_TYPE`: **Needs Triage, High Priority, Low Priority, Closed**

View File

@ -37,7 +37,7 @@ You can try it out using [the online demo](https://try.gitea.io/).
- CI/CD: Gitea Actions supports CI/CD functionality, compatible with GitHub Actions. Users can write workflows in familiar YAML format and reuse a variety of existing Actions plugins. Actions plugins support downloading from any Git website. - CI/CD: Gitea Actions supports CI/CD functionality, compatible with GitHub Actions. Users can write workflows in familiar YAML format and reuse a variety of existing Actions plugins. Actions plugins support downloading from any Git website.
- Project Management: Gitea tracks project requirements, features, and bugs through boards and issues. Issues support features like branches, tags, milestones, assignments, time tracking, due dates, dependencies, and more. - Project Management: Gitea tracks project requirements, features, and bugs through columns and issues. Issues support features like branches, tags, milestones, assignments, time tracking, due dates, dependencies, and more.
- Artifact Repository: Gitea supports over 20 different types of public or private software package management, including Cargo, Chef, Composer, Conan, Conda, Container, Helm, Maven, npm, NuGet, Pub, PyPI, RubyGems, Vagrant, and more. - Artifact Repository: Gitea supports over 20 different types of public or private software package management, including Cargo, Chef, Composer, Conan, Conda, Container, Helm, Maven, npm, NuGet, Pub, PyPI, RubyGems, Vagrant, and more.

View File

@ -104,7 +104,7 @@ _Symbols used in table:_
| Comment reactions | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | ✘ | | Comment reactions | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | ✘ |
| Lock Discussion | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | ✘ | | Lock Discussion | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | ✘ |
| Batch issue handling | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | ✘ | | Batch issue handling | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | ✘ |
| Issue Boards (Kanban) | [/](https://github.com/go-gitea/gitea/issues/14710) | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ | ✘ | | Projects | [/](https://github.com/go-gitea/gitea/issues/14710) | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ | ✘ |
| Create branch from issue | [](https://github.com/go-gitea/gitea/issues/20226) | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ | ✘ | | Create branch from issue | [](https://github.com/go-gitea/gitea/issues/20226) | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ | ✘ |
| Convert comment to new issue | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | ✘ | | Convert comment to new issue | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | ✘ |
| Issue search | ✓ | ✘ | ✓ | ✓ | ✓ | ✓ | ✘ | ✘ | | Issue search | ✓ | ✘ | ✓ | ✓ | ✓ | ✓ | ✘ | ✘ |

View File

@ -48,7 +48,7 @@ With different permissions, people could do different things with these units.
| Wiki | View wiki pages. Clone the wiki repository. | Create/Edit wiki pages, push | - | | Wiki | View wiki pages. Clone the wiki repository. | Create/Edit wiki pages, push | - |
| ExternalWiki | Link to an external wiki | - | - | | ExternalWiki | Link to an external wiki | - | - |
| ExternalTracker | Link to an external issue tracker | - | - | | ExternalTracker | Link to an external issue tracker | - | - |
| Projects | View the boards | Change issues across boards | - | | Projects | View the columns of projects | Change issues across columns | - |
| Packages | View the packages | Upload/Delete packages | - | | Packages | View the packages | Upload/Delete packages | - |
| Actions | View the Actions logs | Approve / Cancel / Restart | - | | Actions | View the Actions logs | Approve / Cancel / Restart | - |
| Settings | - | - | Manage the repository | | Settings | - | - | Manage the repository |

View File

@ -30,7 +30,7 @@ type Statistic struct {
Mirror, Release, AuthSource, Webhook, Mirror, Release, AuthSource, Webhook,
Milestone, Label, HookTask, Milestone, Label, HookTask,
Team, UpdateTask, Project, Team, UpdateTask, Project,
ProjectBoard, Attachment, ProjectColumn, Attachment,
Branches, Tags, CommitStatus int64 Branches, Tags, CommitStatus int64
IssueByLabel []IssueByLabelCount IssueByLabel []IssueByLabelCount
IssueByRepository []IssueByRepositoryCount IssueByRepository []IssueByRepositoryCount
@ -115,6 +115,6 @@ func GetStatistic(ctx context.Context) (stats Statistic) {
stats.Counter.Team, _ = e.Count(new(organization.Team)) stats.Counter.Team, _ = e.Count(new(organization.Team))
stats.Counter.Attachment, _ = e.Count(new(repo_model.Attachment)) stats.Counter.Attachment, _ = e.Count(new(repo_model.Attachment))
stats.Counter.Project, _ = e.Count(new(project_model.Project)) stats.Counter.Project, _ = e.Count(new(project_model.Project))
stats.Counter.ProjectBoard, _ = e.Count(new(project_model.Board)) stats.Counter.ProjectColumn, _ = e.Count(new(project_model.Column))
return stats return stats
} }

View File

@ -100,8 +100,8 @@ const (
CommentTypeMergePull // 28 merge pull request CommentTypeMergePull // 28 merge pull request
CommentTypePullRequestPush // 29 push to PR head branch CommentTypePullRequestPush // 29 push to PR head branch
CommentTypeProject // 30 Project changed CommentTypeProject // 30 Project changed
CommentTypeProjectBoard // 31 Project board changed CommentTypeProjectColumn // 31 Project column changed
CommentTypeDismissReview // 32 Dismiss Review CommentTypeDismissReview // 32 Dismiss Review
@ -146,7 +146,7 @@ var commentStrings = []string{
"merge_pull", "merge_pull",
"pull_push", "pull_push",
"project", "project",
"project_board", "project_board", // FIXME: the name should be project_column
"dismiss_review", "dismiss_review",
"change_issue_ref", "change_issue_ref",
"pull_scheduled_merge", "pull_scheduled_merge",

View File

@ -37,22 +37,22 @@ func (issue *Issue) projectID(ctx context.Context) int64 {
return ip.ProjectID return ip.ProjectID
} }
// ProjectBoardID return project board id if issue was assigned to one // ProjectColumnID return project column id if issue was assigned to one
func (issue *Issue) ProjectBoardID(ctx context.Context) int64 { func (issue *Issue) ProjectColumnID(ctx context.Context) int64 {
var ip project_model.ProjectIssue var ip project_model.ProjectIssue
has, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Get(&ip) has, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Get(&ip)
if err != nil || !has { if err != nil || !has {
return 0 return 0
} }
return ip.ProjectBoardID return ip.ProjectColumnID
} }
// LoadIssuesFromBoard load issues assigned to this board // LoadIssuesFromColumn load issues assigned to this column
func LoadIssuesFromBoard(ctx context.Context, b *project_model.Board) (IssueList, error) { func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column) (IssueList, error) {
issueList, err := Issues(ctx, &IssuesOptions{ issueList, err := Issues(ctx, &IssuesOptions{
ProjectBoardID: b.ID, ProjectColumnID: b.ID,
ProjectID: b.ProjectID, ProjectID: b.ProjectID,
SortType: "project-column-sorting", SortType: "project-column-sorting",
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -60,9 +60,9 @@ func LoadIssuesFromBoard(ctx context.Context, b *project_model.Board) (IssueList
if b.Default { if b.Default {
issues, err := Issues(ctx, &IssuesOptions{ issues, err := Issues(ctx, &IssuesOptions{
ProjectBoardID: db.NoConditionID, ProjectColumnID: db.NoConditionID,
ProjectID: b.ProjectID, ProjectID: b.ProjectID,
SortType: "project-column-sorting", SortType: "project-column-sorting",
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -77,11 +77,11 @@ func LoadIssuesFromBoard(ctx context.Context, b *project_model.Board) (IssueList
return issueList, nil return issueList, nil
} }
// LoadIssuesFromBoardList load issues assigned to the boards // LoadIssuesFromColumnList load issues assigned to the columns
func LoadIssuesFromBoardList(ctx context.Context, bs project_model.BoardList) (map[int64]IssueList, error) { func LoadIssuesFromColumnList(ctx context.Context, bs project_model.ColumnList) (map[int64]IssueList, error) {
issuesMap := make(map[int64]IssueList, len(bs)) issuesMap := make(map[int64]IssueList, len(bs))
for i := range bs { for i := range bs {
il, err := LoadIssuesFromBoard(ctx, bs[i]) il, err := LoadIssuesFromColumn(ctx, bs[i])
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -110,7 +110,7 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo
return util.NewPermissionDeniedErrorf("issue %d can't be accessed by project %d", issue.ID, newProject.ID) return util.NewPermissionDeniedErrorf("issue %d can't be accessed by project %d", issue.ID, newProject.ID)
} }
if newColumnID == 0 { if newColumnID == 0 {
newDefaultColumn, err := newProject.GetDefaultBoard(ctx) newDefaultColumn, err := newProject.GetDefaultColumn(ctx)
if err != nil { if err != nil {
return err return err
} }
@ -153,10 +153,10 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo
} }
newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
return db.Insert(ctx, &project_model.ProjectIssue{ return db.Insert(ctx, &project_model.ProjectIssue{
IssueID: issue.ID, IssueID: issue.ID,
ProjectID: newProjectID, ProjectID: newProjectID,
ProjectBoardID: newColumnID, ProjectColumnID: newColumnID,
Sorting: newSorting, Sorting: newSorting,
}) })
}) })
} }

View File

@ -33,7 +33,7 @@ type IssuesOptions struct { //nolint
SubscriberID int64 SubscriberID int64
MilestoneIDs []int64 MilestoneIDs []int64
ProjectID int64 ProjectID int64
ProjectBoardID int64 ProjectColumnID int64
IsClosed optional.Option[bool] IsClosed optional.Option[bool]
IsPull optional.Option[bool] IsPull optional.Option[bool]
LabelIDs []int64 LabelIDs []int64
@ -169,12 +169,12 @@ func applyProjectCondition(sess *xorm.Session, opts *IssuesOptions) *xorm.Sessio
return sess return sess
} }
func applyProjectBoardCondition(sess *xorm.Session, opts *IssuesOptions) *xorm.Session { func applyProjectColumnCondition(sess *xorm.Session, opts *IssuesOptions) *xorm.Session {
// opts.ProjectBoardID == 0 means all project boards, // opts.ProjectColumnID == 0 means all project columns,
// do not need to apply any condition // do not need to apply any condition
if opts.ProjectBoardID > 0 { if opts.ProjectColumnID > 0 {
sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": opts.ProjectBoardID})) sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": opts.ProjectColumnID}))
} else if opts.ProjectBoardID == db.NoConditionID { } else if opts.ProjectColumnID == db.NoConditionID {
sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": 0})) sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": 0}))
} }
return sess return sess
@ -246,7 +246,7 @@ func applyConditions(sess *xorm.Session, opts *IssuesOptions) *xorm.Session {
applyProjectCondition(sess, opts) applyProjectCondition(sess, opts)
applyProjectBoardCondition(sess, opts) applyProjectColumnCondition(sess, opts)
if opts.IsPull.Has() { if opts.IsPull.Has() {
sess.And("issue.is_pull=?", opts.IsPull.Value()) sess.And("issue.is_pull=?", opts.IsPull.Value())

View File

@ -15,7 +15,7 @@ import (
func Test_CheckProjectColumnsConsistency(t *testing.T) { func Test_CheckProjectColumnsConsistency(t *testing.T) {
// Prepare and load the testing database // Prepare and load the testing database
x, deferable := base.PrepareTestEnv(t, 0, new(project.Project), new(project.Board)) x, deferable := base.PrepareTestEnv(t, 0, new(project.Project), new(project.Column))
defer deferable() defer deferable()
if x == nil || t.Failed() { if x == nil || t.Failed() {
return return
@ -23,22 +23,22 @@ func Test_CheckProjectColumnsConsistency(t *testing.T) {
assert.NoError(t, CheckProjectColumnsConsistency(x)) assert.NoError(t, CheckProjectColumnsConsistency(x))
// check if default board was added // check if default column was added
var defaultBoard project.Board var defaultColumn project.Column
has, err := x.Where("project_id=? AND `default` = ?", 1, true).Get(&defaultBoard) has, err := x.Where("project_id=? AND `default` = ?", 1, true).Get(&defaultColumn)
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, has) assert.True(t, has)
assert.Equal(t, int64(1), defaultBoard.ProjectID) assert.Equal(t, int64(1), defaultColumn.ProjectID)
assert.True(t, defaultBoard.Default) assert.True(t, defaultColumn.Default)
// check if multiple defaults, previous were removed and last will be kept // check if multiple defaults, previous were removed and last will be kept
expectDefaultBoard, err := project.GetBoard(db.DefaultContext, 2) expectDefaultColumn, err := project.GetColumn(db.DefaultContext, 2)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, int64(2), expectDefaultBoard.ProjectID) assert.Equal(t, int64(2), expectDefaultColumn.ProjectID)
assert.False(t, expectDefaultBoard.Default) assert.False(t, expectDefaultColumn.Default)
expectNonDefaultBoard, err := project.GetBoard(db.DefaultContext, 3) expectNonDefaultColumn, err := project.GetColumn(db.DefaultContext, 3)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, int64(2), expectNonDefaultBoard.ProjectID) assert.Equal(t, int64(2), expectNonDefaultColumn.ProjectID)
assert.True(t, expectNonDefaultBoard.Default) assert.True(t, expectNonDefaultColumn.Default)
} }

View File

@ -1,389 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
"errors"
"fmt"
"regexp"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
"xorm.io/builder"
)
type (
// BoardType is used to represent a project board type
BoardType uint8
// CardType is used to represent a project board card type
CardType uint8
// BoardList is a list of all project boards in a repository
BoardList []*Board
)
const (
// BoardTypeNone is a project board type that has no predefined columns
BoardTypeNone BoardType = iota
// BoardTypeBasicKanban is a project board type that has basic predefined columns
BoardTypeBasicKanban
// BoardTypeBugTriage is a project board type that has predefined columns suited to hunting down bugs
BoardTypeBugTriage
)
const (
// CardTypeTextOnly is a project board card type that is text only
CardTypeTextOnly CardType = iota
// CardTypeImagesAndText is a project board card type that has images and text
CardTypeImagesAndText
)
// BoardColorPattern is a regexp witch can validate BoardColor
var BoardColorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$")
// Board is used to represent boards on a project
type Board struct {
ID int64 `xorm:"pk autoincr"`
Title string
Default bool `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific board will be assigned to this board
Sorting int8 `xorm:"NOT NULL DEFAULT 0"`
Color string `xorm:"VARCHAR(7)"`
ProjectID int64 `xorm:"INDEX NOT NULL"`
CreatorID int64 `xorm:"NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
}
// TableName return the real table name
func (Board) TableName() string {
return "project_board"
}
// NumIssues return counter of all issues assigned to the board
func (b *Board) NumIssues(ctx context.Context) int {
c, err := db.GetEngine(ctx).Table("project_issue").
Where("project_id=?", b.ProjectID).
And("project_board_id=?", b.ID).
GroupBy("issue_id").
Cols("issue_id").
Count()
if err != nil {
return 0
}
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() {
db.RegisterModel(new(Board))
}
// IsBoardTypeValid checks if the project board type is valid
func IsBoardTypeValid(p BoardType) bool {
switch p {
case BoardTypeNone, BoardTypeBasicKanban, BoardTypeBugTriage:
return true
default:
return false
}
}
// IsCardTypeValid checks if the project board card type is valid
func IsCardTypeValid(p CardType) bool {
switch p {
case CardTypeTextOnly, CardTypeImagesAndText:
return true
default:
return false
}
}
func createBoardsForProjectsType(ctx context.Context, project *Project) error {
var items []string
switch project.BoardType {
case BoardTypeBugTriage:
items = setting.Project.ProjectBoardBugTriageType
case BoardTypeBasicKanban:
items = setting.Project.ProjectBoardBasicKanbanType
case BoardTypeNone:
fallthrough
default:
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 {
return nil
}
boards := make([]Board, 0, len(items))
for _, v := range items {
boards = append(boards, Board{
CreatedUnix: timeutil.TimeStampNow(),
CreatorID: project.CreatorID,
Title: v,
ProjectID: project.ID,
})
}
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
func NewBoard(ctx context.Context, board *Board) error {
if len(board.Color) != 0 && !BoardColorPattern.MatchString(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)
return err
}
// DeleteBoardByID removes all issues references to the project board.
func DeleteBoardByID(ctx context.Context, boardID int64) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
if err := deleteBoardByID(ctx, boardID); err != nil {
return err
}
return committer.Commit()
}
func deleteBoardByID(ctx context.Context, boardID int64) error {
board, err := GetBoard(ctx, boardID)
if err != nil {
if IsErrProjectBoardNotExist(err) {
return nil
}
return err
}
if board.Default {
return fmt.Errorf("deleteBoardByID: cannot delete default board")
}
// 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
}
if _, err := db.GetEngine(ctx).ID(board.ID).NoAutoCondition().Delete(board); err != nil {
return err
}
return nil
}
func deleteBoardByProjectID(ctx context.Context, projectID int64) error {
_, err := db.GetEngine(ctx).Where("project_id=?", projectID).Delete(&Board{})
return err
}
// GetBoard fetches the current board of a project
func GetBoard(ctx context.Context, boardID int64) (*Board, error) {
board := new(Board)
has, err := db.GetEngine(ctx).ID(boardID).Get(board)
if err != nil {
return nil, err
} else if !has {
return nil, ErrProjectBoardNotExist{BoardID: boardID}
}
return board, nil
}
// UpdateBoard updates a project board
func UpdateBoard(ctx context.Context, board *Board) error {
var fieldToUpdate []string
if board.Sorting != 0 {
fieldToUpdate = append(fieldToUpdate, "sorting")
}
if board.Title != "" {
fieldToUpdate = append(fieldToUpdate, "title")
}
if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) {
return fmt.Errorf("bad color code: %s", board.Color)
}
fieldToUpdate = append(fieldToUpdate, "color")
_, err := db.GetEngine(ctx).ID(board.ID).Cols(fieldToUpdate...).Update(board)
return err
}
// 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=?", p.ID).OrderBy("sorting, id").Find(&boards); err != nil {
return nil, err
}
return boards, nil
}
// 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).
Desc("id").Get(&board)
if err != nil {
return nil, err
}
if has {
return &board, nil
}
// create a default board if none is found
board = Board{
ProjectID: p.ID,
Default: true,
Title: "Uncategorized",
CreatorID: p.CreatorID,
}
if _, err := db.GetEngine(ctx).Insert(&board); err != nil {
return nil, err
}
return &board, nil
}
// SetDefaultBoard represents a board for issues not assigned to one
func SetDefaultBoard(ctx context.Context, projectID, boardID int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if _, err := GetBoard(ctx, boardID); err != nil {
return err
}
if _, err := db.GetEngine(ctx).Where(builder.Eq{
"project_id": projectID,
"`default`": true,
}).Cols("`default`").Update(&Board{Default: false}); err != nil {
return err
}
_, err := db.GetEngine(ctx).ID(boardID).
Where(builder.Eq{"project_id": projectID}).
Cols("`default`").Update(&Board{Default: true})
return err
})
}
// UpdateBoardSorting update project board sorting
func UpdateBoardSorting(ctx context.Context, bs BoardList) error {
return db.WithTx(ctx, func(ctx context.Context) error {
for i := range bs {
if _, err := db.GetEngine(ctx).ID(bs[i].ID).Cols(
"sorting",
).Update(bs[i]); err != nil {
return err
}
}
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
})
}

359
models/project/column.go Normal file
View File

@ -0,0 +1,359 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
"errors"
"fmt"
"regexp"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
"xorm.io/builder"
)
type (
// CardType is used to represent a project column card type
CardType uint8
// ColumnList is a list of all project columns in a repository
ColumnList []*Column
)
const (
// CardTypeTextOnly is a project column card type that is text only
CardTypeTextOnly CardType = iota
// CardTypeImagesAndText is a project column card type that has images and text
CardTypeImagesAndText
)
// ColumnColorPattern is a regexp witch can validate ColumnColor
var ColumnColorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$")
// Column is used to represent column on a project
type Column struct {
ID int64 `xorm:"pk autoincr"`
Title string
Default bool `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific column will be assigned to this column
Sorting int8 `xorm:"NOT NULL DEFAULT 0"`
Color string `xorm:"VARCHAR(7)"`
ProjectID int64 `xorm:"INDEX NOT NULL"`
CreatorID int64 `xorm:"NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
}
// TableName return the real table name
func (Column) TableName() string {
return "project_board" // TODO: the legacy table name should be project_column
}
// NumIssues return counter of all issues assigned to the column
func (c *Column) NumIssues(ctx context.Context) int {
total, err := db.GetEngine(ctx).Table("project_issue").
Where("project_id=?", c.ProjectID).
And("project_board_id=?", c.ID).
GroupBy("issue_id").
Cols("issue_id").
Count()
if err != nil {
return 0
}
return int(total)
}
func (c *Column) GetIssues(ctx context.Context) ([]*ProjectIssue, error) {
issues := make([]*ProjectIssue, 0, 5)
if err := db.GetEngine(ctx).Where("project_id=?", c.ProjectID).
And("project_board_id=?", c.ID).
OrderBy("sorting, id").
Find(&issues); err != nil {
return nil, err
}
return issues, nil
}
func init() {
db.RegisterModel(new(Column))
}
// IsCardTypeValid checks if the project column card type is valid
func IsCardTypeValid(p CardType) bool {
switch p {
case CardTypeTextOnly, CardTypeImagesAndText:
return true
default:
return false
}
}
func createDefaultColumnsForProject(ctx context.Context, project *Project) error {
var items []string
switch project.TemplateType {
case TemplateTypeBugTriage:
items = setting.Project.ProjectBoardBugTriageType
case TemplateTypeBasicKanban:
items = setting.Project.ProjectBoardBasicKanbanType
case TemplateTypeNone:
fallthrough
default:
return nil
}
return db.WithTx(ctx, func(ctx context.Context) error {
column := Column{
CreatedUnix: timeutil.TimeStampNow(),
CreatorID: project.CreatorID,
Title: "Backlog",
ProjectID: project.ID,
Default: true,
}
if err := db.Insert(ctx, column); err != nil {
return err
}
if len(items) == 0 {
return nil
}
columns := make([]Column, 0, len(items))
for _, v := range items {
columns = append(columns, Column{
CreatedUnix: timeutil.TimeStampNow(),
CreatorID: project.CreatorID,
Title: v,
ProjectID: project.ID,
})
}
return db.Insert(ctx, columns)
})
}
// maxProjectColumns max columns allowed in a project, this should not bigger than 127
// because sorting is int8 in database
const maxProjectColumns = 20
// NewColumn adds a new project column to a given project
func NewColumn(ctx context.Context, column *Column) error {
if len(column.Color) != 0 && !ColumnColorPattern.MatchString(column.Color) {
return fmt.Errorf("bad color code: %s", column.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=?", column.ProjectID).Get(&res); err != nil {
return err
}
if res.ColumnCount >= maxProjectColumns {
return fmt.Errorf("NewBoard: maximum number of columns reached")
}
column.Sorting = int8(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0))
_, err := db.GetEngine(ctx).Insert(column)
return err
}
// DeleteColumnByID removes all issues references to the project column.
func DeleteColumnByID(ctx context.Context, columnID int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
return deleteColumnByID(ctx, columnID)
})
}
func deleteColumnByID(ctx context.Context, columnID int64) error {
column, err := GetColumn(ctx, columnID)
if err != nil {
if IsErrProjectColumnNotExist(err) {
return nil
}
return err
}
if column.Default {
return fmt.Errorf("deleteColumnByID: cannot delete default column")
}
// move all issues to the default column
project, err := GetProjectByID(ctx, column.ProjectID)
if err != nil {
return err
}
defaultColumn, err := project.GetDefaultColumn(ctx)
if err != nil {
return err
}
if err = column.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil {
return err
}
if _, err := db.GetEngine(ctx).ID(column.ID).NoAutoCondition().Delete(column); err != nil {
return err
}
return nil
}
func deleteColumnByProjectID(ctx context.Context, projectID int64) error {
_, err := db.GetEngine(ctx).Where("project_id=?", projectID).Delete(&Column{})
return err
}
// GetColumn fetches the current column of a project
func GetColumn(ctx context.Context, columnID int64) (*Column, error) {
column := new(Column)
has, err := db.GetEngine(ctx).ID(columnID).Get(column)
if err != nil {
return nil, err
} else if !has {
return nil, ErrProjectColumnNotExist{ColumnID: columnID}
}
return column, nil
}
// UpdateColumn updates a project column
func UpdateColumn(ctx context.Context, column *Column) error {
var fieldToUpdate []string
if column.Sorting != 0 {
fieldToUpdate = append(fieldToUpdate, "sorting")
}
if column.Title != "" {
fieldToUpdate = append(fieldToUpdate, "title")
}
if len(column.Color) != 0 && !ColumnColorPattern.MatchString(column.Color) {
return fmt.Errorf("bad color code: %s", column.Color)
}
fieldToUpdate = append(fieldToUpdate, "color")
_, err := db.GetEngine(ctx).ID(column.ID).Cols(fieldToUpdate...).Update(column)
return err
}
// GetColumns fetches all columns related to a project
func (p *Project) GetColumns(ctx context.Context) (ColumnList, error) {
columns := make([]*Column, 0, 5)
if err := db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Find(&columns); err != nil {
return nil, err
}
return columns, nil
}
// GetDefaultColumn return default column and ensure only one exists
func (p *Project) GetDefaultColumn(ctx context.Context) (*Column, error) {
var column Column
has, err := db.GetEngine(ctx).
Where("project_id=? AND `default` = ?", p.ID, true).
Desc("id").Get(&column)
if err != nil {
return nil, err
}
if has {
return &column, nil
}
// create a default column if none is found
column = Column{
ProjectID: p.ID,
Default: true,
Title: "Uncategorized",
CreatorID: p.CreatorID,
}
if _, err := db.GetEngine(ctx).Insert(&column); err != nil {
return nil, err
}
return &column, nil
}
// SetDefaultColumn represents a column for issues not assigned to one
func SetDefaultColumn(ctx context.Context, projectID, columnID int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if _, err := GetColumn(ctx, columnID); err != nil {
return err
}
if _, err := db.GetEngine(ctx).Where(builder.Eq{
"project_id": projectID,
"`default`": true,
}).Cols("`default`").Update(&Column{Default: false}); err != nil {
return err
}
_, err := db.GetEngine(ctx).ID(columnID).
Where(builder.Eq{"project_id": projectID}).
Cols("`default`").Update(&Column{Default: true})
return err
})
}
// UpdateColumnSorting update project column sorting
func UpdateColumnSorting(ctx context.Context, cl ColumnList) error {
return db.WithTx(ctx, func(ctx context.Context) error {
for i := range cl {
if _, err := db.GetEngine(ctx).ID(cl[i].ID).Cols(
"sorting",
).Update(cl[i]); err != nil {
return err
}
}
return nil
})
}
func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) {
columns := make([]*Column, 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
})
}

View File

@ -14,48 +14,48 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestGetDefaultBoard(t *testing.T) { func TestGetDefaultColumn(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
projectWithoutDefault, err := GetProjectByID(db.DefaultContext, 5) projectWithoutDefault, err := GetProjectByID(db.DefaultContext, 5)
assert.NoError(t, err) assert.NoError(t, err)
// check if default board was added // check if default column was added
board, err := projectWithoutDefault.GetDefaultBoard(db.DefaultContext) column, err := projectWithoutDefault.GetDefaultColumn(db.DefaultContext)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, int64(5), board.ProjectID) assert.Equal(t, int64(5), column.ProjectID)
assert.Equal(t, "Uncategorized", board.Title) assert.Equal(t, "Uncategorized", column.Title)
projectWithMultipleDefaults, err := GetProjectByID(db.DefaultContext, 6) projectWithMultipleDefaults, err := GetProjectByID(db.DefaultContext, 6)
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) column, err = projectWithMultipleDefaults.GetDefaultColumn(db.DefaultContext)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, int64(6), board.ProjectID) assert.Equal(t, int64(6), column.ProjectID)
assert.Equal(t, int64(9), board.ID) assert.Equal(t, int64(9), column.ID)
// set 8 as default board // set 8 as default column
assert.NoError(t, SetDefaultBoard(db.DefaultContext, board.ProjectID, 8)) assert.NoError(t, SetDefaultColumn(db.DefaultContext, column.ProjectID, 8))
// then 9 will become a non-default board // then 9 will become a non-default column
board, err = GetBoard(db.DefaultContext, 9) column, err = GetColumn(db.DefaultContext, 9)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, int64(6), board.ProjectID) assert.Equal(t, int64(6), column.ProjectID)
assert.False(t, board.Default) assert.False(t, column.Default)
} }
func Test_moveIssuesToAnotherColumn(t *testing.T) { func Test_moveIssuesToAnotherColumn(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
column1 := unittest.AssertExistsAndLoadBean(t, &Board{ID: 1, ProjectID: 1}) column1 := unittest.AssertExistsAndLoadBean(t, &Column{ID: 1, ProjectID: 1})
issues, err := column1.GetIssues(db.DefaultContext) issues, err := column1.GetIssues(db.DefaultContext)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, issues, 1) assert.Len(t, issues, 1)
assert.EqualValues(t, 1, issues[0].ID) assert.EqualValues(t, 1, issues[0].ID)
column2 := unittest.AssertExistsAndLoadBean(t, &Board{ID: 2, ProjectID: 1}) column2 := unittest.AssertExistsAndLoadBean(t, &Column{ID: 2, ProjectID: 1})
issues, err = column2.GetIssues(db.DefaultContext) issues, err = column2.GetIssues(db.DefaultContext)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, issues, 1) assert.Len(t, issues, 1)
@ -81,7 +81,7 @@ func Test_MoveColumnsOnProject(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1}) project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
columns, err := project1.GetBoards(db.DefaultContext) columns, err := project1.GetColumns(db.DefaultContext)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, columns, 3) 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[0].Sorting) // even if there is no default sorting, the code should also work
@ -95,7 +95,7 @@ func Test_MoveColumnsOnProject(t *testing.T) {
}) })
assert.NoError(t, err) assert.NoError(t, err)
columnsAfter, err := project1.GetBoards(db.DefaultContext) columnsAfter, err := project1.GetColumns(db.DefaultContext)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, columnsAfter, 3) assert.Len(t, columnsAfter, 3)
assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID) assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID)
@ -103,23 +103,23 @@ func Test_MoveColumnsOnProject(t *testing.T) {
assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID) assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID)
} }
func Test_NewBoard(t *testing.T) { func Test_NewColumn(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1}) project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
columns, err := project1.GetBoards(db.DefaultContext) columns, err := project1.GetColumns(db.DefaultContext)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, columns, 3) assert.Len(t, columns, 3)
for i := 0; i < maxProjectColumns-3; i++ { for i := 0; i < maxProjectColumns-3; i++ {
err := NewBoard(db.DefaultContext, &Board{ err := NewColumn(db.DefaultContext, &Column{
Title: fmt.Sprintf("board-%d", i+4), Title: fmt.Sprintf("column-%d", i+4),
ProjectID: project1.ID, ProjectID: project1.ID,
}) })
assert.NoError(t, err) assert.NoError(t, err)
} }
err = NewBoard(db.DefaultContext, &Board{ err = NewColumn(db.DefaultContext, &Column{
Title: "board-21", Title: "column-21",
ProjectID: project1.ID, ProjectID: project1.ID,
}) })
assert.Error(t, err) assert.Error(t, err)

View File

@ -18,10 +18,10 @@ type ProjectIssue struct { //revive:disable-line:exported
IssueID int64 `xorm:"INDEX"` IssueID int64 `xorm:"INDEX"`
ProjectID int64 `xorm:"INDEX"` ProjectID int64 `xorm:"INDEX"`
// 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. // ProjectColumnID 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"` ProjectColumnID int64 `xorm:"'project_board_id' INDEX"`
// the sorting order on the board // the sorting order on the column
Sorting int64 `xorm:"NOT NULL DEFAULT 0"` Sorting int64 `xorm:"NOT NULL DEFAULT 0"`
} }
@ -76,13 +76,13 @@ func (p *Project) NumOpenIssues(ctx context.Context) int {
return int(c) return int(c)
} }
// MoveIssuesOnProjectBoard moves or keeps issues in a column and sorts them inside that column // MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs map[int64]int64) error { func MoveIssuesOnProjectColumn(ctx context.Context, column *Column, 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 := util.ValuesOfMap(sortedIssueIDs)
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=?", column.ProjectID).In("issue_id", issueIDs).Count()
if err != nil { if err != nil {
return err return err
} }
@ -91,7 +91,7 @@ func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs
} }
for sorting, issueID := range sortedIssueIDs { for sorting, issueID := range sortedIssueIDs {
_, err = sess.Exec("UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", board.ID, sorting, issueID) _, err = sess.Exec("UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", column.ID, sorting, issueID)
if err != nil { if err != nil {
return err return err
} }
@ -100,12 +100,12 @@ func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs
}) })
} }
func (b *Board) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Board) error { func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Column) error {
if b.ProjectID != newColumn.ProjectID { if c.ProjectID != newColumn.ProjectID {
return fmt.Errorf("columns have to be in the same project") return fmt.Errorf("columns have to be in the same project")
} }
if b.ID == newColumn.ID { if c.ID == newColumn.ID {
return nil return nil
} }
@ -121,7 +121,7 @@ func (b *Board) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Board)
return err return err
} }
issues, err := b.GetIssues(ctx) issues, err := c.GetIssues(ctx)
if err != nil { if err != nil {
return err return err
} }
@ -132,7 +132,7 @@ func (b *Board) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Board)
nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
return db.WithTx(ctx, func(ctx context.Context) error { return db.WithTx(ctx, func(ctx context.Context) error {
for i, issue := range issues { for i, issue := range issues {
issue.ProjectBoardID = newColumn.ID issue.ProjectColumnID = newColumn.ID
issue.Sorting = nextSorting + int64(i) issue.Sorting = nextSorting + int64(i)
if _, err := db.GetEngine(ctx).ID(issue.ID).Cols("project_board_id", "sorting").Update(issue); err != nil { if _, err := db.GetEngine(ctx).ID(issue.ID).Cols("project_board_id", "sorting").Update(issue); err != nil {
return err return err

View File

@ -21,13 +21,7 @@ import (
) )
type ( type (
// BoardConfig is used to identify the type of board that is being created // CardConfig is used to identify the type of column card that is being used
BoardConfig struct {
BoardType BoardType
Translation string
}
// CardConfig is used to identify the type of board card that is being used
CardConfig struct { CardConfig struct {
CardType CardType CardType CardType
Translation string Translation string
@ -38,7 +32,7 @@ type (
) )
const ( const (
// TypeIndividual is a type of project board that is owned by an individual // TypeIndividual is a type of project column that is owned by an individual
TypeIndividual Type = iota + 1 TypeIndividual Type = iota + 1
// TypeRepository is a project that is tied to a repository // TypeRepository is a project that is tied to a repository
@ -68,39 +62,39 @@ func (err ErrProjectNotExist) Unwrap() error {
return util.ErrNotExist return util.ErrNotExist
} }
// ErrProjectBoardNotExist represents a "ProjectBoardNotExist" kind of error. // ErrProjectColumnNotExist represents a "ErrProjectColumnNotExist" kind of error.
type ErrProjectBoardNotExist struct { type ErrProjectColumnNotExist struct {
BoardID int64 ColumnID int64
} }
// IsErrProjectBoardNotExist checks if an error is a ErrProjectBoardNotExist // IsErrProjectColumnNotExist checks if an error is a ErrProjectColumnNotExist
func IsErrProjectBoardNotExist(err error) bool { func IsErrProjectColumnNotExist(err error) bool {
_, ok := err.(ErrProjectBoardNotExist) _, ok := err.(ErrProjectColumnNotExist)
return ok return ok
} }
func (err ErrProjectBoardNotExist) Error() string { func (err ErrProjectColumnNotExist) Error() string {
return fmt.Sprintf("project board does not exist [id: %d]", err.BoardID) return fmt.Sprintf("project column does not exist [id: %d]", err.ColumnID)
} }
func (err ErrProjectBoardNotExist) Unwrap() error { func (err ErrProjectColumnNotExist) Unwrap() error {
return util.ErrNotExist return util.ErrNotExist
} }
// Project represents a project board // Project represents a project
type Project struct { type Project struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
Title string `xorm:"INDEX NOT NULL"` Title string `xorm:"INDEX NOT NULL"`
Description string `xorm:"TEXT"` Description string `xorm:"TEXT"`
OwnerID int64 `xorm:"INDEX"` OwnerID int64 `xorm:"INDEX"`
Owner *user_model.User `xorm:"-"` Owner *user_model.User `xorm:"-"`
RepoID int64 `xorm:"INDEX"` RepoID int64 `xorm:"INDEX"`
Repo *repo_model.Repository `xorm:"-"` Repo *repo_model.Repository `xorm:"-"`
CreatorID int64 `xorm:"NOT NULL"` CreatorID int64 `xorm:"NOT NULL"`
IsClosed bool `xorm:"INDEX"` IsClosed bool `xorm:"INDEX"`
BoardType BoardType TemplateType TemplateType `xorm:"'board_type'"` // TODO: rename the column to template_type
CardType CardType CardType CardType
Type Type Type Type
RenderedContent template.HTML `xorm:"-"` RenderedContent template.HTML `xorm:"-"`
@ -172,16 +166,7 @@ func init() {
db.RegisterModel(new(Project)) db.RegisterModel(new(Project))
} }
// GetBoardConfig retrieves the types of configurations project boards could have // GetCardConfig retrieves the types of configurations project column cards could have
func GetBoardConfig() []BoardConfig {
return []BoardConfig{
{BoardTypeNone, "repo.projects.type.none"},
{BoardTypeBasicKanban, "repo.projects.type.basic_kanban"},
{BoardTypeBugTriage, "repo.projects.type.bug_triage"},
}
}
// GetCardConfig retrieves the types of configurations project board cards could have
func GetCardConfig() []CardConfig { func GetCardConfig() []CardConfig {
return []CardConfig{ return []CardConfig{
{CardTypeTextOnly, "repo.projects.card_type.text_only"}, {CardTypeTextOnly, "repo.projects.card_type.text_only"},
@ -251,8 +236,8 @@ func GetSearchOrderByBySortType(sortType string) db.SearchOrderBy {
// NewProject creates a new Project // NewProject creates a new Project
func NewProject(ctx context.Context, p *Project) error { func NewProject(ctx context.Context, p *Project) error {
if !IsBoardTypeValid(p.BoardType) { if !IsTemplateTypeValid(p.TemplateType) {
p.BoardType = BoardTypeNone p.TemplateType = TemplateTypeNone
} }
if !IsCardTypeValid(p.CardType) { if !IsCardTypeValid(p.CardType) {
@ -263,27 +248,19 @@ func NewProject(ctx context.Context, p *Project) error {
return util.NewInvalidArgumentErrorf("project type is not valid") return util.NewInvalidArgumentErrorf("project type is not valid")
} }
ctx, committer, err := db.TxContext(ctx) return db.WithTx(ctx, func(ctx context.Context) error {
if err != nil { if err := db.Insert(ctx, p); err != nil {
return err
}
defer committer.Close()
if err := db.Insert(ctx, p); err != nil {
return err
}
if p.RepoID > 0 {
if _, err := db.Exec(ctx, "UPDATE `repository` SET num_projects = num_projects + 1 WHERE id = ?", p.RepoID); err != nil {
return err return err
} }
}
if err := createBoardsForProjectsType(ctx, p); err != nil { if p.RepoID > 0 {
return err if _, err := db.Exec(ctx, "UPDATE `repository` SET num_projects = num_projects + 1 WHERE id = ?", p.RepoID); err != nil {
} return err
}
}
return committer.Commit() return createDefaultColumnsForProject(ctx, p)
})
} }
// GetProjectByID returns the projects in a repository // GetProjectByID returns the projects in a repository
@ -417,7 +394,7 @@ func DeleteProjectByID(ctx context.Context, id int64) error {
return err return err
} }
if err := deleteBoardByProjectID(ctx, id); err != nil { if err := deleteColumnByProjectID(ctx, id); err != nil {
return err return err
} }

View File

@ -51,13 +51,13 @@ func TestProject(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
project := &Project{ project := &Project{
Type: TypeRepository, Type: TypeRepository,
BoardType: BoardTypeBasicKanban, TemplateType: TemplateTypeBasicKanban,
CardType: CardTypeTextOnly, CardType: CardTypeTextOnly,
Title: "New Project", Title: "New Project",
RepoID: 1, RepoID: 1,
CreatedUnix: timeutil.TimeStampNow(), CreatedUnix: timeutil.TimeStampNow(),
CreatorID: 2, CreatorID: 2,
} }
assert.NoError(t, NewProject(db.DefaultContext, project)) assert.NoError(t, NewProject(db.DefaultContext, project))

View File

@ -0,0 +1,45 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
type (
// TemplateType is used to represent a project template type
TemplateType uint8
// TemplateConfig is used to identify the template type of project that is being created
TemplateConfig struct {
TemplateType TemplateType
Translation string
}
)
const (
// TemplateTypeNone is a project template type that has no predefined columns
TemplateTypeNone TemplateType = iota
// TemplateTypeBasicKanban is a project template type that has basic predefined columns
TemplateTypeBasicKanban
// TemplateTypeBugTriage is a project template type that has predefined columns suited to hunting down bugs
TemplateTypeBugTriage
)
// GetTemplateConfigs retrieves the template configs of configurations project columns could have
func GetTemplateConfigs() []TemplateConfig {
return []TemplateConfig{
{TemplateTypeNone, "repo.projects.type.none"},
{TemplateTypeBasicKanban, "repo.projects.type.basic_kanban"},
{TemplateTypeBugTriage, "repo.projects.type.bug_triage"},
}
}
// IsTemplateTypeValid checks if the project template type is valid
func IsTemplateTypeValid(p TemplateType) bool {
switch p {
case TemplateTypeNone, TemplateTypeBasicKanban, TemplateTypeBugTriage:
return true
default:
return false
}
}

View File

@ -28,7 +28,7 @@ const (
TypeWiki // 5 Wiki TypeWiki // 5 Wiki
TypeExternalWiki // 6 ExternalWiki TypeExternalWiki // 6 ExternalWiki
TypeExternalTracker // 7 ExternalTracker TypeExternalTracker // 7 ExternalTracker
TypeProjects // 8 Kanban board TypeProjects // 8 Projects
TypePackages // 9 Packages TypePackages // 9 Packages
TypeActions // 10 Actions TypeActions // 10 Actions
) )

View File

@ -224,8 +224,8 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
if options.ProjectID.Has() { if options.ProjectID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectID.Value(), "project_id")) queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectID.Value(), "project_id"))
} }
if options.ProjectBoardID.Has() { if options.ProjectColumnID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectBoardID.Value(), "project_board_id")) queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id"))
} }
if options.PosterID.Has() { if options.PosterID.Has() {

View File

@ -61,7 +61,7 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
ReviewedID: convertID(options.ReviewedID), ReviewedID: convertID(options.ReviewedID),
SubscriberID: convertID(options.SubscriberID), SubscriberID: convertID(options.SubscriberID),
ProjectID: convertID(options.ProjectID), ProjectID: convertID(options.ProjectID),
ProjectBoardID: convertID(options.ProjectBoardID), ProjectColumnID: convertID(options.ProjectColumnID),
IsClosed: options.IsClosed, IsClosed: options.IsClosed,
IsPull: options.IsPull, IsPull: options.IsPull,
IncludedLabelNames: nil, IncludedLabelNames: nil,

View File

@ -50,7 +50,7 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
} }
searchOpt.ProjectID = convertID(opts.ProjectID) searchOpt.ProjectID = convertID(opts.ProjectID)
searchOpt.ProjectBoardID = convertID(opts.ProjectBoardID) searchOpt.ProjectColumnID = convertID(opts.ProjectColumnID)
searchOpt.PosterID = convertID(opts.PosterID) searchOpt.PosterID = convertID(opts.PosterID)
searchOpt.AssigneeID = convertID(opts.AssigneeID) searchOpt.AssigneeID = convertID(opts.AssigneeID)
searchOpt.MentionID = convertID(opts.MentionedID) searchOpt.MentionID = convertID(opts.MentionedID)

View File

@ -197,8 +197,8 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
if options.ProjectID.Has() { if options.ProjectID.Has() {
query.Must(elastic.NewTermQuery("project_id", options.ProjectID.Value())) query.Must(elastic.NewTermQuery("project_id", options.ProjectID.Value()))
} }
if options.ProjectBoardID.Has() { if options.ProjectColumnID.Has() {
query.Must(elastic.NewTermQuery("project_board_id", options.ProjectBoardID.Value())) query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value()))
} }
if options.PosterID.Has() { if options.PosterID.Has() {

View File

@ -369,13 +369,13 @@ func searchIssueInProject(t *testing.T) {
}, },
{ {
SearchOptions{ SearchOptions{
ProjectBoardID: optional.Some(int64(1)), ProjectColumnID: optional.Some(int64(1)),
}, },
[]int64{1}, []int64{1},
}, },
{ {
SearchOptions{ SearchOptions{
ProjectBoardID: optional.Some(int64(0)), // issue with in default board ProjectColumnID: optional.Some(int64(0)), // issue with in default column
}, },
[]int64{2}, []int64{2},
}, },

View File

@ -27,7 +27,7 @@ type IndexerData struct {
NoLabel bool `json:"no_label"` // True if LabelIDs is empty NoLabel bool `json:"no_label"` // True if LabelIDs is empty
MilestoneID int64 `json:"milestone_id"` MilestoneID int64 `json:"milestone_id"`
ProjectID int64 `json:"project_id"` ProjectID int64 `json:"project_id"`
ProjectBoardID int64 `json:"project_board_id"` ProjectColumnID int64 `json:"project_board_id"` // the key should be kept as project_board_id to keep compatible
PosterID int64 `json:"poster_id"` PosterID int64 `json:"poster_id"`
AssigneeID int64 `json:"assignee_id"` AssigneeID int64 `json:"assignee_id"`
MentionIDs []int64 `json:"mention_ids"` MentionIDs []int64 `json:"mention_ids"`
@ -89,8 +89,8 @@ type SearchOptions struct {
MilestoneIDs []int64 // milestones the issues have MilestoneIDs []int64 // milestones the issues have
ProjectID optional.Option[int64] // project the issues belong to ProjectID optional.Option[int64] // project the issues belong to
ProjectBoardID optional.Option[int64] // project board the issues belong to ProjectColumnID optional.Option[int64] // project column the issues belong to
PosterID optional.Option[int64] // poster of the issues PosterID optional.Option[int64] // poster of the issues

View File

@ -338,38 +338,38 @@ var cases = []*testIndexerCase{
}, },
}, },
{ {
Name: "ProjectBoardID", Name: "ProjectColumnID",
SearchOptions: &internal.SearchOptions{ SearchOptions: &internal.SearchOptions{
Paginator: &db.ListOptions{ Paginator: &db.ListOptions{
PageSize: 5, PageSize: 5,
}, },
ProjectBoardID: optional.Some(int64(1)), ProjectColumnID: optional.Some(int64(1)),
}, },
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Equal(t, 5, len(result.Hits)) assert.Equal(t, 5, len(result.Hits))
for _, v := range result.Hits { for _, v := range result.Hits {
assert.Equal(t, int64(1), data[v.ID].ProjectBoardID) assert.Equal(t, int64(1), data[v.ID].ProjectColumnID)
} }
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.ProjectBoardID == 1 return v.ProjectColumnID == 1
}), result.Total) }), result.Total)
}, },
}, },
{ {
Name: "no ProjectBoardID", Name: "no ProjectColumnID",
SearchOptions: &internal.SearchOptions{ SearchOptions: &internal.SearchOptions{
Paginator: &db.ListOptions{ Paginator: &db.ListOptions{
PageSize: 5, PageSize: 5,
}, },
ProjectBoardID: optional.Some(int64(0)), ProjectColumnID: optional.Some(int64(0)),
}, },
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Equal(t, 5, len(result.Hits)) assert.Equal(t, 5, len(result.Hits))
for _, v := range result.Hits { for _, v := range result.Hits {
assert.Equal(t, int64(0), data[v.ID].ProjectBoardID) assert.Equal(t, int64(0), data[v.ID].ProjectColumnID)
} }
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.ProjectBoardID == 0 return v.ProjectColumnID == 0
}), result.Total) }), result.Total)
}, },
}, },
@ -706,7 +706,7 @@ func generateDefaultIndexerData() []*internal.IndexerData {
NoLabel: len(labelIDs) == 0, NoLabel: len(labelIDs) == 0,
MilestoneID: issueIndex % 4, MilestoneID: issueIndex % 4,
ProjectID: issueIndex % 5, ProjectID: issueIndex % 5,
ProjectBoardID: issueIndex % 6, ProjectColumnID: issueIndex % 6,
PosterID: id%10 + 1, // PosterID should not be 0 PosterID: id%10 + 1, // PosterID should not be 0
AssigneeID: issueIndex % 10, AssigneeID: issueIndex % 10,
MentionIDs: mentionIDs, MentionIDs: mentionIDs,

View File

@ -174,8 +174,8 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
if options.ProjectID.Has() { if options.ProjectID.Has() {
query.And(inner_meilisearch.NewFilterEq("project_id", options.ProjectID.Value())) query.And(inner_meilisearch.NewFilterEq("project_id", options.ProjectID.Value()))
} }
if options.ProjectBoardID.Has() { if options.ProjectColumnID.Has() {
query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectBoardID.Value())) query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value()))
} }
if options.PosterID.Has() { if options.PosterID.Has() {

View File

@ -105,7 +105,7 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
NoLabel: len(labels) == 0, NoLabel: len(labels) == 0,
MilestoneID: issue.MilestoneID, MilestoneID: issue.MilestoneID,
ProjectID: projectID, ProjectID: projectID,
ProjectBoardID: issue.ProjectBoardID(ctx), ProjectColumnID: issue.ProjectColumnID(ctx),
PosterID: issue.PosterID, PosterID: issue.PosterID,
AssigneeID: issue.AssigneeID, AssigneeID: issue.AssigneeID,
MentionIDs: mentionIDs, MentionIDs: mentionIDs,

View File

@ -36,7 +36,7 @@ type Collector struct {
Oauths *prometheus.Desc Oauths *prometheus.Desc
Organizations *prometheus.Desc Organizations *prometheus.Desc
Projects *prometheus.Desc Projects *prometheus.Desc
ProjectBoards *prometheus.Desc ProjectColumns *prometheus.Desc
PublicKeys *prometheus.Desc PublicKeys *prometheus.Desc
Releases *prometheus.Desc Releases *prometheus.Desc
Repositories *prometheus.Desc Repositories *prometheus.Desc
@ -146,9 +146,9 @@ func NewCollector() Collector {
"Number of projects", "Number of projects",
nil, nil, nil, nil,
), ),
ProjectBoards: prometheus.NewDesc( ProjectColumns: prometheus.NewDesc(
namespace+"projects_boards", namespace+"projects_boards", // TODO: change the key name will affect the consume's result history
"Number of project boards", "Number of project columns",
nil, nil, nil, nil,
), ),
PublicKeys: prometheus.NewDesc( PublicKeys: prometheus.NewDesc(
@ -219,7 +219,7 @@ func (c Collector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.Oauths ch <- c.Oauths
ch <- c.Organizations ch <- c.Organizations
ch <- c.Projects ch <- c.Projects
ch <- c.ProjectBoards ch <- c.ProjectColumns
ch <- c.PublicKeys ch <- c.PublicKeys
ch <- c.Releases ch <- c.Releases
ch <- c.Repositories ch <- c.Repositories
@ -336,9 +336,9 @@ func (c Collector) Collect(ch chan<- prometheus.Metric) {
float64(stats.Counter.Project), float64(stats.Counter.Project),
) )
ch <- prometheus.MustNewConstMetric( ch <- prometheus.MustNewConstMetric(
c.ProjectBoards, c.ProjectColumns,
prometheus.GaugeValue, prometheus.GaugeValue,
float64(stats.Counter.ProjectBoard), float64(stats.Counter.ProjectColumn),
) )
ch <- prometheus.MustNewConstMetric( ch <- prometheus.MustNewConstMetric(
c.PublicKeys, c.PublicKeys,

View File

@ -1215,7 +1215,7 @@ branches = Branches
tags = Tags tags = Tags
issues = Issues issues = Issues
pulls = Pull Requests pulls = Pull Requests
project_board = Projects projects = Projects
packages = Packages packages = Packages
actions = Actions actions = Actions
labels = Labels labels = Labels
@ -1379,7 +1379,7 @@ ext_issues = Access to External Issues
ext_issues.desc = Link to an external issue tracker. ext_issues.desc = Link to an external issue tracker.
projects = Projects projects = Projects
projects.desc = Manage issues and pulls in project boards. projects.desc = Manage issues and pulls in projects.
projects.description = Description (optional) projects.description = Description (optional)
projects.description_placeholder = Description projects.description_placeholder = Description
projects.create = Create Project projects.create = Create Project

View File

@ -34,7 +34,7 @@ const (
// MustEnableProjects check if projects are enabled in settings // MustEnableProjects check if projects are enabled in settings
func MustEnableProjects(ctx *context.Context) { func MustEnableProjects(ctx *context.Context) {
if unit.TypeProjects.UnitGlobalDisabled() { if unit.TypeProjects.UnitGlobalDisabled() {
ctx.NotFound("EnableKanbanBoard", nil) ctx.NotFound("EnableProjects", nil)
return return
} }
} }
@ -42,7 +42,7 @@ func MustEnableProjects(ctx *context.Context) {
// Projects renders the home page of projects // Projects renders the home page of projects
func Projects(ctx *context.Context) { func Projects(ctx *context.Context) {
shared_user.PrepareContextForProfileBigAvatar(ctx) shared_user.PrepareContextForProfileBigAvatar(ctx)
ctx.Data["Title"] = ctx.Tr("repo.project_board") ctx.Data["Title"] = ctx.Tr("repo.projects")
sortType := ctx.FormTrim("sort") sortType := ctx.FormTrim("sort")
@ -139,7 +139,7 @@ func canWriteProjects(ctx *context.Context) bool {
// RenderNewProject render creating a project page // RenderNewProject render creating a project page
func RenderNewProject(ctx *context.Context) { func RenderNewProject(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.projects.new") ctx.Data["Title"] = ctx.Tr("repo.projects.new")
ctx.Data["BoardTypes"] = project_model.GetBoardConfig() ctx.Data["TemplateConfigs"] = project_model.GetTemplateConfigs()
ctx.Data["CardTypes"] = project_model.GetCardConfig() ctx.Data["CardTypes"] = project_model.GetCardConfig()
ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) ctx.Data["CanWriteProjects"] = canWriteProjects(ctx)
ctx.Data["PageIsViewProjects"] = true ctx.Data["PageIsViewProjects"] = true
@ -168,12 +168,12 @@ func NewProjectPost(ctx *context.Context) {
} }
newProject := project_model.Project{ newProject := project_model.Project{
OwnerID: ctx.ContextUser.ID, OwnerID: ctx.ContextUser.ID,
Title: form.Title, Title: form.Title,
Description: form.Content, Description: form.Content,
CreatorID: ctx.Doer.ID, CreatorID: ctx.Doer.ID,
BoardType: form.BoardType, TemplateType: form.TemplateType,
CardType: form.CardType, CardType: form.CardType,
} }
if ctx.ContextUser.IsOrganization() { if ctx.ContextUser.IsOrganization() {
@ -314,7 +314,7 @@ func EditProjectPost(ctx *context.Context) {
} }
} }
// ViewProject renders the project board for a project // ViewProject renders the project with board view for a project
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 {
@ -326,15 +326,15 @@ func ViewProject(ctx *context.Context) {
return return
} }
boards, err := project.GetBoards(ctx) columns, err := project.GetColumns(ctx)
if err != nil { if err != nil {
ctx.ServerError("GetProjectBoards", err) ctx.ServerError("GetProjectColumns", err)
return return
} }
issuesMap, err := issues_model.LoadIssuesFromBoardList(ctx, boards) issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns)
if err != nil { if err != nil {
ctx.ServerError("LoadIssuesOfBoards", err) ctx.ServerError("LoadIssuesOfColumns", err)
return return
} }
@ -377,7 +377,7 @@ func ViewProject(ctx *context.Context) {
ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) ctx.Data["CanWriteProjects"] = canWriteProjects(ctx)
ctx.Data["Project"] = project ctx.Data["Project"] = project
ctx.Data["IssuesMap"] = issuesMap ctx.Data["IssuesMap"] = issuesMap
ctx.Data["Columns"] = boards // TODO: rename boards to columns in backend ctx.Data["Columns"] = columns
shared_user.RenderUserHeader(ctx) shared_user.RenderUserHeader(ctx)
err = shared_user.LoadHeaderCount(ctx) err = shared_user.LoadHeaderCount(ctx)
@ -389,8 +389,8 @@ func ViewProject(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplProjectsView) ctx.HTML(http.StatusOK, tplProjectsView)
} }
// DeleteProjectBoard allows for the deletion of a project board // DeleteProjectColumn allows for the deletion of a project column
func DeleteProjectBoard(ctx *context.Context) { func DeleteProjectColumn(ctx *context.Context) {
if ctx.Doer == nil { if ctx.Doer == nil {
ctx.JSON(http.StatusForbidden, map[string]string{ ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only signed in users are allowed to perform this action.", "message": "Only signed in users are allowed to perform this action.",
@ -404,36 +404,36 @@ func DeleteProjectBoard(ctx *context.Context) {
return return
} }
pb, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) pb, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID"))
if err != nil { if err != nil {
ctx.ServerError("GetProjectBoard", err) ctx.ServerError("GetProjectColumn", err)
return return
} }
if pb.ProjectID != ctx.ParamsInt64(":id") { if pb.ProjectID != ctx.ParamsInt64(":id") {
ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
"message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", pb.ID, project.ID), "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", pb.ID, project.ID),
}) })
return return
} }
if project.OwnerID != ctx.ContextUser.ID { if project.OwnerID != ctx.ContextUser.ID {
ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
"message": fmt.Sprintf("ProjectBoard[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID), "message": fmt.Sprintf("ProjectColumn[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID),
}) })
return return
} }
if err := project_model.DeleteBoardByID(ctx, ctx.ParamsInt64(":boardID")); err != nil { if err := project_model.DeleteColumnByID(ctx, ctx.ParamsInt64(":columnID")); err != nil {
ctx.ServerError("DeleteProjectBoardByID", err) ctx.ServerError("DeleteProjectColumnByID", err)
return return
} }
ctx.JSONOK() ctx.JSONOK()
} }
// AddBoardToProjectPost allows a new board to be added to a project. // AddColumnToProjectPost allows a new column to be added to a project.
func AddBoardToProjectPost(ctx *context.Context) { func AddColumnToProjectPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditProjectBoardForm) form := web.GetForm(ctx).(*forms.EditProjectColumnForm)
project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
if err != nil { if err != nil {
@ -441,21 +441,21 @@ func AddBoardToProjectPost(ctx *context.Context) {
return return
} }
if err := project_model.NewBoard(ctx, &project_model.Board{ if err := project_model.NewColumn(ctx, &project_model.Column{
ProjectID: project.ID, ProjectID: project.ID,
Title: form.Title, Title: form.Title,
Color: form.Color, Color: form.Color,
CreatorID: ctx.Doer.ID, CreatorID: ctx.Doer.ID,
}); err != nil { }); err != nil {
ctx.ServerError("NewProjectBoard", err) ctx.ServerError("NewProjectColumn", err)
return return
} }
ctx.JSONOK() ctx.JSONOK()
} }
// CheckProjectBoardChangePermissions check permission // CheckProjectColumnChangePermissions check permission
func CheckProjectBoardChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Board) { func CheckProjectColumnChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Column) {
if ctx.Doer == nil { if ctx.Doer == nil {
ctx.JSON(http.StatusForbidden, map[string]string{ ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only signed in users are allowed to perform this action.", "message": "Only signed in users are allowed to perform this action.",
@ -469,62 +469,60 @@ func CheckProjectBoardChangePermissions(ctx *context.Context) (*project_model.Pr
return nil, nil return nil, nil
} }
board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) column, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID"))
if err != nil { if err != nil {
ctx.ServerError("GetProjectBoard", err) ctx.ServerError("GetProjectColumn", err)
return nil, nil return nil, nil
} }
if board.ProjectID != ctx.ParamsInt64(":id") { if column.ProjectID != ctx.ParamsInt64(":id") {
ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
"message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", board.ID, project.ID), "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", column.ID, project.ID),
}) })
return nil, nil return nil, nil
} }
if project.OwnerID != ctx.ContextUser.ID { if project.OwnerID != ctx.ContextUser.ID {
ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
"message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", board.ID, project.ID), "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, project.ID),
}) })
return nil, nil return nil, nil
} }
return project, board return project, column
} }
// EditProjectBoard allows a project board's to be updated // EditProjectColumn allows a project column's to be updated
func EditProjectBoard(ctx *context.Context) { func EditProjectColumn(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditProjectBoardForm) form := web.GetForm(ctx).(*forms.EditProjectColumnForm)
_, board := CheckProjectBoardChangePermissions(ctx) _, column := CheckProjectColumnChangePermissions(ctx)
if ctx.Written() { if ctx.Written() {
return return
} }
if form.Title != "" { if form.Title != "" {
board.Title = form.Title column.Title = form.Title
} }
column.Color = form.Color
board.Color = form.Color
if form.Sorting != 0 { if form.Sorting != 0 {
board.Sorting = form.Sorting column.Sorting = form.Sorting
} }
if err := project_model.UpdateBoard(ctx, board); err != nil { if err := project_model.UpdateColumn(ctx, column); err != nil {
ctx.ServerError("UpdateProjectBoard", err) ctx.ServerError("UpdateProjectColumn", err)
return return
} }
ctx.JSONOK() ctx.JSONOK()
} }
// SetDefaultProjectBoard set default board for uncategorized issues/pulls // SetDefaultProjectColumn set default column for uncategorized issues/pulls
func SetDefaultProjectBoard(ctx *context.Context) { func SetDefaultProjectColumn(ctx *context.Context) {
project, board := CheckProjectBoardChangePermissions(ctx) project, column := CheckProjectColumnChangePermissions(ctx)
if ctx.Written() { if ctx.Written() {
return return
} }
if err := project_model.SetDefaultBoard(ctx, project.ID, board.ID); err != nil { if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil {
ctx.ServerError("SetDefaultBoard", err) ctx.ServerError("SetDefaultColumn", err)
return return
} }
@ -550,14 +548,14 @@ func MoveIssues(ctx *context.Context) {
return return
} }
board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) column, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID"))
if err != nil { if err != nil {
ctx.NotFoundOrServerError("GetProjectBoard", project_model.IsErrProjectBoardNotExist, err) ctx.NotFoundOrServerError("GetProjectColumn", project_model.IsErrProjectColumnNotExist, err)
return return
} }
if board.ProjectID != project.ID { if column.ProjectID != project.ID {
ctx.NotFound("BoardNotInProject", nil) ctx.NotFound("ColumnNotInProject", nil)
return return
} }
@ -602,8 +600,8 @@ func MoveIssues(ctx *context.Context) {
} }
} }
if err = project_model.MoveIssuesOnProjectBoard(ctx, board, sortedIssueIDs); err != nil { if err = project_model.MoveIssuesOnProjectColumn(ctx, column, sortedIssueIDs); err != nil {
ctx.ServerError("MoveIssuesOnProjectBoard", err) ctx.ServerError("MoveIssuesOnProjectColumn", err)
return return
} }

View File

@ -13,16 +13,16 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestCheckProjectBoardChangePermissions(t *testing.T) { func TestCheckProjectColumnChangePermissions(t *testing.T) {
unittest.PrepareTestEnv(t) unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user2/-/projects/4/4") ctx, _ := contexttest.MockContext(t, "user2/-/projects/4/4")
contexttest.LoadUser(t, ctx, 2) contexttest.LoadUser(t, ctx, 2)
ctx.ContextUser = ctx.Doer // user2 ctx.ContextUser = ctx.Doer // user2
ctx.SetParams(":id", "4") ctx.SetParams(":id", "4")
ctx.SetParams(":boardID", "4") ctx.SetParams(":columnID", "4")
project, board := org.CheckProjectBoardChangePermissions(ctx) project, column := org.CheckProjectColumnChangePermissions(ctx)
assert.NotNil(t, project) assert.NotNil(t, project)
assert.NotNil(t, board) assert.NotNil(t, column)
assert.False(t, ctx.Written()) assert.False(t, ctx.Written())
} }

View File

@ -2826,12 +2826,12 @@ func ListIssues(ctx *context.Context) {
Page: ctx.FormInt("page"), Page: ctx.FormInt("page"),
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
}, },
Keyword: keyword, Keyword: keyword,
RepoIDs: []int64{ctx.Repo.Repository.ID}, RepoIDs: []int64{ctx.Repo.Repository.ID},
IsPull: isPull, IsPull: isPull,
IsClosed: isClosed, IsClosed: isClosed,
ProjectBoardID: projectID, ProjectID: projectID,
SortBy: issue_indexer.SortByCreatedDesc, SortBy: issue_indexer.SortByCreatedDesc,
} }
if since != 0 { if since != 0 {
searchOpt.UpdatedAfterUnix = optional.Some(since) searchOpt.UpdatedAfterUnix = optional.Some(since)

View File

@ -36,7 +36,7 @@ const (
// MustEnableRepoProjects check if repo projects are enabled in settings // MustEnableRepoProjects check if repo projects are enabled in settings
func MustEnableRepoProjects(ctx *context.Context) { func MustEnableRepoProjects(ctx *context.Context) {
if unit.TypeProjects.UnitGlobalDisabled() { if unit.TypeProjects.UnitGlobalDisabled() {
ctx.NotFound("EnableKanbanBoard", nil) ctx.NotFound("EnableRepoProjects", nil)
return return
} }
@ -51,7 +51,7 @@ func MustEnableRepoProjects(ctx *context.Context) {
// Projects renders the home page of projects // Projects renders the home page of projects
func Projects(ctx *context.Context) { func Projects(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.project_board") ctx.Data["Title"] = ctx.Tr("repo.projects")
sortType := ctx.FormTrim("sort") sortType := ctx.FormTrim("sort")
@ -132,7 +132,7 @@ func Projects(ctx *context.Context) {
// RenderNewProject render creating a project page // RenderNewProject render creating a project page
func RenderNewProject(ctx *context.Context) { func RenderNewProject(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.projects.new") ctx.Data["Title"] = ctx.Tr("repo.projects.new")
ctx.Data["BoardTypes"] = project_model.GetBoardConfig() ctx.Data["TemplateConfigs"] = project_model.GetTemplateConfigs()
ctx.Data["CardTypes"] = project_model.GetCardConfig() ctx.Data["CardTypes"] = project_model.GetCardConfig()
ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
ctx.Data["CancelLink"] = ctx.Repo.Repository.Link() + "/projects" ctx.Data["CancelLink"] = ctx.Repo.Repository.Link() + "/projects"
@ -150,13 +150,13 @@ func NewProjectPost(ctx *context.Context) {
} }
if err := project_model.NewProject(ctx, &project_model.Project{ if err := project_model.NewProject(ctx, &project_model.Project{
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,
Title: form.Title, Title: form.Title,
Description: form.Content, Description: form.Content,
CreatorID: ctx.Doer.ID, CreatorID: ctx.Doer.ID,
BoardType: form.BoardType, TemplateType: form.TemplateType,
CardType: form.CardType, CardType: form.CardType,
Type: project_model.TypeRepository, Type: project_model.TypeRepository,
}); err != nil { }); err != nil {
ctx.ServerError("NewProject", err) ctx.ServerError("NewProject", err)
return return
@ -289,7 +289,7 @@ func EditProjectPost(ctx *context.Context) {
} }
} }
// ViewProject renders the project board for a project // ViewProject renders the project with board view
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 {
@ -305,15 +305,15 @@ func ViewProject(ctx *context.Context) {
return return
} }
boards, err := project.GetBoards(ctx) columns, err := project.GetColumns(ctx)
if err != nil { if err != nil {
ctx.ServerError("GetProjectBoards", err) ctx.ServerError("GetProjectColumns", err)
return return
} }
issuesMap, err := issues_model.LoadIssuesFromBoardList(ctx, boards) issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns)
if err != nil { if err != nil {
ctx.ServerError("LoadIssuesOfBoards", err) ctx.ServerError("LoadIssuesOfColumns", err)
return return
} }
@ -368,7 +368,7 @@ func ViewProject(ctx *context.Context) {
ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
ctx.Data["Project"] = project ctx.Data["Project"] = project
ctx.Data["IssuesMap"] = issuesMap ctx.Data["IssuesMap"] = issuesMap
ctx.Data["Columns"] = boards // TODO: rename boards to columns in backend ctx.Data["Columns"] = columns
ctx.HTML(http.StatusOK, tplProjectsView) ctx.HTML(http.StatusOK, tplProjectsView)
} }
@ -406,8 +406,8 @@ func UpdateIssueProject(ctx *context.Context) {
ctx.JSONOK() ctx.JSONOK()
} }
// DeleteProjectBoard allows for the deletion of a project board // DeleteProjectColumn allows for the deletion of a project column
func DeleteProjectBoard(ctx *context.Context) { func DeleteProjectColumn(ctx *context.Context) {
if ctx.Doer == nil { if ctx.Doer == nil {
ctx.JSON(http.StatusForbidden, map[string]string{ ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only signed in users are allowed to perform this action.", "message": "Only signed in users are allowed to perform this action.",
@ -432,36 +432,36 @@ func DeleteProjectBoard(ctx *context.Context) {
return return
} }
pb, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) pb, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID"))
if err != nil { if err != nil {
ctx.ServerError("GetProjectBoard", err) ctx.ServerError("GetProjectColumn", err)
return return
} }
if pb.ProjectID != ctx.ParamsInt64(":id") { if pb.ProjectID != ctx.ParamsInt64(":id") {
ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
"message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", pb.ID, project.ID), "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", pb.ID, project.ID),
}) })
return return
} }
if project.RepoID != ctx.Repo.Repository.ID { if project.RepoID != ctx.Repo.Repository.ID {
ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
"message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", pb.ID, ctx.Repo.Repository.ID), "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", pb.ID, ctx.Repo.Repository.ID),
}) })
return return
} }
if err := project_model.DeleteBoardByID(ctx, ctx.ParamsInt64(":boardID")); err != nil { if err := project_model.DeleteColumnByID(ctx, ctx.ParamsInt64(":columnID")); err != nil {
ctx.ServerError("DeleteProjectBoardByID", err) ctx.ServerError("DeleteProjectColumnByID", err)
return return
} }
ctx.JSONOK() ctx.JSONOK()
} }
// AddBoardToProjectPost allows a new board to be added to a project. // AddColumnToProjectPost allows a new column to be added to a project.
func AddBoardToProjectPost(ctx *context.Context) { func AddColumnToProjectPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditProjectBoardForm) form := web.GetForm(ctx).(*forms.EditProjectColumnForm)
if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) {
ctx.JSON(http.StatusForbidden, map[string]string{ ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only authorized users are allowed to perform this action.", "message": "Only authorized users are allowed to perform this action.",
@ -479,20 +479,20 @@ func AddBoardToProjectPost(ctx *context.Context) {
return return
} }
if err := project_model.NewBoard(ctx, &project_model.Board{ if err := project_model.NewColumn(ctx, &project_model.Column{
ProjectID: project.ID, ProjectID: project.ID,
Title: form.Title, Title: form.Title,
Color: form.Color, Color: form.Color,
CreatorID: ctx.Doer.ID, CreatorID: ctx.Doer.ID,
}); err != nil { }); err != nil {
ctx.ServerError("NewProjectBoard", err) ctx.ServerError("NewProjectColumn", err)
return return
} }
ctx.JSONOK() ctx.JSONOK()
} }
func checkProjectBoardChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Board) { func checkProjectColumnChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Column) {
if ctx.Doer == nil { if ctx.Doer == nil {
ctx.JSON(http.StatusForbidden, map[string]string{ ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only signed in users are allowed to perform this action.", "message": "Only signed in users are allowed to perform this action.",
@ -517,62 +517,60 @@ func checkProjectBoardChangePermissions(ctx *context.Context) (*project_model.Pr
return nil, nil return nil, nil
} }
board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) column, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID"))
if err != nil { if err != nil {
ctx.ServerError("GetProjectBoard", err) ctx.ServerError("GetProjectColumn", err)
return nil, nil return nil, nil
} }
if board.ProjectID != ctx.ParamsInt64(":id") { if column.ProjectID != ctx.ParamsInt64(":id") {
ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
"message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", board.ID, project.ID), "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", column.ID, project.ID),
}) })
return nil, nil return nil, nil
} }
if project.RepoID != ctx.Repo.Repository.ID { if project.RepoID != ctx.Repo.Repository.ID {
ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
"message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", board.ID, ctx.Repo.Repository.ID), "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, ctx.Repo.Repository.ID),
}) })
return nil, nil return nil, nil
} }
return project, board return project, column
} }
// EditProjectBoard allows a project board's to be updated // EditProjectColumn allows a project column's to be updated
func EditProjectBoard(ctx *context.Context) { func EditProjectColumn(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditProjectBoardForm) form := web.GetForm(ctx).(*forms.EditProjectColumnForm)
_, board := checkProjectBoardChangePermissions(ctx) _, column := checkProjectColumnChangePermissions(ctx)
if ctx.Written() { if ctx.Written() {
return return
} }
if form.Title != "" { if form.Title != "" {
board.Title = form.Title column.Title = form.Title
} }
column.Color = form.Color
board.Color = form.Color
if form.Sorting != 0 { if form.Sorting != 0 {
board.Sorting = form.Sorting column.Sorting = form.Sorting
} }
if err := project_model.UpdateBoard(ctx, board); err != nil { if err := project_model.UpdateColumn(ctx, column); err != nil {
ctx.ServerError("UpdateProjectBoard", err) ctx.ServerError("UpdateProjectColumn", err)
return return
} }
ctx.JSONOK() ctx.JSONOK()
} }
// SetDefaultProjectBoard set default board for uncategorized issues/pulls // SetDefaultProjectColumn set default column for uncategorized issues/pulls
func SetDefaultProjectBoard(ctx *context.Context) { func SetDefaultProjectColumn(ctx *context.Context) {
project, board := checkProjectBoardChangePermissions(ctx) project, column := checkProjectColumnChangePermissions(ctx)
if ctx.Written() { if ctx.Written() {
return return
} }
if err := project_model.SetDefaultBoard(ctx, project.ID, board.ID); err != nil { if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil {
ctx.ServerError("SetDefaultBoard", err) ctx.ServerError("SetDefaultColumn", err)
return return
} }
@ -609,18 +607,18 @@ func MoveIssues(ctx *context.Context) {
return return
} }
board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) column, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID"))
if err != nil { if err != nil {
if project_model.IsErrProjectBoardNotExist(err) { if project_model.IsErrProjectColumnNotExist(err) {
ctx.NotFound("ProjectBoardNotExist", nil) ctx.NotFound("ProjectColumnNotExist", nil)
} else { } else {
ctx.ServerError("GetProjectBoard", err) ctx.ServerError("GetProjectColumn", err)
} }
return return
} }
if board.ProjectID != project.ID { if column.ProjectID != project.ID {
ctx.NotFound("BoardNotInProject", nil) ctx.NotFound("ColumnNotInProject", nil)
return return
} }
@ -664,8 +662,8 @@ func MoveIssues(ctx *context.Context) {
} }
} }
if err = project_model.MoveIssuesOnProjectBoard(ctx, board, sortedIssueIDs); err != nil { if err = project_model.MoveIssuesOnProjectColumn(ctx, column, sortedIssueIDs); err != nil {
ctx.ServerError("MoveIssuesOnProjectBoard", err) ctx.ServerError("MoveIssuesOnProjectColumn", err)
return return
} }

View File

@ -12,16 +12,16 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestCheckProjectBoardChangePermissions(t *testing.T) { func TestCheckProjectColumnChangePermissions(t *testing.T) {
unittest.PrepareTestEnv(t) unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user2/repo1/projects/1/2") ctx, _ := contexttest.MockContext(t, "user2/repo1/projects/1/2")
contexttest.LoadUser(t, ctx, 2) contexttest.LoadUser(t, ctx, 2)
contexttest.LoadRepo(t, ctx, 1) contexttest.LoadRepo(t, ctx, 1)
ctx.SetParams(":id", "1") ctx.SetParams(":id", "1")
ctx.SetParams(":boardID", "2") ctx.SetParams(":columnID", "2")
project, board := checkProjectBoardChangePermissions(ctx) project, column := checkProjectColumnChangePermissions(ctx)
assert.NotNil(t, project) assert.NotNil(t, project)
assert.NotNil(t, board) assert.NotNil(t, column)
assert.False(t, ctx.Written()) assert.False(t, ctx.Written())
} }

View File

@ -1000,7 +1000,7 @@ func registerRoutes(m *web.Route) {
m.Get("/new", org.RenderNewProject) m.Get("/new", org.RenderNewProject)
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.EditProjectColumnForm{}), org.AddColumnToProjectPost)
m.Post("/move", project.MoveColumns) m.Post("/move", project.MoveColumns)
m.Post("/delete", org.DeleteProject) m.Post("/delete", org.DeleteProject)
@ -1008,10 +1008,10 @@ func registerRoutes(m *web.Route) {
m.Post("/edit", web.Bind(forms.CreateProjectForm{}), org.EditProjectPost) m.Post("/edit", web.Bind(forms.CreateProjectForm{}), org.EditProjectPost)
m.Post("/{action:open|close}", org.ChangeProjectStatus) m.Post("/{action:open|close}", org.ChangeProjectStatus)
m.Group("/{boardID}", func() { m.Group("/{columnID}", func() {
m.Put("", web.Bind(forms.EditProjectBoardForm{}), org.EditProjectBoard) m.Put("", web.Bind(forms.EditProjectColumnForm{}), org.EditProjectColumn)
m.Delete("", org.DeleteProjectBoard) m.Delete("", org.DeleteProjectColumn)
m.Post("/default", org.SetDefaultProjectBoard) m.Post("/default", org.SetDefaultProjectColumn)
m.Post("/move", org.MoveIssues) m.Post("/move", org.MoveIssues)
}) })
}) })
@ -1356,7 +1356,7 @@ func registerRoutes(m *web.Route) {
m.Get("/new", repo.RenderNewProject) m.Get("/new", repo.RenderNewProject)
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.EditProjectColumnForm{}), repo.AddColumnToProjectPost)
m.Post("/move", project.MoveColumns) m.Post("/move", project.MoveColumns)
m.Post("/delete", repo.DeleteProject) m.Post("/delete", repo.DeleteProject)
@ -1364,10 +1364,10 @@ func registerRoutes(m *web.Route) {
m.Post("/edit", web.Bind(forms.CreateProjectForm{}), repo.EditProjectPost) m.Post("/edit", web.Bind(forms.CreateProjectForm{}), repo.EditProjectPost)
m.Post("/{action:open|close}", repo.ChangeProjectStatus) m.Post("/{action:open|close}", repo.ChangeProjectStatus)
m.Group("/{boardID}", func() { m.Group("/{columnID}", func() {
m.Put("", web.Bind(forms.EditProjectBoardForm{}), repo.EditProjectBoard) m.Put("", web.Bind(forms.EditProjectColumnForm{}), repo.EditProjectColumn)
m.Delete("", repo.DeleteProjectBoard) m.Delete("", repo.DeleteProjectColumn)
m.Post("/default", repo.SetDefaultProjectBoard) m.Post("/default", repo.SetDefaultProjectColumn)
m.Post("/move", repo.MoveIssues) m.Post("/move", repo.MoveIssues)
}) })
}) })

View File

@ -505,45 +505,21 @@ func (i IssueLockForm) HasValidReason() bool {
return false return false
} }
// __________ __ __
// \______ \_______ ____ |__| ____ _____/ |_ ______
// | ___/\_ __ \/ _ \ | |/ __ \_/ ___\ __\/ ___/
// | | | | \( <_> ) | \ ___/\ \___| | \___ \
// |____| |__| \____/\__| |\___ >\___ >__| /____ >
// \______| \/ \/ \/
// CreateProjectForm form for creating a project // CreateProjectForm form for creating a project
type CreateProjectForm struct { type CreateProjectForm struct {
Title string `binding:"Required;MaxSize(100)"` Title string `binding:"Required;MaxSize(100)"`
Content string Content string
BoardType project_model.BoardType TemplateType project_model.TemplateType
CardType project_model.CardType CardType project_model.CardType
} }
// UserCreateProjectForm is a from for creating an individual or organization // EditProjectColumnForm is a form for editing a project column
// form. type EditProjectColumnForm struct {
type UserCreateProjectForm struct {
Title string `binding:"Required;MaxSize(100)"`
Content string
BoardType project_model.BoardType
CardType project_model.CardType
UID int64 `binding:"Required"`
}
// EditProjectBoardForm is a form for editing a project board
type EditProjectBoardForm struct {
Title string `binding:"Required;MaxSize(100)"` Title string `binding:"Required;MaxSize(100)"`
Sorting int8 Sorting int8
Color string `binding:"MaxSize(7)"` Color string `binding:"MaxSize(7)"`
} }
// _____ .__.__ __
// / \ |__| | ____ _______/ |_ ____ ____ ____
// / \ / \| | | _/ __ \ / ___/\ __\/ _ \ / \_/ __ \
// / Y \ | |_\ ___/ \___ \ | | ( <_> ) | \ ___/
// \____|__ /__|____/\___ >____ > |__| \____/|___| /\___ >
// \/ \/ \/ \/ \/
// CreateMilestoneForm form for creating milestone // CreateMilestoneForm form for creating milestone
type CreateMilestoneForm struct { type CreateMilestoneForm struct {
Title string `binding:"Required;MaxSize(50)"` Title string `binding:"Required;MaxSize(50)"`
@ -557,13 +533,6 @@ func (f *CreateMilestoneForm) Validate(req *http.Request, errs binding.Errors) b
return middleware.Validate(errs, ctx.Data, f, ctx.Locale) return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
} }
// .____ ___. .__
// | | _____ \_ |__ ____ | |
// | | \__ \ | __ \_/ __ \| |
// | |___ / __ \| \_\ \ ___/| |__
// |_______ (____ /___ /\___ >____/
// \/ \/ \/ \/
// CreateLabelForm form for creating label // CreateLabelForm form for creating label
type CreateLabelForm struct { type CreateLabelForm struct {
ID int64 ID int64
@ -591,13 +560,6 @@ func (f *InitializeLabelsForm) Validate(req *http.Request, errs binding.Errors)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale) return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
} }
// __________ .__ .__ __________ __
// \______ \__ __| | | | \______ \ ____ ________ __ ____ _______/ |_
// | ___/ | \ | | | | _// __ \/ ____/ | \_/ __ \ / ___/\ __\
// | | | | / |_| |__ | | \ ___< <_| | | /\ ___/ \___ \ | |
// |____| |____/|____/____/ |____|_ /\___ >__ |____/ \___ >____ > |__|
// \/ \/ |__| \/ \/
// MergePullRequestForm form for merging Pull Request // MergePullRequestForm form for merging Pull Request
// swagger:model MergePullRequestOption // swagger:model MergePullRequestOption
type MergePullRequestForm struct { type MergePullRequestForm struct {

View File

@ -65,7 +65,7 @@ var hiddenCommentTypeGroups = hiddenCommentTypeGroupsType{
}, },
"project": { "project": {
/*30*/ issues_model.CommentTypeProject, /*30*/ issues_model.CommentTypeProject,
/*31*/ issues_model.CommentTypeProjectBoard, /*31*/ issues_model.CommentTypeProjectColumn,
}, },
"issue_ref": { "issue_ref": {
/*33*/ issues_model.CommentTypeChangeIssueRef, /*33*/ issues_model.CommentTypeChangeIssueRef,

View File

@ -25,11 +25,11 @@
<div class="field"> <div class="field">
<label>{{ctx.Locale.Tr "repo.projects.template.desc"}}</label> <label>{{ctx.Locale.Tr "repo.projects.template.desc"}}</label>
<div class="ui selection dropdown"> <div class="ui selection dropdown">
<input type="hidden" name="board_type" value="{{.type}}"> <input type="hidden" name="template_type" value="{{.type}}">
<div class="default text">{{ctx.Locale.Tr "repo.projects.template.desc_helper"}}</div> <div class="default text">{{ctx.Locale.Tr "repo.projects.template.desc_helper"}}</div>
<div class="menu"> <div class="menu">
{{range $element := .BoardTypes}} {{range $element := .TemplateConfigs}}
<div class="item" data-id="{{$element.BoardType}}" data-value="{{$element.BoardType}}">{{ctx.Locale.Tr $element.Translation}}</div> <div class="item" data-id="{{$element.TemplateType}}" data-value="{{$element.TemplateType}}">{{ctx.Locale.Tr $element.Translation}}</div>
{{end}} {{end}}
</div> </div>
</div> </div>

View File

@ -180,7 +180,7 @@
{{$projectsUnit := .Repository.MustGetUnit $.Context ctx.Consts.RepoUnitTypeProjects}} {{$projectsUnit := .Repository.MustGetUnit $.Context ctx.Consts.RepoUnitTypeProjects}}
{{if and (not .UnitProjectsGlobalDisabled) (.Permission.CanRead ctx.Consts.RepoUnitTypeProjects) ($projectsUnit.ProjectsConfig.IsProjectsAllowed "repo")}} {{if and (not .UnitProjectsGlobalDisabled) (.Permission.CanRead ctx.Consts.RepoUnitTypeProjects) ($projectsUnit.ProjectsConfig.IsProjectsAllowed "repo")}}
<a href="{{.RepoLink}}/projects" class="{{if .IsProjectsPage}}active {{end}}item"> <a href="{{.RepoLink}}/projects" class="{{if .IsProjectsPage}}active {{end}}item">
{{svg "octicon-project"}} {{ctx.Locale.Tr "repo.project_board"}} {{svg "octicon-project"}} {{ctx.Locale.Tr "repo.projects"}}
{{if .Repository.NumOpenProjects}} {{if .Repository.NumOpenProjects}}
<span class="ui small label">{{CountFmt .Repository.NumOpenProjects}}</span> <span class="ui small label">{{CountFmt .Repository.NumOpenProjects}}</span>
{{end}} {{end}}

View File

@ -71,7 +71,7 @@
<!-- Projects --> <!-- Projects -->
<div class="ui{{if not (or .OpenProjects .ClosedProjects)}} disabled{{end}} dropdown jump item"> <div class="ui{{if not (or .OpenProjects .ClosedProjects)}} disabled{{end}} dropdown jump item">
<span class="text"> <span class="text">
{{ctx.Locale.Tr "repo.project_board"}} {{ctx.Locale.Tr "repo.projects"}}
</span> </span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}} {{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu"> <div class="menu">

View File

@ -467,7 +467,7 @@
{{$isProjectsGlobalDisabled := ctx.Consts.RepoUnitTypeProjects.UnitGlobalDisabled}} {{$isProjectsGlobalDisabled := ctx.Consts.RepoUnitTypeProjects.UnitGlobalDisabled}}
{{$projectsUnit := .Repository.MustGetUnit $.Context ctx.Consts.RepoUnitTypeProjects}} {{$projectsUnit := .Repository.MustGetUnit $.Context ctx.Consts.RepoUnitTypeProjects}}
<div class="inline field"> <div class="inline field">
<label>{{ctx.Locale.Tr "repo.project_board"}}</label> <label>{{ctx.Locale.Tr "repo.projects"}}</label>
<div class="ui checkbox{{if $isProjectsGlobalDisabled}} disabled{{end}}"{{if $isProjectsGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}> <div class="ui checkbox{{if $isProjectsGlobalDisabled}} disabled{{end}}"{{if $isProjectsGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_projects" type="checkbox" data-target="#projects_box" {{if $isProjectsEnabled}}checked{{end}}> <input class="enable-system" name="enable_projects" type="checkbox" data-target="#projects_box" {{if $isProjectsEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.projects_desc"}}</label> <label>{{ctx.Locale.Tr "repo.settings.projects_desc"}}</label>

View File

@ -39,23 +39,23 @@ func TestMoveRepoProjectColumns(t *testing.T) {
assert.True(t, projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo)) assert.True(t, projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo))
project1 := project_model.Project{ project1 := project_model.Project{
Title: "new created project", Title: "new created project",
RepoID: repo2.ID, RepoID: repo2.ID,
Type: project_model.TypeRepository, Type: project_model.TypeRepository,
BoardType: project_model.BoardTypeNone, TemplateType: project_model.TemplateTypeNone,
} }
err := project_model.NewProject(db.DefaultContext, &project1) err := project_model.NewProject(db.DefaultContext, &project1)
assert.NoError(t, err) assert.NoError(t, err)
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
err = project_model.NewBoard(db.DefaultContext, &project_model.Board{ err = project_model.NewColumn(db.DefaultContext, &project_model.Column{
Title: fmt.Sprintf("column %d", i+1), Title: fmt.Sprintf("column %d", i+1),
ProjectID: project1.ID, ProjectID: project1.ID,
}) })
assert.NoError(t, err) assert.NoError(t, err)
} }
columns, err := project1.GetBoards(db.DefaultContext) columns, err := project1.GetColumns(db.DefaultContext)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, columns, 3) assert.Len(t, columns, 3)
assert.EqualValues(t, 0, columns[0].Sorting) assert.EqualValues(t, 0, columns[0].Sorting)
@ -76,7 +76,7 @@ func TestMoveRepoProjectColumns(t *testing.T) {
}) })
sess.MakeRequest(t, req, http.StatusOK) sess.MakeRequest(t, req, http.StatusOK)
columnsAfter, err := project1.GetBoards(db.DefaultContext) columnsAfter, err := project1.GetColumns(db.DefaultContext)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, columns, 3) assert.Len(t, columns, 3)
assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID) assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID)

View File

@ -7,7 +7,7 @@
} }
.project-column { .project-column {
background-color: var(--color-project-board-bg) !important; background-color: var(--color-project-column-bg) !important;
border: 1px solid var(--color-secondary) !important; border: 1px solid var(--color-secondary) !important;
margin: 0 0.5rem !important; margin: 0 0.5rem !important;
padding: 0.5rem !important; padding: 0.5rem !important;

View File

@ -216,7 +216,7 @@
--color-expand-button: #2f363d; --color-expand-button: #2f363d;
--color-placeholder-text: var(--color-text-light-3); --color-placeholder-text: var(--color-text-light-3);
--color-editor-line-highlight: var(--color-primary-light-5); --color-editor-line-highlight: var(--color-primary-light-5);
--color-project-board-bg: var(--color-secondary-light-2); --color-project-column-bg: var(--color-secondary-light-2);
--color-caret: var(--color-text); /* should ideally be --color-text-dark, see #15651 */ --color-caret: var(--color-text); /* should ideally be --color-text-dark, see #15651 */
--color-reaction-bg: #e8f3ff12; --color-reaction-bg: #e8f3ff12;
--color-reaction-hover-bg: var(--color-primary-light-4); --color-reaction-hover-bg: var(--color-primary-light-4);

View File

@ -216,7 +216,7 @@
--color-expand-button: #cfe8fa; --color-expand-button: #cfe8fa;
--color-placeholder-text: var(--color-text-light-3); --color-placeholder-text: var(--color-text-light-3);
--color-editor-line-highlight: var(--color-primary-light-6); --color-editor-line-highlight: var(--color-primary-light-6);
--color-project-board-bg: var(--color-secondary-light-4); --color-project-column-bg: var(--color-secondary-light-4);
--color-caret: var(--color-text-dark); --color-caret: var(--color-text-dark);
--color-reaction-bg: #0000170a; --color-reaction-bg: #0000170a;
--color-reaction-hover-bg: var(--color-primary-light-5); --color-reaction-hover-bg: var(--color-primary-light-5);