Add label/author/assignee filters to the user/org home issue list (#32779)

Replace #26661, fix #25979

Not perfect, but usable and much better than before. Since it is quite
complex, I am not quite sure whether there would be any regression, if
any, I will fix in first time.

I have tested the related pages many times: issue list, milestone issue
list, project view, user issue list, org issue list.
This commit is contained in:
wxiaoguang 2024-12-11 14:33:24 +08:00 committed by GitHub
parent 734ddf7118
commit e619384098
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 338 additions and 319 deletions

View File

@ -26,8 +26,10 @@ const (
SearchOrderByForksReverse SearchOrderBy = "num_forks DESC" SearchOrderByForksReverse SearchOrderBy = "num_forks DESC"
) )
const ( // NoConditionID means a condition to filter the records which don't match any id.
// Which means a condition to filter the records which don't match any id. // eg: "milestone_id=-1" means "find the items without any milestone.
// It's different from zero which means the condition could be ignored. const NoConditionID int64 = -1
NoConditionID = -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 int64 AssigneeID optional.Option[int64]
PosterID int64 PosterID optional.Option[int64]
MentionedID int64 MentionedID int64
ReviewRequestedID int64 ReviewRequestedID int64
ReviewedID int64 ReviewedID int64
@ -231,15 +231,8 @@ func applyConditions(sess *xorm.Session, opts *IssuesOptions) {
sess.And("issue.is_closed=?", opts.IsClosed.Value()) sess.And("issue.is_closed=?", opts.IsClosed.Value())
} }
if opts.AssigneeID > 0 { applyAssigneeCondition(sess, opts.AssigneeID)
applyAssigneeCondition(sess, opts.AssigneeID) applyPosterCondition(sess, opts.PosterID)
} else if opts.AssigneeID == db.NoConditionID {
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
}
if opts.PosterID > 0 {
applyPosterCondition(sess, opts.PosterID)
}
if opts.MentionedID > 0 { if opts.MentionedID > 0 {
applyMentionedCondition(sess, opts.MentionedID) applyMentionedCondition(sess, opts.MentionedID)
@ -359,13 +352,27 @@ func issuePullAccessibleRepoCond(repoIDstr string, userID int64, org *organizati
return cond return cond
} }
func applyAssigneeCondition(sess *xorm.Session, assigneeID int64) { func applyAssigneeCondition(sess *xorm.Session, assigneeID optional.Option[int64]) {
sess.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id"). // old logic: 0 is also treated as "not filtering assignee", because the "assignee" was read as FormInt64
And("issue_assignees.assignee_id = ?", assigneeID) if !assigneeID.Has() || assigneeID.Value() == 0 {
return
}
if assigneeID.Value() == db.NoConditionID {
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
} else {
sess.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id").
And("issue_assignees.assignee_id = ?", assigneeID.Value())
}
} }
func applyPosterCondition(sess *xorm.Session, posterID int64) { func applyPosterCondition(sess *xorm.Session, posterID optional.Option[int64]) {
sess.And("issue.poster_id=?", posterID) if !posterID.Has() {
return
}
// poster doesn't need to support db.NoConditionID(-1), so just use the value as-is
if posterID.Has() {
sess.And("issue.poster_id=?", posterID.Value())
}
} }
func applyMentionedCondition(sess *xorm.Session, mentionedID int64) { func applyMentionedCondition(sess *xorm.Session, mentionedID int64) {

View File

@ -151,15 +151,9 @@ func applyIssuesOptions(sess *xorm.Session, opts *IssuesOptions, issueIDs []int6
applyProjectCondition(sess, opts) applyProjectCondition(sess, opts)
if opts.AssigneeID > 0 { applyAssigneeCondition(sess, opts.AssigneeID)
applyAssigneeCondition(sess, opts.AssigneeID)
} else if opts.AssigneeID == db.NoConditionID {
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
}
if opts.PosterID > 0 { applyPosterCondition(sess, opts.PosterID)
applyPosterCondition(sess, opts.PosterID)
}
if opts.MentionedID > 0 { if opts.MentionedID > 0 {
applyMentionedCondition(sess, opts.MentionedID) applyMentionedCondition(sess, opts.MentionedID)

View File

@ -16,6 +16,7 @@ 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 +156,7 @@ func TestIssues(t *testing.T) {
}{ }{
{ {
issues_model.IssuesOptions{ issues_model.IssuesOptions{
AssigneeID: 1, AssigneeID: optional.Some(int64(1)),
SortType: "oldest", SortType: "oldest",
}, },
[]int64{1, 6}, []int64{1, 6},

View File

@ -54,8 +54,8 @@ 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: convertID(options.AssigneeID), AssigneeID: optional.Some(convertID(options.AssigneeID)),
PosterID: convertID(options.PosterID), PosterID: options.PosterID,
MentionedID: convertID(options.MentionID), MentionedID: convertID(options.MentionID),
ReviewRequestedID: convertID(options.ReviewRequestedID), ReviewRequestedID: convertID(options.ReviewRequestedID),
ReviewedID: convertID(options.ReviewedID), ReviewedID: convertID(options.ReviewedID),

View File

@ -40,14 +40,14 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
if opts.ProjectID > 0 { if opts.ProjectID > 0 {
searchOpt.ProjectID = optional.Some(opts.ProjectID) searchOpt.ProjectID = optional.Some(opts.ProjectID)
} else if opts.ProjectID == -1 { // FIXME: this is inconsistent from other places } else if opts.ProjectID == db.NoConditionID { // FIXME: this is inconsistent from other places
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 > 0 { if opts.AssigneeID.Value() == db.NoConditionID {
searchOpt.AssigneeID = optional.Some(opts.AssigneeID) searchOpt.AssigneeID = optional.Some[int64](0) // FIXME: this is inconsistent from other places, 0 means "no assignee"
} else if opts.AssigneeID == -1 { // FIXME: this is inconsistent from other places } else if opts.AssigneeID.Value() != 0 {
searchOpt.AssigneeID = optional.Some[int64](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
@ -62,7 +62,7 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
} }
searchOpt.ProjectColumnID = convertID(opts.ProjectColumnID) searchOpt.ProjectColumnID = convertID(opts.ProjectColumnID)
searchOpt.PosterID = convertID(opts.PosterID) searchOpt.PosterID = opts.PosterID
searchOpt.MentionID = convertID(opts.MentionedID) searchOpt.MentionID = convertID(opts.MentionedID)
searchOpt.ReviewedID = convertID(opts.ReviewedID) searchOpt.ReviewedID = convertID(opts.ReviewedID)
searchOpt.ReviewRequestedID = convertID(opts.ReviewRequestedID) searchOpt.ReviewRequestedID = convertID(opts.ReviewRequestedID)

View File

@ -191,7 +191,7 @@ func searchIssueByID(t *testing.T) {
}, },
{ {
// 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 will set AssigneeID to 0 when it is passed as -1.
opts: *ToSearchOptions("", &issues.IssuesOptions{AssigneeID: -1}), opts: *ToSearchOptions("", &issues.IssuesOptions{AssigneeID: optional.Some(db.NoConditionID)}),
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},
}, },
{ {

View File

@ -21,6 +21,7 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/web/shared/issue"
shared_user "code.gitea.io/gitea/routers/web/shared/user" shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
@ -334,23 +335,15 @@ func ViewProject(ctx *context.Context) {
return return
} }
var labelIDs []int64 labelIDs := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner)
// 1,-2 means including label 1 and excluding label 2 if ctx.Written() {
// 0 means issues with no label return
// blank means labels will not be filtered for issues
selectLabels := ctx.FormString("labels")
if selectLabels != "" {
labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
if err != nil {
ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
}
} }
assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future
assigneeID := ctx.FormInt64("assignee")
issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{ issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{
LabelIDs: labelIDs, LabelIDs: labelIDs,
AssigneeID: assigneeID, AssigneeID: optional.Some(assigneeID),
}) })
if err != nil { if err != nil {
ctx.ServerError("LoadIssuesOfColumns", err) ctx.ServerError("LoadIssuesOfColumns", err)
@ -426,8 +419,6 @@ func ViewProject(ctx *context.Context) {
return return
} }
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers) ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
ctx.Data["SelectLabels"] = selectLabels
ctx.Data["AssigneeID"] = assigneeID ctx.Data["AssigneeID"] = assigneeID
project.RenderedContent = templates.NewRenderUtils(ctx).MarkdownToHtml(project.Description) project.RenderedContent = templates.NewRenderUtils(ctx).MarkdownToHtml(project.Description)

View File

@ -17,12 +17,12 @@ import (
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
issue_indexer "code.gitea.io/gitea/modules/indexer/issues" issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/web/shared/issue"
shared_user "code.gitea.io/gitea/routers/web/shared/user" shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/convert"
@ -263,8 +263,10 @@ func getUserIDForFilter(ctx *context.Context, queryName string) int64 {
return user.ID return user.ID
} }
// ListIssues list the issues of a repository // SearchRepoIssuesJSON lists the issues of a repository
func ListIssues(ctx *context.Context) { // This function was copied from API (decouple the web and API routes),
// it is only used by frontend to search some dependency or related issues
func SearchRepoIssuesJSON(ctx *context.Context) {
before, since, err := context.GetQueryBeforeSince(ctx.Base) before, since, err := context.GetQueryBeforeSince(ctx.Base)
if err != nil { if err != nil {
ctx.Error(http.StatusUnprocessableEntity, err.Error()) ctx.Error(http.StatusUnprocessableEntity, err.Error())
@ -286,20 +288,11 @@ func ListIssues(ctx *context.Context) {
keyword = "" keyword = ""
} }
var labelIDs []int64
if splitted := strings.Split(ctx.FormString("labels"), ","); len(splitted) > 0 {
labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, splitted)
if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
}
var mileIDs []int64 var mileIDs []int64
if part := strings.Split(ctx.FormString("milestones"), ","); len(part) > 0 { if part := strings.Split(ctx.FormString("milestones"), ","); len(part) > 0 {
for i := range part { for i := range part {
// uses names and fall back to ids // uses names and fall back to ids
// non existent milestones are discarded // non-existent milestones are discarded
mile, err := issues_model.GetMilestoneByRepoIDANDName(ctx, ctx.Repo.Repository.ID, part[i]) mile, err := issues_model.GetMilestoneByRepoIDANDName(ctx, ctx.Repo.Repository.ID, part[i])
if err == nil { if err == nil {
mileIDs = append(mileIDs, mile.ID) mileIDs = append(mileIDs, mile.ID)
@ -370,17 +363,8 @@ func ListIssues(ctx *context.Context) {
if before != 0 { if before != 0 {
searchOpt.UpdatedBeforeUnix = optional.Some(before) searchOpt.UpdatedBeforeUnix = optional.Some(before)
} }
if len(labelIDs) == 1 && labelIDs[0] == 0 {
searchOpt.NoLabelOnly = true // TODO: the "labels" query parameter is never used, so no need to handle it
} else {
for _, labelID := range labelIDs {
if labelID > 0 {
searchOpt.IncludedLabelIDs = append(searchOpt.IncludedLabelIDs, labelID)
} else {
searchOpt.ExcludedLabelIDs = append(searchOpt.ExcludedLabelIDs, -labelID)
}
}
}
if len(mileIDs) == 1 && mileIDs[0] == db.NoConditionID { if len(mileIDs) == 1 && mileIDs[0] == db.NoConditionID {
searchOpt.MilestoneIDs = []int64{0} searchOpt.MilestoneIDs = []int64{0}
@ -503,8 +487,8 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
if !util.SliceContainsString(types, viewType, true) { if !util.SliceContainsString(types, viewType, true) {
viewType = "all" viewType = "all"
} }
// TODO: "assignee" should also use GetFilterUserIDByName in the future to support usernames directly
assigneeID := ctx.FormInt64("assignee") assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future
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
@ -512,7 +496,7 @@ 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 = ctx.Doer.ID posterUserID = optional.Some(ctx.Doer.ID)
case "mentioned": case "mentioned":
mentionedID = ctx.Doer.ID mentionedID = ctx.Doer.ID
case "assigned": case "assigned":
@ -525,18 +509,6 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
} }
repo := ctx.Repo.Repository repo := ctx.Repo.Repository
var labelIDs []int64
// 1,-2 means including label 1 and excluding label 2
// 0 means issues with no label
// blank means labels will not be filtered for issues
selectLabels := ctx.FormString("labels")
if selectLabels != "" {
labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
if err != nil {
ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
}
}
keyword := strings.Trim(ctx.FormString("q"), " ") keyword := strings.Trim(ctx.FormString("q"), " ")
if bytes.Contains([]byte(keyword), []byte{0x00}) { if bytes.Contains([]byte(keyword), []byte{0x00}) {
keyword = "" keyword = ""
@ -547,13 +519,18 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
mileIDs = []int64{milestoneID} mileIDs = []int64{milestoneID}
} }
labelIDs := issue.PrepareFilterIssueLabels(ctx, repo.ID, ctx.Repo.Owner)
if ctx.Written() {
return
}
var issueStats *issues_model.IssueStats var issueStats *issues_model.IssueStats
statsOpts := &issues_model.IssuesOptions{ statsOpts := &issues_model.IssuesOptions{
RepoIDs: []int64{repo.ID}, RepoIDs: []int64{repo.ID},
LabelIDs: labelIDs, LabelIDs: labelIDs,
MilestoneIDs: mileIDs, MilestoneIDs: mileIDs,
ProjectID: projectID, ProjectID: projectID,
AssigneeID: assigneeID, AssigneeID: optional.Some(assigneeID),
MentionedID: mentionedID, MentionedID: mentionedID,
PosterID: posterUserID, PosterID: posterUserID,
ReviewRequestedID: reviewRequestedID, ReviewRequestedID: reviewRequestedID,
@ -634,7 +611,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: assigneeID, AssigneeID: optional.Some(assigneeID),
PosterID: posterUserID, PosterID: posterUserID,
MentionedID: mentionedID, MentionedID: mentionedID,
ReviewRequestedID: reviewRequestedID, ReviewRequestedID: reviewRequestedID,
@ -709,49 +686,6 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
return return
} }
labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByRepoID", err)
return
}
if repo.Owner.IsOrganization() {
orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByOrgID", err)
return
}
ctx.Data["OrgLabels"] = orgLabels
labels = append(labels, orgLabels...)
}
// Get the exclusive scope for every label ID
labelExclusiveScopes := make([]string, 0, len(labelIDs))
for _, labelID := range labelIDs {
foundExclusiveScope := false
for _, label := range labels {
if label.ID == labelID || label.ID == -labelID {
labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope())
foundExclusiveScope = true
break
}
}
if !foundExclusiveScope {
labelExclusiveScopes = append(labelExclusiveScopes, "")
}
}
for _, l := range labels {
l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
}
ctx.Data["Labels"] = labels
ctx.Data["NumLabels"] = len(labels)
if ctx.FormInt64("assignee") == 0 {
assigneeID = 0 // Reset ID to prevent unexpected selection of assignee.
}
ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, ctx.Repo.RepoLink) ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, ctx.Repo.RepoLink)
ctx.Data["ApprovalCounts"] = func(issueID int64, typ string) int64 { ctx.Data["ApprovalCounts"] = func(issueID int64, typ string) int64 {
@ -792,13 +726,11 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
ctx.Data["OpenCount"] = issueStats.OpenCount ctx.Data["OpenCount"] = issueStats.OpenCount
ctx.Data["ClosedCount"] = issueStats.ClosedCount ctx.Data["ClosedCount"] = issueStats.ClosedCount
ctx.Data["SelLabelIDs"] = labelIDs ctx.Data["SelLabelIDs"] = labelIDs
ctx.Data["SelectLabels"] = selectLabels
ctx.Data["ViewType"] = viewType ctx.Data["ViewType"] = viewType
ctx.Data["SortType"] = sortType ctx.Data["SortType"] = sortType
ctx.Data["MilestoneID"] = milestoneID ctx.Data["MilestoneID"] = milestoneID
ctx.Data["ProjectID"] = projectID ctx.Data["ProjectID"] = projectID
ctx.Data["AssigneeID"] = assigneeID ctx.Data["AssigneeID"] = assigneeID
ctx.Data["PosterUserID"] = posterUserID
ctx.Data["PosterUsername"] = posterUsername ctx.Data["PosterUsername"] = posterUsername
ctx.Data["Keyword"] = keyword ctx.Data["Keyword"] = keyword
ctx.Data["IsShowClosed"] = isShowClosed ctx.Data["IsShowClosed"] = isShowClosed
@ -810,19 +742,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
default: default:
ctx.Data["State"] = "open" ctx.Data["State"] = "open"
} }
pager.AddParamFromRequest(ctx.Req)
pager.AddParamString("q", keyword)
pager.AddParamString("type", viewType)
pager.AddParamString("sort", sortType)
pager.AddParamString("state", fmt.Sprint(ctx.Data["State"]))
pager.AddParamString("labels", fmt.Sprint(selectLabels))
pager.AddParamString("milestone", fmt.Sprint(milestoneID))
pager.AddParamString("project", fmt.Sprint(projectID))
pager.AddParamString("assignee", fmt.Sprint(assigneeID))
pager.AddParamString("poster", posterUsername)
if showArchivedLabels {
pager.AddParamString("archived_labels", "true")
}
ctx.Data["Page"] = pager ctx.Data["Page"] = pager
} }

View File

@ -23,6 +23,7 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/web/shared/issue"
shared_user "code.gitea.io/gitea/routers/web/shared/user" shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
@ -307,23 +308,13 @@ func ViewProject(ctx *context.Context) {
return return
} }
var labelIDs []int64 labelIDs := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner)
// 1,-2 means including label 1 and excluding label 2
// 0 means issues with no label
// blank means labels will not be filtered for issues
selectLabels := ctx.FormString("labels")
if selectLabels != "" {
labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
if err != nil {
ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
}
}
assigneeID := ctx.FormInt64("assignee") assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future
issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{ issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{
LabelIDs: labelIDs, LabelIDs: labelIDs,
AssigneeID: assigneeID, AssigneeID: optional.Some(assigneeID),
}) })
if err != nil { if err != nil {
ctx.ServerError("LoadIssuesOfColumns", err) ctx.ServerError("LoadIssuesOfColumns", err)
@ -409,8 +400,6 @@ func ViewProject(ctx *context.Context) {
return return
} }
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers) ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
ctx.Data["SelectLabels"] = selectLabels
ctx.Data["AssigneeID"] = assigneeID ctx.Data["AssigneeID"] = assigneeID
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository) rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository)

View File

@ -0,0 +1,71 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issue
import (
"strings"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/services/context"
)
// PrepareFilterIssueLabels reads the "labels" query parameter, sets `ctx.Data["Labels"]` and `ctx.Data["SelectLabels"]`
func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_model.User) (labelIDs []int64) {
// 1,-2 means including label 1 and excluding label 2
// 0 means issues with no label
// blank means labels will not be filtered for issues
selectLabels := ctx.FormString("labels")
if selectLabels != "" {
var err error
labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
if err != nil {
ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
}
}
var allLabels []*issues_model.Label
if repoID != 0 {
repoLabels, err := issues_model.GetLabelsByRepoID(ctx, repoID, "", db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByRepoID", err)
return nil
}
allLabels = append(allLabels, repoLabels...)
}
if owner != nil && owner.IsOrganization() {
orgLabels, err := issues_model.GetLabelsByOrgID(ctx, owner.ID, "", db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByOrgID", err)
return nil
}
allLabels = append(allLabels, orgLabels...)
}
// Get the exclusive scope for every label ID
labelExclusiveScopes := make([]string, 0, len(labelIDs))
for _, labelID := range labelIDs {
foundExclusiveScope := false
for _, label := range allLabels {
if label.ID == labelID || label.ID == -labelID {
labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope())
foundExclusiveScope = true
break
}
}
if !foundExclusiveScope {
labelExclusiveScopes = append(labelExclusiveScopes, "")
}
}
for _, l := range allLabels {
l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
}
ctx.Data["Labels"] = allLabels
ctx.Data["SelectLabels"] = selectLabels
return labelIDs
}

View File

@ -8,7 +8,9 @@ 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 {
@ -31,17 +33,20 @@ func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User {
// Before, the "issue filter" passes user ID to query the list, but in many cases, it's impossible to pre-fetch the full user list. // Before, the "issue filter" passes user ID to query the list, but in many cases, it's impossible to pre-fetch the full user list.
// 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.
// Old usage: poster=123, new usage: poster=the-username (at the moment, non-existing username is treated as poster=0, not ideal but acceptable) // Return values:
func GetFilterUserIDByName(ctx context.Context, name string) int64 { // * nil: no filter
// * some(id): match the id, the id could be -1 to match the issues without assignee
// * some(NonExistingID): match no issue (due to the user doesn't exist)
func GetFilterUserIDByName(ctx context.Context, name string) optional.Option[int64] {
if name == "" { if name == "" {
return 0 return optional.None[int64]()
} }
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 id return optional.Some(id)
} }
return 0 return optional.Some(db.NonExistingID)
} }
return u.ID return optional.Some(u.ID)
} }

