]> source.dussan.org Git - gitea.git/commitdiff
Add option to filter board cards by labels and assignees (#31999)
authorLauris BH <lauris@nix.lv>
Thu, 12 Sep 2024 03:53:40 +0000 (06:53 +0300)
committerGitHub <noreply@github.com>
Thu, 12 Sep 2024 03:53:40 +0000 (03:53 +0000)
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

15 files changed:
models/issues/issue_project.go
models/issues/issue_search.go
models/organization/org_user.go
routers/web/org/projects.go
routers/web/repo/actions/actions.go
routers/web/repo/helper.go
routers/web/repo/helper_test.go [deleted file]
routers/web/repo/issue.go
routers/web/repo/projects.go
routers/web/repo/pull.go
routers/web/repo/release.go
routers/web/shared/user/helper.go [new file with mode: 0644]
routers/web/shared/user/helper_test.go [new file with mode: 0644]
templates/projects/view.tmpl
web_src/css/features/projects.css

index 835ea1db5259b9ed344b1c3b97e2e1539e5394a1..c4515fd898595c546a282abc945b296fbf50262c 100644 (file)
@@ -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
                }
index e9f116bfc6e89cb712fbf95ceee059be526d8e50..5948a67d4ebc664c895b1aaad6839315c5d2b757 100644 (file)
@@ -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) {
index 5fe3a178d2b580a7a65f4608407d88417f1a77eb..1d3b2fab44dd30e281498065b17fc76687ac6ff4 100644 (file)
@@ -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
index 66760d31db713e372700003972577fdc9577028e..2a5434b4147f652f992bb71abbff953abc947027 100644 (file)
@@ -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
index 63cf3e948a888aa3f8ba7ec4c267ad33a653e7ff..f5fb056494d7f97a133a6bf8f28f3188ed362649 100644 (file)
@@ -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)
 
index 5e1e116018eb5c3684c987c067bdbac443e73c40..ed6216fa5ce2a559cb0f83c2e1d844663a993936 100644 (file)
@@ -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/helper_test.go b/routers/web/repo/helper_test.go
deleted file mode 100644 (file)
index 978758e..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright 2023 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package repo
-
-import (
-       "testing"
-
-       "code.gitea.io/gitea/models/user"
-
-       "github.com/stretchr/testify/assert"
-)
-
-func TestMakeSelfOnTop(t *testing.T) {
-       users := MakeSelfOnTop(nil, []*user.User{{ID: 2}, {ID: 1}})
-       assert.Len(t, users, 2)
-       assert.EqualValues(t, 2, users[0].ID)
-
-       users = MakeSelfOnTop(&user.User{ID: 1}, []*user.User{{ID: 2}, {ID: 1}})
-       assert.Len(t, users, 2)
-       assert.EqualValues(t, 1, users[0].ID)
-
-       users = MakeSelfOnTop(&user.User{ID: 2}, []*user.User{{ID: 2}, {ID: 1}})
-       assert.Len(t, users, 2)
-       assert.EqualValues(t, 2, users[0].ID)
-}
index fd6abe04fe6a14fed7bf69d46d5afe6a76646a19..596abb4b9ca5bd5503f0b5ecc485a12bf0e21bb6 100644 (file)
@@ -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))
index aac8997d6278acfa890f69bc85941a3e4bd29838..664ea7eb76d796b5344b6eb4b1047f6c5f30897f 100644 (file)
@@ -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,
index 7187a35e0e71342b109e98eb8db339784681985e..95299b44d5a253e27c47438ef1062631bc32ed76 100644 (file)
@@ -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() {
index 85c7828f2e404458004039e98106c51fff83f724..a28330349254cd12d566354c5349aedf61dbe2a6 100644 (file)
@@ -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 (file)
index 0000000..6186b9b
--- /dev/null
@@ -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/shared/user/helper_test.go b/routers/web/shared/user/helper_test.go
new file mode 100644 (file)
index 0000000..ccdf536
--- /dev/null
@@ -0,0 +1,26 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+       "testing"
+
+       "code.gitea.io/gitea/models/user"
+
+       "github.com/stretchr/testify/assert"
+)
+
+func TestMakeSelfOnTop(t *testing.T) {
+       users := MakeSelfOnTop(nil, []*user.User{{ID: 2}, {ID: 1}})
+       assert.Len(t, users, 2)
+       assert.EqualValues(t, 2, users[0].ID)
+
+       users = MakeSelfOnTop(&user.User{ID: 1}, []*user.User{{ID: 2}, {ID: 1}})
+       assert.Len(t, users, 2)
+       assert.EqualValues(t, 1, users[0].ID)
+
+       users = MakeSelfOnTop(&user.User{ID: 2}, []*user.User{{ID: 2}, {ID: 1}})
+       assert.Len(t, users, 2)
+       assert.EqualValues(t, 2, users[0].ID)
+}
index 584462d2a24dd5008f0f6104967dd6d6f047925d..f5c1bb76703c4df9c504b98382d6eff897dc5a4a 100644 (file)
@@ -3,6 +3,82 @@
 <div class="ui container tw-max-w-full">
        <div class="tw-flex tw-justify-between tw-items-center tw-mb-4 tw-gap-3">
                <h2 class="tw-mb-0 tw-flex-1 tw-break-anywhere">{{.Project.Title}}</h2>
