2
0

feat(explore): add organization grouping on explore page

Adds optional group_header field to organizations for categorizing them on the explore page (e.g., "Enterprise", "Community", "Partners"). Includes database migration, organization settings form field, and grouped display template. Groups are sorted alphabetically with ungrouped organizations shown last. Users can toggle grouping view with show_groups parameter.
This commit is contained in:
2026-01-18 13:08:30 -05:00
parent 678439836e
commit 07738be978
12 changed files with 145 additions and 0 deletions

View File

@@ -408,6 +408,7 @@ func prepareMigrationTasks() []*migration {
newMigration(331, "Add is_homepage_pinned to user table", v1_26.AddIsHomepagePinnedToUser),
newMigration(332, "Add display_title and license_type to repository", v1_26.AddDisplayTitleAndLicenseTypeToRepository),
newMigration(333, "Add group_header to repository", v1_26.AddGroupHeaderToRepository),
newMigration(334, "Add group_header to user for organization grouping", v1_26.AddGroupHeaderToUser),
}
return preparedMigrations
}

View File

@@ -0,0 +1,17 @@
// Copyright 2026 The Gitea Authors and MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"xorm.io/xorm"
)
// AddGroupHeaderToUser adds group_header column to the user table for organization grouping
func AddGroupHeaderToUser(x *xorm.Engine) error {
type User struct {
GroupHeader string `xorm:"VARCHAR(255)"`
}
return x.Sync(new(User))
}

View File

