feat: Add sorting by exclusive labels (issue priority) (#33206)

Fix #2616

This PR adds a new sort option for exclusive labels.

For exclusive labels, a new property is exposed called "order", while in
the UI options are populated automatically in the `Sort` column (see
screenshot below) for each exclusive label scope.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Thomas E Lackey 2025-04-10 12:18:07 -05:00 committed by GitHub
parent 02e49a0f47
commit fa49cd719f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 236 additions and 105 deletions

View File

@ -21,6 +21,8 @@ import (
"xorm.io/xorm"
)
const ScopeSortPrefix = "scope-"
// IssuesOptions represents options of an issue.
type IssuesOptions struct { //nolint
Paginator *db.ListOptions
@ -70,6 +72,17 @@ func (o *IssuesOptions) Copy(edit ...func(options *IssuesOptions)) *IssuesOption
// applySorts sort an issues-related session based on the provided
// sortType string
func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) {
// Since this sortType is dynamically created, it has to be treated specially.
if strings.HasPrefix(sortType, ScopeSortPrefix) {
scope := strings.TrimPrefix(sortType, ScopeSortPrefix)
sess.Join("LEFT", "issue_label", "issue.id = issue_label.issue_id")
// "exclusive_order=0" means "no order is set", so exclude it from the JOIN criteria and then "LEFT JOIN" result is also null
sess.Join("LEFT", "label", "label.id = issue_label.label_id AND label.exclusive_order <> 0 AND label.name LIKE ?", scope+"/%")
// Use COALESCE to make sure we sort NULL last regardless of backend DB (2147483647 == max int)
sess.OrderBy("COALESCE(label.exclusive_order, 2147483647) ASC").Desc("issue.id")
return
}
switch sortType {
case "oldest":
sess.Asc("issue.created_unix").Asc("issue.id")

View File

@ -87,6 +87,7 @@ type Label struct {
OrgID int64 `xorm:"INDEX"`
Name string
Exclusive bool
ExclusiveOrder int `xorm:"DEFAULT 0"` // 0 means no exclusive order
Description string
Color string `xorm:"VARCHAR(7)"`
NumIssues int
@ -236,7 +237,7 @@ func UpdateLabel(ctx context.Context, l *Label) error {
}
l.Color = color
return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "archived_unix")
return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "exclusive_order", "archived_unix")
}
// DeleteLabel delete a label

View File

@ -380,6 +380,7 @@ func prepareMigrationTasks() []*migration {
newMigration(316, "Add description for secrets and variables", v1_24.AddDescriptionForSecretsAndVariables),
newMigration(317, "Add new index for action for heatmap", v1_24.AddNewIndexForUserDashboard),
newMigration(318, "Add anonymous_access_mode for repo_unit", v1_24.AddRepoUnitAnonymousAccessMode),
newMigration(319, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable),
}
return preparedMigrations
}

View File

@ -0,0 +1,16 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_24 //nolint
import (
"xorm.io/xorm"
)
func AddExclusiveOrderColumnToLabelTable(x *xorm.Engine) error {
type Label struct {
ExclusiveOrder int `xorm:"DEFAULT 0"`
}
return x.Sync(new(Label))
}

View File

