summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--models/fixtures/issue.yml17
-rw-r--r--models/fixtures/label.yml45
-rw-r--r--models/fixtures/repository.yml12
-rw-r--r--models/fixtures/user.yml2
-rw-r--r--models/issues/issue.go27
-rw-r--r--models/issues/issue_test.go19
-rw-r--r--models/issues/label.go106
-rw-r--r--models/issues/label_test.go45
-rw-r--r--models/migrations/fixtures/Test_DeleteOrphanedIssueLabels/label.yml5
-rw-r--r--models/migrations/migrations.go2
-rw-r--r--models/migrations/v1_19/v244.go16
-rw-r--r--modules/migration/label.go1
-rw-r--r--modules/structs/issue_label.go9
-rw-r--r--modules/templates/helper.go70
-rw-r--r--options/locale/locale_en-US.ini9
-rw-r--r--routers/api/v1/org/label.go4
-rw-r--r--routers/api/v1/repo/label.go4
-rw-r--r--routers/web/org/org_labels.go2
-rw-r--r--routers/web/repo/issue.go18
-rw-r--r--routers/web/repo/issue_label.go18
-rw-r--r--services/convert/issue.go1
-rw-r--r--services/forms/repo_form.go1
-rw-r--r--services/migrations/main_test.go1
-rw-r--r--services/repository/template.go1
-rw-r--r--templates/projects/view.tmpl2
-rw-r--r--templates/repo/issue/labels/edit_delete_label.tmpl42
-rw-r--r--templates/repo/issue/labels/label.tmpl6
-rw-r--r--templates/repo/issue/labels/label_list.tmpl34
-rw-r--r--templates/repo/issue/labels/label_new.tmpl56
-rw-r--r--templates/repo/issue/list.tmpl16
-rw-r--r--templates/repo/issue/milestone_issues.tmpl4
-rw-r--r--templates/repo/issue/new_form.tmpl16
-rw-r--r--templates/repo/issue/view_content/sidebar.tmpl16
-rw-r--r--templates/repo/projects/view.tmpl2
-rw-r--r--templates/shared/issuelist.tmpl2
-rw-r--r--templates/swagger/v1_json.tmpl18
-rw-r--r--tests/integration/api_issue_test.go14
-rw-r--r--tests/integration/api_nodeinfo_test.go2
-rw-r--r--tests/integration/issue_test.go14
-rw-r--r--web_src/js/components/ContextPopup.vue23
-rw-r--r--web_src/js/features/common-issue.js5
-rw-r--r--web_src/js/features/comp/LabelEdit.js69
-rw-r--r--web_src/js/features/repo-legacy.js74
-rw-r--r--web_src/js/features/repo-projects.js20
-rw-r--r--web_src/js/utils.js15
-rw-r--r--web_src/less/_base.less12
-rw-r--r--web_src/less/_repository.less54
47 files changed, 709 insertions, 242 deletions
diff --git a/models/fixtures/issue.yml b/models/fixtures/issue.yml
index 4dea8add13..174345ff5a 100644
--- a/models/fixtures/issue.yml
+++ b/models/fixtures/issue.yml
@@ -287,3 +287,20 @@
created_unix: 1602935696
updated_unix: 1602935696
is_locked: false
+
+-
+ id: 18
+ repo_id: 55
+ index: 1
+ poster_id: 2
+ original_author_id: 0
+ name: issue for scoped labels
+ content: content
+ milestone_id: 0
+ priority: 0
+ is_closed: false
+ is_pull: false
+ num_comments: 0
+ created_unix: 946684830
+ updated_unix: 978307200
+ is_locked: false
diff --git a/models/fixtures/label.yml b/models/fixtures/label.yml
index 57bf804457..ab4d5ef944 100644
--- a/models/fixtures/label.yml
+++ b/models/fixtures/label.yml
@@ -4,6 +4,7 @@
org_id: 0
name: label1
color: '#abcdef'
+ exclusive: false
num_issues: 2
num_closed_issues: 0
@@ -13,6 +14,7 @@
org_id: 0
name: label2
color: '#000000'
+ exclusive: false
num_issues: 1
num_closed_issues: 1
@@ -22,6 +24,7 @@
org_id: 3
name: orglabel3
color: '#abcdef'
+ exclusive: false
num_issues: 0
num_closed_issues: 0
@@ -31,6 +34,7 @@
org_id: 3
name: orglabel4
color: '#000000'
+ exclusive: false
num_issues: 1
num_closed_issues: 0
@@ -40,5 +44,46 @@
org_id: 0
name: pull-test-label
color: '#000000'
+ exclusive: false
+ num_issues: 0
+ num_closed_issues: 0
+
+-
+ id: 6
+ repo_id: 55
+ org_id: 0
+ name: unscoped_label
+ color: '#000000'
+ exclusive: false
+ num_issues: 0
+ num_closed_issues: 0
+
+-
+ id: 7
+ repo_id: 55
+ org_id: 0
+ name: scope/label1
+ color: '#000000'
+ exclusive: true
+ num_issues: 0
+ num_closed_issues: 0
+
+-
+ id: 8
+ repo_id: 55
+ org_id: 0
+ name: scope/label2
+ color: '#000000'
+ exclusive: true
+ num_issues: 0
+ num_closed_issues: 0
+
+-
+ id: 9
+ repo_id: 55
+ org_id: 0
+ name: scope/subscope/label2
+ color: '#000000'
+ exclusive: true
num_issues: 0
num_closed_issues: 0
diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml
index ef3cfbbbec..58f9b919ac 100644
--- a/models/fixtures/repository.yml
+++ b/models/fixtures/repository.yml
@@ -1622,3 +1622,15 @@
is_archived: false
is_private: true
status: 0
+
+-
+ id: 55
+ owner_id: 2
+ owner_name: user2
+ lower_name: scoped_label
+ name: scoped_label
+ is_empty: false
+ is_archived: false
+ is_private: true
+ num_issues: 1
+ status: 0
diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml
index 63a5e0f890..0a1d85b48b 100644
--- a/models/fixtures/user.yml
+++ b/models/fixtures/user.yml
@@ -66,7 +66,7 @@
num_followers: 2
num_following: 1
num_stars: 2
- num_repos: 10
+ num_repos: 11
num_teams: 0
num_members: 0
visibility: 0
diff --git a/models/issues/issue.go b/models/issues/issue.go
index b1c7fdbf7e..9d7dea0177 100644
--- a/models/issues/issue.go
+++ b/models/issues/issue.go
@@ -538,6 +538,31 @@ func (ts labelSorter) Swap(i, j int) {
[]*Label(ts)[i], []*Label(ts)[j] = []*Label(ts)[j], []*Label(ts)[i]
}
+// Ensure only one label of a given scope exists, with labels at the end of the
+// array getting preference over earlier ones.
+func RemoveDuplicateExclusiveLabels(labels []*Label) []*Label {
+ validLabels := make([]*Label, 0, len(labels))
+
+ for i, label := range labels {
+ scope := label.ExclusiveScope()
+ if scope != "" {
+ foundOther := false
+ for _, otherLabel := range labels[i+1:] {
+ if otherLabel.ExclusiveScope() == scope {
+ foundOther = true
+ break
+ }
+ }
+ if foundOther {
+ continue
+ }
+ }
+ validLabels = append(validLabels, label)
+ }
+
+ return validLabels
+}
+
// ReplaceIssueLabels removes all current labels and add new labels to the issue.
// Triggers appropriate WebHooks, if any.
func ReplaceIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) {
@@ -555,6 +580,8 @@ func ReplaceIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (e
return err
}
+ labels = RemoveDuplicateExclusiveLabels(labels)
+
sort.Sort(labelSorter(labels))
sort.Sort(labelSorter(issue.Labels))
diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go
index de1da19ab9..3a83d8d2b7 100644
--- a/models/issues/issue_test.go
+++ b/models/issues/issue_test.go
@@ -25,7 +25,7 @@ import (
func TestIssue_ReplaceLabels(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
- testSuccess := func(issueID int64, labelIDs []int64) {
+ testSuccess := func(issueID int64, labelIDs, expectedLabelIDs []int64) {
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueID})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
@@ -35,15 +35,20 @@ func TestIssue_ReplaceLabels(t *testing.T) {
labels[i] = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: labelID, RepoID: repo.ID})
}
assert.NoError(t, issues_model.ReplaceIssueLabels(issue, labels, doer))
- unittest.AssertCount(t, &issues_model.IssueLabel{IssueID: issueID}, len(labelIDs))
- for _, labelID := range labelIDs {
+ unittest.AssertCount(t, &issues_model.IssueLabel{IssueID: issueID}, len(expectedLabelIDs))
+ for _, labelID := range expectedLabelIDs {
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issueID, LabelID: labelID})
}
}
- testSuccess(1, []int64{2})
- testSuccess(1, []int64{1, 2})
- testSuccess(1, []int64{})
+ testSuccess(1, []int64{2}, []int64{2})
+ testSuccess(1, []int64{1, 2}, []int64{1, 2})
+ testSuccess(1, []int64{}, []int64{})
+
+ // mutually exclusive scoped labels 7 and 8
+ testSuccess(18, []int64{6, 7}, []int64{6, 7})
+ testSuccess(18, []int64{7, 8}, []int64{8})
+ testSuccess(18, []int64{6, 8, 7}, []int64{6, 7})
}
func Test_GetIssueIDsByRepoID(t *testing.T) {
@@ -523,5 +528,5 @@ func TestCountIssues(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
count, err := issues_model.CountIssues(db.DefaultContext, &issues_model.IssuesOptions{})
assert.NoError(t, err)
- assert.EqualValues(t, 17, count)
+ assert.EqualValues(t, 18, count)
}
diff --git a/models/issues/label.go b/models/issues/label.go
index dbb7a139ef..0dd12fb5c9 100644
--- a/models/issues/label.go
+++ b/models/issues/label.go
@@ -7,8 +7,6 @@ package issues
import (
"context"
"fmt"
- "html/template"
- "math"
"regexp"
"strconv"
"strings"
@@ -89,6 +87,7 @@ type Label struct {
RepoID int64 `xorm:"INDEX"`
OrgID int64 `xorm:"INDEX"`
Name string
+ Exclusive bool
Description string
Color string `xorm:"VARCHAR(7)"`
NumIssues int
@@ -128,18 +127,22 @@ func (label *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64)
}
// LoadSelectedLabelsAfterClick calculates the set of selected labels when a label is clicked
-func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64) {
+func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64, currentSelectedExclusiveScopes []string) {
var labelQuerySlice []string
labelSelected := false
labelID := strconv.FormatInt(label.ID, 10)
- for _, s := range currentSelectedLabels {
+ labelScope := label.ExclusiveScope()
+ for i, s := range currentSelectedLabels {
if s == label.ID {
labelSelected = true
} else if -s == label.ID {
labelSelected = true
label.IsExcluded = true
} else if s != 0 {
- labelQuerySlice = append(labelQuerySlice, strconv.FormatInt(s, 10))
+ // Exclude other labels in the same scope from selection
+ if s < 0 || labelScope == "" || labelScope != currentSelectedExclusiveScopes[i] {
+ labelQuerySlice = append(labelQuerySlice, strconv.FormatInt(s, 10))
+ }
}
}
if !labelSelected {
@@ -159,49 +162,43 @@ func (label *Label) BelongsToRepo() bool {
return label.RepoID > 0
}
-// SrgbToLinear converts a component of an sRGB color to its linear intensity
-// See: https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation_(sRGB_to_CIE_XYZ)
-func SrgbToLinear(color uint8) float64 {
- flt := float64(color) / 255
- if flt <= 0.04045 {
- return flt / 12.92
+// Get color as RGB values in 0..255 range
+func (label *Label) ColorRGB() (float64, float64, float64, error) {
+ color, err := strconv.ParseUint(label.Color[1:], 16, 64)
+ if err != nil {
+ return 0, 0, 0, err
}
- return math.Pow((flt+0.055)/1.055, 2.4)
-}
-
-// Luminance returns the luminance of an sRGB color
-func Luminance(color uint32) float64 {
- r := SrgbToLinear(uint8(0xFF & (color >> 16)))
- g := SrgbToLinear(uint8(0xFF & (color >> 8)))
- b := SrgbToLinear(uint8(0xFF & color))
- // luminance ratios for sRGB
- return 0.2126*r + 0.7152*g + 0.0722*b
+ r := float64(uint8(0xFF & (uint32(color) >> 16)))
+ g := float64(uint8(0xFF & (uint32(color) >> 8)))
+ b := float64(uint8(0xFF & uint32(color)))
+ return r, g, b, nil
}
-// LuminanceThreshold is the luminance at which white and black appear to have the same contrast
-// i.e. x such that 1.05 / (x + 0.05) = (x + 0.05) / 0.05
-// i.e. math.Sqrt(1.05*0.05) - 0.05
-const LuminanceThreshold float64 = 0.179
-
-// ForegroundColor calculates the text color for labels based
-// on their background color.
-func (label *Label) ForegroundColor() template.CSS {
+// Determine if label text should be light or dark to be readable on background color
+func (label *Label) UseLightTextColor() bool {
if strings.HasPrefix(label.Color, "#") {
- if color, err := strconv.ParseUint(label.Color[1:], 16, 64); err == nil {
- // NOTE: see web_src/js/components/ContextPopup.vue for similar implementation
- luminance := Luminance(uint32(color))
-
- // prefer white or black based upon contrast
- if luminance < LuminanceThreshold {
- return template.CSS("#fff")
- }
- return template.CSS("#000")
+ if r, g, b, err := label.ColorRGB(); err == nil {
+ // Perceived brightness from: https://www.w3.org/TR/AERT/#color-contrast
+ // In the future WCAG 3 APCA may be a better solution
+ brightness := (0.299*r + 0.587*g + 0.114*b) / 255
+ return brightness < 0.35
}
}
- // default to black
- return template.CSS("#000")
+ return false
+}
+
+// Return scope substring of label name, or empty string if none exists
+func (label *Label) ExclusiveScope() string {
+ if !label.Exclusive {
+ return ""
+ }
+ lastIndex := strings.LastIndex(label.Name, "/")
+ if lastIndex == -1 || lastIndex == 0 || lastIndex == len(label.Name)-1 {
+ return ""
+ }
+ return label.Name[:lastIndex]
}
// NewLabel creates a new label
@@ -253,7 +250,7 @@ func UpdateLabel(l *Label) error {
if !LabelColorPattern.MatchString(l.Color) {
return fmt.Errorf("bad color code: %s", l.Color)
}
- return updateLabelCols(db.DefaultContext, l, "name", "description", "color")
+ return updateLabelCols(db.DefaultContext, l, "name", "description", "color", "exclusive")
}
// DeleteLabel delete a label
@@ -620,6 +617,29 @@ func newIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_m
return updateLabelCols(ctx, label, "num_issues", "num_closed_issue")
}
+// Remove all issue labels in the given exclusive scope
+func RemoveDuplicateExclusiveIssueLabels(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) {
+ scope := label.ExclusiveScope()
+ if scope == "" {
+ return nil
+ }
+
+ var toRemove []*Label
+ for _, issueLabel := range issue.Labels {
+ if label.ID != issueLabel.ID && issueLabel.ExclusiveScope() == scope {
+ toRemove = append(toRemove, issueLabel)
+ }
+ }
+
+ for _, issueLabel := range toRemove {
+ if err = deleteIssueLabel(ctx, issue, issueLabel, doer); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
// NewIssueLabel creates a new issue-label relation.
func NewIssueLabel(issue *Issue, label *Label, doer *user_model.User) (err error) {
if HasIssueLabel(db.DefaultContext, issue.ID, label.ID) {
@@ -641,6 +661,10 @@ func NewIssueLabel(issue *Issue, label *Label, doer *user_model.User) (err error
return nil
}
+ if err = RemoveDuplicateExclusiveIssueLabels(ctx, issue, label, doer); err != nil {
+ return nil
+ }
+
if err = newIssueLabel(ctx, issue, label, doer); err != nil {
return err
}
diff --git a/models/issues/label_test.go b/models/issues/label_test.go
index 239e328d47..0e45e0db0b 100644
--- a/models/issues/label_test.go
+++ b/models/issues/label_test.go
@@ -4,7 +4,6 @@
package issues_test
import (
- "html/template"
"testing"
"code.gitea.io/gitea/models/db"
@@ -25,13 +24,22 @@ func TestLabel_CalOpenIssues(t *testing.T) {
assert.EqualValues(t, 2, label.NumOpenIssues)
}
-func TestLabel_ForegroundColor(t *testing.T) {
+func TestLabel_TextColor(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
- assert.Equal(t, template.CSS("#000"), label.ForegroundColor())
+ assert.False(t, label.UseLightTextColor())
label = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 2})
- assert.Equal(t, template.CSS("#fff"), label.ForegroundColor())
+ assert.True(t, label.UseLightTextColor())
+}
+
+func TestLabel_ExclusiveScope(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 7})
+ assert.Equal(t, "scope", label.ExclusiveScope())
+
+ label = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 9})
+ assert.Equal(t, "scope/subscope", label.ExclusiveScope())
}
func TestNewLabels(t *testing.T) {
@@ -266,6 +274,7 @@ func TestUpdateLabel(t *testing.T) {
Color: "#ffff00",
Name: "newLabelName",
Description: label.Description,
+ Exclusive: false,
}
label.Color = update.Color
label.Name = update.Name
@@ -323,6 +332,34 @@ func TestNewIssueLabel(t *testing.T) {
unittest.CheckConsistencyFor(t, &issues_model.Issue{}, &issues_model.Label{})
}
+func TestNewIssueExclusiveLabel(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 18})
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ otherLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 6})
+ exclusiveLabelA := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 7})
+ exclusiveLabelB := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 8})
+
+ // coexisting regular and exclusive label
+ assert.NoError(t, issues_model.NewIssueLabel(issue, otherLabel, doer))
+ assert.NoError(t, issues_model.NewIssueLabel(issue, exclusiveLabelA, doer))
+ unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID})
+ unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID})
+
+ // exclusive label replaces existing one
+ assert.NoError(t, issues_model.NewIssueLabel(issue, exclusiveLabelB, doer))
+ unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID})
+ unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelB.ID})
+ unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID})
+
+ // exclusive label replaces existing one again
+ assert.NoError(t, issues_model.NewIssueLabel(issue, exclusiveLabelA, doer))
+ unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID})
+ unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID})
+ unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelB.ID})
+}
+
func TestNewIssueLabels(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
label1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
diff --git a/models/migrations/fixtures/Test_DeleteOrphanedIssueLabels/label.yml b/models/migrations/fixtures/Test_DeleteOrphanedIssueLabels/label.yml
index d651c87d5b..085b7f0882 100644
--- a/models/migrations/fixtures/Test_DeleteOrphanedIssueLabels/label.yml
+++ b/models/migrations/fixtures/Test_DeleteOrphanedIssueLabels/label.yml
@@ -4,6 +4,7 @@
org_id: 0
name: label1
color: '#abcdef'
+ exclusive: false
num_issues: 2
num_closed_issues: 0
@@ -13,6 +14,7 @@
org_id: 0
name: label2
color: '#000000'
+ exclusive: false
num_issues: 1
num_closed_issues: 1
-
@@ -21,6 +23,7 @@
org_id: 3
name: orglabel3
color: '#abcdef'
+ exclusive: false
num_issues: 0
num_closed_issues: 0
@@ -30,6 +33,7 @@
org_id: 3
name: orglabel4
color: '#000000'
+ exclusive: false
num_issues: 1
num_closed_issues: 0
@@ -39,5 +43,6 @@
org_id: 0
name: pull-test-label
color: '#000000'
+ exclusive: false
num_issues: 0
num_closed_issues: 0
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index 73c44f008a..c7497becd1 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -459,6 +459,8 @@ var migrations = []Migration{
NewMigration("Add card_type column to project table", v1_19.AddCardTypeToProjectTable),
// v242 -> v243
NewMigration("Alter gpg_key_import content TEXT field to MEDIUMTEXT", v1_19.AlterPublicGPGKeyImportContentFieldToMediumText),
+ // v243 -> v244
+ NewMigration("Add exclusive label", v1_19.AddExclusiveLabel),
}
// GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v1_19/v244.go b/models/migrations/v1_19/v244.go
new file mode 100644
index 0000000000..55bbfafb2f
--- /dev/null
+++ b/models/migrations/v1_19/v244.go
@@ -0,0 +1,16 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_19 //nolint
+
+import (
+ "xorm.io/xorm"
+)
+
+func AddExclusiveLabel(x *xorm.Engine) error {
+ type Label struct {
+ Exclusive bool
+ }
+
+ return x.Sync(new(Label))
+}
diff --git a/modules/migration/label.go b/modules/migration/label.go
index 38f0eb10da..4927be3c0b 100644
--- a/modules/migration/label.go
+++ b/modules/migration/label.go
@@ -9,4 +9,5 @@ type Label struct {
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
+ Exclusive bool `json:"exclusive"`
}
diff --git a/modules/structs/issue_label.go b/modules/structs/issue_label.go
index 5c622797f4..5bb6cc3b84 100644
--- a/modules/structs/issue_label.go
+++ b/modules/structs/issue_label.go
@@ -9,6 +9,8 @@ package structs
type Label struct {
ID int64 `json:"id"`
Name string `json:"name"`
+ // example: false
+ Exclusive bool `json:"exclusive"`
// example: 00aabb
Color string `json:"color"`
Description string `json:"description"`
@@ -19,6 +21,8 @@ type Label struct {
type CreateLabelOption struct {
// required:true
Name string `json:"name" binding:"Required"`
+ // example: false
+ Exclusive bool `json:"exclusive"`
// required:true
// example: #00aabb
Color string `json:"color" binding:"Required"`
@@ -27,7 +31,10 @@ type CreateLabelOption struct {
// EditLabelOption options for editing a label
type EditLabelOption struct {
- Name *string `json:"name"`
+ Name *string `json:"name"`
+ // example: false
+ Exclusive *bool `json:"exclusive"`
+ // example: #00aabb
Color *string `json:"color"`
Description *string `json:"description"`
}
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index 8f8f565c1f..4ffd0a5dee 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -7,10 +7,12 @@ package templates
import (
"bytes"
"context"
+ "encoding/hex"
"errors"
"fmt"
"html"
"html/template"
+ "math"
"mime"
"net/url"
"path/filepath"
@@ -382,6 +384,9 @@ func NewFuncMap() []template.FuncMap {
// the table is NOT sorted with this header
return ""
},
+ "RenderLabel": func(label *issues_model.Label) template.HTML {
+ return template.HTML(RenderLabel(label))
+ },
"RenderLabels": func(labels []*issues_model.Label, repoLink string) template.HTML {
htmlCode := `<span class="labels-list">`
for _, label := range labels {
@@ -389,8 +394,8 @@ func NewFuncMap() []template.FuncMap {
if label == nil {
continue
}
- htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d' class='ui label' style='color: %s !important; background-color: %s !important' title='%s'>%s</a> ",
- repoLink, label.ID, label.ForegroundColor(), label.Color, html.EscapeString(label.Description), RenderEmoji(label.Name))
+ htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d'>%s</a> ",
+ repoLink, label.ID, RenderLabel(label))
}
htmlCode += "</span>"
return template.HTML(htmlCode)
@@ -801,6 +806,67 @@ func RenderIssueTitle(ctx context.Context, text, urlPrefix string, metas map[str
return template.HTML(renderedText)
}
+// RenderLabel renders a label
+func RenderLabel(label *issues_model.Label) string {
+ labelScope := label.ExclusiveScope()
+
+ textColor := "#111"
+ if label.UseLightTextColor() {
+ textColor = "#eee"
+ }
+
+ description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description))
+
+ if labelScope == "" {
+ // Regular label
+ return fmt.Sprintf("<div class='ui label' style='color: %s !important; background-color: %s !important' title='%s'>%s</div>",
+ textColor, label.Color, description, RenderEmoji(label.Name))
+ }
+
+ // Scoped label
+ scopeText := RenderEmoji(labelScope)
+ itemText := RenderEmoji(label.Name[len(labelScope)+1:])
+
+ itemColor := label.Color
+ scopeColor := label.Color
+ if r, g, b, err := label.ColorRGB(); err == nil {
+ // Make scope and item background colors slightly darker and lighter respectively.
+ // More contrast needed with higher luminance, empirically tweaked.
+ luminance := (0.299*r + 0.587*g + 0.114*b) / 255
+ contrast := 0.01 + luminance*0.06
+ // Ensure we add the same amount of contrast also near 0 and 1.
+ darken := contrast + math.Max(luminance+contrast-1.0, 0.0)
+ lighten := contrast + math.Max(contrast-luminance, 0.0)
+ // Compute factor to keep RGB values proportional.
+ darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0)
+ lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0)
+
+ scopeBytes := []byte{
+ uint8(math.Min(math.Round(r*darkenFactor), 255)),
+ uint8(math.Min(math.Round(g*darkenFactor), 255)),
+ uint8(math.Min(math.Round(b*darkenFactor), 255)),
+ }
+ itemBytes := []byte{
+ uint8(math.Min(math.Round(r*lightenFactor), 255)),
+ uint8(math.Min(math.Round(g*lightenFactor), 255)),
+ uint8(math.Min(math.Round(b*lightenFactor), 255)),
+ }
+
+ itemColor = "#" + hex.EncodeToString(itemBytes)
+ scopeColor = "#" + hex.EncodeToString(scopeBytes)
+ }
+
+ return fmt.Sprintf("<span class='ui label scope-parent' 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='background: linear-gradient(-80deg, %s 48%%, %s 52%% 0%%);'>&nbsp;</div>"+
+ "<div class='ui label scope-right' style='color: %s !important; background-color: %s !important''>%s</div>"+
+ "</span>",
+ description,
+ textColor, scopeColor, scopeText,
+ itemColor, scopeColor,
+ textColor, itemColor, itemText)
+}
+
// RenderEmoji renders html text with emoji post processors
func RenderEmoji(text string) template.HTML {
renderedText, err := markup.RenderEmoji(template.HTMLEscapeString(text))
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 51ae8c7a02..411a585c81 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1395,9 +1395,12 @@ issues.sign_in_require_desc = <a href="%s">Sign in</a> to join this conversation
issues.edit = Edit
issues.cancel = Cancel
issues.save = Save
-issues.label_title = Label name
-issues.label_description = Label description
-issues.label_color = Label color
+issues.label_title = Name
+issues.label_description = Description
+issues.label_color = Color
+issues.label_exclusive = Exclusive
+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_count = %d labels
issues.label_open_issues = %d open issues/pull requests
issues.label_edit = Edit
diff --git a/routers/api/v1/org/label.go b/routers/api/v1/org/label.go
index 5d0455cdd4..938fe79df6 100644
--- a/routers/api/v1/org/label.go
+++ b/routers/api/v1/org/label.go
@@ -94,6 +94,7 @@ func CreateLabel(ctx *context.APIContext) {
label := &issues_model.Label{
Name: form.Name,
+ Exclusive: form.Exclusive,
Color: form.Color,
OrgID: ctx.Org.Organization.ID,
Description: form.Description,
@@ -195,6 +196,9 @@ func EditLabel(ctx *context.APIContext) {
if form.Name != nil {
label.Name = *form.Name
}
+ if form.Exclusive != nil {
+ label.Exclusive = *form.Exclusive
+ }
if form.Color != nil {
label.Color = strings.Trim(*form.Color, " ")
if len(label.Color) == 6 {
diff --git a/routers/api/v1/repo/label.go b/routers/api/v1/repo/label.go
index 411c0274e6..a06d26e837 100644
--- a/routers/api/v1/repo/label.go
+++ b/routers/api/v1/repo/label.go
@@ -156,6 +156,7 @@ func CreateLabel(ctx *context.APIContext) {
label := &issues_model.Label{
Name: form.Name,
+ Exclusive: form.Exclusive,
Color: form.Color,
RepoID: ctx.Repo.Repository.ID,
Description: form.Description,
@@ -218,6 +219,9 @@ func EditLabel(ctx *context.APIContext) {
if form.Name != nil {
label.Name = *form.Name
}
+ if form.Exclusive != nil {
+ label.Exclusive = *form.Exclusive
+ }
if form.Color != nil {
label.Color = strings.Trim(*form.Color, " ")
if len(label.Color) == 6 {
diff --git a/routers/web/org/org_labels.go b/routers/web/org/org_labels.go
index 1c910a93a5..e96627762b 100644
--- a/routers/web/org/org_labels.go
+++ b/routers/web/org/org_labels.go
@@ -45,6 +45,7 @@ func NewLabel(ctx *context.Context) {
l := &issues_model.Label{
OrgID: ctx.Org.Organization.ID,
Name: form.Title,
+ Exclusive: form.Exclusive,
Description: form.Description,
Color: form.Color,
}
@@ -70,6 +71,7 @@ func UpdateLabel(ctx *context.Context) {
}
l.Name = form.Title
+ l.Exclusive = form.Exclusive
l.Description = form.Description
l.Color = form.Color
if err := issues_model.UpdateLabel(l); err != nil {
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index 62565af50f..05ba26a70c 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -332,8 +332,24 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
labels = append(labels, orgLabels...)
}
+ // Get the exclusive scope for every label ID
+ labelExclusiveScopes := make([]string, 0, len(labelIDs))
+ for _, labelID := range labelIDs {
+ foundExclusiveScope := false
+ for _, label := range labels {
+ if label.ID == labelID || label.ID == -labelID {
+ labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope())
+ foundExclusiveScope = true
+ break
+ }
+ }
+ if !foundExclusiveScope {
+ labelExclusiveScopes = append(labelExclusiveScopes, "")
+ }
+ }
+
for _, l := range labels {
- l.LoadSelectedLabelsAfterClick(labelIDs)
+ l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
}
ctx.Data["Labels"] = labels
ctx.Data["NumLabels"] = len(labels)
diff --git a/routers/web/repo/issue_label.go b/routers/web/repo/issue_label.go
index 66e8920bd9..d4fece9f01 100644
--- a/routers/web/repo/issue_label.go
+++ b/routers/web/repo/issue_label.go
@@ -113,6 +113,7 @@ func NewLabel(ctx *context.Context) {
l := &issues_model.Label{
RepoID: ctx.Repo.Repository.ID,
Name: form.Title,
+ Exclusive: form.Exclusive,
Description: form.Description,
Color: form.Color,
}
@@ -138,6 +139,7 @@ func UpdateLabel(ctx *context.Context) {
}
l.Name = form.Title
+ l.Exclusive = form.Exclusive
l.Description = form.Description
l.Color = form.Color
if err := issues_model.UpdateLabel(l); err != nil {
@@ -175,7 +177,7 @@ func UpdateIssueLabel(ctx *context.Context) {
return
}
}
- case "attach", "detach", "toggle":
+ case "attach", "detach", "toggle", "toggle-alt":
label, err := issues_model.GetLabelByID(ctx, ctx.FormInt64("id"))
if err != nil {
if issues_model.IsErrRepoLabelNotExist(err) {
@@ -189,12 +191,18 @@ func UpdateIssueLabel(ctx *context.Context) {
if action == "toggle" {
// detach if any issues already have label, otherwise attach
action = "attach"
- for _, issue := range issues {
- if issues_model.HasIssueLabel(ctx, issue.ID, label.ID) {
- action = "detach"
- break
+ if label.ExclusiveScope() == "" {
+ for _, issue := range issues {
+ if issues_model.HasIssueLabel(ctx, issue.ID, label.ID) {
+ action = "detach"
+ break
+ }
}
}
+ } else if action == "toggle-alt" {
+ // always detach with alt key pressed, to be able to remove
+ // scoped labels
+ action = "detach"
}
if action == "attach" {
diff --git a/services/convert/issue.go b/services/convert/issue.go
index dccf2f3727..e79fcfcccb 100644
--- a/services/convert/issue.go
+++ b/services/convert/issue.go
@@ -182,6 +182,7 @@ func ToLabel(label *issues_model.Label, repo *repo_model.Repository, org *user_m
result := &api.Label{
ID: label.ID,
Name: label.Name,
+ Exclusive: label.Exclusive,
Color: strings.TrimLeft(label.Color, "#"),
Description: label.Description,
}
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index c1b5800968..ff0916f8e1 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -564,6 +564,7 @@ func (f *CreateMilestoneForm) Validate(req *http.Request, errs binding.Errors) b
type CreateLabelForm struct {
ID int64
Title string `binding:"Required;MaxSize(50)" locale:"repo.issues.label_title"`
+ Exclusive bool `form:"exclusive"`
Description string `binding:"MaxSize(200)" locale:"repo.issues.label_description"`
Color string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"`
}
diff --git a/services/migrations/main_test.go b/services/migrations/main_test.go
index 30875f6e5b..42c433fb00 100644
--- a/services/migrations/main_test.go
+++ b/services/migrations/main_test.go
@@ -59,6 +59,7 @@ func assertCommentsEqual(t *testing.T, expected, actual []*base.Comment) {
func assertLabelEqual(t *testing.T, expected, actual *base.Label) {
assert.Equal(t, expected.Name, actual.Name)
+ assert.Equal(t, expected.Exclusive, actual.Exclusive)
assert.Equal(t, expected.Color, actual.Color)
assert.Equal(t, expected.Description, actual.Description)
}
diff --git a/services/repository/template.go b/services/repository/template.go
index 13e0749869..8c75948c41 100644
--- a/services/repository/template.go
+++ b/services/repository/template.go
@@ -31,6 +31,7 @@ func GenerateIssueLabels(ctx context.Context, templateRepo, generateRepo *repo_m
newLabels = append(newLabels, &issues_model.Label{
RepoID: generateRepo.ID,
Name: templateLabel.Name,
+ Exclusive: templateLabel.Exclusive,
Description: templateLabel.Description,
Color: templateLabel.Color,
})
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index 112e6be7ce..b25cf2526e 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -234,7 +234,7 @@
{{if or .Labels .Assignees}}
<div class="extra content labels-list gt-p-0 gt-pt-2">
{{range .Labels}}
- <a class="ui label" target="_blank" href="{{$.RepoLink}}/issues?labels={{.ID}}" style="color: {{.ForegroundColor}}; background-color: {{.Color}};" title="{{.Description | RenderEmojiPlain}}">{{.Name | RenderEmoji}}</a>
+ <a target="_blank" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{RenderLabel .}}</a>
{{end}}
<div class="right floated">
{{range .Assignees}}
diff --git a/templates/repo/issue/labels/edit_delete_label.tmpl b/templates/repo/issue/labels/edit_delete_label.tmpl
index a0479dde1b..450061e835 100644
--- a/templates/repo/issue/labels/edit_delete_label.tmpl
+++ b/templates/repo/issue/labels/edit_delete_label.tmpl
@@ -26,31 +26,45 @@
<form class="ui edit-label form ignore-dirty" action="{{$.Link}}/edit" method="post">
{{.CsrfTokenHtml}}
<input id="label-modal-id" name="id" type="hidden">
- <div class="ui grid">
- <div class="three wide column">
- <div class="ui small input">
- <input class="new-label-input emoji-input" name="title" placeholder="{{.locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
- </div>
+ <div class="required field">
+ <label for="name">{{.locale.Tr "repo.issues.label_title"}}</label>
+ <div class="ui small input">
+ <input class="label-name-input emoji-input" name="title" placeholder="{{.locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
</div>
- <div class="five wide column">
- <div class="ui small fluid input">
- <input class="new-label-desc-input" name="description" placeholder="{{.locale.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200">
- </div>
+ </div>
+ <div class="field label-exclusive-input-field">
+ <div class="ui checkbox">
+ <input class="label-exclusive-input" name="exclusive" type="checkbox">
+ <label>{{.locale.Tr "repo.issues.label_exclusive"}}</label>
</div>
+ <br/>
+ <small class="desc">{{.locale.Tr "repo.issues.label_exclusive_desc" | Safe}}</small>
+ <div class="desc gt-ml-2 gt-mt-3 gt-hidden label-exclusive-warning">
+ {{svg "octicon-alert"}} {{.locale.Tr "repo.issues.label_exclusive_warning" | Safe}}
+ </div>
+ </div>
+ <div class="field">
+ <label for="description">{{.locale.Tr "repo.issues.label_description"}}</label>
+ <div class="ui small fluid input">
+ <input class="label-desc-input" name="description" placeholder="{{.locale.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200">
+ </div>
+ </div>
+ <div class="field color-field">
+ <label for="color">{{$.locale.Tr "repo.issues.label_color"}}</label>
<div class="color picker column">
<input class="color-picker" name="color" value="#70c24a" required maxlength="7">
- </div>
- <div class="column precolors">
- {{template "repo/issue/label_precolors"}}
+ <div class="column precolors">
+ {{template "repo/issue/label_precolors"}}
+ </div>
</div>
</div>
</form>
</div>
<div class="actions">
- <div class="ui negative button">
+ <div class="ui secondary small basic cancel button">
{{.locale.Tr "cancel"}}
</div>
- <div class="ui positive button">
+ <div class="ui primary small approve button">
{{.locale.Tr "save"}}
</div>
</div>
diff --git a/templates/repo/issue/labels/label.tmpl b/templates/repo/issue/labels/label.tmpl
index 0afe5cb6e7..87d8f0c41c 100644
--- a/templates/repo/issue/labels/label.tmpl
+++ b/templates/repo/issue/labels/label.tmpl
@@ -1,9 +1,7 @@
<a
- class="ui label item {{if not .label.IsChecked}}hide{{end}}"
+ class="item {{if not .label.IsChecked}}hide{{end}}"
id="label_{{.label.ID}}"
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 */}}
- style="color: {{.label.ForegroundColor}}; background-color: {{.label.Color}}"
- title="{{.label.Description | RenderEmojiPlain}}"
>
- {{.label.Name | RenderEmoji}}
+ {{RenderLabel .label}}
</a>
diff --git a/templates/repo/issue/labels/label_list.tmpl b/templates/repo/issue/labels/label_list.tmpl
index 464c9fe208..e8f00fa256 100644
--- a/templates/repo/issue/labels/label_list.tmpl
+++ b/templates/repo/issue/labels/label_list.tmpl
@@ -30,28 +30,24 @@
{{range .Labels}}
<li class="item">
<div class="ui grid middle aligned">
- <div class="four wide column">
- <div class="ui label" style="color: {{.ForegroundColor}}; background-color: {{.Color}}">{{svg "octicon-tag"}} {{.Name | RenderEmoji}}</div>
- </div>
- <div class="six wide column">
- <div class="ui">
- {{.Description | RenderEmoji}}
- </div>
+ <div class="nine wide column">
+ {{RenderLabel .}}
+ {{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}}
</div>
- <div class="three wide column">
+ <div class="four wide column">
{{if $.PageIsOrgSettingsLabels}}
- <a class="ui right open-issues" href="{{AppSubUrl}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenIssues}}</a>
+ <a class="ui left open-issues" href="{{AppSubUrl}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenIssues}}</a>
{{else}}
- <a class="ui right open-issues" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenIssues}}</a>
+ <a class="ui left open-issues" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenIssues}}</a>
{{end}}
</div>
<div class="three wide column">
{{if and (not $.PageIsOrgSettingsLabels ) (not $.Repository.IsArchived) (or $.CanWriteIssues $.CanWritePulls)}}
<a class="ui right delete-button" href="#" data-url="{{$.Link}}/delete" data-id="{{.ID}}">{{svg "octicon-trash"}} {{$.locale.Tr "repo.issues.label_delete"}}</a>
- <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>
+ <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>
{{else if $.PageIsOrgSettingsLabels}}
<a class="ui right delete-button" href="#" data-url="{{$.Link}}/delete" data-id="{{.ID}}">{{svg "octicon-trash"}} {{$.locale.Tr "repo.issues.label_delete"}}</a>
- <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>
+ <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>
{{end}}
</div>
</div>
@@ -73,16 +69,12 @@
{{range .OrgLabels}}
<li class="item">
<div class="ui grid middle aligned">
- <div class="three wide column">
- <div class="ui label" style="color: {{.ForegroundColor}}; background-color: {{.Color}}">{{svg "octicon-tag"}} {{.Name | RenderEmoji}}</div>
+ <div class="nine wide column">
+ {{RenderLabel .}}
+ {{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}}
</div>
- <div class="seven wide column">
- <div class="ui">
- {{.Description | RenderEmoji}}
- </div>
- </div>
- <div class="three wide column">
- <a class="ui right open-issues" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenRepoIssues}}</a>
+ <div class="four wide column">
+ <a class="ui left open-issues" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenRepoIssues}}</a>
</div>
<div class="three wide column">
</div>
diff --git a/templates/repo/issue/labels/label_new.tmpl b/templates/repo/issue/labels/label_new.tmpl
index 035a4db800..62f7155b74 100644
--- a/templates/repo/issue/labels/label_new.tmpl
+++ b/templates/repo/issue/labels/label_new.tmpl
@@ -1,27 +1,47 @@
-<div class="ui new-label segment hide">
- <form class="ui form" action="{{$.Link}}/new" method="post">
- {{.CsrfTokenHtml}}
- <div class="ui grid">
- <div class="three wide column">
+<div class="ui small new-label modal">
+ <div class="header">
+ {{.locale.Tr "repo.issues.new_label"}}
+ </div>
+ <div class="content">
+ <form class="ui new-label form ignore-dirty" action="{{$.Link}}/new" method="post">
+ {{.CsrfTokenHtml}}
+ <div class="required field">
+ <label for="name">{{.locale.Tr "repo.issues.label_title"}}</label>
<div class="ui small input">
- <input class="new-label-input emoji-input" name="title" placeholder="{{.locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
+ <input class="label-name-input emoji-input" name="title" placeholder="{{.locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
</div>
</div>
- <div class="three wide column">
- <div class="ui small fluid input">
- <input class="new-label-desc-input" name="description" placeholder="{{.locale.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200">
+ <div class="field label-exclusive-input-field">
+ <div class="ui checkbox">
+ <input class="label-exclusive-input" name="exclusive" type="checkbox">
+ <label>{{.locale.Tr "repo.issues.label_exclusive"}}</label>
</div>
+ <br/>
+ <small class="desc">{{.locale.Tr "repo.issues.label_exclusive_desc" | Safe}}</small>
</div>
- <div class="color picker column">
- <input class="color-picker" name="color" value="#70c24a" required maxlength="7">
- </div>
- <div class="column precolors">
- {{template "repo/issue/label_precolors"}}
+ <div class="field">
+ <label for="description">{{.locale.Tr "repo.issues.label_description"}}</label>
+ <div class="ui small fluid input">
+ <input class="label-desc-input" name="description" placeholder="{{.locale.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200">
+ </div>
</div>
- <div class="buttons">
- <div class="ui secondary small basic cancel button">{{.locale.Tr "repo.milestones.cancel"}}</div>
- <button class="ui primary small button">{{.locale.Tr "repo.issues.create_label"}}</button>
+ <div class="field color-field">
+ <label for="color">{{$.locale.Tr "repo.issues.label_color"}}</label>
+ <div class="color picker column">
+ <input class="color-picker" name="color" value="#70c24a" required maxlength="7">
+ <div class="column precolors">
+ {{template "repo/issue/label_precolors"}}
+ </div>
+ </div>
</div>
+ </form>
+ </div>
+ <div class="actions">
+ <div class="ui secondary small basic cancel button">
+ {{.locale.Tr "cancel"}}
+ </div>
+ <div class="ui primary small approve button">
+ {{.locale.Tr "repo.issues.create_label"}}
</div>
- </form>
+ </div>
</div>
diff --git a/templates/repo/issue/list.tmpl b/templates/repo/issue/list.tmpl
index 4b55e7bec8..0e4969a706 100644
--- a/templates/repo/issue/list.tmpl
+++ b/templates/repo/issue/list.tmpl
@@ -50,8 +50,14 @@
</div>
<span class="info">{{.locale.Tr "repo.issues.filter_label_exclude" | Safe}}</span>
<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>
+ {{$previousExclusiveScope := "_no_scope"}}
{{range .Labels}}
- <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>
+ {{$exclusiveScope := .ExclusiveScope}}
+ {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
+ <div class="ui divider"></div>
+ {{end}}
+ {{$previousExclusiveScope = $exclusiveScope}}
+ <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>
{{end}}
</div>
</div>
@@ -217,9 +223,15 @@
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
</span>
<div class="menu">
+ {{$previousExclusiveScope := "_no_scope"}}
{{range .Labels}}
+ {{$exclusiveScope := .ExclusiveScope}}
+ {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
+ <div class="ui divider"></div>
+ {{end}}
+ {{$previousExclusiveScope = $exclusiveScope}}
<div class="item issue-action" data-action="toggle" data-element-id="{{.ID}}" data-url="{{$.RepoLink}}/issues/labels">
- {{if contain $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}}<span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}}
+ {{if contain $.SelLabelIDs .ID}}{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}{{end}} {{RenderLabel .}}
</div>
{{end}}
</div>
diff --git a/templates/repo/issue/milestone_issues.tmpl b/templates/repo/issue/milestone_issues.tmpl
index 8d6a97a713..57012bddb6 100644
--- a/templates/repo/issue/milestone_issues.tmpl
+++ b/templates/repo/issue/milestone_issues.tmpl
@@ -58,7 +58,7 @@
<span class="info">{{.locale.Tr "repo.issues.filter_label_exclude" | Safe}}</span>
<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>
{{range .Labels}}
- <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>
+ <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>
{{end}}
</div>
</div>
@@ -161,7 +161,7 @@
<div class="menu">
{{range .Labels}}
<div class="item issue-action" data-action="toggle" data-element-id="{{.ID}}" data-url="{{$.RepoLink}}/issues/labels">
- {{if contain $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}}<span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}}
+ {{if contain $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}} {{RenderLabel .}}
</div>
{{end}}
</div>
diff --git a/templates/repo/issue/new_form.tmpl b/templates/repo/issue/new_form.tmpl
index 8fbd9d256a..2a6fcaa995 100644
--- a/templates/repo/issue/new_form.tmpl
+++ b/templates/repo/issue/new_form.tmpl
@@ -53,14 +53,26 @@
{{end}}
<div class="no-select item">{{.locale.Tr "repo.issues.new.clear_labels"}}</div>
{{if or .Labels .OrgLabels}}
+ {{$previousExclusiveScope := "_no_scope"}}
{{range .Labels}}
- <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}}
+ {{$exclusiveScope := .ExclusiveScope}}
+ {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
+ <div class="ui divider"></div>
+ {{end}}
+ {{$previousExclusiveScope = $exclusiveScope}}
+ <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 .}}
{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}}</a>
{{end}}
<div class="ui divider"></div>
+ {{$previousExclusiveScope := "_no_scope"}}
{{range .OrgLabels}}
- <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}}
+ {{$exclusiveScope := .ExclusiveScope}}
+ {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
+ <div class="ui divider"></div>
+ {{end}}
+ {{$previousExclusiveScope = $exclusiveScope}}
+ <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 .}}
{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}}</a>
{{end}}
{{else}}
diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl
index 9ba46f3715..8cd34ede6e 100644
--- a/templates/repo/issue/view_content/sidebar.tmpl
+++ b/templates/repo/issue/view_content/sidebar.tmpl
@@ -123,13 +123,25 @@
{{end}}
<div class="no-select item">{{.locale.Tr "repo.issues.new.clear_labels"}}</div>
{{if or .Labels .OrgLabels}}
+ {{$previousExclusiveScope := "_no_scope"}}
{{range .Labels}}
- <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}}
+ {{$exclusiveScope := .ExclusiveScope}}
+ {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
+ <div class="ui divider"></div>
+ {{end}}
+ {{$previousExclusiveScope = $exclusiveScope}}
+ <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 .}}
{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}}</a>
{{end}}
<div class="ui divider"></div>
+ {{$previousExclusiveScope := "_no_scope"}}
{{range .OrgLabels}}
- <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}}
+ {{$exclusiveScope := .ExclusiveScope}}
+ {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
+ <div class="ui divider"></div>
+ {{end}}
+ {{$previousExclusiveScope = $exclusiveScope}}
+ <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 .}}
{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}}</a>
{{end}}
{{else}}
diff --git a/templates/repo/projects/view.tmpl b/templates/repo/projects/view.tmpl
index de0911e6cd..a4ada87353 100644
--- a/templates/repo/projects/view.tmpl
+++ b/templates/repo/projects/view.tmpl
@@ -245,7 +245,7 @@
{{if or .Labels .Assignees}}
<div class="extra content labels-list gt-p-0 gt-pt-2">
{{range .Labels}}
- <a class="ui label" target="_blank" href="{{$.RepoLink}}/issues?labels={{.ID}}" style="color: {{.ForegroundColor}}; background-color: {{.Color}};" title="{{.Description | RenderEmojiPlain}}">{{.Name | RenderEmoji}}</a>
+ <a target="_blank" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{RenderLabel .}}</a>
{{end}}
<div class="right floated">
{{range .Assignees}}
diff --git a/templates/shared/issuelist.tmpl b/templates/shared/issuelist.tmpl
index a246b70093..a43047c79d 100644
--- a/templates/shared/issuelist.tmpl
+++ b/templates/shared/issuelist.tmpl
@@ -42,7 +42,7 @@
{{end}}
<span class="labels-list gt-ml-2">
{{range .Labels}}
- <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>
+ <a href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&state={{$.State}}&labels={{.ID}}{{if ne $.listType "milestone"}}&milestone={{$.MilestoneID}}{{end}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{RenderLabel .}}</a>
{{end}}
</span>
</div>
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 00fc3b60c4..2a675766ab 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -15348,6 +15348,11 @@
"type": "string",
"x-go-name": "Description"
},
+ "exclusive": {
+ "type": "boolean",
+ "x-go-name": "Exclusive",
+ "example": false
+ },
"name": {
"type": "string",
"x-go-name": "Name"
@@ -16283,12 +16288,18 @@
"properties": {
"color": {
"type": "string",
- "x-go-name": "Color"
+ "x-go-name": "Color",
+ "example": "#00aabb"
},
"description": {
"type": "string",
"x-go-name": "Description"
},
+ "exclusive": {
+ "type": "boolean",
+ "x-go-name": "Exclusive",
+ "example": false
+ },
"name": {
"type": "string",
"x-go-name": "Name"
@@ -17615,6 +17626,11 @@
"type": "string",
"x-go-name": "Description"
},
+ "exclusive": {
+ "type": "boolean",
+ "x-go-name": "Exclusive",
+ "example": false
+ },
"id": {
"type": "integer",
"format": "int64",
diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go
index 2f27978a37..4344c15ea4 100644
--- a/tests/integration/api_issue_test.go
+++ b/tests/integration/api_issue_test.go
@@ -174,7 +174,7 @@ func TestAPISearchIssues(t *testing.T) {
token := getUserToken(t, "user2")
// as this API was used in the frontend, it uses UI page size
- expectedIssueCount := 15 // from the fixtures
+ expectedIssueCount := 16 // from the fixtures
if expectedIssueCount > setting.UI.IssuePagingNum {
expectedIssueCount = setting.UI.IssuePagingNum
}
@@ -198,7 +198,7 @@ func TestAPISearchIssues(t *testing.T) {
req = NewRequest(t, "GET", link.String())
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
- assert.Len(t, apiIssues, 8)
+ assert.Len(t, apiIssues, 9)
query.Del("since")
query.Del("before")
@@ -214,15 +214,15 @@ func TestAPISearchIssues(t *testing.T) {
req = NewRequest(t, "GET", link.String())
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
- assert.EqualValues(t, "17", resp.Header().Get("X-Total-Count"))
- assert.Len(t, apiIssues, 17)
+ assert.EqualValues(t, "18", resp.Header().Get("X-Total-Count"))
+ assert.Len(t, apiIssues, 18)
query.Add("limit", "10")
link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String())
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
- assert.EqualValues(t, "17", resp.Header().Get("X-Total-Count"))
+ assert.EqualValues(t, "18", resp.Header().Get("X-Total-Count"))
assert.Len(t, apiIssues, 10)
query = url.Values{"assigned": {"true"}, "state": {"all"}, "token": {token}}
@@ -251,7 +251,7 @@ func TestAPISearchIssues(t *testing.T) {
req = NewRequest(t, "GET", link.String())
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
- assert.Len(t, apiIssues, 6)
+ assert.Len(t, apiIssues, 7)
query = url.Values{"owner": {"user3"}, "token": {token}} // organization
link.RawQuery = query.Encode()
@@ -272,7 +272,7 @@ func TestAPISearchIssuesWithLabels(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// as this API was used in the frontend, it uses UI page size
- expectedIssueCount := 15 // from the fixtures
+ expectedIssueCount := 16 // from the fixtures
if expectedIssueCount > setting.UI.IssuePagingNum {
expectedIssueCount = setting.UI.IssuePagingNum
}
diff --git a/tests/integration/api_nodeinfo_test.go b/tests/integration/api_nodeinfo_test.go
index 6e80ebc19c..29fff8ba72 100644
--- a/tests/integration/api_nodeinfo_test.go
+++ b/tests/integration/api_nodeinfo_test.go
@@ -34,7 +34,7 @@ func TestNodeinfo(t *testing.T) {
assert.True(t, nodeinfo.OpenRegistrations)
assert.Equal(t, "gitea", nodeinfo.Software.Name)
assert.Equal(t, 24, nodeinfo.Usage.Users.Total)
- assert.Equal(t, 17, nodeinfo.Usage.LocalPosts)
+ assert.Equal(t, 18, nodeinfo.Usage.LocalPosts)
assert.Equal(t, 2, nodeinfo.Usage.LocalComments)
})
}
diff --git a/tests/integration/issue_test.go b/tests/integration/issue_test.go
index c913a2000c..eccf3c3795 100644
--- a/tests/integration/issue_test.go
+++ b/tests/integration/issue_test.go
@@ -356,7 +356,7 @@ func TestSearchIssues(t *testing.T) {
session := loginUser(t, "user2")
- expectedIssueCount := 15 // from the fixtures
+ expectedIssueCount := 16 // from the fixtures
if expectedIssueCount > setting.UI.IssuePagingNum {
expectedIssueCount = setting.UI.IssuePagingNum
}
@@ -377,7 +377,7 @@ func TestSearchIssues(t *testing.T) {
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
- assert.Len(t, apiIssues, 8)
+ assert.Len(t, apiIssues, 9)
query.Del("since")
query.Del("before")
@@ -393,15 +393,15 @@ func TestSearchIssues(t *testing.T) {
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
- assert.EqualValues(t, "17", resp.Header().Get("X-Total-Count"))
- assert.Len(t, apiIssues, 17)
+ assert.EqualValues(t, "18", resp.Header().Get("X-Total-Count"))
+ assert.Len(t, apiIssues, 18)
query.Add("limit", "5")
link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
- assert.EqualValues(t, "17", resp.Header().Get("X-Total-Count"))
+ assert.EqualValues(t, "18", resp.Header().Get("X-Total-Count"))
assert.Len(t, apiIssues, 5)
query = url.Values{"assigned": {"true"}, "state": {"all"}}
@@ -430,7 +430,7 @@ func TestSearchIssues(t *testing.T) {
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
- assert.Len(t, apiIssues, 6)
+ assert.Len(t, apiIssues, 7)
query = url.Values{"owner": {"user3"}} // organization
link.RawQuery = query.Encode()
@@ -450,7 +450,7 @@ func TestSearchIssues(t *testing.T) {
func TestSearchIssuesWithLabels(t *testing.T) {
defer tests.PrepareTestEnv(t)()
- expectedIssueCount := 15 // from the fixtures
+ expectedIssueCount := 16 // from the fixtures
if expectedIssueCount > setting.UI.IssuePagingNum {
expectedIssueCount = setting.UI.IssuePagingNum
}
diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue
index 07c73ff5cf..3244034782 100644
--- a/web_src/js/components/ContextPopup.vue
+++ b/web_src/js/components/ContextPopup.vue
@@ -26,25 +26,10 @@
<script>
import $ from 'jquery';
import {SvgIcon} from '../svg.js';
+import {useLightTextOnBackground} from '../utils.js';
const {appSubUrl, i18n} = window.config;
-// NOTE: see models/issue_label.go for similar implementation
-const srgbToLinear = (color) => {
- color /= 255;
- if (color <= 0.04045) {
- return color / 12.92;
- }
- return ((color + 0.055) / 1.055) ** 2.4;
-};
-const luminance = (colorString) => {
- const r = srgbToLinear(parseInt(colorString.substring(0, 2), 16));
- const g = srgbToLinear(parseInt(colorString.substring(2, 4), 16));
- const b = srgbToLinear(parseInt(colorString.substring(4, 6), 16));
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
-};
-const luminanceThreshold = 0.179;
-
export default {
components: {SvgIcon},
data: () => ({
@@ -92,10 +77,10 @@ export default {
labels() {
return this.issue.labels.map((label) => {
let textColor;
- if (luminance(label.color) < luminanceThreshold) {
- textColor = '#ffffff';
+ if (useLightTextOnBackground(label.color)) {
+ textColor = '#eeeeee';
} else {
- textColor = '#000000';
+ textColor = '#111111';
}
return {name: label.name, color: `#${label.color}`, textColor};
});
diff --git a/web_src/js/features/common-issue.js b/web_src/js/features/common-issue.js
index 4a62089c60..f53dd5081b 100644
--- a/web_src/js/features/common-issue.js
+++ b/web_src/js/features/common-issue.js
@@ -32,7 +32,7 @@ export function initCommonIssue() {
syncIssueSelectionState();
});
- $('.issue-action').on('click', async function () {
+ $('.issue-action').on('click', async function (e) {
let action = this.getAttribute('data-action');
let elementId = this.getAttribute('data-element-id');
const url = this.getAttribute('data-url');
@@ -43,6 +43,9 @@ export function initCommonIssue() {
elementId = '';
action = 'clear';
}
+ if (action === 'toggle' && e.altKey) {
+ action = 'toggle-alt';
+ }
updateIssuesMeta(
url,
action,
diff --git a/web_src/js/features/comp/LabelEdit.js b/web_src/js/features/comp/LabelEdit.js
index df294078fa..313d406821 100644
--- a/web_src/js/features/comp/LabelEdit.js
+++ b/web_src/js/features/comp/LabelEdit.js
@@ -1,26 +1,64 @@
import $ from 'jquery';
import {initCompColorPicker} from './ColorPicker.js';
+function isExclusiveScopeName(name) {
+ return /.*[^/]\/[^/].*/.test(name);
+}
+
+function updateExclusiveLabelEdit(form) {
+ const nameInput = $(`${form} .label-name-input`);
+ const exclusiveField = $(`${form} .label-exclusive-input-field`);
+ const exclusiveCheckbox = $(`${form} .label-exclusive-input`);
+ const exclusiveWarning = $(`${form} .label-exclusive-warning`);
+
+ if (isExclusiveScopeName(nameInput.val())) {
+ exclusiveField.removeClass('muted');
+ if (exclusiveCheckbox.prop('checked') && exclusiveCheckbox.data('exclusive-warn')) {
+ exclusiveWarning.removeClass('gt-hidden');
+ } else {
+ exclusiveWarning.addClass('gt-hidden');
+ }
+ } else {
+ exclusiveField.addClass('muted');
+ exclusiveWarning.addClass('gt-hidden');
+ }
+}
+
export function initCompLabelEdit(selector) {
if (!$(selector).length) return;
+ initCompColorPicker();
+
// Create label
- const $newLabelPanel = $('.new-label.segment');
$('.new-label.button').on('click', () => {
- $newLabelPanel.show();
- });
- $('.new-label.segment .cancel').on('click', () => {
- $newLabelPanel.hide();
+ updateExclusiveLabelEdit('.new-label');
+ $('.new-label.modal').modal({
+ onApprove() {
+ $('.new-label.form').trigger('submit');
+ }
+ }).modal('show');
+ return false;
});
- initCompColorPicker();
-
+ // Edit label
$('.edit-label-button').on('click', function () {
$('.edit-label .color-picker').minicolors('value', $(this).data('color'));
$('#label-modal-id').val($(this).data('id'));
- $('.edit-label .new-label-input').val($(this).data('title'));
- $('.edit-label .new-label-desc-input').val($(this).data('description'));
+
+ const nameInput = $('.edit-label .label-name-input');
+ nameInput.val($(this).data('title'));
+
+ const exclusiveCheckbox = $('.edit-label .label-exclusive-input');
+ exclusiveCheckbox.prop('checked', this.hasAttribute('data-exclusive'));
+ // Warn when label was previously not exclusive and used in issues
+ exclusiveCheckbox.data('exclusive-warn',
+ $(this).data('num-issues') > 0 &&
+ (!this.hasAttribute('data-exclusive') || !isExclusiveScopeName(nameInput.val())));
+ updateExclusiveLabelEdit('.edit-label');
+
+ $('.edit-label .label-desc-input').val($(this).data('description'));
$('.edit-label .color-picker').val($(this).data('color'));
$('.edit-label .minicolors-swatch-color').css('background-color', $(this).data('color'));
+
$('.edit-label.modal').modal({
onApprove() {
$('.edit-label.form').trigger('submit');
@@ -28,4 +66,17 @@ export function initCompLabelEdit(selector) {
}).modal('show');
return false;
});
+
+ $('.new-label .label-name-input').on('input', () => {
+ updateExclusiveLabelEdit('.new-label');
+ });
+ $('.new-label .label-exclusive-input').on('change', () => {
+ updateExclusiveLabelEdit('.new-label');
+ });
+ $('.edit-label .label-name-input').on('input', () => {
+ updateExclusiveLabelEdit('.edit-label');
+ });
+ $('.edit-label .label-exclusive-input').on('change', () => {
+ updateExclusiveLabelEdit('.edit-label');
+ });
}
diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js
index 07c67ba5da..2cf4963b6a 100644
--- a/web_src/js/features/repo-legacy.js
+++ b/web_src/js/features/repo-legacy.js
@@ -110,35 +110,59 @@ export function initRepoCommentForm() {
}
hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var
- if ($(this).hasClass('checked')) {
- $(this).removeClass('checked');
- $(this).find('.octicon-check').addClass('invisible');
- if (hasUpdateAction) {
- if (!($(this).data('id') in items)) {
- items[$(this).data('id')] = {
- 'update-url': $listMenu.data('update-url'),
- action: 'detach',
- 'issue-id': $listMenu.data('issue-id'),
- };
- } else {
- delete items[$(this).data('id')];
+
+ const clickedItem = $(this);
+ const scope = $(this).attr('data-scope');
+ const canRemoveScope = e.altKey;
+
+ $(this).parent().find('.item').each(function () {
+ if (scope) {
+ // Enable only clicked item for scoped labels
+ if ($(this).attr('data-scope') !== scope) {
+ return true;
}
+ if ($(this).is(clickedItem)) {
+ if (!canRemoveScope && $(this).hasClass('checked')) {
+ return true;
+ }
+ } else if (!$(this).hasClass('checked')) {
+ return true;
+ }
+ } else if (!$(this).is(clickedItem)) {
+ // Toggle for other labels
+ return true;
}
- } else {
- $(this).addClass('checked');
- $(this).find('.octicon-check').removeClass('invisible');
- if (hasUpdateAction) {
- if (!($(this).data('id') in items)) {
- items[$(this).data('id')] = {
- 'update-url': $listMenu.data('update-url'),
- action: 'attach',
- 'issue-id': $listMenu.data('issue-id'),
- };
- } else {
- delete items[$(this).data('id')];
+
+ if ($(this).hasClass('checked')) {
+ $(this).removeClass('checked');
+ $(this).find('.octicon-check').addClass('invisible');
+ if (hasUpdateAction) {
+ if (!($(this).data('id') in items)) {
+ items[$(this).data('id')] = {
+ 'update-url': $listMenu.data('update-url'),
+ action: 'detach',
+ 'issue-id': $listMenu.data('issue-id'),
+ };
+ } else {
+ delete items[$(this).data('id')];
+ }
+ }
+ } else {
+ $(this).addClass('checked');
+ $(this).find('.octicon-check').removeClass('invisible');
+ if (hasUpdateAction) {
+ if (!($(this).data('id') in items)) {
+ items[$(this).data('id')] = {
+ 'update-url': $listMenu.data('update-url'),
+ action: 'attach',
+ 'issue-id': $listMenu.data('issue-id'),
+ };
+ } else {
+ delete items[$(this).data('id')];
+ }
}
}
- }
+ });
// TODO: Which thing should be done for choosing review requests
// to make chosen items be shown on time here?
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index f6d6c89816..534f517853 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import {useLightTextOnBackground} from '../utils.js';
const {csrfToken} = window.config;
@@ -183,26 +184,13 @@ export function initRepoProject() {
}
function setLabelColor(label, color) {
- const red = getRelativeColor(parseInt(color.slice(1, 3), 16));
- const green = getRelativeColor(parseInt(color.slice(3, 5), 16));
- const blue = getRelativeColor(parseInt(color.slice(5, 7), 16));
- const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
-
- if (luminance > 0.179) {
- label.removeClass('light-label').addClass('dark-label');
- } else {
+ if (useLightTextOnBackground(color)) {
label.removeClass('dark-label').addClass('light-label');
+ } else {
+ label.removeClass('light-label').addClass('dark-label');
}
}
-/**
- * Inspired by W3C recommendation https://www.w3.org/TR/WCAG20/#relativeluminancedef
- */
-function getRelativeColor(color) {
- color /= 255;
- return color <= 0.03928 ? color / 12.92 : ((color + 0.055) / 1.055) ** 2.4;
-}
-
function rgbToHex(rgb) {
rgb = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+).*\)$/);
return `#${hex(rgb[1])}${hex(rgb[2])}${hex(rgb[3])}`;
diff --git a/web_src/js/utils.js b/web_src/js/utils.js
index c7624404c7..b3ffbf2988 100644
--- a/web_src/js/utils.js
+++ b/web_src/js/utils.js
@@ -146,3 +146,18 @@ export function toAbsoluteUrl(url) {
}
return `${window.location.origin}${url}`;
}
+
+// determine if light or dark text color should be used on a given background color
+// NOTE: see models/issue_label.go for similar implementation
+export function useLightTextOnBackground(backgroundColor) {
+ if (backgroundColor[0] === '#') {
+ backgroundColor = backgroundColor.substring(1);
+ }
+ // Perceived brightness from: https://www.w3.org/TR/AERT/#color-contrast
+ // In the future WCAG 3 APCA may be a better solution.
+ const r = parseInt(backgroundColor.substring(0, 2), 16);
+ const g = parseInt(backgroundColor.substring(2, 4), 16);
+ const b = parseInt(backgroundColor.substring(4, 6), 16);
+ const brightness = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+ return brightness < 0.35;
+}
diff --git a/web_src/less/_base.less b/web_src/less/_base.less
index 4b65ae6812..771049ad39 100644
--- a/web_src/less/_base.less
+++ b/web_src/less/_base.less
@@ -1116,6 +1116,7 @@ a.ui.card:hover,
.ui.modal > .content {
background: var(--color-body);
+ text-align: left !important;
}
.ui.modal > .actions {
@@ -1364,6 +1365,10 @@ a.ui.card:hover,
-webkit-text-fill-color: var(--color-black) !important;
}
+.ui.form .field.muted {
+ opacity: var(--opacity-disabled);
+}
+
.ui.loading.loading.input > i.icon svg {
visibility: hidden;
}
@@ -2568,8 +2573,7 @@ table th[data-sortt-desc] {
border-top: none;
a {
- font-size: 15px;
- padding-top: 5px;
+ font-size: 12px;
padding-right: 10px;
color: var(--color-text-light);
@@ -2581,10 +2585,6 @@ table th[data-sortt-desc] {
margin-right: 30px;
}
}
-
- .ui.label {
- font-size: 1em;
- }
}
.item:last-child {
diff --git a/web_src/less/_repository.less b/web_src/less/_repository.less
index f7087d4d30..b2c4cdcdfb 100644
--- a/web_src/less/_repository.less
+++ b/web_src/less/_repository.less
@@ -92,7 +92,7 @@
.metas {
.menu {
overflow-x: auto;
- max-height: 300px;
+ max-height: 500px;
}
.ui.list {
@@ -155,12 +155,6 @@
}
.filter.menu {
- .label.color {
- border-radius: 3px;
- margin-left: 15px;
- padding: 0 8px;
- }
-
&.labels {
.label-filter .menu .info {
display: inline-block;
@@ -181,7 +175,7 @@
}
.menu {
- max-height: 300px;
+ max-height: 500px;
overflow-x: auto;
right: 0 !important;
left: auto !important;
@@ -190,7 +184,7 @@
.select-label {
.desc {
- padding-left: 16px;
+ padding-left: 23px;
}
}
@@ -607,7 +601,7 @@
min-width: 220px;
.filter.menu {
- max-height: 300px;
+ max-height: 500px;
overflow-x: auto;
}
}
@@ -2774,7 +2768,7 @@
}
.edit-label.modal,
-.new-label.segment {
+.new-label.modal {
.form {
.column {
padding-right: 0;
@@ -2786,12 +2780,9 @@
}
.color.picker.column {
- width: auto;
-
- .color-picker {
- height: 35px;
- width: auto;
- padding-left: 30px;
+ display: flex;
+ .minicolors {
+ flex: 1;
}
}
@@ -2872,6 +2863,35 @@
line-height: 1.3em; // there is a `font-size: 1.25em` for inside emoji, so here the line-height needs to be larger slightly
}
+// Scoped labels with different colors on left and right, and slanted divider in the middle
+.scope-parent {
+ background: none !important;
+ padding: 0 !important;
+}
+
+.ui.label.scope-left {
+ border-bottom-right-radius: 0;
+ border-top-right-radius: 0;
+ padding-right: 0;
+ margin-right: 0;
+}
+
+.ui.label.scope-middle {
+ width: 12px;
+ border-radius: 0;
+ padding-left: 0;
+ padding-right: 0;
+ margin-left: 0;
+ margin-right: 0;
+}
+
+.ui.label.scope-right {
+ border-bottom-left-radius: 0;
+ border-top-left-radius: 0;
+ padding-left: 0;
+ margin-left: 0;
+}
+
.repo-button-row {
margin-bottom: 10px;
}