diff options
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%%);'> </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> {{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> {{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> {{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> {{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; } |