From 4ab6fc62d23bcef060cb98c60cfc29aa286a02d1 Mon Sep 17 00:00:00 2001 From: Lauris BH Date: Thu, 12 Sep 2024 06:53:40 +0300 Subject: [PATCH] Add option to filter board cards by labels and assignees (#31999) Works in both organization and repository project boards Fixes #21846 Replaces #21963 Replaces #27117 ![image](https://github.com/user-attachments/assets/1837ace8-3de2-444f-a153-e166bd0da2c0) **Note** that implementation was made intentionally to work same as in issue list so that URL can be bookmarked for quick access with predefined filters in URL --- models/issues/issue_project.go | 16 ++-- models/issues/issue_search.go | 13 ++++ models/organization/org_user.go | 45 +++++++++++ routers/web/org/projects.go | 65 +++++++++++++++- routers/web/repo/actions/actions.go | 4 +- routers/web/repo/helper.go | 14 ---- routers/web/repo/issue.go | 7 +- routers/web/repo/projects.go | 74 +++++++++++++++++- routers/web/repo/pull.go | 3 +- routers/web/repo/release.go | 5 +- routers/web/shared/user/helper.go | 22 ++++++ .../web/{repo => shared/user}/helper_test.go | 2 +- templates/projects/view.tmpl | 76 +++++++++++++++++++ web_src/css/features/projects.css | 12 +++ 14 files changed, 325 insertions(+), 33 deletions(-) create mode 100644 routers/web/shared/user/helper.go rename routers/web/{repo => shared/user}/helper_test.go (97%) diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 835ea1db52..c4515fd898 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -48,12 +48,12 @@ func (issue *Issue) ProjectColumnID(ctx context.Context) int64 { } // LoadIssuesFromColumn load issues assigned to this column -func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column) (IssueList, error) { - issueList, err := Issues(ctx, &IssuesOptions{ - ProjectColumnID: b.ID, - ProjectID: b.ProjectID, - SortType: "project-column-sorting", - }) +func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *IssuesOptions) (IssueList, error) { + issueList, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) { + o.ProjectColumnID = b.ID + o.ProjectID = b.ProjectID + o.SortType = "project-column-sorting" + })) if err != nil { return nil, err } @@ -78,10 +78,10 @@ func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column) (IssueLi } // LoadIssuesFromColumnList load issues assigned to the columns -func LoadIssuesFromColumnList(ctx context.Context, bs project_model.ColumnList) (map[int64]IssueList, error) { +func LoadIssuesFromColumnList(ctx context.Context, bs project_model.ColumnList, opts *IssuesOptions) (map[int64]IssueList, error) { issuesMap := make(map[int64]IssueList, len(bs)) for i := range bs { - il, err := LoadIssuesFromColumn(ctx, bs[i]) + il, err := LoadIssuesFromColumn(ctx, bs[i], opts) if err != nil { return nil, err } diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index e9f116bfc6..5948a67d4e 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -54,6 +54,19 @@ type IssuesOptions struct { //nolint User *user_model.User // issues permission scope } +// Copy returns a copy of the options. +// Be careful, it's not a deep copy, so `IssuesOptions.RepoIDs = {...}` is OK while `IssuesOptions.RepoIDs[0] = ...` is not. +func (o *IssuesOptions) Copy(edit ...func(options *IssuesOptions)) *IssuesOptions { + if o == nil { + return nil + } + v := *o + for _, e := range edit { + e(&v) + } + return &v +} + // applySorts sort an issues-related session based on the provided // sortType string func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) { diff --git a/models/organization/org_user.go b/models/organization/org_user.go index 5fe3a178d2..1d3b2fab44 100644 --- a/models/organization/org_user.go +++ b/models/organization/org_user.go @@ -9,7 +9,9 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" "xorm.io/builder" @@ -112,6 +114,49 @@ func IsUserOrgOwner(ctx context.Context, users user_model.UserList, orgID int64) return results } +// GetOrgAssignees returns all users that have write access and can be assigned to issues +// of the any repository in the organization. +func GetOrgAssignees(ctx context.Context, orgID int64) (_ []*user_model.User, err error) { + e := db.GetEngine(ctx) + userIDs := make([]int64, 0, 10) + if err = e.Table("access"). + Join("INNER", "repository", "`repository`.id = `access`.repo_id"). + Where("`repository`.owner_id = ? AND `access`.mode >= ?", orgID, perm.AccessModeWrite). + Select("user_id"). + Find(&userIDs); err != nil { + return nil, err + } + + additionalUserIDs := make([]int64, 0, 10) + if err = e.Table("team_user"). + Join("INNER", "team_repo", "`team_repo`.team_id = `team_user`.team_id"). + Join("INNER", "team_unit", "`team_unit`.team_id = `team_user`.team_id"). + Join("INNER", "repository", "`repository`.id = `team_repo`.repo_id"). + Where("`repository`.owner_id = ? AND (`team_unit`.access_mode >= ? OR (`team_unit`.access_mode = ? AND `team_unit`.`type` = ?))", + orgID, perm.AccessModeWrite, perm.AccessModeRead, unit.TypePullRequests). + Distinct("`team_user`.uid"). + Select("`team_user`.uid"). + Find(&additionalUserIDs); err != nil { + return nil, err + } + + uniqueUserIDs := make(container.Set[int64]) + uniqueUserIDs.AddMultiple(userIDs...) + uniqueUserIDs.AddMultiple(additionalUserIDs...) + + users := make([]*user_model.User, 0, len(uniqueUserIDs)) + if len(userIDs) > 0 { + if err = e.In("id", uniqueUserIDs.Values()). + Where(builder.Eq{"`user`.is_active": true}). + OrderBy(user_model.GetOrderByName()). + Find(&users); err != nil { + return nil, err + } + } + + return users, nil +} + func loadOrganizationOwners(ctx context.Context, users user_model.UserList, orgID int64) (map[int64]*TeamUser, error) { if len(users) == 0 { return nil, nil diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index 66760d31db..2a5434b414 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" + org_model "code.gitea.io/gitea/models/organization" project_model "code.gitea.io/gitea/models/project" attachment_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" @@ -333,7 +334,29 @@ func ViewProject(ctx *context.Context) { return } - issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns) + 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 == "" { + ctx.Data["AllLabels"] = true + } else if selectLabels == "0" { + ctx.Data["NoLabel"] = true + } + if len(selectLabels) > 0 { + labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ",")) + if err != nil { + ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true) + } + } + + assigneeID := ctx.FormInt64("assignee") + + issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{ + LabelIDs: labelIDs, + AssigneeID: assigneeID, + }) if err != nil { ctx.ServerError("LoadIssuesOfColumns", err) return @@ -372,6 +395,46 @@ func ViewProject(ctx *context.Context) { } } + // TODO: Add option to filter also by repository specific labels + labels, err := issues_model.GetLabelsByOrgID(ctx, project.OwnerID, "", db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByOrgID", err) + return + } + + // 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) + + // Get assignees. + assigneeUsers, err := org_model.GetOrgAssignees(ctx, project.OwnerID) + if err != nil { + ctx.ServerError("GetRepoAssignees", err) + return + } + ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers) + + ctx.Data["SelectLabels"] = selectLabels + ctx.Data["AssigneeID"] = assigneeID + project.RenderedContent = templates.RenderMarkdownToHtml(ctx, project.Description) ctx.Data["LinkedPRs"] = linkedPrsMap ctx.Data["PageIsViewProjects"] = true diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 63cf3e948a..f5fb056494 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -23,7 +23,7 @@ import ( "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" - "code.gitea.io/gitea/routers/web/repo" + shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" @@ -252,7 +252,7 @@ func List(ctx *context.Context) { ctx.ServerError("GetActors", err) return } - ctx.Data["Actors"] = repo.MakeSelfOnTop(ctx.Doer, actors) + ctx.Data["Actors"] = shared_user.MakeSelfOnTop(ctx.Doer, actors) ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx) diff --git a/routers/web/repo/helper.go b/routers/web/repo/helper.go index 5e1e116018..ed6216fa5c 100644 --- a/routers/web/repo/helper.go +++ b/routers/web/repo/helper.go @@ -5,25 +5,11 @@ package repo import ( "net/url" - "sort" - "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/services/context" ) -func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User { - if doer != nil { - sort.Slice(users, func(i, j int) bool { - if users[i].ID == users[j].ID { - return false - } - return users[i].ID == doer.ID // if users[i] is self, put it before others, so less=true - }) - } - return users -} - func HandleGitError(ctx *context.Context, msg string, err error) { if git.IsErrNotExist(err) { refType := "" diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index fd6abe04fe..596abb4b9c 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -49,6 +49,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/utils" + shared_user "code.gitea.io/gitea/routers/web/shared/user" asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context/upload" @@ -360,7 +361,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt ctx.ServerError("GetRepoAssignees", err) return } - ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers) + ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers) handleTeamMentions(ctx) if ctx.Written() { @@ -580,7 +581,7 @@ func RetrieveRepoMilestonesAndAssignees(ctx *context.Context, repo *repo_model.R ctx.ServerError("GetRepoAssignees", err) return } - ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers) + ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers) handleTeamMentions(ctx) } @@ -3771,7 +3772,7 @@ func issuePosters(ctx *context.Context, isPullList bool) { } } - posters = MakeSelfOnTop(ctx.Doer, posters) + posters = shared_user.MakeSelfOnTop(ctx.Doer, posters) resp := &userSearchResponse{} resp.Results = make([]*userSearchInfo, len(posters)) diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index aac8997d62..664ea7eb76 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" + shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" project_service "code.gitea.io/gitea/services/projects" @@ -313,7 +314,29 @@ func ViewProject(ctx *context.Context) { return } - issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns) + 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 == "" { + ctx.Data["AllLabels"] = true + } else if selectLabels == "0" { + ctx.Data["NoLabel"] = true + } + if len(selectLabels) > 0 { + labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ",")) + if err != nil { + ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true) + } + } + + assigneeID := ctx.FormInt64("assignee") + + issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{ + LabelIDs: labelIDs, + AssigneeID: assigneeID, + }) if err != nil { ctx.ServerError("LoadIssuesOfColumns", err) return @@ -353,6 +376,55 @@ func ViewProject(ctx *context.Context) { } ctx.Data["LinkedPRs"] = linkedPrsMap + labels, err := issues_model.GetLabelsByRepoID(ctx, project.RepoID, "", db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByRepoID", err) + return + } + + if ctx.Repo.Owner.IsOrganization() { + orgLabels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Repo.Owner.ID, "", db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByOrgID", err) + return + } + + 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) + + // Get assignees. + assigneeUsers, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("GetRepoAssignees", err) + return + } + ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers) + + ctx.Data["SelectLabels"] = selectLabels + ctx.Data["AssigneeID"] = assigneeID + project.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ Links: markup.Links{ Base: ctx.Repo.RepoLink, diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 7187a35e0e..95299b44d5 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -34,6 +34,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/utils" + shared_user "code.gitea.io/gitea/routers/web/shared/user" asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/automerge" "code.gitea.io/gitea/services/context" @@ -825,7 +826,7 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi ctx.ServerError("GetRepoAssignees", err) return } - ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers) + ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers) handleTeamMentions(ctx) if ctx.Written() { diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index 85c7828f2e..a283303492 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -26,6 +26,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/web/feed" + shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/forms" @@ -370,7 +371,7 @@ func NewRelease(ctx *context.Context) { ctx.ServerError("GetRepoAssignees", err) return } - ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers) + ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers) upload.AddUploadContext(ctx, "release") @@ -559,7 +560,7 @@ func EditRelease(ctx *context.Context) { ctx.ServerError("GetRepoAssignees", err) return } - ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers) + ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers) ctx.HTML(http.StatusOK, tplReleaseNew) } diff --git a/routers/web/shared/user/helper.go b/routers/web/shared/user/helper.go new file mode 100644 index 0000000000..6186b9b9ff --- /dev/null +++ b/routers/web/shared/user/helper.go @@ -0,0 +1,22 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "sort" + + "code.gitea.io/gitea/models/user" +) + +func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User { + if doer != nil { + sort.Slice(users, func(i, j int) bool { + if users[i].ID == users[j].ID { + return false + } + return users[i].ID == doer.ID // if users[i] is self, put it before others, so less=true + }) + } + return users +} diff --git a/routers/web/repo/helper_test.go b/routers/web/shared/user/helper_test.go similarity index 97% rename from routers/web/repo/helper_test.go rename to routers/web/shared/user/helper_test.go index 978758e77f..ccdf536c13 100644 --- a/routers/web/repo/helper_test.go +++ b/routers/web/shared/user/helper_test.go @@ -1,7 +1,7 @@ // Copyright 2023 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package repo +package user import ( "testing" diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl index 584462d2a2..f5c1bb7670 100644 --- a/templates/projects/view.tmpl +++ b/templates/projects/view.tmpl @@ -3,6 +3,82 @@

{{.Project.Title}}

+
+ +
{{if $canWriteProject}}