@ -6,6 +6,7 @@ package db
import (
"context"
"strings"
"sync"
"code.gitea.io/gitea/models/db"
issue_model "code.gitea.io/gitea/models/issues"
@ -18,7 +19,7 @@ import (
"xorm.io/builder"
)
var _ internal.Indexer = &Indexer{}
var _ internal.Indexer = (*Indexer)(nil)
// Indexer implements Indexer interface to use database's like search
type Indexer struct {
@ -29,11 +30,9 @@ func (i *Indexer) SupportedSearchModes() []indexer.SearchMode {
return indexer.SearchModesExactWords()
}
func NewIndexer() *Indexer {
return &Indexer{
Indexer: &inner_db.Indexer{},
}
}
var GetIndexer = sync.OnceValue(func() *Indexer {
return &Indexer{Indexer: &inner_db.Indexer{}}
})
// Index dummy function
func (i *Indexer) Index(_ context.Context, _ ...*internal.IndexerData) error {
@ -122,7 +121,11 @@ func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
}, nil
}
ids, total, err := issue_model.IssueIDs(ctx, opt, cond)
return i.FindWithIssueOptions(ctx, opt, cond)
}
func (i *Indexer) FindWithIssueOptions(ctx context.Context, opt *issue_model.IssuesOptions, otherConds ...builder.Cond) (*internal.SearchResult, error) {
ids, total, err := issue_model.IssueIDs(ctx, opt, otherConds...)
if err != nil {
return nil, err
}

View File

@ -6,6 +6,7 @@ package db
import (
"context"
"fmt"
"strings"
"code.gitea.io/gitea/models/db"
issue_model "code.gitea.io/gitea/models/issues"
@ -34,7 +35,11 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
case internal.SortByDeadlineAsc:
sortType = "nearduedate"
default:
sortType = "newest"
if strings.HasPrefix(string(options.SortBy), issue_model.ScopeSortPrefix) {
sortType = string(options.SortBy)
} else {
sortType = "newest"
}
}
// See the comment of issues_model.SearchOptions for the reason why we need to convert
@ -68,7 +73,6 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
ExcludedLabelNames: nil,
IncludeMilestones: nil,
SortType: sortType,
IssueIDs: nil,
UpdatedAfterUnix: options.UpdatedAfterUnix.Value(),
UpdatedBeforeUnix: options.UpdatedBeforeUnix.Value(),
PriorityRepoID: 0,

View File

@ -4,12 +4,19 @@
package issues
import (
"strings"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
)
func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOptions {
if opts.IssueIDs != nil {
setting.PanicInDevOrTesting("Indexer SearchOptions doesn't support IssueIDs")
}
searchOpt := &SearchOptions{
Keyword: keyword,
RepoIDs: opts.RepoIDs,
@ -95,7 +102,11 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
// Unsupported sort type for search
fallthrough
default:
searchOpt.SortBy = SortByUpdatedDesc
if strings.HasPrefix(opts.SortType, issues_model.ScopeSortPrefix) {
searchOpt.SortBy = internal.SortBy(opts.SortType)
} else {
searchOpt.SortBy = SortByUpdatedDesc
}
}
return searchOpt

View File

@ -103,7 +103,7 @@ func InitIssueIndexer(syncReindex bool) {
log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", setting.Indexer.IssueConnStr, err)
}
case "db":
issueIndexer = db.NewIndexer()
issueIndexer = db.GetIndexer()
case "meilisearch":
issueIndexer = meilisearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueConnAuth, setting.Indexer.IssueIndexerName)
existed, err = issueIndexer.Init(ctx)
@ -291,19 +291,22 @@ func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, err
// So if the user creates an issue and list issues immediately, the issue may not be listed because the indexer needs time to index the issue.
// Even worse, the external indexer like elastic search may not be available for a while,
// and the user may not be able to list issues completely until it is available again.
ix = db.NewIndexer()
ix = db.GetIndexer()
}
result, err := ix.Search(ctx, opts)
if err != nil {
return nil, 0, err
}
return SearchResultToIDSlice(result), result.Total, nil
}
func SearchResultToIDSlice(result *internal.SearchResult) []int64 {
ret := make([]int64, 0, len(result.Hits))
for _, hit := range result.Hits {
ret = append(ret, hit.ID)
}
return ret, result.Total, nil
return ret
}
// CountIssues counts issues by options. It is a shortcut of SearchIssues(ctx, opts) but only returns the total count.

View File

@ -14,10 +14,11 @@ var colorPattern = regexp.MustCompile("^#?(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})$")
// Label represents label information loaded from template
type Label struct {
Name string `yaml:"name"`
Color string `yaml:"color"`
Description string `yaml:"description,omitempty"`
Exclusive bool `yaml:"exclusive,omitempty"`
Name string `yaml:"name"`
Color string `yaml:"color"`
Description string `yaml:"description,omitempty"`
Exclusive bool `yaml:"exclusive,omitempty"`
ExclusiveOrder int `yaml:"exclusive_order,omitempty"`
}
// NormalizeColor normalizes a color string to a 6-character hex code

