Add pagination and search box to org teams list (#37245)

- Add pagination and keyword search to the teams list page
- 5 teams shown at most in the overview page

Fixes: #34482
Fixes: #36602
Fixes: #37084
Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Animesh Kumar <83393501+kmranimesh@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
PineBale
2026-04-17 23:29:11 +08:00
committed by GitHub
parent eb334e3738
commit 18064f772d
13 changed files with 226 additions and 86 deletions

View File

@@ -88,7 +88,7 @@ func SearchTeam(ctx context.Context, opts *SearchTeamOptions) (TeamList, int64,
sess = db.SetSessionPagination(sess, opts)
teams := make([]*Team, 0, opts.PageSize)
count, err := sess.Where(cond).OrderBy("lower_name").FindAndCount(&teams)
count, err := sess.Where(cond).OrderBy("CASE WHEN name=? THEN '' ELSE lower_name END", OwnerTeamName).FindAndCount(&teams)
if err != nil {
return nil, 0, err
}

View File

@@ -2826,7 +2826,7 @@
"org.teams.manage_team_member_prompt": "Members are managed through teams. Add users to a team to invite them to this organization.",
"org.teams.update_settings": "Update Settings",
"org.teams.delete_team": "Delete Team",
"org.teams.add_team_member": "Add Team Member",
"org.teams.add_team_member": "Add team member",
"org.teams.invite_team_member": "Invite to %s",
"org.teams.invite_team_member.list": "Pending Invitations",
"org.teams.delete_team_title": "Delete Team",

View File

@@ -98,8 +98,10 @@ func home(ctx *context.Context, viewRepositories bool) {
ctx.ServerError("FindOrgMembers", err)
return
}
ctx.Data["Members"] = members
ctx.Data["Teams"] = ctx.Org.Teams
const orgOverviewTeamsLimit = 5
ctx.Data["OrgOverviewMembers"] = members
ctx.Data["OrgOverviewTeams"] = ctx.Org.Teams[:min(len(ctx.Org.Teams), orgOverviewTeamsLimit)]
ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull
ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0

View File

@@ -22,6 +22,7 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"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/context"
@@ -54,13 +55,54 @@ func Teams(ctx *context.Context) {
ctx.Data["Title"] = org.FullName
ctx.Data["PageIsOrgTeams"] = true
for _, t := range ctx.Org.Teams {
keyword := ctx.FormTrim("q")
page := max(ctx.FormInt("page"), 1)
pagingNum := setting.UI.MembersPagingNum
searchTeams := func() (teams []*org_model.Team, count int64, err error) {
if keyword == "" {
// fast path, use existing teams in context if no need to filter from database
count = int64(len(ctx.Org.Teams))
start := (page - 1) * pagingNum
if start > len(ctx.Org.Teams) {
return nil, count, nil
}
end := min(start+pagingNum, len(ctx.Org.Teams))
return ctx.Org.Teams[start:end], count, nil
}
shouldSeeAllOrgTeams, err := context.UserShouldSeeAllOrgTeams(ctx)
if err != nil {
return nil, 0, err
}
opts := &org_model.SearchTeamOptions{
OrgID: org.ID,
UserID: util.Iif(shouldSeeAllOrgTeams, 0, ctx.Doer.ID),
Keyword: keyword,
IncludeDesc: true,
ListOptions: db.ListOptions{Page: page, PageSize: pagingNum},
}
return org_model.SearchTeam(ctx, opts)
}
teams, count, err := searchTeams()
if err != nil {
ctx.ServerError("SearchTeam", err)
return
}
for _, t := range teams {
if err := t.LoadMembers(ctx); err != nil {
ctx.ServerError("GetMembers", err)
return
}
}
ctx.Data["Teams"] = ctx.Org.Teams
ctx.Data["OrgListTeams"] = teams
ctx.Data["Keyword"] = keyword
pager := context.NewPagination(count, setting.UI.MembersPagingNum, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplTeams)
}
@@ -213,7 +255,7 @@ func checkIsOrgMemberAndRedirect(ctx *context.Context, defaultRedirect string) {
if isOrgMember, err := org_model.IsOrganizationMember(ctx, ctx.Org.Organization.ID, ctx.Doer.ID); err != nil {
ctx.ServerError("IsOrganizationMember", err)
return
} else if !isOrgMember {
} else if !isOrgMember && !ctx.Doer.IsAdmin {
if ctx.Org.Organization.Visibility.IsPrivate() {
defaultRedirect = setting.AppSubURL + "/"
} else {

View File

@@ -174,23 +174,12 @@ func OrgAssignment(orgAssignmentOpts OrgAssignmentOptions) func(ctx *Context) {
}
// Team.
shouldSeeAllTeams, err := UserShouldSeeAllOrgTeams(ctx)
if err != nil {
ctx.ServerError("UserShouldSeeAllOrgTeams", err)
return
}
if ctx.Org.IsMember {
shouldSeeAllTeams := false
if ctx.Org.IsOwner {
shouldSeeAllTeams = true
} else {
teams, err := org.GetUserTeams(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("GetUserTeams", err)
return
}
for _, team := range teams {
if team.IncludesAllRepositories && team.HasAdminAccess() {
shouldSeeAllTeams = true
break
}
}
}
if shouldSeeAllTeams {
ctx.Org.Teams, err = org.LoadTeams(ctx)
if err != nil {
@@ -255,3 +244,25 @@ func OrgAssignment(orgAssignmentOpts OrgAssignmentOptions) func(ctx *Context) {
}
}
}
// UserShouldSeeAllOrgTeams tells if a user has permission to view all teams in the org.
func UserShouldSeeAllOrgTeams(ctx *Context) (bool, error) {
if !ctx.Org.IsMember {
return false, nil
}
if ctx.Org.IsOwner {
return true, nil
}
teams, err := ctx.Org.Organization.GetUserTeams(ctx, ctx.Doer.ID)
if err != nil {
return false, err
}
for _, team := range teams {
if team.IncludesAllRepositories && team.HasAdminAccess() {
return true, nil
}
}
return false, nil
}

View File

@@ -60,10 +60,9 @@
<a class="tw-text-text-light flex-text-inline" href="{{.OrgLink}}/members"><span>{{.NumMembers}}</span> {{svg "octicon-chevron-right"}}</a>
</h4>
<div class="ui attached segment members">
{{$isMember := .IsOrganizationMember}}
{{range .Members}}
{{if or $isMember (call $.IsPublicMember .ID)}}
<a href="{{.HomeLink}}" title="{{.Name}}{{if .FullName}} ({{.FullName}}){{end}}">{{ctx.AvatarUtils.Avatar . 48}}</a>
{{range $memberUser := .OrgOverviewMembers}}
{{if or $.IsOrganizationMember (call $.IsPublicMember $memberUser.ID)}}
{{template "shared/user/avatarlink" dict "user" $memberUser "size" 32 "tooltip" true}}
{{end}}
{{end}}
</div>
@@ -74,7 +73,7 @@
<a class="tw-text-text-light flex-text-inline" href="{{.OrgLink}}/teams"><span>{{.Org.NumTeams}}</span> {{svg "octicon-chevron-right"}}</a>
</div>
<div class="ui attached table segment teams">
{{range .Teams}}
{{range .OrgOverviewTeams}}
<div class="item">
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}"><strong class="team-name">{{.Name}}</strong></a>
<p class="tw-text-text-light">

View File

@@ -20,7 +20,7 @@
</div>
<div class="field {{if .Err_Description}}error{{end}}">
<label for="description">{{ctx.Locale.Tr "org.team_desc"}}</label>
<input id="description" name="description" value="{{.Team.Description}}">
<input id="description" name="description" value="{{.Team.Description}}" maxlength="255">
<span class="help">{{ctx.Locale.Tr "org.team_desc_helper"}}</span>
</div>
{{if not (eq .Team.LowerName "owners")}}

View File

@@ -1,17 +1,16 @@
<div class="ui six wide column">
<h4 class="ui top attached header">
<h4 class="ui top attached header flex-left-right">
<strong>{{.Team.Name}}</strong>
<div class="ui right">
<div class="flex-center-wrap">
{{if .Team.IsMember ctx $.SignedUser.ID}}
<form>
<button class="ui red tiny button delete-button" data-modal-id="leave-team-sidebar"
data-url="{{.OrgLink}}/teams/{{.Team.LowerName | PathEscape}}/action/leave" data-datauid="{{$.SignedUser.ID}}"
data-name="{{.Team.Name}}">{{ctx.Locale.Tr "org.teams.leave"}}</button>
</form>
<button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team"
data-modal-form.action="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/leave?uid={{$.SignedUser.ID}}"
data-modal-to-leave-team-name="{{$.Team.Name}}"
>{{ctx.Locale.Tr "org.teams.leave"}}</button>
{{else if .IsOrganizationOwner}}
<form method="post" action="{{.OrgLink}}/teams/{{.Team.LowerName | PathEscape}}/action/join">
<input type="hidden" name="page" value="team">
<button type="submit" class="ui primary tiny button" name="uid" value="{{$.SignedUser.ID}}">{{ctx.Locale.Tr "org.teams.join"}}</button>
<button type="submit" class="ui primary mini compact button" name="uid" value="{{$.SignedUser.ID}}">{{ctx.Locale.Tr "org.teams.join"}}</button>
</form>
{{end}}
</div>
@@ -85,12 +84,12 @@
</div>
{{end}}
</div>
<div class="ui g-modal-confirm delete modal" id="leave-team-sidebar">
<div class="ui mini modal" id="org-member-leave-team">
<div class="header">
{{ctx.Locale.Tr "org.teams.leave"}}
</div>
<div class="content">
<p>{{ctx.Locale.Tr "org.teams.leave.detail" (HTMLFormat `<span class="%s"></span>` "name")}}</p>
</div>
{{template "base/modal_actions_confirm" .}}
<form class="content ui form form-fetch-action" method="post">
<p>{{ctx.Locale.Tr "org.teams.leave.detail" (HTMLFormat `<span class="%s"></span>` "to-leave-team-name")}}</p>
{{template "base/modal_actions_confirm" .}}
</form>
</div>

View File

@@ -8,49 +8,59 @@
<div class="tw-flex-1">{{ctx.Locale.Tr "org.teams.manage_team_member_prompt"}}</div>
<a class="ui primary button" href="{{.OrgLink}}/teams/new">{{svg "octicon-plus"}} {{ctx.Locale.Tr "org.create_new_team"}}</a>
</div>
<div class="divider"></div>
{{end}}
<form class="ui form ignore-dirty tw-my-4" method="get" action="{{$.Link}}">
<div class="ui fluid action input">
<input type="search" name="q" value="{{$.Keyword}}" placeholder="{{ctx.Locale.Tr "search.team_kind"}}" maxlength="255" spellcheck="false">
<button class="ui button" type="submit">{{svg "octicon-search"}}</button>
</div>
</form>
<div class="ui two column stackable grid">
{{range .Teams}}
<div class="column">
<div class="ui top attached header">
<a class="tw-text-text" href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}"><strong>{{.Name}}</strong></a>
<div class="ui right">
<a class="ui primary tiny button" href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}">{{ctx.Locale.Tr "view"}}</a>
{{range $team := $.OrgListTeams}}
<div class="column team-item-box">
<div class="ui top attached header muted-links flex-left-right team-item-header">
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}"><strong>{{.Name}}</strong></a>
<div class="flex-center-wrap">
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}">{{.NumMembers}} {{ctx.Locale.Tr "org.lower_members"}}</a>
·
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/repositories">{{.NumRepos}} {{ctx.Locale.Tr "org.lower_repositories"}}</a>
{{if .IsMember ctx $.SignedUser.ID}}
<form>
<button class="ui red tiny button delete-button" data-modal-id="leave-team"
data-url="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/action/leave" data-datauid="{{$.SignedUser.ID}}"
data-name="{{.Name}}">{{ctx.Locale.Tr "org.teams.leave"}}</button>
</form>
{{else if $.IsOrganizationOwner}}
<form method="post" action="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/action/join">
<button type="submit" class="ui primary tiny button" name="uid" value="{{$.SignedUser.ID}}">{{ctx.Locale.Tr "org.teams.join"}}</button>
</form>
<button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team"
data-modal-form.action="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/action/leave?uid={{$.SignedUser.ID}}"
data-modal-to-leave-team-name="{{.Name}}"
>{{ctx.Locale.Tr "org.teams.leave"}}</button>
{{end}}
</div>
</div>
<div class="ui attached segment members">
{{range .Members}}
{{template "shared/user/avatarlink" dict "user" .}}
{{end}}
{{if $team.Description}}
<div class="ui attached header team-item-description">
{{if $team.Description}}{{$team.Description}}{{end}}
</div>
<div class="ui bottom attached header">
<p class="team-meta"><a class="muted" href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}">{{.NumMembers}} {{ctx.Locale.Tr "org.lower_members"}}</a> · <a class="muted" href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/repositories">{{.NumRepos}} {{ctx.Locale.Tr "org.lower_repositories"}}</a></p>
{{end}}
<div class="ui attached segment">
<div class="flex-center-wrap">
{{range .Members}}
{{template "shared/user/avatarlink" dict "user" . "size" 32 "tooltip" true}}
{{else}}
<a class="flex-text-inline tw-h-[32px]" href="{{$.OrgLink}}/teams/{{$team.LowerName | PathEscape}}">{{ctx.Locale.Tr "org.teams.add_team_member"}}</a>
{{end}}
</div>
</div>
</div>
{{end}}
</div>
{{template "base/paginate" .}}
</div>
</div>
<div class="ui g-modal-confirm delete modal" id="leave-team">
<div class="ui mini modal" id="org-member-leave-team">
<div class="header">
{{ctx.Locale.Tr "org.teams.leave"}}
</div>
<div class="content">
<p>{{ctx.Locale.Tr "org.teams.leave.detail" (HTMLFormat `<span class="%s"></span>` "name")}}</p>
</div>
{{template "base/modal_actions_confirm" .}}
<form class="content ui form form-fetch-action" method="post">
<p>{{ctx.Locale.Tr "org.teams.leave.detail" (HTMLFormat `<span class="%s"></span>` "to-leave-team-name")}}</p>
{{template "base/modal_actions_confirm" .}}
</form>
</div>
{{template "base/footer" .}}

View File

@@ -1 +1 @@
<a class="avatar-with-link" {{if gt .user.ID 0}}href="{{.user.HomeLink}}"{{end}}>{{ctx.AvatarUtils.Avatar .user (or .size 20)}}</a>
<a class="avatar-with-link" {{if .tooltip}}data-tooltip-content="{{.user.Name}}{{if .user.FullName}} ({{.user.FullName}}){{end}}"{{end}} {{if gt .user.ID 0}}href="{{.user.HomeLink}}"{{end}}>{{ctx.AvatarUtils.Avatar .user (or .size 20)}}</a>

View File

@@ -16,9 +16,12 @@ import (
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/tests"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -31,6 +34,7 @@ func TestOrg(t *testing.T) {
t.Run("OrgMembers", testOrgMembers)
t.Run("OrgRestrictedUser", testOrgRestrictedUser)
t.Run("TeamSearch", testTeamSearch)
t.Run("TeamsPage", testTeamsPage)
t.Run("OrgSettings", testOrgSettings)
}
@@ -251,6 +255,67 @@ func testTeamSearch(t *testing.T) {
})
}
func testTeamsPage(t *testing.T) {
// org17 has three teams in fixtures: Owners (id 5), test_team (id 8), review_team (id 9).
// user15 is in Owners; user20 is in review_team only; user5 is not a member.
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17})
listTeams := func(t *testing.T, session *TestSession, query string) []string {
req := NewRequestf(t, "GET", "/org/%s/teams%s", org.Name, query)
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
sel := htmlDoc.doc.Find(".ui.top.attached.header strong")
names := make([]string, 0, sel.Length())
sel.Each(func(_ int, s *goquery.Selection) {
names = append(names, s.Text())
})
return names
}
// Owner sees all teams, "Owners" sorted first regardless of alphabetical order
ownerSession := loginUser(t, "user15")
assert.Equal(t, []string{"Owners", "review_team", "test_team"}, listTeams(t, ownerSession, ""))
// Keyword filter narrows by name
assert.Equal(t, []string{"review_team"}, listTeams(t, ownerSession, "?q=review"))
// Non-admin org member sees only the teams they belong to
memberSession := loginUser(t, "user20")
assert.Equal(t, []string{"review_team"}, listTeams(t, memberSession, ""))
// Edit review_team so user20 gets full list
reviewTeam := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 9})
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/org/%s/teams/%s/edit", org.Name, reviewTeam.Name), map[string]string{
"team_name": reviewTeam.Name,
"description": reviewTeam.Description,
"repo_access": "all",
"permission": "admin",
"unit_1": "1",
"unit_2": "1",
"unit_3": "1",
"unit_4": "1",
"unit_5": "1",
"unit_6": "1",
"unit_7": "1",
"unit_8": "1",
"unit_9": "1",
"unit_10": "1",
})
ownerSession.MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, []string{"Owners", "review_team", "test_team"}, listTeams(t, memberSession, ""))
// Non-member is denied
nonMemberSession := loginUser(t, "user5")
req = NewRequestf(t, "GET", "/org/%s/teams", org.Name)
nonMemberSession.MakeRequest(t, req, http.StatusNotFound)
t.Run("Pagination", func(t *testing.T) {
defer test.MockVariableValue(&setting.UI.MembersPagingNum, 2)()
assert.Len(t, listTeams(t, ownerSession, "?page=1"), 2)
assert.Equal(t, []string{"test_team"}, listTeams(t, ownerSession, "?page=2"))
})
}
func testOrgSettings(t *testing.T) {
session := loginUser(t, "user2")

View File

@@ -873,6 +873,25 @@ table th[data-sortt-desc] .svg {
gap: var(--gap-block);
}
/* TODO: use this to replace all existing "flex + justify-between" (there are quite a lot) */
.flex-left-right {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: var(--gap-block);
min-width: 0;
}
/* TODO: use this to replace all existing "flex + wrap" and (there are quite a lot of) */
.flex-center-wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--gap-block);
min-width: 0;
}
.ui.list.flex-items-block > .item,
.ui.vertical.menu.flex-items-block > .item,
.ui.form .field > label.flex-text-block, /* override fomantic "block" style */

View File

@@ -18,24 +18,17 @@
margin-bottom: 10px;
}
.page-content.organization #org-info .meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
.page-content.organization .team-item-box > .team-item-header {
min-height: 50px; /* the header sometimes contains a mini button, sometimes not, so we set a min-height to make sure the layout is consistent */
}
.page-content.organization .ui.top.header .ui.right {
margin-top: 0;
}
.page-content.organization .teams .item {
padding: 10px 15px;
}
.page-content.organization .members .ui.avatar {
margin-right: 5px;
margin-bottom: 5px;
.page-content.organization .team-item-box .team-item-description {
padding-top: 0.5em;
padding-bottom: 0.5em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-light-3);
}
.organization.invite #invite-box {