From 40274b4a935fff50e223751ce3653c2549352b10 Mon Sep 17 00:00:00 2001
From: Jimmy Praet <jimmy.praet@telenet.be>
Date: Sun, 27 Dec 2020 20:58:03 +0100
Subject: [PATCH] Team dashboards (#14159)

---
 models/action.go                       | 10 +++++
 models/org.go                          | 32 ++++++++++++----
 models/repo_list.go                    |  5 +++
 models/user_heatmap.go                 | 10 +++++
 options/locale/locale_en-US.ini        |  1 +
 routers/api/v1/repo/repo.go            |  6 +++
 routers/routes/macaron.go              |  6 ++-
 routers/user/home.go                   | 68 ++++++++++++++++++----------------
 templates/swagger/v1_json.tmpl         |  7 ++++
 templates/user/dashboard/navbar.tmpl   | 38 +++++++++++++++++--
 templates/user/dashboard/repolist.tmpl |  3 ++
 web_src/js/index.js                    |  9 ++++-
 12 files changed, 148 insertions(+), 47 deletions(-)

diff --git a/models/action.go b/models/action.go
index ccf161192e..2fdab7f4e9 100644
--- a/models/action.go
+++ b/models/action.go
@@ -289,6 +289,7 @@ func (a *Action) GetIssueContent() string {
 // GetFeedsOptions options for retrieving feeds
 type GetFeedsOptions struct {
 	RequestedUser   *User // the user we want activity for
+	RequestedTeam   *Team // the team we want activity for
 	Actor           *User // the user viewing the activity
 	IncludePrivate  bool  // include private actions
 	OnlyPerformedBy bool  // only actions performed by requested user
@@ -357,6 +358,15 @@ func activityQueryCondition(opts GetFeedsOptions) (builder.Cond, error) {
 		}
 	}
 
+	if opts.RequestedTeam != nil {
+		env := opts.RequestedUser.AccessibleTeamReposEnv(opts.RequestedTeam)
+		teamRepoIDs, err := env.RepoIDs(1, opts.RequestedUser.NumRepos)
+		if err != nil {
+			return nil, fmt.Errorf("GetTeamRepositories: %v", err)
+		}
+		cond = cond.And(builder.In("repo_id", teamRepoIDs))
+	}
+
 	cond = cond.And(builder.Eq{"user_id": opts.RequestedUser.ID})
 
 	if opts.OnlyPerformedBy {
diff --git a/models/org.go b/models/org.go
index 84f2892e4a..c93a30fd77 100644
--- a/models/org.go
+++ b/models/org.go
@@ -746,6 +746,7 @@ type AccessibleReposEnvironment interface {
 type accessibleReposEnv struct {
 	org     *User
 	user    *User
+	team    *Team
 	teamIDs []int64
 	e       Engine
 	keyword string
@@ -782,16 +783,31 @@ func (org *User) accessibleReposEnv(e Engine, userID int64) (AccessibleReposEnvi
 	}, nil
 }
 
+// AccessibleTeamReposEnv an AccessibleReposEnvironment for the repositories in `org`
+// that are accessible to the specified team.
+func (org *User) AccessibleTeamReposEnv(team *Team) AccessibleReposEnvironment {
+	return &accessibleReposEnv{
+		org:     org,
+		team:    team,
+		e:       x,
+		orderBy: SearchOrderByRecentUpdated,
+	}
+}
+
 func (env *accessibleReposEnv) cond() builder.Cond {
 	var cond = builder.NewCond()
-	if env.user == nil || !env.user.IsRestricted {
-		cond = cond.Or(builder.Eq{
-			"`repository`.owner_id":   env.org.ID,
-			"`repository`.is_private": false,
-		})
-	}
-	if len(env.teamIDs) > 0 {
-		cond = cond.Or(builder.In("team_repo.team_id", env.teamIDs))
+	if env.team != nil {
+		cond = cond.And(builder.Eq{"team_repo.team_id": env.team.ID})
+	} else {
+		if env.user == nil || !env.user.IsRestricted {
+			cond = cond.Or(builder.Eq{
+				"`repository`.owner_id":   env.org.ID,
+				"`repository`.is_private": false,
+			})
+		}
+		if len(env.teamIDs) > 0 {
+			cond = cond.Or(builder.In("team_repo.team_id", env.teamIDs))
+		}
 	}
 	if env.keyword != "" {
 		cond = cond.And(builder.Like{"`repository`.lower_name", strings.ToLower(env.keyword)})
diff --git a/models/repo_list.go b/models/repo_list.go
index 355b801a7e..de3562a2ab 100644
--- a/models/repo_list.go
+++ b/models/repo_list.go
@@ -138,6 +138,7 @@ type SearchRepoOptions struct {
 	Keyword         string
 	OwnerID         int64
 	PriorityOwnerID int64
+	TeamID          int64
 	OrderBy         SearchOrderBy
 	Private         bool // Include private repositories in results
 	StarredByID     int64
@@ -294,6 +295,10 @@ func SearchRepositoryCondition(opts *SearchRepoOptions) builder.Cond {
 		cond = cond.And(accessCond)
 	}
 
+	if opts.TeamID > 0 {
+		cond = cond.And(builder.In("`repository`.id", builder.Select("`team_repo`.repo_id").From("team_repo").Where(builder.Eq{"`team_repo`.team_id": opts.TeamID})))
+	}
+
 	if opts.Keyword != "" {
 		// separate keyword
 		var subQueryCond = builder.NewCond()
diff --git a/models/user_heatmap.go b/models/user_heatmap.go
index 425817e6d1..f518249111 100644
--- a/models/user_heatmap.go
+++ b/models/user_heatmap.go
@@ -17,6 +17,15 @@ type UserHeatmapData struct {
 
 // GetUserHeatmapDataByUser returns an array of UserHeatmapData
 func GetUserHeatmapDataByUser(user *User, doer *User) ([]*UserHeatmapData, error) {
+	return getUserHeatmapData(user, nil, doer)
+}
+
+// GetUserHeatmapDataByUserTeam returns an array of UserHeatmapData
+func GetUserHeatmapDataByUserTeam(user *User, team *Team, doer *User) ([]*UserHeatmapData, error) {
+	return getUserHeatmapData(user, team, doer)
+}
+
+func getUserHeatmapData(user *User, team *Team, doer *User) ([]*UserHeatmapData, error) {
 	hdata := make([]*UserHeatmapData, 0)
 
 	if !activityReadable(user, doer) {
@@ -39,6 +48,7 @@ func GetUserHeatmapDataByUser(user *User, doer *User) ([]*UserHeatmapData, error
 
 	cond, err := activityQueryCondition(GetFeedsOptions{
 		RequestedUser:  user,
+		RequestedTeam:  team,
 		Actor:          doer,
 		IncludePrivate: true, // don't filter by private, as we already filter by repo access
 		IncludeDeleted: true,
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 6b772d2392..3aff43c0a8 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -216,6 +216,7 @@ my_mirrors = My Mirrors
 view_home = View %s
 search_repos = Find a repository…
 filter = Other Filters
+filter_by_team_repositories = Filter by team repositories
 
 show_archived = Archived
 show_both_archived_unarchived = Showing both archived and unarchived
diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go
index 048f7d6b1f..f1df31ccac 100644
--- a/routers/api/v1/repo/repo.go
+++ b/routers/api/v1/repo/repo.go
@@ -70,6 +70,11 @@ func Search(ctx *context.APIContext) {
 	//   description: repo owner to prioritize in the results
 	//   type: integer
 	//   format: int64
+	// - name: team_id
+	//   in: query
+	//   description: search only for repos that belong to the given team id
+	//   type: integer
+	//   format: int64
 	// - name: starredBy
 	//   in: query
 	//   description: search only for repos that the user with the given id has starred
@@ -131,6 +136,7 @@ func Search(ctx *context.APIContext) {
 		Keyword:            strings.Trim(ctx.Query("q"), " "),
 		OwnerID:            ctx.QueryInt64("uid"),
 		PriorityOwnerID:    ctx.QueryInt64("priority_owner_id"),
+		TeamID:             ctx.QueryInt64("team_id"),
 		TopicOnly:          ctx.QueryBool("topic"),
 		Collaborate:        util.OptionalBoolNone,
 		Private:            ctx.IsSigned && (ctx.Query("private") == "" || ctx.QueryBool("private")),
diff --git a/routers/routes/macaron.go b/routers/routes/macaron.go
index 16977b9470..019b476e71 100644
--- a/routers/routes/macaron.go
+++ b/routers/routes/macaron.go
@@ -444,13 +444,15 @@ func RegisterMacaronRoutes(m *macaron.Macaron) {
 
 		m.Group("/:org", func() {
 			m.Get("/dashboard", user.Dashboard)
+			m.Get("/dashboard/:team", user.Dashboard)
 			m.Get("/^:type(issues|pulls)$", user.Issues)
+			m.Get("/^:type(issues|pulls)$/:team", user.Issues)
 			m.Get("/milestones", reqMilestonesDashboardPageEnabled, user.Milestones)
+			m.Get("/milestones/:team", reqMilestonesDashboardPageEnabled, user.Milestones)
 			m.Get("/members", org.Members)
 			m.Post("/members/action/:action", org.MembersAction)
-
 			m.Get("/teams", org.Teams)
-		}, context.OrgAssignment(true))
+		}, context.OrgAssignment(true, false, true))
 
 		m.Group("/:org", func() {
 			m.Get("/teams/:team", org.TeamMembers)
diff --git a/routers/user/home.go b/routers/user/home.go
index 92a9138475..27b7f3c29b 100644
--- a/routers/user/home.go
+++ b/routers/user/home.go
@@ -42,17 +42,8 @@ func getDashboardContextUser(ctx *context.Context) *models.User {
 	ctxUser := ctx.User
 	orgName := ctx.Params(":org")
 	if len(orgName) > 0 {
-		// Organization.
-		org, err := models.GetUserByName(orgName)
-		if err != nil {
-			if models.IsErrUserNotExist(err) {
-				ctx.NotFound("GetUserByName", err)
-			} else {
-				ctx.ServerError("GetUserByName", err)
-			}
-			return nil
-		}
-		ctxUser = org
+		ctxUser = ctx.Org.Organization
+		ctx.Data["Teams"] = ctx.Org.Organization.Teams
 	}
 	ctx.Data["ContextUser"] = ctxUser
 
@@ -112,12 +103,13 @@ func Dashboard(ctx *context.Context) {
 	ctx.Data["PageIsDashboard"] = true
 	ctx.Data["PageIsNews"] = true
 	ctx.Data["SearchLimit"] = setting.UI.User.RepoPagingNum
+
 	// no heatmap access for admins; GetUserHeatmapDataByUser ignores the calling user
 	// so everyone would get the same empty heatmap
 	if setting.Service.EnableUserHeatmap && !ctxUser.KeepActivityPrivate {
-		data, err := models.GetUserHeatmapDataByUser(ctxUser, ctx.User)
+		data, err := models.GetUserHeatmapDataByUserTeam(ctxUser, ctx.Org.Team, ctx.User)
 		if err != nil {
-			ctx.ServerError("GetUserHeatmapDataByUser", err)
+			ctx.ServerError("GetUserHeatmapDataByUserTeam", err)
 			return
 		}
 		ctx.Data["HeatmapData"] = data
@@ -126,12 +118,16 @@ func Dashboard(ctx *context.Context) {
 	var err error
 	var mirrors []*models.Repository
 	if ctxUser.IsOrganization() {
-		env, err := ctxUser.AccessibleReposEnv(ctx.User.ID)
-		if err != nil {
-			ctx.ServerError("AccessibleReposEnv", err)
-			return
+		var env models.AccessibleReposEnvironment
+		if ctx.Org.Team != nil {
+			env = ctxUser.AccessibleTeamReposEnv(ctx.Org.Team)
+		} else {
+			env, err = ctxUser.AccessibleReposEnv(ctx.User.ID)
+			if err != nil {
+				ctx.ServerError("AccessibleReposEnv", err)
+				return
+			}
 		}
-
 		mirrors, err = env.MirrorRepos()
 		if err != nil {
 			ctx.ServerError("env.MirrorRepos", err)
@@ -155,6 +151,7 @@ func Dashboard(ctx *context.Context) {
 
 	retrieveFeeds(ctx, models.GetFeedsOptions{
 		RequestedUser:   ctxUser,
+		RequestedTeam:   ctx.Org.Team,
 		Actor:           ctx.User,
 		IncludePrivate:  true,
 		OnlyPerformedBy: false,
@@ -183,16 +180,20 @@ func Milestones(ctx *context.Context) {
 		return
 	}
 
-	var (
-		repoOpts = models.SearchRepoOptions{
-			Actor:         ctxUser,
-			OwnerID:       ctxUser.ID,
-			Private:       true,
-			AllPublic:     false,                 // Include also all public repositories of users and public organisations
-			AllLimited:    false,                 // Include also all public repositories of limited organisations
-			HasMilestones: util.OptionalBoolTrue, // Just needs display repos has milestones
-		}
+	repoOpts := models.SearchRepoOptions{
+		Actor:         ctxUser,
+		OwnerID:       ctxUser.ID,
+		Private:       true,
+		AllPublic:     false,                 // Include also all public repositories of users and public organisations
+		AllLimited:    false,                 // Include also all public repositories of limited organisations
+		HasMilestones: util.OptionalBoolTrue, // Just needs display repos has milestones
+	}
+
+	if ctxUser.IsOrganization() && ctx.Org.Team != nil {
+		repoOpts.TeamID = ctx.Org.Team.ID
+	}
 
+	var (
 		userRepoCond = models.SearchRepositoryCondition(&repoOpts) // all repo condition user could visit
 		repoCond     = userRepoCond
 		repoIDs      []int64
@@ -412,10 +413,15 @@ func Issues(ctx *context.Context) {
 	var err error
 	var userRepoIDs []int64
 	if ctxUser.IsOrganization() {
-		env, err := ctxUser.AccessibleReposEnv(ctx.User.ID)
-		if err != nil {
-			ctx.ServerError("AccessibleReposEnv", err)
-			return
+		var env models.AccessibleReposEnvironment
+		if ctx.Org.Team != nil {
+			env = ctxUser.AccessibleTeamReposEnv(ctx.Org.Team)
+		} else {
+			env, err = ctxUser.AccessibleReposEnv(ctx.User.ID)
+			if err != nil {
+				ctx.ServerError("AccessibleReposEnv", err)
+				return
+			}
 		}
 		userRepoIDs, err = env.RepoIDs(1, ctxUser.NumRepos)
 		if err != nil {
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 72665e2b6d..5de056f3c7 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -2012,6 +2012,13 @@
           {
             "type": "integer",
             "format": "int64",
+            "description": "search only for repos that belong to the given team id",
+            "name": "team_id",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "format": "int64",
             "description": "search only for repos that the user with the given id has starred",
             "name": "starredBy",
             "in": "query"
diff --git a/templates/user/dashboard/navbar.tmpl b/templates/user/dashboard/navbar.tmpl
index 890b192f9a..70eb7cce7f 100644
--- a/templates/user/dashboard/navbar.tmpl
+++ b/templates/user/dashboard/navbar.tmpl
@@ -44,21 +44,51 @@
 
 		{{if .ContextUser.IsOrganization}}
 			<div class="right stackable menu">
-				<a class="{{if .PageIsNews}}active{{end}} item" style="margin-left: auto" href="{{AppSubUrl}}/org/{{.ContextUser.Name}}/dashboard">
+				<div class="item">
+					<div class="ui floating dropdown link jump">
+						<span class="text">
+							{{svg "octicon-people" 18}}
+							{{if .Team}}
+								{{.Team.Name}}
+							{{else}}
+								{{.i18n.Tr "org.teams"}}
+							{{end}}
+							{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+						</span>
+						<div class="context user overflow menu" tabindex="-1">
+							<div class="ui header">
+								{{.i18n.Tr "home.filter_by_team_repositories"}}
+							</div>
+							<div class="scrolling menu items">
+								<a class="{{if not $.Team}}active selected{{end}} item" title="{{.i18n.Tr "all"}}" href="{{AppSubUrl}}/org/{{$.Org.Name}}/{{if $.PageIsIssues}}issues{{else if $.PageIsPulls}}pulls{{else if $.PageIsMilestonesDashboard}}milestones{{else}}dashboard{{end}}">
+									{{.i18n.Tr "all"}}
+								</a>
+								{{range .Org.Teams}}
+									{{if not .IncludesAllRepositories}}
+										<a class="{{if $.Team}}{{if eq $.Team.ID .ID}}active selected{{end}}{{end}} item" title="{{.Name}}" href="{{AppSubUrl}}/org/{{$.Org.Name}}/{{if $.PageIsIssues}}issues{{else if $.PageIsPulls}}pulls{{else if $.PageIsMilestonesDashboard}}milestones{{else}}dashboard{{end}}/{{.Name}}">
+											{{.Name}}
+										</a>
+									{{end}}
+								{{end}}
+							</div>
+						</div>
+					</div>
+				</div>
+				<a class="{{if .PageIsNews}}active{{end}} item" style="margin-left: auto" href="{{AppSubUrl}}/org/{{.ContextUser.Name}}/dashboard{{if .Team}}/{{.Team.Name}}{{end}}">
 					{{svg "octicon-rss"}}&nbsp;{{.i18n.Tr "activities"}}
 				</a>
 				{{if not .UnitIssuesGlobalDisabled}}
-				<a class="{{if .PageIsIssues}}active{{end}} item" href="{{AppSubUrl}}/org/{{.ContextUser.Name}}/issues">
+				<a class="{{if .PageIsIssues}}active{{end}} item" href="{{AppSubUrl}}/org/{{.ContextUser.Name}}/issues{{if .Team}}/{{.Team.Name}}{{end}}">
 					{{svg "octicon-issue-opened"}}&nbsp;{{.i18n.Tr "issues"}}
 				</a>
 				{{end}}
 				{{if not .UnitPullsGlobalDisabled}}
-				<a class="{{if .PageIsPulls}}active{{end}} item" href="{{AppSubUrl}}/org/{{.ContextUser.Name}}/pulls">
+				<a class="{{if .PageIsPulls}}active{{end}} item" href="{{AppSubUrl}}/org/{{.ContextUser.Name}}/pulls{{if .Team}}/{{.Team.Name}}{{end}}">
 					{{svg "octicon-git-pull-request"}}&nbsp;{{.i18n.Tr "pull_requests"}}
 				</a>
 				{{end}}
 				{{if and .ShowMilestonesDashboardPage (not (and .UnitIssuesGlobalDisabled .UnitPullsGlobalDisabled))}}
-				<a class="{{if .PageIsMilestonesDashboard}}active{{end}} item" href="{{AppSubUrl}}/org/{{.ContextUser.Name}}/milestones">
+				<a class="{{if .PageIsMilestonesDashboard}}active{{end}} item" href="{{AppSubUrl}}/org/{{.ContextUser.Name}}/milestones{{if .Team}}/{{.Team.Name}}{{end}}">
 					{{svg "octicon-milestone"}}&nbsp;{{.i18n.Tr "milestones"}}
 				</a>
 				{{end}}
diff --git a/templates/user/dashboard/repolist.tmpl b/templates/user/dashboard/repolist.tmpl
index 005e8756ff..9115c62ecd 100644
--- a/templates/user/dashboard/repolist.tmpl
+++ b/templates/user/dashboard/repolist.tmpl
@@ -3,6 +3,9 @@
 	:search-limit="searchLimit"
 	:suburl="suburl"
 	:uid="uid"
+	{{if .Team}}
+	:team-id="{{.Team.ID}}"
+	{{end}}
 	:more-repos-link="'{{.ContextUser.HomeLink}}'"
 	{{if not .ContextUser.IsOrganization}}
 	:organizations="[
diff --git a/web_src/js/index.js b/web_src/js/index.js
index c3a70d756f..93708e4fdc 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -2755,6 +2755,11 @@ function initVueComponents() {
         type: Number,
         required: true
       },
+      teamId: {
+        type: Number,
+        required: false,
+        default: 0
+      },
       organizations: {
         type: Array,
         default: () => [],
@@ -2853,7 +2858,7 @@ function initVueComponents() {
         return this.repos.length > 0 && this.repos.length < this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`];
       },
       searchURL() {
-        return `${this.suburl}/api/v1/repos/search?sort=updated&order=desc&uid=${this.uid}&q=${this.searchQuery
+        return `${this.suburl}/api/v1/repos/search?sort=updated&order=desc&uid=${this.uid}&team_id=${this.teamId}&q=${this.searchQuery
         }&page=${this.page}&limit=${this.searchLimit}&mode=${this.repoTypes[this.reposFilter].searchMode
         }${this.reposFilter !== 'all' ? '&exclusive=1' : ''
         }${this.archivedFilter === 'archived' ? '&archived=true' : ''}${this.archivedFilter === 'unarchived' ? '&archived=false' : ''
@@ -3034,7 +3039,7 @@ function initVueComponents() {
         this.isLoading = true;
 
         if (!this.reposTotalCount) {
-          const totalCountSearchURL = `${this.suburl}/api/v1/repos/search?sort=updated&order=desc&uid=${this.uid}&q=&page=1&mode=`;
+          const totalCountSearchURL = `${this.suburl}/api/v1/repos/search?sort=updated&order=desc&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`;
           $.getJSON(totalCountSearchURL, (_result, _textStatus, request) => {
             self.reposTotalCount = request.getResponseHeader('X-Total-Count');
           });