Browse Source

Batch updates for issues (#926)

Ethan Koenig 2 years ago
parent
commit
09fe4a2ae9

+ 5 - 6
cmd/web.go

@@ -467,16 +467,15 @@ func runWeb(ctx *cli.Context) error {
467 467
 				Post(bindIgnErr(auth.CreateIssueForm{}), repo.NewIssuePost)
468 468
 
469 469
 			m.Group("/:index", func() {
470
-				m.Post("/label", repo.UpdateIssueLabel)
471
-				m.Post("/milestone", repo.UpdateIssueMilestone)
472
-				m.Post("/assignee", repo.UpdateIssueAssignee)
473
-			}, reqRepoWriter)
474
-
475
-			m.Group("/:index", func() {
476 470
 				m.Post("/title", repo.UpdateIssueTitle)
477 471
 				m.Post("/content", repo.UpdateIssueContent)
478 472
 				m.Combo("/comments").Post(bindIgnErr(auth.CreateCommentForm{}), repo.NewComment)
479 473
 			})
474
+
475
+			m.Post("/labels", repo.UpdateIssueLabel, reqRepoWriter)
476
+			m.Post("/milestone", repo.UpdateIssueMilestone, reqRepoWriter)
477
+			m.Post("/assignee", repo.UpdateIssueAssignee, reqRepoWriter)
478
+			m.Post("/status", repo.UpdateIssueStatus, reqRepoWriter)
480 479
 		})
