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" | 	"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,7 +35,11 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m | |||||||
| 	case internal.SortByDeadlineAsc: | 	case internal.SortByDeadlineAsc: | ||||||
| 		sortType = "nearduedate" | 		sortType = "nearduedate" | ||||||
| 	default: | 	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 | 	// 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, | 		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,7 +102,11 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp | |||||||
| 		// Unsupported sort type for search | 		// Unsupported sort type for search | ||||||
| 		fallthrough | 		fallthrough | ||||||
| 	default: | 	default: | ||||||
| 		searchOpt.SortBy = SortByUpdatedDesc | 		if strings.HasPrefix(opts.SortType, issues_model.ScopeSortPrefix) { | ||||||
|  | 			searchOpt.SortBy = internal.SortBy(opts.SortType) | ||||||
|  | 		} else { | ||||||
|  | 			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. | ||||||
|   | |||||||
| @@ -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 | // Label represents label information loaded from template | ||||||
| type Label struct { | type Label struct { | ||||||
| 	Name        string `yaml:"name"` | 	Name           string `yaml:"name"` | ||||||
| 	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 | ||||||
|   | |||||||
| @@ -127,10 +127,11 @@ func InitializeLabels(ctx context.Context, id int64, labelTemplate string, isOrg | |||||||
| 	labels := make([]*issues_model.Label, len(list)) | 	labels := make([]*issues_model.Label, len(list)) | ||||||
| 	for i := 0; i < len(list); i++ { | 	for i := 0; i < len(list); i++ { | ||||||
| 		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, | ||||||
| 			Description: list[i].Description, | 			ExclusiveOrder: list[i].ExclusiveOrder, | ||||||
| 			Color:       list[i].Color, | 			Description:    list[i].Description, | ||||||
|  | 			Color:          list[i].Color, | ||||||
| 		} | 		} | ||||||
| 		if isOrg { | 		if isOrg { | ||||||
| 			labels[i].OrgID = id | 			labels[i].OrgID = id | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -44,11 +44,12 @@ func NewLabel(ctx *context.Context) { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	l := &issues_model.Label{ | 	l := &issues_model.Label{ | ||||||
| 		OrgID:       ctx.Org.Organization.ID, | 		OrgID:          ctx.Org.Organization.ID, | ||||||
| 		Name:        form.Title, | 		Name:           form.Title, | ||||||
| 		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) | ||||||
|   | |||||||
| @@ -111,11 +111,12 @@ func NewLabel(ctx *context.Context) { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	l := &issues_model.Label{ | 	l := &issues_model.Label{ | ||||||
| 		RepoID:      ctx.Repo.Repository.ID, | 		RepoID:         ctx.Repo.Repository.ID, | ||||||
| 		Name:        form.Title, | 		Name:           form.Title, | ||||||
| 		Exclusive:   form.Exclusive, | 		Exclusive:      form.Exclusive, | ||||||
| 		Description: form.Description, | 		ExclusiveOrder: form.ExclusiveOrder, | ||||||
| 		Color:       form.Color, | 		Description:    form.Description, | ||||||
|  | 		Color:          form.Color, | ||||||
| 	} | 	} | ||||||
| 	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) | ||||||
| @@ -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. | ||||||
|  | 			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. | 	if issueStats == nil { | ||||||
| 		// Just set issueStats to empty. | 		// Either it did search with the keyword, and found some issues, it needs to get issueStats of these issues. | ||||||
| 		issueStats = &issues_model.IssueStats{} |  | ||||||
| 	} else { |  | ||||||
| 		// So 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 |  | ||||||
| 			} |  | ||||||
| 			ctx.Data["IssueIndexerUnavailable"] = true |  | ||||||
| 			return | 			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 { | 		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...) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -519,12 +519,13 @@ func (f *CreateMilestoneForm) Validate(req *http.Request, errs binding.Errors) b | |||||||
|  |  | ||||||
| // CreateLabelForm form for creating label | // CreateLabelForm form for creating label | ||||||
| type CreateLabelForm struct { | 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"` | ||||||
| 	IsArchived  bool   `form:"is_archived"` | 	ExclusiveOrder int    `form:"exclusive_order"` | ||||||
| 	Description string `binding:"MaxSize(200)" locale:"repo.issues.label_description"` | 	IsArchived     bool   `form:"is_archived"` | ||||||
| 	Color       string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"` | 	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 | // 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 "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
	 Thomas E Lackey
					Thomas E Lackey