View File

@ -33,6 +33,7 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/web/feed" "code.gitea.io/gitea/routers/web/feed"
"code.gitea.io/gitea/routers/web/shared/issue"
"code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
feed_service "code.gitea.io/gitea/services/feed" feed_service "code.gitea.io/gitea/services/feed"
@ -413,6 +414,13 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
viewType = "your_repositories" viewType = "your_repositories"
} }
isPullList := unitType == unit.TypePullRequests
opts := &issues_model.IssuesOptions{
IsPull: optional.Some(isPullList),
SortType: sortType,
IsArchived: optional.Some(false),
User: ctx.Doer,
}
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Build opts (IssuesOptions), which contains filter information. // Build opts (IssuesOptions), which contains filter information.
// Will eventually be used to retrieve issues relevant for the overview page. // Will eventually be used to retrieve issues relevant for the overview page.
@ -422,30 +430,24 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Get repository IDs where User/Org/Team has access. // Get repository IDs where User/Org/Team has access.
var team *organization.Team if ctx.Org != nil && ctx.Org.Organization != nil {
var org *organization.Organization opts.Org = ctx.Org.Organization
if ctx.Org != nil { opts.Team = ctx.Org.Team
org = ctx.Org.Organization
team = ctx.Org.Team
}
isPullList := unitType == unit.TypePullRequests issue.PrepareFilterIssueLabels(ctx, 0, ctx.Org.Organization.AsUser())
opts := &issues_model.IssuesOptions{ if ctx.Written() {
IsPull: optional.Some(isPullList), return
SortType: sortType, }
IsArchived: optional.Some(false),
Org: org,
Team: team,
User: ctx.Doer,
} }
// Get filter by author id & assignee id // Get filter by author id & assignee id
// FIXME: this feature doesn't work at the moment, because frontend can't use a "user-remote-search" dropdown directly
// the existing "/posters" handlers doesn't work for this case, it is unable to list the related users correctly. // the existing "/posters" handlers doesn't work for this case, it is unable to list the related users correctly.
// In the future, we need something like github: "author:user1" to accept usernames directly. // In the future, we need something like github: "author:user1" to accept usernames directly.
posterUsername := ctx.FormString("poster") posterUsername := ctx.FormString("poster")
ctx.Data["FilterPosterUsername"] = posterUsername
opts.PosterID = user.GetFilterUserIDByName(ctx, posterUsername) opts.PosterID = user.GetFilterUserIDByName(ctx, posterUsername)
// TODO: "assignee" should also use GetFilterUserIDByName in the future to support usernames directly assigneeUsername := ctx.FormString("assignee")
opts.AssigneeID, _ = strconv.ParseInt(ctx.FormString("assignee"), 10, 64) ctx.Data["FilterAssigneeUsername"] = assigneeUsername
opts.AssigneeID = user.GetFilterUserIDByName(ctx, assigneeUsername)
isFuzzy := ctx.FormBool("fuzzy") isFuzzy := ctx.FormBool("fuzzy")
@ -471,8 +473,8 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
UnitType: unitType, UnitType: unitType,
Archived: optional.Some(false), Archived: optional.Some(false),
} }
if team != nil { if opts.Team != nil {
repoOpts.TeamID = team.ID repoOpts.TeamID = opts.Team.ID
} }
accessibleRepos := container.Set[int64]{} accessibleRepos := container.Set[int64]{}
{ {
@ -500,9 +502,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 = ctx.Doer.ID opts.AssigneeID = optional.Some(ctx.Doer.ID)
case issues_model.FilterModeCreate: case issues_model.FilterModeCreate:
opts.PosterID = ctx.Doer.ID opts.PosterID = optional.Some(ctx.Doer.ID)
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:
@ -584,10 +586,6 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// because the doer may create issues or be mentioned in any public repo. // because the doer may create issues or be mentioned in any public repo.
// So we need search issues in all public repos. // So we need search issues in all public repos.
o.AllPublic = ctx.Doer.ID == ctxUser.ID o.AllPublic = ctx.Doer.ID == ctxUser.ID
// TODO: to make it work with poster/assignee filter, then these IDs should be kept
o.AssigneeID = nil
o.PosterID = nil
o.MentionID = nil o.MentionID = nil
o.ReviewRequestedID = nil o.ReviewRequestedID = nil
o.ReviewedID = nil o.ReviewedID = nil
@ -645,10 +643,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
ctx.Data["ViewType"] = viewType ctx.Data["ViewType"] = viewType
ctx.Data["SortType"] = sortType ctx.Data["SortType"] = sortType
ctx.Data["IsShowClosed"] = isShowClosed ctx.Data["IsShowClosed"] = isShowClosed
ctx.Data["SelectLabels"] = selectedLabels
ctx.Data["IsFuzzy"] = isFuzzy ctx.Data["IsFuzzy"] = isFuzzy
ctx.Data["SearchFilterPosterID"] = util.Iif[any](opts.PosterID != 0, opts.PosterID, nil)
ctx.Data["SearchFilterAssigneeID"] = util.Iif[any](opts.AssigneeID != 0, opts.AssigneeID, nil)
if isShowClosed { if isShowClosed {
ctx.Data["State"] = "closed" ctx.Data["State"] = "closed"
@ -657,16 +652,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
} }
pager := context.NewPagination(shownIssues, setting.UI.IssuePagingNum, page, 5) pager := context.NewPagination(shownIssues, setting.UI.IssuePagingNum, page, 5)
pager.AddParamString("q", keyword) pager.AddParamFromRequest(ctx.Req)
pager.AddParamString("type", viewType)
pager.AddParamString("sort", sortType)
pager.AddParamString("state", fmt.Sprint(ctx.Data["State"]))
pager.AddParamString("labels", selectedLabels)
pager.AddParamString("fuzzy", fmt.Sprint(isFuzzy))
pager.AddParamString("poster", posterUsername)
if opts.AssigneeID != 0 {
pager.AddParamString("assignee", fmt.Sprint(opts.AssigneeID))
}
ctx.Data["Page"] = pager ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplIssues) ctx.HTML(http.StatusOK, tplIssues)

View File

@ -1208,7 +1208,7 @@ func registerRoutes(m *web.Router) {
Post(web.Bind(forms.CreateIssueForm{}), repo.NewIssuePost) Post(web.Bind(forms.CreateIssueForm{}), repo.NewIssuePost)
m.Get("/choose", context.RepoRef(), repo.NewIssueChooseTemplate) m.Get("/choose", context.RepoRef(), repo.NewIssueChooseTemplate)
}) })
m.Get("/search", repo.ListIssues) m.Get("/search", repo.SearchRepoIssuesJSON)
}, context.RepoMustNotBeArchived(), reqRepoIssueReader) }, context.RepoMustNotBeArchived(), reqRepoIssueReader)
// FIXME: should use different URLs but mostly same logic for comments of issue and pull request. // FIXME: should use different URLs but mostly same logic for comments of issue and pull request.

