From dbd9d8dd54fd3b0ff7b02ae7b7da8369654e3725 Mon Sep 17 00:00:00 2001
From: jaqra <48099350+jaqra@users.noreply.github.com>
Date: Wed, 23 Oct 2019 19:29:14 +0300
Subject: [PATCH] Add 'Alt + click' feature to exclude labels (#8199)

Add 'Alt + click' and 'Alt +enter' feature to exclude particular labels on searching for issues.
---
 models/issue.go                 |  8 ++++++--
 models/issue_label.go           |  6 +++++-
 options/locale/locale_en-US.ini |  1 +
 public/css/index.css            |  2 ++
 public/js/index.js              | 35 +++++++++++++++++++++++++++++++--
 public/less/_repository.less    | 17 ++++++++++++++++
 templates/repo/issue/list.tmpl  |  5 +++--
 7 files changed, 67 insertions(+), 7 deletions(-)

diff --git a/models/issue.go b/models/issue.go
index 688a412d8c..f006917891 100644
--- a/models/issue.go
+++ b/models/issue.go
@@ -1248,8 +1248,12 @@ func (opts *IssuesOptions) setupSession(sess *xorm.Session) {
 
 	if opts.LabelIDs != nil {
 		for i, labelID := range opts.LabelIDs {
-			sess.Join("INNER", fmt.Sprintf("issue_label il%d", i),
-				fmt.Sprintf("issue.id = il%[1]d.issue_id AND il%[1]d.label_id = %[2]d", i, labelID))
+			if labelID > 0 {
+				sess.Join("INNER", fmt.Sprintf("issue_label il%d", i),
+					fmt.Sprintf("issue.id = il%[1]d.issue_id AND il%[1]d.label_id = %[2]d", i, labelID))
+			} else {
+				sess.Where("issue.id not in (select issue_id from issue_label where label_id = ?)", -labelID)
+			}
 		}
 	}
 }
diff --git a/models/issue_label.go b/models/issue_label.go
index 9efc7fd51f..1fc873cfd4 100644
--- a/models/issue_label.go
+++ b/models/issue_label.go
@@ -72,6 +72,7 @@ type Label struct {
 	IsChecked       bool   `xorm:"-"`
 	QueryString     string `xorm:"-"`
 	IsSelected      bool   `xorm:"-"`
+	IsExcluded      bool   `xorm:"-"`
 }
 
 // APIFormat converts a Label to the api.Label format
@@ -97,7 +98,10 @@ func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64)
 	for _, s := range currentSelectedLabels {
 		if s == label.ID {
 			labelSelected = true
-		} else if s > 0 {
+		} else if -s == label.ID {
+			labelSelected = true
+			label.IsExcluded = true
+		} else if s != 0 {
 			labelQuerySlice = append(labelQuerySlice, strconv.FormatInt(s, 10))
 		}
 	}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index eb38a777c8..60acab0178 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -802,6 +802,7 @@ issues.delete_branch_at = `deleted branch <b>%s</b> %s`
 issues.open_tab = %d Open
 issues.close_tab = %d Closed
 issues.filter_label = Label
+issues.filter_label_exclude = `Use <code>alt</code> + <code>click/enter</code> to exclude labels`
 issues.filter_label_no_select = All labels
 issues.filter_milestone = Milestone
 issues.filter_milestone_no_select = All milestones
diff --git a/public/css/index.css b/public/css/index.css
index 9292604422..e404c1fec6 100644
--- a/public/css/index.css
+++ b/public/css/index.css
@@ -458,6 +458,8 @@ i.icon.centerlock{top:1.5em}
 .repository .filter.menu .label.color{border-radius:3px;margin-left:15px;padding:0 8px}
 .repository .filter.menu .octicon{float:left;margin:5px -7px 0 -5px;width:16px}
 .repository .filter.menu.labels .octicon{margin:-2px -7px 0 -5px}