@@ -147,6 +147,7 @@ type User struct {
NumMembers int
Visibility structs.VisibleType `xorm:"NOT NULL DEFAULT 0"`
RepoAdminChangeTeamAccess bool `xorm:"NOT NULL DEFAULT false"`
GroupHeader string `xorm:"VARCHAR(255)"`
// Preferences
DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"`

View File

@@ -367,6 +367,7 @@
"explore.code_last_indexed_at": "Last indexed %s",
"explore.relevant_repositories_tooltip": "Repositories that are forks or that have no topic, no icon, and no description are hidden.",
"explore.relevant_repositories": "Only relevant repositories are being shown, <a href=\"%s\">show unfiltered results</a>.",
"explore.orgs.show_groups": "Show Groups",
"auth.create_new_account": "Register Account",
"auth.already_have_account": "Already have an account?",
"auth.sign_in_now": "Sign in now!",
@@ -2766,6 +2767,9 @@
"org.settings.email": "Contact Email Address",
"org.settings.website": "Website",
"org.settings.location": "Location",
"org.settings.group_header": "Group Header",
"org.settings.group_header_placeholder": "e.g., Enterprise, Community, Partners",
"org.settings.group_header_help": "Optional header for grouping this organization on the explore page",
"org.settings.permission": "Permissions",
"org.settings.repoadminchangeteam": "Repository admin can add and remove access for teams",
"org.settings.visibility": "Visibility",

View File

@@ -26,6 +26,10 @@ func Organizations(ctx *context.Context) {
ctx.Data["PageIsExploreOrganizations"] = true
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
// Set up grouping before RenderUserSearch (grouping logic is in RenderUserSearch)
showGrouping := ctx.FormString("show_groups") != "0" // default to true
ctx.Data["ShowGrouping"] = showGrouping
visibleTypes := []structs.VisibleType{structs.VisibleTypePublic}
if ctx.Doer != nil {
visibleTypes = append(visibleTypes, structs.VisibleTypeLimited, structs.VisibleTypePrivate)

View File

@@ -6,6 +6,7 @@ package explore
import (
"bytes"
"net/http"
"sort"
"code.gitcaddy.com/server/v3/models/db"
user_model "code.gitcaddy.com/server/v3/models/user"
@@ -119,6 +120,36 @@ func RenderUserSearch(ctx *context.Context, opts user_model.SearchUserOptions, t
ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
// Group organizations by GroupHeader if grouping is enabled (for org explore page)
if ctx.Data["PageIsExploreOrganizations"] == true && ctx.Data["ShowGrouping"] == true {
groupedOrgs := make(map[string][]*user_model.User)
var headers []string
headerSeen := make(map[string]bool)
for _, user := range users {
header := user.GroupHeader
if !headerSeen[header] {
headerSeen[header] = true
headers = append(headers, header)
}
groupedOrgs[header] = append(groupedOrgs[header], user)
}
// Sort headers alphabetically, empty string last
sort.Slice(headers, func(i, j int) bool {
if headers[i] == "" {
return false
}
if headers[j] == "" {
return true
}
return headers[i] < headers[j]
})
ctx.Data["GroupedOrgs"] = groupedOrgs
ctx.Data["OrgHeaders"] = headers
}
pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager

View File

@@ -84,6 +84,7 @@ func SettingsPost(ctx *context.Context) {
Description: optional.Some(form.Description),
Website: optional.Some(form.Website),
Location: optional.Some(form.Location),
GroupHeader: optional.Some(form.GroupHeader),
RepoAdminChangeTeamAccess: optional.Some(form.RepoAdminChangeTeamAccess),
}
if ctx.Doer.IsAdmin {

View File

@@ -42,6 +42,7 @@ type UpdateOrgSettingForm struct {
Description string `binding:"MaxSize(255)"`
Website string `binding:"ValidUrl;MaxSize(255)"`
Location string `binding:"MaxSize(50)"`
GroupHeader string `binding:"MaxSize(255)"`
MaxRepoCreation int
RepoAdminChangeTeamAccess bool
IsHomepagePinned bool

View File

@@ -41,6 +41,7 @@ type UpdateOptions struct {
Website optional.Option[string]
Location optional.Option[string]
Description optional.Option[string]
GroupHeader optional.Option[string]
AllowGitHook optional.Option[bool]
AllowImportLocal optional.Option[bool]
MaxRepoCreation optional.Option[int]
@@ -88,6 +89,11 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er
cols = append(cols, "description")
}
if opts.GroupHeader.Has() {
u.GroupHeader = opts.GroupHeader.Value()
cols = append(cols, "group_header")
}
if opts.Language.Has() {
u.Language = opts.Language.Value()

View File

@@ -0,0 +1,61 @@
{{if and (eq (.SystemConfig.Theme.ExploreOrgDisplayFormat.Value ctx) "tiles")}}
{{/* Tile Cards View for Organizations - Grouped */}}
{{range .OrgHeaders}}
{{if .}}
<h4 class="ui dividing header tw-mt-4">{{.}}</h4>
{{end}}
{{$orgs := index $.GroupedOrgs .}}
<div class="ui four doubling stackable cards">
{{range $orgs}}
<a class="ui card" href="{{.HomeLink}}">
<div class="content tw-text-center">
<div class="tw-mb-3">
{{ctx.AvatarUtils.Avatar . 64 "tw-rounded"}}
</div>
<div class="header">{{.DisplayName}}</div>
{{if .Description}}
<div class="meta tw-mt-2">{{.Description}}</div>
{{end}}
</div>
</a>
{{end}}
</div>
{{end}}
{{else}}
{{/* Default List View - Grouped */}}
{{range .OrgHeaders}}
{{if .}}
<h4 class="ui dividing header tw-mt-4">{{.}}</h4>
{{end}}
{{$orgs := index $.GroupedOrgs .}}
<div class="flex-list">
{{range $orgs}}
<div class="flex-item tw-items-center">
<div class="flex-item-leading">
{{ctx.AvatarUtils.Avatar . 48}}
</div>
<div class="flex-item-main">
<div class="flex-item-title">
<a class="text muted" href="{{.HomeLink}}">{{.DisplayName}}</a>
{{if .Visibility.IsPrivate}}
<span class="ui basic tiny label">{{ctx.Locale.Tr "repo.desc.private"}}</span>
{{end}}
</div>
<div class="flex-item-body">
{{if .Location}}
<span class="flex-text-inline">{{svg "octicon-location"}}{{.Location}}</span>
{{end}}
{{if and .Email (or (and $.ShowUserEmail $.IsSigned (not .KeepEmailPrivate)) $.PageIsAdminUsers)}}
<span class="flex-text-inline">
{{svg "octicon-mail"}}
<a href="mailto:{{.Email}}">{{.Email}}</a>
</span>
{{end}}
<span class="flex-text-inline">{{svg "octicon-calendar"}}{{ctx.Locale.Tr "user.joined_on" (DateUtils.AbsoluteShort .CreatedUnix)}}</span>
</div>
</div>
</div>
{{end}}
</div>
{{end}}
{{end}}

View File

@@ -3,7 +3,20 @@
{{template "explore/navbar" .}}
<div class="ui container">
{{template "explore/search" .}}
{{if .PageIsExploreOrganizations}}
<div class="tw-flex tw-items-center tw-justify-end tw-mb-4">
<label class="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer">
<input type="checkbox" id="show-grouping" {{if .ShowGrouping}}checked{{end}} onchange="window.location.href='?show_groups=' + (this.checked ? '1' : '0') + '&q={{.Keyword}}&sort={{.SortType}}'">
<span>{{ctx.Locale.Tr "explore.orgs.show_groups"}}</span>
</label>
</div>
{{end}}
{{if and .PageIsExploreOrganizations .ShowGrouping .OrgHeaders}}
{{/* Grouped View for Organizations */}}
{{template "explore/org_list_grouped" .}}
{{else}}
{{template "explore/user_list" .}}
{{end}}
{{template "base/paginate" .}}
</div>
</div>

View File

@@ -27,6 +27,11 @@
<label for="location">{{ctx.Locale.Tr "org.settings.location"}}</label>
<input id="location" name="location" value="{{.Org.Location}}" maxlength="50">
</div>
<div class="field">
<label for="group_header">{{ctx.Locale.Tr "org.settings.group_header"}}</label>
<input id="group_header" name="group_header" value="{{.Org.GroupHeader}}" maxlength="255" placeholder="{{ctx.Locale.Tr "org.settings.group_header_placeholder"}}">
<p class="help">{{ctx.Locale.Tr "org.settings.group_header_help"}}</p>
</div>
<div class="field" id="permission_box">
<label>{{ctx.Locale.Tr "org.settings.permission"}}</label>