View File

@ -6,6 +6,7 @@ package context
import ( import (
"fmt" "fmt"
"html/template" "html/template"
"net/http"
"net/url" "net/url"
"strings" "strings"
@ -32,6 +33,18 @@ func (p *Pagination) AddParamString(key, value string) {
p.urlParams = append(p.urlParams, urlParam) p.urlParams = append(p.urlParams, urlParam)
} }
func (p *Pagination) AddParamFromRequest(req *http.Request) {
for key, values := range req.URL.Query() {
if key == "page" || len(values) == 0 {
continue
}
for _, value := range values {
urlParam := fmt.Sprintf("%s=%v", key, url.QueryEscape(value))
p.urlParams = append(p.urlParams, urlParam)
}
}
}
// GetParams returns the configured URL params // GetParams returns the configured URL params
func (p *Pagination) GetParams() template.URL { func (p *Pagination) GetParams() template.URL {
return template.URL(strings.Join(p.urlParams, "&")) return template.URL(strings.Join(p.urlParams, "&"))

View File

@ -24,16 +24,19 @@
<input type="hidden" name="state" value="{{$.State}}"> <input type="hidden" name="state" value="{{$.State}}">
{{template "shared/search/combo" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.project_kind")}} {{template "shared/search/combo" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.project_kind")}}
</form> </form>
<!-- Sort -->
<div class="list-header-sort ui small dropdown type jump item"> <div class="list-header-filters">
<span class="text"> <!-- Sort -->
{{ctx.Locale.Tr "repo.issues.filter_sort"}} <div class="item ui small dropdown jump">
</span> <span class="text">
{{svg "octicon-triangle-down" 14 "dropdown icon"}} {{ctx.Locale.Tr "repo.issues.filter_sort"}}
<div class="menu"> </span>
<a class="{{if eq .SortType "oldest"}}active {{end}}item" href="?q={{$.Keyword}}&sort=oldest&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.filter_sort.oldest"}}</a> {{svg "octicon-triangle-down" 14 "dropdown icon"}}
<a class="{{if eq .SortType "recentupdate"}}active {{end}}item" href="?q={{$.Keyword}}&sort=recentupdate&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.filter_sort.recentupdate"}}</a> <div class="menu">
<a class="{{if eq .SortType "leastupdate"}}active {{end}}item" href="?q={{$.Keyword}}&sort=leastupdate&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastupdate"}}</a> <a class="{{if eq .SortType "oldest"}}active {{end}}item" href="?q={{$.Keyword}}&sort=oldest&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.filter_sort.oldest"}}</a>
<a class="{{if eq .SortType "recentupdate"}}active {{end}}item" href="?q={{$.Keyword}}&sort=recentupdate&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.filter_sort.recentupdate"}}</a>
<a class="{{if eq .SortType "leastupdate"}}active {{end}}item" href="?q={{$.Keyword}}&sort=leastupdate&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastupdate"}}</a>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -19,7 +19,7 @@
<span data-tooltip-content={{ctx.Locale.Tr "repo.issues.label_archive_tooltip"}}>{{svg "octicon-info"}}</span> <span data-tooltip-content={{ctx.Locale.Tr "repo.issues.label_archive_tooltip"}}>{{svg "octicon-info"}}</span>
</label> </label>
{{end}} {{end}}
<span class="info">{{ctx.Locale.Tr "repo.issues.filter_label_exclude"}}</span> <span class="label-filter-exclude-info">{{ctx.Locale.Tr "repo.issues.filter_label_exclude"}}</span>
<div class="divider"></div> <div class="divider"></div>
<a class="item label-filter-query-default" href="{{QueryBuild $queryLink "labels" NIL}}">{{ctx.Locale.Tr "repo.issues.filter_label_no_select"}}</a> <a class="item label-filter-query-default" href="{{QueryBuild $queryLink "labels" NIL}}">{{ctx.Locale.Tr "repo.issues.filter_label_no_select"}}</a>
<a class="item label-filter-query-not-set" href="{{QueryBuild $queryLink "labels" 0}}">{{ctx.Locale.Tr "repo.issues.filter_label_select_no_label"}}</a> <a class="item label-filter-query-not-set" href="{{QueryBuild $queryLink "labels" 0}}">{{ctx.Locale.Tr "repo.issues.filter_label_select_no_label"}}</a>

