Team dashboards (#14159)

This commit is contained in:
Jimmy Praet 2020-12-27 20:58:03 +01:00 committed by GitHub
parent 25f8970b2c
commit 40274b4a93
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 148 additions and 47 deletions

View file

@ -289,6 +289,7 @@ func (a *Action) GetIssueContent() string {
// GetFeedsOptions options for retrieving feeds // GetFeedsOptions options for retrieving feeds
type GetFeedsOptions struct { type GetFeedsOptions struct {
RequestedUser *User // the user we want activity for RequestedUser *User // the user we want activity for
RequestedTeam *Team // the team we want activity for
Actor *User // the user viewing the activity Actor *User // the user viewing the activity
IncludePrivate bool // include private actions IncludePrivate bool // include private actions
OnlyPerformedBy bool // only actions performed by requested user 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}) cond = cond.And(builder.Eq{"user_id": opts.RequestedUser.ID})
if opts.OnlyPerformedBy { if opts.OnlyPerformedBy {

View file

@ -746,6 +746,7 @@ type AccessibleReposEnvironment interface {
type accessibleReposEnv struct { type accessibleReposEnv struct {
org *User org *User
user *User user *User
team *Team
teamIDs []int64 teamIDs []int64
e Engine e Engine
keyword string keyword string
@ -782,8 +783,22 @@ func (org *User) accessibleReposEnv(e Engine, userID int64) (AccessibleReposEnvi
}, nil }, 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 { func (env *accessibleReposEnv) cond() builder.Cond {
var cond = builder.NewCond() var cond = builder.NewCond()
if env.team != nil {
cond = cond.And(builder.Eq{"team_repo.team_id": env.team.ID})
} else {
if env.user == nil || !env.user.IsRestricted { if env.user == nil || !env.user.IsRestricted {
cond = cond.Or(builder.Eq{ cond = cond.Or(builder.Eq{
"`repository`.owner_id": env.org.ID, "`repository`.owner_id": env.org.ID,
@ -793,6 +808,7 @@ func (env *accessibleReposEnv) cond() builder.Cond {
if len(env.teamIDs) > 0 { if len(env.teamIDs) > 0 {
cond = cond.Or(builder.In("team_repo.team_id", env.teamIDs)) cond = cond.Or(builder.In("team_repo.team_id", env.teamIDs))
} }
}
if env.keyword != "" { if env.keyword != "" {
cond = cond.And(builder.Like{"`repository`.lower_name", strings.ToLower(env.keyword)}) cond = cond.And(builder.Like{"`repository`.lower_name", strings.ToLower(env.keyword)})
} }

View file

@ -138,6 +138,7 @@ type SearchRepoOptions struct {
Keyword string Keyword string
OwnerID int64 OwnerID int64
PriorityOwnerID int64 PriorityOwnerID int64
TeamID int64
OrderBy SearchOrderBy OrderBy SearchOrderBy
Private bool // Include private repositories in results Private bool // Include private repositories in results
StarredByID int64 StarredByID int64
@ -294,6 +295,10 @@ func SearchRepositoryCondition(opts *SearchRepoOptions) builder.Cond {
cond = cond.And(accessCond) 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 != "" { if opts.Keyword != "" {
// separate keyword // separate keyword
var subQueryCond = builder.NewCond() var subQueryCond = builder.NewCond()

View file

@ -17,6 +17,15 @@ type UserHeatmapData struct {
// GetUserHeatmapDataByUser returns an array of UserHeatmapData // GetUserHeatmapDataByUser returns an array of UserHeatmapData
func GetUserHeatmapDataByUser(user *User, doer *User) ([]*UserHeatmapData, error) { 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) hdata := make([]*UserHeatmapData, 0)
if !activityReadable(user, doer) { if !activityReadable(user, doer) {
@ -39,6 +48,7 @@ func GetUserHeatmapDataByUser(user *User, doer *User) ([]*UserHeatmapData, error
cond, err := activityQueryCondition(GetFeedsOptions{ cond, err := activityQueryCondition(GetFeedsOptions{
RequestedUser: user, RequestedUser: user,
RequestedTeam: team,
Actor: doer, Actor: doer,
IncludePrivate: true, // don't filter by private, as we already filter by repo access IncludePrivate: true, // don't filter by private, as we already filter by repo access
IncludeDeleted: true, IncludeDeleted: true,

View file

@ -216,6 +216,7 @@ my_mirrors = My Mirrors
view_home = View %s view_home = View %s
search_repos = Find a repository… search_repos = Find a repository…
filter = Other Filters filter = Other Filters
filter_by_team_repositories = Filter by team repositories
show_archived = Archived show_archived = Archived
show_both_archived_unarchived = Showing both archived and unarchived show_both_archived_unarchived = Showing both archived and unarchived

View file

@ -70,6 +70,11 @@ func Search(ctx *context.APIContext) {
// description: repo owner to prioritize in the results // description: repo owner to prioritize in the results
// type: integer // type: integer
// format: int64 // 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 // - name: starredBy
// in: query // in: query
// description: search only for repos that the user with the given id has starred // 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"), " "), Keyword: strings.Trim(ctx.Query("q"), " "),
OwnerID: ctx.QueryInt64("uid"), OwnerID: ctx.QueryInt64("uid"),
PriorityOwnerID: ctx.QueryInt64("priority_owner_id"), PriorityOwnerID: ctx.QueryInt64("priority_owner_id"),
TeamID: ctx.QueryInt64("team_id"),
TopicOnly: ctx.QueryBool("topic"), TopicOnly: ctx.QueryBool("topic"),
Collaborate: util.OptionalBoolNone, Collaborate: util.OptionalBoolNone,
Private: ctx.IsSigned && (ctx.Query("private") == "" || ctx.QueryBool("private")), Private: ctx.IsSigned && (ctx.Query("private") == "" || ctx.QueryBool("private")),

View file

@ -444,13 +444,15 @@ func RegisterMacaronRoutes(m *macaron.Macaron) {
m.Group("/:org", func() { m.Group("/:org", func() {
m.Get("/dashboard", user.Dashboard) m.Get("/dashboard", user.Dashboard)
m.Get("/dashboard/:team", user.Dashboard)
m.Get("/^:type(issues|pulls)$", user.Issues) m.Get("/^:type(issues|pulls)$", user.Issues)
m.Get("/^:type(issues|pulls)$/:team", user.Issues)
m.Get("/milestones", reqMilestonesDashboardPageEnabled, user.Milestones) m.Get("/milestones", reqMilestonesDashboardPageEnabled, user.Milestones)
m.Get("/milestones/:team", reqMilestonesDashboardPageEnabled, user.Milestones)
m.Get("/members", org.Members) m.Get("/members", org.Members)
m.Post("/members/action/:action", org.MembersAction) m.Post("/members/action/:action", org.MembersAction)
m.Get("/teams", org.Teams) m.Get("/teams", org.Teams)
}, context.OrgAssignment(true)) }, context.OrgAssignment(true, false, true))
m.Group("/:org", func() { m.Group("/:org", func() {
m.Get("/teams/:team", org.TeamMembers) m.Get("/teams/:team", org.TeamMembers)

View file

@ -42,17 +42,8 @@ func getDashboardContextUser(ctx *context.Context) *models.User {
ctxUser := ctx.User ctxUser := ctx.User
orgName := ctx.Params(":org") orgName := ctx.Params(":org")
if len(orgName) > 0 { if len(orgName) > 0 {
// Organization. ctxUser = ctx.Org.Organization
org, err := models.GetUserByName(orgName) ctx.Data["Teams"] = ctx.Org.Organization.Teams
if err != nil {
if models.IsErrUserNotExist(err) {
ctx.NotFound("GetUserByName", err)
} else {
ctx.ServerError("GetUserByName", err)
}
return nil
}
ctxUser = org
} }
ctx.Data["ContextUser"] = ctxUser ctx.Data["ContextUser"] = ctxUser
@ -112,12 +103,13 @@ func Dashboard(ctx *context.Context) {
ctx.Data["PageIsDashboard"] = true ctx.Data["PageIsDashboard"] = true
ctx.Data["PageIsNews"] = true ctx.Data["PageIsNews"] = true
ctx.Data["SearchLimit"] = setting.UI.User.RepoPagingNum ctx.Data["SearchLimit"] = setting.UI.User.RepoPagingNum
// no heatmap access for admins; GetUserHeatmapDataByUser ignores the calling user // no heatmap access for admins; GetUserHeatmapDataByUser ignores the calling user
// so everyone would get the same empty heatmap // so everyone would get the same empty heatmap
if setting.Service.EnableUserHeatmap && !ctxUser.KeepActivityPrivate { 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 { if err != nil {
ctx.ServerError("GetUserHeatmapDataByUser", err) ctx.ServerError("GetUserHeatmapDataByUserTeam", err)
return return
} }
ctx.Data["HeatmapData"] = data ctx.Data["HeatmapData"] = data
@ -126,12 +118,16 @@ func Dashboard(ctx *context.Context) {
var err error var err error
var mirrors []*models.Repository var mirrors []*models.Repository
if ctxUser.IsOrganization() { if ctxUser.IsOrganization() {
env, err := ctxUser.AccessibleReposEnv(ctx.User.ID) 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 { if err != nil {
ctx.ServerError("AccessibleReposEnv", err) ctx.ServerError("AccessibleReposEnv", err)
return return
} }
}
mirrors, err = env.MirrorRepos() mirrors, err = env.MirrorRepos()
if err != nil { if err != nil {
ctx.ServerError("env.MirrorRepos", err) ctx.ServerError("env.MirrorRepos", err)
@ -155,6 +151,7 @@ func Dashboard(ctx *context.Context) {
retrieveFeeds(ctx, models.GetFeedsOptions{ retrieveFeeds(ctx, models.GetFeedsOptions{
RequestedUser: ctxUser, RequestedUser: ctxUser,
RequestedTeam: ctx.Org.Team,
Actor: ctx.User, Actor: ctx.User,
IncludePrivate: true, IncludePrivate: true,
OnlyPerformedBy: false, OnlyPerformedBy: false,
@ -183,8 +180,7 @@ func Milestones(ctx *context.Context) {
return return
} }
var ( repoOpts := models.SearchRepoOptions{
repoOpts = models.SearchRepoOptions{
Actor: ctxUser, Actor: ctxUser,
OwnerID: ctxUser.ID, OwnerID: ctxUser.ID,
Private: true, Private: true,
@ -193,6 +189,11 @@ func Milestones(ctx *context.Context) {
HasMilestones: util.OptionalBoolTrue, // Just needs display repos has milestones 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 userRepoCond = models.SearchRepositoryCondition(&repoOpts) // all repo condition user could visit
repoCond = userRepoCond repoCond = userRepoCond
repoIDs []int64 repoIDs []int64
@ -412,11 +413,16 @@ func Issues(ctx *context.Context) {
var err error var err error
var userRepoIDs []int64 var userRepoIDs []int64
if ctxUser.IsOrganization() { if ctxUser.IsOrganization() {
env, err := ctxUser.AccessibleReposEnv(ctx.User.ID) 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 { if err != nil {
ctx.ServerError("AccessibleReposEnv", err) ctx.ServerError("AccessibleReposEnv", err)
return return
} }
}
userRepoIDs, err = env.RepoIDs(1, ctxUser.NumRepos) userRepoIDs, err = env.RepoIDs(1, ctxUser.NumRepos)
if err != nil { if err != nil {
ctx.ServerError("env.RepoIDs", err) ctx.ServerError("env.RepoIDs", err)

View file

@ -2009,6 +2009,13 @@
"name": "priority_owner_id", "name": "priority_owner_id",
"in": "query" "in": "query"
}, },
{
"type": "integer",
"format": "int64",
"description": "search only for repos that belong to the given team id",
"name": "team_id",
"in": "query"
},
{ {
"type": "integer", "type": "integer",
"format": "int64", "format": "int64",

View file

@ -44,21 +44,51 @@
{{if .ContextUser.IsOrganization}} {{if .ContextUser.IsOrganization}}
<div class="right stackable menu"> <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"}} {{svg "octicon-rss"}}&nbsp;{{.i18n.Tr "activities"}}
</a> </a>
{{if not .UnitIssuesGlobalDisabled}} {{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"}} {{svg "octicon-issue-opened"}}&nbsp;{{.i18n.Tr "issues"}}
</a> </a>
{{end}} {{end}}
{{if not .UnitPullsGlobalDisabled}} {{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"}} {{svg "octicon-git-pull-request"}}&nbsp;{{.i18n.Tr "pull_requests"}}
</a> </a>
{{end}} {{end}}
{{if and .ShowMilestonesDashboardPage (not (and .UnitIssuesGlobalDisabled .UnitPullsGlobalDisabled))}} {{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"}} {{svg "octicon-milestone"}}&nbsp;{{.i18n.Tr "milestones"}}
</a> </a>
{{end}} {{end}}

View file

@ -3,6 +3,9 @@
:search-limit="searchLimit" :search-limit="searchLimit"
:suburl="suburl" :suburl="suburl"
:uid="uid" :uid="uid"
{{if .Team}}
:team-id="{{.Team.ID}}"
{{end}}
:more-repos-link="'{{.ContextUser.HomeLink}}'" :more-repos-link="'{{.ContextUser.HomeLink}}'"
{{if not .ContextUser.IsOrganization}} {{if not .ContextUser.IsOrganization}}
:organizations="[ :organizations="[

View file

@ -2755,6 +2755,11 @@ function initVueComponents() {
type: Number, type: Number,
required: true required: true
}, },
teamId: {
type: Number,
required: false,
default: 0
},
organizations: { organizations: {
type: Array, type: Array,
default: () => [], default: () => [],
@ -2853,7 +2858,7 @@ function initVueComponents() {
return this.repos.length > 0 && this.repos.length < this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`]; return this.repos.length > 0 && this.repos.length < this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`];
}, },
searchURL() { 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 }&page=${this.page}&limit=${this.searchLimit}&mode=${this.repoTypes[this.reposFilter].searchMode
}${this.reposFilter !== 'all' ? '&exclusive=1' : '' }${this.reposFilter !== 'all' ? '&exclusive=1' : ''
}${this.archivedFilter === 'archived' ? '&archived=true' : ''}${this.archivedFilter === 'unarchived' ? '&archived=false' : '' }${this.archivedFilter === 'archived' ? '&archived=true' : ''}${this.archivedFilter === 'unarchived' ? '&archived=false' : ''
@ -3034,7 +3039,7 @@ function initVueComponents() {
this.isLoading = true; this.isLoading = true;
if (!this.reposTotalCount) { 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) => { $.getJSON(totalCountSearchURL, (_result, _textStatus, request) => {
self.reposTotalCount = request.getResponseHeader('X-Total-Count'); self.reposTotalCount = request.getResponseHeader('X-Total-Count');
}); });