From fa06e98553b78da66cb75e13cffe56b3ef013447 Mon Sep 17 00:00:00 2001
From: Roger Luo <rogerluo410@gmail.com>
Date: Thu, 8 Apr 2021 19:53:59 +0800
Subject: [PATCH] Add dashboard milestone search and repo milestone search by
 name (#14866)

Feature for issue #13845:
 - Add milestones search by name on dashboard milestones page.
 - Add milestones search by name on repo issue/milestones page.
---
 models/issue_milestone.go                | 66 +++++++++++++++++++++-
 routers/repo/milestone.go                |  7 +++
 routers/user/home.go                     | 13 +++--
 templates/repo/issue/milestones.tmpl     | 66 ++++++++++++++--------
 templates/user/dashboard/milestones.tmpl | 71 ++++++++++++++----------
 5 files changed, 165 insertions(+), 58 deletions(-)

diff --git a/models/issue_milestone.go b/models/issue_milestone.go
index ec3cbb91db..5aa83ea691 100644
--- a/models/issue_milestone.go
+++ b/models/issue_milestone.go
@@ -426,9 +426,12 @@ func GetMilestones(opts GetMilestonesOption) (MilestoneList, error) {
 }
 
 // SearchMilestones search milestones
-func SearchMilestones(repoCond builder.Cond, page int, isClosed bool, sortType string) (MilestoneList, error) {
+func SearchMilestones(repoCond builder.Cond, page int, isClosed bool, sortType string, keyword string) (MilestoneList, error) {
 	miles := make([]*Milestone, 0, setting.UI.IssuePagingNum)
 	sess := x.Where("is_closed = ?", isClosed)
+	if len(keyword) > 0 {
+		sess = sess.And(builder.Like{"UPPER(name)", strings.ToUpper(keyword)})
+	}
 	if repoCond.IsValid() {
 		sess.In("repo_id", builder.Select("id").From("repository").Where(repoCond))
 	}
@@ -460,6 +463,7 @@ func GetMilestonesByRepoIDs(repoIDs []int64, page int, isClosed bool, sortType s
 		page,
 		isClosed,
 		sortType,
+		"",
 	)
 }
 
@@ -506,6 +510,38 @@ func GetMilestonesStatsByRepoCond(repoCond builder.Cond) (*MilestonesStats, erro
 	return stats, nil
 }
 
+// GetMilestonesStatsByRepoCondAndKw returns milestone statistic information for dashboard by given repo conditions and name keyword.
+func GetMilestonesStatsByRepoCondAndKw(repoCond builder.Cond, keyword string) (*MilestonesStats, error) {
+	var err error
+	stats := &MilestonesStats{}
+
+	sess := x.Where("is_closed = ?", false)
+	if len(keyword) > 0 {
+		sess = sess.And(builder.Like{"UPPER(name)", strings.ToUpper(keyword)})
+	}
+	if repoCond.IsValid() {
+		sess.And(builder.In("repo_id", builder.Select("id").From("repository").Where(repoCond)))
+	}
+	stats.OpenCount, err = sess.Count(new(Milestone))
+	if err != nil {
+		return nil, err
+	}
+
+	sess = x.Where("is_closed = ?", true)
+	if len(keyword) > 0 {
+		sess = sess.And(builder.Like{"UPPER(name)", strings.ToUpper(keyword)})
+	}
+	if repoCond.IsValid() {
+		sess.And(builder.In("repo_id", builder.Select("id").From("repository").Where(repoCond)))
+	}
+	stats.ClosedCount, err = sess.Count(new(Milestone))
+	if err != nil {
+		return nil, err
+	}
+
+	return stats, nil
+}
+
 func countRepoMilestones(e Engine, repoID int64) (int64, error) {
 	return e.
 		Where("repo_id=?", repoID).
@@ -548,6 +584,34 @@ func CountMilestonesByRepoCond(repoCond builder.Cond, isClosed bool) (map[int64]
 	return countMap, nil
 }
 
+// CountMilestonesByRepoCondAndKw map from repo conditions and the keyword of milestones' name to number of milestones matching the options`
+func CountMilestonesByRepoCondAndKw(repoCond builder.Cond, keyword string, isClosed bool) (map[int64]int64, error) {
+	sess := x.Where("is_closed = ?", isClosed)
+	if len(keyword) > 0 {
+		sess = sess.And(builder.Like{"UPPER(name)", strings.ToUpper(keyword)})
+	}
+	if repoCond.IsValid() {
+		sess.In("repo_id", builder.Select("id").From("repository").Where(repoCond))
+	}
+
+	countsSlice := make([]*struct {
+		RepoID int64
+		Count  int64
+	}, 0, 10)
+	if err := sess.GroupBy("repo_id").
+		Select("repo_id AS repo_id, COUNT(*) AS count").
+		Table("milestone").
+		Find(&countsSlice); err != nil {
+		return nil, err
+	}
+
+	countMap := make(map[int64]int64, len(countsSlice))
+	for _, c := range countsSlice {
+		countMap[c.RepoID] = c.Count
+	}
+	return countMap, nil
+}
+
 func updateRepoMilestoneNum(e Engine, repoID int64) error {
 	_, err := e.Exec("UPDATE `repository` SET num_milestones=(SELECT count(*) FROM milestone WHERE repo_id=?),num_closed_milestones=(SELECT count(*) FROM milestone WHERE repo_id=? AND is_closed=?) WHERE id=?",
 		repoID,
diff --git a/routers/repo/milestone.go b/routers/repo/milestone.go
index 4d1fc022c2..5a9d2351bc 100644
--- a/routers/repo/milestone.go
+++ b/routers/repo/milestone.go
@@ -6,6 +6,7 @@ package repo
 
 import (
 	"net/http"
+	"strings"
 	"time"
 
 	"code.gitea.io/gitea/models"
@@ -44,6 +45,9 @@ func Milestones(ctx *context.Context) {
 	ctx.Data["ClosedCount"] = stats.ClosedCount
 
 	sortType := ctx.Query("sort")
+
+	keyword := strings.Trim(ctx.Query("q"), " ")
+
 	page := ctx.QueryInt("page")
 	if page <= 1 {
 		page = 1
@@ -67,6 +71,7 @@ func Milestones(ctx *context.Context) {
 		RepoID:   ctx.Repo.Repository.ID,
 		State:    state,
 		SortType: sortType,
+		Name:     keyword,
 	})
 	if err != nil {
 		ctx.ServerError("GetMilestones", err)
@@ -90,10 +95,12 @@ func Milestones(ctx *context.Context) {
 	}
 
 	ctx.Data["SortType"] = sortType
+	ctx.Data["Keyword"] = keyword
 	ctx.Data["IsShowClosed"] = isShowClosed
 
 	pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5)
 	pager.AddParam(ctx, "state", "State")
+	pager.AddParam(ctx, "q", "Keyword")
 	ctx.Data["Page"] = pager
 
 	ctx.HTML(http.StatusOK, tplMilestone)
diff --git a/routers/user/home.go b/routers/user/home.go
index 178ad57a79..3436a44bae 100644
--- a/routers/user/home.go
+++ b/routers/user/home.go
@@ -202,6 +202,7 @@ func Milestones(ctx *context.Context) {
 		isShowClosed = ctx.Query("state") == "closed"
 		sortType     = ctx.Query("sort")
 		page         = ctx.QueryInt("page")
+		keyword      = strings.Trim(ctx.Query("q"), " ")
 	)
 
 	if page <= 1 {
@@ -234,15 +235,15 @@ func Milestones(ctx *context.Context) {
 		}
 	}
 
-	counts, err := models.CountMilestonesByRepoCond(userRepoCond, isShowClosed)
+	counts, err := models.CountMilestonesByRepoCondAndKw(userRepoCond, keyword, isShowClosed)
 	if err != nil {
 		ctx.ServerError("CountMilestonesByRepoIDs", err)
 		return
 	}
 
-	milestones, err := models.SearchMilestones(repoCond, page, isShowClosed, sortType)
+	milestones, err := models.SearchMilestones(repoCond, page, isShowClosed, sortType, keyword)
 	if err != nil {
-		ctx.ServerError("GetMilestonesByRepoIDs", err)
+		ctx.ServerError("SearchMilestones", err)
 		return
 	}
 
@@ -277,7 +278,7 @@ func Milestones(ctx *context.Context) {
 		i++
 	}
 
-	milestoneStats, err := models.GetMilestonesStatsByRepoCond(repoCond)
+	milestoneStats, err := models.GetMilestonesStatsByRepoCondAndKw(repoCond, keyword)
 	if err != nil {
 		ctx.ServerError("GetMilestoneStats", err)
 		return
@@ -287,7 +288,7 @@ func Milestones(ctx *context.Context) {
 	if len(repoIDs) == 0 {
 		totalMilestoneStats = milestoneStats
 	} else {
-		totalMilestoneStats, err = models.GetMilestonesStatsByRepoCond(userRepoCond)
+		totalMilestoneStats, err = models.GetMilestonesStatsByRepoCondAndKw(userRepoCond, keyword)
 		if err != nil {
 			ctx.ServerError("GetMilestoneStats", err)
 			return
@@ -310,12 +311,14 @@ func Milestones(ctx *context.Context) {
 	ctx.Data["Counts"] = counts
 	ctx.Data["MilestoneStats"] = milestoneStats
 	ctx.Data["SortType"] = sortType
+	ctx.Data["Keyword"] = keyword
 	if milestoneStats.Total() != totalMilestoneStats.Total() {
 		ctx.Data["RepoIDs"] = repoIDs
 	}
 	ctx.Data["IsShowClosed"] = isShowClosed
 
 	pager := context.NewPagination(pagerCount, setting.UI.IssuePagingNum, page, 5)
+	pager.AddParam(ctx, "q", "Keyword")
 	pager.AddParam(ctx, "repos", "RepoIDs")
 	pager.AddParam(ctx, "sort", "SortType")
 	pager.AddParam(ctx, "state", "State")
diff --git a/templates/repo/issue/milestones.tmpl b/templates/repo/issue/milestones.tmpl
index ecb7cd95f1..c572e0c3f3 100644
--- a/templates/repo/issue/milestones.tmpl
+++ b/templates/repo/issue/milestones.tmpl
@@ -12,34 +12,52 @@
 		</div>
 		<div class="ui divider"></div>
 		{{template "base/alert" .}}
-		<div class="ui compact tiny menu">
-			<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/milestones?state=open">
-				{{svg "octicon-milestone" 16 "mr-3"}}
-				{{.i18n.Tr "repo.milestones.open_tab" .OpenCount}}
-			</a>
-			<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/milestones?state=closed">
-				{{svg "octicon-milestone" 16 "mr-3"}}
-				{{.i18n.Tr "repo.milestones.close_tab" .ClosedCount}}
-			</a>
-		</div>
 
-		<div class="ui right floated secondary filter menu">
-		<!-- Sort -->
-			<div class="ui dropdown type jump item">
-				<span class="text">
-					{{.i18n.Tr "repo.issues.filter_sort"}}
-					{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-				</span>
-				<div class="menu">
-					<a class="{{if or (eq .SortType "closestduedate") (not .SortType)}}active{{end}} item" href="{{$.Link}}?sort=closestduedate&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.closest_due_date"}}</a>
-					<a class="{{if eq .SortType "furthestduedate"}}active{{end}} item" href="{{$.Link}}?sort=furthestduedate&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.furthest_due_date"}}</a>
-					<a class="{{if eq .SortType "leastcomplete"}}active{{end}} item" href="{{$.Link}}?sort=leastcomplete&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.least_complete"}}</a>
-					<a class="{{if eq .SortType "mostcomplete"}}active{{end}} item" href="{{$.Link}}?sort=mostcomplete&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.most_complete"}}</a>
-					<a class="{{if eq .SortType "mostissues"}}active{{end}} item" href="{{$.Link}}?sort=mostissues&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.most_issues"}}</a>
-					<a class="{{if eq .SortType "leastissues"}}active{{end}} item" href="{{$.Link}}?sort=leastissues&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.least_issues"}}</a>
+		<div class="ui three column stackable grid">
+		  <div class="column">
+				<div class="ui compact tiny menu">
+					<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/milestones?state=open&q={{$.Keyword}}">
+						{{svg "octicon-milestone" 16 "mr-3"}}
+						{{.i18n.Tr "repo.milestones.open_tab" .OpenCount}}
+					</a>
+					<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/milestones?state=closed&q={{$.Keyword}}">
+						{{svg "octicon-milestone" 16 "mr-3"}}
+						{{.i18n.Tr "repo.milestones.close_tab" .ClosedCount}}
+					</a>
+				</div>
+			</div>
+
+			<!-- Search -->
+			<div class="column center aligned">
+				<form class="ui form ignore-dirty">
+					<div class="ui search fluid action input">
+						<input type="hidden" name="state" value="{{$.State}}"/>
+						<input name="q" value="{{.Keyword}}" placeholder="{{.i18n.Tr "explore.search"}}...">
+						<button class="ui blue button" type="submit">{{.i18n.Tr "explore.search"}}</button>
+					</div>
+				</form>
+			</div>
+
+			<div class="column right aligned df ac je">
+				<!-- Sort -->
+				<div class="ui dropdown type jump item">
+					<span class="text">
+						{{.i18n.Tr "repo.issues.filter_sort"}}
+						{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+					</span>
+					<div class="menu">
+						<a class="{{if or (eq .SortType "closestduedate") (not .SortType)}}active{{end}} item" href="{{$.Link}}?sort=closestduedate&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.milestones.filter_sort.closest_due_date"}}</a>
+						<a class="{{if eq .SortType "furthestduedate"}}active{{end}} item" href="{{$.Link}}?sort=furthestduedate&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.milestones.filter_sort.furthest_due_date"}}</a>
+						<a class="{{if eq .SortType "leastcomplete"}}active{{end}} item" href="{{$.Link}}?sort=leastcomplete&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.milestones.filter_sort.least_complete"}}</a>
+						<a class="{{if eq .SortType "mostcomplete"}}active{{end}} item" href="{{$.Link}}?sort=mostcomplete&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.milestones.filter_sort.most_complete"}}</a>
+						<a class="{{if eq .SortType "mostissues"}}active{{end}} item" href="{{$.Link}}?sort=mostissues&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.milestones.filter_sort.most_issues"}}</a>
+						<a class="{{if eq .SortType "leastissues"}}active{{end}} item" href="{{$.Link}}?sort=leastissues&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.milestones.filter_sort.least_issues"}}</a>
+					</div>
 				</div>
 			</div>
 		</div>