View File

@ -2,13 +2,13 @@
* QueryParamKey: eg: "poster", "assignee" * QueryParamKey: eg: "poster", "assignee"
* QueryLink * QueryLink
* UserSearchUrl * UserSearchUrl
* SelectedUserId * SelectedUsername
* TextFilterTitle * TextFilterTitle
*/}} */}}
{{$queryLink := .QueryLink}} {{$queryLink := .QueryLink}}
<div class="item ui dropdown custom user-remote-search" data-tooltip-content="{{ctx.Locale.Tr "repo.user_search_tooltip"}}" <div class="item ui dropdown custom user-remote-search" data-tooltip-content="{{ctx.Locale.Tr "repo.user_search_tooltip"}}"
data-search-url="{{$.UserSearchUrl}}" data-search-url="{{$.UserSearchUrl}}"
data-selected-user-id="{{$.SelectedUserId}}" data-selected-username="{{$.SelectedUsername}}"
data-action-jump-url="{{QueryBuild $queryLink $.QueryParamKey NIL}}&{{$.QueryParamKey}}={username}" data-action-jump-url="{{QueryBuild $queryLink $.QueryParamKey NIL}}&{{$.QueryParamKey}}={username}"
> >
{{$.TextFilterTitle}} {{svg "octicon-triangle-down" 14 "dropdown icon"}} {{$.TextFilterTitle}} {{svg "octicon-triangle-down" 14 "dropdown icon"}}