481 480
 		m.Group("/comments/:id", func() {
482 481
 			m.Post("", repo.UpdateCommentContent)

+ 10 - 0
models/issue.go

@@ -1002,6 +1002,16 @@ func GetIssueByID(id int64) (*Issue, error) {
1002 1002
 	return getIssueByID(x, id)
1003 1003
 }
1004 1004
 
1005
+func getIssuesByIDs(e Engine, issueIDs []int64) ([]*Issue, error) {
1006
+	issues := make([]*Issue, 0, 10)
1007
+	return issues, e.In("id", issueIDs).Find(&issues)
1008
+}
1009
+
1010
+// GetIssuesByIDs return issues with the given IDs.
1011
+func GetIssuesByIDs(issueIDs []int64) ([]*Issue, error) {
1012
+	return getIssuesByIDs(x, issueIDs)
1013
+}
1014
+
1005 1015
 // IssuesOptions represents options of an issue.
1006 1016
 type IssuesOptions struct {
1007 1017
 	RepoID      int64

+ 16 - 0
models/issue_test.go

@@ -42,3 +42,19 @@ func TestIssueAPIURL(t *testing.T) {
42 42
 	assert.NoError(t, err)
43 43
 	assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/issues/1", issue.APIURL())
44 44
 }
45
+
46
+func TestGetIssuesByIDs(t *testing.T) {
47
+	assert.NoError(t, PrepareTestDatabase())
48
+	testSuccess := func(expectedIssueIDs []int64, nonExistentIssueIDs []int64) {
49
+		issues, err := GetIssuesByIDs(append(expectedIssueIDs, nonExistentIssueIDs...))
50
+		assert.NoError(t, err)
51
+		actualIssueIDs := make([]int64, len(issues))
52
+		for i, issue := range issues {
53
+			actualIssueIDs[i] = issue.ID
54
+		}
55
+		assert.Equal(t, expectedIssueIDs, actualIssueIDs)
56
+
57
+	}
58
+	testSuccess([]int64{1, 2, 3}, []int64{})
59
+	testSuccess([]int64{1, 2, 3}, []int64{NonexistentID})
60
+}

+ 7 - 0
options/locale/locale_en-US.ini

@@ -583,6 +583,13 @@ issues.filter_sort.recentupdate = Recently updated
583 583
 issues.filter_sort.leastupdate = Least recently updated
584 584
 issues.filter_sort.mostcomment = Most commented
585 585
 issues.filter_sort.leastcomment = Least commented
586
+issues.action_open = Open
587
+issues.action_close = Close
588
+issues.action_label = Label
589
+issues.action_milestone = Milestone
590
+issues.action_milestone_no_select = No milestone
591
+issues.action_assignee = Assignee
592
+issues.action_assignee_no_select = No assignee
586 593
 issues.opened_by = opened %[1]s by <a href="%[2]s">%[3]s</a>
587 594
 issues.opened_by_fake = opened %[1]s by %[2]s
588 595
 issues.previous = Previous

+ 3 - 0
public/css/index.css

@@ -2270,6 +2270,9 @@ footer .ui.language .menu {
2270 2270
 #search-user-box .results .item img {
2271 2271
   margin-right: 8px;
2272 2272
 }
2273
+.issue-actions {
2274
+  display: none;
2275
+}
2273 2276
 .issue.list {
2274 2277
   list-style: none;
2275 2278
   padding-top: 15px;

+ 67 - 13
public/js/index.js

@@ -87,6 +87,20 @@ function initEditForm() {
87 87
 }
88 88
 
89 89
 
90
+function updateIssuesMeta(url, action, issueIds, elementId, afterSuccess) {
91
+    $.ajax({
92
+        type: "POST",
93
+        url: url,
94
+        data: {
95
+            "_csrf": csrf,
96
+            "action": action,
97
+            "issue_ids": issueIds,
98
+            "id": elementId
99
+        },
100
+        success: afterSuccess
101
+    })
102
+}
103
+
90 104
 function initCommentForm() {
91 105
     if ($('.comment.form').length == 0) {
92 106
         return
@@ -100,14 +114,6 @@ function initCommentForm() {
100 114
     var $labelMenu = $('.select-label .menu');
101 115
     var hasLabelUpdateAction = $labelMenu.data('action') == 'update';
102 116
 
103
-    function updateIssueMeta(url, action, id) {
104
-        $.post(url, {
105
-            "_csrf": csrf,
106
-            "action": action,
107
-            "id": id
108
-        });
109
-    }
110
-
111 117
     $('.select-label').dropdown('setting', 'onHide', function(){
112 118
         if (hasLabelUpdateAction) {
113 119
             location.reload();
@@ -119,13 +125,23 @@ function initCommentForm() {
119 125
             $(this).removeClass('checked');
120 126
             $(this).find('.octicon').removeClass('octicon-check');
121 127
             if (hasLabelUpdateAction) {
122
-                updateIssueMeta($labelMenu.data('update-url'), "detach", $(this).data('id'));
128
+                updateIssuesMeta(
129
+                    $labelMenu.data('update-url'),
130
+                    "detach",
131
+                    $labelMenu.data('issue-id'),
132
+                    $(this).data('id')
133
+                );
123 134
             }
124 135
         } else {
125 136
             $(this).addClass('checked');
126 137
             $(this).find('.octicon').addClass('octicon-check');
127 138
             if (hasLabelUpdateAction) {
128
-                updateIssueMeta($labelMenu.data('update-url'), "attach", $(this).data('id'));
139
+                updateIssuesMeta(
140
+                    $labelMenu.data('update-url'),
141
+                    "attach",
142
+                    $labelMenu.data('issue-id'),
143
+                    $(this).data('id')
144
+                );
129 145
             }
130 146
         }
131 147
 
@@ -148,7 +164,12 @@ function initCommentForm() {
148 164
     });
149 165
     $labelMenu.find('.no-select.item').click(function () {
150 166
         if (hasLabelUpdateAction) {
151
-            updateIssueMeta($labelMenu.data('update-url'), "clear", '');
167
+            updateIssuesMeta(
168
+                $labelMenu.data('update-url'),
169
+                "clear",
170
+                $labelMenu.data('issue-id'),
171
+                ""
172
+            );
152 173
         }
153 174
 
154 175
         $(this).parent().find('.item').each(function () {
@@ -181,7 +202,12 @@ function initCommentForm() {
181 202
 
182 203
             $(this).addClass('selected active');
183 204
             if (hasUpdateAction) {
184
-                updateIssueMeta($menu.data('update-url'), '', $(this).data('id'));
205
+                updateIssuesMeta(
206
+                    $menu.data('update-url'),
207
+                    "",
208
+                    $menu.data('issue-id'),
209
+                    $(this).data('id')
210
+                );
185 211
             }
186 212
             switch (input_id) {
187 213
                 case '#milestone_id':
@@ -202,7 +228,12 @@ function initCommentForm() {
202 228
             });
203 229
 
204 230
             if (hasUpdateAction) {
205
-                updateIssueMeta($menu.data('update-url'), '', '');
231
+                updateIssuesMeta(
232
+                    $menu.data('update-url'),
233
+                    "",
234
+                    $menu.data('issue-id'),
235
+                    $(this).data('id')
236
+                );
206 237
             }
207 238
 
208 239
             $list.find('.selected').html('');
@@ -1431,6 +1462,29 @@ $(document).ready(function () {
1431 1462
     });
1432 1463
     $('.markdown').autolink();
1433 1464
 
1465
+    $('.issue-checkbox').click(function() {
1466
+        var numChecked = $('.issue-checkbox').children('input:checked').length;
1467
+        if (numChecked > 0) {
1468
+            $('.issue-filters').hide();
1469
+            $('.issue-actions').show();
1470
+        } else {
1471
+            $('.issue-filters').show();
1472
+            $('.issue-actions').hide();
1473
+        }
1474
+    });
1475
+
1476
+    $('.issue-action').click(function () {
1477
+        var action = this.dataset.action
1478
+        var elementId = this.dataset.elementId
1479
+        var issueIDs = $('.issue-checkbox').children('input:checked').map(function() {
1480
+            return this.dataset.issueId;
1481
+        }).get().join();
1482
+        var url = this.dataset.url
1483
+        updateIssuesMeta(url, action, issueIDs, elementId, function() {
1484
+            location.reload();
1485
+        });
1486
+    });
1487
+
1434 1488
     buttonsClickOnEnter();
1435 1489
     searchUsers();
1436 1490
     searchRepositories();

+ 4 - 0
public/less/_repository.less

@@ -1261,6 +1261,10 @@
1261 1261
 	}
1262 1262
 }
1263 1263
 
1264
+.issue-actions {
1265
+    display: none;
1266
+}
1267
+
1264 1268
 .issue.list {
1265 1269
 	list-style: none;
1266 1270
 	padding-top: 15px;

+ 71 - 22
routers/repo/issue.go

@@ -10,6 +10,7 @@ import (
10 10
 	"fmt"
11 11
 	"io"
12 12
 	"io/ioutil"
13
+	"strconv"
13 14
 	"strings"
14 15
 	"time"
15 16
 
@@ -644,6 +645,28 @@ func getActionIssue(ctx *context.Context) *models.Issue {
644 645
 	return issue
645 646
 }
646 647
 
648
+func getActionIssues(ctx *context.Context) []*models.Issue {
649
+	commaSeparatedIssueIDs := ctx.Query("issue_ids")
650
+	if len(commaSeparatedIssueIDs) == 0 {
651
+		return nil
652
+	}
653
+	issueIDs := make([]int64, 0, 10)
654
+	for _, stringIssueID := range strings.Split(commaSeparatedIssueIDs, ",") {
655
+		issueID, err := strconv.ParseInt(stringIssueID, 10, 64)
656
+		if err != nil {
657
+			ctx.Handle(500, "ParseInt", err)
658
+			return nil
659
+		}
660
+		issueIDs = append(issueIDs, issueID)
661
+	}
662
+	issues, err := models.GetIssuesByIDs(issueIDs)
663
+	if err != nil {
664
+		ctx.Handle(500, "GetIssuesByIDs", err)
665
+		return nil
666
+	}
667
+	return issues
668
+}
669
+
647 670
 // UpdateIssueTitle change issue's title
648 671
 func UpdateIssueTitle(ctx *context.Context) {
649 672
 	issue := getActionIssue(ctx)
@@ -697,25 +720,22 @@ func UpdateIssueContent(ctx *context.Context) {
697 720
 
698 721
 // UpdateIssueMilestone change issue's milestone
699 722
 func UpdateIssueMilestone(ctx *context.Context) {
700
-	issue := getActionIssue(ctx)
723
+	issues := getActionIssues(ctx)
701 724
 	if ctx.Written() {
702 725
 		return
703 726
 	}
704 727
 
705
-	oldMilestoneID := issue.MilestoneID
706 728
 	milestoneID := ctx.QueryInt64("id")
707
-	if oldMilestoneID == milestoneID {
708
-		ctx.JSON(200, map[string]interface{}{
709
-			"ok": true,
710
-		})
711
-		return
712
-	}
713
-
714
-	// Not check for invalid milestone id and give responsibility to owners.
715
-	issue.MilestoneID = milestoneID
716
-	if err := models.ChangeMilestoneAssign(issue, ctx.User, oldMilestoneID); err != nil {
717
-		ctx.Handle(500, "ChangeMilestoneAssign", err)
718
-		return
729
+	for _, issue := range issues {
730
+		oldMilestoneID := issue.MilestoneID
731
+		if oldMilestoneID == milestoneID {
732
+			continue
733
+		}
734
+		issue.MilestoneID = milestoneID
735
+		if err := models.ChangeMilestoneAssign(issue, ctx.User, oldMilestoneID); err != nil {
736
+			ctx.Handle(500, "ChangeMilestoneAssign", err)
737
+			return
738
+		}
719 739
 	}
720 740
 
721 741
 	ctx.JSON(200, map[string]interface{}{
@@ -725,24 +745,53 @@ func UpdateIssueMilestone(ctx *context.Context) {
725 745
 
726 746
 // UpdateIssueAssignee change issue's assignee
727 747
 func UpdateIssueAssignee(ctx *context.Context) {
728
-	issue := getActionIssue(ctx)
748
+	issues := getActionIssues(ctx)
729 749
 	if ctx.Written() {
730 750
 		return
731 751
 	}
732 752
 
733 753
 	assigneeID := ctx.QueryInt64("id")
734
-	if issue.AssigneeID == assigneeID {
735
-		ctx.JSON(200, map[string]interface{}{
736
-			"ok": true,
737
-		})
738
-		return
754
+	for _, issue := range issues {
755
+		if issue.AssigneeID == assigneeID {
756
+			continue
757
+		}
758
+		if err := issue.ChangeAssignee(ctx.User, assigneeID); err != nil {
759
+			ctx.Handle(500, "ChangeAssignee", err)
760
+			return
761
+		}
739 762
 	}
763
+	ctx.JSON(200, map[string]interface{}{
764
+		"ok": true,
765
+	})
766
+}
740 767
 
741
-	if err := issue.ChangeAssignee(ctx.User, assigneeID); err != nil {
742
-		ctx.Handle(500, "ChangeAssignee", err)
768
+// UpdateIssueStatus change issue's status
769
+func UpdateIssueStatus(ctx *context.Context) {
770
+	issues := getActionIssues(ctx)
771
+	if ctx.Written() {
743 772
 		return
744 773
 	}
745 774
 
775
+	var isClosed bool
776
+	switch action := ctx.Query("action"); action {
777
+	case "open":
778
+		isClosed = false
779
+	case "close":
780
+		isClosed = true
781
+	default:
782
+		log.Warn("Unrecognized action: %s", action)
783
+	}
784
+
785
+	if _, err := models.IssueList(issues).LoadRepositories(); err != nil {
786
+		ctx.Handle(500, "LoadRepositories", err)
787
+		return
788
+	}
789
+	for _, issue := range issues {
790
+		if err := issue.ChangeStatus(ctx.User, issue.Repo, isClosed); err != nil {
791
+			ctx.Handle(500, "ChangeStatus", err)
792
+			return
793
+		}
794
+	}
746 795
 	ctx.JSON(200, map[string]interface{}{
747 796
 		"ok": true,
748 797
 	})

+ 41 - 15
routers/repo/issue_label.go

@@ -9,6 +9,7 @@ import (
9 9
 	"code.gitea.io/gitea/modules/auth"
10 10
 	"code.gitea.io/gitea/modules/base"
11 11
 	"code.gitea.io/gitea/modules/context"
12
+	"code.gitea.io/gitea/modules/log"
12 13
 )
13 14
 
14 15
 const (
@@ -129,18 +130,20 @@ func DeleteLabel(ctx *context.Context) {
129 130
 
130 131
 // UpdateIssueLabel change issue's labels
131 132
 func UpdateIssueLabel(ctx *context.Context) {
132
-	issue := getActionIssue(ctx)
133
+	issues := getActionIssues(ctx)
133 134
 	if ctx.Written() {
134 135
 		return
135 136
 	}
136 137
 
137
-	if ctx.Query("action") == "clear" {
138
-		if err := issue.ClearLabels(ctx.User); err != nil {
139
-			ctx.Handle(500, "ClearLabels", err)
140
-			return
138
+	switch action := ctx.Query("action"); action {
139
+	case "clear":
140
+		for _, issue := range issues {
141
+			if err := issue.ClearLabels(ctx.User); err != nil {
142
+				ctx.Handle(500, "ClearLabels", err)
143
+				return
144
+			}
141 145
 		}
142
-	} else {
143
-		isAttach := ctx.Query("action") == "attach"
146
+	case "attach", "detach", "toggle":
144 147
 		label, err := models.GetLabelByID(ctx.QueryInt64("id"))
145 148
 		if err != nil {
146 149
 			if models.IsErrLabelNotExist(err) {
@@ -151,17 +154,40 @@ func UpdateIssueLabel(ctx *context.Context) {
151 154
 			return
152 155
 		}
153 156
 
154
-		if isAttach && !issue.HasLabel(label.ID) {
155
-			if err = issue.AddLabel(ctx.User, label); err != nil {
156
-				ctx.Handle(500, "AddLabel", err)
157
-				return
157
+		if action == "toggle" {
158
+			anyHaveLabel := false
159
+			for _, issue := range issues {
160
+				if issue.HasLabel(label.ID) {
161
+					anyHaveLabel = true
162
+					break
163
+				}
158 164
 			}
159
-		} else if !isAttach && issue.HasLabel(label.ID) {
160
-			if err = issue.RemoveLabel(ctx.User, label); err != nil {
161
-				ctx.Handle(500, "RemoveLabel", err)
162
-				return
165
+			if anyHaveLabel {
166
+				action = "detach"
167
+			} else {
168
+				action = "attach"
169
+			}
170
+		}
171
+
172
+		if action == "attach" {
173
+			for _, issue := range issues {
174
+				if err = issue.AddLabel(ctx.User, label); err != nil {
175
+					ctx.Handle(500, "AddLabel", err)
176
+					return
177
+				}
178
+			}
179
+		} else {
180
+			for _, issue := range issues {
181
+				if err = issue.RemoveLabel(ctx.User, label); err != nil {
182
+					ctx.Handle(500, "RemoveLabel", err)
183
+					return
184
+				}
163 185
 			}
164 186
 		}
187
+	default:
188
+		log.Warn("Unrecognized action: %s", action)
189
+		ctx.Error(500)
190
+		return
165 191
 	}
166 192
 
167 193
 	ctx.JSON(200, map[string]interface{}{

+ 135 - 71
templates/repo/issue/list.tmpl

@@ -14,86 +14,147 @@
14 14
 			</div>
15 15
 		</div>
16 16
 		<div class="ui divider"></div>
17
-		<div class="ui tiny basic status buttons">
18
-			<a class="ui {{if not .IsShowClosed}}green active{{end}} basic button" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state=open&labels={{.SelectLabels}}&milestone={{.MilestoneID}}&assignee={{.AssigneeID}}">
19
-				<i class="octicon octicon-issue-opened"></i>
20
-				{{.i18n.Tr "repo.issues.open_tab" .IssueStats.OpenCount}}
21
-			</a>
22
-			<a class="ui {{if .IsShowClosed}}red active{{end}} basic button" href="{{$.Link}}?q={{$.Keyword}}&type={{.ViewType}}&sort={{$.SortType}}&state=closed&labels={{.SelectLabels}}&milestone={{.MilestoneID}}&assignee={{.AssigneeID}}">
23
-				<i class="octicon octicon-issue-closed"></i>
24
-				{{.i18n.Tr "repo.issues.close_tab" .IssueStats.ClosedCount}}
25
-			</a>
26
-		</div>
27
-		<div class="ui right floated secondary filter menu">
28
-			<!-- Label -->
29
-			<div class="ui {{if not .Labels}}disabled{{end}} dropdown jump item">
30
-				<span class="text">
31
-					{{.i18n.Tr "repo.issues.filter_label"}}
32
-					<i class="dropdown icon"></i>
33
-				</span>
34
-				<div class="menu">
35
-					<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>
36
-					{{range .Labels}}
37
-						<a class="item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.ID}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}"><span class="octicon {{if eq $.SelectLabels .ID}}octicon-check{{end}}"></span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name | Sanitize}}</a>
38
-					{{end}}
39
-				</div>
17
+		<div class="issue-filters">
18
+			<div class="ui tiny basic status buttons">
19
+				<a class="ui {{if not .IsShowClosed}}green active{{end}} basic button" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state=open&labels={{.SelectLabels}}&milestone={{.MilestoneID}}&assignee={{.AssigneeID}}">
20
+					<i class="octicon octicon-issue-opened"></i>
21
+					{{.i18n.Tr "repo.issues.open_tab" .IssueStats.OpenCount}}
22
+				</a>
23
+				<a class="ui {{if .IsShowClosed}}red active{{end}} basic button" href="{{$.Link}}?q={{$.Keyword}}&type={{.ViewType}}&sort={{$.SortType}}&state=closed&labels={{.SelectLabels}}&milestone={{.MilestoneID}}&assignee={{.AssigneeID}}">
24
+					<i class="octicon octicon-issue-closed"></i>
25
+					{{.i18n.Tr "repo.issues.close_tab" .IssueStats.ClosedCount}}
26
+				</a>
40 27
 			</div>
28
+			<div class="ui right floated secondary filter menu">
29
+				<!-- Label -->
30
+				<div class="ui {{if not .Labels}}disabled{{end}} dropdown jump item">
31
+					<span class="text">
32
+						{{.i18n.Tr "repo.issues.filter_label"}}
33
+						<i class="dropdown icon"></i>
34
+					</span>
35
+					<div class="menu">
36
+						<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>
37
+						{{range .Labels}}
38
+							<a class="item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.ID}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}"><span class="octicon {{if eq $.SelectLabels .ID}}octicon-check{{end}}"></span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name | Sanitize}}</a>
39
+						{{end}}
40
+					</div>
41
+				</div>
41 42
 
42
-			<!-- Milestone -->
43
-			<div class="ui {{if not .Milestones}}disabled{{end}} dropdown jump item">
44
-				<span class="text">
45
-					{{.i18n.Tr "repo.issues.filter_milestone"}}
46
-					<i class="dropdown icon"></i>
47
-				</span>
48
-				<div class="menu">
49
-					<a class="item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_milestone_no_select"}}</a>
50
-					{{range .Milestones}}
51
-						<a class="{{if eq $.MilestoneID .ID}}active selected{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{$.SelectLabels}}&milestone={{.ID}}&assignee={{$.AssigneeID}}">{{.Name | Sanitize}}</a>
52
-					{{end}}
43
+				<!-- Milestone -->
44
+				<div class="ui {{if not .Milestones}}disabled{{end}} dropdown jump item">
45
+					<span class="text">
46
+						{{.i18n.Tr "repo.issues.filter_milestone"}}
47
+						<i class="dropdown icon"></i>
48
+					</span>
49
+					<div class="menu">
50
+						<a class="item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_milestone_no_select"}}</a>
51
+						{{range .Milestones}}
52
+							<a class="{{if eq $.MilestoneID .ID}}active selected{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{$.SelectLabels}}&milestone={{.ID}}&assignee={{$.AssigneeID}}">{{.Name | Sanitize}}</a>
53
+						{{end}}
54
+					</div>
53 55
 				</div>
54
-			</div>
55 56
 
56
-			<!-- Assignee -->
57
-			<div class="ui {{if not .Assignees}}disabled{{end}} dropdown jump item">
58
-				<span class="text">
59
-					{{.i18n.Tr "repo.issues.filter_assignee"}}
60
-					<i class="dropdown icon"></i>
61
-				</span>
62
-				<div class="menu">
63
-					<a class="item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}">{{.i18n.Tr "repo.issues.filter_assginee_no_select"}}</a>
64
-					{{range .Assignees}}
65
-						<a class="{{if eq $.AssigneeID .ID}}active selected{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{$.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{.ID}}"><img src="{{.RelAvatarLink}}"> {{.Name}}</a>
66
-					{{end}}
57
+				<!-- Assignee -->
58
+				<div class="ui {{if not .Assignees}}disabled{{end}} dropdown jump item">
59
+					<span class="text">
60
+						{{.i18n.Tr "repo.issues.filter_assignee"}}
61
+						<i class="dropdown icon"></i>
62
+					</span>
63
+					<div class="menu">
64
+						<a class="item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}">{{.i18n.Tr "repo.issues.filter_assginee_no_select"}}</a>
65
+						{{range .Assignees}}
66
+							<a class="{{if eq $.AssigneeID .ID}}active selected{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{$.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{.ID}}"><img src="{{.RelAvatarLink}}"> {{.Name}}</a>
67
+						{{end}}
68
+					</div>
69
+				</div>
70
+
71
+				<!-- Type -->
72
+				<div class="ui dropdown type jump item">
73
+					<span class="text">
74
+						{{.i18n.Tr "repo.issues.filter_type"}}
75
+						<i class="dropdown icon"></i>
76
+					</span>
77
+					<div class="menu">
78
+						<a class="{{if eq .ViewType "all"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type=all&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_type.all_issues"}}</a>
79
+						<a class="{{if eq .ViewType "assigned"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type=assigned&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_type.assigned_to_you"}}</a>
80
+						<a class="{{if eq .ViewType "created_by"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type=created_by&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_type.created_by_you"}}</a>
81
+						<a class="{{if eq .ViewType "mentioned"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type=mentioned&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_type.mentioning_you"}}</a>
82
+					</div>
67 83
 				</div>
68
-			</div>
69 84
 
70
-			<!-- Type -->
71
-			<div class="ui dropdown type jump item">
72
-				<span class="text">
73
-					{{.i18n.Tr "repo.issues.filter_type"}}
74
-					<i class="dropdown icon"></i>
75
-				</span>
76
-				<div class="menu">
77
-					<a class="{{if eq .ViewType "all"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type=all&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_type.all_issues"}}</a>
78
-					<a class="{{if eq .ViewType "assigned"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type=assigned&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_type.assigned_to_you"}}</a>
79
-					<a class="{{if eq .ViewType "created_by"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type=created_by&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_type.created_by_you"}}</a>
80
-					<a class="{{if eq .ViewType "mentioned"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type=mentioned&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_type.mentioning_you"}}</a>
85
+				<!-- Sort -->
86
+				<div class="ui dropdown type jump item">
87
+					<span class="text">
88
+						{{.i18n.Tr "repo.issues.filter_sort"}}
89
+						<i class="dropdown icon"></i>
90
+					</span>
91
+					<div class="menu">
92
+						<a class="{{if or (eq .SortType "latest") (not .SortType)}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort=latest&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_sort.latest"}}</a>
93
+						<a class="{{if eq .SortType "oldest"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort=oldest&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_sort.oldest"}}</a>
94
+						<a class="{{if eq .SortType "recentupdate"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort=recentupdate&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_sort.recentupdate"}}</a>
95
+						<a class="{{if eq .SortType "leastupdate"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort=leastupdate&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_sort.leastupdate"}}</a>
96
+						<a class="{{if eq .SortType "mostcomment"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort=mostcomment&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_sort.mostcomment"}}</a>
97
+						<a class="{{if eq .SortType "leastcomment"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort=leastcomment&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_sort.leastcomment"}}</a>
98
+					</div>
81 99
 				</div>
