mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	Add label/author/assignee filters to the user/org home issue list (#32779)
Replace #26661, fix #25979 Not perfect, but usable and much better than before. Since it is quite complex, I am not quite sure whether there would be any regression, if any, I will fix in first time. I have tested the related pages many times: issue list, milestone issue list, project view, user issue list, org issue list.
This commit is contained in:
		| @@ -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 | ||||
|   | ||||
| @@ -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) | ||||
| 	} | ||||
|  | ||||
| 	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) { | ||||
| 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) | ||||
| 			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) { | ||||
|   | ||||
| @@ -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)") | ||||
| 	} | ||||
|  | ||||
| 	if opts.PosterID > 0 { | ||||
| 	applyPosterCondition(sess, opts.PosterID) | ||||
| 	} | ||||
|  | ||||
| 	if opts.MentionedID > 0 { | ||||
| 		applyMentionedCondition(sess, opts.MentionedID) | ||||
|   | ||||
| @@ -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}, | ||||
|   | ||||
| @@ -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), | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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}, | ||||
| 		}, | ||||
| 		{ | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
							
								
								
									
										71
									
								
								routers/web/shared/issue/issue_label.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								routers/web/shared/issue/issue_label.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| // Copyright 2024 The Gitea Authors. All rights reserved. | ||||
| // SPDX-License-Identifier: MIT | ||||
|  | ||||
| package issue | ||||
|  | ||||
| import ( | ||||
| 	"strings" | ||||
|  | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| 	issues_model "code.gitea.io/gitea/models/issues" | ||||
| 	user_model "code.gitea.io/gitea/models/user" | ||||
| 	"code.gitea.io/gitea/modules/base" | ||||
| 	"code.gitea.io/gitea/services/context" | ||||
| ) | ||||
|  | ||||
| // PrepareFilterIssueLabels reads the "labels" query parameter, sets `ctx.Data["Labels"]` and `ctx.Data["SelectLabels"]` | ||||
| func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_model.User) (labelIDs []int64) { | ||||
| 	// 1,-2 means including label 1 and excluding label 2 | ||||
| 	// 0 means issues with no label | ||||
| 	// blank means labels will not be filtered for issues | ||||
| 	selectLabels := ctx.FormString("labels") | ||||
| 	if selectLabels != "" { | ||||
| 		var err error | ||||
| 		labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ",")) | ||||
| 		if err != nil { | ||||
| 			ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	var allLabels []*issues_model.Label | ||||
| 	if repoID != 0 { | ||||
| 		repoLabels, err := issues_model.GetLabelsByRepoID(ctx, repoID, "", db.ListOptions{}) | ||||
| 		if err != nil { | ||||
| 			ctx.ServerError("GetLabelsByRepoID", err) | ||||
| 			return nil | ||||
| 		} | ||||
| 		allLabels = append(allLabels, repoLabels...) | ||||
| 	} | ||||
|  | ||||
| 	if owner != nil && owner.IsOrganization() { | ||||
| 		orgLabels, err := issues_model.GetLabelsByOrgID(ctx, owner.ID, "", db.ListOptions{}) | ||||
| 		if err != nil { | ||||
| 			ctx.ServerError("GetLabelsByOrgID", err) | ||||
| 			return nil | ||||
| 		} | ||||
| 		allLabels = append(allLabels, orgLabels...) | ||||
| 	} | ||||
|  | ||||
| 	// Get the exclusive scope for every label ID | ||||
| 	labelExclusiveScopes := make([]string, 0, len(labelIDs)) | ||||
| 	for _, labelID := range labelIDs { | ||||
| 		foundExclusiveScope := false | ||||
| 		for _, label := range allLabels { | ||||
| 			if label.ID == labelID || label.ID == -labelID { | ||||
| 				labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope()) | ||||
| 				foundExclusiveScope = true | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
| 		if !foundExclusiveScope { | ||||
| 			labelExclusiveScopes = append(labelExclusiveScopes, "") | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	for _, l := range allLabels { | ||||
| 		l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes) | ||||
| 	} | ||||
| 	ctx.Data["Labels"] = allLabels | ||||
| 	ctx.Data["SelectLabels"] = selectLabels | ||||
| 	return labelIDs | ||||
| } | ||||
| @@ -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) | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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. | ||||
|   | ||||
| @@ -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, "&")) | ||||
|   | ||||
| @@ -24,8 +24,10 @@ | ||||
| 		<input type="hidden" name="state" value="{{$.State}}"> | ||||
| 		{{template "shared/search/combo" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.project_kind")}} | ||||
| 	</form> | ||||
|  | ||||
| 	<div class="list-header-filters"> | ||||
| 		<!-- 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> | ||||
| @@ -37,6 +39,7 @@ | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </div> | ||||
|  | ||||
| <div class="milestone-list"> | ||||
| 	{{range .Projects}} | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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"}} | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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}}"> | ||||
|   | ||||
| @@ -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,15 +50,37 @@ | ||||
| 						</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> | ||||
| 					</form> | ||||
|  | ||||
| 					<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="list-header-sort ui small dropdown type jump item"> | ||||
| 						<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"}} | ||||
| @@ -75,6 +97,7 @@ | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				{{template "shared/issuelist" dict "." . "listType" "dashboard"}} | ||||
| 			</div> | ||||
| 		</div> | ||||
|   | ||||
| @@ -52,8 +52,9 @@ | ||||
| 							<input type="hidden" name="state" value="{{$.State}}"> | ||||
| 						{{template "shared/search/combo" dict "Value" $.Keyword}} | ||||
| 					</form> | ||||
| 					<div class="list-header-filters"> | ||||
| 						<!-- Sort --> | ||||
| 					<div class="list-header-sort ui dropdown type jump item"> | ||||
| 						<div class="item ui dropdown jump"> | ||||
| 							<span class="text"> | ||||
| 								{{ctx.Locale.Tr "repo.issues.filter_sort"}} | ||||
| 							</span> | ||||
| @@ -69,6 +70,7 @@ | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<div class="milestone-list"> | ||||
| 					{{range .Milestones}} | ||||
| 						<li class="milestone-card"> | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|   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)); | ||||
|   } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user