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>tags/v1.19.0-rc0
@@ -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 |
@@ -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 |
@@ -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) | |||
} |
@@ -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, |
@@ -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)) |
@@ -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) | |||
} |
@@ -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)) |
@@ -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 { |
@@ -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 |
@@ -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 ( |
@@ -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("..", "..", ".."), | |||
}) | |||
} |
@@ -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, | |||
}) | |||
} |
@@ -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()) | |||
} |
@@ -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 | |||
} |
@@ -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 + "/") | |||
} |
@@ -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 | |||
} |
@@ -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{ |
@@ -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, |
@@ -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() { |
@@ -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 | |||
} | |||
} | |||
} | |||
} |
@@ -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"}} |
@@ -0,0 +1,6 @@ | |||
{{template "base/head" .}} | |||
<div class="page-content repository packages"> | |||
{{template "user/overview/header" .}} | |||
{{template "projects/list" .}} | |||
</div> | |||
{{template "base/footer" .}} |
@@ -0,0 +1,6 @@ | |||
{{template "base/head" .}} | |||
<div class="page-content repository packages"> | |||
{{template "user/overview/header" .}} | |||
{{template "projects/new" .}} | |||
</div> | |||
{{template "base/footer" .}} |
@@ -0,0 +1,6 @@ | |||
{{template "base/head" .}} | |||
<div class="page-content repository packages"> | |||
{{template "user/overview/header" .}} | |||
{{template "projects/view" .}} | |||
</div> | |||
{{template "base/footer" .}} |
@@ -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}} |
@@ -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> |
@@ -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}} |
@@ -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}} |
@@ -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"}} |
@@ -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"}} |