mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00: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" | ||||
| ) | ||||
|  | ||||
| const ScopeSortPrefix = "scope-" | ||||
|  | ||||
| // IssuesOptions represents options of an issue. | ||||
| type IssuesOptions struct { //nolint | ||||
| 	Paginator          *db.ListOptions | ||||
| @@ -70,6 +72,17 @@ func (o *IssuesOptions) Copy(edit ...func(options *IssuesOptions)) *IssuesOption | ||||
| // applySorts sort an issues-related session based on the provided | ||||
| // sortType string | ||||
| func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) { | ||||
| 	// Since this sortType is dynamically created, it has to be treated specially. | ||||
| 	if strings.HasPrefix(sortType, ScopeSortPrefix) { | ||||
| 		scope := strings.TrimPrefix(sortType, ScopeSortPrefix) | ||||
| 		sess.Join("LEFT", "issue_label", "issue.id = issue_label.issue_id") | ||||
| 		// "exclusive_order=0" means "no order is set", so exclude it from the JOIN criteria and then "LEFT JOIN" result is also null | ||||
| 		sess.Join("LEFT", "label", "label.id = issue_label.label_id AND label.exclusive_order <> 0 AND label.name LIKE ?", scope+"/%") | ||||
| 		// Use COALESCE to make sure we sort NULL last regardless of backend DB (2147483647 == max int) | ||||
| 		sess.OrderBy("COALESCE(label.exclusive_order, 2147483647) ASC").Desc("issue.id") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	switch sortType { | ||||
| 	case "oldest": | ||||
| 		sess.Asc("issue.created_unix").Asc("issue.id") | ||||
|   | ||||
| @@ -87,6 +87,7 @@ type Label struct { | ||||
| 	OrgID           int64 `xorm:"INDEX"` | ||||
| 	Name            string | ||||
| 	Exclusive       bool | ||||
| 	ExclusiveOrder  int `xorm:"DEFAULT 0"` // 0 means no exclusive order | ||||
| 	Description     string | ||||
| 	Color           string `xorm:"VARCHAR(7)"` | ||||
| 	NumIssues       int | ||||
| @@ -236,7 +237,7 @@ func UpdateLabel(ctx context.Context, l *Label) error { | ||||
| 	} | ||||
| 	l.Color = color | ||||
|  | ||||
| 	return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "archived_unix") | ||||
| 	return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "exclusive_order", "archived_unix") | ||||
| } | ||||
|  | ||||
| // DeleteLabel delete a label | ||||
|   | ||||
| @@ -380,6 +380,7 @@ func prepareMigrationTasks() []*migration { | ||||
| 		newMigration(316, "Add description for secrets and variables", v1_24.AddDescriptionForSecretsAndVariables), | ||||
| 		newMigration(317, "Add new index for action for heatmap", v1_24.AddNewIndexForUserDashboard), | ||||
| 		newMigration(318, "Add anonymous_access_mode for repo_unit", v1_24.AddRepoUnitAnonymousAccessMode), | ||||
| 		newMigration(319, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable), | ||||
| 	} | ||||
| 	return preparedMigrations | ||||
| } | ||||
|   | ||||
							
								
								
									
										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 ( | ||||
| 	"context" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
|  | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| 	issue_model "code.gitea.io/gitea/models/issues" | ||||
| @@ -18,7 +19,7 @@ import ( | ||||
| 	"xorm.io/builder" | ||||
| ) | ||||
|  | ||||
| var _ internal.Indexer = &Indexer{} | ||||
| var _ internal.Indexer = (*Indexer)(nil) | ||||
|  | ||||
| // Indexer implements Indexer interface to use database's like search | ||||
| type Indexer struct { | ||||
| @@ -29,11 +30,9 @@ func (i *Indexer) SupportedSearchModes() []indexer.SearchMode { | ||||
| 	return indexer.SearchModesExactWords() | ||||
| } | ||||
|  | ||||
| func NewIndexer() *Indexer { | ||||
| 	return &Indexer{ | ||||
| 		Indexer: &inner_db.Indexer{}, | ||||
| 	} | ||||
| } | ||||
| var GetIndexer = sync.OnceValue(func() *Indexer { | ||||
| 	return &Indexer{Indexer: &inner_db.Indexer{}} | ||||
| }) | ||||
|  | ||||
| // Index dummy function | ||||
| func (i *Indexer) Index(_ context.Context, _ ...*internal.IndexerData) error { | ||||
| @@ -122,7 +121,11 @@ func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( | ||||
| 		}, nil | ||||
| 	} | ||||
|  | ||||
| 	ids, total, err := issue_model.IssueIDs(ctx, opt, cond) | ||||
| 	return i.FindWithIssueOptions(ctx, opt, cond) | ||||
| } | ||||
|  | ||||
| func (i *Indexer) FindWithIssueOptions(ctx context.Context, opt *issue_model.IssuesOptions, otherConds ...builder.Cond) (*internal.SearchResult, error) { | ||||
| 	ids, total, err := issue_model.IssueIDs(ctx, opt, otherConds...) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|   | ||||
| @@ -6,6 +6,7 @@ package db | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"strings" | ||||
|  | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| 	issue_model "code.gitea.io/gitea/models/issues" | ||||
| @@ -34,7 +35,11 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m | ||||
| 	case internal.SortByDeadlineAsc: | ||||
| 		sortType = "nearduedate" | ||||
| 	default: | ||||
| 		sortType = "newest" | ||||
| 		if strings.HasPrefix(string(options.SortBy), issue_model.ScopeSortPrefix) { | ||||
| 			sortType = string(options.SortBy) | ||||
| 		} else { | ||||
| 			sortType = "newest" | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// See the comment of issues_model.SearchOptions for the reason why we need to convert | ||||
| @@ -68,7 +73,6 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m | ||||
| 		ExcludedLabelNames: nil, | ||||
| 		IncludeMilestones:  nil, | ||||
| 		SortType:           sortType, | ||||
| 		IssueIDs:           nil, | ||||
| 		UpdatedAfterUnix:   options.UpdatedAfterUnix.Value(), | ||||
| 		UpdatedBeforeUnix:  options.UpdatedBeforeUnix.Value(), | ||||
| 		PriorityRepoID:     0, | ||||
|   | ||||
| @@ -4,12 +4,19 @@ | ||||
| package issues | ||||
|  | ||||
| import ( | ||||
| 	"strings" | ||||
|  | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| 	issues_model "code.gitea.io/gitea/models/issues" | ||||
| 	"code.gitea.io/gitea/modules/indexer/issues/internal" | ||||
| 	"code.gitea.io/gitea/modules/optional" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| ) | ||||
|  | ||||
| func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOptions { | ||||
| 	if opts.IssueIDs != nil { | ||||
| 		setting.PanicInDevOrTesting("Indexer SearchOptions doesn't support IssueIDs") | ||||
| 	} | ||||
| 	searchOpt := &SearchOptions{ | ||||
| 		Keyword:    keyword, | ||||
| 		RepoIDs:    opts.RepoIDs, | ||||
| @@ -95,7 +102,11 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp | ||||
| 		// Unsupported sort type for search | ||||
| 		fallthrough | ||||
| 	default: | ||||
| 		searchOpt.SortBy = SortByUpdatedDesc | ||||
| 		if strings.HasPrefix(opts.SortType, issues_model.ScopeSortPrefix) { | ||||
| 			searchOpt.SortBy = internal.SortBy(opts.SortType) | ||||
| 		} else { | ||||
| 			searchOpt.SortBy = SortByUpdatedDesc | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return searchOpt | ||||
|   | ||||
| @@ -103,7 +103,7 @@ func InitIssueIndexer(syncReindex bool) { | ||||
| 				log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", setting.Indexer.IssueConnStr, err) | ||||
| 			} | ||||
| 		case "db": | ||||
| 			issueIndexer = db.NewIndexer() | ||||
| 			issueIndexer = db.GetIndexer() | ||||
| 		case "meilisearch": | ||||
| 			issueIndexer = meilisearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueConnAuth, setting.Indexer.IssueIndexerName) | ||||
| 			existed, err = issueIndexer.Init(ctx) | ||||
| @@ -291,19 +291,22 @@ func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, err | ||||
| 		// So if the user creates an issue and list issues immediately, the issue may not be listed because the indexer needs time to index the issue. | ||||
| 		// Even worse, the external indexer like elastic search may not be available for a while, | ||||
| 		// and the user may not be able to list issues completely until it is available again. | ||||
| 		ix = db.NewIndexer() | ||||
| 		ix = db.GetIndexer() | ||||
| 	} | ||||
|  | ||||
| 	result, err := ix.Search(ctx, opts) | ||||
| 	if err != nil { | ||||
| 		return nil, 0, err | ||||
| 	} | ||||
| 	return SearchResultToIDSlice(result), result.Total, nil | ||||
| } | ||||
|  | ||||
| func SearchResultToIDSlice(result *internal.SearchResult) []int64 { | ||||
| 	ret := make([]int64, 0, len(result.Hits)) | ||||
| 	for _, hit := range result.Hits { | ||||
| 		ret = append(ret, hit.ID) | ||||
| 	} | ||||
|  | ||||
| 	return ret, result.Total, nil | ||||
| 	return ret | ||||
| } | ||||
|  | ||||
| // CountIssues counts issues by options. It is a shortcut of SearchIssues(ctx, opts) but only returns the total count. | ||||
|   | ||||
| @@ -14,10 +14,11 @@ var colorPattern = regexp.MustCompile("^#?(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})$") | ||||
|  | ||||
| // Label represents label information loaded from template | ||||
| type Label struct { | ||||
| 	Name        string `yaml:"name"` | ||||
| 	Color       string `yaml:"color"` | ||||
| 	Description string `yaml:"description,omitempty"` | ||||
| 	Exclusive   bool   `yaml:"exclusive,omitempty"` | ||||
| 	Name           string `yaml:"name"` | ||||
| 	Color          string `yaml:"color"` | ||||
| 	Description    string `yaml:"description,omitempty"` | ||||
| 	Exclusive      bool   `yaml:"exclusive,omitempty"` | ||||
| 	ExclusiveOrder int    `yaml:"exclusive_order,omitempty"` | ||||
| } | ||||
|  | ||||
| // NormalizeColor normalizes a color string to a 6-character hex code | ||||
|   | ||||
| @@ -127,10 +127,11 @@ func InitializeLabels(ctx context.Context, id int64, labelTemplate string, isOrg | ||||
| 	labels := make([]*issues_model.Label, len(list)) | ||||
| 	for i := 0; i < len(list); i++ { | ||||
| 		labels[i] = &issues_model.Label{ | ||||
| 			Name:        list[i].Name, | ||||
| 			Exclusive:   list[i].Exclusive, | ||||
| 			Description: list[i].Description, | ||||
| 			Color:       list[i].Color, | ||||
| 			Name:           list[i].Name, | ||||
| 			Exclusive:      list[i].Exclusive, | ||||
| 			ExclusiveOrder: list[i].ExclusiveOrder, | ||||
| 			Description:    list[i].Description, | ||||
| 			Color:          list[i].Color, | ||||
| 		} | ||||
| 		if isOrg { | ||||
| 			labels[i].OrgID = id | ||||
|   | ||||
| @@ -170,13 +170,28 @@ func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML { | ||||
| 	itemColor := "#" + hex.EncodeToString(itemBytes) | ||||
| 	scopeColor := "#" + hex.EncodeToString(scopeBytes) | ||||
|  | ||||
| 	if label.ExclusiveOrder > 0 { | ||||
| 		// <scope> | <label> | <order> | ||||
| 		return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+ | ||||
| 			`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+ | ||||
| 			`<div class="ui label scope-middle" style="color: %s !important; background-color: %s !important">%s</div>`+ | ||||
| 			`<div class="ui label scope-right">%d</div>`+ | ||||
| 			`</span>`, | ||||
| 			extraCSSClasses, descriptionText, | ||||
| 			textColor, scopeColor, scopeHTML, | ||||
| 			textColor, itemColor, itemHTML, | ||||
| 			label.ExclusiveOrder) | ||||
| 	} | ||||
|  | ||||
| 	// <scope> | <label> | ||||
| 	return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+ | ||||
| 		`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+ | ||||
| 		`<div class="ui label scope-right" style="color: %s !important; background-color: %s !important">%s</div>`+ | ||||
| 		`</span>`, | ||||
| 		extraCSSClasses, descriptionText, | ||||
| 		textColor, scopeColor, scopeHTML, | ||||
| 		textColor, itemColor, itemHTML) | ||||
| 		textColor, itemColor, itemHTML, | ||||
| 	) | ||||
| } | ||||
|  | ||||
| // RenderEmoji renders html text with emoji post processors | ||||
|   | ||||
| @@ -22,49 +22,60 @@ labels: | ||||
|     description: Breaking change that won't be backward compatible | ||||
|   - name: "Reviewed/Duplicate" | ||||
|     exclusive: true | ||||
|     exclusive_order: 2 | ||||
|     color: 616161 | ||||
|     description: This issue or pull request already exists | ||||
|   - name: "Reviewed/Invalid" | ||||
|     exclusive: true | ||||
|     exclusive_order: 3 | ||||
|     color: 546e7a | ||||
|     description: Invalid issue | ||||
|   - name: "Reviewed/Confirmed" | ||||
|     exclusive: true | ||||
|     exclusive_order: 1 | ||||
|     color: 795548 | ||||
|     description: Issue has been confirmed | ||||
|   - name: "Reviewed/Won't Fix" | ||||
|     exclusive: true | ||||
|     exclusive_order: 3 | ||||
|     color: eeeeee | ||||
|     description: This issue won't be fixed | ||||
|   - name: "Status/Need More Info" | ||||
|     exclusive: true | ||||
|     exclusive_order: 2 | ||||
|     color: 424242 | ||||
|     description: Feedback is required to reproduce issue or to continue work | ||||
|   - name: "Status/Blocked" | ||||
|     exclusive: true | ||||
|     exclusive_order: 1 | ||||
|     color: 880e4f | ||||
|     description: Something is blocking this issue or pull request | ||||
|   - name: "Status/Abandoned" | ||||
|     exclusive: true | ||||
|     exclusive_order: 3 | ||||
|     color: "222222" | ||||
|     description: Somebody has started to work on this but abandoned work | ||||
|   - name: "Priority/Critical" | ||||
|     exclusive: true | ||||
|     exclusive_order: 1 | ||||
|     color: b71c1c | ||||
|     description: The priority is critical | ||||
|     priority: critical | ||||
|   - name: "Priority/High" | ||||
|     exclusive: true | ||||
|     exclusive_order: 2 | ||||
|     color: d32f2f | ||||
|     description: The priority is high | ||||
|     priority: high | ||||
|   - name: "Priority/Medium" | ||||
|     exclusive: true | ||||
|     exclusive_order: 3 | ||||
|     color: e64a19 | ||||
|     description: The priority is medium | ||||
|     priority: medium | ||||
|   - name: "Priority/Low" | ||||
|     exclusive: true | ||||
|     exclusive_order: 4 | ||||
|     color: 4caf50 | ||||
|     description: The priority is low | ||||
|     priority: low | ||||
|   | ||||
| @@ -1655,6 +1655,8 @@ issues.label_archived_filter = Show archived labels | ||||
| issues.label_archive_tooltip = Archived labels are excluded by default from the suggestions when searching by label. | ||||
| issues.label_exclusive_desc = Name the label <code>scope/item</code> to make it mutually exclusive with other <code>scope/</code> labels. | ||||
| issues.label_exclusive_warning = Any conflicting scoped labels will be removed when editing the labels of an issue or pull request. | ||||
| issues.label_exclusive_order = Sort Order | ||||
| issues.label_exclusive_order_tooltip = Exclusive labels in the same scope will be sorted according to this numeric order. | ||||
| issues.label_count = %d labels | ||||
| issues.label_open_issues = %d open issues/pull requests | ||||
| issues.label_edit = Edit | ||||
|   | ||||
| @@ -44,11 +44,12 @@ func NewLabel(ctx *context.Context) { | ||||
| 	} | ||||
|  | ||||
| 	l := &issues_model.Label{ | ||||
| 		OrgID:       ctx.Org.Organization.ID, | ||||
| 		Name:        form.Title, | ||||
| 		Exclusive:   form.Exclusive, | ||||
| 		Description: form.Description, | ||||
| 		Color:       form.Color, | ||||
| 		OrgID:          ctx.Org.Organization.ID, | ||||
| 		Name:           form.Title, | ||||
| 		Exclusive:      form.Exclusive, | ||||
| 		Description:    form.Description, | ||||
| 		Color:          form.Color, | ||||
| 		ExclusiveOrder: form.ExclusiveOrder, | ||||
| 	} | ||||
| 	if err := issues_model.NewLabel(ctx, l); err != nil { | ||||
| 		ctx.ServerError("NewLabel", err) | ||||
| @@ -73,6 +74,7 @@ func UpdateLabel(ctx *context.Context) { | ||||
|  | ||||
| 	l.Name = form.Title | ||||
| 	l.Exclusive = form.Exclusive | ||||
| 	l.ExclusiveOrder = form.ExclusiveOrder | ||||
| 	l.Description = form.Description | ||||
| 	l.Color = form.Color | ||||
| 	l.SetArchived(form.IsArchived) | ||||
|   | ||||
| @@ -343,14 +343,14 @@ func ViewProject(ctx *context.Context) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	labelIDs := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner) | ||||
| 	preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner) | ||||
| 	if ctx.Written() { | ||||
| 		return | ||||
| 	} | ||||
| 	assigneeID := ctx.FormString("assignee") | ||||
|  | ||||
| 	opts := issues_model.IssuesOptions{ | ||||
| 		LabelIDs:   labelIDs, | ||||
| 		LabelIDs:   preparedLabelFilter.SelectedLabelIDs, | ||||
| 		AssigneeID: assigneeID, | ||||
| 		Owner:      project.Owner, | ||||
| 		Doer:       ctx.Doer, | ||||
| @@ -406,8 +406,8 @@ func ViewProject(ctx *context.Context) { | ||||
| 	} | ||||
|  | ||||
| 	// Get the exclusive scope for every label ID | ||||
| 	labelExclusiveScopes := make([]string, 0, len(labelIDs)) | ||||
| 	for _, labelID := range labelIDs { | ||||
| 	labelExclusiveScopes := make([]string, 0, len(preparedLabelFilter.SelectedLabelIDs)) | ||||
| 	for _, labelID := range preparedLabelFilter.SelectedLabelIDs { | ||||
| 		foundExclusiveScope := false | ||||
| 		for _, label := range labels { | ||||
| 			if label.ID == labelID || label.ID == -labelID { | ||||
| @@ -422,7 +422,7 @@ func ViewProject(ctx *context.Context) { | ||||
| 	} | ||||
|  | ||||
| 	for _, l := range labels { | ||||
| 		l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes) | ||||
| 		l.LoadSelectedLabelsAfterClick(preparedLabelFilter.SelectedLabelIDs, labelExclusiveScopes) | ||||
| 	} | ||||
| 	ctx.Data["Labels"] = labels | ||||
| 	ctx.Data["NumLabels"] = len(labels) | ||||
|   | ||||
| @@ -111,11 +111,12 @@ func NewLabel(ctx *context.Context) { | ||||
| 	} | ||||
|  | ||||
| 	l := &issues_model.Label{ | ||||
| 		RepoID:      ctx.Repo.Repository.ID, | ||||
| 		Name:        form.Title, | ||||
| 		Exclusive:   form.Exclusive, | ||||
| 		Description: form.Description, | ||||
| 		Color:       form.Color, | ||||
| 		RepoID:         ctx.Repo.Repository.ID, | ||||
| 		Name:           form.Title, | ||||
| 		Exclusive:      form.Exclusive, | ||||
| 		ExclusiveOrder: form.ExclusiveOrder, | ||||
| 		Description:    form.Description, | ||||
| 		Color:          form.Color, | ||||
| 	} | ||||
| 	if err := issues_model.NewLabel(ctx, l); err != nil { | ||||
| 		ctx.ServerError("NewLabel", err) | ||||
| @@ -139,6 +140,7 @@ func UpdateLabel(ctx *context.Context) { | ||||
| 	} | ||||
| 	l.Name = form.Title | ||||
| 	l.Exclusive = form.Exclusive | ||||
| 	l.ExclusiveOrder = form.ExclusiveOrder | ||||
| 	l.Description = form.Description | ||||
| 	l.Color = form.Color | ||||
|  | ||||
|   | ||||
| @@ -5,8 +5,10 @@ package repo | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"fmt" | ||||
| 	"maps" | ||||
| 	"net/http" | ||||
| 	"slices" | ||||
| 	"sort" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
|  | ||||
| @@ -18,6 +20,7 @@ import ( | ||||
| 	"code.gitea.io/gitea/models/unit" | ||||
| 	user_model "code.gitea.io/gitea/models/user" | ||||
| 	issue_indexer "code.gitea.io/gitea/modules/indexer/issues" | ||||
| 	db_indexer "code.gitea.io/gitea/modules/indexer/issues/db" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/optional" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| @@ -30,14 +33,6 @@ import ( | ||||
| 	pull_service "code.gitea.io/gitea/services/pull" | ||||
| ) | ||||
|  | ||||
| func issueIDsFromSearch(ctx *context.Context, keyword string, opts *issues_model.IssuesOptions) ([]int64, error) { | ||||
| 	ids, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts)) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("SearchIssues: %w", err) | ||||
| 	} | ||||
| 	return ids, nil | ||||
| } | ||||
|  | ||||
| func retrieveProjectsForIssueList(ctx *context.Context, repo *repo_model.Repository) { | ||||
| 	ctx.Data["OpenProjects"], ctx.Data["ClosedProjects"] = retrieveProjectsInternal(ctx, repo) | ||||
| } | ||||
| @@ -459,6 +454,19 @@ func UpdateIssueStatus(ctx *context.Context) { | ||||
| 	ctx.JSONOK() | ||||
| } | ||||
|  | ||||
| func prepareIssueFilterExclusiveOrderScopes(ctx *context.Context, allLabels []*issues_model.Label) { | ||||
| 	scopeSet := make(map[string]bool) | ||||
| 	for _, label := range allLabels { | ||||
| 		scope := label.ExclusiveScope() | ||||
| 		if len(scope) > 0 && label.ExclusiveOrder > 0 { | ||||
| 			scopeSet[scope] = true | ||||
| 		} | ||||
| 	} | ||||
| 	scopes := slices.Collect(maps.Keys(scopeSet)) | ||||
| 	sort.Strings(scopes) | ||||
| 	ctx.Data["ExclusiveLabelScopes"] = scopes | ||||
| } | ||||
|  | ||||
| func renderMilestones(ctx *context.Context) { | ||||
| 	// Get milestones | ||||
| 	milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ | ||||
| @@ -481,7 +489,7 @@ func renderMilestones(ctx *context.Context) { | ||||
| 	ctx.Data["ClosedMilestones"] = closedMilestones | ||||
| } | ||||
|  | ||||
| func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption optional.Option[bool]) { | ||||
| func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int64, isPullOption optional.Option[bool]) { | ||||
| 	var err error | ||||
| 	viewType := ctx.FormString("type") | ||||
| 	sortType := ctx.FormString("sort") | ||||
| @@ -521,15 +529,18 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt | ||||
| 		mileIDs = []int64{milestoneID} | ||||
| 	} | ||||
|  | ||||
| 	labelIDs := issue.PrepareFilterIssueLabels(ctx, repo.ID, ctx.Repo.Owner) | ||||
| 	preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, repo.ID, ctx.Repo.Owner) | ||||
| 	if ctx.Written() { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	prepareIssueFilterExclusiveOrderScopes(ctx, preparedLabelFilter.AllLabels) | ||||
|  | ||||
| 	var keywordMatchedIssueIDs []int64 | ||||
| 	var issueStats *issues_model.IssueStats | ||||
| 	statsOpts := &issues_model.IssuesOptions{ | ||||
| 		RepoIDs:           []int64{repo.ID}, | ||||
| 		LabelIDs:          labelIDs, | ||||
| 		LabelIDs:          preparedLabelFilter.SelectedLabelIDs, | ||||
| 		MilestoneIDs:      mileIDs, | ||||
| 		ProjectID:         projectID, | ||||
| 		AssigneeID:        assigneeID, | ||||
| @@ -541,7 +552,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt | ||||
| 		IssueIDs:          nil, | ||||
| 	} | ||||
| 	if keyword != "" { | ||||
| 		allIssueIDs, err := issueIDsFromSearch(ctx, keyword, statsOpts) | ||||
| 		keywordMatchedIssueIDs, _, err = issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, statsOpts)) | ||||
| 		if err != nil { | ||||
| 			if issue_indexer.IsAvailable(ctx) { | ||||
| 				ctx.ServerError("issueIDsFromSearch", err) | ||||
| @@ -550,14 +561,17 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt | ||||
| 			ctx.Data["IssueIndexerUnavailable"] = true | ||||
| 			return | ||||
| 		} | ||||
| 		statsOpts.IssueIDs = allIssueIDs | ||||
| 		if len(keywordMatchedIssueIDs) == 0 { | ||||
| 			// It did search with the keyword, but no issue found, just set issueStats to empty, then no need to do query again. | ||||
| 			issueStats = &issues_model.IssueStats{} | ||||
| 			// set keywordMatchedIssueIDs to empty slice, so we can distinguish it from "nil" | ||||
| 			keywordMatchedIssueIDs = []int64{} | ||||
| 		} | ||||
| 		statsOpts.IssueIDs = keywordMatchedIssueIDs | ||||
| 	} | ||||
| 	if keyword != "" && len(statsOpts.IssueIDs) == 0 { | ||||
| 		// So it did search with the keyword, but no issue found. | ||||
| 		// Just set issueStats to empty. | ||||
| 		issueStats = &issues_model.IssueStats{} | ||||
| 	} else { | ||||
| 		// So it did search with the keyword, and found some issues. It needs to get issueStats of these issues. | ||||
|  | ||||
| 	if issueStats == nil { | ||||
| 		// Either it did search with the keyword, and found some issues, it needs to get issueStats of these issues. | ||||
| 		// Or the keyword is empty, so it doesn't need issueIDs as filter, just get issueStats with statsOpts. | ||||
| 		issueStats, err = issues_model.GetIssueStats(ctx, statsOpts) | ||||
| 		if err != nil { | ||||
| @@ -589,25 +603,21 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt | ||||
| 		ctx.Data["TotalTrackedTime"] = totalTrackedTime | ||||
| 	} | ||||
|  | ||||
| 	page := ctx.FormInt("page") | ||||
| 	if page <= 1 { | ||||
| 		page = 1 | ||||
| 	} | ||||
|  | ||||
| 	var total int | ||||
| 	switch { | ||||
| 	case isShowClosed.Value(): | ||||
| 		total = int(issueStats.ClosedCount) | ||||
| 	case !isShowClosed.Has(): | ||||
| 		total = int(issueStats.OpenCount + issueStats.ClosedCount) | ||||
| 	default: | ||||
| 		total = int(issueStats.OpenCount) | ||||
| 	// prepare pager | ||||
| 	total := int(issueStats.OpenCount + issueStats.ClosedCount) | ||||
| 	if isShowClosed.Has() { | ||||
| 		total = util.Iif(isShowClosed.Value(), int(issueStats.ClosedCount), int(issueStats.OpenCount)) | ||||
| 	} | ||||
| 	page := max(ctx.FormInt("page"), 1) | ||||
| 	pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5) | ||||
|  | ||||
| 	// prepare real issue list: | ||||
| 	var issues issues_model.IssueList | ||||
| 	{ | ||||
| 		ids, err := issueIDsFromSearch(ctx, keyword, &issues_model.IssuesOptions{ | ||||
| 	if keywordMatchedIssueIDs == nil || len(keywordMatchedIssueIDs) > 0 { | ||||
| 		// Either it did search with the keyword, and found some issues, then keywordMatchedIssueIDs is not null, it needs to use db indexer. | ||||
| 		// Or the keyword is empty, it also needs to usd db indexer. | ||||
| 		// In either case, no need to use keyword anymore | ||||
| 		searchResult, err := db_indexer.GetIndexer().FindWithIssueOptions(ctx, &issues_model.IssuesOptions{ | ||||
| 			Paginator: &db.ListOptions{ | ||||
| 				Page:     pager.Paginater.Current(), | ||||
| 				PageSize: setting.UI.IssuePagingNum, | ||||
| @@ -622,18 +632,16 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt | ||||
| 			ProjectID:         projectID, | ||||
| 			IsClosed:          isShowClosed, | ||||
| 			IsPull:            isPullOption, | ||||
| 			LabelIDs:          labelIDs, | ||||
| 			LabelIDs:          preparedLabelFilter.SelectedLabelIDs, | ||||
| 			SortType:          sortType, | ||||
| 			IssueIDs:          keywordMatchedIssueIDs, | ||||
| 		}) | ||||
| 		if err != nil { | ||||
| 			if issue_indexer.IsAvailable(ctx) { | ||||
| 				ctx.ServerError("issueIDsFromSearch", err) | ||||
| 				return | ||||
| 			} | ||||
| 			ctx.Data["IssueIndexerUnavailable"] = true | ||||
| 			ctx.ServerError("DBIndexer.Search", err) | ||||
| 			return | ||||
| 		} | ||||
| 		issues, err = issues_model.GetIssuesByIDs(ctx, ids, true) | ||||
| 		issueIDs := issue_indexer.SearchResultToIDSlice(searchResult) | ||||
| 		issues, err = issues_model.GetIssuesByIDs(ctx, issueIDs, true) | ||||
| 		if err != nil { | ||||
| 			ctx.ServerError("GetIssuesByIDs", err) | ||||
| 			return | ||||
| @@ -728,7 +736,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt | ||||
| 	ctx.Data["IssueStats"] = issueStats | ||||
| 	ctx.Data["OpenCount"] = issueStats.OpenCount | ||||
| 	ctx.Data["ClosedCount"] = issueStats.ClosedCount | ||||
| 	ctx.Data["SelLabelIDs"] = labelIDs | ||||
| 	ctx.Data["SelLabelIDs"] = preparedLabelFilter.SelectedLabelIDs | ||||
| 	ctx.Data["ViewType"] = viewType | ||||
| 	ctx.Data["SortType"] = sortType | ||||
| 	ctx.Data["MilestoneID"] = milestoneID | ||||
| @@ -769,7 +777,7 @@ func Issues(ctx *context.Context) { | ||||
| 		ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) | ||||
| 	} | ||||
|  | ||||
| 	issues(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), optional.Some(isPullList)) | ||||
| 	prepareIssueFilterAndList(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), optional.Some(isPullList)) | ||||
| 	if ctx.Written() { | ||||
| 		return | ||||
| 	} | ||||
|   | ||||
| @@ -263,7 +263,7 @@ func MilestoneIssuesAndPulls(ctx *context.Context) { | ||||
| 	ctx.Data["Title"] = milestone.Name | ||||
| 	ctx.Data["Milestone"] = milestone | ||||
|  | ||||
| 	issues(ctx, milestoneID, projectID, optional.None[bool]()) | ||||
| 	prepareIssueFilterAndList(ctx, milestoneID, projectID, optional.None[bool]()) | ||||
|  | ||||
| 	ret := issue.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) | ||||
| 	ctx.Data["NewIssueChooseTemplate"] = len(ret.IssueTemplates) > 0 | ||||
|   | ||||
| @@ -313,13 +313,13 @@ func ViewProject(ctx *context.Context) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	labelIDs := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner) | ||||
| 	preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner) | ||||
|  | ||||
| 	assigneeID := ctx.FormString("assignee") | ||||
|  | ||||
| 	issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &issues_model.IssuesOptions{ | ||||
| 		RepoIDs:    []int64{ctx.Repo.Repository.ID}, | ||||
| 		LabelIDs:   labelIDs, | ||||
| 		LabelIDs:   preparedLabelFilter.SelectedLabelIDs, | ||||
| 		AssigneeID: assigneeID, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| @@ -381,8 +381,8 @@ func ViewProject(ctx *context.Context) { | ||||
| 	} | ||||
|  | ||||
| 	// Get the exclusive scope for every label ID | ||||
| 	labelExclusiveScopes := make([]string, 0, len(labelIDs)) | ||||
| 	for _, labelID := range labelIDs { | ||||
| 	labelExclusiveScopes := make([]string, 0, len(preparedLabelFilter.SelectedLabelIDs)) | ||||
| 	for _, labelID := range preparedLabelFilter.SelectedLabelIDs { | ||||
| 		foundExclusiveScope := false | ||||
| 		for _, label := range labels { | ||||
| 			if label.ID == labelID || label.ID == -labelID { | ||||
| @@ -397,7 +397,7 @@ func ViewProject(ctx *context.Context) { | ||||
| 	} | ||||
|  | ||||
| 	for _, l := range labels { | ||||
| 		l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes) | ||||
| 		l.LoadSelectedLabelsAfterClick(preparedLabelFilter.SelectedLabelIDs, labelExclusiveScopes) | ||||
| 	} | ||||
| 	ctx.Data["Labels"] = labels | ||||
| 	ctx.Data["NumLabels"] = len(labels) | ||||
|   | ||||
| @@ -14,14 +14,18 @@ import ( | ||||
| ) | ||||
|  | ||||
| // PrepareFilterIssueLabels reads the "labels" query parameter, sets `ctx.Data["Labels"]` and `ctx.Data["SelectLabels"]` | ||||
| func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_model.User) (labelIDs []int64) { | ||||
| func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_model.User) (ret struct { | ||||
| 	AllLabels        []*issues_model.Label | ||||
| 	SelectedLabelIDs []int64 | ||||
| }, | ||||
| ) { | ||||
| 	// 1,-2 means including label 1 and excluding label 2 | ||||
| 	// 0 means issues with no label | ||||
| 	// blank means labels will not be filtered for issues | ||||
| 	selectLabels := ctx.FormString("labels") | ||||
| 	if selectLabels != "" { | ||||
| 		var err error | ||||
| 		labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ",")) | ||||
| 		ret.SelectedLabelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ",")) | ||||
| 		if err != nil { | ||||
| 			ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true) | ||||
| 		} | ||||
| @@ -32,7 +36,7 @@ func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_mo | ||||
| 		repoLabels, err := issues_model.GetLabelsByRepoID(ctx, repoID, "", db.ListOptions{}) | ||||
| 		if err != nil { | ||||
| 			ctx.ServerError("GetLabelsByRepoID", err) | ||||
| 			return nil | ||||
| 			return ret | ||||
| 		} | ||||
| 		allLabels = append(allLabels, repoLabels...) | ||||
| 	} | ||||
| @@ -41,14 +45,14 @@ func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_mo | ||||
| 		orgLabels, err := issues_model.GetLabelsByOrgID(ctx, owner.ID, "", db.ListOptions{}) | ||||
| 		if err != nil { | ||||
| 			ctx.ServerError("GetLabelsByOrgID", err) | ||||
| 			return nil | ||||
| 			return ret | ||||
| 		} | ||||
| 		allLabels = append(allLabels, orgLabels...) | ||||
| 	} | ||||
|  | ||||
| 	// Get the exclusive scope for every label ID | ||||
| 	labelExclusiveScopes := make([]string, 0, len(labelIDs)) | ||||
| 	for _, labelID := range labelIDs { | ||||
| 	labelExclusiveScopes := make([]string, 0, len(ret.SelectedLabelIDs)) | ||||
| 	for _, labelID := range ret.SelectedLabelIDs { | ||||
| 		foundExclusiveScope := false | ||||
| 		for _, label := range allLabels { | ||||
| 			if label.ID == labelID || label.ID == -labelID { | ||||
| @@ -63,9 +67,10 @@ func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_mo | ||||
| 	} | ||||
|  | ||||
| 	for _, l := range allLabels { | ||||
| 		l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes) | ||||
| 		l.LoadSelectedLabelsAfterClick(ret.SelectedLabelIDs, labelExclusiveScopes) | ||||
| 	} | ||||
| 	ctx.Data["Labels"] = allLabels | ||||
| 	ctx.Data["SelectLabels"] = selectLabels | ||||
| 	return labelIDs | ||||
| 	ret.AllLabels = allLabels | ||||
| 	return ret | ||||
| } | ||||
|   | ||||
| @@ -43,8 +43,9 @@ func ApplicationsPost(ctx *context.Context) { | ||||
|  | ||||
| 	_ = ctx.Req.ParseForm() | ||||
| 	var scopeNames []string | ||||
| 	const accessTokenScopePrefix = "scope-" | ||||
| 	for k, v := range ctx.Req.Form { | ||||
| 		if strings.HasPrefix(k, "scope-") { | ||||
| 		if strings.HasPrefix(k, accessTokenScopePrefix) { | ||||
| 			scopeNames = append(scopeNames, v...) | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -519,12 +519,13 @@ func (f *CreateMilestoneForm) Validate(req *http.Request, errs binding.Errors) b | ||||
|  | ||||
| // CreateLabelForm form for creating label | ||||
| type CreateLabelForm struct { | ||||
| 	ID          int64 | ||||
| 	Title       string `binding:"Required;MaxSize(50)" locale:"repo.issues.label_title"` | ||||
| 	Exclusive   bool   `form:"exclusive"` | ||||
| 	IsArchived  bool   `form:"is_archived"` | ||||
| 	Description string `binding:"MaxSize(200)" locale:"repo.issues.label_description"` | ||||
| 	Color       string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"` | ||||
| 	ID             int64 | ||||
| 	Title          string `binding:"Required;MaxSize(50)" locale:"repo.issues.label_title"` | ||||
| 	Exclusive      bool   `form:"exclusive"` | ||||
| 	ExclusiveOrder int    `form:"exclusive_order"` | ||||
| 	IsArchived     bool   `form:"is_archived"` | ||||
| 	Description    string `binding:"MaxSize(200)" locale:"repo.issues.label_description"` | ||||
| 	Color          string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"` | ||||
| } | ||||
|  | ||||
| // Validate validates the fields | ||||
|   | ||||
| @@ -133,5 +133,11 @@ | ||||
| 		<a class="{{if eq .SortType "leastcomment"}}active {{end}}item" href="{{QueryBuild $queryLink "sort" "leastcomment"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastcomment"}}</a> | ||||
| 		<a class="{{if eq .SortType "nearduedate"}}active {{end}}item" href="{{QueryBuild $queryLink "sort" "nearduedate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.nearduedate"}}</a> | ||||
| 		<a class="{{if eq .SortType "farduedate"}}active {{end}}item" href="{{QueryBuild $queryLink "sort" "farduedate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.farduedate"}}</a> | ||||
| 		<div class="divider"></div> | ||||
| 		<div class="header">{{ctx.Locale.Tr "repo.issues.filter_label"}}</div> | ||||
| 		{{range $scope := .ExclusiveLabelScopes}} | ||||
| 			{{$sortType := (printf "scope-%s" $scope)}} | ||||
| 			<a class="{{if eq $.SortType $sortType}}active {{end}}item" href="{{QueryBuild $queryLink "sort" $sortType}}">{{$scope}}</a> | ||||
| 		{{end}} | ||||
| 	</div> | ||||
| </div> | ||||
|   | ||||
| @@ -24,7 +24,13 @@ | ||||
| 				<div class="desc tw-ml-1 tw-mt-2 tw-hidden label-exclusive-warning"> | ||||
| 					{{svg "octicon-alert"}} {{ctx.Locale.Tr "repo.issues.label_exclusive_warning"}} | ||||
| 				</div> | ||||
| 				<br> | ||||
| 				<div class="field label-exclusive-order-input-field tw-mt-2"> | ||||
| 					<label class="flex-text-block"> | ||||
| 						{{ctx.Locale.Tr "repo.issues.label_exclusive_order"}} | ||||
| 						<span data-tooltip-content="{{ctx.Locale.Tr "repo.issues.label_exclusive_order_tooltip"}}">{{svg "octicon-info"}}</span> | ||||
| 					</label> | ||||
| 					<input class="label-exclusive-order-input" name="exclusive_order" type="number" maxlength="4"> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div class="field label-is-archived-input-field"> | ||||
| 				<div class="ui checkbox"> | ||||
|   | ||||
| @@ -50,6 +50,7 @@ | ||||
| 							data-label-id="{{.ID}}" data-label-name="{{.Name}}" data-label-color="{{.Color}}" | ||||
| 							data-label-exclusive="{{.Exclusive}}" data-label-is-archived="{{gt .ArchivedUnix 0}}" | ||||
| 							data-label-num-issues="{{.NumIssues}}" data-label-description="{{.Description}}" | ||||
| 							data-label-exclusive-order="{{.ExclusiveOrder}}" | ||||
| 						>{{svg "octicon-pencil"}} {{ctx.Locale.Tr "repo.issues.label_edit"}}</a> | ||||
| 						<a class="link-action" href="#" data-url="{{$.Link}}/delete?id={{.ID}}" | ||||
| 							data-modal-confirm-header="{{ctx.Locale.Tr "repo.issues.label_deletion"}}" | ||||
|   | ||||
| @@ -1127,6 +1127,7 @@ table th[data-sortt-desc] .svg { | ||||
| } | ||||
|  | ||||
| .ui.list.flex-items-block > .item, | ||||
| .ui.form .field > label.flex-text-block, /* override fomantic "block" style */ | ||||
| .flex-items-block > .item, | ||||
| .flex-text-block { | ||||
|   display: flex; | ||||
|   | ||||
| @@ -1604,6 +1604,12 @@ td .commit-summary { | ||||
|   margin-right: 0; | ||||
| } | ||||
|  | ||||
| .ui.label.scope-middle { | ||||
|   border-radius: 0; | ||||
|   margin-left: 0; | ||||
|   margin-right: 0; | ||||
| } | ||||
|  | ||||
| .ui.label.scope-right { | ||||
|   border-bottom-left-radius: 0; | ||||
|   border-top-left-radius: 0; | ||||
|   | ||||
| @@ -18,6 +18,8 @@ export function initCompLabelEdit(pageSelector: string) { | ||||
|   const elExclusiveField = elModal.querySelector('.label-exclusive-input-field'); | ||||
|   const elExclusiveInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-input'); | ||||
|   const elExclusiveWarning = elModal.querySelector('.label-exclusive-warning'); | ||||
|   const elExclusiveOrderField = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input-field'); | ||||
|   const elExclusiveOrderInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input'); | ||||
|   const elIsArchivedField = elModal.querySelector('.label-is-archived-input-field'); | ||||
|   const elIsArchivedInput = elModal.querySelector<HTMLInputElement>('.label-is-archived-input'); | ||||
|   const elDescInput = elModal.querySelector<HTMLInputElement>('.label-desc-input'); | ||||
| @@ -29,6 +31,13 @@ export function initCompLabelEdit(pageSelector: string) { | ||||
|     const showExclusiveWarning = hasScope && elExclusiveInput.checked && elModal.hasAttribute('data-need-warn-exclusive'); | ||||
|     toggleElem(elExclusiveWarning, showExclusiveWarning); | ||||
|     if (!hasScope) elExclusiveInput.checked = false; | ||||
|     toggleElem(elExclusiveOrderField, elExclusiveInput.checked); | ||||
|  | ||||
|     if (parseInt(elExclusiveOrderInput.value) <= 0) { | ||||
|       elExclusiveOrderInput.style.color = 'var(--color-placeholder-text) !important'; | ||||
|     } else { | ||||
|       elExclusiveOrderInput.style.color = null; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const showLabelEditModal = (btn:HTMLElement) => { | ||||
| @@ -36,6 +45,7 @@ export function initCompLabelEdit(pageSelector: string) { | ||||
|     const form = elModal.querySelector<HTMLFormElement>('form'); | ||||
|     elLabelId.value = btn.getAttribute('data-label-id') || ''; | ||||
|     elNameInput.value = btn.getAttribute('data-label-name') || ''; | ||||
|     elExclusiveOrderInput.value = btn.getAttribute('data-label-exclusive-order') || '0'; | ||||
|     elIsArchivedInput.checked = btn.getAttribute('data-label-is-archived') === 'true'; | ||||
|     elExclusiveInput.checked = btn.getAttribute('data-label-exclusive') === 'true'; | ||||
|     elDescInput.value = btn.getAttribute('data-label-description') || ''; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Thomas E Lackey
					Thomas E Lackey