loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Scoped labels (#22585)

Add a new "exclusive" option per label. This makes it so that when the
label is named `scope/name`, no other label with the same `scope/`
prefix can be set on an issue.

The scope is determined by the last occurence of `/`, so for example
`scope/alpha/name` and `scope/beta/name` are considered to be in
different scopes and can coexist.

Exclusive scopes are not enforced by any database rules, however they
are enforced when editing labels at the models level, automatically
removing any existing labels in the same scope when either attaching a
new label or replacing all labels.

In menus use a circle instead of checkbox to indicate they function as
radio buttons per scope. Issue filtering by label ensures that only a
single scoped label is selected at a time. Clicking with alt key can be
used to remove a scoped label, both when editing individual issues and
batch editing.

Label rendering refactor for consistency and code simplification:

* Labels now consistently have the same shape, emojis and tooltips
everywhere. This includes the label list and label assignment menus.
* In label list, show description below label same as label menus.
* Don't use exactly black/white text colors to look a bit nicer.
* Simplify text color computation. There is no point computing luminance
in linear color space, as this is a perceptual problem and sRGB is
closer to perceptually linear.
* Increase height of label assignment menus to show more labels. Showing
only 3-4 labels at a time leads to a lot of scrolling.
* Render all labels with a new RenderLabel template helper function.

Label creation and editing in multiline modal menu:

* Change label creation to open a modal menu like label editing.
* Change menu layout to place name, description and colors on separate
lines.
* Don't color cancel button red in label editing modal menu.
* Align text to the left in model menu for better readability and
consistent with settings layout elsewhere.

Custom exclusive scoped label rendering:

* Display scoped label prefix and suffix with slightly darker and
lighter background color respectively, and a slanted edge between them
similar to the `/` symbol.
* In menus exclusive labels are grouped with a divider line.

---------

Co-authored-by: Yarden Shoham <hrsi88@gmail.com>
Co-authored-by: Lauris BH <lauris@nix.lv>

authored by

Brecht Van Lommel
Yarden Shoham
Lauris BH
and committed by
GitHub
6221a6fd feed1ff3

+709 -242
+17
models/fixtures/issue.yml
··· 287 287 created_unix: 1602935696 288 288 updated_unix: 1602935696 289 289 is_locked: false 290 + 291 + - 292 + id: 18 293 + repo_id: 55 294 + index: 1 295 + poster_id: 2 296 + original_author_id: 0 297 + name: issue for scoped labels 298 + content: content 299 + milestone_id: 0 300 + priority: 0 301 + is_closed: false 302 + is_pull: false 303 + num_comments: 0 304 + created_unix: 946684830 305 + updated_unix: 978307200 306 + is_locked: false
+45
models/fixtures/label.yml
··· 4 4 org_id: 0 5 5 name: label1 6 6 color: '#abcdef' 7 + exclusive: false 7 8 num_issues: 2 8 9 num_closed_issues: 0 9 10 ··· 13 14 org_id: 0 14 15 name: label2 15 16 color: '#000000' 17 + exclusive: false 16 18 num_issues: 1 17 19 num_closed_issues: 1 18 20 ··· 22 24 org_id: 3 23 25 name: orglabel3 24 26 color: '#abcdef' 27 + exclusive: false 25 28 num_issues: 0 26 29 num_closed_issues: 0 27 30 ··· 31 34 org_id: 3 32 35 name: orglabel4 33 36 color: '#000000' 37 + exclusive: false 34 38 num_issues: 1 35 39 num_closed_issues: 0 36 40 ··· 40 44 org_id: 0 41 45 name: pull-test-label 42 46 color: '#000000' 47 + exclusive: false 48 + num_issues: 0 49 + num_closed_issues: 0 50 + 51 + - 52 + id: 6 53 + repo_id: 55 54 + org_id: 0 55 + name: unscoped_label 56 + color: '#000000' 57 + exclusive: false 58 + num_issues: 0 59 + num_closed_issues: 0 60 + 61 + - 62 + id: 7 63 + repo_id: 55 64 + org_id: 0 65 + name: scope/label1 66 + color: '#000000' 67 + exclusive: true 68 + num_issues: 0 69 + num_closed_issues: 0 70 + 71 + - 72 + id: 8 73 + repo_id: 55 74 + org_id: 0 75 + name: scope/label2 76 + color: '#000000' 77 + exclusive: true 78 + num_issues: 0 79 + num_closed_issues: 0 80 + 81 + - 82 + id: 9 83 + repo_id: 55 84 + org_id: 0 85 + name: scope/subscope/label2 86 + color: '#000000' 87 + exclusive: true 43 88 num_issues: 0 44 89 num_closed_issues: 0
+12
models/fixtures/repository.yml
··· 1622 1622 is_archived: false 1623 1623 is_private: true 1624 1624 status: 0 1625 + 1626 + - 1627 + id: 55 1628 + owner_id: 2 1629 + owner_name: user2 1630 + lower_name: scoped_label 1631 + name: scoped_label 1632 + is_empty: false 1633 + is_archived: false 1634 + is_private: true 1635 + num_issues: 1 1636 + status: 0
+1 -1
models/fixtures/user.yml
··· 66 66 num_followers: 2 67 67 num_following: 1 68 68 num_stars: 2 69 - num_repos: 10 69 + num_repos: 11 70 70 num_teams: 0 71 71 num_members: 0 72 72 visibility: 0
+27
models/issues/issue.go
··· 538 538 []*Label(ts)[i], []*Label(ts)[j] = []*Label(ts)[j], []*Label(ts)[i] 539 539 } 540 540 541 + // Ensure only one label of a given scope exists, with labels at the end of the 542 + // array getting preference over earlier ones. 543 + func RemoveDuplicateExclusiveLabels(labels []*Label) []*Label { 544 + validLabels := make([]*Label, 0, len(labels)) 545 + 546 + for i, label := range labels { 547 + scope := label.ExclusiveScope() 548 + if scope != "" { 549 + foundOther := false 550 + for _, otherLabel := range labels[i+1:] { 551 + if otherLabel.ExclusiveScope() == scope { 552 + foundOther = true 553 + break 554 + } 555 + } 556 + if foundOther { 557 + continue 558 + } 559 + } 560 + validLabels = append(validLabels, label) 561 + } 562 + 563 + return validLabels 564 + } 565 + 541 566 // ReplaceIssueLabels removes all current labels and add new labels to the issue. 542 567 // Triggers appropriate WebHooks, if any. 543 568 func ReplaceIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) { ··· 554 579 if err = issue.LoadLabels(ctx); err != nil { 555 580 return err 556 581 } 582 + 583 + labels = RemoveDuplicateExclusiveLabels(labels) 557 584 558 585 sort.Sort(labelSorter(labels)) 559 586 sort.Sort(labelSorter(issue.Labels))
+12 -7
models/issues/issue_test.go
··· 25 25 func TestIssue_ReplaceLabels(t *testing.T) { 26 26 assert.NoError(t, unittest.PrepareTestDatabase()) 27 27 28 - testSuccess := func(issueID int64, labelIDs []int64) { 28 + testSuccess := func(issueID int64, labelIDs, expectedLabelIDs []int64) { 29 29 issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueID}) 30 30 repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) 31 31 doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) ··· 35 35 labels[i] = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: labelID, RepoID: repo.ID}) 36 36 } 37 37 assert.NoError(t, issues_model.ReplaceIssueLabels(issue, labels, doer)) 38 - unittest.AssertCount(t, &issues_model.IssueLabel{IssueID: issueID}, len(labelIDs)) 39 - for _, labelID := range labelIDs { 38 + unittest.AssertCount(t, &issues_model.IssueLabel{IssueID: issueID}, len(expectedLabelIDs)) 39 + for _, labelID := range expectedLabelIDs { 40 40 unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issueID, LabelID: labelID}) 41 41 } 42 42 } 43 43 44 - testSuccess(1, []int64{2}) 45 - testSuccess(1, []int64{1, 2}) 46 - testSuccess(1, []int64{}) 44 + testSuccess(1, []int64{2}, []int64{2}) 45 + testSuccess(1, []int64{1, 2}, []int64{1, 2}) 46 + testSuccess(1, []int64{}, []int64{}) 47 + 48 + // mutually exclusive scoped labels 7 and 8 49 + testSuccess(18, []int64{6, 7}, []int64{6, 7}) 50 + testSuccess(18, []int64{7, 8}, []int64{8}) 51 + testSuccess(18, []int64{6, 8, 7}, []int64{6, 7}) 47 52 } 48 53 49 54 func Test_GetIssueIDsByRepoID(t *testing.T) { ··· 523 528 assert.NoError(t, unittest.PrepareTestDatabase()) 524 529 count, err := issues_model.CountIssues(db.DefaultContext, &issues_model.IssuesOptions{}) 525 530 assert.NoError(t, err) 526 - assert.EqualValues(t, 17, count) 531 + assert.EqualValues(t, 18, count) 527 532 }
+65 -41
models/issues/label.go
··· 7 7 import ( 8 8 "context" 9 9 "fmt" 10 - "html/template" 11 - "math" 12 10 "regexp" 13 11 "strconv" 14 12 "strings" ··· 89 87 RepoID int64 `xorm:"INDEX"` 90 88 OrgID int64 `xorm:"INDEX"` 91 89 Name string 90 + Exclusive bool 92 91 Description string 93 92 Color string `xorm:"VARCHAR(7)"` 94 93 NumIssues int ··· 128 127 } 129 128 130 129 // LoadSelectedLabelsAfterClick calculates the set of selected labels when a label is clicked 131 - func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64) { 130 + func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64, currentSelectedExclusiveScopes []string) { 132 131 var labelQuerySlice []string 133 132 labelSelected := false 134 133 labelID := strconv.FormatInt(label.ID, 10) 135 - for _, s := range currentSelectedLabels { 134 + labelScope := label.ExclusiveScope() 135 + for i, s := range currentSelectedLabels { 136 136 if s == label.ID { 137 137 labelSelected = true 138 138 } else if -s == label.ID { 139 139 labelSelected = true 140 140 label.IsExcluded = true 141 141 } else if s != 0 { 142 - labelQuerySlice = append(labelQuerySlice, strconv.FormatInt(s, 10)) 142 + // Exclude other labels in the same scope from selection 143 + if s < 0 || labelScope == "" || labelScope != currentSelectedExclusiveScopes[i] { 144 + labelQuerySlice = append(labelQuerySlice, strconv.FormatInt(s, 10)) 145 + } 143 146 } 144 147 } 145 148 if !labelSelected { ··· 159 162 return label.RepoID > 0 160 163 } 161 164 162 - // SrgbToLinear converts a component of an sRGB color to its linear intensity 163 - // See: https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation_(sRGB_to_CIE_XYZ) 164 - func SrgbToLinear(color uint8) float64 { 165 - flt := float64(color) / 255 166 - if flt <= 0.04045 { 167 - return flt / 12.92 165 + // Get color as RGB values in 0..255 range 166 + func (label *Label) ColorRGB() (float64, float64, float64, error) { 167 + color, err := strconv.ParseUint(label.Color[1:], 16, 64) 168 + if err != nil { 169 + return 0, 0, 0, err 168 170 } 169 - return math.Pow((flt+0.055)/1.055, 2.4) 170 - } 171 171 172 - // Luminance returns the luminance of an sRGB color 173 - func Luminance(color uint32) float64 { 174 - r := SrgbToLinear(uint8(0xFF & (color >> 16))) 175 - g := SrgbToLinear(uint8(0xFF & (color >> 8))) 176 - b := SrgbToLinear(uint8(0xFF & color)) 177 - 178 - // luminance ratios for sRGB 179 - return 0.2126*r + 0.7152*g + 0.0722*b 172 + r := float64(uint8(0xFF & (uint32(color) >> 16))) 173 + g := float64(uint8(0xFF & (uint32(color) >> 8))) 174 + b := float64(uint8(0xFF & uint32(color))) 175 + return r, g, b, nil 180 176 } 181 177 182 - // LuminanceThreshold is the luminance at which white and black appear to have the same contrast 183 - // i.e. x such that 1.05 / (x + 0.05) = (x + 0.05) / 0.05 184 - // i.e. math.Sqrt(1.05*0.05) - 0.05 185 - const LuminanceThreshold float64 = 0.179 186 - 187 - // ForegroundColor calculates the text color for labels based 188 - // on their background color. 189 - func (label *Label) ForegroundColor() template.CSS { 178 + // Determine if label text should be light or dark to be readable on background color 179 + func (label *Label) UseLightTextColor() bool { 190 180 if strings.HasPrefix(label.Color, "#") { 191 - if color, err := strconv.ParseUint(label.Color[1:], 16, 64); err == nil { 192 - // NOTE: see web_src/js/components/ContextPopup.vue for similar implementation 193 - luminance := Luminance(uint32(color)) 194 - 195 - // prefer white or black based upon contrast 196 - if luminance < LuminanceThreshold { 197 - return template.CSS("#fff") 198 - } 199 - return template.CSS("#000") 181 + if r, g, b, err := label.ColorRGB(); err == nil { 182 + // Perceived brightness from: https://www.w3.org/TR/AERT/#color-contrast 183 + // In the future WCAG 3 APCA may be a better solution 184 + brightness := (0.299*r + 0.587*g + 0.114*b) / 255 185 + return brightness < 0.35 200 186 } 201 187 } 202 188 203 - // default to black 204 - return template.CSS("#000") 189 + return false 190 + } 191 + 192 + // Return scope substring of label name, or empty string if none exists 193 + func (label *Label) ExclusiveScope() string { 194 + if !label.Exclusive { 195 + return "" 196 + } 197 + lastIndex := strings.LastIndex(label.Name, "/") 198 + if lastIndex == -1 || lastIndex == 0 || lastIndex == len(label.Name)-1 { 199 + return "" 200 + } 201 + return label.Name[:lastIndex] 205 202 } 206 203 207 204 // NewLabel creates a new label ··· 253 250 if !LabelColorPattern.MatchString(l.Color) { 254 251 return fmt.Errorf("bad color code: %s", l.Color) 255 252 } 256 - return updateLabelCols(db.DefaultContext, l, "name", "description", "color") 253 + return updateLabelCols(db.DefaultContext, l, "name", "description", "color", "exclusive") 257 254 } 258 255 259 256 // DeleteLabel delete a label ··· 620 617 return updateLabelCols(ctx, label, "num_issues", "num_closed_issue") 621 618 } 622 619 620 + // Remove all issue labels in the given exclusive scope 621 + func RemoveDuplicateExclusiveIssueLabels(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) { 622 + scope := label.ExclusiveScope() 623 + if scope == "" { 624 + return nil 625 + } 626 + 627 + var toRemove []*Label 628 + for _, issueLabel := range issue.Labels { 629 + if label.ID != issueLabel.ID && issueLabel.ExclusiveScope() == scope { 630 + toRemove = append(toRemove, issueLabel) 631 + } 632 + } 633 + 634 + for _, issueLabel := range toRemove { 635 + if err = deleteIssueLabel(ctx, issue, issueLabel, doer); err != nil { 636 + return err 637 + } 638 + } 639 + 640 + return nil 641 + } 642 + 623 643 // NewIssueLabel creates a new issue-label relation. 624 644 func NewIssueLabel(issue *Issue, label *Label, doer *user_model.User) (err error) { 625 645 if HasIssueLabel(db.DefaultContext, issue.ID, label.ID) { ··· 638 658 639 659 // Do NOT add invalid labels 640 660 if issue.RepoID != label.RepoID && issue.Repo.OwnerID != label.OrgID { 661 + return nil 662 + } 663 + 664 + if err = RemoveDuplicateExclusiveIssueLabels(ctx, issue, label, doer); err != nil { 641 665 return nil 642 666 } 643 667
+41 -4
models/issues/label_test.go
··· 4 4 package issues_test 5 5 6 6 import ( 7 - "html/template" 8 7 "testing" 9 8 10 9 "code.gitea.io/gitea/models/db" ··· 25 24 assert.EqualValues(t, 2, label.NumOpenIssues) 26 25 } 27 26 28 - func TestLabel_ForegroundColor(t *testing.T) { 27 + func TestLabel_TextColor(t *testing.T) { 29 28 assert.NoError(t, unittest.PrepareTestDatabase()) 30 29 label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}) 31 - assert.Equal(t, template.CSS("#000"), label.ForegroundColor()) 30 + assert.False(t, label.UseLightTextColor()) 32 31 33 32 label = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 2}) 34 - assert.Equal(t, template.CSS("#fff"), label.ForegroundColor()) 33 + assert.True(t, label.UseLightTextColor()) 34 + } 35 + 36 + func TestLabel_ExclusiveScope(t *testing.T) { 37 + assert.NoError(t, unittest.PrepareTestDatabase()) 38 + label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 7}) 39 + assert.Equal(t, "scope", label.ExclusiveScope()) 40 + 41 + label = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 9}) 42 + assert.Equal(t, "scope/subscope", label.ExclusiveScope()) 35 43 } 36 44 37 45 func TestNewLabels(t *testing.T) { ··· 266 274 Color: "#ffff00", 267 275 Name: "newLabelName", 268 276 Description: label.Description, 277 + Exclusive: false, 269 278 } 270 279 label.Color = update.Color 271 280 label.Name = update.Name ··· 321 330 // re-add existing IssueLabel 322 331 assert.NoError(t, issues_model.NewIssueLabel(issue, label, doer)) 323 332 unittest.CheckConsistencyFor(t, &issues_model.Issue{}, &issues_model.Label{}) 333 + } 334 + 335 + func TestNewIssueExclusiveLabel(t *testing.T) { 336 + assert.NoError(t, unittest.PrepareTestDatabase()) 337 + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 18}) 338 + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) 339 + 340 + otherLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 6}) 341 + exclusiveLabelA := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 7}) 342 + exclusiveLabelB := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 8}) 343 + 344 + // coexisting regular and exclusive label 345 + assert.NoError(t, issues_model.NewIssueLabel(issue, otherLabel, doer)) 346 + assert.NoError(t, issues_model.NewIssueLabel(issue, exclusiveLabelA, doer)) 347 + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID}) 348 + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID}) 349 + 350 + // exclusive label replaces existing one 351 + assert.NoError(t, issues_model.NewIssueLabel(issue, exclusiveLabelB, doer)) 352 + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID}) 353 + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelB.ID}) 354 + unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID}) 355 + 356 + // exclusive label replaces existing one again 357 + assert.NoError(t, issues_model.NewIssueLabel(issue, exclusiveLabelA, doer)) 358 + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID}) 359 + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID}) 360 + unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelB.ID}) 324 361 } 325 362 326 363 func TestNewIssueLabels(t *testing.T) {
+5
models/migrations/fixtures/Test_DeleteOrphanedIssueLabels/label.yml
··· 4 4 org_id: 0 5 5 name: label1 6 6 color: '#abcdef' 7 + exclusive: false 7 8 num_issues: 2 8 9 num_closed_issues: 0 9 10 ··· 13 14 org_id: 0 14 15 name: label2 15 16 color: '#000000' 17 + exclusive: false 16 18 num_issues: 1 17 19 num_closed_issues: 1 18 20 - ··· 21 23 org_id: 3 22 24 name: orglabel3 23 25 color: '#abcdef' 26 + exclusive: false 24 27 num_issues: 0 25 28 num_closed_issues: 0 26 29 ··· 30 33 org_id: 3 31 34 name: orglabel4 32 35 color: '#000000' 36 + exclusive: false 33 37 num_issues: 1 34 38 num_closed_issues: 0 35 39 ··· 39 43 org_id: 0 40 44 name: pull-test-label 41 45 color: '#000000' 46 + exclusive: false 42 47 num_issues: 0 43 48 num_closed_issues: 0
+2
models/migrations/migrations.go
··· 459 459 NewMigration("Add card_type column to project table", v1_19.AddCardTypeToProjectTable), 460 460 // v242 -> v243 461 461 NewMigration("Alter gpg_key_import content TEXT field to MEDIUMTEXT", v1_19.AlterPublicGPGKeyImportContentFieldToMediumText), 462 + // v243 -> v244 463 + NewMigration("Add exclusive label", v1_19.AddExclusiveLabel), 462 464 } 463 465 464 466 // GetCurrentDBVersion returns the current db version
+16
models/migrations/v1_19/v244.go
··· 1 + // Copyright 2023 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package v1_19 //nolint 5 + 6 + import ( 7 + "xorm.io/xorm" 8 + ) 9 + 10 + func AddExclusiveLabel(x *xorm.Engine) error { 11 + type Label struct { 12 + Exclusive bool 13 + } 14 + 15 + return x.Sync(new(Label)) 16 + }
+1
modules/migration/label.go
··· 9 9 Name string `json:"name"` 10 10 Color string `json:"color"` 11 11 Description string `json:"description"` 12 + Exclusive bool `json:"exclusive"` 12 13 }
+8 -1
modules/structs/issue_label.go
··· 9 9 type Label struct { 10 10 ID int64 `json:"id"` 11 11 Name string `json:"name"` 12 + // example: false 13 + Exclusive bool `json:"exclusive"` 12 14 // example: 00aabb 13 15 Color string `json:"color"` 14 16 Description string `json:"description"` ··· 19 21 type CreateLabelOption struct { 20 22 // required:true 21 23 Name string `json:"name" binding:"Required"` 24 + // example: false 25 + Exclusive bool `json:"exclusive"` 22 26 // required:true 23 27 // example: #00aabb 24 28 Color string `json:"color" binding:"Required"` ··· 27 31 28 32 // EditLabelOption options for editing a label 29 33 type EditLabelOption struct { 30 - Name *string `json:"name"` 34 + Name *string `json:"name"` 35 + // example: false 36 + Exclusive *bool `json:"exclusive"` 37 + // example: #00aabb 31 38 Color *string `json:"color"` 32 39 Description *string `json:"description"` 33 40 }
+68 -2
modules/templates/helper.go
··· 7 7 import ( 8 8 "bytes" 9 9 "context" 10 + "encoding/hex" 10 11 "errors" 11 12 "fmt" 12 13 "html" 13 14 "html/template" 15 + "math" 14 16 "mime" 15 17 "net/url" 16 18 "path/filepath" ··· 382 384 // the table is NOT sorted with this header 383 385 return "" 384 386 }, 387 + "RenderLabel": func(label *issues_model.Label) template.HTML { 388 + return template.HTML(RenderLabel(label)) 389 + }, 385 390 "RenderLabels": func(labels []*issues_model.Label, repoLink string) template.HTML { 386 391 htmlCode := `<span class="labels-list">` 387 392 for _, label := range labels { ··· 389 394 if label == nil { 390 395 continue 391 396 } 392 - htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d' class='ui label' style='color: %s !important; background-color: %s !important' title='%s'>%s</a> ", 393 - repoLink, label.ID, label.ForegroundColor(), label.Color, html.EscapeString(label.Description), RenderEmoji(label.Name)) 397 + htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d'>%s</a> ", 398 + repoLink, label.ID, RenderLabel(label)) 394 399 } 395 400 htmlCode += "</span>" 396 401 return template.HTML(htmlCode) ··· 799 804 return template.HTML("") 800 805 } 801 806 return template.HTML(renderedText) 807 + } 808 + 809 + // RenderLabel renders a label 810 + func RenderLabel(label *issues_model.Label) string { 811 + labelScope := label.ExclusiveScope() 812 + 813 + textColor := "#111" 814 + if label.UseLightTextColor() { 815 + textColor = "#eee" 816 + } 817 + 818 + description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description)) 819 + 820 + if labelScope == "" { 821 + // Regular label 822 + return fmt.Sprintf("<div class='ui label' style='color: %s !important; background-color: %s !important' title='%s'>%s</div>", 823 + textColor, label.Color, description, RenderEmoji(label.Name)) 824 + } 825 + 826 + // Scoped label 827 + scopeText := RenderEmoji(labelScope) 828 + itemText := RenderEmoji(label.Name[len(labelScope)+1:]) 829 + 830 + itemColor := label.Color 831 + scopeColor := label.Color 832 + if r, g, b, err := label.ColorRGB(); err == nil { 833 + // Make scope and item background colors slightly darker and lighter respectively. 834 + // More contrast needed with higher luminance, empirically tweaked. 835 + luminance := (0.299*r + 0.587*g + 0.114*b) / 255 836 + contrast := 0.01 + luminance*0.06 837 + // Ensure we add the same amount of contrast also near 0 and 1. 838 + darken := contrast + math.Max(luminance+contrast-1.0, 0.0) 839 + lighten := contrast + math.Max(contrast-luminance, 0.0) 840 + // Compute factor to keep RGB values proportional. 841 + darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0) 842 + lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0) 843 + 844 + scopeBytes := []byte{ 845 + uint8(math.Min(math.Round(r*darkenFactor), 255)), 846 + uint8(math.Min(math.Round(g*darkenFactor), 255)), 847 + uint8(math.Min(math.Round(b*darkenFactor), 255)), 848 + } 849 + itemBytes := []byte{ 850 + uint8(math.Min(math.Round(r*lightenFactor), 255)), 851 + uint8(math.Min(math.Round(g*lightenFactor), 255)), 852 + uint8(math.Min(math.Round(b*lightenFactor), 255)), 853 + } 854 + 855 + itemColor = "#" + hex.EncodeToString(itemBytes) 856 + scopeColor = "#" + hex.EncodeToString(scopeBytes) 857 + } 858 + 859 + return fmt.Sprintf("<span class='ui label scope-parent' title='%s'>"+ 860 + "<div class='ui label scope-left' style='color: %s !important; background-color: %s !important'>%s</div>"+ 861 + "<div class='ui label scope-middle' style='background: linear-gradient(-80deg, %s 48%%, %s 52%% 0%%);'>&nbsp;</div>"+ 862 + "<div class='ui label scope-right' style='color: %s !important; background-color: %s !important''>%s</div>"+ 863 + "</span>", 864 + description, 865 + textColor, scopeColor, scopeText, 866 + itemColor, scopeColor, 867 + textColor, itemColor, itemText) 802 868 } 803 869 804 870 // RenderEmoji renders html text with emoji post processors
+6 -3
options/locale/locale_en-US.ini
··· 1395 1395 issues.edit = Edit 1396 1396 issues.cancel = Cancel 1397 1397 issues.save = Save 1398 - issues.label_title = Label name 1399 - issues.label_description = Label description 1400 - issues.label_color = Label color 1398 + issues.label_title = Name 1399 + issues.label_description = Description 1400 + issues.label_color = Color 1401 + issues.label_exclusive = Exclusive 1402 + issues.label_exclusive_desc = Name the label <code>scope/item</code> to make it mutually exclusive with other <code>scope/</code> labels. 1403 + issues.label_exclusive_warning = Any conflicting scoped labels will be removed when editing the labels of an issue or pull request. 1401 1404 issues.label_count = %d labels 1402 1405 issues.label_open_issues = %d open issues/pull requests 1403 1406 issues.label_edit = Edit
+4
routers/api/v1/org/label.go
··· 94 94 95 95 label := &issues_model.Label{ 96 96 Name: form.Name, 97 + Exclusive: form.Exclusive, 97 98 Color: form.Color, 98 99 OrgID: ctx.Org.Organization.ID, 99 100 Description: form.Description, ··· 194 195 195 196 if form.Name != nil { 196 197 label.Name = *form.Name 198 + } 199 + if form.Exclusive != nil { 200 + label.Exclusive = *form.Exclusive 197 201 } 198 202 if form.Color != nil { 199 203 label.Color = strings.Trim(*form.Color, " ")
+4
routers/api/v1/repo/label.go
··· 156 156 157 157 label := &issues_model.Label{ 158 158 Name: form.Name, 159 + Exclusive: form.Exclusive, 159 160 Color: form.Color, 160 161 RepoID: ctx.Repo.Repository.ID, 161 162 Description: form.Description, ··· 217 218 218 219 if form.Name != nil { 219 220 label.Name = *form.Name 221 + } 222 + if form.Exclusive != nil { 223 + label.Exclusive = *form.Exclusive 220 224 } 221 225 if form.Color != nil { 222 226 label.Color = strings.Trim(*form.Color, " ")
+2
routers/web/org/org_labels.go
··· 45 45 l := &issues_model.Label{ 46 46 OrgID: ctx.Org.Organization.ID, 47 47 Name: form.Title, 48 + Exclusive: form.Exclusive, 48 49 Description: form.Description, 49 50 Color: form.Color, 50 51 } ··· 70 71 } 71 72 72 73 l.Name = form.Title 74 + l.Exclusive = form.Exclusive 73 75 l.Description = form.Description 74 76 l.Color = form.Color 75 77 if err := issues_model.UpdateLabel(l); err != nil {
+17 -1
routers/web/repo/issue.go
··· 332 332 labels = append(labels, orgLabels...) 333 333 } 334 334 335 + // Get the exclusive scope for every label ID 336 + labelExclusiveScopes := make([]string, 0, len(labelIDs)) 337 + for _, labelID := range labelIDs { 338 + foundExclusiveScope := false 339 + for _, label := range labels { 340 + if label.ID == labelID || label.ID == -labelID { 341 + labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope()) 342 + foundExclusiveScope = true 343 + break 344 + } 345 + } 346 + if !foundExclusiveScope { 347 + labelExclusiveScopes = append(labelExclusiveScopes, "") 348 + } 349 + } 350 + 335 351 for _, l := range labels { 336 - l.LoadSelectedLabelsAfterClick(labelIDs) 352 + l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes) 337 353 } 338 354 ctx.Data["Labels"] = labels 339 355 ctx.Data["NumLabels"] = len(labels)
+13 -5
routers/web/repo/issue_label.go
··· 113 113 l := &issues_model.Label{ 114 114 RepoID: ctx.Repo.Repository.ID, 115 115 Name: form.Title, 116 + Exclusive: form.Exclusive, 116 117 Description: form.Description, 117 118 Color: form.Color, 118 119 } ··· 138 139 } 139 140 140 141 l.Name = form.Title 142 + l.Exclusive = form.Exclusive 141 143 l.Description = form.Description 142 144 l.Color = form.Color 143 145 if err := issues_model.UpdateLabel(l); err != nil { ··· 175 177 return 176 178 } 177 179 } 178 - case "attach", "detach", "toggle": 180 + case "attach", "detach", "toggle", "toggle-alt": 179 181 label, err := issues_model.GetLabelByID(ctx, ctx.FormInt64("id")) 180 182 if err != nil { 181 183 if issues_model.IsErrRepoLabelNotExist(err) { ··· 189 191 if action == "toggle" { 190 192 // detach if any issues already have label, otherwise attach 191 193 action = "attach" 192 - for _, issue := range issues { 193 - if issues_model.HasIssueLabel(ctx, issue.ID, label.ID) { 194 - action = "detach" 195 - break 194 + if label.ExclusiveScope() == "" { 195 + for _, issue := range issues { 196 + if issues_model.HasIssueLabel(ctx, issue.ID, label.ID) { 197 + action = "detach" 198 + break 199 + } 196 200 } 197 201 } 202 + } else if action == "toggle-alt" { 203 + // always detach with alt key pressed, to be able to remove 204 + // scoped labels 205 + action = "detach" 198 206 } 199 207 200 208 if action == "attach" {
+1
services/convert/issue.go
··· 182 182 result := &api.Label{ 183 183 ID: label.ID, 184 184 Name: label.Name, 185 + Exclusive: label.Exclusive, 185 186 Color: strings.TrimLeft(label.Color, "#"), 186 187 Description: label.Description, 187 188 }
+1
services/forms/repo_form.go
··· 564 564 type CreateLabelForm struct { 565 565 ID int64 566 566 Title string `binding:"Required;MaxSize(50)" locale:"repo.issues.label_title"` 567 + Exclusive bool `form:"exclusive"` 567 568 Description string `binding:"MaxSize(200)" locale:"repo.issues.label_description"` 568 569 Color string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"` 569 570 }
+1
services/migrations/main_test.go
··· 59 59 60 60 func assertLabelEqual(t *testing.T, expected, actual *base.Label) { 61 61 assert.Equal(t, expected.Name, actual.Name) 62 + assert.Equal(t, expected.Exclusive, actual.Exclusive) 62 63 assert.Equal(t, expected.Color, actual.Color) 63 64 assert.Equal(t, expected.Description, actual.Description) 64 65 }
+1
services/repository/template.go
··· 31 31 newLabels = append(newLabels, &issues_model.Label{ 32 32 RepoID: generateRepo.ID, 33 33 Name: templateLabel.Name, 34 + Exclusive: templateLabel.Exclusive, 34 35 Description: templateLabel.Description, 35 36 Color: templateLabel.Color, 36 37 })
+1 -1
templates/projects/view.tmpl
··· 234 234 {{if or .Labels .Assignees}} 235 235 <div class="extra content labels-list gt-p-0 gt-pt-2"> 236 236 {{range .Labels}} 237 - <a class="ui label" target="_blank" href="{{$.RepoLink}}/issues?labels={{.ID}}" style="color: {{.ForegroundColor}}; background-color: {{.Color}};" title="{{.Description | RenderEmojiPlain}}">{{.Name | RenderEmoji}}</a> 237 + <a target="_blank" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{RenderLabel .}}</a> 238 238 {{end}} 239 239 <div class="right floated"> 240 240 {{range .Assignees}}
+28 -14
templates/repo/issue/labels/edit_delete_label.tmpl
··· 26 26 <form class="ui edit-label form ignore-dirty" action="{{$.Link}}/edit" method="post"> 27 27 {{.CsrfTokenHtml}} 28 28 <input id="label-modal-id" name="id" type="hidden"> 29 - <div class="ui grid"> 30 - <div class="three wide column"> 31 - <div class="ui small input"> 32 - <input class="new-label-input emoji-input" name="title" placeholder="{{.locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50"> 33 - </div> 29 + <div class="required field"> 30 + <label for="name">{{.locale.Tr "repo.issues.label_title"}}</label> 31 + <div class="ui small input"> 32 + <input class="label-name-input emoji-input" name="title" placeholder="{{.locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50"> 34 33 </div> 35 - <div class="five wide column"> 36 - <div class="ui small fluid input"> 37 - <input class="new-label-desc-input" name="description" placeholder="{{.locale.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200"> 38 - </div> 34 + </div> 35 + <div class="field label-exclusive-input-field"> 36 + <div class="ui checkbox"> 37 + <input class="label-exclusive-input" name="exclusive" type="checkbox"> 38 + <label>{{.locale.Tr "repo.issues.label_exclusive"}}</label> 39 39 </div> 40 + <br/> 41 + <small class="desc">{{.locale.Tr "repo.issues.label_exclusive_desc" | Safe}}</small> 42 + <div class="desc gt-ml-2 gt-mt-3 gt-hidden label-exclusive-warning"> 43 + {{svg "octicon-alert"}} {{.locale.Tr "repo.issues.label_exclusive_warning" | Safe}} 44 + </div> 45 + </div> 46 + <div class="field"> 47 + <label for="description">{{.locale.Tr "repo.issues.label_description"}}</label> 48 + <div class="ui small fluid input"> 49 + <input class="label-desc-input" name="description" placeholder="{{.locale.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200"> 50 + </div> 51 + </div> 52 + <div class="field color-field"> 53 + <label for="color">{{$.locale.Tr "repo.issues.label_color"}}</label> 40 54 <div class="color picker column"> 41 55 <input class="color-picker" name="color" value="#70c24a" required maxlength="7"> 42 - </div> 43 - <div class="column precolors"> 44 - {{template "repo/issue/label_precolors"}} 56 + <div class="column precolors"> 57 + {{template "repo/issue/label_precolors"}} 58 + </div> 45 59 </div> 46 60 </div> 47 61 </form> 48 62 </div> 49 63 <div class="actions"> 50 - <div class="ui negative button"> 64 + <div class="ui secondary small basic cancel button"> 51 65 {{.locale.Tr "cancel"}} 52 66 </div> 53 - <div class="ui positive button"> 67 + <div class="ui primary small approve button"> 54 68 {{.locale.Tr "save"}} 55 69 </div> 56 70 </div>
+2 -4
templates/repo/issue/labels/label.tmpl
··· 1 1 <a 2 - class="ui label item {{if not .label.IsChecked}}hide{{end}}" 2 + class="item {{if not .label.IsChecked}}hide{{end}}" 3 3 id="label_{{.label.ID}}" 4 4 href="{{.root.RepoLink}}/{{if or .root.IsPull .root.Issue.IsPull}}pulls{{else}}issues{{end}}?labels={{.label.ID}}"{{/* FIXME: use .root.Issue.Link or create .root.Link */}} 5 - style="color: {{.label.ForegroundColor}}; background-color: {{.label.Color}}" 6 - title="{{.label.Description | RenderEmojiPlain}}" 7 5 > 8 - {{.label.Name | RenderEmoji}} 6 + {{RenderLabel .label}} 9 7 </a>
+13 -21
templates/repo/issue/labels/label_list.tmpl
··· 30 30 {{range .Labels}} 31 31 <li class="item"> 32 32 <div class="ui grid middle aligned"> 33 + <div class="nine wide column"> 34 + {{RenderLabel .}} 35 + {{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}} 36 + </div> 33 37 <div class="four wide column"> 34 - <div class="ui label" style="color: {{.ForegroundColor}}; background-color: {{.Color}}">{{svg "octicon-tag"}} {{.Name | RenderEmoji}}</div> 35 - </div> 36 - <div class="six wide column"> 37 - <div class="ui"> 38 - {{.Description | RenderEmoji}} 39 - </div> 40 - </div> 41 - <div class="three wide column"> 42 38 {{if $.PageIsOrgSettingsLabels}} 43 - <a class="ui right open-issues" href="{{AppSubUrl}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenIssues}}</a> 39 + <a class="ui left open-issues" href="{{AppSubUrl}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenIssues}}</a> 44 40 {{else}} 45 - <a class="ui right open-issues" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenIssues}}</a> 41 + <a class="ui left open-issues" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenIssues}}</a> 46 42 {{end}} 47 43 </div> 48 44 <div class="three wide column"> 49 45 {{if and (not $.PageIsOrgSettingsLabels ) (not $.Repository.IsArchived) (or $.CanWriteIssues $.CanWritePulls)}} 50 46 <a class="ui right delete-button" href="#" data-url="{{$.Link}}/delete" data-id="{{.ID}}">{{svg "octicon-trash"}} {{$.locale.Tr "repo.issues.label_delete"}}</a> 51 - <a class="ui right edit-label-button" href="#" data-id="{{.ID}}" data-title="{{.Name}}" data-description="{{.Description}}" data-color={{.Color}}>{{svg "octicon-pencil"}} {{$.locale.Tr "repo.issues.label_edit"}}</a> 47 + <a class="ui right edit-label-button" href="#" data-id="{{.ID}}" data-title="{{.Name}}" {{if .Exclusive}}data-exclusive{{end}} data-num-issues="{{.NumIssues}}" data-description="{{.Description}}" data-color={{.Color}}>{{svg "octicon-pencil"}} {{$.locale.Tr "repo.issues.label_edit"}}</a> 52 48 {{else if $.PageIsOrgSettingsLabels}} 53 49 <a class="ui right delete-button" href="#" data-url="{{$.Link}}/delete" data-id="{{.ID}}">{{svg "octicon-trash"}} {{$.locale.Tr "repo.issues.label_delete"}}</a> 54 - <a class="ui right edit-label-button" href="#" data-id="{{.ID}}" data-title="{{.Name}}" data-description="{{.Description}}" data-color={{.Color}}>{{svg "octicon-pencil"}} {{$.locale.Tr "repo.issues.label_edit"}}</a> 50 + <a class="ui right edit-label-button" href="#" data-id="{{.ID}}" data-title="{{.Name}}" {{if .Exclusive}}data-exclusive{{end}} data-num-issues="{{.NumIssues}}" data-description="{{.Description}}" data-color={{.Color}}>{{svg "octicon-pencil"}} {{$.locale.Tr "repo.issues.label_edit"}}</a> 55 51 {{end}} 56 52 </div> 57 53 </div> ··· 73 69 {{range .OrgLabels}} 74 70 <li class="item"> 75 71 <div class="ui grid middle aligned"> 76 - <div class="three wide column"> 77 - <div class="ui label" style="color: {{.ForegroundColor}}; background-color: {{.Color}}">{{svg "octicon-tag"}} {{.Name | RenderEmoji}}</div> 78 - </div> 79 - <div class="seven wide column"> 80 - <div class="ui"> 81 - {{.Description | RenderEmoji}} 82 - </div> 72 + <div class="nine wide column"> 73 + {{RenderLabel .}} 74 + {{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}} 83 75 </div> 84 - <div class="three wide column"> 85 - <a class="ui right open-issues" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenRepoIssues}}</a> 76 + <div class="four wide column"> 77 + <a class="ui left open-issues" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenRepoIssues}}</a> 86 78 </div> 87 79 <div class="three wide column"> 88 80 </div>
+38 -18
templates/repo/issue/labels/label_new.tmpl
··· 1 - <div class="ui new-label segment hide"> 2 - <form class="ui form" action="{{$.Link}}/new" method="post"> 3 - {{.CsrfTokenHtml}} 4 - <div class="ui grid"> 5 - <div class="three wide column"> 1 + <div class="ui small new-label modal"> 2 + <div class="header"> 3 + {{.locale.Tr "repo.issues.new_label"}} 4 + </div> 5 + <div class="content"> 6 + <form class="ui new-label form ignore-dirty" action="{{$.Link}}/new" method="post"> 7 + {{.CsrfTokenHtml}} 8 + <div class="required field"> 9 + <label for="name">{{.locale.Tr "repo.issues.label_title"}}</label> 6 10 <div class="ui small input"> 7 - <input class="new-label-input emoji-input" name="title" placeholder="{{.locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50"> 11 + <input class="label-name-input emoji-input" name="title" placeholder="{{.locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50"> 8 12 </div> 9 13 </div> 10 - <div class="three wide column"> 11 - <div class="ui small fluid input"> 12 - <input class="new-label-desc-input" name="description" placeholder="{{.locale.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200"> 14 + <div class="field label-exclusive-input-field"> 15 + <div class="ui checkbox"> 16 + <input class="label-exclusive-input" name="exclusive" type="checkbox"> 17 + <label>{{.locale.Tr "repo.issues.label_exclusive"}}</label> 13 18 </div> 14 - </div> 15 - <div class="color picker column"> 16 - <input class="color-picker" name="color" value="#70c24a" required maxlength="7"> 19 + <br/> 20 + <small class="desc">{{.locale.Tr "repo.issues.label_exclusive_desc" | Safe}}</small> 17 21 </div> 18 - <div class="column precolors"> 19 - {{template "repo/issue/label_precolors"}} 22 + <div class="field"> 23 + <label for="description">{{.locale.Tr "repo.issues.label_description"}}</label> 24 + <div class="ui small fluid input"> 25 + <input class="label-desc-input" name="description" placeholder="{{.locale.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200"> 26 + </div> 20 27 </div> 21 - <div class="buttons"> 22 - <div class="ui secondary small basic cancel button">{{.locale.Tr "repo.milestones.cancel"}}</div> 23 - <button class="ui primary small button">{{.locale.Tr "repo.issues.create_label"}}</button> 28 + <div class="field color-field"> 29 + <label for="color">{{$.locale.Tr "repo.issues.label_color"}}</label> 30 + <div class="color picker column"> 31 + <input class="color-picker" name="color" value="#70c24a" required maxlength="7"> 32 + <div class="column precolors"> 33 + {{template "repo/issue/label_precolors"}} 34 + </div> 35 + </div> 24 36 </div> 37 + </form> 38 + </div> 39 + <div class="actions"> 40 + <div class="ui secondary small basic cancel button"> 41 + {{.locale.Tr "cancel"}} 25 42 </div> 26 - </form> 43 + <div class="ui primary small approve button"> 44 + {{.locale.Tr "repo.issues.create_label"}} 45 + </div> 46 + </div> 27 47 </div>
+14 -2
templates/repo/issue/list.tmpl
··· 50 50 </div> 51 51 <span class="info">{{.locale.Tr "repo.issues.filter_label_exclude" | Safe}}</span> 52 52 <a class="item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_label_no_select"}}</a> 53 + {{$previousExclusiveScope := "_no_scope"}} 53 54 {{range .Labels}} 54 - <a class="item label-filter-item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.QueryString}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}" data-label-id="{{.ID}}">{{if .IsExcluded}}{{svg "octicon-circle-slash"}}{{else if .IsSelected}}{{svg "octicon-check"}}{{end}}<span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}}</a> 55 + {{$exclusiveScope := .ExclusiveScope}} 56 + {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}} 57 + <div class="ui divider"></div> 58 + {{end}} 59 + {{$previousExclusiveScope = $exclusiveScope}} 60 + <a class="item label-filter-item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.QueryString}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}" data-label-id="{{.ID}}">{{if .IsExcluded}}{{svg "octicon-circle-slash"}}{{else if .IsSelected}}{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}{{end}} {{RenderLabel .}}</a> 55 61 {{end}} 56 62 </div> 57 63 </div> ··· 217 223 {{svg "octicon-triangle-down" 14 "dropdown icon"}} 218 224 </span> 219 225 <div class="menu"> 226 + {{$previousExclusiveScope := "_no_scope"}} 220 227 {{range .Labels}} 228 + {{$exclusiveScope := .ExclusiveScope}} 229 + {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}} 230 + <div class="ui divider"></div> 231 + {{end}} 232 + {{$previousExclusiveScope = $exclusiveScope}} 221 233 <div class="item issue-action" data-action="toggle" data-element-id="{{.ID}}" data-url="{{$.RepoLink}}/issues/labels"> 222 - {{if contain $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}}<span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}} 234 + {{if contain $.SelLabelIDs .ID}}{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}{{end}} {{RenderLabel .}} 223 235 </div> 224 236 {{end}} 225 237 </div>
+2 -2
templates/repo/issue/milestone_issues.tmpl
··· 58 58 <span class="info">{{.locale.Tr "repo.issues.filter_label_exclude" | Safe}}</span> 59 59 <a class="item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_label_no_select"}}</a> 60 60 {{range .Labels}} 61 - <a class="item label-filter-item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.QueryString}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}" data-label-id="{{.ID}}">{{if .IsExcluded}}{{svg "octicon-circle-slash"}}{{else if contain $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}}<span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}}</a> 61 + <a class="item label-filter-item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.QueryString}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}" data-label-id="{{.ID}}">{{if .IsExcluded}}{{svg "octicon-circle-slash"}}{{else if contain $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}} {{RenderLabel .}}</a> 62 62 {{end}} 63 63 </div> 64 64 </div> ··· 161 161 <div class="menu"> 162 162 {{range .Labels}} 163 163 <div class="item issue-action" data-action="toggle" data-element-id="{{.ID}}" data-url="{{$.RepoLink}}/issues/labels"> 164 - {{if contain $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}}<span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}} 164 + {{if contain $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}} {{RenderLabel .}} 165 165 </div> 166 166 {{end}} 167 167 </div>
+14 -2
templates/repo/issue/new_form.tmpl
··· 53 53 {{end}} 54 54 <div class="no-select item">{{.locale.Tr "repo.issues.new.clear_labels"}}</div> 55 55 {{if or .Labels .OrgLabels}} 56 + {{$previousExclusiveScope := "_no_scope"}} 56 57 {{range .Labels}} 57 - <a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{svg "octicon-check"}}</span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}} 58 + {{$exclusiveScope := .ExclusiveScope}} 59 + {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}} 60 + <div class="ui divider"></div> 61 + {{end}} 62 + {{$previousExclusiveScope = $exclusiveScope}} 63 + <a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}</span>&nbsp;&nbsp;{{RenderLabel .}} 58 64 {{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}}</a> 59 65 {{end}} 60 66 61 67 <div class="ui divider"></div> 68 + {{$previousExclusiveScope := "_no_scope"}} 62 69 {{range .OrgLabels}} 63 - <a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{svg "octicon-check"}}</span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}} 70 + {{$exclusiveScope := .ExclusiveScope}} 71 + {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}} 72 + <div class="ui divider"></div> 73 + {{end}} 74 + {{$previousExclusiveScope = $exclusiveScope}} 75 + <a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}</span>&nbsp;&nbsp;{{RenderLabel .}} 64 76 {{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}}</a> 65 77 {{end}} 66 78 {{else}}
+14 -2
templates/repo/issue/view_content/sidebar.tmpl
··· 123 123 {{end}} 124 124 <div class="no-select item">{{.locale.Tr "repo.issues.new.clear_labels"}}</div> 125 125 {{if or .Labels .OrgLabels}} 126 + {{$previousExclusiveScope := "_no_scope"}} 126 127 {{range .Labels}} 127 - <a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{svg "octicon-check"}}</span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}} 128 + {{$exclusiveScope := .ExclusiveScope}} 129 + {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}} 130 + <div class="ui divider"></div> 131 + {{end}} 132 + {{$previousExclusiveScope = $exclusiveScope}} 133 + <a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}</span>&nbsp;&nbsp;{{RenderLabel .}} 128 134 {{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}}</a> 129 135 {{end}} 130 136 <div class="ui divider"></div> 137 + {{$previousExclusiveScope := "_no_scope"}} 131 138 {{range .OrgLabels}} 132 - <a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{svg "octicon-check"}}</span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}} 139 + {{$exclusiveScope := .ExclusiveScope}} 140 + {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}} 141 + <div class="ui divider"></div> 142 + {{end}} 143 + {{$previousExclusiveScope = $exclusiveScope}} 144 + <a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}</span>&nbsp;&nbsp;{{RenderLabel .}} 133 145 {{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}}</a> 134 146 {{end}} 135 147 {{else}}
+1 -1
templates/repo/projects/view.tmpl
··· 245 245 {{if or .Labels .Assignees}} 246 246 <div class="extra content labels-list gt-p-0 gt-pt-2"> 247 247 {{range .Labels}} 248 - <a class="ui label" target="_blank" href="{{$.RepoLink}}/issues?labels={{.ID}}" style="color: {{.ForegroundColor}}; background-color: {{.Color}};" title="{{.Description | RenderEmojiPlain}}">{{.Name | RenderEmoji}}</a> 248 + <a target="_blank" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{RenderLabel .}}</a> 249 249 {{end}} 250 250 <div class="right floated"> 251 251 {{range .Assignees}}
+1 -1
templates/shared/issuelist.tmpl
··· 42 42 {{end}} 43 43 <span class="labels-list gt-ml-2"> 44 44 {{range .Labels}} 45 - <a class="ui label" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&state={{$.State}}&labels={{.ID}}{{if ne $.listType "milestone"}}&milestone={{$.MilestoneID}}{{end}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}" style="color: {{.ForegroundColor}}; background-color: {{.Color}}" title="{{.Description | RenderEmojiPlain}}">{{.Name | RenderEmoji}}</a> 45 + <a href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&state={{$.State}}&labels={{.ID}}{{if ne $.listType "milestone"}}&milestone={{$.MilestoneID}}{{end}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{RenderLabel .}}</a> 46 46 {{end}} 47 47 </span> 48 48 </div>
+17 -1
templates/swagger/v1_json.tmpl
··· 15348 15348 "type": "string", 15349 15349 "x-go-name": "Description" 15350 15350 }, 15351 + "exclusive": { 15352 + "type": "boolean", 15353 + "x-go-name": "Exclusive", 15354 + "example": false 15355 + }, 15351 15356 "name": { 15352 15357 "type": "string", 15353 15358 "x-go-name": "Name" ··· 16283 16288 "properties": { 16284 16289 "color": { 16285 16290 "type": "string", 16286 - "x-go-name": "Color" 16291 + "x-go-name": "Color", 16292 + "example": "#00aabb" 16287 16293 }, 16288 16294 "description": { 16289 16295 "type": "string", 16290 16296 "x-go-name": "Description" 16297 + }, 16298 + "exclusive": { 16299 + "type": "boolean", 16300 + "x-go-name": "Exclusive", 16301 + "example": false 16291 16302 }, 16292 16303 "name": { 16293 16304 "type": "string", ··· 17614 17625 "description": { 17615 17626 "type": "string", 17616 17627 "x-go-name": "Description" 17628 + }, 17629 + "exclusive": { 17630 + "type": "boolean", 17631 + "x-go-name": "Exclusive", 17632 + "example": false 17617 17633 }, 17618 17634 "id": { 17619 17635 "type": "integer",
+7 -7
tests/integration/api_issue_test.go
··· 174 174 token := getUserToken(t, "user2") 175 175 176 176 // as this API was used in the frontend, it uses UI page size 177 - expectedIssueCount := 15 // from the fixtures 177 + expectedIssueCount := 16 // from the fixtures 178 178 if expectedIssueCount > setting.UI.IssuePagingNum { 179 179 expectedIssueCount = setting.UI.IssuePagingNum 180 180 } ··· 198 198 req = NewRequest(t, "GET", link.String()) 199 199 resp = MakeRequest(t, req, http.StatusOK) 200 200 DecodeJSON(t, resp, &apiIssues) 201 - assert.Len(t, apiIssues, 8) 201 + assert.Len(t, apiIssues, 9) 202 202 query.Del("since") 203 203 query.Del("before") 204 204 ··· 214 214 req = NewRequest(t, "GET", link.String()) 215 215 resp = MakeRequest(t, req, http.StatusOK) 216 216 DecodeJSON(t, resp, &apiIssues) 217 - assert.EqualValues(t, "17", resp.Header().Get("X-Total-Count")) 218 - assert.Len(t, apiIssues, 17) 217 + assert.EqualValues(t, "18", resp.Header().Get("X-Total-Count")) 218 + assert.Len(t, apiIssues, 18) 219 219 220 220 query.Add("limit", "10") 221 221 link.RawQuery = query.Encode() 222 222 req = NewRequest(t, "GET", link.String()) 223 223 resp = MakeRequest(t, req, http.StatusOK) 224 224 DecodeJSON(t, resp, &apiIssues) 225 - assert.EqualValues(t, "17", resp.Header().Get("X-Total-Count")) 225 + assert.EqualValues(t, "18", resp.Header().Get("X-Total-Count")) 226 226 assert.Len(t, apiIssues, 10) 227 227 228 228 query = url.Values{"assigned": {"true"}, "state": {"all"}, "token": {token}} ··· 251 251 req = NewRequest(t, "GET", link.String()) 252 252 resp = MakeRequest(t, req, http.StatusOK) 253 253 DecodeJSON(t, resp, &apiIssues) 254 - assert.Len(t, apiIssues, 6) 254 + assert.Len(t, apiIssues, 7) 255 255 256 256 query = url.Values{"owner": {"user3"}, "token": {token}} // organization 257 257 link.RawQuery = query.Encode() ··· 272 272 defer tests.PrepareTestEnv(t)() 273 273 274 274 // as this API was used in the frontend, it uses UI page size 275 - expectedIssueCount := 15 // from the fixtures 275 + expectedIssueCount := 16 // from the fixtures 276 276 if expectedIssueCount > setting.UI.IssuePagingNum { 277 277 expectedIssueCount = setting.UI.IssuePagingNum 278 278 }
+1 -1
tests/integration/api_nodeinfo_test.go
··· 34 34 assert.True(t, nodeinfo.OpenRegistrations) 35 35 assert.Equal(t, "gitea", nodeinfo.Software.Name) 36 36 assert.Equal(t, 24, nodeinfo.Usage.Users.Total) 37 - assert.Equal(t, 17, nodeinfo.Usage.LocalPosts) 37 + assert.Equal(t, 18, nodeinfo.Usage.LocalPosts) 38 38 assert.Equal(t, 2, nodeinfo.Usage.LocalComments) 39 39 }) 40 40 }
+7 -7
tests/integration/issue_test.go
··· 356 356 357 357 session := loginUser(t, "user2") 358 358 359 - expectedIssueCount := 15 // from the fixtures 359 + expectedIssueCount := 16 // from the fixtures 360 360 if expectedIssueCount > setting.UI.IssuePagingNum { 361 361 expectedIssueCount = setting.UI.IssuePagingNum 362 362 } ··· 377 377 req = NewRequest(t, "GET", link.String()) 378 378 resp = session.MakeRequest(t, req, http.StatusOK) 379 379 DecodeJSON(t, resp, &apiIssues) 380 - assert.Len(t, apiIssues, 8) 380 + assert.Len(t, apiIssues, 9) 381 381 query.Del("since") 382 382 query.Del("before") 383 383 ··· 393 393 req = NewRequest(t, "GET", link.String()) 394 394 resp = session.MakeRequest(t, req, http.StatusOK) 395 395 DecodeJSON(t, resp, &apiIssues) 396 - assert.EqualValues(t, "17", resp.Header().Get("X-Total-Count")) 397 - assert.Len(t, apiIssues, 17) 396 + assert.EqualValues(t, "18", resp.Header().Get("X-Total-Count")) 397 + assert.Len(t, apiIssues, 18) 398 398 399 399 query.Add("limit", "5") 400 400 link.RawQuery = query.Encode() 401 401 req = NewRequest(t, "GET", link.String()) 402 402 resp = session.MakeRequest(t, req, http.StatusOK) 403 403 DecodeJSON(t, resp, &apiIssues) 404 - assert.EqualValues(t, "17", resp.Header().Get("X-Total-Count")) 404 + assert.EqualValues(t, "18", resp.Header().Get("X-Total-Count")) 405 405 assert.Len(t, apiIssues, 5) 406 406 407 407 query = url.Values{"assigned": {"true"}, "state": {"all"}} ··· 430 430 req = NewRequest(t, "GET", link.String()) 431 431 resp = session.MakeRequest(t, req, http.StatusOK) 432 432 DecodeJSON(t, resp, &apiIssues) 433 - assert.Len(t, apiIssues, 6) 433 + assert.Len(t, apiIssues, 7) 434 434 435 435 query = url.Values{"owner": {"user3"}} // organization 436 436 link.RawQuery = query.Encode() ··· 450 450 func TestSearchIssuesWithLabels(t *testing.T) { 451 451 defer tests.PrepareTestEnv(t)() 452 452 453 - expectedIssueCount := 15 // from the fixtures 453 + expectedIssueCount := 16 // from the fixtures 454 454 if expectedIssueCount > setting.UI.IssuePagingNum { 455 455 expectedIssueCount = setting.UI.IssuePagingNum 456 456 }
+4 -19
web_src/js/components/ContextPopup.vue
··· 26 26 <script> 27 27 import $ from 'jquery'; 28 28 import {SvgIcon} from '../svg.js'; 29 + import {useLightTextOnBackground} from '../utils.js'; 29 30 30 31 const {appSubUrl, i18n} = window.config; 31 - 32 - // NOTE: see models/issue_label.go for similar implementation 33 - const srgbToLinear = (color) => { 34 - color /= 255; 35 - if (color <= 0.04045) { 36 - return color / 12.92; 37 - } 38 - return ((color + 0.055) / 1.055) ** 2.4; 39 - }; 40 - const luminance = (colorString) => { 41 - const r = srgbToLinear(parseInt(colorString.substring(0, 2), 16)); 42 - const g = srgbToLinear(parseInt(colorString.substring(2, 4), 16)); 43 - const b = srgbToLinear(parseInt(colorString.substring(4, 6), 16)); 44 - return 0.2126 * r + 0.7152 * g + 0.0722 * b; 45 - }; 46 - const luminanceThreshold = 0.179; 47 32 48 33 export default { 49 34 components: {SvgIcon}, ··· 92 77 labels() { 93 78 return this.issue.labels.map((label) => { 94 79 let textColor; 95 - if (luminance(label.color) < luminanceThreshold) { 96 - textColor = '#ffffff'; 80 + if (useLightTextOnBackground(label.color)) { 81 + textColor = '#eeeeee'; 97 82 } else { 98 - textColor = '#000000'; 83 + textColor = '#111111'; 99 84 } 100 85 return {name: label.name, color: `#${label.color}`, textColor}; 101 86 });
+4 -1
web_src/js/features/common-issue.js
··· 32 32 syncIssueSelectionState(); 33 33 }); 34 34 35 - $('.issue-action').on('click', async function () { 35 + $('.issue-action').on('click', async function (e) { 36 36 let action = this.getAttribute('data-action'); 37 37 let elementId = this.getAttribute('data-element-id'); 38 38 const url = this.getAttribute('data-url'); ··· 42 42 if (elementId === '0' && url.slice(-9) === '/assignee') { 43 43 elementId = ''; 44 44 action = 'clear'; 45 + } 46 + if (action === 'toggle' && e.altKey) { 47 + action = 'toggle-alt'; 45 48 } 46 49 updateIssuesMeta( 47 50 url,
+60 -9
web_src/js/features/comp/LabelEdit.js
··· 1 1 import $ from 'jquery'; 2 2 import {initCompColorPicker} from './ColorPicker.js'; 3 3 4 + function isExclusiveScopeName(name) { 5 + return /.*[^/]\/[^/].*/.test(name); 6 + } 7 + 8 + function updateExclusiveLabelEdit(form) { 9 + const nameInput = $(`${form} .label-name-input`); 10 + const exclusiveField = $(`${form} .label-exclusive-input-field`); 11 + const exclusiveCheckbox = $(`${form} .label-exclusive-input`); 12 + const exclusiveWarning = $(`${form} .label-exclusive-warning`); 13 + 14 + if (isExclusiveScopeName(nameInput.val())) { 15 + exclusiveField.removeClass('muted'); 16 + if (exclusiveCheckbox.prop('checked') && exclusiveCheckbox.data('exclusive-warn')) { 17 + exclusiveWarning.removeClass('gt-hidden'); 18 + } else { 19 + exclusiveWarning.addClass('gt-hidden'); 20 + } 21 + } else { 22 + exclusiveField.addClass('muted'); 23 + exclusiveWarning.addClass('gt-hidden'); 24 + } 25 + } 26 + 4 27 export function initCompLabelEdit(selector) { 5 28 if (!$(selector).length) return; 29 + initCompColorPicker(); 30 + 6 31 // Create label 7 - const $newLabelPanel = $('.new-label.segment'); 8 32 $('.new-label.button').on('click', () => { 9 - $newLabelPanel.show(); 10 - }); 11 - $('.new-label.segment .cancel').on('click', () => { 12 - $newLabelPanel.hide(); 33 + updateExclusiveLabelEdit('.new-label'); 34 + $('.new-label.modal').modal({ 35 + onApprove() { 36 + $('.new-label.form').trigger('submit'); 37 + } 38 + }).modal('show'); 39 + return false; 13 40 }); 14 41 15 - initCompColorPicker(); 16 - 42 + // Edit label 17 43 $('.edit-label-button').on('click', function () { 18 44 $('.edit-label .color-picker').minicolors('value', $(this).data('color')); 19 45 $('#label-modal-id').val($(this).data('id')); 20 - $('.edit-label .new-label-input').val($(this).data('title')); 21 - $('.edit-label .new-label-desc-input').val($(this).data('description')); 46 + 47 + const nameInput = $('.edit-label .label-name-input'); 48 + nameInput.val($(this).data('title')); 49 + 50 + const exclusiveCheckbox = $('.edit-label .label-exclusive-input'); 51 + exclusiveCheckbox.prop('checked', this.hasAttribute('data-exclusive')); 52 + // Warn when label was previously not exclusive and used in issues 53 + exclusiveCheckbox.data('exclusive-warn', 54 + $(this).data('num-issues') > 0 && 55 + (!this.hasAttribute('data-exclusive') || !isExclusiveScopeName(nameInput.val()))); 56 + updateExclusiveLabelEdit('.edit-label'); 57 + 58 + $('.edit-label .label-desc-input').val($(this).data('description')); 22 59 $('.edit-label .color-picker').val($(this).data('color')); 23 60 $('.edit-label .minicolors-swatch-color').css('background-color', $(this).data('color')); 61 + 24 62 $('.edit-label.modal').modal({ 25 63 onApprove() { 26 64 $('.edit-label.form').trigger('submit'); 27 65 } 28 66 }).modal('show'); 29 67 return false; 68 + }); 69 + 70 + $('.new-label .label-name-input').on('input', () => { 71 + updateExclusiveLabelEdit('.new-label'); 72 + }); 73 + $('.new-label .label-exclusive-input').on('change', () => { 74 + updateExclusiveLabelEdit('.new-label'); 75 + }); 76 + $('.edit-label .label-name-input').on('input', () => { 77 + updateExclusiveLabelEdit('.edit-label'); 78 + }); 79 + $('.edit-label .label-exclusive-input').on('change', () => { 80 + updateExclusiveLabelEdit('.edit-label'); 30 81 }); 31 82 }
+49 -25
web_src/js/features/repo-legacy.js
··· 110 110 } 111 111 112 112 hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var 113 - if ($(this).hasClass('checked')) { 114 - $(this).removeClass('checked'); 115 - $(this).find('.octicon-check').addClass('invisible'); 116 - if (hasUpdateAction) { 117 - if (!($(this).data('id') in items)) { 118 - items[$(this).data('id')] = { 119 - 'update-url': $listMenu.data('update-url'), 120 - action: 'detach', 121 - 'issue-id': $listMenu.data('issue-id'), 122 - }; 123 - } else { 124 - delete items[$(this).data('id')]; 113 + 114 + const clickedItem = $(this); 115 + const scope = $(this).attr('data-scope'); 116 + const canRemoveScope = e.altKey; 117 + 118 + $(this).parent().find('.item').each(function () { 119 + if (scope) { 120 + // Enable only clicked item for scoped labels 121 + if ($(this).attr('data-scope') !== scope) { 122 + return true; 123 + } 124 + if ($(this).is(clickedItem)) { 125 + if (!canRemoveScope && $(this).hasClass('checked')) { 126 + return true; 127 + } 128 + } else if (!$(this).hasClass('checked')) { 129 + return true; 125 130 } 131 + } else if (!$(this).is(clickedItem)) { 132 + // Toggle for other labels 133 + return true; 126 134 } 127 - } else { 128 - $(this).addClass('checked'); 129 - $(this).find('.octicon-check').removeClass('invisible'); 130 - if (hasUpdateAction) { 131 - if (!($(this).data('id') in items)) { 132 - items[$(this).data('id')] = { 133 - 'update-url': $listMenu.data('update-url'), 134 - action: 'attach', 135 - 'issue-id': $listMenu.data('issue-id'), 136 - }; 137 - } else { 138 - delete items[$(this).data('id')]; 135 + 136 + if ($(this).hasClass('checked')) { 137 + $(this).removeClass('checked'); 138 + $(this).find('.octicon-check').addClass('invisible'); 139 + if (hasUpdateAction) { 140 + if (!($(this).data('id') in items)) { 141 + items[$(this).data('id')] = { 142 + 'update-url': $listMenu.data('update-url'), 143 + action: 'detach', 144 + 'issue-id': $listMenu.data('issue-id'), 145 + }; 146 + } else { 147 + delete items[$(this).data('id')]; 148 + } 149 + } 150 + } else { 151 + $(this).addClass('checked'); 152 + $(this).find('.octicon-check').removeClass('invisible'); 153 + if (hasUpdateAction) { 154 + if (!($(this).data('id') in items)) { 155 + items[$(this).data('id')] = { 156 + 'update-url': $listMenu.data('update-url'), 157 + action: 'attach', 158 + 'issue-id': $listMenu.data('issue-id'), 159 + }; 160 + } else { 161 + delete items[$(this).data('id')]; 162 + } 139 163 } 140 164 } 141 - } 165 + }); 142 166 143 167 // TODO: Which thing should be done for choosing review requests 144 168 // to make chosen items be shown on time here?
+4 -16
web_src/js/features/repo-projects.js
··· 1 1 import $ from 'jquery'; 2 + import {useLightTextOnBackground} from '../utils.js'; 2 3 3 4 const {csrfToken} = window.config; 4 5 ··· 183 184 } 184 185 185 186 function setLabelColor(label, color) { 186 - const red = getRelativeColor(parseInt(color.slice(1, 3), 16)); 187 - const green = getRelativeColor(parseInt(color.slice(3, 5), 16)); 188 - const blue = getRelativeColor(parseInt(color.slice(5, 7), 16)); 189 - const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; 190 - 191 - if (luminance > 0.179) { 187 + if (useLightTextOnBackground(color)) { 188 + label.removeClass('dark-label').addClass('light-label'); 189 + } else { 192 190 label.removeClass('light-label').addClass('dark-label'); 193 - } else { 194 - label.removeClass('dark-label').addClass('light-label'); 195 191 } 196 - } 197 - 198 - /** 199 - * Inspired by W3C recommendation https://www.w3.org/TR/WCAG20/#relativeluminancedef 200 - */ 201 - function getRelativeColor(color) { 202 - color /= 255; 203 - return color <= 0.03928 ? color / 12.92 : ((color + 0.055) / 1.055) ** 2.4; 204 192 } 205 193 206 194 function rgbToHex(rgb) {
+15
web_src/js/utils.js
··· 146 146 } 147 147 return `${window.location.origin}${url}`; 148 148 } 149 + 150 + // determine if light or dark text color should be used on a given background color 151 + // NOTE: see models/issue_label.go for similar implementation 152 + export function useLightTextOnBackground(backgroundColor) { 153 + if (backgroundColor[0] === '#') { 154 + backgroundColor = backgroundColor.substring(1); 155 + } 156 + // Perceived brightness from: https://www.w3.org/TR/AERT/#color-contrast 157 + // In the future WCAG 3 APCA may be a better solution. 158 + const r = parseInt(backgroundColor.substring(0, 2), 16); 159 + const g = parseInt(backgroundColor.substring(2, 4), 16); 160 + const b = parseInt(backgroundColor.substring(4, 6), 16); 161 + const brightness = (0.299 * r + 0.587 * g + 0.114 * b) / 255; 162 + return brightness < 0.35; 163 + }
+6 -6
web_src/less/_base.less
··· 1116 1116 1117 1117 .ui.modal > .content { 1118 1118 background: var(--color-body); 1119 + text-align: left !important; 1119 1120 } 1120 1121 1121 1122 .ui.modal > .actions { ··· 1362 1363 // It's a problem from Formatic UI, and this rule overrides it. 1363 1364 .ui.form .field.field input:-webkit-autofill { 1364 1365 -webkit-text-fill-color: var(--color-black) !important; 1366 + } 1367 + 1368 + .ui.form .field.muted { 1369 + opacity: var(--opacity-disabled); 1365 1370 } 1366 1371 1367 1372 .ui.loading.loading.input > i.icon svg { ··· 2568 2573 border-top: none; 2569 2574 2570 2575 a { 2571 - font-size: 15px; 2572 - padding-top: 5px; 2576 + font-size: 12px; 2573 2577 padding-right: 10px; 2574 2578 color: var(--color-text-light); 2575 2579 ··· 2580 2584 &.open-issues { 2581 2585 margin-right: 30px; 2582 2586 } 2583 - } 2584 - 2585 - .ui.label { 2586 - font-size: 1em; 2587 2587 } 2588 2588 } 2589 2589
+37 -17
web_src/less/_repository.less
··· 92 92 .metas { 93 93 .menu { 94 94 overflow-x: auto; 95 - max-height: 300px; 95 + max-height: 500px; 96 96 } 97 97 98 98 .ui.list { ··· 155 155 } 156 156 157 157 .filter.menu { 158 - .label.color { 159 - border-radius: 3px; 160 - margin-left: 15px; 161 - padding: 0 8px; 162 - } 163 - 164 158 &.labels { 165 159 .label-filter .menu .info { 166 160 display: inline-block; ··· 181 175 } 182 176 183 177 .menu { 184 - max-height: 300px; 178 + max-height: 500px; 185 179 overflow-x: auto; 186 180 right: 0 !important; 187 181 left: auto !important; ··· 190 184 191 185 .select-label { 192 186 .desc { 193 - padding-left: 16px; 187 + padding-left: 23px; 194 188 } 195 189 } 196 190 ··· 607 601 min-width: 220px; 608 602 609 603 .filter.menu { 610 - max-height: 300px; 604 + max-height: 500px; 611 605 overflow-x: auto; 612 606 } 613 607 } ··· 2774 2768 } 2775 2769 2776 2770 .edit-label.modal, 2777 - .new-label.segment { 2771 + .new-label.modal { 2778 2772 .form { 2779 2773 .column { 2780 2774 padding-right: 0; ··· 2786 2780 } 2787 2781 2788 2782 .color.picker.column { 2789 - width: auto; 2790 - 2791 - .color-picker { 2792 - height: 35px; 2793 - width: auto; 2794 - padding-left: 30px; 2783 + display: flex; 2784 + .minicolors { 2785 + flex: 1; 2795 2786 } 2796 2787 } 2797 2788 ··· 2870 2861 margin: 2px 0; 2871 2862 display: inline-block !important; 2872 2863 line-height: 1.3em; // there is a `font-size: 1.25em` for inside emoji, so here the line-height needs to be larger slightly 2864 + } 2865 + 2866 + // Scoped labels with different colors on left and right, and slanted divider in the middle 2867 + .scope-parent { 2868 + background: none !important; 2869 + padding: 0 !important; 2870 + } 2871 + 2872 + .ui.label.scope-left { 2873 + border-bottom-right-radius: 0; 2874 + border-top-right-radius: 0; 2875 + padding-right: 0; 2876 + margin-right: 0; 2877 + } 2878 + 2879 + .ui.label.scope-middle { 2880 + width: 12px; 2881 + border-radius: 0; 2882 + padding-left: 0; 2883 + padding-right: 0; 2884 + margin-left: 0; 2885 + margin-right: 0; 2886 + } 2887 + 2888 + .ui.label.scope-right { 2889 + border-bottom-left-radius: 0; 2890 + border-top-left-radius: 0; 2891 + padding-left: 0; 2892 + margin-left: 0; 2873 2893 } 2874 2894 2875 2895 .repo-button-row {