View File

@ -127,10 +127,11 @@ func InitializeLabels(ctx context.Context, id int64, labelTemplate string, isOrg
labels := make([]*issues_model.Label, len(list))
for i := 0; i < len(list); i++ {
labels[i] = &issues_model.Label{
Name: list[i].Name,
Exclusive: list[i].Exclusive,
Description: list[i].Description,
Color: list[i].Color,
Name: list[i].Name,
Exclusive: list[i].Exclusive,
ExclusiveOrder: list[i].ExclusiveOrder,
Description: list[i].Description,
Color: list[i].Color,
}
if isOrg {
labels[i].OrgID = id

View File

@ -170,13 +170,28 @@ func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
itemColor := "#" + hex.EncodeToString(itemBytes)
scopeColor := "#" + hex.EncodeToString(scopeBytes)
if label.ExclusiveOrder > 0 {
// <scope> | <label> | <order>
return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
`<div class="ui label scope-middle" style="color: %s !important; background-color: %s !important">%s</div>`+
`<div class="ui label scope-right">%d</div>`+
`</span>`,
extraCSSClasses, descriptionText,
textColor, scopeColor, scopeHTML,
textColor, itemColor, itemHTML,
label.ExclusiveOrder)
}
// <scope> | <label>
return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
`<div class="ui label scope-right" style="color: %s !important; background-color: %s !important">%s</div>`+
`</span>`,
extraCSSClasses, descriptionText,
textColor, scopeColor, scopeHTML,
textColor, itemColor, itemHTML)
textColor, itemColor, itemHTML,
)
}
// RenderEmoji renders html text with emoji post processors

View File

@ -22,49 +22,60 @@ labels:
description: Breaking change that won't be backward compatible
- name: "Reviewed/Duplicate"
exclusive: true
exclusive_order: 2
color: 616161
description: This issue or pull request already exists
- name: "Reviewed/Invalid"
exclusive: true
exclusive_order: 3
color: 546e7a
description: Invalid issue
- name: "Reviewed/Confirmed"
exclusive: true
exclusive_order: 1
color: 795548
description: Issue has been confirmed
- name: "Reviewed/Won't Fix"
exclusive: true
exclusive_order: 3
color: eeeeee
description: This issue won't be fixed
- name: "Status/Need More Info"
exclusive: true
exclusive_order: 2
color: 424242
description: Feedback is required to reproduce issue or to continue work
- name: "Status/Blocked"
exclusive: true
exclusive_order: 1
color: 880e4f
description: Something is blocking this issue or pull request
- name: "Status/Abandoned"
exclusive: true
exclusive_order: 3
color: "222222"
description: Somebody has started to work on this but abandoned work
- name: "Priority/Critical"
exclusive: true
exclusive_order: 1
color: b71c1c
description: The priority is critical
priority: critical
- name: "Priority/High"
exclusive: true
exclusive_order: 2
color: d32f2f
description: The priority is high
priority: high
- name: "Priority/Medium"
exclusive: true
exclusive_order: 3
color: e64a19
description: The priority is medium
priority: medium
- name: "Priority/Low"
exclusive: true
exclusive_order: 4
color: 4caf50
description: The priority is low
priority: low

View File

@ -1655,6 +1655,8 @@ issues.label_archived_filter = Show archived labels
issues.label_archive_tooltip = Archived labels are excluded by default from the suggestions when searching by label.
issues.label_exclusive_desc = Name the label <code>scope/item</code> to make it mutually exclusive with other <code>scope/</code> labels.
issues.label_exclusive_warning = Any conflicting scoped labels will be removed when editing the labels of an issue or pull request.
issues.label_exclusive_order = Sort Order
issues.label_exclusive_order_tooltip = Exclusive labels in the same scope will be sorted according to this numeric order.
issues.label_count = %d labels
issues.label_open_issues = %d open issues/pull requests
issues.label_edit = Edit