View File

@ -4,7 +4,7 @@
{{if not .Milestone}} {{if not .Milestone}}
<!-- Milestone --> <!-- Milestone -->
<div class="ui {{if not (or .OpenMilestones .ClosedMilestones)}}disabled{{end}} dropdown jump item"> <div class="item ui dropdown jump {{if not (or .OpenMilestones .ClosedMilestones)}}disabled{{end}}">
<span class="text"> <span class="text">
{{ctx.Locale.Tr "repo.issues.filter_milestone"}} {{ctx.Locale.Tr "repo.issues.filter_milestone"}}
</span> </span>
@ -42,7 +42,7 @@
{{end}} {{end}}
<!-- Project --> <!-- Project -->
<div class="ui{{if not (or .OpenProjects .ClosedProjects)}} disabled{{end}} dropdown jump item"> <div class="item ui dropdown jump {{if not (or .OpenProjects .ClosedProjects)}}disabled{{end}}">
<span class="text"> <span class="text">
{{ctx.Locale.Tr "repo.issues.filter_project"}} {{ctx.Locale.Tr "repo.issues.filter_project"}}
</span> </span>
@ -84,7 +84,7 @@
"QueryParamKey" "poster" "QueryParamKey" "poster"
"QueryLink" $queryLink "QueryLink" $queryLink
"UserSearchUrl" (Iif .Milestone (print $.RepoLink "/issues/posters") (print $.Link "/posters")) "UserSearchUrl" (Iif .Milestone (print $.RepoLink "/issues/posters") (print $.Link "/posters"))
"SelectedUserId" $.PosterUserID "SelectedUsername" $.PosterUsername
"TextFilterTitle" (ctx.Locale.Tr "repo.issues.filter_poster") "TextFilterTitle" (ctx.Locale.Tr "repo.issues.filter_poster")
}} }}
@ -100,7 +100,7 @@
{{if .IsSigned}} {{if .IsSigned}}
<!-- Type --> <!-- Type -->
<div class="ui dropdown type jump item"> <div class="item ui dropdown jump">
<span class="text"> <span class="text">
{{ctx.Locale.Tr "repo.issues.filter_type"}} {{ctx.Locale.Tr "repo.issues.filter_type"}}
</span> </span>
@ -119,7 +119,7 @@
{{end}} {{end}}
<!-- Sort --> <!-- Sort -->
<div class="list-header-sort ui small dropdown downward type jump item"> <div class="item ui dropdown jump">
<span class="text"> <span class="text">
{{ctx.Locale.Tr "repo.issues.filter_sort"}} {{ctx.Locale.Tr "repo.issues.filter_sort"}}
</span> </span>