+
+		<!-- milestone list -->
 		<div class="milestone list">
 			{{range .Milestones}}
 				<li class="item">
diff --git a/templates/user/dashboard/milestones.tmpl b/templates/user/dashboard/milestones.tmpl
index b9cf0ec1d2..5c3aa55062 100644
--- a/templates/user/dashboard/milestones.tmpl
+++ b/templates/user/dashboard/milestones.tmpl
@@ -5,7 +5,7 @@
 		<div class="ui stackable grid">
 			<div class="four wide column">
 				<div class="ui secondary vertical filter menu">
-					<a class="item" href="{{.Link}}?type=your_repositories&sort={{$.SortType}}&state={{.State}}">
+					<a class="item" href="{{.Link}}?type=your_repositories&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
 						{{.i18n.Tr "home.issues.in_your_repos"}}
 						<strong class="ui right">{{.Total}}</strong>
 					</a>
@@ -25,7 +25,7 @@
 										{{$Repo.ID}}%2C
 									{{end}}
 								{{end}}
-								]&sort={{$.SortType}}&state={{$.State}}" title="{{.FullName}}">
+								]&sort={{$.SortType}}&state={{$.State}}&q={{$.Keyword}}" title="{{.FullName}}">
 								<span class="text truncate">{{$Repo.FullName}}</span>
 								<div class="ui {{if $.IsShowClosed}}red{{else}}green{{end}} label">{{index $.Counts $Repo.ID}}</div>
 							</a>