+                       <div class="project-toolbar-right">
+                               <div class="ui secondary filter menu labels">
+                                       <!-- Label -->
+                                       <div class="ui {{if not .Labels}}disabled{{end}} dropdown jump item label-filter">
+                                               <span class="text">
+                                                       {{ctx.Locale.Tr "repo.issues.filter_label"}}
+                                               </span>
+                                               {{svg "octicon-triangle-down" 14 "dropdown icon"}}
+                                               <div class="menu">
+                                                       <div class="ui icon search input">
+                                                               <i class="icon">{{svg "octicon-search" 16}}</i>
+                                                               <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_label"}}">
+                                                       </div>
+                                                       <div class="ui checkbox compact archived-label-filter">
+                                                               <input name="archived" type="checkbox"
+                                                                       id="archived-filter-checkbox"
+                                                                       {{if .ShowArchivedLabels}}checked{{end}}
+                                                               >
+                                                               <label for="archived-filter-checkbox">
+                                                                       {{ctx.Locale.Tr "repo.issues.label_archived_filter"}}
+                                                                       <i class="tw-ml-1" data-tooltip-content={{ctx.Locale.Tr "repo.issues.label_archive_tooltip"}}>
+                                                                               {{svg "octicon-info"}}
+                                                                       </i>
+                                                               </label>
+                                                       </div>
+                                                       <span class="info">{{ctx.Locale.Tr "repo.issues.filter_label_exclude"}}</span>
+                                                       <div class="divider"></div>
+                                                       <a class="{{if .AllLabels}}active selected {{end}}item" href="?assignee={{$.AssigneeID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{ctx.Locale.Tr "repo.issues.filter_label_no_select"}}</a>
+                                                       <a class="{{if .NoLabel}}active selected {{end}}item" href="?assignee={{$.AssigneeID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{ctx.Locale.Tr "repo.issues.filter_label_select_no_label"}}</a>
+                                                       {{$previousExclusiveScope := "_no_scope"}}
+                                                       {{range .Labels}}
+                                                               {{$exclusiveScope := .ExclusiveScope}}
+                                                               {{if and (ne $previousExclusiveScope $exclusiveScope)}}
+                                                                       <div class="divider"></div>
+                                                               {{end}}
+                                                               {{$previousExclusiveScope = $exclusiveScope}}
+                                                               <a class="item label-filter-item tw-flex tw-items-center" {{if .IsArchived}}data-is-archived{{end}} href="?labels={{.QueryString}}&assignee={{$.AssigneeID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}" data-label-id="{{.ID}}">
+                                                                       {{if .IsExcluded}}
+                                                                               {{svg "octicon-circle-slash"}}
+                                                                       {{else if .IsSelected}}
+                                                                               {{if $exclusiveScope}}
+                                                                                       {{svg "octicon-dot-fill"}}
+                                                                               {{else}}
+                                                                                       {{svg "octicon-check"}}
+                                                                               {{end}}
+                                                                       {{end}}
+                                                                       {{RenderLabel $.Context ctx.Locale .}}
+                                                                       <p class="tw-ml-auto">{{template "repo/issue/labels/label_archived" .}}</p>
+                                                               </a>
+                                                       {{end}}
+                                               </div>
+                                       </div>
+
+                                       <!-- Assignee -->
+                                       <div class="ui {{if not .Assignees}}disabled{{end}} dropdown jump item">
+                                               <span class="text">
+                                                       {{ctx.Locale.Tr "repo.issues.filter_assignee"}}
+                                               </span>
+                                               {{svg "octicon-triangle-down" 14 "dropdown icon"}}
+                                               <div class="menu">
+                                                       <div class="ui icon search input">
+                                                               <i class="icon">{{svg "octicon-search" 16}}</i>
+                                                               <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_assignee"}}">
+                                                       </div>
+                                                       <a class="{{if not .AssigneeID}}active selected {{end}}item" href="?labels={{.SelectLabels}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{ctx.Locale.Tr "repo.issues.filter_assginee_no_select"}}</a>
+                                                       <a class="{{if eq .AssigneeID -1}}active selected {{end}}item" href="?labels={{.SelectLabels}}&assignee=-1{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{ctx.Locale.Tr "repo.issues.filter_assginee_no_assignee"}}</a>
+                                                       <div class="divider"></div>
+                                                       {{range .Assignees}}
+                                                               <a class="{{if eq $.AssigneeID .ID}}active selected{{end}} item tw-flex" href="?labels={{$.SelectLabels}}&assignee={{.ID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">
+                                                                       {{ctx.AvatarUtils.Avatar . 20}}{{template "repo/search_name" .}}
+                                                               </a>
+                                                       {{end}}
+                                               </div>
+                                       </div>
+                               </div>
+                       </div>
                {{if $canWriteProject}}
                        <div class="ui compact mini menu">
                                <a class="item" href="{{.Link}}/edit?redirect=project">
index 4a0205c910a33b130963f3e1a5e91ae4b21a99f0..08547cd8728170c8d2e619f0f3bacf2e29d14a4d 100644 (file)
@@ -6,6 +6,18 @@
   margin: 0 0.5em;
 }
 
+.project-toolbar-right .filter.menu {
+  flex-direction: row;
+  flex-wrap: wrap;
+}
+
+@media (max-width: 767.98px) {
+  .project-toolbar-right .dropdown .menu {
+    left: auto !important;
+    right: auto !important;
+  }
+}
+
 .project-column {
   background-color: var(--color-project-column-bg) !important;
   border: 1px solid var(--color-secondary) !important;