diff options
author | wxiaoguang <wxiaoguang@gmail.com> | 2024-12-11 14:33:24 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-12-11 06:33:24 +0000 |
commit | e619384098419569e570796a57ee6af4948067ae (patch) | |
tree | 9c5413be580d91ed9912878f80336eab89543670 | |
parent | 734ddf71180a9bf843d12dd9664eed28ba1a5748 (diff) | |
download | gitea-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.
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)); + } } |