@@ -34,34 +34,49 @@
 				</div>
 			</div>
 			<div class="twelve wide column content">
-				<div class="ui compact tiny menu">
-					<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open">
-						{{svg "octicon-issue-opened" 16 "mr-3"}}
-						{{.i18n.Tr "repo.milestones.open_tab" .MilestoneStats.OpenCount}}
-					</a>
-					<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed">
-						{{svg "octicon-issue-closed" 16 "mr-3"}}
-						{{.i18n.Tr "repo.milestones.close_tab" .MilestoneStats.ClosedCount}}
-					</a>
-				</div>
-				<div class="ui right floated secondary filter menu">
-					<!-- Sort -->
-					<div class="ui dropdown type jump item">
-						<span class="text">
-							{{.i18n.Tr "repo.issues.filter_sort"}}
-							{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-						</span>
-						<div class="menu">
-							<a class="{{if or (eq .SortType "closestduedate") (not .SortType)}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=closestduedate&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.closest_due_date"}}</a>
-              <a class="{{if eq .SortType "furthestduedate"}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=furthestduedate&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.furthest_due_date"}}</a>
-              <a class="{{if eq .SortType "leastcomplete"}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastcomplete&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.least_complete"}}</a>
-              <a class="{{if eq .SortType "mostcomplete"}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=mostcomplete&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.most_complete"}}</a>
-              <a class="{{if eq .SortType "mostissues"}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=mostissues&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.most_issues"}}</a>
-              <a class="{{if eq .SortType "leastissues"}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastissues&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.least_issues"}}</a>
+				<div class="ui three column stackable grid">
+					<div class="column">
+						<div class="ui compact tiny menu">
+							<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open&q={{$.Keyword}}">
+								{{svg "octicon-issue-opened" 16 "mr-3"}}
+								{{.i18n.Tr "repo.milestones.open_tab" .MilestoneStats.OpenCount}}
+							</a>
+							<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed&q={{$.Keyword}}">
+								{{svg "octicon-issue-closed" 16 "mr-3"}}
+								{{.i18n.Tr "repo.milestones.close_tab" .MilestoneStats.ClosedCount}}
+							</a>
 						</div>
 					</div>
