From e619384098419569e570796a57ee6af4948067ae Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 11 Dec 2024 14:33:24 +0800 Subject: [PATCH] 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. --- models/db/search.go | 12 +- models/issues/issue_search.go | 39 +++--- models/issues/issue_stats.go | 10 +- models/issues/issue_test.go | 3 +- modules/indexer/issues/db/options.go | 4 +- modules/indexer/issues/dboptions.go | 12 +- modules/indexer/issues/indexer_test.go | 2 +- routers/web/org/projects.go | 21 +--- routers/web/repo/issue_list.go | 118 +++--------------- routers/web/repo/projects.go | 19 +-- routers/web/shared/issue/issue_label.go | 71 +++++++++++ routers/web/shared/user/helper.go | 17 ++- routers/web/user/home.go | 62 ++++----- routers/web/web.go | 2 +- services/context/pagination.go | 13 ++ templates/projects/list.tmpl | 23 ++-- templates/repo/issue/filter_item_label.tmpl | 2 +- .../repo/issue/filter_item_user_fetch.tmpl | 4 +- templates/repo/issue/filter_list.tmpl | 10 +- .../repo/issue/milestone/filter_list.tmpl | 2 +- templates/repo/issue/search.tmpl | 2 +- templates/user/dashboard/issues.tmpl | 69 ++++++---- templates/user/dashboard/milestones.tmpl | 30 ++--- web_src/css/repo.css | 18 --- web_src/css/repo/issue-list.css | 18 +++ web_src/css/repo/list-header.css | 26 ++-- web_src/js/features/repo-issue-list.ts | 48 +++---- 27 files changed, 338 insertions(+), 319 deletions(-) create mode 100644 routers/web/shared/issue/issue_label.go 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 @@ {{template "shared/search/combo" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.project_kind")}} - -