}
// 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
}
}
// 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
}
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) {
"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"
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
"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"
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
}
}
+ // 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
"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"
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)
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 := ""
+++ /dev/null
-// 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)
-}
"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"
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() {
ctx.ServerError("GetRepoAssignees", err)
return
}
- ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers)
+ ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
handleTeamMentions(ctx)
}
}
}
- posters = MakeSelfOnTop(ctx.Doer, posters)
+ posters = shared_user.MakeSelfOnTop(ctx.Doer, posters)
resp := &userSearchResponse{}
resp.Results = make([]*userSearchInfo, len(posters))
"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"
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
}
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,
"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"
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() {
"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"
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")
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)
}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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)
+}
<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">
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;