View File

@ -1,5 +1,5 @@
<!-- Sort --> <!-- Sort -->
<div class="list-header-sort ui small dropdown type jump item"> <div class="item ui small dropdown jump">
<span class="text"> <span class="text">
{{ctx.Locale.Tr "repo.issues.filter_sort"}} {{ctx.Locale.Tr "repo.issues.filter_sort"}}
</span> </span>

View File

@ -3,7 +3,7 @@
<input type="hidden" name="state" value="{{$.State}}"> <input type="hidden" name="state" value="{{$.State}}">
{{if not .PageIsMilestones}} {{if not .PageIsMilestones}}
<input type="hidden" name="type" value="{{$.ViewType}}"> <input type="hidden" name="type" value="{{$.ViewType}}">
<input type="hidden" name="labels" value="{{.SelectLabels}}"> <input type="hidden" name="labels" value="{{$.SelectLabels}}">
<input type="hidden" name="milestone" value="{{$.MilestoneID}}"> <input type="hidden" name="milestone" value="{{$.MilestoneID}}">
<input type="hidden" name="project" value="{{$.ProjectID}}"> <input type="hidden" name="project" value="{{$.ProjectID}}">
<input type="hidden" name="assignee" value="{{$.AssigneeID}}"> <input type="hidden" name="assignee" value="{{$.AssigneeID}}">

View File