82 100
 			</div>
101
+		</div>
102
+		<div class="issue-actions">
103
+			<div class="ui basic status buttons">
104
+				<div class="ui green active basic button issue-action" data-action="open" data-url="{{$.Link}}/status">{{.i18n.Tr "repo.issues.action_open"}}</div>
105
+				<div class="ui red active basic button issue-action" data-action="close" data-url="{{$.Link}}/status">{{.i18n.Tr "repo.issues.action_close"}}</div>
106
+			</div>
83 107
 
84
-			<!-- Sort -->
85
-			<div class="ui dropdown type jump item">
86
-				<span class="text">
87
-					{{.i18n.Tr "repo.issues.filter_sort"}}
88
-					<i class="dropdown icon"></i>
89
-				</span>
90
-				<div class="menu">
91
-					<a class="{{if or (eq .SortType "latest") (not .SortType)}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort=latest&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_sort.latest"}}</a>
92
-					<a class="{{if eq .SortType "oldest"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort=oldest&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_sort.oldest"}}</a>
93
-					<a class="{{if eq .SortType "recentupdate"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort=recentupdate&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_sort.recentupdate"}}</a>
94
-					<a class="{{if eq .SortType "leastupdate"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort=leastupdate&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_sort.leastupdate"}}</a>
95
-					<a class="{{if eq .SortType "mostcomment"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort=mostcomment&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_sort.mostcomment"}}</a>
96
-					<a class="{{if eq .SortType "leastcomment"}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort=leastcomment&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&assignee={{$.AssigneeID}}">{{.i18n.Tr "repo.issues.filter_sort.leastcomment"}}</a>
108
+			<div class="ui secondary filter menu floated right">
109
+				<!-- Labels -->
110
+				<div class="ui {{if not .Labels}}disabled{{end}} dropdown jump item">
111
+					<span class="text">
112
+						{{.i18n.Tr "repo.issues.action_label"}}
113
+						<i class="dropdown icon"></i>
114
+					</span>
115
+					<div class="menu">
116
+						{{range .Labels}}
117
+							<div class="item issue-action" data-action="toggle" data-element-id="{{.ID}}" data-url="{{$.Link}}/labels">
118
+								<span class="octicon {{if eq $.SelectLabels .ID}}octicon-check{{end}}"></span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name | Sanitize}}
119
+							</div>
120
+						{{end}}
121
+					</div>
122
+				</div>
123
+
124
+				<!-- Milestone -->
125
+				<div class="ui {{if not .Milestones}}disabled{{end}} dropdown jump item">
126
+					<span class="text">
127
+						{{.i18n.Tr "repo.issues.action_milestone"}}
128
+						<i class="dropdown icon"></i>
129
+					</span>
130
+					<div class="menu">
131
+						<div class="item issue-action" data-element-id="0" data-url="{{$.Link}}/milestone">
132
+						  {{.i18n.Tr "repo.issues.action_milestone_no_select"}}
133
+						</div>
134
+						{{range .Milestones}}
135
+							<div class="item issue-action" data-element-id="{{.ID}}" data-url="{{$.Link}}/milestone">
136
+								{{.Name | Sanitize}}
137
+							</div>
138
+						{{end}}
139
+					</div>
140
+				</div>
141
+
142
+				<!-- Assignee -->
143
+				<div class="ui {{if not .Assignees}}disabled{{end}} dropdown jump item">
144
+					<span class="text">
145
+						{{.i18n.Tr "repo.issues.action_assignee"}}
146
+						<i class="dropdown icon"></i>
147
+					</span>
148
+					<div class="menu">
149
+						<div class="item issue-action" data-element-id="0" data-url="{{$.Link}}/assignee">
150
+							{{.i18n.Tr "repo.issues.action_assignee_no_select"}}
151
+						</div>
152
+						{{range .Assignees}}
153
+							<div class="item issue-action" data-element-id="{{.ID}}" data-url="{{$.Link}}/assignee">
154
+								<img src="{{.RelAvatarLink}}"> {{.Name}}
155
+							</div>
156
+						{{end}}
157
+					</div>
97 158
 				</div>
