diff options
author | Lunny Xiao <xiaolunwen@gmail.com> | 2023-01-20 19:42:33 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-01-20 19:42:33 +0800 |
commit | 6fe3c8b3980f850c9789f9fa62bdfee7b2708ff0 (patch) | |
tree | 5e11864dd63c60564080506291ca93e465c19940 | |
parent | 0c048e554ba42d99bd66c07447de5f35cf6c981b (diff) | |
download | gitea-6fe3c8b3980f850c9789f9fa62bdfee7b2708ff0.tar.gz gitea-6fe3c8b3980f850c9789f9fa62bdfee7b2708ff0.zip |
Support org/user level projects (#22235)
Fix #13405
<img width="1151" alt="image"
src="https://user-images.githubusercontent.com/81045/209442911-7baa3924-c389-47b6-b63b-a740803e640e.png">
Co-authored-by: 6543 <6543@obermui.de>
30 files changed, 1556 insertions, 176 deletions
diff --git a/models/fixtures/project.yml b/models/fixtures/project.yml index 3d42597c5e..f38b5344bb 100644 --- a/models/fixtures/project.yml +++ b/models/fixtures/project.yml @@ -24,3 +24,12 @@ creator_id: 5 board_type: 1 type: 2 + +- + id: 4 + title: project on user2 + owner_id: 2 + is_closed: false + creator_id: 2 + board_type: 1 + type: 2 diff --git a/models/fixtures/project_board.yml b/models/fixtures/project_board.yml index 9e06e8c239..dc4f9cf565 100644 --- a/models/fixtures/project_board.yml +++ b/models/fixtures/project_board.yml @@ -21,3 +21,11 @@ creator_id: 2 created_unix: 1588117528 updated_unix: 1588117528 + +- + id: 4 + project_id: 4 + title: Done + creator_id: 2 + created_unix: 1588117528 + updated_unix: 1588117528 diff --git a/models/issues/issue.go b/models/issues/issue.go index 4a8ab06824..dc9e5c5acd 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -1098,7 +1098,7 @@ func GetIssueWithAttrsByID(id int64) (*Issue, error) { } // GetIssuesByIDs return issues with the given IDs. -func GetIssuesByIDs(ctx context.Context, issueIDs []int64) ([]*Issue, error) { +func GetIssuesByIDs(ctx context.Context, issueIDs []int64) (IssueList, error) { issues := make([]*Issue, 0, 10) return issues, db.GetEngine(ctx).In("id", issueIDs).Find(&issues) } diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 8e559f13c9..c9f4c9f533 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -125,13 +125,17 @@ func ChangeProjectAssign(issue *Issue, doer *user_model.User, newProjectID int64 func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error { oldProjectID := issue.projectID(ctx) + if err := issue.LoadRepo(ctx); err != nil { + return err + } + // Only check if we add a new project and not remove it. if newProjectID > 0 { newProject, err := project_model.GetProjectByID(ctx, newProjectID) if err != nil { return err } - if newProject.RepoID != issue.RepoID { + if newProject.RepoID != issue.RepoID && newProject.OwnerID != issue.Repo.OwnerID { return fmt.Errorf("issue's repository is not the same as project's repository") } } @@ -140,10 +144,6 @@ func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.U return err } - if err := issue.LoadRepo(ctx); err != nil { - return err - } - if oldProjectID > 0 || newProjectID > 0 { if _, err := CreateComment(ctx, &CreateCommentOptions{ Type: CommentTypeProject, diff --git a/models/organization/team.go b/models/organization/team.go index 55d3f17276..0c2577dab1 100644 --- a/models/organization/team.go +++ b/models/organization/team.go @@ -16,8 +16,6 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" - - "xorm.io/builder" ) // ___________ @@ -96,59 +94,6 @@ func init() { db.RegisterModel(new(TeamInvite)) } -// SearchTeamOptions holds the search options -type SearchTeamOptions struct { - db.ListOptions - UserID int64 - Keyword string - OrgID int64 - IncludeDesc bool -} - -func (opts *SearchTeamOptions) toCond() builder.Cond { - cond := builder.NewCond() - - if len(opts.Keyword) > 0 { - lowerKeyword := strings.ToLower(opts.Keyword) - var keywordCond builder.Cond = builder.Like{"lower_name", lowerKeyword} - if opts.IncludeDesc { - keywordCond = keywordCond.Or(builder.Like{"LOWER(description)", lowerKeyword}) - } - cond = cond.And(keywordCond) - } - - if opts.OrgID > 0 { - cond = cond.And(builder.Eq{"`team`.org_id": opts.OrgID}) - } - - if opts.UserID > 0 { - cond = cond.And(builder.Eq{"team_user.uid": opts.UserID}) - } - - return cond -} - -// SearchTeam search for teams. Caller is responsible to check permissions. -func SearchTeam(opts *SearchTeamOptions) ([]*Team, int64, error) { - sess := db.GetEngine(db.DefaultContext) - - opts.SetDefaultValues() - cond := opts.toCond() - - if opts.UserID > 0 { - sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id") - } - sess = db.SetSessionPagination(sess, opts) - - teams := make([]*Team, 0, opts.PageSize) - count, err := sess.Where(cond).OrderBy("lower_name").FindAndCount(&teams) - if err != nil { - return nil, 0, err - } - - return teams, count, nil -} - // ColorFormat provides a basic color format for a Team func (t *Team) ColorFormat(s fmt.State) { if t == nil { @@ -335,16 +280,6 @@ func GetTeamNamesByID(teamIDs []int64) ([]string, error) { return teamNames, err } -// GetRepoTeams gets the list of teams that has access to the repository -func GetRepoTeams(ctx context.Context, repo *repo_model.Repository) (teams []*Team, err error) { - return teams, db.GetEngine(ctx). - Join("INNER", "team_repo", "team_repo.team_id = team.id"). - Where("team.org_id = ?", repo.OwnerID). - And("team_repo.repo_id=?", repo.ID). - OrderBy("CASE WHEN name LIKE '" + OwnerTeamName + "' THEN '' ELSE name END"). - Find(&teams) -} - // IncrTeamRepoNum increases the number of repos for the given team by 1 func IncrTeamRepoNum(ctx context.Context, teamID int64) error { _, err := db.GetEngine(ctx).Incr("num_repos").ID(teamID).Update(new(Team)) diff --git a/models/organization/team_list.go b/models/organization/team_list.go new file mode 100644 index 0000000000..5d3bd555cc --- /dev/null +++ b/models/organization/team_list.go @@ -0,0 +1,128 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization + +import ( + "context" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + + "xorm.io/builder" +) + +type TeamList []*Team + +func (t TeamList) LoadUnits(ctx context.Context) error { + for _, team := range t { + if err := team.getUnits(ctx); err != nil { + return err + } + } + return nil +} + +func (t TeamList) UnitMaxAccess(tp unit.Type) perm.AccessMode { + maxAccess := perm.AccessModeNone + for _, team := range t { + if team.IsOwnerTeam() { + return perm.AccessModeOwner + } + for _, teamUnit := range team.Units { + if teamUnit.Type != tp { + continue + } + if teamUnit.AccessMode > maxAccess { + maxAccess = teamUnit.AccessMode + } + } + } + return maxAccess +} + +// SearchTeamOptions holds the search options +type SearchTeamOptions struct { + db.ListOptions + UserID int64 + Keyword string + OrgID int64 + IncludeDesc bool +} + +func (opts *SearchTeamOptions) toCond() builder.Cond { + cond := builder.NewCond() + + if len(opts.Keyword) > 0 { + lowerKeyword := strings.ToLower(opts.Keyword) + var keywordCond builder.Cond = builder.Like{"lower_name", lowerKeyword} + if opts.IncludeDesc { + keywordCond = keywordCond.Or(builder.Like{"LOWER(description)", lowerKeyword}) + } + cond = cond.And(keywordCond) + } + + if opts.OrgID > 0 { + cond = cond.And(builder.Eq{"`team`.org_id": opts.OrgID}) + } + + if opts.UserID > 0 { + cond = cond.And(builder.Eq{"team_user.uid": opts.UserID}) + } + + return cond +} + +// SearchTeam search for teams. Caller is responsible to check permissions. +func SearchTeam(opts *SearchTeamOptions) (TeamList, int64, error) { + sess := db.GetEngine(db.DefaultContext) + + opts.SetDefaultValues() + cond := opts.toCond() + + if opts.UserID > 0 { + sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id") + } + sess = db.SetSessionPagination(sess, opts) + + teams := make([]*Team, 0, opts.PageSize) + count, err := sess.Where(cond).OrderBy("lower_name").FindAndCount(&teams) + if err != nil { + return nil, 0, err + } + + return teams, count, nil +} + +// GetRepoTeams gets the list of teams that has access to the repository +func GetRepoTeams(ctx context.Context, repo *repo_model.Repository) (teams TeamList, err error) { + return teams, db.GetEngine(ctx). + Join("INNER", "team_repo", "team_repo.team_id = team.id"). + Where("team.org_id = ?", repo.OwnerID). + And("team_repo.repo_id=?", repo.ID). + OrderBy("CASE WHEN name LIKE '" + OwnerTeamName + "' THEN '' ELSE name END"). + Find(&teams) +} + +// GetUserOrgTeams returns all teams that user belongs to in given organization. +func GetUserOrgTeams(ctx context.Context, orgID, userID int64) (teams TeamList, err error) { + return teams, db.GetEngine(ctx). + Join("INNER", "team_user", "team_user.team_id = team.id"). + Where("team.org_id = ?", orgID). + And("team_user.uid=?", userID). + Find(&teams) +} + +// GetUserRepoTeams returns user repo's teams +func GetUserRepoTeams(ctx context.Context, orgID, userID, repoID int64) (teams TeamList, err error) { + return teams, db.GetEngine(ctx). + Join("INNER", "team_user", "team_user.team_id = team.id"). + Join("INNER", "team_repo", "team_repo.team_id = team.id"). + Where("team.org_id = ?", orgID). + And("team_user.uid=?", userID). + And("team_repo.repo_id=?", repoID). + Find(&teams) +} diff --git a/models/organization/team_user.go b/models/organization/team_user.go index 7a024f1c6d..816daf3d34 100644 --- a/models/organization/team_user.go +++ b/models/organization/team_user.go @@ -72,26 +72,6 @@ func GetTeamMembers(ctx context.Context, opts *SearchMembersOptions) ([]*user_mo return members, nil } -// GetUserOrgTeams returns all teams that user belongs to in given organization. -func GetUserOrgTeams(ctx context.Context, orgID, userID int64) (teams []*Team, err error) { - return teams, db.GetEngine(ctx). - Join("INNER", "team_user", "team_user.team_id = team.id"). - Where("team.org_id = ?", orgID). - And("team_user.uid=?", userID). - Find(&teams) -} - -// GetUserRepoTeams returns user repo's teams -func GetUserRepoTeams(ctx context.Context, orgID, userID, repoID int64) (teams []*Team, err error) { - return teams, db.GetEngine(ctx). - Join("INNER", "team_user", "team_user.team_id = team.id"). - Join("INNER", "team_repo", "team_repo.team_id = team.id"). - Where("team.org_id = ?", orgID). - And("team_user.uid=?", userID). - And("team_repo.repo_id=?", repoID). - Find(&teams) -} - // IsUserInTeams returns if a user in some teams func IsUserInTeams(ctx context.Context, userID int64, teamIDs []int64) (bool, error) { return db.GetEngine(ctx).Where("uid=?", userID).In("team_id", teamIDs).Exist(new(TeamUser)) diff --git a/models/project/project.go b/models/project/project.go index f432d0bc4c..8bac9115ba 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -8,6 +8,9 @@ import ( "fmt" "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -78,12 +81,15 @@ func (err ErrProjectBoardNotExist) Unwrap() error { // Project represents a project board type Project struct { - ID int64 `xorm:"pk autoincr"` - Title string `xorm:"INDEX NOT NULL"` - Description string `xorm:"TEXT"` - RepoID int64 `xorm:"INDEX"` - CreatorID int64 `xorm:"NOT NULL"` - IsClosed bool `xorm:"INDEX"` + ID int64 `xorm:"pk autoincr"` + Title string `xorm:"INDEX NOT NULL"` + Description string `xorm:"TEXT"` + OwnerID int64 `xorm:"INDEX"` + Owner *user_model.User `xorm:"-"` + RepoID int64 `xorm:"INDEX"` + Repo *repo_model.Repository `xorm:"-"` + CreatorID int64 `xorm:"NOT NULL"` + IsClosed bool `xorm:"INDEX"` BoardType BoardType Type Type @@ -94,6 +100,46 @@ type Project struct { ClosedDateUnix timeutil.TimeStamp } +func (p *Project) LoadOwner(ctx context.Context) (err error) { + if p.Owner != nil { + return nil + } + p.Owner, err = user_model.GetUserByID(ctx, p.OwnerID) + return err +} + +func (p *Project) LoadRepo(ctx context.Context) (err error) { + if p.RepoID == 0 || p.Repo != nil { + return nil + } + p.Repo, err = repo_model.GetRepositoryByID(ctx, p.RepoID) + return err +} + +func (p *Project) Link() string { + if p.OwnerID > 0 { + err := p.LoadOwner(db.DefaultContext) + if err != nil { + log.Error("LoadOwner: %v", err) + return "" + } + return fmt.Sprintf("/%s/-/projects/%d", p.Owner.Name, p.ID) + } + if p.RepoID > 0 { + err := p.LoadRepo(db.DefaultContext) + if err != nil { + log.Error("LoadRepo: %v", err) + return "" + } + return fmt.Sprintf("/%s/projects/%d", p.Repo.RepoPath(), p.ID) + } + return "" +} + +func (p *Project) IsOrganizationProject() bool { + return p.Type == TypeOrganization +} + func init() { db.RegisterModel(new(Project)) } @@ -110,7 +156,7 @@ func GetProjectsConfig() []ProjectsConfig { // IsTypeValid checks if a project type is valid func IsTypeValid(p Type) bool { switch p { - case TypeRepository: + case TypeRepository, TypeOrganization: return true default: return false @@ -119,6 +165,7 @@ func IsTypeValid(p Type) bool { // SearchOptions are options for GetProjects type SearchOptions struct { + OwnerID int64 RepoID int64 Page int IsClosed util.OptionalBool @@ -126,12 +173,11 @@ type SearchOptions struct { Type Type } -// GetProjects returns a list of all projects that have been created in the repository -func GetProjects(ctx context.Context, opts SearchOptions) ([]*Project, int64, error) { - e := db.GetEngine(ctx) - projects := make([]*Project, 0, setting.UI.IssuePagingNum) - - var cond builder.Cond = builder.Eq{"repo_id": opts.RepoID} +func (opts *SearchOptions) toConds() builder.Cond { + cond := builder.NewCond() + if opts.RepoID > 0 { + cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) + } switch opts.IsClosed { case util.OptionalBoolTrue: cond = cond.And(builder.Eq{"is_closed": true}) @@ -142,6 +188,22 @@ func GetProjects(ctx context.Context, opts SearchOptions) ([]*Project, int64, er if opts.Type > 0 { cond = cond.And(builder.Eq{"type": opts.Type}) } + if opts.OwnerID > 0 { + cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) + } + return cond +} + +// CountProjects counts projects +func CountProjects(ctx context.Context, opts SearchOptions) (int64, error) { + return db.GetEngine(ctx).Where(opts.toConds()).Count(new(Project)) +} + +// FindProjects returns a list of all projects that have been created in the repository +func FindProjects(ctx context.Context, opts SearchOptions) ([]*Project, int64, error) { + e := db.GetEngine(ctx) + projects := make([]*Project, 0, setting.UI.IssuePagingNum) + cond := opts.toConds() count, err := e.Where(cond).Count(new(Project)) if err != nil { @@ -188,8 +250,10 @@ func NewProject(p *Project) error { return err } - if _, err := db.Exec(ctx, "UPDATE `repository` SET num_projects = num_projects + 1 WHERE id = ?", p.RepoID); 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 + } } if err := createBoardsForProjectsType(ctx, p); err != nil { diff --git a/models/project/project_test.go b/models/project/project_test.go index 4fde0fc7ce..c2d9005c43 100644 --- a/models/project/project_test.go +++ b/models/project/project_test.go @@ -22,7 +22,7 @@ func TestIsProjectTypeValid(t *testing.T) { }{ {TypeIndividual, false}, {TypeRepository, true}, - {TypeOrganization, false}, + {TypeOrganization, true}, {UnknownType, false}, } @@ -34,13 +34,13 @@ func TestIsProjectTypeValid(t *testing.T) { func TestGetProjects(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - projects, _, err := GetProjects(db.DefaultContext, SearchOptions{RepoID: 1}) + projects, _, err := FindProjects(db.DefaultContext, SearchOptions{RepoID: 1}) assert.NoError(t, err) // 1 value for this repo exists in the fixtures assert.Len(t, projects, 1) - projects, _, err = GetProjects(db.DefaultContext, SearchOptions{RepoID: 3}) + projects, _, err = FindProjects(db.DefaultContext, SearchOptions{RepoID: 3}) assert.NoError(t, err) // 1 value for this repo exists in the fixtures diff --git a/modules/context/org.go b/modules/context/org.go index 39df29a860..ff3a5ae7ec 100644 --- a/modules/context/org.go +++ b/modules/context/org.go @@ -9,7 +9,9 @@ import ( "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" ) @@ -28,6 +30,32 @@ type Organization struct { Teams []*organization.Team } +func (org *Organization) CanWriteUnit(ctx *Context, unitType unit.Type) bool { + if ctx.Doer == nil { + return false + } + return org.UnitPermission(ctx, ctx.Doer.ID, unitType) >= perm.AccessModeWrite +} + +func (org *Organization) UnitPermission(ctx *Context, doerID int64, unitType unit.Type) perm.AccessMode { + if doerID > 0 { + teams, err := organization.GetUserOrgTeams(ctx, org.Organization.ID, doerID) + if err != nil { + log.Error("GetUserOrgTeams: %v", err) + return perm.AccessModeNone + } + if len(teams) > 0 { + return teams.UnitMaxAccess(unitType) + } + } + + if org.Organization.Visibility == structs.VisibleTypePublic { + return perm.AccessModeRead + } + + return perm.AccessModeNone +} + // HandleOrgAssignment handles organization assignment func HandleOrgAssignment(ctx *Context, args ...bool) { var ( diff --git a/routers/web/org/main_test.go b/routers/web/org/main_test.go new file mode 100644 index 0000000000..41323a3601 --- /dev/null +++ b/routers/web/org/main_test.go @@ -0,0 +1,17 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org_test + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{ + GiteaRootPath: filepath.Join("..", "..", ".."), + }) +} diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go new file mode 100644 index 0000000000..1ce44d4866 --- /dev/null +++ b/routers/web/org/projects.go @@ -0,0 +1,670 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + issues_model "code.gitea.io/gitea/models/issues" + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplProjects base.TplName = "org/projects/list" + tplProjectsNew base.TplName = "org/projects/new" + tplProjectsView base.TplName = "org/projects/view" + tplGenericProjectsNew base.TplName = "user/project" +) + +// MustEnableProjects check if projects are enabled in settings +func MustEnableProjects(ctx *context.Context) { + if unit.TypeProjects.UnitGlobalDisabled() { + ctx.NotFound("EnableKanbanBoard", nil) + return + } +} + +// Projects renders the home page of projects +func Projects(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.project_board") + + sortType := ctx.FormTrim("sort") + + isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + projects, total, err := project_model.FindProjects(ctx, project_model.SearchOptions{ + OwnerID: ctx.ContextUser.ID, + Page: page, + IsClosed: util.OptionalBoolOf(isShowClosed), + SortType: sortType, + Type: project_model.TypeOrganization, + }) + if err != nil { + ctx.ServerError("FindProjects", err) + return + } + + opTotal, err := project_model.CountProjects(ctx, project_model.SearchOptions{ + OwnerID: ctx.ContextUser.ID, + IsClosed: util.OptionalBoolOf(!isShowClosed), + Type: project_model.TypeOrganization, + }) + if err != nil { + ctx.ServerError("CountProjects", err) + return + } + + if isShowClosed { + ctx.Data["OpenCount"] = opTotal + ctx.Data["ClosedCount"] = total + } else { + ctx.Data["OpenCount"] = total + ctx.Data["ClosedCount"] = opTotal + } + + ctx.Data["Projects"] = projects + shared_user.RenderUserHeader(ctx) + + if isShowClosed { + ctx.Data["State"] = "closed" + } else { + ctx.Data["State"] = "open" + } + + for _, project := range projects { + project.RenderedContent = project.Description + } + + numPages := 0 + if total > 0 { + numPages = (int(total) - 1/setting.UI.IssuePagingNum) + } + + pager := context.NewPagination(int(total), setting.UI.IssuePagingNum, page, numPages) + pager.AddParam(ctx, "state", "State") + ctx.Data["Page"] = pager + + ctx.Data["CanWriteProjects"] = canWriteUnit(ctx) + ctx.Data["IsShowClosed"] = isShowClosed + ctx.Data["PageIsViewProjects"] = true + ctx.Data["SortType"] = sortType + + ctx.HTML(http.StatusOK, tplProjects) +} + +func canWriteUnit(ctx *context.Context) bool { + if ctx.ContextUser.IsOrganization() { + return ctx.Org.CanWriteUnit(ctx, unit.TypeProjects) + } + return ctx.Doer != nil && ctx.ContextUser.ID == ctx.Doer.ID +} + +// NewProject render creating a project page +func NewProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig() + ctx.Data["CanWriteProjects"] = canWriteUnit(ctx) + ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink() + shared_user.RenderUserHeader(ctx) + ctx.HTML(http.StatusOK, tplProjectsNew) +} + +// NewProjectPost creates a new project +func NewProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateProjectForm) + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + shared_user.RenderUserHeader(ctx) + + if ctx.HasError() { + ctx.Data["CanWriteProjects"] = canWriteUnit(ctx) + ctx.Data["PageIsViewProjects"] = true + ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig() + ctx.HTML(http.StatusOK, tplProjectsNew) + return + } + + if err := project_model.NewProject(&project_model.Project{ + OwnerID: ctx.ContextUser.ID, + Title: form.Title, + Description: form.Content, + CreatorID: ctx.Doer.ID, + BoardType: form.BoardType, + Type: project_model.TypeOrganization, + }); err != nil { + ctx.ServerError("NewProject", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) + ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects") +} + +// ChangeProjectStatus updates the status of a project between "open" and "close" +func ChangeProjectStatus(ctx *context.Context) { + toClose := false + switch ctx.Params(":action") { + case "open": + toClose = false + case "close": + toClose = true + default: + ctx.Redirect(ctx.Repo.RepoLink + "/projects") + } + id := ctx.ParamsInt64(":id") + + if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx.Repo.Repository.ID, id, toClose); err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", err) + } else { + ctx.ServerError("ChangeProjectStatusByIDAndRepoID", err) + } + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/projects?state=" + url.QueryEscape(ctx.Params(":action"))) +} + +// DeleteProject delete a project +func DeleteProject(ctx *context.Context) { + p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil { + ctx.Flash.Error("DeleteProjectByID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/projects", + }) +} + +// EditProject allows a project to be edited +func EditProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsEditProjects"] = true + ctx.Data["PageIsViewProjects"] = true + ctx.Data["CanWriteProjects"] = canWriteUnit(ctx) + shared_user.RenderUserHeader(ctx) + + p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + ctx.Data["title"] = p.Title + ctx.Data["content"] = p.Description + + ctx.HTML(http.StatusOK, tplProjectsNew) +} + +// EditProjectPost response for editing a project +func EditProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateProjectForm) + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsEditProjects"] = true + ctx.Data["PageIsViewProjects"] = true + ctx.Data["CanWriteProjects"] = canWriteUnit(ctx) + shared_user.RenderUserHeader(ctx) + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplProjectsNew) + return + } + + p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + p.Title = form.Title + p.Description = form.Content + if err = project_model.UpdateProject(ctx, p); err != nil { + ctx.ServerError("UpdateProjects", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title)) + ctx.Redirect(ctx.Repo.RepoLink + "/projects") +} + +// ViewProject renders the project board for a project +func ViewProject(ctx *context.Context) { + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if project.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + boards, err := project_model.GetBoards(ctx, project.ID) + if err != nil { + ctx.ServerError("GetProjectBoards", err) + return + } + + if boards[0].ID == 0 { + boards[0].Title = ctx.Tr("repo.projects.type.uncategorized") + } + + issuesMap, err := issues_model.LoadIssuesFromBoardList(ctx, boards) + if err != nil { + ctx.ServerError("LoadIssuesOfBoards", err) + return + } + + linkedPrsMap := make(map[int64][]*issues_model.Issue) + for _, issuesList := range issuesMap { + for _, issue := range issuesList { + var referencedIds []int64 + for _, comment := range issue.Comments { + if comment.RefIssueID != 0 && comment.RefIsPull { + referencedIds = append(referencedIds, comment.RefIssueID) + } + } + + if len(referencedIds) > 0 { + if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ + IssueIDs: referencedIds, + IsPull: util.OptionalBoolTrue, + }); err == nil { + linkedPrsMap[issue.ID] = linkedPrs + } + } + } + } + + project.RenderedContent = project.Description + ctx.Data["LinkedPRs"] = linkedPrsMap + ctx.Data["PageIsViewProjects"] = true + ctx.Data["CanWriteProjects"] = canWriteUnit(ctx) + ctx.Data["Project"] = project + ctx.Data["IssuesMap"] = issuesMap + ctx.Data["Boards"] = boards + shared_user.RenderUserHeader(ctx) + + ctx.HTML(http.StatusOK, tplProjectsView) +} + +func getActionIssues(ctx *context.Context) []*issues_model.Issue { + commaSeparatedIssueIDs := ctx.FormString("issue_ids") + if len(commaSeparatedIssueIDs) == 0 { + return nil + } + issueIDs := make([]int64, 0, 10) + for _, stringIssueID := range strings.Split(commaSeparatedIssueIDs, ",") { + issueID, err := strconv.ParseInt(stringIssueID, 10, 64) + if err != nil { + ctx.ServerError("ParseInt", err) + return nil + } + issueIDs = append(issueIDs, issueID) + } + issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) + if err != nil { + ctx.ServerError("GetIssuesByIDs", err) + return nil + } + // Check access rights for all issues + issueUnitEnabled := ctx.Repo.CanRead(unit.TypeIssues) + prUnitEnabled := ctx.Repo.CanRead(unit.TypePullRequests) + for _, issue := range issues { + if issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("some issue's RepoID is incorrect", errors.New("some issue's RepoID is incorrect")) + return nil + } + if issue.IsPull && !prUnitEnabled || !issue.IsPull && !issueUnitEnabled { + ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) + return nil + } + if err = issue.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return nil + } + } + return issues +} + +// UpdateIssueProject change an issue's project +func UpdateIssueProject(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + projectID := ctx.FormInt64("id") + for _, issue := range issues { + oldProjectID := issue.ProjectID() + if oldProjectID == projectID { + continue + } + + if err := issues_model.ChangeProjectAssign(issue, ctx.Doer, projectID); err != nil { + ctx.ServerError("ChangeProjectAssign", err) + return + } + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// DeleteProjectBoard allows for the deletion of a project board +func DeleteProjectBoard(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + pb, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) + if err != nil { + ctx.ServerError("GetProjectBoard", err) + return + } + if pb.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", pb.ID, project.ID), + }) + return + } + + if project.OwnerID != ctx.ContextUser.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID), + }) + return + } + + if err := project_model.DeleteBoardByID(ctx.ParamsInt64(":boardID")); err != nil { + ctx.ServerError("DeleteProjectBoardByID", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// AddBoardToProjectPost allows a new board to be added to a project. +func AddBoardToProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditProjectBoardForm) + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + if err := project_model.NewBoard(&project_model.Board{ + ProjectID: project.ID, + Title: form.Title, + Color: form.Color, + CreatorID: ctx.Doer.ID, + }); err != nil { + ctx.ServerError("NewProjectBoard", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// CheckProjectBoardChangePermissions check permission +func CheckProjectBoardChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Board) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return nil, nil + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return nil, nil + } + + board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) + if err != nil { + ctx.ServerError("GetProjectBoard", err) + return nil, nil + } + if board.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", board.ID, project.ID), + }) + return nil, nil + } + + if project.OwnerID != ctx.ContextUser.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", board.ID, project.ID), + }) + return nil, nil + } + return project, board +} + +// EditProjectBoard allows a project board's to be updated +func EditProjectBoard(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditProjectBoardForm) + _, board := CheckProjectBoardChangePermissions(ctx) + if ctx.Written() { + return + } + + if form.Title != "" { + board.Title = form.Title + } + + board.Color = form.Color + + if form.Sorting != 0 { + board.Sorting = form.Sorting + } + + if err := project_model.UpdateBoard(ctx, board); err != nil { + ctx.ServerError("UpdateProjectBoard", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// SetDefaultProjectBoard set default board for uncategorized issues/pulls +func SetDefaultProjectBoard(ctx *context.Context) { + project, board := CheckProjectBoardChangePermissions(ctx) + if ctx.Written() { + return + } + + if err := project_model.SetDefaultBoard(project.ID, board.ID); err != nil { + ctx.ServerError("SetDefaultBoard", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// MoveIssues moves or keeps issues in a column and sorts them inside that column +func MoveIssues(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("ProjectNotExist", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if project.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("InvalidRepoID", nil) + return + } + + var board *project_model.Board + + if ctx.ParamsInt64(":boardID") == 0 { + board = &project_model.Board{ + ID: 0, + ProjectID: project.ID, + Title: ctx.Tr("repo.projects.type.uncategorized"), + } + } else { + board, err = project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) + if err != nil { + if project_model.IsErrProjectBoardNotExist(err) { + ctx.NotFound("ProjectBoardNotExist", nil) + } else { + ctx.ServerError("GetProjectBoard", err) + } + return + } + if board.ProjectID != project.ID { + ctx.NotFound("BoardNotInProject", nil) + return + } + } + + type movedIssuesForm struct { + Issues []struct { + IssueID int64 `json:"issueID"` + Sorting int64 `json:"sorting"` + } `json:"issues"` + } + + form := &movedIssuesForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedIssuesForm", err) + } + + issueIDs := make([]int64, 0, len(form.Issues)) + sortedIssueIDs := make(map[int64]int64) + for _, issue := range form.Issues { + issueIDs = append(issueIDs, issue.IssueID) + sortedIssueIDs[issue.Sorting] = issue.IssueID + } + movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound("IssueNotExisting", nil) + } else { + ctx.ServerError("GetIssueByID", err) + } + return + } + + if len(movedIssues) != len(form.Issues) { + ctx.ServerError("some issues do not exist", errors.New("some issues do not exist")) + return + } + + if _, err = movedIssues.LoadRepositories(ctx); err != nil { + ctx.ServerError("LoadRepositories", err) + return + } + + for _, issue := range movedIssues { + if issue.RepoID != project.RepoID && issue.Repo.OwnerID != project.OwnerID { + ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID")) + return + } + } + + if err = project_model.MoveIssuesOnProjectBoard(board, sortedIssueIDs); err != nil { + ctx.ServerError("MoveIssuesOnProjectBoard", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} diff --git a/routers/web/org/projects_test.go b/routers/web/org/projects_test.go new file mode 100644 index 0000000000..3450fa8e72 --- /dev/null +++ b/routers/web/org/projects_test.go @@ -0,0 +1,28 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org_test + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers/web/org" + + "github.com/stretchr/testify/assert" +) + +func TestCheckProjectBoardChangePermissions(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/-/projects/4/4") + test.LoadUser(t, ctx, 2) + ctx.ContextUser = ctx.Doer // user2 + ctx.SetParams(":id", "4") + ctx.SetParams(":boardID", "4") + + project, board := org.CheckProjectBoardChangePermissions(ctx) + assert.NotNil(t, project) + assert.NotNil(t, board) + assert.False(t, ctx.Written()) +} diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 59ab717a1d..44ac81f65d 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -363,7 +363,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti } if ctx.Repo.CanWriteIssuesOrPulls(ctx.Params(":type") == "pulls") { - projects, _, err := project_model.GetProjects(ctx, project_model.SearchOptions{ + projects, _, err := project_model.FindProjects(ctx, project_model.SearchOptions{ RepoID: repo.ID, Type: project_model.TypeRepository, IsClosed: util.OptionalBoolOf(isShowClosed), @@ -474,8 +474,7 @@ func RetrieveRepoMilestonesAndAssignees(ctx *context.Context, repo *repo_model.R func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { var err error - - ctx.Data["OpenProjects"], _, err = project_model.GetProjects(ctx, project_model.SearchOptions{ + projects, _, err := project_model.FindProjects(ctx, project_model.SearchOptions{ RepoID: repo.ID, Page: -1, IsClosed: util.OptionalBoolFalse, @@ -485,8 +484,20 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { ctx.ServerError("GetProjects", err) return } + projects2, _, err := project_model.FindProjects(ctx, project_model.SearchOptions{ + OwnerID: repo.OwnerID, + Page: -1, + IsClosed: util.OptionalBoolFalse, + Type: project_model.TypeOrganization, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + + ctx.Data["OpenProjects"] = append(projects, projects2...) - ctx.Data["ClosedProjects"], _, err = project_model.GetProjects(ctx, project_model.SearchOptions{ + projects, _, err = project_model.FindProjects(ctx, project_model.SearchOptions{ RepoID: repo.ID, Page: -1, IsClosed: util.OptionalBoolTrue, @@ -496,6 +507,18 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { ctx.ServerError("GetProjects", err) return } + projects2, _, err = project_model.FindProjects(ctx, project_model.SearchOptions{ + OwnerID: repo.OwnerID, + Page: -1, + IsClosed: util.OptionalBoolTrue, + Type: project_model.TypeOrganization, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + + ctx.Data["ClosedProjects"] = append(projects, projects2...) } // repoReviewerSelection items to bee shown @@ -988,7 +1011,7 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull ctx.ServerError("GetProjectByID", err) return nil, nil, 0, 0 } - if p.RepoID != ctx.Repo.Repository.ID { + if p.RepoID != ctx.Repo.Repository.ID && p.OwnerID != ctx.Repo.Repository.OwnerID { ctx.NotFound("", nil) return nil, nil, 0, 0 } diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 75cd290b8f..3becf799c5 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -70,7 +70,7 @@ func Projects(ctx *context.Context) { total = repo.NumClosedProjects } - projects, count, err := project_model.GetProjects(ctx, project_model.SearchOptions{ + projects, count, err := project_model.FindProjects(ctx, project_model.SearchOptions{ RepoID: repo.ID, Page: page, IsClosed: util.OptionalBoolOf(isShowClosed), @@ -112,7 +112,7 @@ func Projects(ctx *context.Context) { pager.AddParam(ctx, "state", "State") ctx.Data["Page"] = pager - ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["CanWriteProjects"] = true ctx.Data["IsShowClosed"] = isShowClosed ctx.Data["IsProjectsPage"] = true ctx.Data["SortType"] = sortType @@ -653,47 +653,3 @@ func MoveIssues(ctx *context.Context) { "ok": true, }) } - -// CreateProject renders the generic project creation page -func CreateProject(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("repo.projects.new") - ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig() - ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) - - ctx.HTML(http.StatusOK, tplGenericProjectsNew) -} - -// CreateProjectPost creates an individual and/or organization project -func CreateProjectPost(ctx *context.Context, form forms.UserCreateProjectForm) { - user := checkContextUser(ctx, form.UID) - if ctx.Written() { - return - } - - ctx.Data["ContextUser"] = user - - if ctx.HasError() { - ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) - ctx.HTML(http.StatusOK, tplGenericProjectsNew) - return - } - - projectType := project_model.TypeIndividual - if user.IsOrganization() { - projectType = project_model.TypeOrganization - } - - if err := project_model.NewProject(&project_model.Project{ - Title: form.Title, - Description: form.Content, - CreatorID: user.ID, - BoardType: form.BoardType, - Type: projectType, - }); err != nil { - ctx.ServerError("NewProject", err) - return - } - - ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) - ctx.Redirect(setting.AppSubURL + "/") -} diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go new file mode 100644 index 0000000000..94e59e2a49 --- /dev/null +++ b/routers/web/shared/user/header.go @@ -0,0 +1,14 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" +) + +func RenderUserHeader(ctx *context.Context) { + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + ctx.Data["ContextUser"] = ctx.ContextUser +} diff --git a/routers/web/user/package.go b/routers/web/user/package.go index c0aba7583f..ed4f0dd797 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -19,6 +19,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/forms" packages_service "code.gitea.io/gitea/services/packages" ) @@ -83,10 +84,10 @@ func ListPackages(ctx *context.Context) { return } + shared_user.RenderUserHeader(ctx) + ctx.Data["Title"] = ctx.Tr("packages.title") ctx.Data["IsPackagesPage"] = true - ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled - ctx.Data["ContextUser"] = ctx.ContextUser ctx.Data["Query"] = query ctx.Data["PackageType"] = packageType ctx.Data["AvailableTypes"] = packages_model.TypeList @@ -156,10 +157,10 @@ func RedirectToLastVersion(ctx *context.Context) { func ViewPackageVersion(ctx *context.Context) { pd := ctx.Package.Descriptor + shared_user.RenderUserHeader(ctx) + ctx.Data["Title"] = pd.Package.Name ctx.Data["IsPackagesPage"] = true - ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled - ctx.Data["ContextUser"] = ctx.ContextUser ctx.Data["PackageDescriptor"] = pd var ( @@ -235,10 +236,10 @@ func ListPackageVersions(ctx *context.Context) { query := ctx.FormTrim("q") sort := ctx.FormTrim("sort") + shared_user.RenderUserHeader(ctx) + ctx.Data["Title"] = ctx.Tr("packages.title") ctx.Data["IsPackagesPage"] = true - ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled - ctx.Data["ContextUser"] = ctx.ContextUser ctx.Data["PackageDescriptor"] = &packages_model.PackageDescriptor{ Package: p, Owner: ctx.Package.Owner, @@ -311,10 +312,10 @@ func ListPackageVersions(ctx *context.Context) { func PackageSettings(ctx *context.Context) { pd := ctx.Package.Descriptor + shared_user.RenderUserHeader(ctx) + ctx.Data["Title"] = pd.Package.Name ctx.Data["IsPackagesPage"] = true - ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled - ctx.Data["ContextUser"] = ctx.ContextUser ctx.Data["PackageDescriptor"] = pd repos, _, _ := repo_model.GetUserRepositories(&repo_model.SearchRepoOptions{ diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index 0002d56de0..0e342991d6 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -224,7 +224,7 @@ func Profile(ctx *context.Context) { total = int(count) case "projects": - ctx.Data["OpenProjects"], _, err = project_model.GetProjects(ctx, project_model.SearchOptions{ + ctx.Data["OpenProjects"], _, err = project_model.FindProjects(ctx, project_model.SearchOptions{ Page: -1, IsClosed: util.OptionalBoolFalse, Type: project_model.TypeIndividual, diff --git a/routers/web/web.go b/routers/web/web.go index f0fedd0715..d37d82820d 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -835,6 +835,46 @@ func RegisterRoutes(m *web.Route) { }) }, ignSignIn, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) } + + m.Group("/projects", func() { + m.Get("", org.Projects) + m.Get("/{id}", org.ViewProject) + m.Group("", func() { //nolint:dupl + m.Get("/new", org.NewProject) + m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost) + m.Group("/{id}", func() { + m.Post("", web.Bind(forms.EditProjectBoardForm{}), org.AddBoardToProjectPost) + m.Post("/delete", org.DeleteProject) + + m.Get("/edit", org.EditProject) + m.Post("/edit", web.Bind(forms.CreateProjectForm{}), org.EditProjectPost) + m.Post("/{action:open|close}", org.ChangeProjectStatus) + + m.Group("/{boardID}", func() { + m.Put("", web.Bind(forms.EditProjectBoardForm{}), org.EditProjectBoard) + m.Delete("", org.DeleteProjectBoard) + m.Post("/default", org.SetDefaultProjectBoard) + + m.Post("/move", org.MoveIssues) + }) + }) + }, reqSignIn, func(ctx *context.Context) { + if ctx.ContextUser == nil { + ctx.NotFound("NewProject", nil) + return + } + if ctx.ContextUser.IsOrganization() { + if !ctx.Org.CanWriteUnit(ctx, unit.TypeProjects) { + ctx.NotFound("NewProject", nil) + return + } + } else if ctx.ContextUser.ID != ctx.Doer.ID { + ctx.NotFound("NewProject", nil) + return + } + }) + }, repo.MustEnableProjects) + m.Get("/code", user.CodeSearch) }, context_service.UserAssignmentWeb()) @@ -1168,7 +1208,7 @@ func RegisterRoutes(m *web.Route) { m.Group("/projects", func() { m.Get("", repo.Projects) m.Get("/{id}", repo.ViewProject) - m.Group("", func() { + m.Group("", func() { //nolint:dupl m.Get("/new", repo.NewProject) m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost) m.Group("/{id}", func() { diff --git a/services/context/user.go b/services/context/user.go index 9dc84c3ac1..7642cba4e1 100644 --- a/services/context/user.go +++ b/services/context/user.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + org_model "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/context" ) @@ -56,6 +57,14 @@ func userAssignment(ctx *context.Context, errCb func(int, string, interface{})) } else { errCb(http.StatusInternalServerError, "GetUserByName", err) } + } else { + if ctx.ContextUser.IsOrganization() { + if ctx.Org == nil { + ctx.Org = &context.Organization{} + } + ctx.Org.Organization = (*org_model.Organization)(ctx.ContextUser) + ctx.Data["Org"] = ctx.Org.Organization + } } } } diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl index 87242b94d3..5f543424fc 100644 --- a/templates/org/menu.tmpl +++ b/templates/org/menu.tmpl @@ -3,6 +3,9 @@ <a class="{{if .PageIsViewRepositories}}active {{end}}item" href="{{$.Org.HomeLink}}"> {{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}} </a> + <a class="{{if .PageIsViewProjects}}active {{end}}item" href="{{$.Org.HomeLink}}/-/projects"> + {{svg "octicon-project"}} {{.locale.Tr "user.projects"}} + </a> {{if .IsPackageEnabled}} <a class="item" href="{{$.Org.HomeLink}}/-/packages"> {{svg "octicon-package"}} {{.locale.Tr "packages.title"}} diff --git a/templates/org/projects/list.tmpl b/templates/org/projects/list.tmpl new file mode 100644 index 0000000000..544ed38742 --- /dev/null +++ b/templates/org/projects/list.tmpl @@ -0,0 +1,6 @@ +{{template "base/head" .}} +<div class="page-content repository packages"> + {{template "user/overview/header" .}} + {{template "projects/list" .}} +</div> +{{template "base/footer" .}} diff --git a/templates/org/projects/new.tmpl b/templates/org/projects/new.tmpl new file mode 100644 index 0000000000..b3d6c6001e --- /dev/null +++ b/templates/org/projects/new.tmpl @@ -0,0 +1,6 @@ +{{template "base/head" .}} +<div class="page-content repository packages"> + {{template "user/overview/header" .}} + {{template "projects/new" .}} +</div> +{{template "base/footer" .}} diff --git a/templates/org/projects/view.tmpl b/templates/org/projects/view.tmpl new file mode 100644 index 0000000000..03327e2530 --- /dev/null +++ b/templates/org/projects/view.tmpl @@ -0,0 +1,6 @@ +{{template "base/head" .}} +<div class="page-content repository packages"> + {{template "user/overview/header" .}} + {{template "projects/view" .}} +</div> +{{template "base/footer" .}} diff --git a/templates/projects/list.tmpl b/templates/projects/list.tmpl new file mode 100644 index 0000000000..ae2eaec6ea --- /dev/null +++ b/templates/projects/list.tmpl @@ -0,0 +1,98 @@ +<div class="page-content repository projects"> + <div class="ui container"> + {{if .CanWriteProjects}} + <div class="navbar"> + <div class="ui right"> + <a class="ui green button" href="{{$.Link}}/new">{{.locale.Tr "repo.projects.new"}}</a> + </div> + </div> + <div class="ui divider"></div> + {{end}} + + {{template "base/alert" .}} + <div class="ui compact tiny menu"> + <a class="item{{if not .IsShowClosed}} active{{end}}" href="{{$.Link}}?state=open"> + {{svg "octicon-project" 16 "mr-3"}} + {{JsPrettyNumber .OpenCount}} {{.locale.Tr "repo.issues.open_title"}} + </a> + <a class="item{{if .IsShowClosed}} active{{end}}" href="{{$.Link}}?state=closed"> + {{svg "octicon-check" 16 "mr-3"}} + {{JsPrettyNumber .ClosedCount}} {{.locale.Tr "repo.issues.closed_title"}} + </a> + </div> + + <div class="ui right floated secondary filter menu"> + <!-- Sort --> + <div class="ui dropdown type jump item"> + <span class="text"> + {{.locale.Tr "repo.issues.filter_sort"}} + {{svg "octicon-triangle-down" 14 "dropdown icon"}} + </span> + <div class="menu"> + <a class="{{if eq .SortType "oldest"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&sort=oldest&state={{$.State}}">{{.locale.Tr "repo.issues.filter_sort.oldest"}}</a> + <a class="{{if eq .SortType "recentupdate"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&sort=recentupdate&state={{$.State}}">{{.locale.Tr "repo.issues.filter_sort.recentupdate"}}</a> + <a class="{{if eq .SortType "leastupdate"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&sort=leastupdate&state={{$.State}}">{{.locale.Tr "repo.issues.filter_sort.leastupdate"}}</a> + </div> + </div> + </div> + <div class="milestone list"> + {{range .Projects}} + <li class="item"> + {{svg "octicon-project"}} <a href="{{$.Link}}/{{.ID}}">{{.Title}}</a> + <div class="meta"> + {{$closedDate:= TimeSinceUnix .ClosedDateUnix $.locale}} + {{if .IsClosed}} + {{svg "octicon-clock"}} {{$.locale.Tr "repo.milestones.closed" $closedDate|Str2html}} + {{end}} + <span class="issue-stats"> + {{svg "octicon-issue-opened" 16 "mr-3"}} + {{JsPrettyNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}} + {{svg "octicon-check" 16 "mr-3"}} + {{JsPrettyNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}} + </span> + </div> + {{if and (or $.CanWriteIssues $.CanWritePulls) (not $.Repository.IsArchived)}} + <div class="ui right operate"> + <a href="{{$.Link}}/{{.ID}}/edit" data-id={{.ID}} data-title={{.Title}}>{{svg "octicon-pencil"}} {{$.locale.Tr "repo.issues.label_edit"}}</a> + {{if .IsClosed}} + <a class="link-action" href data-url="{{$.Link}}/{{.ID}}/open">{{svg "octicon-check"}} {{$.locale.Tr "repo.projects.open"}}</a> + {{else}} + <a class="link-action" href data-url="{{$.Link}}/{{.ID}}/close">{{svg "octicon-skip"}} {{$.locale.Tr "repo.projects.close"}}</a> + {{end}} + <a class="delete-button" href="#" data-url="{{$.RepoLink}}/projects/{{.ID}}/delete" data-id="{{.ID}}">{{svg "octicon-trash"}} {{$.locale.Tr "repo.issues.label_delete"}}</a> + </div> + {{end}} + {{if .Description}} + <div class="content"> + {{.RenderedContent|Str2html}} + </div> + {{end}} + </li> + {{end}} + + {{template "base/paginate" .}} + </div> + </div> +</div> + +{{if or .CanWriteIssues .CanWritePulls}} +<div class="ui small basic delete modal"> + <div class="ui icon header"> + {{svg "octicon-trash"}} + {{.locale.Tr "repo.projects.deletion"}} + </div> + <div class="content"> + <p>{{.locale.Tr "repo.projects.deletion_desc"}}</p> + </div> + <div class="actions"> + <div class="ui red basic inverted cancel button"> + <i class="remove icon"></i> + {{.locale.Tr "modal.no"}} + </div> + <div class="ui green basic inverted ok button"> + <i class="checkmark icon"></i> + {{.locale.Tr "modal.yes"}} + </div> + </div> +</div> +{{end}} diff --git a/templates/projects/new.tmpl b/templates/projects/new.tmpl new file mode 100644 index 0000000000..1069102792 --- /dev/null +++ b/templates/projects/new.tmpl @@ -0,0 +1,66 @@ +<div class="page-content repository projects edit-project new milestone"> + <div class="ui container"> + <div class="navbar"> + {{if and .CanWriteProjects .PageIsEditProject}} + <div class="ui right floated secondary menu"> + <a class="ui green button" href="{{$.HomeLink}}/-/projects/new">{{.locale.Tr "repo.milestones.new"}}</a> + </div> + {{end}} + </div> + <div class="ui divider"></div> + <h2 class="ui dividing header"> + {{if .PageIsEditProjects}} + {{.locale.Tr "repo.projects.edit"}} + <div class="sub header">{{.locale.Tr "repo.projects.edit_subheader"}}</div> + {{else}} + {{.locale.Tr "repo.projects.new"}} + <div class="sub header">{{.locale.Tr "repo.projects.new_subheader"}}</div> + {{end}} + </h2> + {{template "base/alert" .}} + <form class="ui form grid" action="{{.Link}}" method="post"> + {{.CsrfTokenHtml}} + <div class="eleven wide column"> + <div class="field {{if .Err_Title}}error{{end}}"> + <label>{{.locale.Tr "repo.projects.title"}}</label> + <input name="title" placeholder="{{.locale.Tr "repo.projects.title"}}" value="{{.title}}" autofocus required> + </div> + <div class="field"> + <label>{{.locale.Tr "repo.projects.description"}}</label> + <textarea name="content" placeholder="{{.locale.Tr "repo.projects.description_placeholder"}}">{{.content}}</textarea> + </div> + + {{if not .PageIsEditProjects}} + <label>{{.locale.Tr "repo.projects.template.desc"}}</label> + <div class="ui selection dropdown"> + <input type="hidden" name="board_type" value="{{.type}}"> + <div class="default text">{{.locale.Tr "repo.projects.template.desc_helper"}}</div> + <div class="menu"> + {{range $element := .ProjectTypes}} + <div class="item" data-id="{{$element.BoardType}}" data-value="{{$element.BoardType}}">{{$.locale.Tr $element.Translation}}</div> + {{end}} + </div> + </div> + {{end}} + </div> + <div class="ui container"> + <div class="ui divider"></div> + <div class="ui left"> + {{if .PageIsEditProjects}} + <a class="ui primary basic button" href="{{.RepoLink}}/projects"> + {{.locale.Tr "repo.milestones.cancel"}} + </a> + <button class="ui green button"> + {{.locale.Tr "repo.projects.modify"}} + </button> + {{else}} + <button class="ui green button"> + {{.locale.Tr "repo.projects.create"}} + </button> + {{end}} + </div> + </div> + + </form> + </div> +</div> diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl new file mode 100644 index 0000000000..ac72acb82b --- /dev/null +++ b/templates/projects/view.tmpl @@ -0,0 +1,279 @@ +<div class="page-content repository projects view-project"> + <div class="ui container"> + <div class="ui two column stackable grid"> + <div class="column"> + </div> + <div class="column right aligned"> + {{if .CanWriteProjects}} + <a class="ui green button show-modal item" data-modal="#new-board-item">{{.locale.Tr "new_project_board"}}</a> + {{end}} + <div class="ui small modal new-board-modal" id="new-board-item"> + <div class="header"> + {{$.locale.Tr "repo.projects.board.new"}} + </div> + <div class="content"> + <form class="ui form"> + <div class="required field"> + <label for="new_board">{{$.locale.Tr "repo.projects.board.new_title"}}</label> + <input class="new-board" id="new_board" name="title" required> + </div> + + <div class="field color-field"> + <label for="new_board_color">{{$.locale.Tr "repo.projects.board.color"}}</label> + <div class="color picker column"> + <input class="color-picker" maxlength="7" placeholder="#c320f6" id="new_board_color_picker" name="color"> + <div class="column precolors"> + {{template "repo/issue/label_precolors"}} + </div> + </div> + </div> + + <div class="text right actions"> + <div class="ui cancel button">{{$.locale.Tr "settings.cancel"}}</div> + <button data-url="{{$.Link}}" class="ui green button" id="new_board_submit">{{$.locale.Tr "repo.projects.board.new_submit"}}</button> + </div> + </form> + </div> + </div> + </div> + </div> + <div class="ui divider"></div> + <div class="ui two column stackable grid"> + <div class="column"> + <h2 class="project-title">{{$.Project.Title}}</h2> + <div class="content project-description">{{$.Project.RenderedContent|Str2html}}</div> + </div> + {{if or $.CanWriteIssues $.CanWritePulls}} + <div class="column right aligned"> + <div class="ui compact right small menu"> + <a class="item" href="{{$.Link}}/edit" data-id={{$.Project.ID}} data-title={{$.Project.Title}}> + {{svg "octicon-pencil"}} + <span class="mx-3">{{$.locale.Tr "repo.issues.label_edit"}}</span> + </a> + {{if .Project.IsClosed}} + <a class="item link-action" href data-url="{{$.Link}}/open"> + {{svg "octicon-check"}} + <span class="mx-3">{{$.locale.Tr "repo.projects.open"}}</span> + </a> + {{else}} + <a class="item link-action" href data-url="{{$.Link}}/close"> + {{svg "octicon-skip"}} + <span class="mx-3">{{$.locale.Tr "repo.projects.close"}}</span> + </a> + {{end}} + <a class="item delete-button" href="#" data-url="{{$.Link}}/delete" data-id="{{.Project.ID}}"> + {{svg "octicon-trash"}} + <span class="mx-3">{{$.locale.Tr "repo.issues.label_delete"}}</span> + </a> + </div> + </div> + {{end}} + </div> + <div class="ui divider"></div> + </div> + <div class="ui container fluid padded" id="project-board"> + + <div class="board"> + {{range $board := .Boards}} + + <div class="ui segment board-column" style="background: {{.Color}} !important;" data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}"> + <div class="board-column-header df ac sb"> + <div class="ui large label board-label py-2"> + <div class="ui small circular grey label board-card-cnt"> + {{.NumIssues}} + </div> + {{.Title}} + </div> + {{if and $.CanWriteProjects (ne .ID 0)}} + <div class="ui dropdown jump item tooltip"> + <div class="not-mobile px-3" tabindex="-1"> + {{svg "octicon-kebab-horizontal"}} + </div> + <div class="menu user-menu" tabindex="-1"> + <a class="item show-modal button" data-modal="#edit-project-board-modal-{{.ID}}"> + {{svg "octicon-pencil"}} + {{$.locale.Tr "repo.projects.board.edit"}} + </a> + {{if not .Default}} + <a class="item show-modal button" data-modal="#set-default-project-board-modal-{{.ID}}"> + {{svg "octicon-pin"}} + {{$.locale.Tr "repo.projects.board.set_default"}} + </a> + {{end}} + <a class="item show-modal button" data-modal="#delete-board-modal-{{.ID}}"> + {{svg "octicon-trash"}} + {{$.locale.Tr "repo.projects.board.delete"}} + </a> + + <div class="ui small modal edit-project-board" id="edit-project-board-modal-{{.ID}}"> + <div class="header"> + {{$.locale.Tr "repo.projects.board.edit"}} + </div> + <div class="content"> + <form class="ui form"> + <div class="required field"> + <label for="new_board_title">{{$.locale.Tr "repo.projects.board.edit_title"}}</label> + <input class="project-board-title" id="new_board_title" name="title" value="{{.Title}}" required> + </div> + + <div class="field color-field"> + <label for="new_board_color">{{$.locale.Tr "repo.projects.board.color"}}</label> + <div class="color picker column"> + <input class="color-picker" maxlength="7" placeholder="#c320f6" id="new_board_color" name="color" value="{{.Color}}"> + <div class="column precolors"> + {{template "repo/issue/label_precolors"}} + </div> + </div> + </div> + + <div class="text right actions"> + <div class="ui cancel button">{{$.locale.Tr "settings.cancel"}}</div> + <button data-url="{{$.Link}}/{{.ID}}" class="ui red button">{{$.locale.Tr "repo.projects.board.edit"}}</button> + </div> + </form> + </div> + </div> + + <div class="ui basic modal" id="set-default-project-board-modal-{{.ID}}"> + <div class="ui icon header"> + {{$.locale.Tr "repo.projects.board.set_default"}} + </div> + <div class="content center"> + <label> + {{$.locale.Tr "repo.projects.board.set_default_desc"}} + </label> + </div> + <div class="text right actions"> + <div class="ui cancel button">{{$.locale.Tr "settings.cancel"}}</div> + <button class="ui red button set-default-project-board" data-url="{{$.Link}}/{{.ID}}/default">{{$.locale.Tr "repo.projects.board.set_default"}}</button> + </div> + </div> + + <div class="ui basic modal" id="delete-board-modal-{{.ID}}"> + <div class="ui icon header"> + {{$.locale.Tr "repo.projects.board.delete"}} + </div> + <div class="content center"> + <label> + {{$.locale.Tr "repo.projects.board.deletion_desc"}} + </label> + </div> + <div class="text right actions"> + <div class="ui cancel button">{{$.locale.Tr "settings.cancel"}}</div> + <button class="ui red button delete-project-board" data-url="{{$.Link}}/{{.ID}}">{{$.locale.Tr "repo.projects.board.delete"}}</button> + </div> + </div> + </div> + </div> + {{end}} + </div> + <div class="ui divider"></div> + + <div class="ui cards board" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}"> + + {{range (index $.IssuesMap .ID)}} + + <!-- start issue card --> + <div class="card board-card" data-issue="{{.ID}}"> + <div class="content p-0"> + <div class="header"> + <span class="dif ac vm {{if .IsClosed}}red{{else}}green{{end}}"> + {{if .IsPull}} + {{if .PullRequest.HasMerged}} + {{svg "octicon-git-merge" 16 "text purple"}} + {{else}} + {{if .IsClosed}} + {{svg "octicon-git-pull-request" 16 "text red"}} + {{else}} + {{svg "octicon-git-pull-request" 16 "text green"}} + {{end}} + {{end}} + {{else}} + {{if .IsClosed}} + {{svg "octicon-issue-closed" 16 "text red"}} + {{else}} + {{svg "octicon-issue-opened" 16 "text green"}} + {{end}} + {{end}} + </span> + <a class="project-board-title vm" href="{{.Link}}"> + {{.Title}} + </a> + </div> + <div class="meta my-2"> + <span class="text light grey"> + {{.Repo.FullName}}#{{.Index}} + {{$timeStr := TimeSinceUnix .GetLastEventTimestamp $.locale}} + {{if .OriginalAuthor}} + {{$.locale.Tr .GetLastEventLabelFake $timeStr (.OriginalAuthor|Escape) | Safe}} + {{else if gt .Poster.ID 0}} + {{$.locale.Tr .GetLastEventLabel $timeStr (.Poster.HomeLink|Escape) (.Poster.GetDisplayName | Escape) | Safe}} + {{else}} + {{$.locale.Tr .GetLastEventLabelFake $timeStr (.Poster.GetDisplayName | Escape) | Safe}} + {{end}} + </span> + </div> + {{- if .MilestoneID}} + <div class="meta my-2"> + <a class="milestone" href="{{$.RepoLink}}/milestone/{{.MilestoneID}}"> + {{svg "octicon-milestone" 16 "mr-2 vm"}} + <span class="vm">{{.Milestone.Name}}</span> + </a> + </div> + {{- end}} + {{- range index $.LinkedPRs .ID}} + <div class="meta my-2"> + <a href="{{$.RepoLink}}/pulls/{{.Index}}"> + <span class="m-0 {{if .PullRequest.HasMerged}}purple{{else if .IsClosed}}red{{else}}green{{end}}">{{svg "octicon-git-merge" 16 "mr-2 vm"}}</span> + <span class="vm">{{.Title}} <span class="text light grey">#{{.Index}}</span></span> + </a> + </div> + {{- end}} + </div> + + {{if or .Labels .Assignees}} + <div class="extra content labels-list p-0 pt-2"> + {{range .Labels}} + <a class="ui label" target="_blank" href="{{$.RepoLink}}/issues?labels={{.ID}}" style="color: {{.ForegroundColor}}; background-color: {{.Color}};" title="{{.Description | RenderEmojiPlain}}">{{.Name | RenderEmoji}}</a> + {{end}} + <div class="right floated"> + {{range .Assignees}} + <a class="tooltip" target="_blank" href="{{.HTMLURL}}" data-content="{{$.locale.Tr "repo.projects.board.assigned_to"}} {{.Name}}">{{avatar . 28 "mini mr-3"}}</a> + {{end}} + </div> + </div> + {{end}} + </div> + <!-- stop issue card --> + + {{end}} + </div> + </div> + {{end}} + </div> + + </div> + +</div> + +{{if or .CanWriteIssues .CanWritePulls}} + <div class="ui small basic delete modal"> + <div class="ui icon header"> + {{svg "octicon-trash"}} + {{.locale.Tr "repo.projects.deletion"}} + </div> + <div class="content"> + <p>{{.locale.Tr "repo.projects.deletion_desc"}}</p> + </div> + <div class="actions"> + <div class="ui red basic inverted cancel button"> + <i class="remove icon"></i> + {{.locale.Tr "modal.no"}} + </div> + <div class="ui green basic inverted ok button"> + <i class="checkmark icon"></i> + {{.locale.Tr "modal.yes"}} + </div> + </div> + </div> +{{end}} diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index 6cb00fdd1d..ca947e3612 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -219,8 +219,8 @@ {{.locale.Tr "repo.issues.new.open_projects"}} </div> {{range .OpenProjects}} - <a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{$.RepoLink}}/projects/{{.ID}}"> - {{svg "octicon-project" 18 "mr-3"}} + <a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{.Link}}"> + {{if .IsOrganizationProject}}{{svg "octicon-project-symlink" 18 "mr-3"}}{{else}}{{svg "octicon-project" 18 "mr-3"}}{{end}} {{.Title}} </a> {{end}} @@ -231,8 +231,8 @@ {{.locale.Tr "repo.issues.new.closed_projects"}} </div> {{range .ClosedProjects}} - <a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{$.RepoLink}}/projects/{{.ID}}"> - {{svg "octicon-project" 18 "mr-3"}} + <a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{.Link}}"> + {{if .IsOrganizationProject}}{{svg "octicon-project-symlink" 18 "mr-3"}}{{else}}{{svg "octicon-project" 18 "mr-3"}}{{end}} {{.Title}} </a> {{end}} @@ -243,8 +243,8 @@ <span class="no-select item {{if .Issue.ProjectID}}hide{{end}}">{{.locale.Tr "repo.issues.new.no_projects"}}</span> <div class="selected"> {{if .Issue.ProjectID}} - <a class="item muted sidebar-item-link" href="{{.RepoLink}}/projects/{{.Issue.ProjectID}}"> - {{svg "octicon-project" 18 "mr-3"}} + <a class="item muted sidebar-item-link" href="{{.Issue.Project.Link}}"> + {{if .IsOrganizationProject}}{{svg "octicon-project-symlink" 18 "mr-3"}}{{else}}{{svg "octicon-project" 18 "mr-3"}}{{end}} {{.Issue.Project.Title}} </a> {{end}} diff --git a/templates/user/overview/header.tmpl b/templates/user/overview/header.tmpl index 61b19c6032..8fb882718c 100644 --- a/templates/user/overview/header.tmpl +++ b/templates/user/overview/header.tmpl @@ -22,6 +22,9 @@ <a class="item" href="{{.ContextUser.HomeLink}}"> {{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}} </a> + <a href="{{.ContextUser.HomeLink}}/-/projects" class="{{if .PageIsViewProjects}}active {{end}}item"> + {{svg "octicon-project"}} {{.locale.Tr "user.projects"}} + </a> {{if (not .UnitPackagesGlobalDisabled)}} <a href="{{.ContextUser.HomeLink}}/-/packages" class="{{if .IsPackagesPage}}active {{end}}item"> {{svg "octicon-package"}} {{.locale.Tr "packages.title"}} diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl index 6c31723e0f..74211eb67b 100644 --- a/templates/user/profile.tmpl +++ b/templates/user/profile.tmpl @@ -106,6 +106,9 @@ <a class='{{if and (ne .TabName "activity") (ne .TabName "following") (ne .TabName "followers") (ne .TabName "stars") (ne .TabName "watching") (ne .TabName "projects") (ne .TabName "code")}}active {{end}}item' href="{{.Owner.HomeLink}}"> {{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}} </a> + <a href="{{.Owner.HomeLink}}/-/projects" class="{{if eq .TabName "projects"}}active {{end}}item"> + {{svg "octicon-project"}} {{.locale.Tr "user.projects"}} + </a> {{if .IsPackageEnabled}} <a class='{{if eq .TabName "packages"}}active {{end}}item' href="{{.Owner.HomeLink}}/-/packages"> {{svg "octicon-package"}} {{.locale.Tr "packages.title"}} |