mirror of
https://github.com/go-gitea/gitea.git
synced 2025-04-20 00:19:08 +03:00
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:
@ -21,6 +21,8 @@ import (
|
|||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const ScopeSortPrefix = "scope-"
|
||||||
|
|
||||||
// IssuesOptions represents options of an issue.
|
// IssuesOptions represents options of an issue.
|
||||||
type IssuesOptions struct { //nolint
|
type IssuesOptions struct { //nolint
|
||||||
Paginator *db.ListOptions
|
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
|
// applySorts sort an issues-related session based on the provided
|
||||||
// sortType string
|
// sortType string
|
||||||
func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) {
|
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 {
|
switch sortType {
|
||||||
case "oldest":
|
case "oldest":
|
||||||
sess.Asc("issue.created_unix").Asc("issue.id")
|
sess.Asc("issue.created_unix").Asc("issue.id")
|
||||||
|
@ -87,6 +87,7 @@ type Label struct {
|
|||||||
OrgID int64 `xorm:"INDEX"`
|
OrgID int64 `xorm:"INDEX"`
|
||||||
Name string
|
Name string
|
||||||
Exclusive bool
|
Exclusive bool
|
||||||
|
ExclusiveOrder int `xorm:"DEFAULT 0"` // 0 means no exclusive order
|
||||||
Description string
|
Description string
|
||||||
Color string `xorm:"VARCHAR(7)"`
|
Color string `xorm:"VARCHAR(7)"`
|
||||||
NumIssues int
|
NumIssues int
|
||||||
@ -236,7 +237,7 @@ func UpdateLabel(ctx context.Context, l *Label) error {
|
|||||||
}
|
}
|
||||||
l.Color = color
|
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
|
// DeleteLabel delete a label
|
||||||
|
@ -380,6 +380,7 @@ func prepareMigrationTasks() []*migration {
|
|||||||
newMigration(316, "Add description for secrets and variables", v1_24.AddDescriptionForSecretsAndVariables),
|
newMigration(316, "Add description for secrets and variables", v1_24.AddDescriptionForSecretsAndVariables),
|
||||||
newMigration(317, "Add new index for action for heatmap", v1_24.AddNewIndexForUserDashboard),
|
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(318, "Add anonymous_access_mode for repo_unit", v1_24.AddRepoUnitAnonymousAccessMode),
|
||||||
|
newMigration(319, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable),
|
||||||
}
|
}
|
||||||
return preparedMigrations
|
return preparedMigrations
|
||||||
}
|
}
|
||||||
|
16
models/migrations/v1_24/v319.go
Normal file
16
models/migrations/v1_24/v319.go
Normal 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))
|
||||||
|
}
|
@ -6,6 +6,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
issue_model "code.gitea.io/gitea/models/issues"
|
issue_model "code.gitea.io/gitea/models/issues"
|
||||||
@ -18,7 +19,7 @@ import (
|
|||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ internal.Indexer = &Indexer{}
|
var _ internal.Indexer = (*Indexer)(nil)
|
||||||
|
|
||||||
// Indexer implements Indexer interface to use database's like search
|
// Indexer implements Indexer interface to use database's like search
|
||||||
type Indexer struct {
|
type Indexer struct {
|
||||||
@ -29,11 +30,9 @@ func (i *Indexer) SupportedSearchModes() []indexer.SearchMode {
|
|||||||
return indexer.SearchModesExactWords()
|
return indexer.SearchModesExactWords()
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIndexer() *Indexer {
|
var GetIndexer = sync.OnceValue(func() *Indexer {
|
||||||
return &Indexer{
|
return &Indexer{Indexer: &inner_db.Indexer{}}
|
||||||
Indexer: &inner_db.Indexer{},
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Index dummy function
|
// Index dummy function
|
||||||
func (i *Indexer) Index(_ context.Context, _ ...*internal.IndexerData) error {
|
func (i *Indexer) Index(_ context.Context, _ ...*internal.IndexerData) error {
|
||||||
@ -122,7 +121,11 @@ func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
|
|||||||
}, nil
|
}, 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
issue_model "code.gitea.io/gitea/models/issues"
|
issue_model "code.gitea.io/gitea/models/issues"
|
||||||
@ -34,8 +35,12 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
|
|||||||
case internal.SortByDeadlineAsc:
|
case internal.SortByDeadlineAsc:
|
||||||
sortType = "nearduedate"
|
sortType = "nearduedate"
|
||||||
default:
|
default:
|
||||||
|
if strings.HasPrefix(string(options.SortBy), issue_model.ScopeSortPrefix) {
|
||||||
|
sortType = string(options.SortBy)
|
||||||
|
} else {
|
||||||
sortType = "newest"
|
sortType = "newest"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// See the comment of issues_model.SearchOptions for the reason why we need to convert
|
// See the comment of issues_model.SearchOptions for the reason why we need to convert
|
||||||
convertID := func(id optional.Option[int64]) int64 {
|
convertID := func(id optional.Option[int64]) int64 {
|
||||||
@ -68,7 +73,6 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
|
|||||||
ExcludedLabelNames: nil,
|
ExcludedLabelNames: nil,
|
||||||
IncludeMilestones: nil,
|
IncludeMilestones: nil,
|
||||||
SortType: sortType,
|
SortType: sortType,
|
||||||
IssueIDs: nil,
|
|
||||||
UpdatedAfterUnix: options.UpdatedAfterUnix.Value(),
|
UpdatedAfterUnix: options.UpdatedAfterUnix.Value(),
|
||||||
UpdatedBeforeUnix: options.UpdatedBeforeUnix.Value(),
|
UpdatedBeforeUnix: options.UpdatedBeforeUnix.Value(),
|
||||||
PriorityRepoID: 0,
|
PriorityRepoID: 0,
|
||||||
|
@ -4,12 +4,19 @@
|
|||||||
package issues
|
package issues
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
issues_model "code.gitea.io/gitea/models/issues"
|
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/optional"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOptions {
|
func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOptions {
|
||||||
|
if opts.IssueIDs != nil {
|
||||||
|
setting.PanicInDevOrTesting("Indexer SearchOptions doesn't support IssueIDs")
|
||||||
|
}
|
||||||
searchOpt := &SearchOptions{
|
searchOpt := &SearchOptions{
|
||||||
Keyword: keyword,
|
Keyword: keyword,
|
||||||
RepoIDs: opts.RepoIDs,
|
RepoIDs: opts.RepoIDs,
|
||||||
@ -95,8 +102,12 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
|
|||||||
// Unsupported sort type for search
|
// Unsupported sort type for search
|
||||||
fallthrough
|
fallthrough
|
||||||
default:
|
default:
|
||||||
|
if strings.HasPrefix(opts.SortType, issues_model.ScopeSortPrefix) {
|
||||||
|
searchOpt.SortBy = internal.SortBy(opts.SortType)
|
||||||
|
} else {
|
||||||
searchOpt.SortBy = SortByUpdatedDesc
|
searchOpt.SortBy = SortByUpdatedDesc
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return searchOpt
|
return searchOpt
|
||||||
}
|
}
|
||||||
|
@ -103,7 +103,7 @@ func InitIssueIndexer(syncReindex bool) {
|
|||||||
log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", setting.Indexer.IssueConnStr, err)
|
log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", setting.Indexer.IssueConnStr, err)
|
||||||
}
|
}
|
||||||
case "db":
|
case "db":
|
||||||
issueIndexer = db.NewIndexer()
|
issueIndexer = db.GetIndexer()
|
||||||
case "meilisearch":
|
case "meilisearch":
|
||||||
issueIndexer = meilisearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueConnAuth, setting.Indexer.IssueIndexerName)
|
issueIndexer = meilisearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueConnAuth, setting.Indexer.IssueIndexerName)
|
||||||
existed, err = issueIndexer.Init(ctx)
|
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.
|
// 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,
|
// 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.
|
// 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)
|
result, err := ix.Search(ctx, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
return SearchResultToIDSlice(result), result.Total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SearchResultToIDSlice(result *internal.SearchResult) []int64 {
|
||||||
ret := make([]int64, 0, len(result.Hits))
|
ret := make([]int64, 0, len(result.Hits))
|
||||||
for _, hit := range result.Hits {
|
for _, hit := range result.Hits {
|
||||||
ret = append(ret, hit.ID)
|
ret = append(ret, hit.ID)
|
||||||
}
|
}
|
||||||
|
return ret
|
||||||
return ret, result.Total, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountIssues counts issues by options. It is a shortcut of SearchIssues(ctx, opts) but only returns the total count.
|
// CountIssues counts issues by options. It is a shortcut of SearchIssues(ctx, opts) but only returns the total count.
|
||||||
|
@ -18,6 +18,7 @@ type Label struct {
|
|||||||
Color string `yaml:"color"`
|
Color string `yaml:"color"`
|
||||||
Description string `yaml:"description,omitempty"`
|
Description string `yaml:"description,omitempty"`
|
||||||
Exclusive bool `yaml:"exclusive,omitempty"`
|
Exclusive bool `yaml:"exclusive,omitempty"`
|
||||||
|
ExclusiveOrder int `yaml:"exclusive_order,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NormalizeColor normalizes a color string to a 6-character hex code
|
// NormalizeColor normalizes a color string to a 6-character hex code
|
||||||
|
@ -129,6 +129,7 @@ func InitializeLabels(ctx context.Context, id int64, labelTemplate string, isOrg
|
|||||||
labels[i] = &issues_model.Label{
|
labels[i] = &issues_model.Label{
|
||||||
Name: list[i].Name,
|
Name: list[i].Name,
|
||||||
Exclusive: list[i].Exclusive,
|
Exclusive: list[i].Exclusive,
|
||||||
|
ExclusiveOrder: list[i].ExclusiveOrder,
|
||||||
Description: list[i].Description,
|
Description: list[i].Description,
|
||||||
Color: list[i].Color,
|
Color: list[i].Color,
|
||||||
}
|
}
|
||||||
|
@ -170,13 +170,28 @@ func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
|
|||||||
itemColor := "#" + hex.EncodeToString(itemBytes)
|
itemColor := "#" + hex.EncodeToString(itemBytes)
|
||||||
scopeColor := "#" + hex.EncodeToString(scopeBytes)
|
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">`+
|
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-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>`+
|
`<div class="ui label scope-right" style="color: %s !important; background-color: %s !important">%s</div>`+
|
||||||
`</span>`,
|
`</span>`,
|
||||||
extraCSSClasses, descriptionText,
|
extraCSSClasses, descriptionText,
|
||||||
textColor, scopeColor, scopeHTML,
|
textColor, scopeColor, scopeHTML,
|
||||||
textColor, itemColor, itemHTML)
|
textColor, itemColor, itemHTML,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderEmoji renders html text with emoji post processors
|
// RenderEmoji renders html text with emoji post processors
|
||||||
|
@ -22,49 +22,60 @@ labels:
|
|||||||
description: Breaking change that won't be backward compatible
|
description: Breaking change that won't be backward compatible
|
||||||
- name: "Reviewed/Duplicate"
|
- name: "Reviewed/Duplicate"
|
||||||
exclusive: true
|
exclusive: true
|
||||||
|
exclusive_order: 2
|
||||||
color: 616161
|
color: 616161
|
||||||
description: This issue or pull request already exists
|
description: This issue or pull request already exists
|
||||||
- name: "Reviewed/Invalid"
|
- name: "Reviewed/Invalid"
|
||||||
exclusive: true
|
exclusive: true
|
||||||
|
exclusive_order: 3
|
||||||
color: 546e7a
|
color: 546e7a
|
||||||
description: Invalid issue
|
description: Invalid issue
|
||||||
- name: "Reviewed/Confirmed"
|
- name: "Reviewed/Confirmed"
|
||||||
exclusive: true
|
exclusive: true
|
||||||
|
exclusive_order: 1
|
||||||
color: 795548
|
color: 795548
|
||||||
description: Issue has been confirmed
|
description: Issue has been confirmed
|
||||||
- name: "Reviewed/Won't Fix"
|
- name: "Reviewed/Won't Fix"
|
||||||
exclusive: true
|
exclusive: true
|
||||||
|
exclusive_order: 3
|
||||||
color: eeeeee
|
color: eeeeee
|
||||||
description: This issue won't be fixed
|
description: This issue won't be fixed
|
||||||
- name: "Status/Need More Info"
|
- name: "Status/Need More Info"
|
||||||
exclusive: true
|
exclusive: true
|
||||||
|
exclusive_order: 2
|
||||||
color: 424242
|
color: 424242
|
||||||
description: Feedback is required to reproduce issue or to continue work
|
description: Feedback is required to reproduce issue or to continue work
|
||||||
- name: "Status/Blocked"
|
- name: "Status/Blocked"
|
||||||
exclusive: true
|
exclusive: true
|
||||||
|
exclusive_order: 1
|
||||||
color: 880e4f
|
color: 880e4f
|
||||||
description: Something is blocking this issue or pull request
|
description: Something is blocking this issue or pull request
|
||||||
- name: "Status/Abandoned"
|
- name: "Status/Abandoned"
|
||||||
exclusive: true
|
exclusive: true
|
||||||
|
exclusive_order: 3
|
||||||
color: "222222"
|
color: "222222"
|
||||||
description: Somebody has started to work on this but abandoned work
|
description: Somebody has started to work on this but abandoned work
|
||||||
- name: "Priority/Critical"
|
- name: "Priority/Critical"
|
||||||
exclusive: true
|
exclusive: true
|
||||||
|
exclusive_order: 1
|
||||||
color: b71c1c
|
color: b71c1c
|
||||||
description: The priority is critical
|
description: The priority is critical
|
||||||
priority: critical
|
priority: critical
|
||||||
- name: "Priority/High"
|
- name: "Priority/High"
|
||||||
exclusive: true
|
exclusive: true
|
||||||
|
exclusive_order: 2
|
||||||
color: d32f2f
|
color: d32f2f
|
||||||
description: The priority is high
|
description: The priority is high
|
||||||
priority: high
|
priority: high
|
||||||
- name: "Priority/Medium"
|
- name: "Priority/Medium"
|
||||||
exclusive: true
|
exclusive: true
|
||||||
|
exclusive_order: 3
|
||||||
color: e64a19
|
color: e64a19
|
||||||
description: The priority is medium
|
description: The priority is medium
|
||||||
priority: medium
|
priority: medium
|
||||||
- name: "Priority/Low"
|
- name: "Priority/Low"
|
||||||
exclusive: true
|
exclusive: true
|
||||||
|
exclusive_order: 4
|
||||||
color: 4caf50
|
color: 4caf50
|
||||||
description: The priority is low
|
description: The priority is low
|
||||||
priority: low
|
priority: low
|
||||||
|
@ -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_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_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_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_count = %d labels
|
||||||
issues.label_open_issues = %d open issues/pull requests
|
issues.label_open_issues = %d open issues/pull requests
|
||||||
issues.label_edit = Edit
|
issues.label_edit = Edit
|
||||||
|
@ -49,6 +49,7 @@ func NewLabel(ctx *context.Context) {
|
|||||||
Exclusive: form.Exclusive,
|
Exclusive: form.Exclusive,
|
||||||
Description: form.Description,
|
Description: form.Description,
|
||||||
Color: form.Color,
|
Color: form.Color,
|
||||||
|
ExclusiveOrder: form.ExclusiveOrder,
|
||||||
}
|
}
|
||||||
if err := issues_model.NewLabel(ctx, l); err != nil {
|
if err := issues_model.NewLabel(ctx, l); err != nil {
|
||||||
ctx.ServerError("NewLabel", err)
|
ctx.ServerError("NewLabel", err)
|
||||||
@ -73,6 +74,7 @@ func UpdateLabel(ctx *context.Context) {
|
|||||||
|
|
||||||
l.Name = form.Title
|
l.Name = form.Title
|
||||||
l.Exclusive = form.Exclusive
|
l.Exclusive = form.Exclusive
|
||||||
|
l.ExclusiveOrder = form.ExclusiveOrder
|
||||||
l.Description = form.Description
|
l.Description = form.Description
|
||||||
l.Color = form.Color
|
l.Color = form.Color
|
||||||
l.SetArchived(form.IsArchived)
|
l.SetArchived(form.IsArchived)
|
||||||
|
@ -343,14 +343,14 @@ func ViewProject(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
labelIDs := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner)
|
preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner)
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
assigneeID := ctx.FormString("assignee")
|
assigneeID := ctx.FormString("assignee")
|
||||||
|
|
||||||
opts := issues_model.IssuesOptions{
|
opts := issues_model.IssuesOptions{
|
||||||
LabelIDs: labelIDs,
|
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
||||||
AssigneeID: assigneeID,
|
AssigneeID: assigneeID,
|
||||||
Owner: project.Owner,
|
Owner: project.Owner,
|
||||||
Doer: ctx.Doer,
|
Doer: ctx.Doer,
|
||||||
@ -406,8 +406,8 @@ func ViewProject(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the exclusive scope for every label ID
|
// Get the exclusive scope for every label ID
|
||||||
labelExclusiveScopes := make([]string, 0, len(labelIDs))
|
labelExclusiveScopes := make([]string, 0, len(preparedLabelFilter.SelectedLabelIDs))
|
||||||
for _, labelID := range labelIDs {
|
for _, labelID := range preparedLabelFilter.SelectedLabelIDs {
|
||||||
foundExclusiveScope := false
|
foundExclusiveScope := false
|
||||||
for _, label := range labels {
|
for _, label := range labels {
|
||||||
if label.ID == labelID || label.ID == -labelID {
|
if label.ID == labelID || label.ID == -labelID {
|
||||||
@ -422,7 +422,7 @@ func ViewProject(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, l := range labels {
|
for _, l := range labels {
|
||||||
l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
|
l.LoadSelectedLabelsAfterClick(preparedLabelFilter.SelectedLabelIDs, labelExclusiveScopes)
|
||||||
}
|
}
|
||||||
ctx.Data["Labels"] = labels
|
ctx.Data["Labels"] = labels
|
||||||
ctx.Data["NumLabels"] = len(labels)
|
ctx.Data["NumLabels"] = len(labels)
|
||||||
|
@ -114,6 +114,7 @@ func NewLabel(ctx *context.Context) {
|
|||||||
RepoID: ctx.Repo.Repository.ID,
|
RepoID: ctx.Repo.Repository.ID,
|
||||||
Name: form.Title,
|
Name: form.Title,
|
||||||
Exclusive: form.Exclusive,
|
Exclusive: form.Exclusive,
|
||||||
|
ExclusiveOrder: form.ExclusiveOrder,
|
||||||
Description: form.Description,
|
Description: form.Description,
|
||||||
Color: form.Color,
|
Color: form.Color,
|
||||||
}
|
}
|
||||||
@ -139,6 +140,7 @@ func UpdateLabel(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
l.Name = form.Title
|
l.Name = form.Title
|
||||||
l.Exclusive = form.Exclusive
|
l.Exclusive = form.Exclusive
|
||||||
|
l.ExclusiveOrder = form.ExclusiveOrder
|
||||||
l.Description = form.Description
|
l.Description = form.Description
|
||||||
l.Color = form.Color
|
l.Color = form.Color
|
||||||
|
|
||||||
|
@ -5,8 +5,10 @@ package repo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@ -18,6 +20,7 @@ import (
|
|||||||
"code.gitea.io/gitea/models/unit"
|
"code.gitea.io/gitea/models/unit"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
|
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/log"
|
||||||
"code.gitea.io/gitea/modules/optional"
|
"code.gitea.io/gitea/modules/optional"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
@ -30,14 +33,6 @@ import (
|
|||||||
pull_service "code.gitea.io/gitea/services/pull"
|
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) {
|
func retrieveProjectsForIssueList(ctx *context.Context, repo *repo_model.Repository) {
|
||||||
ctx.Data["OpenProjects"], ctx.Data["ClosedProjects"] = retrieveProjectsInternal(ctx, repo)
|
ctx.Data["OpenProjects"], ctx.Data["ClosedProjects"] = retrieveProjectsInternal(ctx, repo)
|
||||||
}
|
}
|
||||||
@ -459,6 +454,19 @@ func UpdateIssueStatus(ctx *context.Context) {
|
|||||||
ctx.JSONOK()
|
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) {
|
func renderMilestones(ctx *context.Context) {
|
||||||
// Get milestones
|
// Get milestones
|
||||||
milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
|
milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
|
||||||
@ -481,7 +489,7 @@ func renderMilestones(ctx *context.Context) {
|
|||||||
ctx.Data["ClosedMilestones"] = closedMilestones
|
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
|
var err error
|
||||||
viewType := ctx.FormString("type")
|
viewType := ctx.FormString("type")
|
||||||
sortType := ctx.FormString("sort")
|
sortType := ctx.FormString("sort")
|
||||||
@ -521,15 +529,18 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
|
|||||||
mileIDs = []int64{milestoneID}
|
mileIDs = []int64{milestoneID}
|
||||||
}
|
}
|
||||||
|
|
||||||
labelIDs := issue.PrepareFilterIssueLabels(ctx, repo.ID, ctx.Repo.Owner)
|
preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, repo.ID, ctx.Repo.Owner)
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prepareIssueFilterExclusiveOrderScopes(ctx, preparedLabelFilter.AllLabels)
|
||||||
|
|
||||||
|
var keywordMatchedIssueIDs []int64
|
||||||
var issueStats *issues_model.IssueStats
|
var issueStats *issues_model.IssueStats
|
||||||
statsOpts := &issues_model.IssuesOptions{
|
statsOpts := &issues_model.IssuesOptions{
|
||||||
RepoIDs: []int64{repo.ID},
|
RepoIDs: []int64{repo.ID},
|
||||||
LabelIDs: labelIDs,
|
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
||||||
MilestoneIDs: mileIDs,
|
MilestoneIDs: mileIDs,
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
AssigneeID: assigneeID,
|
AssigneeID: assigneeID,
|
||||||
@ -541,7 +552,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
|
|||||||
IssueIDs: nil,
|
IssueIDs: nil,
|
||||||
}
|
}
|
||||||
if keyword != "" {
|
if keyword != "" {
|
||||||
allIssueIDs, err := issueIDsFromSearch(ctx, keyword, statsOpts)
|
keywordMatchedIssueIDs, _, err = issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, statsOpts))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if issue_indexer.IsAvailable(ctx) {
|
if issue_indexer.IsAvailable(ctx) {
|
||||||
ctx.ServerError("issueIDsFromSearch", err)
|
ctx.ServerError("issueIDsFromSearch", err)
|
||||||
@ -550,14 +561,17 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
|
|||||||
ctx.Data["IssueIndexerUnavailable"] = true
|
ctx.Data["IssueIndexerUnavailable"] = true
|
||||||
return
|
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.
|
||||||
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{}
|
issueStats = &issues_model.IssueStats{}
|
||||||
} else {
|
// set keywordMatchedIssueIDs to empty slice, so we can distinguish it from "nil"
|
||||||
// So it did search with the keyword, and found some issues. It needs to get issueStats of these issues.
|
keywordMatchedIssueIDs = []int64{}
|
||||||
|
}
|
||||||
|
statsOpts.IssueIDs = keywordMatchedIssueIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
// 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)
|
issueStats, err = issues_model.GetIssueStats(ctx, statsOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -589,25 +603,21 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
|
|||||||
ctx.Data["TotalTrackedTime"] = totalTrackedTime
|
ctx.Data["TotalTrackedTime"] = totalTrackedTime
|
||||||
}
|
}
|
||||||
|
|
||||||
page := ctx.FormInt("page")
|
// prepare pager
|
||||||
if page <= 1 {
|
total := int(issueStats.OpenCount + issueStats.ClosedCount)
|
||||||
page = 1
|
if isShowClosed.Has() {
|
||||||
}
|
total = util.Iif(isShowClosed.Value(), int(issueStats.ClosedCount), int(issueStats.OpenCount))
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
page := max(ctx.FormInt("page"), 1)
|
||||||
pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5)
|
pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5)
|
||||||
|
|
||||||
|
// prepare real issue list:
|
||||||
var issues issues_model.IssueList
|
var issues issues_model.IssueList
|
||||||
{
|
if keywordMatchedIssueIDs == nil || len(keywordMatchedIssueIDs) > 0 {
|
||||||
ids, err := issueIDsFromSearch(ctx, keyword, &issues_model.IssuesOptions{
|
// 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{
|
Paginator: &db.ListOptions{
|
||||||
Page: pager.Paginater.Current(),
|
Page: pager.Paginater.Current(),
|
||||||
PageSize: setting.UI.IssuePagingNum,
|
PageSize: setting.UI.IssuePagingNum,
|
||||||
@ -622,18 +632,16 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
|
|||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
IsClosed: isShowClosed,
|
IsClosed: isShowClosed,
|
||||||
IsPull: isPullOption,
|
IsPull: isPullOption,
|
||||||
LabelIDs: labelIDs,
|
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
||||||
SortType: sortType,
|
SortType: sortType,
|
||||||
|
IssueIDs: keywordMatchedIssueIDs,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if issue_indexer.IsAvailable(ctx) {
|
ctx.ServerError("DBIndexer.Search", err)
|
||||||
ctx.ServerError("issueIDsFromSearch", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.Data["IssueIndexerUnavailable"] = true
|
issueIDs := issue_indexer.SearchResultToIDSlice(searchResult)
|
||||||
return
|
issues, err = issues_model.GetIssuesByIDs(ctx, issueIDs, true)
|
||||||
}
|
|
||||||
issues, err = issues_model.GetIssuesByIDs(ctx, ids, true)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("GetIssuesByIDs", err)
|
ctx.ServerError("GetIssuesByIDs", err)
|
||||||
return
|
return
|
||||||
@ -728,7 +736,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
|
|||||||
ctx.Data["IssueStats"] = issueStats
|
ctx.Data["IssueStats"] = issueStats
|
||||||
ctx.Data["OpenCount"] = issueStats.OpenCount
|
ctx.Data["OpenCount"] = issueStats.OpenCount
|
||||||
ctx.Data["ClosedCount"] = issueStats.ClosedCount
|
ctx.Data["ClosedCount"] = issueStats.ClosedCount
|
||||||
ctx.Data["SelLabelIDs"] = labelIDs
|
ctx.Data["SelLabelIDs"] = preparedLabelFilter.SelectedLabelIDs
|
||||||
ctx.Data["ViewType"] = viewType
|
ctx.Data["ViewType"] = viewType
|
||||||
ctx.Data["SortType"] = sortType
|
ctx.Data["SortType"] = sortType
|
||||||
ctx.Data["MilestoneID"] = milestoneID
|
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)
|
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() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -263,7 +263,7 @@ func MilestoneIssuesAndPulls(ctx *context.Context) {
|
|||||||
ctx.Data["Title"] = milestone.Name
|
ctx.Data["Title"] = milestone.Name
|
||||||
ctx.Data["Milestone"] = milestone
|
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)
|
ret := issue.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
|
||||||
ctx.Data["NewIssueChooseTemplate"] = len(ret.IssueTemplates) > 0
|
ctx.Data["NewIssueChooseTemplate"] = len(ret.IssueTemplates) > 0
|
||||||
|
@ -313,13 +313,13 @@ func ViewProject(ctx *context.Context) {
|
|||||||
return
|
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")
|
assigneeID := ctx.FormString("assignee")
|
||||||
|
|
||||||
issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &issues_model.IssuesOptions{
|
issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &issues_model.IssuesOptions{
|
||||||
RepoIDs: []int64{ctx.Repo.Repository.ID},
|
RepoIDs: []int64{ctx.Repo.Repository.ID},
|
||||||
LabelIDs: labelIDs,
|
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
||||||
AssigneeID: assigneeID,
|
AssigneeID: assigneeID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -381,8 +381,8 @@ func ViewProject(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the exclusive scope for every label ID
|
// Get the exclusive scope for every label ID
|
||||||
labelExclusiveScopes := make([]string, 0, len(labelIDs))
|
labelExclusiveScopes := make([]string, 0, len(preparedLabelFilter.SelectedLabelIDs))
|
||||||
for _, labelID := range labelIDs {
|
for _, labelID := range preparedLabelFilter.SelectedLabelIDs {
|
||||||
foundExclusiveScope := false
|
foundExclusiveScope := false
|
||||||
for _, label := range labels {
|
for _, label := range labels {
|
||||||
if label.ID == labelID || label.ID == -labelID {
|
if label.ID == labelID || label.ID == -labelID {
|
||||||
@ -397,7 +397,7 @@ func ViewProject(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, l := range labels {
|
for _, l := range labels {
|
||||||
l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
|
l.LoadSelectedLabelsAfterClick(preparedLabelFilter.SelectedLabelIDs, labelExclusiveScopes)
|
||||||
}
|
}
|
||||||
ctx.Data["Labels"] = labels
|
ctx.Data["Labels"] = labels
|
||||||
ctx.Data["NumLabels"] = len(labels)
|
ctx.Data["NumLabels"] = len(labels)
|
||||||
|
@ -14,14 +14,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// PrepareFilterIssueLabels reads the "labels" query parameter, sets `ctx.Data["Labels"]` and `ctx.Data["SelectLabels"]`
|
// 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
|
// 1,-2 means including label 1 and excluding label 2
|
||||||
// 0 means issues with no label
|
// 0 means issues with no label
|
||||||
// blank means labels will not be filtered for issues
|
// blank means labels will not be filtered for issues
|
||||||
selectLabels := ctx.FormString("labels")
|
selectLabels := ctx.FormString("labels")
|
||||||
if selectLabels != "" {
|
if selectLabels != "" {
|
||||||
var err error
|
var err error
|
||||||
labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
|
ret.SelectedLabelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
|
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{})
|
repoLabels, err := issues_model.GetLabelsByRepoID(ctx, repoID, "", db.ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("GetLabelsByRepoID", err)
|
ctx.ServerError("GetLabelsByRepoID", err)
|
||||||
return nil
|
return ret
|
||||||
}
|
}
|
||||||
allLabels = append(allLabels, repoLabels...)
|
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{})
|
orgLabels, err := issues_model.GetLabelsByOrgID(ctx, owner.ID, "", db.ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("GetLabelsByOrgID", err)
|
ctx.ServerError("GetLabelsByOrgID", err)
|
||||||
return nil
|
return ret
|
||||||
}
|
}
|
||||||
allLabels = append(allLabels, orgLabels...)
|
allLabels = append(allLabels, orgLabels...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the exclusive scope for every label ID
|
// Get the exclusive scope for every label ID
|
||||||
labelExclusiveScopes := make([]string, 0, len(labelIDs))
|
labelExclusiveScopes := make([]string, 0, len(ret.SelectedLabelIDs))
|
||||||
for _, labelID := range labelIDs {
|
for _, labelID := range ret.SelectedLabelIDs {
|
||||||
foundExclusiveScope := false
|
foundExclusiveScope := false
|
||||||
for _, label := range allLabels {
|
for _, label := range allLabels {
|
||||||
if label.ID == labelID || label.ID == -labelID {
|
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 {
|
for _, l := range allLabels {
|
||||||
l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
|
l.LoadSelectedLabelsAfterClick(ret.SelectedLabelIDs, labelExclusiveScopes)
|
||||||
}
|
}
|
||||||
ctx.Data["Labels"] = allLabels
|
ctx.Data["Labels"] = allLabels
|
||||||
ctx.Data["SelectLabels"] = selectLabels
|
ctx.Data["SelectLabels"] = selectLabels
|
||||||
return labelIDs
|
ret.AllLabels = allLabels
|
||||||
|
return ret
|
||||||
}
|
}
|
||||||
|
@ -43,8 +43,9 @@ func ApplicationsPost(ctx *context.Context) {
|
|||||||
|
|
||||||
_ = ctx.Req.ParseForm()
|
_ = ctx.Req.ParseForm()
|
||||||
var scopeNames []string
|
var scopeNames []string
|
||||||
|
const accessTokenScopePrefix = "scope-"
|
||||||
for k, v := range ctx.Req.Form {
|
for k, v := range ctx.Req.Form {
|
||||||
if strings.HasPrefix(k, "scope-") {
|
if strings.HasPrefix(k, accessTokenScopePrefix) {
|
||||||
scopeNames = append(scopeNames, v...)
|
scopeNames = append(scopeNames, v...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -522,6 +522,7 @@ type CreateLabelForm struct {
|
|||||||
ID int64
|
ID int64
|
||||||
Title string `binding:"Required;MaxSize(50)" locale:"repo.issues.label_title"`
|
Title string `binding:"Required;MaxSize(50)" locale:"repo.issues.label_title"`
|
||||||
Exclusive bool `form:"exclusive"`
|
Exclusive bool `form:"exclusive"`
|
||||||
|
ExclusiveOrder int `form:"exclusive_order"`
|
||||||
IsArchived bool `form:"is_archived"`
|
IsArchived bool `form:"is_archived"`
|
||||||
Description string `binding:"MaxSize(200)" locale:"repo.issues.label_description"`
|
Description string `binding:"MaxSize(200)" locale:"repo.issues.label_description"`
|
||||||
Color string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"`
|
Color string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"`
|
||||||
|
@ -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 "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 "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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
@ -24,7 +24,13 @@
|
|||||||
<div class="desc tw-ml-1 tw-mt-2 tw-hidden label-exclusive-warning">
|
<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"}}
|
{{svg "octicon-alert"}} {{ctx.Locale.Tr "repo.issues.label_exclusive_warning"}}
|
||||||
</div>
|
</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>
|
||||||
<div class="field label-is-archived-input-field">
|
<div class="field label-is-archived-input-field">
|
||||||
<div class="ui checkbox">
|
<div class="ui checkbox">
|
||||||
|
@ -50,6 +50,7 @@
|
|||||||
data-label-id="{{.ID}}" data-label-name="{{.Name}}" data-label-color="{{.Color}}"
|
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-exclusive="{{.Exclusive}}" data-label-is-archived="{{gt .ArchivedUnix 0}}"
|
||||||
data-label-num-issues="{{.NumIssues}}" data-label-description="{{.Description}}"
|
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>
|
>{{svg "octicon-pencil"}} {{ctx.Locale.Tr "repo.issues.label_edit"}}</a>
|
||||||
<a class="link-action" href="#" data-url="{{$.Link}}/delete?id={{.ID}}"
|
<a class="link-action" href="#" data-url="{{$.Link}}/delete?id={{.ID}}"
|
||||||
data-modal-confirm-header="{{ctx.Locale.Tr "repo.issues.label_deletion"}}"
|
data-modal-confirm-header="{{ctx.Locale.Tr "repo.issues.label_deletion"}}"
|
||||||
|
@ -1127,6 +1127,7 @@ table th[data-sortt-desc] .svg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui.list.flex-items-block > .item,
|
.ui.list.flex-items-block > .item,
|
||||||
|
.ui.form .field > label.flex-text-block, /* override fomantic "block" style */
|
||||||
.flex-items-block > .item,
|
.flex-items-block > .item,
|
||||||
.flex-text-block {
|
.flex-text-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -1604,6 +1604,12 @@ td .commit-summary {
|
|||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui.label.scope-middle {
|
||||||
|
border-radius: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.ui.label.scope-right {
|
.ui.label.scope-right {
|
||||||
border-bottom-left-radius: 0;
|
border-bottom-left-radius: 0;
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
|
@ -18,6 +18,8 @@ export function initCompLabelEdit(pageSelector: string) {
|
|||||||
const elExclusiveField = elModal.querySelector('.label-exclusive-input-field');
|
const elExclusiveField = elModal.querySelector('.label-exclusive-input-field');
|
||||||
const elExclusiveInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-input');
|
const elExclusiveInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-input');
|
||||||
const elExclusiveWarning = elModal.querySelector('.label-exclusive-warning');
|
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 elIsArchivedField = elModal.querySelector('.label-is-archived-input-field');
|
||||||
const elIsArchivedInput = elModal.querySelector<HTMLInputElement>('.label-is-archived-input');
|
const elIsArchivedInput = elModal.querySelector<HTMLInputElement>('.label-is-archived-input');
|
||||||
const elDescInput = elModal.querySelector<HTMLInputElement>('.label-desc-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');
|
const showExclusiveWarning = hasScope && elExclusiveInput.checked && elModal.hasAttribute('data-need-warn-exclusive');
|
||||||
toggleElem(elExclusiveWarning, showExclusiveWarning);
|
toggleElem(elExclusiveWarning, showExclusiveWarning);
|
||||||
if (!hasScope) elExclusiveInput.checked = false;
|
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) => {
|
const showLabelEditModal = (btn:HTMLElement) => {
|
||||||
@ -36,6 +45,7 @@ export function initCompLabelEdit(pageSelector: string) {
|
|||||||
const form = elModal.querySelector<HTMLFormElement>('form');
|
const form = elModal.querySelector<HTMLFormElement>('form');
|
||||||
elLabelId.value = btn.getAttribute('data-label-id') || '';
|
elLabelId.value = btn.getAttribute('data-label-id') || '';
|
||||||
elNameInput.value = btn.getAttribute('data-label-name') || '';
|
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';
|
elIsArchivedInput.checked = btn.getAttribute('data-label-is-archived') === 'true';
|
||||||
elExclusiveInput.checked = btn.getAttribute('data-label-exclusive') === 'true';
|
elExclusiveInput.checked = btn.getAttribute('data-label-exclusive') === 'true';
|
||||||
elDescInput.value = btn.getAttribute('data-label-description') || '';
|
elDescInput.value = btn.getAttribute('data-label-description') || '';
|
||||||
|
Reference in New Issue
Block a user