98 159
 			</div>
99 160
 		</div>
@@ -102,6 +163,9 @@
102 163
 			{{range .Issues}}
103 164
 				{{ $timeStr:= TimeSince .Created $.Lang }}
104 165
 				<li class="item">
166
+					<div class="ui checkbox issue-checkbox">
167
+						<input type="checkbox" data-issue-id={{.ID}}></input>
168
+					</div>
105 169
 					<div class="ui {{if .IsRead}}black{{else}}green{{end}} label">#{{.Index}}</div>
106 170
 					<a class="title has-emoji" href="{{$.Link}}/{{.Index}}">{{.Title}}</a>
107 171
 

+ 4 - 4
templates/repo/issue/view_content.tmpl

@@ -311,7 +311,7 @@
311 311
 					<strong>{{.i18n.Tr "repo.issues.new.labels"}}</strong>
312 312
 					<span class="octicon octicon-gear"></span>
313 313
 				</span>
314
-				<div class="filter menu" data-action="update" data-update-url="{{$.RepoLink}}/issues/{{$.Issue.Index}}/label">
314
+				<div class="filter menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/labels">
315 315
 					<div class="no-select item">{{.i18n.Tr "repo.issues.new.clear_labels"}}</div>
316 316
 					{{range .Labels}}
317 317
 						<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}"><span class="octicon {{if .IsChecked}}octicon-check{{end}}"></span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name}}</a>