View File

@ -44,11 +44,12 @@ func NewLabel(ctx *context.Context) {
}
l := &issues_model.Label{
OrgID: ctx.Org.Organization.ID,
Name: form.Title,
Exclusive: form.Exclusive,
Description: form.Description,
Color: form.Color,
OrgID: ctx.Org.Organization.ID,
Name: form.Title,
Exclusive: form.Exclusive,
Description: form.Description,
Color: form.Color,
ExclusiveOrder: form.ExclusiveOrder,
}
if err := issues_model.NewLabel(ctx, l); err != nil {
ctx.ServerError("NewLabel", err)
@ -73,6 +74,7 @@ func UpdateLabel(ctx *context.Context) {
l.Name = form.Title
l.Exclusive = form.Exclusive
l.ExclusiveOrder = form.ExclusiveOrder
l.Description = form.Description
l.Color = form.Color
l.SetArchived(form.IsArchived)

View File

@ -343,14 +343,14 @@ func ViewProject(ctx *context.Context) {
return
}
labelIDs := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner)
preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner)
if ctx.Written() {
return
}
assigneeID := ctx.FormString("assignee")
opts := issues_model.IssuesOptions{
LabelIDs: labelIDs,
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
AssigneeID: assigneeID,
Owner: project.Owner,
Doer: ctx.Doer,
@ -406,8 +406,8 @@ func ViewProject(ctx *context.Context) {
}
// Get the exclusive scope for every label ID
labelExclusiveScopes := make([]string, 0, len(labelIDs))
for _, labelID := range labelIDs {
labelExclusiveScopes := make([]string, 0, len(preparedLabelFilter.SelectedLabelIDs))
for _, labelID := range preparedLabelFilter.SelectedLabelIDs {
foundExclusiveScope := false
for _, label := range labels {
if label.ID == labelID || label.ID == -labelID {
@ -422,7 +422,7 @@ func ViewProject(ctx *context.Context) {
}
for _, l := range labels {
l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
l.LoadSelectedLabelsAfterClick(preparedLabelFilter.SelectedLabelIDs, labelExclusiveScopes)
}
ctx.Data["Labels"] = labels
ctx.Data["NumLabels"] = len(labels)

View File

@ -111,11 +111,12 @@ func NewLabel(ctx *context.Context) {
}
l := &issues_model.Label{
RepoID: ctx.Repo.Repository.ID,
Name: form.Title,
Exclusive: form.Exclusive,
Description: form.Description,
Color: form.Color,
RepoID: ctx.Repo.Repository.ID,
Name: form.Title,
Exclusive: form.Exclusive,
ExclusiveOrder: form.ExclusiveOrder,
Description: form.Description,
Color: form.Color,
}
if err := issues_model.NewLabel(ctx, l); err != nil {
ctx.ServerError("NewLabel", err)
@ -139,6 +140,7 @@ func UpdateLabel(ctx *context.Context) {
}
l.Name = form.Title
l.Exclusive = form.Exclusive
l.ExclusiveOrder = form.ExclusiveOrder
l.Description = form.Description
l.Color = form.Color

View File

@ -5,8 +5,10 @@ package repo
import (
"bytes"
"fmt"
"maps"
"net/http"
"slices"
"sort"
"strconv"
"strings"
@ -18,6 +20,7 @@ import (
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
db_indexer "code.gitea.io/gitea/modules/indexer/issues/db"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
@ -30,14 +33,6 @@ import (
pull_service "code.gitea.io/gitea/services/pull"
)
func issueIDsFromSearch(ctx *context.Context, keyword string, opts *issues_model.IssuesOptions) ([]int64, error) {
ids, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts))
if err != nil {
return nil, fmt.Errorf("SearchIssues: %w", err)
}
return ids, nil
}
func retrieveProjectsForIssueList(ctx *context.Context, repo *repo_model.Repository) {
ctx.Data["OpenProjects"], ctx.Data["ClosedProjects"] = retrieveProjectsInternal(ctx, repo)
}
@ -459,6 +454,19 @@ func UpdateIssueStatus(ctx *context.Context) {
ctx.JSONOK()
}
func prepareIssueFilterExclusiveOrderScopes(ctx *context.Context, allLabels []*issues_model.Label) {
scopeSet := make(map[string]bool)
for _, label := range allLabels {
scope := label.ExclusiveScope()
if len(scope) > 0 && label.ExclusiveOrder > 0 {
scopeSet[scope] = true
}
}
scopes := slices.Collect(maps.Keys(scopeSet))
sort.Strings(scopes)
ctx.Data["ExclusiveLabelScopes"] = scopes
}
func renderMilestones(ctx *context.Context) {
// Get milestones
milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
@ -481,7 +489,7 @@ func renderMilestones(ctx *context.Context) {
ctx.Data["ClosedMilestones"] = closedMilestones
}
func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption optional.Option[bool]) {
func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int64, isPullOption optional.Option[bool]) {
var err error
viewType := ctx.FormString("type")
sortType := ctx.FormString("sort")
@ -521,15 +529,18 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
mileIDs = []int64{milestoneID}
}
labelIDs := issue.PrepareFilterIssueLabels(ctx, repo.ID, ctx.Repo.Owner)
preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, repo.ID, ctx.Repo.Owner)
if ctx.Written() {
return
}
prepareIssueFilterExclusiveOrderScopes(ctx, preparedLabelFilter.AllLabels)
var keywordMatchedIssueIDs []int64
var issueStats *issues_model.IssueStats
statsOpts := &issues_model.IssuesOptions{
RepoIDs: []int64{repo.ID},
LabelIDs: labelIDs,
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
MilestoneIDs: mileIDs,
ProjectID: projectID,
AssigneeID: assigneeID,
@ -541,7 +552,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
IssueIDs: nil,
}
if keyword != "" {
allIssueIDs, err := issueIDsFromSearch(ctx, keyword, statsOpts)
keywordMatchedIssueIDs, _, err = issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, statsOpts))
if err != nil {
if issue_indexer.IsAvailable(ctx) {
ctx.ServerError("issueIDsFromSearch", err)
@ -550,14 +561,17 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
ctx.Data["IssueIndexerUnavailable"] = true
return
}
statsOpts.IssueIDs = allIssueIDs
if len(keywordMatchedIssueIDs) == 0 {
// It did search with the keyword, but no issue found, just set issueStats to empty, then no need to do query again.
issueStats = &issues_model.IssueStats{}
// set keywordMatchedIssueIDs to empty slice, so we can distinguish it from "nil"
keywordMatchedIssueIDs = []int64{}
}
statsOpts.IssueIDs = keywordMatchedIssueIDs
}
if keyword != "" && len(statsOpts.IssueIDs) == 0 {
// So it did search with the keyword, but no issue found.
// Just set issueStats to empty.
issueStats = &issues_model.IssueStats{}
} else {
// So it did search with the keyword, and found some issues. It needs to get issueStats of these issues.
if issueStats == nil {
// Either it did search with the keyword, and found some issues, it needs to get issueStats of these issues.
// Or the keyword is empty, so it doesn't need issueIDs as filter, just get issueStats with statsOpts.
issueStats, err = issues_model.GetIssueStats(ctx, statsOpts)
if err != nil {
@ -589,25 +603,21 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
ctx.Data["TotalTrackedTime"] = totalTrackedTime
}
page := ctx.FormInt("page")
if page <= 1 {
page = 1
}
var total int
switch {
case isShowClosed.Value():
total = int(issueStats.ClosedCount)
case !isShowClosed.Has():
total = int(issueStats.OpenCount + issueStats.ClosedCount)
default:
total = int(issueStats.OpenCount)
// prepare pager
total := int(issueStats.OpenCount + issueStats.ClosedCount)
if isShowClosed.Has() {
total = util.Iif(isShowClosed.Value(), int(issueStats.ClosedCount), int(issueStats.OpenCount))
}
page := max(ctx.FormInt("page"), 1)
pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5)
// prepare real issue list:
var issues issues_model.IssueList
{
ids, err := issueIDsFromSearch(ctx, keyword, &issues_model.IssuesOptions{
if keywordMatchedIssueIDs == nil || len(keywordMatchedIssueIDs) > 0 {
// Either it did search with the keyword, and found some issues, then keywordMatchedIssueIDs is not null, it needs to use db indexer.
// Or the keyword is empty, it also needs to usd db indexer.
// In either case, no need to use keyword anymore
searchResult, err := db_indexer.GetIndexer().FindWithIssueOptions(ctx, &issues_model.IssuesOptions{
Paginator: &db.ListOptions{
Page: pager.Paginater.Current(),
PageSize: setting.UI.IssuePagingNum,
@ -622,18 +632,16 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
ProjectID: projectID,
IsClosed: isShowClosed,
IsPull: isPullOption,
LabelIDs: labelIDs,
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
SortType: sortType,
IssueIDs: keywordMatchedIssueIDs,
})
if err != nil {
if issue_indexer.IsAvailable(ctx) {
ctx.ServerError("issueIDsFromSearch", err)
return
}
ctx.Data["IssueIndexerUnavailable"] = true
ctx.ServerError("DBIndexer.Search", err)
return
}
issues, err = issues_model.GetIssuesByIDs(ctx, ids, true)
issueIDs := issue_indexer.SearchResultToIDSlice(searchResult)
issues, err = issues_model.GetIssuesByIDs(ctx, issueIDs, true)
if err != nil {
ctx.ServerError("GetIssuesByIDs", err)
return
@ -728,7 +736,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
ctx.Data["IssueStats"] = issueStats
ctx.Data["OpenCount"] = issueStats.OpenCount
ctx.Data["ClosedCount"] = issueStats.ClosedCount
ctx.Data["SelLabelIDs"] = labelIDs
ctx.Data["SelLabelIDs"] = preparedLabelFilter.SelectedLabelIDs
ctx.Data["ViewType"] = viewType
ctx.Data["SortType"] = sortType
ctx.Data["MilestoneID"] = milestoneID
@ -769,7 +777,7 @@ func Issues(ctx *context.Context) {
ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo)
}
issues(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), optional.Some(isPullList))
prepareIssueFilterAndList(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), optional.Some(isPullList))
if ctx.Written() {
return
}

View File

@ -263,7 +263,7 @@ func MilestoneIssuesAndPulls(ctx *context.Context) {
ctx.Data["Title"] = milestone.Name
ctx.Data["Milestone"] = milestone
issues(ctx, milestoneID, projectID, optional.None[bool]())
prepareIssueFilterAndList(ctx, milestoneID, projectID, optional.None[bool]())
ret := issue.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
ctx.Data["NewIssueChooseTemplate"] = len(ret.IssueTemplates) > 0

View File

@ -313,13 +313,13 @@ func ViewProject(ctx *context.Context) {
return
}
labelIDs := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner)
preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner)
assigneeID := ctx.FormString("assignee")
issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &issues_model.IssuesOptions{
RepoIDs: []int64{ctx.Repo.Repository.ID},
LabelIDs: labelIDs,
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
AssigneeID: assigneeID,
})
if err != nil {
@ -381,8 +381,8 @@ func ViewProject(ctx *context.Context) {
}
// Get the exclusive scope for every label ID
labelExclusiveScopes := make([]string, 0, len(labelIDs))
for _, labelID := range labelIDs {
labelExclusiveScopes := make([]string, 0, len(preparedLabelFilter.SelectedLabelIDs))
for _, labelID := range preparedLabelFilter.SelectedLabelIDs {
foundExclusiveScope := false
for _, label := range labels {
if label.ID == labelID || label.ID == -labelID {
@ -397,7 +397,7 @@ func ViewProject(ctx *context.Context) {
}
for _, l := range labels {
l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
l.LoadSelectedLabelsAfterClick(preparedLabelFilter.SelectedLabelIDs, labelExclusiveScopes)
}
ctx.Data["Labels"] = labels
ctx.Data["NumLabels"] = len(labels)

View File

@ -14,14 +14,18 @@ import (
)
// 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) {
func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_model.User) (ret struct {
AllLabels []*issues_model.Label
SelectedLabelIDs []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, ","))
ret.SelectedLabelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
if err != nil {
ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
}
@ -32,7 +36,7 @@ func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_mo
repoLabels, err := issues_model.GetLabelsByRepoID(ctx, repoID, "", db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByRepoID", err)
return nil
return ret
}
allLabels = append(allLabels, repoLabels...)
}
@ -41,14 +45,14 @@ func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_mo
orgLabels, err := issues_model.GetLabelsByOrgID(ctx, owner.ID, "", db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByOrgID", err)
return nil
return ret
}
allLabels = append(allLabels, orgLabels...)
}
// Get the exclusive scope for every label ID
labelExclusiveScopes := make([]string, 0, len(labelIDs))
for _, labelID := range labelIDs {
labelExclusiveScopes := make([]string, 0, len(ret.SelectedLabelIDs))
for _, labelID := range ret.SelectedLabelIDs {
foundExclusiveScope := false
for _, label := range allLabels {
if label.ID == labelID || label.ID == -labelID {
@ -63,9 +67,10 @@ func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_mo
}
for _, l := range allLabels {
l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
l.LoadSelectedLabelsAfterClick(ret.SelectedLabelIDs, labelExclusiveScopes)
}
ctx.Data["Labels"] = allLabels
ctx.Data["SelectLabels"] = selectLabels
return labelIDs
ret.AllLabels = allLabels
return ret
}

View File

@ -43,8 +43,9 @@ func ApplicationsPost(ctx *context.Context) {
_ = ctx.Req.ParseForm()
var scopeNames []string
const accessTokenScopePrefix = "scope-"
for k, v := range ctx.Req.Form {
if strings.HasPrefix(k, "scope-") {
if strings.HasPrefix(k, accessTokenScopePrefix) {
scopeNames = append(scopeNames, v...)
}
}

View File

@ -519,12 +519,13 @@ func (f *CreateMilestoneForm) Validate(req *http.Request, errs binding.Errors) b
// CreateLabelForm form for creating label
type CreateLabelForm struct {
ID int64
Title string `binding:"Required;MaxSize(50)" locale:"repo.issues.label_title"`
Exclusive bool `form:"exclusive"`
IsArchived bool `form:"is_archived"`
Description string `binding:"MaxSize(200)" locale:"repo.issues.label_description"`
Color string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"`
ID int64
Title string `binding:"Required;MaxSize(50)" locale:"repo.issues.label_title"`
Exclusive bool `form:"exclusive"`
ExclusiveOrder int `form:"exclusive_order"`
IsArchived bool `form:"is_archived"`
Description string `binding:"MaxSize(200)" locale:"repo.issues.label_description"`
Color string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"`
}
// Validate validates the fields

View File

@ -133,5 +133,11 @@
<a class="{{if eq .SortType "leastcomment"}}active {{end}}item" href="{{QueryBuild $queryLink "sort" "leastcomment"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastcomment"}}</a>
<a class="{{if eq .SortType "nearduedate"}}active {{end}}item" href="{{QueryBuild $queryLink "sort" "nearduedate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.nearduedate"}}</a>
<a class="{{if eq .SortType "farduedate"}}active {{end}}item" href="{{QueryBuild $queryLink "sort" "farduedate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.farduedate"}}</a>
<div class="divider"></div>
<div class="header">{{ctx.Locale.Tr "repo.issues.filter_label"}}</div>
{{range $scope := .ExclusiveLabelScopes}}
{{$sortType := (printf "scope-%s" $scope)}}
<a class="{{if eq $.SortType $sortType}}active {{end}}item" href="{{QueryBuild $queryLink "sort" $sortType}}">{{$scope}}</a>
{{end}}
</div>
</div>

View File

@ -24,7 +24,13 @@
<div class="desc tw-ml-1 tw-mt-2 tw-hidden label-exclusive-warning">
{{svg "octicon-alert"}} {{ctx.Locale.Tr "repo.issues.label_exclusive_warning"}}
</div>
<br>
<div class="field label-exclusive-order-input-field tw-mt-2">
<label class="flex-text-block">
{{ctx.Locale.Tr "repo.issues.label_exclusive_order"}}
<span data-tooltip-content="{{ctx.Locale.Tr "repo.issues.label_exclusive_order_tooltip"}}">{{svg "octicon-info"}}</span>
</label>
<input class="label-exclusive-order-input" name="exclusive_order" type="number" maxlength="4">
</div>
</div>
<div class="field label-is-archived-input-field">
<div class="ui checkbox">

View File

@ -50,6 +50,7 @@
data-label-id="{{.ID}}" data-label-name="{{.Name}}" data-label-color="{{.Color}}"
data-label-exclusive="{{.Exclusive}}" data-label-is-archived="{{gt .ArchivedUnix 0}}"
data-label-num-issues="{{.NumIssues}}" data-label-description="{{.Description}}"
data-label-exclusive-order="{{.ExclusiveOrder}}"
>{{svg "octicon-pencil"}} {{ctx.Locale.Tr "repo.issues.label_edit"}}</a>
<a class="link-action" href="#" data-url="{{$.Link}}/delete?id={{.ID}}"
data-modal-confirm-header="{{ctx.Locale.Tr "repo.issues.label_deletion"}}"

View File

@ -1127,6 +1127,7 @@ table th[data-sortt-desc] .svg {
}
.ui.list.flex-items-block > .item,
.ui.form .field > label.flex-text-block, /* override fomantic "block" style */
.flex-items-block > .item,
.flex-text-block {
display: flex;

View File

@ -1604,6 +1604,12 @@ td .commit-summary {
margin-right: 0;
}
.ui.label.scope-middle {
border-radius: 0;
margin-left: 0;
margin-right: 0;
}
.ui.label.scope-right {
border-bottom-left-radius: 0;
border-top-left-radius: 0;

View File

@ -18,6 +18,8 @@ export function initCompLabelEdit(pageSelector: string) {
const elExclusiveField = elModal.querySelector('.label-exclusive-input-field');
const elExclusiveInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-input');
const elExclusiveWarning = elModal.querySelector('.label-exclusive-warning');
const elExclusiveOrderField = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input-field');
const elExclusiveOrderInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input');
const elIsArchivedField = elModal.querySelector('.label-is-archived-input-field');
const elIsArchivedInput = elModal.querySelector<HTMLInputElement>('.label-is-archived-input');
const elDescInput = elModal.querySelector<HTMLInputElement>('.label-desc-input');
@ -29,6 +31,13 @@ export function initCompLabelEdit(pageSelector: string) {
const showExclusiveWarning = hasScope && elExclusiveInput.checked && elModal.hasAttribute('data-need-warn-exclusive');
toggleElem(elExclusiveWarning, showExclusiveWarning);
if (!hasScope) elExclusiveInput.checked = false;
toggleElem(elExclusiveOrderField, elExclusiveInput.checked);
if (parseInt(elExclusiveOrderInput.value) <= 0) {
elExclusiveOrderInput.style.color = 'var(--color-placeholder-text) !important';
} else {
elExclusiveOrderInput.style.color = null;
}
};
const showLabelEditModal = (btn:HTMLElement) => {
@ -36,6 +45,7 @@ export function initCompLabelEdit(pageSelector: string) {
const form = elModal.querySelector<HTMLFormElement>('form');
elLabelId.value = btn.getAttribute('data-label-id') || '';
elNameInput.value = btn.getAttribute('data-label-name') || '';
elExclusiveOrderInput.value = btn.getAttribute('data-label-exclusive-order') || '0';
elIsArchivedInput.checked = btn.getAttribute('data-label-is-archived') === 'true';
elExclusiveInput.checked = btn.getAttribute('data-label-exclusive') === 'true';
elDescInput.value = btn.getAttribute('data-label-description') || '';