mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	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>
This commit is contained in:
		| @@ -24,3 +24,12 @@ | |||||||
|   creator_id: 5 |   creator_id: 5 | ||||||
|   board_type: 1 |   board_type: 1 | ||||||
|   type: 2 |   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 |   creator_id: 2 | ||||||
|   created_unix: 1588117528 |   created_unix: 1588117528 | ||||||
|   updated_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. | // 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) | 	issues := make([]*Issue, 0, 10) | ||||||
| 	return issues, db.GetEngine(ctx).In("id", issueIDs).Find(&issues) | 	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 { | func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error { | ||||||
| 	oldProjectID := issue.projectID(ctx) | 	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. | 	// Only check if we add a new project and not remove it. | ||||||
| 	if newProjectID > 0 { | 	if newProjectID > 0 { | ||||||
| 		newProject, err := project_model.GetProjectByID(ctx, newProjectID) | 		newProject, err := project_model.GetProjectByID(ctx, newProjectID) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return err | 			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") | 			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 | 		return err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if err := issue.LoadRepo(ctx); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	if oldProjectID > 0 || newProjectID > 0 { | 	if oldProjectID > 0 || newProjectID > 0 { | ||||||
| 		if _, err := CreateComment(ctx, &CreateCommentOptions{ | 		if _, err := CreateComment(ctx, &CreateCommentOptions{ | ||||||
| 			Type:         CommentTypeProject, | 			Type:         CommentTypeProject, | ||||||
|   | |||||||
| @@ -16,8 +16,6 @@ import ( | |||||||
| 	user_model "code.gitea.io/gitea/models/user" | 	user_model "code.gitea.io/gitea/models/user" | ||||||
| 	"code.gitea.io/gitea/modules/log" | 	"code.gitea.io/gitea/modules/log" | ||||||
| 	"code.gitea.io/gitea/modules/util" | 	"code.gitea.io/gitea/modules/util" | ||||||
|  |  | ||||||
| 	"xorm.io/builder" |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // ___________ | // ___________ | ||||||
| @@ -96,59 +94,6 @@ func init() { | |||||||
| 	db.RegisterModel(new(TeamInvite)) | 	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 | // ColorFormat provides a basic color format for a Team | ||||||
| func (t *Team) ColorFormat(s fmt.State) { | func (t *Team) ColorFormat(s fmt.State) { | ||||||
| 	if t == nil { | 	if t == nil { | ||||||
| @@ -335,16 +280,6 @@ func GetTeamNamesByID(teamIDs []int64) ([]string, error) { | |||||||
| 	return teamNames, err | 	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 | // IncrTeamRepoNum increases the number of repos for the given team by 1 | ||||||
| func IncrTeamRepoNum(ctx context.Context, teamID int64) error { | func IncrTeamRepoNum(ctx context.Context, teamID int64) error { | ||||||
| 	_, err := db.GetEngine(ctx).Incr("num_repos").ID(teamID).Update(new(Team)) | 	_, err := db.GetEngine(ctx).Incr("num_repos").ID(teamID).Update(new(Team)) | ||||||
|   | |||||||
							
								
								
									
										128
									
								
								models/organization/team_list.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								models/organization/team_list.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | 	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 | // IsUserInTeams returns if a user in some teams | ||||||
| func IsUserInTeams(ctx context.Context, userID int64, teamIDs []int64) (bool, error) { | 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)) | 	return db.GetEngine(ctx).Where("uid=?", userID).In("team_id", teamIDs).Exist(new(TeamUser)) | ||||||
|   | |||||||
| @@ -8,6 +8,9 @@ import ( | |||||||
| 	"fmt" | 	"fmt" | ||||||
|  |  | ||||||
| 	"code.gitea.io/gitea/models/db" | 	"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/setting" | ||||||
| 	"code.gitea.io/gitea/modules/timeutil" | 	"code.gitea.io/gitea/modules/timeutil" | ||||||
| 	"code.gitea.io/gitea/modules/util" | 	"code.gitea.io/gitea/modules/util" | ||||||
| @@ -81,7 +84,10 @@ type Project struct { | |||||||
| 	ID          int64                  `xorm:"pk autoincr"` | 	ID          int64                  `xorm:"pk autoincr"` | ||||||
| 	Title       string                 `xorm:"INDEX NOT NULL"` | 	Title       string                 `xorm:"INDEX NOT NULL"` | ||||||
| 	Description string                 `xorm:"TEXT"` | 	Description string                 `xorm:"TEXT"` | ||||||
|  | 	OwnerID     int64                  `xorm:"INDEX"` | ||||||
|  | 	Owner       *user_model.User       `xorm:"-"` | ||||||
| 	RepoID      int64                  `xorm:"INDEX"` | 	RepoID      int64                  `xorm:"INDEX"` | ||||||
|  | 	Repo        *repo_model.Repository `xorm:"-"` | ||||||
| 	CreatorID   int64                  `xorm:"NOT NULL"` | 	CreatorID   int64                  `xorm:"NOT NULL"` | ||||||
| 	IsClosed    bool                   `xorm:"INDEX"` | 	IsClosed    bool                   `xorm:"INDEX"` | ||||||
| 	BoardType   BoardType | 	BoardType   BoardType | ||||||
| @@ -94,6 +100,46 @@ type Project struct { | |||||||
| 	ClosedDateUnix timeutil.TimeStamp | 	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() { | func init() { | ||||||
| 	db.RegisterModel(new(Project)) | 	db.RegisterModel(new(Project)) | ||||||
| } | } | ||||||
| @@ -110,7 +156,7 @@ func GetProjectsConfig() []ProjectsConfig { | |||||||
| // IsTypeValid checks if a project type is valid | // IsTypeValid checks if a project type is valid | ||||||
| func IsTypeValid(p Type) bool { | func IsTypeValid(p Type) bool { | ||||||
| 	switch p { | 	switch p { | ||||||
| 	case TypeRepository: | 	case TypeRepository, TypeOrganization: | ||||||
| 		return true | 		return true | ||||||
| 	default: | 	default: | ||||||
| 		return false | 		return false | ||||||
| @@ -119,6 +165,7 @@ func IsTypeValid(p Type) bool { | |||||||
|  |  | ||||||
| // SearchOptions are options for GetProjects | // SearchOptions are options for GetProjects | ||||||
| type SearchOptions struct { | type SearchOptions struct { | ||||||
|  | 	OwnerID  int64 | ||||||
| 	RepoID   int64 | 	RepoID   int64 | ||||||
| 	Page     int | 	Page     int | ||||||
| 	IsClosed util.OptionalBool | 	IsClosed util.OptionalBool | ||||||
| @@ -126,12 +173,11 @@ type SearchOptions struct { | |||||||
| 	Type     Type | 	Type     Type | ||||||
| } | } | ||||||
|  |  | ||||||
| // GetProjects returns a list of all projects that have been created in the repository | func (opts *SearchOptions) toConds() builder.Cond { | ||||||
| func GetProjects(ctx context.Context, opts SearchOptions) ([]*Project, int64, error) { | 	cond := builder.NewCond() | ||||||
| 	e := db.GetEngine(ctx) | 	if opts.RepoID > 0 { | ||||||
| 	projects := make([]*Project, 0, setting.UI.IssuePagingNum) | 		cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) | ||||||
|  | 	} | ||||||
| 	var cond builder.Cond = builder.Eq{"repo_id": opts.RepoID} |  | ||||||
| 	switch opts.IsClosed { | 	switch opts.IsClosed { | ||||||
| 	case util.OptionalBoolTrue: | 	case util.OptionalBoolTrue: | ||||||
| 		cond = cond.And(builder.Eq{"is_closed": true}) | 		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 { | 	if opts.Type > 0 { | ||||||
| 		cond = cond.And(builder.Eq{"type": opts.Type}) | 		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)) | 	count, err := e.Where(cond).Count(new(Project)) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| @@ -188,9 +250,11 @@ func NewProject(p *Project) error { | |||||||
| 		return err | 		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 { | 		if _, err := db.Exec(ctx, "UPDATE `repository` SET num_projects = num_projects + 1 WHERE id = ?", p.RepoID); err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	if err := createBoardsForProjectsType(ctx, p); err != nil { | 	if err := createBoardsForProjectsType(ctx, p); err != nil { | ||||||
| 		return err | 		return err | ||||||
|   | |||||||
| @@ -22,7 +22,7 @@ func TestIsProjectTypeValid(t *testing.T) { | |||||||
| 	}{ | 	}{ | ||||||
| 		{TypeIndividual, false}, | 		{TypeIndividual, false}, | ||||||
| 		{TypeRepository, true}, | 		{TypeRepository, true}, | ||||||
| 		{TypeOrganization, false}, | 		{TypeOrganization, true}, | ||||||
| 		{UnknownType, false}, | 		{UnknownType, false}, | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -34,13 +34,13 @@ func TestIsProjectTypeValid(t *testing.T) { | |||||||
| func TestGetProjects(t *testing.T) { | func TestGetProjects(t *testing.T) { | ||||||
| 	assert.NoError(t, unittest.PrepareTestDatabase()) | 	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) | 	assert.NoError(t, err) | ||||||
|  |  | ||||||
| 	// 1 value for this repo exists in the fixtures | 	// 1 value for this repo exists in the fixtures | ||||||
| 	assert.Len(t, projects, 1) | 	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) | 	assert.NoError(t, err) | ||||||
|  |  | ||||||
| 	// 1 value for this repo exists in the fixtures | 	// 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/organization" | ||||||
| 	"code.gitea.io/gitea/models/perm" | 	"code.gitea.io/gitea/models/perm" | ||||||
|  | 	"code.gitea.io/gitea/models/unit" | ||||||
| 	user_model "code.gitea.io/gitea/models/user" | 	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/setting" | ||||||
| 	"code.gitea.io/gitea/modules/structs" | 	"code.gitea.io/gitea/modules/structs" | ||||||
| ) | ) | ||||||
| @@ -28,6 +30,32 @@ type Organization struct { | |||||||
| 	Teams []*organization.Team | 	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 | // HandleOrgAssignment handles organization assignment | ||||||
| func HandleOrgAssignment(ctx *Context, args ...bool) { | func HandleOrgAssignment(ctx *Context, args ...bool) { | ||||||
| 	var ( | 	var ( | ||||||
|   | |||||||
							
								
								
									
										17
									
								
								routers/web/org/main_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								routers/web/org/main_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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("..", "..", ".."), | ||||||
|  | 	}) | ||||||
|  | } | ||||||
							
								
								
									
										670
									
								
								routers/web/org/projects.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										670
									
								
								routers/web/org/projects.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
							
								
								
									
										28
									
								
								routers/web/org/projects_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								routers/web/org/projects_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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") { | 	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, | 			RepoID:   repo.ID, | ||||||
| 			Type:     project_model.TypeRepository, | 			Type:     project_model.TypeRepository, | ||||||
| 			IsClosed: util.OptionalBoolOf(isShowClosed), | 			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) { | func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { | ||||||
| 	var err error | 	var err error | ||||||
|  | 	projects, _, err := project_model.FindProjects(ctx, project_model.SearchOptions{ | ||||||
| 	ctx.Data["OpenProjects"], _, err = project_model.GetProjects(ctx, project_model.SearchOptions{ |  | ||||||
| 		RepoID:   repo.ID, | 		RepoID:   repo.ID, | ||||||
| 		Page:     -1, | 		Page:     -1, | ||||||
| 		IsClosed: util.OptionalBoolFalse, | 		IsClosed: util.OptionalBoolFalse, | ||||||
| @@ -485,8 +484,20 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { | |||||||
| 		ctx.ServerError("GetProjects", err) | 		ctx.ServerError("GetProjects", err) | ||||||
| 		return | 		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["ClosedProjects"], _, err = project_model.GetProjects(ctx, project_model.SearchOptions{ | 	ctx.Data["OpenProjects"] = append(projects, projects2...) | ||||||
|  |  | ||||||
|  | 	projects, _, err = project_model.FindProjects(ctx, project_model.SearchOptions{ | ||||||
| 		RepoID:   repo.ID, | 		RepoID:   repo.ID, | ||||||
| 		Page:     -1, | 		Page:     -1, | ||||||
| 		IsClosed: util.OptionalBoolTrue, | 		IsClosed: util.OptionalBoolTrue, | ||||||
| @@ -496,6 +507,18 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { | |||||||
| 		ctx.ServerError("GetProjects", err) | 		ctx.ServerError("GetProjects", err) | ||||||
| 		return | 		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 | // repoReviewerSelection items to bee shown | ||||||
| @@ -988,7 +1011,7 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull | |||||||
| 			ctx.ServerError("GetProjectByID", err) | 			ctx.ServerError("GetProjectByID", err) | ||||||
| 			return nil, nil, 0, 0 | 			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) | 			ctx.NotFound("", nil) | ||||||
| 			return nil, nil, 0, 0 | 			return nil, nil, 0, 0 | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -70,7 +70,7 @@ func Projects(ctx *context.Context) { | |||||||
| 		total = repo.NumClosedProjects | 		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, | 		RepoID:   repo.ID, | ||||||
| 		Page:     page, | 		Page:     page, | ||||||
| 		IsClosed: util.OptionalBoolOf(isShowClosed), | 		IsClosed: util.OptionalBoolOf(isShowClosed), | ||||||
| @@ -112,7 +112,7 @@ func Projects(ctx *context.Context) { | |||||||
| 	pager.AddParam(ctx, "state", "State") | 	pager.AddParam(ctx, "state", "State") | ||||||
| 	ctx.Data["Page"] = pager | 	ctx.Data["Page"] = pager | ||||||
|  |  | ||||||
| 	ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) | 	ctx.Data["CanWriteProjects"] = true | ||||||
| 	ctx.Data["IsShowClosed"] = isShowClosed | 	ctx.Data["IsShowClosed"] = isShowClosed | ||||||
| 	ctx.Data["IsProjectsPage"] = true | 	ctx.Data["IsProjectsPage"] = true | ||||||
| 	ctx.Data["SortType"] = sortType | 	ctx.Data["SortType"] = sortType | ||||||
| @@ -653,47 +653,3 @@ func MoveIssues(ctx *context.Context) { | |||||||
| 		"ok": true, | 		"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 + "/") |  | ||||||
| } |  | ||||||
|   | |||||||
							
								
								
									
										14
									
								
								routers/web/shared/user/header.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								routers/web/shared/user/header.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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/setting" | ||||||
| 	"code.gitea.io/gitea/modules/util" | 	"code.gitea.io/gitea/modules/util" | ||||||
| 	"code.gitea.io/gitea/modules/web" | 	"code.gitea.io/gitea/modules/web" | ||||||
|  | 	shared_user "code.gitea.io/gitea/routers/web/shared/user" | ||||||
| 	"code.gitea.io/gitea/services/forms" | 	"code.gitea.io/gitea/services/forms" | ||||||
| 	packages_service "code.gitea.io/gitea/services/packages" | 	packages_service "code.gitea.io/gitea/services/packages" | ||||||
| ) | ) | ||||||
| @@ -83,10 +84,10 @@ func ListPackages(ctx *context.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	shared_user.RenderUserHeader(ctx) | ||||||
|  |  | ||||||
| 	ctx.Data["Title"] = ctx.Tr("packages.title") | 	ctx.Data["Title"] = ctx.Tr("packages.title") | ||||||
| 	ctx.Data["IsPackagesPage"] = true | 	ctx.Data["IsPackagesPage"] = true | ||||||
| 	ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled |  | ||||||
| 	ctx.Data["ContextUser"] = ctx.ContextUser |  | ||||||
| 	ctx.Data["Query"] = query | 	ctx.Data["Query"] = query | ||||||
| 	ctx.Data["PackageType"] = packageType | 	ctx.Data["PackageType"] = packageType | ||||||
| 	ctx.Data["AvailableTypes"] = packages_model.TypeList | 	ctx.Data["AvailableTypes"] = packages_model.TypeList | ||||||
| @@ -156,10 +157,10 @@ func RedirectToLastVersion(ctx *context.Context) { | |||||||
| func ViewPackageVersion(ctx *context.Context) { | func ViewPackageVersion(ctx *context.Context) { | ||||||
| 	pd := ctx.Package.Descriptor | 	pd := ctx.Package.Descriptor | ||||||
|  |  | ||||||
|  | 	shared_user.RenderUserHeader(ctx) | ||||||
|  |  | ||||||
| 	ctx.Data["Title"] = pd.Package.Name | 	ctx.Data["Title"] = pd.Package.Name | ||||||
| 	ctx.Data["IsPackagesPage"] = true | 	ctx.Data["IsPackagesPage"] = true | ||||||
| 	ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled |  | ||||||
| 	ctx.Data["ContextUser"] = ctx.ContextUser |  | ||||||
| 	ctx.Data["PackageDescriptor"] = pd | 	ctx.Data["PackageDescriptor"] = pd | ||||||
|  |  | ||||||
| 	var ( | 	var ( | ||||||
| @@ -235,10 +236,10 @@ func ListPackageVersions(ctx *context.Context) { | |||||||
| 	query := ctx.FormTrim("q") | 	query := ctx.FormTrim("q") | ||||||
| 	sort := ctx.FormTrim("sort") | 	sort := ctx.FormTrim("sort") | ||||||
|  |  | ||||||
|  | 	shared_user.RenderUserHeader(ctx) | ||||||
|  |  | ||||||
| 	ctx.Data["Title"] = ctx.Tr("packages.title") | 	ctx.Data["Title"] = ctx.Tr("packages.title") | ||||||
| 	ctx.Data["IsPackagesPage"] = true | 	ctx.Data["IsPackagesPage"] = true | ||||||
| 	ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled |  | ||||||
| 	ctx.Data["ContextUser"] = ctx.ContextUser |  | ||||||
| 	ctx.Data["PackageDescriptor"] = &packages_model.PackageDescriptor{ | 	ctx.Data["PackageDescriptor"] = &packages_model.PackageDescriptor{ | ||||||
| 		Package: p, | 		Package: p, | ||||||
| 		Owner:   ctx.Package.Owner, | 		Owner:   ctx.Package.Owner, | ||||||
| @@ -311,10 +312,10 @@ func ListPackageVersions(ctx *context.Context) { | |||||||
| func PackageSettings(ctx *context.Context) { | func PackageSettings(ctx *context.Context) { | ||||||
| 	pd := ctx.Package.Descriptor | 	pd := ctx.Package.Descriptor | ||||||
|  |  | ||||||
|  | 	shared_user.RenderUserHeader(ctx) | ||||||
|  |  | ||||||
| 	ctx.Data["Title"] = pd.Package.Name | 	ctx.Data["Title"] = pd.Package.Name | ||||||
| 	ctx.Data["IsPackagesPage"] = true | 	ctx.Data["IsPackagesPage"] = true | ||||||
| 	ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled |  | ||||||
| 	ctx.Data["ContextUser"] = ctx.ContextUser |  | ||||||
| 	ctx.Data["PackageDescriptor"] = pd | 	ctx.Data["PackageDescriptor"] = pd | ||||||
|  |  | ||||||
| 	repos, _, _ := repo_model.GetUserRepositories(&repo_model.SearchRepoOptions{ | 	repos, _, _ := repo_model.GetUserRepositories(&repo_model.SearchRepoOptions{ | ||||||
|   | |||||||
| @@ -224,7 +224,7 @@ func Profile(ctx *context.Context) { | |||||||
|  |  | ||||||
| 		total = int(count) | 		total = int(count) | ||||||
| 	case "projects": | 	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, | 			Page:     -1, | ||||||
| 			IsClosed: util.OptionalBoolFalse, | 			IsClosed: util.OptionalBoolFalse, | ||||||
| 			Type:     project_model.TypeIndividual, | 			Type:     project_model.TypeIndividual, | ||||||
|   | |||||||
| @@ -835,6 +835,46 @@ func RegisterRoutes(m *web.Route) { | |||||||
| 				}) | 				}) | ||||||
| 			}, ignSignIn, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) | 			}, 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) | 		m.Get("/code", user.CodeSearch) | ||||||
| 	}, context_service.UserAssignmentWeb()) | 	}, context_service.UserAssignmentWeb()) | ||||||
|  |  | ||||||
| @@ -1168,7 +1208,7 @@ func RegisterRoutes(m *web.Route) { | |||||||
| 		m.Group("/projects", func() { | 		m.Group("/projects", func() { | ||||||
| 			m.Get("", repo.Projects) | 			m.Get("", repo.Projects) | ||||||
| 			m.Get("/{id}", repo.ViewProject) | 			m.Get("/{id}", repo.ViewProject) | ||||||
| 			m.Group("", func() { | 			m.Group("", func() { //nolint:dupl | ||||||
| 				m.Get("/new", repo.NewProject) | 				m.Get("/new", repo.NewProject) | ||||||
| 				m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost) | 				m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost) | ||||||
| 				m.Group("/{id}", func() { | 				m.Group("/{id}", func() { | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ import ( | |||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"strings" | 	"strings" | ||||||
|  |  | ||||||
|  | 	org_model "code.gitea.io/gitea/models/organization" | ||||||
| 	user_model "code.gitea.io/gitea/models/user" | 	user_model "code.gitea.io/gitea/models/user" | ||||||
| 	"code.gitea.io/gitea/modules/context" | 	"code.gitea.io/gitea/modules/context" | ||||||
| ) | ) | ||||||
| @@ -56,6 +57,14 @@ func userAssignment(ctx *context.Context, errCb func(int, string, interface{})) | |||||||
| 			} else { | 			} else { | ||||||
| 				errCb(http.StatusInternalServerError, "GetUserByName", err) | 				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}}"> | 		<a class="{{if .PageIsViewRepositories}}active {{end}}item" href="{{$.Org.HomeLink}}"> | ||||||
| 			{{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}} | 			{{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}} | ||||||
| 		</a> | 		</a> | ||||||
|  | 		<a class="{{if .PageIsViewProjects}}active {{end}}item" href="{{$.Org.HomeLink}}/-/projects"> | ||||||
|  | 			{{svg "octicon-project"}} {{.locale.Tr "user.projects"}} | ||||||
|  | 		</a> | ||||||
| 		{{if .IsPackageEnabled}} | 		{{if .IsPackageEnabled}} | ||||||
| 		<a class="item" href="{{$.Org.HomeLink}}/-/packages"> | 		<a class="item" href="{{$.Org.HomeLink}}/-/packages"> | ||||||
| 			{{svg "octicon-package"}} {{.locale.Tr "packages.title"}} | 			{{svg "octicon-package"}} {{.locale.Tr "packages.title"}} | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								templates/org/projects/list.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								templates/org/projects/list.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | |||||||
|  | {{template "base/head" .}} | ||||||
|  | <div class="page-content repository packages"> | ||||||
|  | 	{{template "user/overview/header" .}} | ||||||
|  | 	{{template "projects/list" .}} | ||||||
|  | </div> | ||||||
|  | {{template "base/footer" .}} | ||||||
							
								
								
									
										6
									
								
								templates/org/projects/new.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								templates/org/projects/new.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | |||||||
|  | {{template "base/head" .}} | ||||||
|  | <div class="page-content repository packages"> | ||||||
|  | 	{{template "user/overview/header" .}} | ||||||
|  | 	{{template "projects/new" .}} | ||||||
|  | </div> | ||||||
|  | {{template "base/footer" .}} | ||||||
							
								
								
									
										6
									
								
								templates/org/projects/view.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								templates/org/projects/view.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | |||||||
|  | {{template "base/head" .}} | ||||||
|  | <div class="page-content repository packages"> | ||||||
|  | 	{{template "user/overview/header" .}} | ||||||
|  | 	{{template "projects/view" .}} | ||||||
|  | </div> | ||||||
|  | {{template "base/footer" .}} | ||||||
							
								
								
									
										98
									
								
								templates/projects/list.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								templates/projects/list.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -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}} | ||||||
							
								
								
									
										66
									
								
								templates/projects/new.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								templates/projects/new.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||||
							
								
								
									
										279
									
								
								templates/projects/view.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										279
									
								
								templates/projects/view.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -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"}} | 							{{.locale.Tr "repo.issues.new.open_projects"}} | ||||||
| 						</div> | 						</div> | ||||||
| 						{{range .OpenProjects}} | 						{{range .OpenProjects}} | ||||||
| 							<a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{$.RepoLink}}/projects/{{.ID}}"> | 							<a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{.Link}}"> | ||||||
| 								{{svg "octicon-project" 18 "mr-3"}} | 								{{if .IsOrganizationProject}}{{svg "octicon-project-symlink" 18 "mr-3"}}{{else}}{{svg "octicon-project" 18 "mr-3"}}{{end}} | ||||||
| 								{{.Title}} | 								{{.Title}} | ||||||
| 							</a> | 							</a> | ||||||
| 						{{end}} | 						{{end}} | ||||||
| @@ -231,8 +231,8 @@ | |||||||
| 							{{.locale.Tr "repo.issues.new.closed_projects"}} | 							{{.locale.Tr "repo.issues.new.closed_projects"}} | ||||||
| 						</div> | 						</div> | ||||||
| 						{{range .ClosedProjects}} | 						{{range .ClosedProjects}} | ||||||
| 							<a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{$.RepoLink}}/projects/{{.ID}}"> | 							<a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{.Link}}"> | ||||||
| 								{{svg "octicon-project" 18 "mr-3"}} | 								{{if .IsOrganizationProject}}{{svg "octicon-project-symlink" 18 "mr-3"}}{{else}}{{svg "octicon-project" 18 "mr-3"}}{{end}} | ||||||
| 								{{.Title}} | 								{{.Title}} | ||||||
| 							</a> | 							</a> | ||||||
| 						{{end}} | 						{{end}} | ||||||
| @@ -243,8 +243,8 @@ | |||||||
| 				<span class="no-select item {{if .Issue.ProjectID}}hide{{end}}">{{.locale.Tr "repo.issues.new.no_projects"}}</span> | 				<span class="no-select item {{if .Issue.ProjectID}}hide{{end}}">{{.locale.Tr "repo.issues.new.no_projects"}}</span> | ||||||
| 				<div class="selected"> | 				<div class="selected"> | ||||||
| 					{{if .Issue.ProjectID}} | 					{{if .Issue.ProjectID}} | ||||||
| 						<a class="item muted sidebar-item-link" href="{{.RepoLink}}/projects/{{.Issue.ProjectID}}"> | 						<a class="item muted sidebar-item-link" href="{{.Issue.Project.Link}}"> | ||||||
| 							{{svg "octicon-project" 18 "mr-3"}} | 							{{if .IsOrganizationProject}}{{svg "octicon-project-symlink" 18 "mr-3"}}{{else}}{{svg "octicon-project" 18 "mr-3"}}{{end}} | ||||||
| 							{{.Issue.Project.Title}} | 							{{.Issue.Project.Title}} | ||||||
| 						</a> | 						</a> | ||||||
| 					{{end}} | 					{{end}} | ||||||
|   | |||||||
| @@ -22,6 +22,9 @@ | |||||||
| 			<a class="item" href="{{.ContextUser.HomeLink}}"> | 			<a class="item" href="{{.ContextUser.HomeLink}}"> | ||||||
| 				{{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}} | 				{{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}} | ||||||
| 			</a> | 			</a> | ||||||
|  | 			<a href="{{.ContextUser.HomeLink}}/-/projects" class="{{if .PageIsViewProjects}}active {{end}}item"> | ||||||
|  | 				{{svg "octicon-project"}} {{.locale.Tr "user.projects"}} | ||||||
|  | 			</a> | ||||||
| 			{{if (not .UnitPackagesGlobalDisabled)}} | 			{{if (not .UnitPackagesGlobalDisabled)}} | ||||||
| 				<a href="{{.ContextUser.HomeLink}}/-/packages" class="{{if .IsPackagesPage}}active {{end}}item"> | 				<a href="{{.ContextUser.HomeLink}}/-/packages" class="{{if .IsPackagesPage}}active {{end}}item"> | ||||||
| 					{{svg "octicon-package"}} {{.locale.Tr "packages.title"}} | 					{{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}}"> | 					<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"}} | 						{{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}} | ||||||
| 					</a> | 					</a> | ||||||
|  | 					<a href="{{.Owner.HomeLink}}/-/projects" class="{{if eq .TabName "projects"}}active {{end}}item"> | ||||||
|  | 						{{svg "octicon-project"}} {{.locale.Tr "user.projects"}} | ||||||
|  | 					</a> | ||||||
| 					{{if .IsPackageEnabled}} | 					{{if .IsPackageEnabled}} | ||||||
| 					<a class='{{if eq .TabName "packages"}}active {{end}}item' href="{{.Owner.HomeLink}}/-/packages"> | 					<a class='{{if eq .TabName "packages"}}active {{end}}item' href="{{.Owner.HomeLink}}/-/packages"> | ||||||
| 						{{svg "octicon-package"}} {{.locale.Tr "packages.title"}} | 						{{svg "octicon-package"}} {{.locale.Tr "packages.title"}} | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user