-				</div>
-
+					<div class="column center aligned">
+						<form class="ui form ignore-dirty">
+							<div class="ui search fluid action input">
+								<input type="hidden" name="type" value="{{$.ViewType}}"/>
+								<input type="hidden" name="repos" value="[{{range $.RepoIDs}}{{.}},{{end}}]"/>
+								<input type="hidden" name="sort" value="{{$.SortType}}"/>
+								<input type="hidden" name="state" value="{{$.State}}"/>
+								<input name="q" value="{{$.Keyword}}" placeholder="{{.i18n.Tr "explore.search"}}...">
+								<button class="ui blue button" type="submit">{{.i18n.Tr "explore.search"}}</button>
+							</div>
+						</form>
+					</div>
+					<div class="column right aligned df ac je">
+						<!-- Sort -->
+						<div class="ui dropdown type jump item">
+							<span class="text">
+								{{.i18n.Tr "repo.issues.filter_sort"}}
+								{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+							</span>
+							<div class="menu">
+								<a class="{{if or (eq .SortType "closestduedate") (not .SortType)}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=closestduedate&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.milestones.filter_sort.closest_due_date"}}</a>
+								<a class="{{if eq .SortType "furthestduedate"}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=furthestduedate&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.milestones.filter_sort.furthest_due_date"}}</a>
+								<a class="{{if eq .SortType "leastcomplete"}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastcomplete&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.milestones.filter_sort.least_complete"}}</a>
+								<a class="{{if eq .SortType "mostcomplete"}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=mostcomplete&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.milestones.filter_sort.most_complete"}}</a>
+								<a class="{{if eq .SortType "mostissues"}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=mostissues&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.milestones.filter_sort.most_issues"}}</a>
+								<a class="{{if eq .SortType "leastissues"}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastissues&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.milestones.filter_sort.least_issues"}}</a>
+							</div>
+						</div>
+					</div>
+        </div>
                 <div class="milestone list">
                     {{range .Milestones}}
                         <li class="item">