@@ -335,7 +335,7 @@
335 335
 					<strong>{{.i18n.Tr "repo.issues.new.milestone"}}</strong>
336 336
 					<span class="octicon octicon-gear"></span>
337 337
 				</span>
338
-				<div class="menu" data-action="update" data-update-url="{{$.RepoLink}}/issues/{{$.Issue.Index}}/milestone">
338
+				<div class="menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/milestone">
339 339
 					<div class="no-select item">{{.i18n.Tr "repo.issues.new.clear_milestone"}}</div>
340 340
 					{{if .OpenMilestones}}
341 341
 						<div class="divider"></div>
@@ -376,7 +376,7 @@
376 376
 					<strong>{{.i18n.Tr "repo.issues.new.assignee"}}</strong>
377 377
 					<span class="octicon octicon-gear"></span>
378 378
 				</span>
379
-				<div class="menu" data-action="update" data-update-url="{{$.RepoLink}}/issues/{{$.Issue.Index}}/assignee">
379
+				<div class="menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/assignee">
380 380
 					<div class="no-select item">{{.i18n.Tr "repo.issues.new.clear_assignee"}}</div>
381 381
 					{{range .Assignees}}
382 382
 						<div class="item" data-id="{{.ID}}" data-href="{{$.RepoLink}}/issues?assignee={{.ID}}" data-avatar="{{.RelAvatarLink}}"><img src="{{.RelAvatarLink}}"> {{.Name}}</div>
@@ -442,4 +442,4 @@
442 442
 		{{.i18n.Tr "repo.branch.delete_notices_2" .HeadTarget | Safe}}<br>
443 443
 	</div>
444 444
 	{{template "base/delete_modal_actions" .}}
445
-</div>
445
+</div>