@ -4,7 +4,7 @@
<div class="ui container"> <div class="ui container">
{{template "base/alert" .}} {{template "base/alert" .}}
<div class="flex-container"> <div class="flex-container">
{{$queryLink := QueryBuild "?" "type" $.ViewType "sort" $.SortType "state" $.State "q" $.Keyword "fuzzy" $.IsFuzzy}} {{$queryLink := QueryBuild "?" "type" $.ViewType "sort" $.SortType "state" $.State "q" $.Keyword "labels" .SelectLabels "fuzzy" $.IsFuzzy}}
<div class="flex-container-nav"> <div class="flex-container-nav">
<div class="ui secondary vertical filter menu tw-bg-transparent"> <div class="ui secondary vertical filter menu tw-bg-transparent">
<a class="{{if eq .ViewType "your_repositories"}}active{{end}} item" href="{{QueryBuild $queryLink "type" "your_repositories"}}"> <a class="{{if eq .ViewType "your_repositories"}}active{{end}} item" href="{{QueryBuild $queryLink "type" "your_repositories"}}">
@ -36,7 +36,7 @@
</div> </div>
</div> </div>
{{$queryLinkWithFilter := QueryBuild $queryLink "poster" $.SearchFilterPosterUsername "assignee" $.SearchFilterAssigneeID}} {{$queryLinkWithFilter := QueryBuild $queryLink "poster" $.FilterPosterUsername "assignee" $.FilterAssigneeUsername}}
<div class="flex-container-main content"> <div class="flex-container-main content">
<div class="list-header"> <div class="list-header">
<div class="small-menu-items ui compact tiny menu list-header-toggle flex-items-block"> <div class="small-menu-items ui compact tiny menu list-header-toggle flex-items-block">
@ -50,28 +50,51 @@
</a> </a>
</div> </div>
<form class="list-header-search ui form ignore-dirty"> <form class="list-header-search ui form ignore-dirty">
<div class="ui small search fluid action input"> <input type="hidden" name="type" value="{{$.ViewType}}">
<input type="hidden" name="type" value="{{$.ViewType}}"> <input type="hidden" name="sort" value="{{$.SortType}}">
<input type="hidden" name="sort" value="{{$.SortType}}"> <input type="hidden" name="state" value="{{$.State}}">
<input type="hidden" name="state" value="{{$.State}}"> {{template "shared/search/combo_fuzzy" dict "Value" $.Keyword "IsFuzzy" $.IsFuzzy "Placeholder" (ctx.Locale.Tr (Iif .PageIsPulls "search.pull_kind" "search.issue_kind")) "Tooltip" (ctx.Locale.Tr "explore.go_to")}}
{{template "shared/search/combo_fuzzy" dict "Value" $.Keyword "IsFuzzy" $.IsFuzzy "Placeholder" (ctx.Locale.Tr (Iif .PageIsPulls "search.pull_kind" "search.issue_kind")) "Tooltip" (ctx.Locale.Tr "explore.go_to")}}
</div>
</form> </form>
<!-- Sort -->
<div class="list-header-sort ui small dropdown type jump item"> <div class="list-header-filters">
<span class="text tw-whitespace-nowrap"> {{if $.Labels}}
{{ctx.Locale.Tr "repo.issues.filter_sort"}} {{template "repo/issue/filter_item_label" dict "Labels" .Labels "QueryLink" $queryLinkWithFilter "SupportArchivedLabel" true}}
{{svg "octicon-triangle-down" 14 "dropdown icon"}} {{end}}
</span>
<div class="menu"> {{if ne $.ViewType "created_by"}}
<a class="{{if eq .SortType "recentupdate"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "recentupdate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.recentupdate"}}</a> {{template "repo/issue/filter_item_user_fetch" dict
<a class="{{if eq .SortType "leastupdate"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "leastupdate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastupdate"}}</a> "QueryParamKey" "poster"
<a class="{{if eq .SortType "latest"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "latest"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.latest"}}</a> "QueryLink" $queryLinkWithFilter
<a class="{{if eq .SortType "oldest"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "oldest"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.oldest"}}</a> "SelectedUsername" $.FilterPosterUsername
<a class="{{if eq .SortType "mostcomment"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "mostcomment"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.mostcomment"}}</a> "TextFilterTitle" (ctx.Locale.Tr "repo.issues.filter_poster")
<a class="{{if eq .SortType "leastcomment"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "leastcomment"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastcomment"}}</a> }}
<a class="{{if eq .SortType "nearduedate"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "nearduedate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.nearduedate"}}</a> {{end}}
<a class="{{if eq .SortType "farduedate"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "farduedate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.farduedate"}}</a>
{{if ne $.ViewType "assigned"}}
{{template "repo/issue/filter_item_user_fetch" dict
"QueryParamKey" "assignee"
"QueryLink" $queryLinkWithFilter
"SelectedUsername" $.FilterAssigneeUsername
"TextFilterTitle" (ctx.Locale.Tr "repo.issues.filter_assignee")
}}
{{end}}
<!-- Sort -->
<div class="item ui small dropdown jump">
<span class="text tw-whitespace-nowrap">
{{ctx.Locale.Tr "repo.issues.filter_sort"}}
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
</span>
<div class="menu">
<a class="{{if eq .SortType "recentupdate"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "recentupdate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.recentupdate"}}</a>
<a class="{{if eq .SortType "leastupdate"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "leastupdate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastupdate"}}</a>
<a class="{{if eq .SortType "latest"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "latest"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.latest"}}</a>
<a class="{{if eq .SortType "oldest"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "oldest"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.oldest"}}</a>
<a class="{{if eq .SortType "mostcomment"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "mostcomment"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.mostcomment"}}</a>
<a class="{{if eq .SortType "leastcomment"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "leastcomment"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastcomment"}}</a>
<a class="{{if eq .SortType "nearduedate"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "nearduedate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.nearduedate"}}</a>
<a class="{{if eq .SortType "farduedate"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "farduedate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.farduedate"}}</a>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -52,20 +52,22 @@
<input type="hidden" name="state" value="{{$.State}}"> <input type="hidden" name="state" value="{{$.State}}">
{{template "shared/search/combo" dict "Value" $.Keyword}} {{template "shared/search/combo" dict "Value" $.Keyword}}
</form> </form>
<!-- Sort --> <div class="list-header-filters">
<div class="list-header-sort ui dropdown type jump item"> <!-- Sort -->
<span class="text"> <div class="item ui dropdown jump">
{{ctx.Locale.Tr "repo.issues.filter_sort"}} <span class="text">
</span> {{ctx.Locale.Tr "repo.issues.filter_sort"}}
{{svg "octicon-triangle-down" 14 "dropdown icon"}} </span>
<div class="menu"> {{svg "octicon-triangle-down" 14 "dropdown icon"}}
<a class="{{if or (eq .SortType "closestduedate") (not .SortType)}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=closestduedate&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.earliest_due_data"}}</a> <div class="menu">
<a class="{{if eq .SortType "furthestduedate"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=furthestduedate&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.latest_due_date"}}</a> <a class="{{if or (eq .SortType "closestduedate") (not .SortType)}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=closestduedate&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.earliest_due_data"}}</a>
<a class="{{if eq .SortType "leastcomplete"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastcomplete&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.least_complete"}}</a> <a class="{{if eq .SortType "furthestduedate"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=furthestduedate&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.latest_due_date"}}</a>
<a class="{{if eq .SortType "mostcomplete"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=mostcomplete&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.most_complete"}}</a> <a class="{{if eq .SortType "leastcomplete"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastcomplete&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.least_complete"}}</a>
<a class="{{if eq .SortType "mostissues"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=mostissues&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.most_issues"}}</a> <a class="{{if eq .SortType "mostcomplete"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=mostcomplete&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.most_complete"}}</a>
<a class="{{if eq .SortType "leastissues"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastissues&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.least_issues"}}</a> <a class="{{if eq .SortType "mostissues"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=mostissues&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.most_issues"}}</a>
<a class="{{if eq .SortType "name"}}active {{end}}item" href="{{$.Link}}?sort=name&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.name"}}</a> <a class="{{if eq .SortType "leastissues"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastissues&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.least_issues"}}</a>
<a class="{{if eq .SortType "name"}}active {{end}}item" href="{{$.Link}}?sort=name&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.name"}}</a>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -90,24 +90,6 @@
left: 0; left: 0;
} }
.repository .filter.menu .ui.dropdown.label-filter .menu .info {
display: inline-block;
padding: 0.5rem 0;
font-size: 12px;
width: 100%;
white-space: nowrap;
margin-left: 10px;
margin-right: 8px;
text-align: left;
}
.repository .filter.menu .ui.dropdown.label-filter .menu .info code {
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
padding: 1px 2px;
font-size: 11px;
}
/* For the secondary pointing menu, respect its own border-bottom */ /* For the secondary pointing menu, respect its own border-bottom */
/* style reference: https://semantic-ui.com/collections/menu.html#pointing */ /* style reference: https://semantic-ui.com/collections/menu.html#pointing */
.repository .ui.tabs.container .ui.menu:not(.secondary.pointing) { .repository .ui.tabs.container .ui.menu:not(.secondary.pointing) {

View File

@ -73,3 +73,21 @@
font-size: 12px; font-size: 12px;
min-width: fit-content; min-width: fit-content;
} }
.label-filter-exclude-info {
display: inline-block;
padding: 0.5rem 0;
font-size: 12px;
width: 100%;
white-space: nowrap;
margin-left: 10px;
margin-right: 8px;
text-align: left;
}
.label-filter-exclude-info code {
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
padding: 1px 2px;
font-size: 11px;
}

View File

