1
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-04-18 00:47:48 -04:00

Allow filtering issues by any assignee (#33343)

This is the opposite of the "No assignee" filter, it will match all
issues that have at least one assignee.

Before
![Before
change](https://github.com/user-attachments/assets/4aea194b-9add-4a84-8d6b-61bfd8d9e58e)

After
![After change with any
filter](https://github.com/user-attachments/assets/99f1205d-ba9f-4a0a-a60b-cc1a0c0823fe)

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Andreas Svanberg 2025-03-21 05:25:36 +01:00 committed by GitHub
parent a4df01b580
commit 0da7318cf3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 167 additions and 101 deletions

View File

@ -29,7 +29,3 @@ const (
// NoConditionID means a condition to filter the records which don't match any id. // NoConditionID means a condition to filter the records which don't match any id.
// eg: "milestone_id=-1" means "find the items without any milestone. // eg: "milestone_id=-1" means "find the items without any milestone.
const NoConditionID int64 = -1 const NoConditionID int64 = -1
// NonExistingID means a condition to match no result (eg: a non-existing user)
// It doesn't use -1 or -2 because they are used as builtin users.
const NonExistingID int64 = -1000000

View File

@ -27,8 +27,8 @@ type IssuesOptions struct { //nolint
RepoIDs []int64 // overwrites RepoCond if the length is not 0 RepoIDs []int64 // overwrites RepoCond if the length is not 0
AllPublic bool // include also all public repositories AllPublic bool // include also all public repositories
RepoCond builder.Cond RepoCond builder.Cond
AssigneeID optional.Option[int64] AssigneeID string // "(none)" or "(any)" or a user ID
PosterID optional.Option[int64] PosterID string // "(none)" or "(any)" or a user ID
MentionedID int64 MentionedID int64
ReviewRequestedID int64 ReviewRequestedID int64
ReviewedID int64 ReviewedID int64
@ -356,26 +356,25 @@ func issuePullAccessibleRepoCond(repoIDstr string, userID int64, owner *user_mod
return cond return cond
} }
func applyAssigneeCondition(sess *xorm.Session, assigneeID optional.Option[int64]) { func applyAssigneeCondition(sess *xorm.Session, assigneeID string) {
// old logic: 0 is also treated as "not filtering assignee", because the "assignee" was read as FormInt64 // old logic: 0 is also treated as "not filtering assignee", because the "assignee" was read as FormInt64
if !assigneeID.Has() || assigneeID.Value() == 0 { if assigneeID == "(none)" {
return
}
if assigneeID.Value() == db.NoConditionID {
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)") sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
} else { } else if assigneeID == "(any)" {
sess.Where("issue.id IN (SELECT issue_id FROM issue_assignees)")
} else if assigneeIDInt64, _ := strconv.ParseInt(assigneeID, 10, 64); assigneeIDInt64 > 0 {
sess.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id"). sess.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id").
And("issue_assignees.assignee_id = ?", assigneeID.Value()) And("issue_assignees.assignee_id = ?", assigneeIDInt64)
} }
} }
func applyPosterCondition(sess *xorm.Session, posterID optional.Option[int64]) { func applyPosterCondition(sess *xorm.Session, posterID string) {
if !posterID.Has() { // Actually every issue has a poster.
return // The "(none)" is for internal usage only: when doer tries to search non-existing user as poster, use "(none)" to return empty result.
} if posterID == "(none)" {
// poster doesn't need to support db.NoConditionID(-1), so just use the value as-is sess.And("issue.poster_id=0")
if posterID.Has() { } else if posterIDInt64, _ := strconv.ParseInt(posterID, 10, 64); posterIDInt64 > 0 {
sess.And("issue.poster_id=?", posterID.Value()) sess.And("issue.poster_id=?", posterIDInt64)
} }
} }

View File

@ -15,7 +15,6 @@ import (
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -155,7 +154,7 @@ func TestIssues(t *testing.T) {
}{ }{
{ {
issues_model.IssuesOptions{ issues_model.IssuesOptions{
AssigneeID: optional.Some(int64(1)), AssigneeID: "1",
SortType: "oldest", SortType: "oldest",
}, },
[]int64{1, 6}, []int64{1, 6},

View File

@ -5,11 +5,13 @@ package bleve
import ( import (
"context" "context"
"strconv"
"code.gitea.io/gitea/modules/indexer" "code.gitea.io/gitea/modules/indexer"
indexer_internal "code.gitea.io/gitea/modules/indexer/internal" indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
inner_bleve "code.gitea.io/gitea/modules/indexer/internal/bleve" inner_bleve "code.gitea.io/gitea/modules/indexer/internal/bleve"
"code.gitea.io/gitea/modules/indexer/issues/internal" "code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"github.com/blevesearch/bleve/v2" "github.com/blevesearch/bleve/v2"
@ -246,12 +248,20 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id")) queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id"))
} }
if options.PosterID.Has() { if options.PosterID != "" {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.PosterID.Value(), "poster_id")) // "(none)" becomes 0, it means no poster
posterIDInt64, _ := strconv.ParseInt(options.PosterID, 10, 64)
queries = append(queries, inner_bleve.NumericEqualityQuery(posterIDInt64, "poster_id"))
} }
if options.AssigneeID.Has() { if options.AssigneeID != "" {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.AssigneeID.Value(), "assignee_id")) if options.AssigneeID == "(any)" {
queries = append(queries, inner_bleve.NumericRangeInclusiveQuery(optional.Some[int64](1), optional.None[int64](), "assignee_id"))
} else {
// "(none)" becomes 0, it means no assignee
assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64)
queries = append(queries, inner_bleve.NumericEqualityQuery(assigneeIDInt64, "assignee_id"))
}
} }
if options.MentionID.Has() { if options.MentionID.Has() {

View File

@ -54,7 +54,7 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
RepoIDs: options.RepoIDs, RepoIDs: options.RepoIDs,
AllPublic: options.AllPublic, AllPublic: options.AllPublic,
RepoCond: nil, RepoCond: nil,
AssigneeID: optional.Some(convertID(options.AssigneeID)), AssigneeID: options.AssigneeID,
PosterID: options.PosterID, PosterID: options.PosterID,
MentionedID: convertID(options.MentionID), MentionedID: convertID(options.MentionID),
ReviewRequestedID: convertID(options.ReviewRequestedID), ReviewRequestedID: convertID(options.ReviewRequestedID),

View File

@ -45,11 +45,7 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
searchOpt.ProjectID = optional.Some[int64](0) // Those issues with no project(projectid==0) searchOpt.ProjectID = optional.Some[int64](0) // Those issues with no project(projectid==0)
} }
if opts.AssigneeID.Value() == db.NoConditionID { searchOpt.AssigneeID = opts.AssigneeID
searchOpt.AssigneeID = optional.Some[int64](0) // FIXME: this is inconsistent from other places, 0 means "no assignee"
} else if opts.AssigneeID.Value() != 0 {
searchOpt.AssigneeID = opts.AssigneeID
}
// See the comment of issues_model.SearchOptions for the reason why we need to convert // See the comment of issues_model.SearchOptions for the reason why we need to convert
convertID := func(id int64) optional.Option[int64] { convertID := func(id int64) optional.Option[int64] {

View File

@ -212,12 +212,22 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value())) query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value()))
} }
if options.PosterID.Has() { if options.PosterID != "" {
query.Must(elastic.NewTermQuery("poster_id", options.PosterID.Value())) // "(none)" becomes 0, it means no poster
posterIDInt64, _ := strconv.ParseInt(options.PosterID, 10, 64)
query.Must(elastic.NewTermQuery("poster_id", posterIDInt64))
} }
if options.AssigneeID.Has() { if options.AssigneeID != "" {
query.Must(elastic.NewTermQuery("assignee_id", options.AssigneeID.Value())) if options.AssigneeID == "(any)" {
q := elastic.NewRangeQuery("assignee_id")
q.Gte(1)
query.Must(q)
} else {
// "(none)" becomes 0, it means no assignee
assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64)
query.Must(elastic.NewTermQuery("assignee_id", assigneeIDInt64))
}
} }
if options.MentionID.Has() { if options.MentionID.Has() {

View File

@ -44,6 +44,7 @@ func TestDBSearchIssues(t *testing.T) {
t.Run("search issues with order", searchIssueWithOrder) t.Run("search issues with order", searchIssueWithOrder)
t.Run("search issues in project", searchIssueInProject) t.Run("search issues in project", searchIssueInProject)
t.Run("search issues with paginator", searchIssueWithPaginator) t.Run("search issues with paginator", searchIssueWithPaginator)
t.Run("search issues with any assignee", searchIssueWithAnyAssignee)
} }
func searchIssueWithKeyword(t *testing.T) { func searchIssueWithKeyword(t *testing.T) {
@ -176,19 +177,19 @@ func searchIssueByID(t *testing.T) {
}{ }{
{ {
opts: SearchOptions{ opts: SearchOptions{
PosterID: optional.Some(int64(1)), PosterID: "1",
}, },
expectedIDs: []int64{11, 6, 3, 2, 1}, expectedIDs: []int64{11, 6, 3, 2, 1},
}, },
{ {
opts: SearchOptions{ opts: SearchOptions{
AssigneeID: optional.Some(int64(1)), AssigneeID: "1",
}, },
expectedIDs: []int64{6, 1}, expectedIDs: []int64{6, 1},
}, },
{ {
// NOTE: This tests no assignees filtering and also ToSearchOptions() to ensure it will set AssigneeID to 0 when it is passed as -1. // NOTE: This tests no assignees filtering and also ToSearchOptions() to ensure it handles the filter correctly
opts: *ToSearchOptions("", &issues.IssuesOptions{AssigneeID: optional.Some(db.NoConditionID)}), opts: *ToSearchOptions("", &issues.IssuesOptions{AssigneeID: "(none)"}),
expectedIDs: []int64{22, 21, 16, 15, 14, 13, 12, 11, 20, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2}, expectedIDs: []int64{22, 21, 16, 15, 14, 13, 12, 11, 20, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2},
}, },
{ {
@ -462,3 +463,25 @@ func searchIssueWithPaginator(t *testing.T) {
assert.Equal(t, test.expectedTotal, total) assert.Equal(t, test.expectedTotal, total)
} }
} }
func searchIssueWithAnyAssignee(t *testing.T) {
tests := []struct {
opts SearchOptions
expectedIDs []int64
expectedTotal int64
}{
{
SearchOptions{
AssigneeID: "(any)",
},
[]int64{17, 6, 1},
3,
},
}
for _, test := range tests {
issueIDs, total, err := SearchIssues(t.Context(), &test.opts)
require.NoError(t, err)
assert.Equal(t, test.expectedIDs, issueIDs)
assert.Equal(t, test.expectedTotal, total)
}
}

View File

@ -97,9 +97,8 @@ type SearchOptions struct {
ProjectID optional.Option[int64] // project the issues belong to ProjectID optional.Option[int64] // project the issues belong to
ProjectColumnID optional.Option[int64] // project column the issues belong to ProjectColumnID optional.Option[int64] // project column the issues belong to
PosterID optional.Option[int64] // poster of the issues PosterID string // poster of the issues, "(none)" or "(any)" or a user ID
AssigneeID string // assignee of the issues, "(none)" or "(any)" or a user ID
AssigneeID optional.Option[int64] // assignee of the issues, zero means no assignee
MentionID optional.Option[int64] // mentioned user of the issues MentionID optional.Option[int64] // mentioned user of the issues

View File

@ -379,7 +379,7 @@ var cases = []*testIndexerCase{
Paginator: &db.ListOptions{ Paginator: &db.ListOptions{
PageSize: 5, PageSize: 5,
}, },
PosterID: optional.Some(int64(1)), PosterID: "1",
}, },
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5) assert.Len(t, result.Hits, 5)
@ -397,7 +397,7 @@ var cases = []*testIndexerCase{
Paginator: &db.ListOptions{ Paginator: &db.ListOptions{
PageSize: 5, PageSize: 5,
}, },
AssigneeID: optional.Some(int64(1)), AssigneeID: "1",
}, },
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5) assert.Len(t, result.Hits, 5)
@ -415,7 +415,7 @@ var cases = []*testIndexerCase{
Paginator: &db.ListOptions{ Paginator: &db.ListOptions{
PageSize: 5, PageSize: 5,
}, },
AssigneeID: optional.Some(int64(0)), AssigneeID: "(none)",
}, },
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5) assert.Len(t, result.Hits, 5)
@ -647,6 +647,21 @@ var cases = []*testIndexerCase{
} }
}, },
}, },
{
Name: "SearchAnyAssignee",
SearchOptions: &internal.SearchOptions{
AssigneeID: "(any)",
},
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 180)
for _, v := range result.Hits {
assert.GreaterOrEqual(t, data[v.ID].AssigneeID, int64(1))
}
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.AssigneeID >= 1
}), result.Total)
},
},
} }
type testIndexerCase struct { type testIndexerCase struct {

View File

@ -187,12 +187,20 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value())) query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value()))
} }
if options.PosterID.Has() { if options.PosterID != "" {
query.And(inner_meilisearch.NewFilterEq("poster_id", options.PosterID.Value())) // "(none)" becomes 0, it means no poster
posterIDInt64, _ := strconv.ParseInt(options.PosterID, 10, 64)
query.And(inner_meilisearch.NewFilterEq("poster_id", posterIDInt64))
} }
if options.AssigneeID.Has() { if options.AssigneeID != "" {
query.And(inner_meilisearch.NewFilterEq("assignee_id", options.AssigneeID.Value())) if options.AssigneeID == "(any)" {
query.And(inner_meilisearch.NewFilterGte("assignee_id", 1))
} else {
// "(none)" becomes 0, it means no assignee
assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64)
query.And(inner_meilisearch.NewFilterEq("assignee_id", assigneeIDInt64))
}
} }
if options.MentionID.Has() { if options.MentionID.Has() {

View File

@ -1547,8 +1547,8 @@ issues.filter_project = Project
issues.filter_project_all = All projects issues.filter_project_all = All projects
issues.filter_project_none = No project issues.filter_project_none = No project
issues.filter_assignee = Assignee issues.filter_assignee = Assignee
issues.filter_assginee_no_select = All assignees issues.filter_assginee_no_assignee = Assigned to nobody
issues.filter_assginee_no_assignee = No assignee issues.filter_assignee_any_assignee = Assigned to anybody
issues.filter_poster = Author issues.filter_poster = Author
issues.filter_user_placeholder = Search users issues.filter_user_placeholder = Search users
issues.filter_user_no_select = All users issues.filter_user_no_select = All users

View File

@ -290,10 +290,10 @@ func SearchIssues(ctx *context.APIContext) {
if ctx.IsSigned { if ctx.IsSigned {
ctxUserID := ctx.Doer.ID ctxUserID := ctx.Doer.ID
if ctx.FormBool("created") { if ctx.FormBool("created") {
searchOpt.PosterID = optional.Some(ctxUserID) searchOpt.PosterID = strconv.FormatInt(ctxUserID, 10)
} }
if ctx.FormBool("assigned") { if ctx.FormBool("assigned") {
searchOpt.AssigneeID = optional.Some(ctxUserID) searchOpt.AssigneeID = strconv.FormatInt(ctxUserID, 10)
} }
if ctx.FormBool("mentioned") { if ctx.FormBool("mentioned") {
searchOpt.MentionID = optional.Some(ctxUserID) searchOpt.MentionID = optional.Some(ctxUserID)
@ -538,10 +538,10 @@ func ListIssues(ctx *context.APIContext) {
} }
if createdByID > 0 { if createdByID > 0 {
searchOpt.PosterID = optional.Some(createdByID) searchOpt.PosterID = strconv.FormatInt(createdByID, 10)
} }
if assignedByID > 0 { if assignedByID > 0 {
searchOpt.AssigneeID = optional.Some(assignedByID) searchOpt.AssigneeID = strconv.FormatInt(assignedByID, 10)
} }
if mentionedByID > 0 { if mentionedByID > 0 {
searchOpt.MentionID = optional.Some(mentionedByID) searchOpt.MentionID = optional.Some(mentionedByID)

View File

@ -347,11 +347,11 @@ func ViewProject(ctx *context.Context) {
if ctx.Written() { if ctx.Written() {
return return
} }
assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future assigneeID := ctx.FormString("assignee")
opts := issues_model.IssuesOptions{ opts := issues_model.IssuesOptions{
LabelIDs: labelIDs, LabelIDs: labelIDs,
AssigneeID: optional.Some(assigneeID), AssigneeID: assigneeID,
Owner: project.Owner, Owner: project.Owner,
Doer: ctx.Doer, Doer: ctx.Doer,
} }

View File

@ -208,10 +208,10 @@ func SearchIssues(ctx *context.Context) {
if ctx.IsSigned { if ctx.IsSigned {
ctxUserID := ctx.Doer.ID ctxUserID := ctx.Doer.ID
if ctx.FormBool("created") { if ctx.FormBool("created") {
searchOpt.PosterID = optional.Some(ctxUserID) searchOpt.PosterID = strconv.FormatInt(ctxUserID, 10)
} }
if ctx.FormBool("assigned") { if ctx.FormBool("assigned") {
searchOpt.AssigneeID = optional.Some(ctxUserID) searchOpt.AssigneeID = strconv.FormatInt(ctxUserID, 10)
} }
if ctx.FormBool("mentioned") { if ctx.FormBool("mentioned") {
searchOpt.MentionID = optional.Some(ctxUserID) searchOpt.MentionID = optional.Some(ctxUserID)
@ -373,10 +373,10 @@ func SearchRepoIssuesJSON(ctx *context.Context) {
} }
if createdByID > 0 { if createdByID > 0 {
searchOpt.PosterID = optional.Some(createdByID) searchOpt.PosterID = strconv.FormatInt(createdByID, 10)
} }
if assignedByID > 0 { if assignedByID > 0 {
searchOpt.AssigneeID = optional.Some(assignedByID) searchOpt.AssigneeID = strconv.FormatInt(assignedByID, 10)
} }
if mentionedByID > 0 { if mentionedByID > 0 {
searchOpt.MentionID = optional.Some(mentionedByID) searchOpt.MentionID = optional.Some(mentionedByID)
@ -490,7 +490,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
viewType = "all" viewType = "all"
} }
assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future assigneeID := ctx.FormString("assignee")
posterUsername := ctx.FormString("poster") posterUsername := ctx.FormString("poster")
posterUserID := shared_user.GetFilterUserIDByName(ctx, posterUsername) posterUserID := shared_user.GetFilterUserIDByName(ctx, posterUsername)
var mentionedID, reviewRequestedID, reviewedID int64 var mentionedID, reviewRequestedID, reviewedID int64
@ -498,11 +498,11 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
if ctx.IsSigned { if ctx.IsSigned {
switch viewType { switch viewType {
case "created_by": case "created_by":
posterUserID = optional.Some(ctx.Doer.ID) posterUserID = strconv.FormatInt(ctx.Doer.ID, 10)
case "mentioned": case "mentioned":
mentionedID = ctx.Doer.ID mentionedID = ctx.Doer.ID
case "assigned": case "assigned":
assigneeID = ctx.Doer.ID assigneeID = fmt.Sprint(ctx.Doer.ID)
case "review_requested": case "review_requested":
reviewRequestedID = ctx.Doer.ID reviewRequestedID = ctx.Doer.ID
case "reviewed_by": case "reviewed_by":
@ -532,7 +532,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
LabelIDs: labelIDs, LabelIDs: labelIDs,
MilestoneIDs: mileIDs, MilestoneIDs: mileIDs,
ProjectID: projectID, ProjectID: projectID,
AssigneeID: optional.Some(assigneeID), AssigneeID: assigneeID,
MentionedID: mentionedID, MentionedID: mentionedID,
PosterID: posterUserID, PosterID: posterUserID,
ReviewRequestedID: reviewRequestedID, ReviewRequestedID: reviewRequestedID,
@ -613,7 +613,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
PageSize: setting.UI.IssuePagingNum, PageSize: setting.UI.IssuePagingNum,
}, },
RepoIDs: []int64{repo.ID}, RepoIDs: []int64{repo.ID},
AssigneeID: optional.Some(assigneeID), AssigneeID: assigneeID,
PosterID: posterUserID, PosterID: posterUserID,
MentionedID: mentionedID, MentionedID: mentionedID,
ReviewRequestedID: reviewRequestedID, ReviewRequestedID: reviewRequestedID,

View File

@ -315,12 +315,12 @@ func ViewProject(ctx *context.Context) {
labelIDs := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner) labelIDs := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner)
assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future assigneeID := ctx.FormString("assignee")
issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &issues_model.IssuesOptions{ issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &issues_model.IssuesOptions{
RepoIDs: []int64{ctx.Repo.Repository.ID}, RepoIDs: []int64{ctx.Repo.Repository.ID},
LabelIDs: labelIDs, LabelIDs: labelIDs,
AssigneeID: optional.Some(assigneeID), AssigneeID: assigneeID,
}) })
if err != nil { if err != nil {
ctx.ServerError("LoadIssuesOfColumns", err) ctx.ServerError("LoadIssuesOfColumns", err)

View File

@ -8,9 +8,7 @@ import (
"slices" "slices"
"strconv" "strconv"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/user" "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/optional"
) )
func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User { func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User {
@ -34,19 +32,20 @@ func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User {
// So it's better to make it work like GitHub: users could input username directly. // So it's better to make it work like GitHub: users could input username directly.
// Since it only converts the username to ID directly and is only used internally (to search issues), so no permission check is needed. // Since it only converts the username to ID directly and is only used internally (to search issues), so no permission check is needed.
// Return values: // Return values:
// * nil: no filter // * "": no filter
// * some(id): match the id, the id could be -1 to match the issues without assignee // * "{the-id}": match the id
// * some(NonExistingID): match no issue (due to the user doesn't exist) // * "(none)": match no issue (due to the user doesn't exist)
func GetFilterUserIDByName(ctx context.Context, name string) optional.Option[int64] { func GetFilterUserIDByName(ctx context.Context, name string) string {
if name == "" { if name == "" {
return optional.None[int64]() return ""
} }
u, err := user.GetUserByName(ctx, name) u, err := user.GetUserByName(ctx, name)
if err != nil { if err != nil {
if id, err := strconv.ParseInt(name, 10, 64); err == nil { if id, err := strconv.ParseInt(name, 10, 64); err == nil {
return optional.Some(id) return strconv.FormatInt(id, 10)
} }
return optional.Some(db.NonExistingID) // The "(none)" is for internal usage only: when doer tries to search non-existing user, use "(none)" to return empty result.
return "(none)"
} }
return optional.Some(u.ID) return strconv.FormatInt(u.ID, 10)
} }

View File

@ -501,9 +501,9 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
case issues_model.FilterModeAll: case issues_model.FilterModeAll:
case issues_model.FilterModeYourRepositories: case issues_model.FilterModeYourRepositories:
case issues_model.FilterModeAssign: case issues_model.FilterModeAssign:
opts.AssigneeID = optional.Some(ctx.Doer.ID) opts.AssigneeID = strconv.FormatInt(ctx.Doer.ID, 10)
case issues_model.FilterModeCreate: case issues_model.FilterModeCreate:
opts.PosterID = optional.Some(ctx.Doer.ID) opts.PosterID = strconv.FormatInt(ctx.Doer.ID, 10)
case issues_model.FilterModeMention: case issues_model.FilterModeMention:
opts.MentionedID = ctx.Doer.ID opts.MentionedID = ctx.Doer.ID
case issues_model.FilterModeReviewRequested: case issues_model.FilterModeReviewRequested:
@ -792,9 +792,9 @@ func getUserIssueStats(ctx *context.Context, ctxUser *user_model.User, filterMod
case issues_model.FilterModeYourRepositories: case issues_model.FilterModeYourRepositories:
openClosedOpts.AllPublic = false openClosedOpts.AllPublic = false
case issues_model.FilterModeAssign: case issues_model.FilterModeAssign:
openClosedOpts.AssigneeID = optional.Some(doerID) openClosedOpts.AssigneeID = strconv.FormatInt(doerID, 10)
case issues_model.FilterModeCreate: case issues_model.FilterModeCreate:
openClosedOpts.PosterID = optional.Some(doerID) openClosedOpts.PosterID = strconv.FormatInt(doerID, 10)
case issues_model.FilterModeMention: case issues_model.FilterModeMention:
openClosedOpts.MentionID = optional.Some(doerID) openClosedOpts.MentionID = optional.Some(doerID)
case issues_model.FilterModeReviewRequested: case issues_model.FilterModeReviewRequested:
@ -816,8 +816,8 @@ func getUserIssueStats(ctx *context.Context, ctxUser *user_model.User, filterMod
// Below stats are for the left sidebar // Below stats are for the left sidebar
opts = opts.Copy(func(o *issue_indexer.SearchOptions) { opts = opts.Copy(func(o *issue_indexer.SearchOptions) {
o.AssigneeID = nil o.AssigneeID = ""
o.PosterID = nil o.PosterID = ""
o.MentionID = nil o.MentionID = nil
o.ReviewRequestedID = nil o.ReviewRequestedID = nil
o.ReviewedID = nil o.ReviewedID = nil
@ -827,11 +827,11 @@ func getUserIssueStats(ctx *context.Context, ctxUser *user_model.User, filterMod
if err != nil { if err != nil {
return nil, err return nil, err
} }
ret.AssignCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AssigneeID = optional.Some(doerID) })) ret.AssignCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AssigneeID = strconv.FormatInt(doerID, 10) }))
if err != nil { if err != nil {
return nil, err return nil, err
} }
ret.CreateCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.PosterID = optional.Some(doerID) })) ret.CreateCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.PosterID = strconv.FormatInt(doerID, 10) }))
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -15,8 +15,8 @@
"UserSearchList" $.Assignees "UserSearchList" $.Assignees
"SelectedUserId" $.AssigneeID "SelectedUserId" $.AssigneeID
"TextFilterTitle" (ctx.Locale.Tr "repo.issues.filter_assignee") "TextFilterTitle" (ctx.Locale.Tr "repo.issues.filter_assignee")
"TextZeroValue" (ctx.Locale.Tr "repo.issues.filter_assginee_no_select") "TextFilterMatchNone" (ctx.Locale.Tr "repo.issues.filter_assginee_no_assignee")
"TextNegativeOne" (ctx.Locale.Tr "repo.issues.filter_assginee_no_assignee") "TextFilterMatchAny" (ctx.Locale.Tr "repo.issues.filter_assignee_any_assignee")
}} }}
</div> </div>
</div> </div>

View File

@ -4,8 +4,8 @@
* UserSearchList * UserSearchList
* SelectedUserId: 0 or empty means default, -1 means "no user is set" * SelectedUserId: 0 or empty means default, -1 means "no user is set"
* TextFilterTitle * TextFilterTitle
* TextZeroValue: the text for "all issues" * TextFilterMatchNone: the text for "issues with no assignee"
* TextNegativeOne: the text for "issues with no assignee" * TextFilterMatchAny: the text for "issues with any assignee"
*/}} */}}
{{$queryLink := .QueryLink}} {{$queryLink := .QueryLink}}
<div class="item ui dropdown jump {{if not .UserSearchList}}disabled{{end}}"> <div class="item ui dropdown jump {{if not .UserSearchList}}disabled{{end}}">
@ -15,16 +15,24 @@
<i class="icon">{{svg "octicon-search" 16}}</i> <i class="icon">{{svg "octicon-search" 16}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_user_placeholder"}}"> <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_user_placeholder"}}">
</div> </div>
{{if $.TextZeroValue}} {{if $.TextFilterMatchNone}}
<a class="item {{if not .SelectedUserId}}selected{{end}}" href="{{QueryBuild $queryLink $.QueryParamKey NIL}}">{{$.TextZeroValue}}</a> {{$isSelected := eq .SelectedUserId "(none)"}}
<a class="item" href="{{QueryBuild $queryLink $.QueryParamKey (Iif $isSelected NIL "(none)")}}">
{{svg "octicon-check" 14 (Iif $isSelected "" "tw-invisible")}} {{$.TextFilterMatchNone}}
</a>
{{end}} {{end}}
{{if $.TextNegativeOne}} {{if $.TextFilterMatchAny}}
<a class="item {{if eq .SelectedUserId -1}}selected{{end}}" href="{{QueryBuild $queryLink $.QueryParamKey -1}}">{{$.TextNegativeOne}}</a> {{$isSelected := eq .SelectedUserId "(any)"}}
<a class="item" href="{{QueryBuild $queryLink $.QueryParamKey (Iif $isSelected NIL "(any)")}}">
{{svg "octicon-check" 14 (Iif $isSelected "" "tw-invisible")}} {{$.TextFilterMatchAny}}
</a>
{{end}} {{end}}
<div class="divider"></div> <div class="divider"></div>
{{range .UserSearchList}} {{range $user := .UserSearchList}}
<a class="item {{if eq $.SelectedUserId .ID}}selected{{end}}" href="{{QueryBuild $queryLink $.QueryParamKey .ID}}"> {{$isSelected := eq $.SelectedUserId (print $user.ID)}}
{{ctx.AvatarUtils.Avatar . 20}}{{template "repo/search_name" .}} <a class="item" href="{{QueryBuild $queryLink $.QueryParamKey (Iif $isSelected NIL $user.ID)}}">
{{svg "octicon-check" 14 (Iif $isSelected "" "tw-invisible")}}
{{ctx.AvatarUtils.Avatar $user 20}}{{template "repo/search_name" .}}
</a> </a>
{{end}} {{end}}
</div> </div>

View File

@ -94,8 +94,8 @@
"UserSearchList" $.Assignees "UserSearchList" $.Assignees
"SelectedUserId" $.AssigneeID "SelectedUserId" $.AssigneeID
"TextFilterTitle" (ctx.Locale.Tr "repo.issues.filter_assignee") "TextFilterTitle" (ctx.Locale.Tr "repo.issues.filter_assignee")
"TextZeroValue" (ctx.Locale.Tr "repo.issues.filter_assginee_no_select") "TextFilterMatchNone" (ctx.Locale.Tr "repo.issues.filter_assginee_no_assignee")
"TextNegativeOne" (ctx.Locale.Tr "repo.issues.filter_assginee_no_assignee") "TextFilterMatchAny" (ctx.Locale.Tr "repo.issues.filter_assignee_any_assignee")
}} }}
{{if .IsSigned}} {{if .IsSigned}}

View File

@ -1130,7 +1130,11 @@ $.fn.dropdown = function(parameters) {
icon: { icon: {
click: function(event) { click: function(event) {
iconClicked=true; iconClicked=true;
if(module.has.search()) { // GITEA-PATCH: official dropdown doesn't support the search input in menu
// so we need to make the menu could be shown when the search input is in menu and user clicks the icon
const searchInputInMenu = Boolean($menu.find('.search > input').length);
if(module.has.search() && !searchInputInMenu) {
// the search input is in the dropdown element (but not in the popup menu), try to focus it
if(!module.is.active()) { if(!module.is.active()) {
if(settings.showOnFocus){ if(settings.showOnFocus){
module.focusSearch(); module.focusSearch();