enh(issue-search): support filtering by project in user/org listing (#12596)

Support filtering `/issue` & `/pulls` (and corresponding org paths) by a project ID.

Closes #12559

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12596
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
Shiny Nematoda 2026-05-17 12:00:25 +02:00 committed by Gusted
parent db5b475416
commit 4ecb25a549
6 changed files with 129 additions and 10 deletions

View file

@ -78,6 +78,6 @@
is_closed: false
creator_id: 2
board_type: 1
type: 1
type: 3
created_unix: 1688973000
updated_unix: 1688973000

View file

@ -84,3 +84,13 @@
sorting: 1
created_unix: 1588117528
updated_unix: 1588117528
-
id: 10
project_id: 7
title: Default
creator_id: 2
default: true
sorting: 1
created_unix: 1588117528
updated_unix: 1588117528

View file

@ -25,3 +25,24 @@
project_id: 1
project_board_id: 3
sorting: 0
-
id: 5
issue_id: 16
project_id: 4
project_board_id: 4
sorting: 0
-
id: 6
issue_id: 15
project_id: 7
project_board_id: 10
sorting: 0
-
id: 7
issue_id: 17
project_id: 7
project_board_id: 10
sorting: 1

View file

@ -18,6 +18,7 @@ import (
"forgejo.org/models/db"
issues_model "forgejo.org/models/issues"
"forgejo.org/models/organization"
project_model "forgejo.org/models/project"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unit"
user_model "forgejo.org/models/user"
@ -538,6 +539,37 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
PageSize: setting.UI.IssuePagingNum,
}
// Projects
//
// We do not consider listing projects for the individual repos.
// Instead, we only support listing the projects under the scope of an individual/organisation.
// However, this limitation does not affect filtering with a project ID.
{
projOpts := project_model.SearchOptions{
ListOptions: db.ListOptionsAll,
OwnerID: ctxUser.ID,
IsClosed: optional.None[bool](),
Type: project_model.TypeIndividual,
}
if org != nil {
projOpts.OwnerID = org.ID
projOpts.Type = project_model.TypeOrganization
}
projects, err := db.Find[project_model.Project](ctx, projOpts)
if err != nil {
ctx.ServerError("GetProjects", err)
return
}
if projectID := ctx.FormInt64("project"); projectID != 0 {
opts.ProjectID = projectID
ctx.Data["ProjectID"] = projectID
}
ctx.Data["Projects"] = projects
}
// Get IDs for labels (a filter option for issues/pulls).
// Required for IssuesOptions.
selectedLabels := ctx.FormString("labels")
@ -688,6 +720,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
pager.AddParam(ctx, "labels", "SelectLabels")
pager.AddParam(ctx, "milestone", "MilestoneID")
pager.AddParam(ctx, "assignee", "AssigneeID")
pager.AddParam(ctx, "project", "ProjectID")
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplIssues)

View file