@ -5,13 +5,6 @@
gap: .5rem; gap: .5rem;
} }
.list-header-sort {
display: flex;
align-items: center;
padding-left: 1rem;
padding-right: 1rem;
}
.list-header-search { .list-header-search {
display: flex; display: flex;
flex: 1; flex: 1;
@ -21,8 +14,22 @@
min-width: 200px; /* to enable flexbox wrapping on mobile */ min-width: 200px; /* to enable flexbox wrapping on mobile */
} }
.list-header-search .input { .list-header-search > .ui.input {
flex: 1; flex: 1;
min-width: 100px !important;
}
.list-header-search > .ui.input .ui.dropdown {
min-width: auto !important;
}
.list-header-filters {
display: flex;
align-items: center;
}
.list-header-filters > .item {
padding: 5px 0 5px 10px;
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
@ -32,8 +39,7 @@
.list-header-toggle { .list-header-toggle {
order: 1; order: 1;
} }
.list-header-sort { .list-header-filters {
order: 2; order: 2;
margin-left: auto;
} }
} }

View File

@ -95,10 +95,9 @@ function initRepoIssueListCheckboxes() {
function initDropdownUserRemoteSearch(el: Element) { function initDropdownUserRemoteSearch(el: Element) {
let searchUrl = el.getAttribute('data-search-url'); let searchUrl = el.getAttribute('data-search-url');
const actionJumpUrl = el.getAttribute('data-action-jump-url'); const actionJumpUrl = el.getAttribute('data-action-jump-url');
const selectedUserId = parseInt(el.getAttribute('data-selected-user-id')); let selectedUsername = el.getAttribute('data-selected-username') || '';
let selectedUsername = '';
if (!searchUrl.includes('?')) searchUrl += '?';
const $searchDropdown = fomanticQuery(el); const $searchDropdown = fomanticQuery(el);
const elMenu = el.querySelector('.menu');
const elSearchInput = el.querySelector<HTMLInputElement>('.ui.search input'); const elSearchInput = el.querySelector<HTMLInputElement>('.ui.search input');
const elItemFromInput = el.querySelector('.menu > .item-from-input'); const elItemFromInput = el.querySelector('.menu > .item-from-input');
@ -110,17 +109,27 @@ function initDropdownUserRemoteSearch(el: Element) {
}, },
}); });
const selectUsername = (username: string) => {
queryElems(elMenu, '.item.active, .item.selected', (el) => el.classList.remove('active', 'selected'));
elMenu.querySelector(`.item[data-value="${CSS.escape(username)}"]`)?.classList.add('selected');
};
type ProcessedResult = {value: string, name: string}; type ProcessedResult = {value: string, name: string};
const processedResults: ProcessedResult[] = []; // to be used by dropdown to generate menu items const processedResults: ProcessedResult[] = []; // to be used by dropdown to generate menu items
const syncItemFromInput = () => { const syncItemFromInput = () => {
elItemFromInput.setAttribute('data-value', elSearchInput.value); const inputVal = elSearchInput.value.trim();
elItemFromInput.textContent = elSearchInput.value; elItemFromInput.setAttribute('data-value', inputVal);
toggleElem(elItemFromInput, !processedResults.length); elItemFromInput.textContent = inputVal;
const showItemFromInput = !processedResults.length && inputVal !== '';
toggleElem(elItemFromInput, showItemFromInput);
selectUsername(showItemFromInput ? inputVal : selectedUsername);
}; };
elSearchInput.value = selectedUsername;
if (!searchUrl) { if (!searchUrl) {
elSearchInput.addEventListener('input', syncItemFromInput); elSearchInput.addEventListener('input', syncItemFromInput);
} else { } else {
if (!searchUrl.includes('?')) searchUrl += '?';
$searchDropdown.dropdown('setting', 'apiSettings', { $searchDropdown.dropdown('setting', 'apiSettings', {
cache: false, cache: false,
url: `${searchUrl}&q={query}`, url: `${searchUrl}&q={query}`,
@ -130,11 +139,10 @@ function initDropdownUserRemoteSearch(el: Element) {
for (const item of resp.results) { for (const item of resp.results) {
let html = `<img class="ui avatar tw-align-middle" src="${htmlEscape(item.avatar_link)}" aria-hidden="true" alt="" width="20" height="20"><span class="gt-ellipsis">${htmlEscape(item.username)}</span>`; let html = `<img class="ui avatar tw-align-middle" src="${htmlEscape(item.avatar_link)}" aria-hidden="true" alt="" width="20" height="20"><span class="gt-ellipsis">${htmlEscape(item.username)}</span>`;
if (item.full_name) html += `<span class="search-fullname tw-ml-2">${htmlEscape(item.full_name)}</span>`; if (item.full_name) html += `<span class="search-fullname tw-ml-2">${htmlEscape(item.full_name)}</span>`;
if (selectedUserId === item.user_id) selectedUsername = item.username; if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username;
processedResults.push({value: item.username, name: html}); processedResults.push({value: item.username, name: html});
} }
resp.results = processedResults; resp.results = processedResults;
syncItemFromInput();
return resp; return resp;
}, },
}); });
@ -146,9 +154,8 @@ function initDropdownUserRemoteSearch(el: Element) {
const dropdownTemplates = $searchDropdown.dropdown('setting', 'templates'); const dropdownTemplates = $searchDropdown.dropdown('setting', 'templates');
$searchDropdown.dropdown('internal', 'setup', dropdownSetup); $searchDropdown.dropdown('internal', 'setup', dropdownSetup);
dropdownSetup.menu = function (values) { dropdownSetup.menu = function (values) {
const menu = $searchDropdown.find('> .menu')[0];
// remove old dynamic items // remove old dynamic items
for (const el of menu.querySelectorAll(':scope > .dynamic-item')) { for (const el of elMenu.querySelectorAll(':scope > .dynamic-item')) {
el.remove(); el.remove();
} }
@ -160,16 +167,11 @@ function initDropdownUserRemoteSearch(el: Element) {
} }
const div = document.createElement('div'); const div = document.createElement('div');
div.classList.add('divider', 'dynamic-item'); div.classList.add('divider', 'dynamic-item');
menu.append(div, ...newMenuItems); elMenu.append(div, ...newMenuItems);
} }
$searchDropdown.dropdown('refresh'); $searchDropdown.dropdown('refresh');
// defer our selection to the next tick, because dropdown will set the selection item after this `menu` function // defer our selection to the next tick, because dropdown will set the selection item after this `menu` function
setTimeout(() => { setTimeout(() => syncItemFromInput(), 0);
for (const el of menu.querySelectorAll('.item.active, .item.selected')) {
el.classList.remove('active', 'selected');
}
menu.querySelector(`.item[data-value="${CSS.escape(selectedUsername)}"]`)?.classList.add('selected');
}, 0);
}; };
} }
@ -221,8 +223,12 @@ async function initIssuePinSort() {
} }
export function initRepoIssueList() { export function initRepoIssueList() {
if (!document.querySelector('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list')) return; if (document.querySelector('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list')) {
initRepoIssueListCheckboxes(); initRepoIssueListCheckboxes();
queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el)); queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
initIssuePinSort(); initIssuePinSort();
} else if (document.querySelector('.page-content.dashboard.issues')) {
// user or org home: issue list, pull request list
queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
}
} }