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
28 changed files with 236 additions and 105 deletions

View File

@ -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")

View File

@ -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

View File

@ -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
} }

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 ( 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
} }

View File

@ -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,

View File

@ -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
} }

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) 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.

View File

@ -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

View File

@ -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,
} }

View File

@ -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

View File

@ -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

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_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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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
} }

View File

@ -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

View File

@ -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)

View File

@ -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
} }

View File

@ -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...)
} }
} }

View File

@ -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"`

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 "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>

View File

@ -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">

View File

@ -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"}}"

View File

@ -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;

View File

@ -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;

View File

@ -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') || '';