@ -6,11 +6,11 @@
<div class="list-header">
<div class="switch list-header-toggle">
{{$keyword := StringUtils.RemoveAll $.Keyword "is:open" "-is:open" "is:closed" "-is:closed" "is:all"}}
<a class="item{{if not .IsShowClosed}} active{{end}}" href="?type={{$.ViewType}}&sort={{$.SortType}}&state=open&labels={{.SelectLabels}}&q={{$keyword}}">
<a class="item{{if not .IsShowClosed}} active{{end}}" href="?type={{$.ViewType}}&sort={{$.SortType}}&project={{$.ProjectID}}&state=open&labels={{.SelectLabels}}&q={{$keyword}}">
{{svg "octicon-issue-opened" 16}}
{{ctx.Locale.PrettyNumber .IssueStats.OpenCount}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
</a>
<a class="item{{if .IsShowClosed}} active{{end}}" href="?type={{$.ViewType}}&sort={{$.SortType}}&state=closed&labels={{.SelectLabels}}&q={{$keyword}}">
<a class="item{{if .IsShowClosed}} active{{end}}" href="?type={{$.ViewType}}&sort={{$.SortType}}&project={{$.ProjectID}}&state=closed&labels={{.SelectLabels}}&q={{$keyword}}">
{{svg "octicon-issue-closed" 16}}
{{ctx.Locale.PrettyNumber .IssueStats.ClosedCount}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
</a>
@ -19,6 +19,7 @@
<div class="ui search fluid action input">
<input type="hidden" name="type" value="{{$.ViewType}}">
<input type="hidden" name="sort" value="{{$.SortType}}">
<input type="hidden" name="project" value={{$.ProjectID}}>
<input type="hidden" name="state" value="{{$.State}}">
{{$placeholder := ctx.Locale.Tr "search.issue_kind"}}
{{if .PageIsPulls}}
@ -37,6 +38,26 @@
{{if .PageIsOrgIssues}}
{{template "shared/label_filter" .}}
{{end}}
<div class="list-header ui dropdown type jump item" data-test-tag="filter-project">
<span class="text tw-whitespace-nowrap">
{{ctx.Locale.Tr "repo.issues.filter_project"}}
</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="ui menu">
<a class="item" rel="nofollow" href="?type={{$.SortType}}&sort={{$.SortType}}&state={{$.State}}&q={{$.Keyword}}">
{{ctx.Locale.Tr "repo.issues.filter_project_all"}}
</a>
<a class="item" rel="nofollow" href="?type={{$.SortType}}&sort={{$.SortType}}&project=-1&state={{$.State}}&q={{$.Keyword}}">
{{ctx.Locale.Tr "repo.issues.filter_project_none"}}
</a>
<div class="divider"></div>
{{range $project := $.Projects}}
<a class="{{if eq $.ProjectID $project.ID}}active {{end}}item" rel="nofollow" href="?type={{$.SortType}}&sort={{$.SortType}}&project={{$project.ID}}&state={{$.State}}&q={{$.Keyword}}">
{{svg $project.IconName 16 "tw-mr-2 tw-shrink-0"}}{{$project.Title}}
</a>
{{end}}
</div>
</div>
<!-- Type -->
<div class="list-header ui dropdown type jump item">
<span class="text tw-whitespace-nowrap">
@ -44,29 +65,29 @@
</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="ui menu">
<a class="{{if eq .ViewType "created_by"}}active{{end}} item" href="?type=created_by&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
<a class="{{if eq .ViewType "created_by"}}active{{end}} item" href="?type=created_by&sort={{$.SortType}}&project={{$.ProjectID}}&state={{.State}}&q={{$.Keyword}}">
<div class="ui circular mini label tw-ml-0">{{CountFmt .IssueStats.CreateCount}}</div>
{{ctx.Locale.Tr "repo.issues.filter_type.created_by_you"}}
</a>
<a class="{{if eq .ViewType "your_repositories"}}active{{end}} item" href="?type=your_repositories&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
<a class="{{if eq .ViewType "your_repositories"}}active{{end}} item" href="?type=your_repositories&sort={{$.SortType}}&project={{$.ProjectID}}&state={{.State}}&q={{$.Keyword}}">
<div class="ui circular mini label tw-ml-0">{{CountFmt .IssueStats.YourRepositoriesCount}}</div>
{{ctx.Locale.Tr "home.issues.in_your_repos"}}
</a>
<a class="{{if eq .ViewType "assigned"}}active{{end}} item" href="?type=assigned&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
<a class="{{if eq .ViewType "assigned"}}active{{end}} item" href="?type=assigned&sort={{$.SortType}}&project={{$.ProjectID}}&state={{.State}}&q={{$.Keyword}}">
<div class="ui circular mini label tw-ml-0">{{CountFmt .IssueStats.AssignCount}}</div>
{{ctx.Locale.Tr "repo.issues.filter_type.assigned_to_you"}}
</a>
{{if .PageIsPulls}}
<a class="{{if eq .ViewType "review_requested"}}active{{end}} item" href="?type=review_requested&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
<a class="{{if eq .ViewType "review_requested"}}active{{end}} item" href="?type=review_requested&sort={{$.SortType}}&project={{$.ProjectID}}&state={{.State}}&q={{$.Keyword}}">
<div class="ui circular mini label tw-ml-0">{{CountFmt .IssueStats.ReviewRequestedCount}}</div>
{{ctx.Locale.Tr "repo.issues.filter_type.review_requested"}}
</a>
<a class="{{if eq .ViewType "reviewed_by"}}active{{end}} item" href="?type=reviewed_by&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
<a class="{{if eq .ViewType "reviewed_by"}}active{{end}} item" href="?type=reviewed_by&sort={{$.SortType}}&project={{$.ProjectID}}&state={{.State}}&q={{$.Keyword}}">
<div class="ui circular mini label tw-ml-0">{{CountFmt .IssueStats.ReviewedCount}}</div>
{{ctx.Locale.Tr "repo.issues.filter_type.reviewed_by_you"}}
</a>
{{end}}
<a class="{{if eq .ViewType "mentioned"}}active{{end}} item" href="?type=mentioned&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
<a class="{{if eq .ViewType "mentioned"}}active{{end}} item" href="?type=mentioned&sort={{$.SortType}}&project={{$.ProjectID}}&state={{.State}}&q={{$.Keyword}}">
<div class="ui circular mini label tw-ml-0">{{CountFmt .IssueStats.MentionCount}}</div>
{{ctx.Locale.Tr "repo.issues.filter_type.mentioning_you"}}
</a>
@ -83,7 +104,7 @@
{{$o := .}}
{{range $opt := StringUtils.Make "recentupdate" "leastupdate" "latest" "oldest" "mostcomment" "leastcomment" "nearduedate" "farduedate"}}
{{$text := ctx.Locale.Tr (printf "repo.issues.filter_sort.%s" $opt)}}
<a class="{{if or (eq $o.SortType $opt) (and (eq $opt "latest") (not $o.SortType))}}active {{end}}item" href="?type={{$.ViewType}}&sort={{$opt}}&state={{$.State}}&labels={{$o.SelectLabels}}&q={{$keyword}}">{{
<a class="{{if or (eq $o.SortType $opt) (and (eq $opt "latest") (not $o.SortType))}}active {{end}}item" href="?type={{$.ViewType}}&sort={{$opt}}&project={{$.ProjectID}}&state={{$.State}}&labels={{$o.SelectLabels}}&q={{$keyword}}">{{
$text
}}</a>
{{end}}

View file

@ -1462,6 +1462,40 @@ func TestIssueOrgDashboard(t *testing.T) {
}
}
func TestIssueDashboardProjects(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization})
session := loginUser(t, user.Name)
testFn := func(t *testing.T, req *RequestWrapper, projectID int64) {
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
projectFilterHref, ok := htmlDoc.Find("[data-test-tag=filter-project] a.active").Attr("href")
assert.True(t, ok)
assert.Contains(t, projectFilterHref, fmt.Sprintf("project=%d", projectID))
issues := htmlDoc.Find("#issue-list .issue-meta")
assert.NotZero(t, issues.Length())
issues.Each(func(i int, s *goquery.Selection) {
issueProjectHref, ok := s.Find("a.project").Attr("href")
assert.True(t, ok)
assert.Contains(t, issueProjectHref, fmt.Sprintf("projects/%d", projectID))
})
}
t.Run("User", func(t *testing.T) {
testFn(t, NewRequest(t, "GET", "/issues?project=4"), 4)
})
t.Run("Org", func(t *testing.T) {
testFn(t, NewRequestf(t, "GET", "/org/%s/issues?project=7", org.Name), 7)
})
}
func TestIssueCount(t *testing.T) {
defer tests.PrepareTestEnv(t)()