aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorwxiaoguang <wxiaoguang@gmail.com>2024-12-11 14:33:24 +0800
committerGitHub <noreply@github.com>2024-12-11 06:33:24 +0000
commite619384098419569e570796a57ee6af4948067ae (patch)
tree9c5413be580d91ed9912878f80336eab89543670
parent734ddf71180a9bf843d12dd9664eed28ba1a5748 (diff)
downloadgitea-e619384098419569e570796a57ee6af4948067ae.tar.gz
gitea-e619384098419569e570796a57ee6af4948067ae.zip
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.
-rw-r--r--models/db/search.go12
-rw-r--r--models/issues/issue_search.go39
-rw-r--r--models/issues/issue_stats.go10
-rw-r--r--models/issues/issue_test.go3
-rw-r--r--modules/indexer/issues/db/options.go4
-rw-r--r--modules/indexer/issues/dboptions.go12
-rw-r--r--modules/indexer/issues/indexer_test.go2
-rw-r--r--routers/web/org/projects.go21
-rw-r--r--routers/web/repo/issue_list.go118
-rw-r--r--routers/web/repo/projects.go19
-rw-r--r--routers/web/shared/issue/issue_label.go71
-rw-r--r--routers/web/shared/user/helper.go17
-rw-r--r--routers/web/user/home.go62
-rw-r--r--routers/web/web.go2
-rw-r--r--services/context/pagination.go13
-rw-r--r--templates/projects/list.tmpl23
-rw-r--r--templates/repo/issue/filter_item_label.tmpl2
-rw-r--r--templates/repo/issue/filter_item_user_fetch.tmpl4
-rw-r--r--templates/repo/issue/filter_list.tmpl10
-rw-r--r--templates/repo/issue/milestone/filter_list.tmpl2
-rw-r--r--templates/repo/issue/search.tmpl2
-rw-r--r--templates/user/dashboard/issues.tmpl69
-rw-r--r--templates/user/dashboard/milestones.tmpl30
-rw-r--r--web_src/css/repo.css18
-rw-r--r--web_src/css/repo/issue-list.css18
-rw-r--r--web_src/css/repo/list-header.css26
-rw-r--r--web_src/js/features/repo-issue-list.ts48
27 files changed, 338 insertions, 319 deletions
diff --git a/models/db/search.go b/models/db/search.go
index 37565f45e1..e0a1b6bde9 100644
--- a/models/db/search.go
+++ b/models/db/search.go
@@ -26,8 +26,10 @@ const (
SearchOrderByForksReverse SearchOrderBy = "num_forks DESC"
)
-const (
- // Which means a condition to filter the records which don't match any id.
- // It's different from zero which means the condition could be ignored.
- NoConditionID = -1
-)
+// 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.
+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
diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go
index 5948a67d4e..f1cd125d49 100644
--- a/models/issues/issue_search.go
+++ b/models/issues/issue_search.go
@@ -27,8 +27,8 @@ type IssuesOptions struct { //nolint
RepoIDs []int64 // overwrites RepoCond if the length is not 0
AllPublic bool // include also all public repositories
RepoCond builder.Cond
- AssigneeID int64
- PosterID int64
+ AssigneeID optional.Option[int64]
+ PosterID optional.Option[int64]
MentionedID int64
ReviewRequestedID int64
ReviewedID int64
@@ -231,15 +231,8 @@ func applyConditions(sess *xorm.Session, opts *IssuesOptions) {
sess.And("issue.is_closed=?", opts.IsClosed.Value())
}
- if opts.AssigneeID > 0 {
- 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)
- }
+ applyAssigneeCondition(sess, opts.AssigneeID)
+ applyPosterCondition(sess, opts.PosterID)
if opts.MentionedID > 0 {
applyMentionedCondition(sess, opts.MentionedID)
@@ -359,13 +352,27 @@ func issuePullAccessibleRepoCond(repoIDstr string, userID int64, org *organizati
return cond
}
-func applyAssigneeCondition(sess *xorm.Session, assigneeID int64) {
- sess.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id").
- And("issue_assignees.assignee_id = ?", assigneeID)
+func applyAssigneeCondition(sess *xorm.Session, assigneeID optional.Option[int64]) {
+ // old logic: 0 is also treated as "not filtering assignee", because the "assignee" was read as FormInt64
+ 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) {
- sess.And("issue.poster_id=?", posterID)
+func applyPosterCondition(sess *xorm.Session, posterID optional.Option[int64]) {
+ 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) {
diff --git a/models/issues/issue_stats.go b/models/issues/issue_stats.go
index 39326616f8..9ef9347a16 100644
--- a/models/issues/issue_stats.go
+++ b/models/issues/issue_stats.go
@@ -151,15 +151,9 @@ func applyIssuesOptions(sess *xorm.Session, opts *IssuesOptions, issueIDs []int6
applyProjectCondition(sess, opts)
- if opts.AssigneeID > 0 {
- applyAssigneeCondition(sess, opts.AssigneeID)
- } else if opts.AssigneeID == db.NoConditionID {
- sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
- }
+ applyAssigneeCondition(sess, opts.AssigneeID)
- if opts.PosterID > 0 {
- applyPosterCondition(sess, opts.PosterID)
- }
+ applyPosterCondition(sess, opts.PosterID)
if opts.MentionedID > 0 {
applyMentionedCondition(sess, opts.MentionedID)
diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go
index 1bbc0eee56..548f137f39 100644
--- a/models/issues/issue_test.go
+++ b/models/issues/issue_test.go
@@ -16,6 +16,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"github.com/stretchr/testify/assert"
@@ -155,7 +156,7 @@ func TestIssues(t *testing.T) {
}{
{
issues_model.IssuesOptions{
- AssigneeID: 1,
+ AssigneeID: optional.Some(int64(1)),
SortType: "oldest",
},
[]int64{1, 6},
diff --git a/modules/indexer/issues/db/options.go b/modules/indexer/issues/db/options.go
index 875a4ca279..98b097f871 100644
--- a/modules/indexer/issues/db/options.go
+++ b/modules/indexer/issues/db/options.go
@@ -54,8 +54,8 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
RepoIDs: options.RepoIDs,
AllPublic: options.AllPublic,
RepoCond: nil,
- AssigneeID: convertID(options.AssigneeID),
- PosterID: convertID(options.PosterID),
+ AssigneeID: optional.Some(convertID(options.AssigneeID)),
+ PosterID: options.PosterID,
MentionedID: convertID(options.MentionID),
ReviewRequestedID: convertID(options.ReviewRequestedID),
ReviewedID: convertID(options.ReviewedID),
diff --git a/modules/indexer/issues/dboptions.go b/modules/indexer/issues/dboptions.go
index c1f454eeee..1a0f241e61 100644
--- a/modules/indexer/issues/dboptions.go
+++ b/modules/indexer/issues/dboptions.go
@@ -40,14 +40,14 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
if opts.ProjectID > 0 {
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)
}
- if opts.AssigneeID > 0 {
- searchOpt.AssigneeID = optional.Some(opts.AssigneeID)
- } else if opts.AssigneeID == -1 { // FIXME: this is inconsistent from other places
- searchOpt.AssigneeID = optional.Some[int64](0)
+ if opts.AssigneeID.Value() == db.NoConditionID {
+ 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
@@ -62,7 +62,7 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
}
searchOpt.ProjectColumnID = convertID(opts.ProjectColumnID)
- searchOpt.PosterID = convertID(opts.PosterID)
+ searchOpt.PosterID = opts.PosterID
searchOpt.MentionID = convertID(opts.MentionedID)
searchOpt.ReviewedID = convertID(opts.ReviewedID)
searchOpt.ReviewRequestedID = convertID(opts.ReviewRequestedID)
diff --git a/modules/indexer/issues/indexer_test.go b/modules/indexer/issues/indexer_test.go
index 0dce654181..7c3ba75bb0 100644
--- a/modules/indexer/issues/indexer_test.go
+++ b/modules/indexer/issues/indexer_test.go
@@ -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.
- 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},
},
{
diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go
index b94344f2ec..3b9ec2a7b8 100644
--- a/routers/web/org/projects.go
+++ b/routers/web/org/projects.go
@@ -21,6 +21,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/web/shared/issue"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
@@ -334,23 +335,15 @@ func ViewProject(ctx *context.Context) {
return
}
- 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)
- }
+ labelIDs := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner)
+ if ctx.Written() {
+ return
}
-
- 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{
LabelIDs: labelIDs,
- AssigneeID: assigneeID,
+ AssigneeID: optional.Some(assigneeID),
})
if err != nil {
ctx.ServerError("LoadIssuesOfColumns", err)
@@ -426,8 +419,6 @@ func ViewProject(ctx *context.Context) {
return
}
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
-
- ctx.Data["SelectLabels"] = selectLabels
ctx.Data["AssigneeID"] = assigneeID
project.RenderedContent = templates.NewRenderUtils(ctx).MarkdownToHtml(project.Description)
diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go
index 6451f7ac76..ff98bf8ec8 100644
--- a/routers/web/repo/issue_list.go
+++ b/routers/web/repo/issue_list.go
@@ -17,12 +17,12 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
- "code.gitea.io/gitea/modules/base"
issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/web/shared/issue"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
@@ -263,8 +263,10 @@ func getUserIDForFilter(ctx *context.Context, queryName string) int64 {
return user.ID
}
-// ListIssues list the issues of a repository
-func ListIssues(ctx *context.Context) {
+// SearchRepoIssuesJSON lists the issues of a repository
+// 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)
if err != nil {
ctx.Error(http.StatusUnprocessableEntity, err.Error())
@@ -286,20 +288,11 @@ func ListIssues(ctx *context.Context) {
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
if part := strings.Split(ctx.FormString("milestones"), ","); len(part) > 0 {
for i := range part {
// 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])
if err == nil {
mileIDs = append(mileIDs, mile.ID)
@@ -370,17 +363,8 @@ func ListIssues(ctx *context.Context) {
if before != 0 {
searchOpt.UpdatedBeforeUnix = optional.Some(before)
}
- if len(labelIDs) == 1 && labelIDs[0] == 0 {
- searchOpt.NoLabelOnly = true
- } else {
- for _, labelID := range labelIDs {
- if labelID > 0 {
- searchOpt.IncludedLabelIDs = append(searchOpt.IncludedLabelIDs, labelID)
- } else {
- searchOpt.ExcludedLabelIDs = append(searchOpt.ExcludedLabelIDs, -labelID)
- }
- }
- }
+
+ // TODO: the "labels" query parameter is never used, so no need to handle it
if len(mileIDs) == 1 && mileIDs[0] == db.NoConditionID {
searchOpt.MilestoneIDs = []int64{0}
@@ -503,8 +487,8 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
if !util.SliceContainsString(types, viewType, true) {
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")
posterUserID := shared_user.GetFilterUserIDByName(ctx, posterUsername)
var mentionedID, reviewRequestedID, reviewedID int64
@@ -512,7 +496,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
if ctx.IsSigned {
switch viewType {
case "created_by":
- posterUserID = ctx.Doer.ID
+ posterUserID = optional.Some(ctx.Doer.ID)
case "mentioned":
mentionedID = ctx.Doer.ID
case "assigned":
@@ -525,18 +509,6 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
}
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"), " ")
if bytes.Contains([]byte(keyword), []byte{0x00}) {
keyword = ""
@@ -547,13 +519,18 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
mileIDs = []int64{milestoneID}
}
+ labelIDs := issue.PrepareFilterIssueLabels(ctx, repo.ID, ctx.Repo.Owner)
+ if ctx.Written() {
+ return
+ }
+
var issueStats *issues_model.IssueStats
statsOpts := &issues_model.IssuesOptions{
RepoIDs: []int64{repo.ID},
LabelIDs: labelIDs,
MilestoneIDs: mileIDs,
ProjectID: projectID,
- AssigneeID: assigneeID,
+ AssigneeID: optional.Some(assigneeID),
MentionedID: mentionedID,
PosterID: posterUserID,
ReviewRequestedID: reviewRequestedID,
@@ -634,7 +611,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
PageSize: setting.UI.IssuePagingNum,
},
RepoIDs: []int64{repo.ID},
- AssigneeID: assigneeID,
+ AssigneeID: optional.Some(assigneeID),
PosterID: posterUserID,
MentionedID: mentionedID,
ReviewRequestedID: reviewRequestedID,
@@ -709,49 +686,6 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
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["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["ClosedCount"] = issueStats.ClosedCount
ctx.Data["SelLabelIDs"] = labelIDs
- ctx.Data["SelectLabels"] = selectLabels
ctx.Data["ViewType"] = viewType
ctx.Data["SortType"] = sortType
ctx.Data["MilestoneID"] = milestoneID
ctx.Data["ProjectID"] = projectID
ctx.Data["AssigneeID"] = assigneeID
- ctx.Data["PosterUserID"] = posterUserID
ctx.Data["PosterUsername"] = posterUsername
ctx.Data["Keyword"] = keyword
ctx.Data["IsShowClosed"] = isShowClosed
@@ -810,19 +742,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
default:
ctx.Data["State"] = "open"
}
-
- 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")
- }
+ pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
}
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 168da2ca1f..3be9578670 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -23,6 +23,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/web/shared/issue"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
@@ -307,23 +308,13 @@ func ViewProject(ctx *context.Context) {
return
}
- 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)
- }
- }
+ labelIDs := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner)
- 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{
LabelIDs: labelIDs,
- AssigneeID: assigneeID,
+ AssigneeID: optional.Some(assigneeID),
})
if err != nil {
ctx.ServerError("LoadIssuesOfColumns", err)
@@ -409,8 +400,6 @@ func ViewProject(ctx *context.Context) {
return
}
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
-
- ctx.Data["SelectLabels"] = selectLabels
ctx.Data["AssigneeID"] = assigneeID
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository)
diff --git a/routers/web/shared/issue/issue_label.go b/routers/web/shared/issue/issue_label.go
new file mode 100644
index 0000000000..eacea36b02
--- /dev/null
+++ b/routers/web/shared/issue/issue_label.go
@@ -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
+}
diff --git a/routers/web/shared/user/helper.go b/routers/web/shared/user/helper.go
index 7268767e0a..b82181a1df 100644
--- a/routers/web/shared/user/helper.go
+++ b/routers/web/shared/user/helper.go
@@ -8,7 +8,9 @@ import (
"slices"
"strconv"
+ "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/optional"
)
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.
// 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.
-// Old usage: poster=123, new usage: poster=the-username (at the moment, non-existing username is treated as poster=0, not ideal but acceptable)
-func GetFilterUserIDByName(ctx context.Context, name string) int64 {
+// Return values:
+// * 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 == "" {
- return 0
+ return optional.None[int64]()
}
u, err := user.GetUserByName(ctx, name)
if 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)
}
diff --git a/routers/web/user/home.go b/routers/web/user/home.go
index 5a0d46869f..befa33b0c0 100644
--- a/routers/web/user/home.go
+++ b/routers/web/user/home.go
@@ -33,6 +33,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"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/services/context"
feed_service "code.gitea.io/gitea/services/feed"
@@ -413,6 +414,13 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
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.
// 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.
- var team *organization.Team
- var org *organization.Organization
- if ctx.Org != nil {
- org = ctx.Org.Organization
- team = ctx.Org.Team
- }
+ if ctx.Org != nil && ctx.Org.Organization != nil {
+ opts.Org = ctx.Org.Organization
+ opts.Team = ctx.Org.Team
- isPullList := unitType == unit.TypePullRequests
- opts := &issues_model.IssuesOptions{
- IsPull: optional.Some(isPullList),
- SortType: sortType,
- IsArchived: optional.Some(false),
- Org: org,
- Team: team,
- User: ctx.Doer,
+ issue.PrepareFilterIssueLabels(ctx, 0, ctx.Org.Organization.AsUser())
+ if ctx.Written() {
+ return
+ }
}
// 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.
// In the future, we need something like github: "author:user1" to accept usernames directly.
posterUsername := ctx.FormString("poster")
+ ctx.Data["FilterPosterUsername"] = posterUsername
opts.PosterID = user.GetFilterUserIDByName(ctx, posterUsername)
- // TODO: "assignee" should also use GetFilterUserIDByName in the future to support usernames directly
- opts.AssigneeID, _ = strconv.ParseInt(ctx.FormString("assignee"), 10, 64)
+ assigneeUsername := ctx.FormString("assignee")
+ ctx.Data["FilterAssigneeUsername"] = assigneeUsername
+ opts.AssigneeID = user.GetFilterUserIDByName(ctx, assigneeUsername)
isFuzzy := ctx.FormBool("fuzzy")
@@ -471,8 +473,8 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
UnitType: unitType,
Archived: optional.Some(false),
}
- if team != nil {
- repoOpts.TeamID = team.ID
+ if opts.Team != nil {
+ repoOpts.TeamID = opts.Team.ID
}
accessibleRepos := container.Set[int64]{}
{
@@ -500,9 +502,9 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
case issues_model.FilterModeAll:
case issues_model.FilterModeYourRepositories:
case issues_model.FilterModeAssign:
- opts.AssigneeID = ctx.Doer.ID
+ opts.AssigneeID = optional.Some(ctx.Doer.ID)
case issues_model.FilterModeCreate:
- opts.PosterID = ctx.Doer.ID
+ opts.PosterID = optional.Some(ctx.Doer.ID)
case issues_model.FilterModeMention:
opts.MentionedID = ctx.Doer.ID
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.
// So we need search issues in all public repos.
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.ReviewRequestedID = nil
o.ReviewedID = nil
@@ -645,10 +643,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
ctx.Data["ViewType"] = viewType
ctx.Data["SortType"] = sortType
ctx.Data["IsShowClosed"] = isShowClosed
- ctx.Data["SelectLabels"] = selectedLabels
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 {
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.AddParamString("q", keyword)
- 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))
- }
+ pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplIssues)
diff --git a/routers/web/web.go b/routers/web/web.go
index c87c01ea0f..72ee47bb4c 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1208,7 +1208,7 @@ func registerRoutes(m *web.Router) {
Post(web.Bind(forms.CreateIssueForm{}), repo.NewIssuePost)
m.Get("/choose", context.RepoRef(), repo.NewIssueChooseTemplate)
})
- m.Get("/search", repo.ListIssues)
+ m.Get("/search", repo.SearchRepoIssuesJSON)
}, context.RepoMustNotBeArchived(), reqRepoIssueReader)
// FIXME: should use different URLs but mostly same logic for comments of issue and pull request.
diff --git a/services/context/pagination.go b/services/context/pagination.go
index fb2ef699ce..42117cf96d 100644
--- a/services/context/pagination.go
+++ b/services/context/pagination.go
@@ -6,6 +6,7 @@ package context
import (
"fmt"
"html/template"
+ "net/http"
"net/url"
"strings"
@@ -32,6 +33,18 @@ func (p *Pagination) AddParamString(key, value string) {
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
func (p *Pagination) GetParams() template.URL {
return template.URL(strings.Join(p.urlParams, "&"))
diff --git a/templates/projects/list.tmpl b/templates/projects/list.tmpl
index b2f48fe2c9..f5a48f7241 100644
--- a/templates/projects/list.tmpl
+++ b/templates/projects/list.tmpl
@@ -24,16 +24,19 @@
<input type="hidden" name="state" value="{{$.State}}">
{{template "shared/search/combo" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.project_kind")}}
</form>
- <!-- Sort -->
- <div class="list-header-sort ui small dropdown type jump item">
- <span class="text">
- {{ctx.Locale.Tr "repo.issues.filter_sort"}}
- </span>
- {{svg "octicon-triangle-down" 14 "dropdown icon"}}
- <div class="menu">
- <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 class="list-header-filters">
+ <!-- Sort -->
+ <div class="item ui small dropdown jump">
+ <span class="text">
+ {{ctx.Locale.Tr "repo.issues.filter_sort"}}
+ </span>
+ {{svg "octicon-triangle-down" 14 "dropdown icon"}}
+ <div class="menu">
+ <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>
diff --git a/templates/repo/issue/filter_item_label.tmpl b/templates/repo/issue/filter_item_label.tmpl
index 67bfab6fb0..927328ba14 100644
--- a/templates/repo/issue/filter_item_label.tmpl
+++ b/templates/repo/issue/filter_item_label.tmpl
@@ -19,7 +19,7 @@
<span data-tooltip-content={{ctx.Locale.Tr "repo.issues.label_archive_tooltip"}}>{{svg "octicon-info"}}</span>
</label>
{{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>
<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>
diff --git a/templates/repo/issue/filter_item_user_fetch.tmpl b/templates/repo/issue/filter_item_user_fetch.tmpl
index cab128a787..5fa8142354 100644
--- a/templates/repo/issue/filter_item_user_fetch.tmpl
+++ b/templates/repo/issue/filter_item_user_fetch.tmpl
@@ -2,13 +2,13 @@
* QueryParamKey: eg: "poster", "assignee"
* QueryLink
* UserSearchUrl
-* SelectedUserId
+* SelectedUsername
* TextFilterTitle
*/}}
{{$queryLink := .QueryLink}}
<div class="item ui dropdown custom user-remote-search" data-tooltip-content="{{ctx.Locale.Tr "repo.user_search_tooltip"}}"
data-search-url="{{$.UserSearchUrl}}"
- data-selected-user-id="{{$.SelectedUserId}}"
+ data-selected-username="{{$.SelectedUsername}}"
data-action-jump-url="{{QueryBuild $queryLink $.QueryParamKey NIL}}&{{$.QueryParamKey}}={username}"
>
{{$.TextFilterTitle}} {{svg "octicon-triangle-down" 14 "dropdown icon"}}
diff --git a/templates/repo/issue/filter_list.tmpl b/templates/repo/issue/filter_list.tmpl
index c78d23d51c..7612d93b21 100644
--- a/templates/repo/issue/filter_list.tmpl
+++ b/templates/repo/issue/filter_list.tmpl
@@ -4,7 +4,7 @@
{{if not .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">
{{ctx.Locale.Tr "repo.issues.filter_milestone"}}
</span>
@@ -42,7 +42,7 @@
{{end}}
<!-- 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">
{{ctx.Locale.Tr "repo.issues.filter_project"}}
</span>
@@ -84,7 +84,7 @@
"QueryParamKey" "poster"
"QueryLink" $queryLink
"UserSearchUrl" (Iif .Milestone (print $.RepoLink "/issues/posters") (print $.Link "/posters"))
- "SelectedUserId" $.PosterUserID
+ "SelectedUsername" $.PosterUsername
"TextFilterTitle" (ctx.Locale.Tr "repo.issues.filter_poster")
}}
@@ -100,7 +100,7 @@
{{if .IsSigned}}
<!-- Type -->
- <div class="ui dropdown type jump item">
+ <div class="item ui dropdown jump">
<span class="text">
{{ctx.Locale.Tr "repo.issues.filter_type"}}
</span>
@@ -119,7 +119,7 @@
{{end}}
<!-- Sort -->
-<div class="list-header-sort ui small dropdown downward type jump item">
+<div class="item ui dropdown jump">
<span class="text">
{{ctx.Locale.Tr "repo.issues.filter_sort"}}
</span>
diff --git a/templates/repo/issue/milestone/filter_list.tmpl b/templates/repo/issue/milestone/filter_list.tmpl
index 430d3814ee..0740b86ac9 100644
--- a/templates/repo/issue/milestone/filter_list.tmpl
+++ b/templates/repo/issue/milestone/filter_list.tmpl
@@ -1,5 +1,5 @@
<!-- Sort -->
-<div class="list-header-sort ui small dropdown type jump item">
+<div class="item ui small dropdown jump">
<span class="text">
{{ctx.Locale.Tr "repo.issues.filter_sort"}}
</span>
diff --git a/templates/repo/issue/search.tmpl b/templates/repo/issue/search.tmpl
index 1ab0dc74f3..07732ab5e7 100644
--- a/templates/repo/issue/search.tmpl
+++ b/templates/repo/issue/search.tmpl
@@ -3,7 +3,7 @@
<input type="hidden" name="state" value="{{$.State}}">
{{if not .PageIsMilestones}}
<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="project" value="{{$.ProjectID}}">
<input type="hidden" name="assignee" value="{{$.AssigneeID}}">
diff --git a/templates/user/dashboard/issues.tmpl b/templates/user/dashboard/issues.tmpl
index b9d63818fe..7f960a4709 100644
--- a/templates/user/dashboard/issues.tmpl
+++ b/templates/user/dashboard/issues.tmpl
@@ -4,7 +4,7 @@
<div class="ui container">
{{template "base/alert" .}}
<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="ui secondary vertical filter menu tw-bg-transparent">
<a class="{{if eq .ViewType "your_repositories"}}active{{end}} item" href="{{QueryBuild $queryLink "type" "your_repositories"}}">
@@ -36,7 +36,7 @@
</div>
</div>
- {{$queryLinkWithFilter := QueryBuild $queryLink "poster" $.SearchFilterPosterUsername "assignee" $.SearchFilterAssigneeID}}
+ {{$queryLinkWithFilter := QueryBuild $queryLink "poster" $.FilterPosterUsername "assignee" $.FilterAssigneeUsername}}
<div class="flex-container-main content">
<div class="list-header">
<div class="small-menu-items ui compact tiny menu list-header-toggle flex-items-block">
@@ -50,28 +50,51 @@
</a>
</div>
<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="sort" value="{{$.SortType}}">
- <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")}}
- </div>
+ <input type="hidden" name="type" value="{{$.ViewType}}">
+ <input type="hidden" name="sort" value="{{$.SortType}}">
+ <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")}}
</form>
- <!-- Sort -->
- <div class="list-header-sort ui small dropdown type jump item">
- <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 class="list-header-filters">
+ {{if $.Labels}}
+ {{template "repo/issue/filter_item_label" dict "Labels" .Labels "QueryLink" $queryLinkWithFilter "SupportArchivedLabel" true}}
+ {{end}}
+
+ {{if ne $.ViewType "created_by"}}
+ {{template "repo/issue/filter_item_user_fetch" dict
+ "QueryParamKey" "poster"
+ "QueryLink" $queryLinkWithFilter
+ "SelectedUsername" $.FilterPosterUsername
+ "TextFilterTitle" (ctx.Locale.Tr "repo.issues.filter_poster")
+ }}
+ {{end}}
+
+ {{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>
diff --git a/templates/user/dashboard/milestones.tmpl b/templates/user/dashboard/milestones.tmpl
index ad6eb25209..c0059d3cd4 100644
--- a/templates/user/dashboard/milestones.tmpl
+++ b/templates/user/dashboard/milestones.tmpl
@@ -52,20 +52,22 @@
<input type="hidden" name="state" value="{{$.State}}">
{{template "shared/search/combo" dict "Value" $.Keyword}}
</form>
- <!-- Sort -->
- <div class="list-header-sort ui dropdown type jump item">
- <span class="text">
- {{ctx.Locale.Tr "repo.issues.filter_sort"}}
- </span>
- {{svg "octicon-triangle-down" 14 "dropdown icon"}}
- <div class="menu">
- <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 "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 "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 "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 "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 "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 class="list-header-filters">
+ <!-- Sort -->
+ <div class="item ui dropdown jump">
+ <span class="text">
+ {{ctx.Locale.Tr "repo.issues.filter_sort"}}
+ </span>
+ {{svg "octicon-triangle-down" 14 "dropdown icon"}}
+ <div class="menu">
+ <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 "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 "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 "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 "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 "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>
diff --git a/web_src/css/repo.css b/web_src/css/repo.css
index 9e1def87a7..f5785c41a7 100644
--- a/web_src/css/repo.css
+++ b/web_src/css/repo.css
@@ -90,24 +90,6 @@
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 */
/* style reference: https://semantic-ui.com/collections/menu.html#pointing */
.repository .ui.tabs.container .ui.menu:not(.secondary.pointing) {
diff --git a/web_src/css/repo/issue-list.css b/web_src/css/repo/issue-list.css
index 4fafc7d6f8..bf8ff00b7e 100644
--- a/web_src/css/repo/issue-list.css
+++ b/web_src/css/repo/issue-list.css
@@ -73,3 +73,21 @@
font-size: 12px;
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;
+}
diff --git a/web_src/css/repo/list-header.css b/web_src/css/repo/list-header.css
index 4440bba8df..e666e046d3 100644
--- a/web_src/css/repo/list-header.css
+++ b/web_src/css/repo/list-header.css
@@ -5,13 +5,6 @@
gap: .5rem;
}
-.list-header-sort {
- display: flex;
- align-items: center;
- padding-left: 1rem;
- padding-right: 1rem;
-}
-
.list-header-search {
display: flex;
flex: 1;
@@ -21,8 +14,22 @@
min-width: 200px; /* to enable flexbox wrapping on mobile */
}
-.list-header-search .input {
+.list-header-search > .ui.input {
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) {
@@ -32,8 +39,7 @@
.list-header-toggle {
order: 1;
}
- .list-header-sort {
+ .list-header-filters {
order: 2;
- margin-left: auto;
}
}
diff --git a/web_src/js/features/repo-issue-list.ts b/web_src/js/features/repo-issue-list.ts
index a0550837ec..74d4362bfd 100644
--- a/web_src/js/features/repo-issue-list.ts
+++ b/web_src/js/features/repo-issue-list.ts
@@ -95,10 +95,9 @@ function initRepoIssueListCheckboxes() {
function initDropdownUserRemoteSearch(el: Element) {
let searchUrl = el.getAttribute('data-search-url');
const actionJumpUrl = el.getAttribute('data-action-jump-url');
- const selectedUserId = parseInt(el.getAttribute('data-selected-user-id'));
- let selectedUsername = '';
- if (!searchUrl.includes('?')) searchUrl += '?';
+ let selectedUsername = el.getAttribute('data-selected-username') || '';
const $searchDropdown = fomanticQuery(el);
+ const elMenu = el.querySelector('.menu');
const elSearchInput = el.querySelector<HTMLInputElement>('.ui.search 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};
const processedResults: ProcessedResult[] = []; // to be used by dropdown to generate menu items
const syncItemFromInput = () => {
- elItemFromInput.setAttribute('data-value', elSearchInput.value);
- elItemFromInput.textContent = elSearchInput.value;
- toggleElem(elItemFromInput, !processedResults.length);
+ const inputVal = elSearchInput.value.trim();
+ elItemFromInput.setAttribute('data-value', inputVal);
+ elItemFromInput.textContent = inputVal;
+ const showItemFromInput = !processedResults.length && inputVal !== '';
+ toggleElem(elItemFromInput, showItemFromInput);
+ selectUsername(showItemFromInput ? inputVal : selectedUsername);
};
+ elSearchInput.value = selectedUsername;
if (!searchUrl) {
elSearchInput.addEventListener('input', syncItemFromInput);
} else {
+ if (!searchUrl.includes('?')) searchUrl += '?';
$searchDropdown.dropdown('setting', 'apiSettings', {
cache: false,
url: `${searchUrl}&q={query}`,
@@ -130,11 +139,10 @@ function initDropdownUserRemoteSearch(el: Element) {
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>`;
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});
}
resp.results = processedResults;
- syncItemFromInput();
return resp;
},
});
@@ -146,9 +154,8 @@ function initDropdownUserRemoteSearch(el: Element) {
const dropdownTemplates = $searchDropdown.dropdown('setting', 'templates');
$searchDropdown.dropdown('internal', 'setup', dropdownSetup);
dropdownSetup.menu = function (values) {
- const menu = $searchDropdown.find('> .menu')[0];
// remove old dynamic items
- for (const el of menu.querySelectorAll(':scope > .dynamic-item')) {
+ for (const el of elMenu.querySelectorAll(':scope > .dynamic-item')) {
el.remove();
}
@@ -160,16 +167,11 @@ function initDropdownUserRemoteSearch(el: Element) {
}
const div = document.createElement('div');
div.classList.add('divider', 'dynamic-item');
- menu.append(div, ...newMenuItems);
+ elMenu.append(div, ...newMenuItems);
}
$searchDropdown.dropdown('refresh');
// defer our selection to the next tick, because dropdown will set the selection item after this `menu` function
- setTimeout(() => {
- 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);
+ setTimeout(() => syncItemFromInput(), 0);
};
}
@@ -221,8 +223,12 @@ async function initIssuePinSort() {
}
export function initRepoIssueList() {
- if (!document.querySelector('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list')) return;
- initRepoIssueListCheckboxes();
- queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
- initIssuePinSort();
+ if (document.querySelector('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list')) {
+ initRepoIssueListCheckboxes();
+ queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
+ 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));
+ }
}