+.repository .filter.menu.labels .label-filter .menu .info{display:inline-block;padding:9px 7px 7px 7px;text-align:center;border-bottom:1px solid #ccc;font-size:12px}
+.repository .filter.menu.labels .label-filter .menu .info code{border:1px solid #ccc;border-radius:3px;padding:3px 2px 1px 2px;font-size:11px}
 .repository .filter.menu .text{margin-left:.9em}
 .repository .filter.menu .menu{max-height:300px;overflow-x:auto;right:0!important;left:auto!important}
 .repository .filter.menu .dropdown.item{margin:1px;padding-right:0}
diff --git a/public/js/index.js b/public/js/index.js
index 90819677a5..cf19bf71a0 100644
--- a/public/js/index.js
+++ b/public/js/index.js
@@ -3278,8 +3278,39 @@ function initIssueList() {
             },
 
             fullTextSearch: true
-        })
-    ;
+        });
+
+    $(".menu a.label-filter-item").each(function() {
+        $(this).click(function(e) {
+            if (e.altKey) {
+                const href = $(this).attr("href");
+                const id = $(this).data("label-id");
+
+                const regStr = "labels=(-?[0-9]+%2c)*(" + id + ")(%2c-?[0-9]+)*&";
+                const newStr = "labels=$1-$2$3&";
+
+                window.location = href.replace(new RegExp(regStr), newStr);
+            }
+        });
+    });
+
+    $(".menu .ui.dropdown.label-filter").keydown(function(e) {
+        if (e.altKey && e.keyCode == 13) {
+            const selectedItems = $(".menu .ui.dropdown.label-filter .menu .item.selected");
+
+            if (selectedItems.length > 0) {
+                const item = $(selectedItems[0]);
+
+                const href = item.attr("href");
+                const id = item.data("label-id");
+
+                const regStr = "labels=(-?[0-9]+%2c)*(" + id + ")(%2c-?[0-9]+)*&";
+                const newStr = "labels=$1-$2$3&";
+
+                window.location = href.replace(new RegExp(regStr), newStr);
+            }
+        }
+    });
 }
 function cancelCodeComment(btn) {
     const form = $(btn).closest("form");
diff --git a/public/less/_repository.less b/public/less/_repository.less
index 33ee5761c4..48a1214c07 100644
--- a/public/less/_repository.less
+++ b/public/less/_repository.less
@@ -158,6 +158,23 @@
             margin: -2px -7px 0 -5px;
         }
 
+        &.labels {
+            .label-filter .menu .info {
+                display: inline-block;
+                padding: 9px 7px 7px 7px;
+                text-align: center;
+                border-bottom: 1px solid #cccccc;
+                font-size: 12px;
+
+                code {
+                    border: 1px solid #cccccc;
+                    border-radius: 3px;
+                    padding: 3px 2px 1px 2px;
+                    font-size: 11px;
+                }
+            }
+        }
+
         .text {
             margin-left: 0.9em;
         }
diff --git a/templates/repo/issue/list.tmpl b/templates/repo/issue/list.tmpl
index e64cef2724..9b354a6800 100644
--- a/templates/repo/issue/list.tmpl
+++ b/templates/repo/issue/list.tmpl
@@ -42,15 +42,16 @@
 			<div class="ten wide right aligned column">
 				<div class="ui secondary filter stackable menu labels">
 					<!-- Label -->
-					<div class="ui {{if not .Labels}}disabled{{end}} dropdown jump item" style="margin-left: auto">
+					<div class="ui {{if not .Labels}}disabled{{end}} dropdown jump item label-filter" style="margin-left: auto">
 						<span class="text">
 							{{.i18n.Tr "repo.issues.filter_label"}}
 							<i class="dropdown icon"></i>
 						</span>
 						<div class="menu">
+							<span class="info">{{.i18n.Tr "repo.issues.filter_label_exclude" | Safe}}</span>
 							<a class="item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_label_no_select"}}</a>
 							{{range .Labels}}
-								<a class="item has-emoji" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.QueryString}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}"><span class="octicon {{if .IsSelected}}octicon-check{{end}}"></span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name}}</a>
+								<a class="item has-emoji label-filter-item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.QueryString}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}" data-label-id="{{.ID}}"><span class="octicon {{if .IsExcluded}}octicon-circle-slash{{else if .IsSelected}}octicon-check{{end}}"></span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name}}</a>
 							{{end}}
 						</div>
 					</div>