From f6b29012e09d5f7770a0b1ea8659da5172e155b3 Mon Sep 17 00:00:00 2001 From: Brad Albright <32200834+bhalbright@users.noreply.github.com> Date: Sun, 15 Dec 2019 08:20:08 -0600 Subject: [PATCH] Add /milestones endpoint (#8733) Create a /milestones endpoint which basically serves as a dashboard view for milestones, very similar to the /issues or /pulls page. Closes #8232 --- custom/conf/app.ini.sample | 2 + .../doc/advanced/config-cheat-sheet.en-us.md | 1 + integrations/links_test.go | 6 + models/issue_milestone.go | 107 +++++++++- models/issue_milestone_test.go | 85 ++++++++ modules/context/context.go | 1 + modules/setting/service.go | 2 + options/locale/locale_en-US.ini | 1 + routers/routes/routes.go | 9 + routers/user/home.go | 193 +++++++++++++++++- routers/user/home_test.go | 39 ++++ templates/base/head_navbar.tmpl | 1 + templates/user/dashboard/milestones.tmpl | 119 +++++++++++ templates/user/dashboard/navbar.tmpl | 9 +- 14 files changed, 568 insertions(+), 7 deletions(-) create mode 100644 templates/user/dashboard/milestones.tmpl diff --git a/custom/conf/app.ini.sample b/custom/conf/app.ini.sample index 701374d4b..1617d6497 100644 --- a/custom/conf/app.ini.sample +++ b/custom/conf/app.ini.sample @@ -511,6 +511,8 @@ DEFAULT_ALLOW_ONLY_CONTRIBUTORS_TO_TRACK_TIME = true NO_REPLY_ADDRESS = noreply.%(DOMAIN)s ; Show Registration button SHOW_REGISTRATION_BUTTON = true +; Show milestones dashboard page - a view of all the user's milestones +SHOW_MILESTONES_DASHBOARD_PAGE = true ; Default value for AutoWatchNewRepos ; When adding a repo to a team or creating a new repo all team members will watch the ; repo automatically if enabled diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md index 36e56c3fe..c059fe55b 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -310,6 +310,7 @@ relation to port exhaustion. - `EMAIL_DOMAIN_WHITELIST`: **\**: If non-empty, list of domain names that can only be used to register on this instance. - `SHOW_REGISTRATION_BUTTON`: **! DISABLE\_REGISTRATION**: Show Registration Button +- `SHOW_MILESTONES_DASHBOARD_PAGE`: **true** Enable this to show the milestones dashboard page - a view of all the user's milestones - `AUTO_WATCH_NEW_REPOS`: **true**: Enable this to let all organisation users watch new repos when they are created - `AUTO_WATCH_ON_CHANGES`: **false**: Enable this to make users watch a repository after their first commit to it - `DEFAULT_ORG_VISIBILITY`: **public**: Set default visibility mode for organisations, either "public", "limited" or "private". diff --git a/integrations/links_test.go b/integrations/links_test.go index fc0f16455..329e54528 100644 --- a/integrations/links_test.go +++ b/integrations/links_test.go @@ -86,6 +86,12 @@ func testLinksAsUser(userName string, t *testing.T) { "/pulls?type=your_repositories&repos=[0]&sort=&state=closed", "/pulls?type=assigned&repos=[0]&sort=&state=closed", "/pulls?type=created_by&repos=[0]&sort=&state=closed", + "/milestones", + "/milestones?sort=mostcomplete&state=closed", + "/milestones?type=your_repositories&sort=mostcomplete&state=closed", + "/milestones?sort=&repos=[1]&state=closed", + "/milestones?sort=&repos=[1]&state=open", + "/milestones?repos=[0]&sort=mostissues&state=open", "/notifications", "/repo/create", "/repo/migrate", diff --git a/models/issue_milestone.go b/models/issue_milestone.go index 0b854a867..5fd1e1b8c 100644 --- a/models/issue_milestone.go +++ b/models/issue_milestone.go @@ -17,8 +17,9 @@ import ( // Milestone represents a milestone of repository. type Milestone struct { - ID int64 `xorm:"pk autoincr"` - RepoID int64 `xorm:"INDEX"` + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX"` + Repo *Repository `xorm:"-"` Name string Content string `xorm:"TEXT"` RenderedContent string `xorm:"-"` @@ -177,11 +178,38 @@ func (milestones MilestoneList) loadTotalTrackedTimes(e Engine) error { return nil } +func (m *Milestone) loadTotalTrackedTime(e Engine) error { + type totalTimesByMilestone struct { + MilestoneID int64 + Time int64 + } + totalTime := &totalTimesByMilestone{MilestoneID: m.ID} + has, err := e.Table("issue"). + Join("INNER", "milestone", "issue.milestone_id = milestone.id"). + Join("LEFT", "tracked_time", "tracked_time.issue_id = issue.id"). + Select("milestone_id, sum(time) as time"). + Where("milestone_id = ?", m.ID). + GroupBy("milestone_id"). + Get(totalTime) + if err != nil { + return err + } else if !has { + return nil + } + m.TotalTrackedTime = totalTime.Time + return nil +} + // LoadTotalTrackedTimes loads for every milestone in the list the TotalTrackedTime by a batch request func (milestones MilestoneList) LoadTotalTrackedTimes() error { return milestones.loadTotalTrackedTimes(x) } +// LoadTotalTrackedTime loads the tracked time for the milestone +func (m *Milestone) LoadTotalTrackedTime() error { + return m.loadTotalTrackedTime(x) +} + func (milestones MilestoneList) getMilestoneIDs() []int64 { var ids = make([]int64, 0, len(milestones)) for _, ms := range milestones { @@ -465,3 +493,78 @@ func DeleteMilestoneByRepoID(repoID, id int64) error { } return sess.Commit() } + +// CountMilestonesByRepoIDs map from repoIDs to number of milestones matching the options` +func CountMilestonesByRepoIDs(repoIDs []int64, isClosed bool) (map[int64]int64, error) { + sess := x.Where("is_closed = ?", isClosed) + sess.In("repo_id", repoIDs) + + 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 +} + +// GetMilestonesByRepoIDs returns a list of milestones of given repositories and status. +func GetMilestonesByRepoIDs(repoIDs []int64, page int, isClosed bool, sortType string) (MilestoneList, error) { + miles := make([]*Milestone, 0, setting.UI.IssuePagingNum) + sess := x.Where("is_closed = ?", isClosed) + sess.In("repo_id", repoIDs) + if page > 0 { + sess = sess.Limit(setting.UI.IssuePagingNum, (page-1)*setting.UI.IssuePagingNum) + } + + switch sortType { + case "furthestduedate": + sess.Desc("deadline_unix") + case "leastcomplete": + sess.Asc("completeness") + case "mostcomplete": + sess.Desc("completeness") + case "leastissues": + sess.Asc("num_issues") + case "mostissues": + sess.Desc("num_issues") + default: + sess.Asc("deadline_unix") + } + return miles, sess.Find(&miles) +} + +// MilestonesStats represents milestone statistic information. +type MilestonesStats struct { + OpenCount, ClosedCount int64 +} + +// GetMilestonesStats returns milestone statistic information for dashboard by given conditions. +func GetMilestonesStats(userRepoIDs []int64) (*MilestonesStats, error) { + var err error + stats := &MilestonesStats{} + + stats.OpenCount, err = x.Where("is_closed = ?", false). + And(builder.In("repo_id", userRepoIDs)). + Count(new(Milestone)) + if err != nil { + return nil, err + } + stats.ClosedCount, err = x.Where("is_closed = ?", true). + And(builder.In("repo_id", userRepoIDs)). + Count(new(Milestone)) + if err != nil { + return nil, err + } + + return stats, nil +} diff --git a/models/issue_milestone_test.go b/models/issue_milestone_test.go index 6f8548ec6..787b849cc 100644 --- a/models/issue_milestone_test.go +++ b/models/issue_milestone_test.go @@ -289,3 +289,88 @@ func TestMilestoneList_LoadTotalTrackedTimes(t *testing.T) { assert.Equal(t, miles[0].TotalTrackedTime, int64(3662)) } + +func TestCountMilestonesByRepoIDs(t *testing.T) { + assert.NoError(t, PrepareTestDatabase()) + milestonesCount := func(repoID int64) (int, int) { + repo := AssertExistsAndLoadBean(t, &Repository{ID: repoID}).(*Repository) + return repo.NumOpenMilestones, repo.NumClosedMilestones + } + repo1OpenCount, repo1ClosedCount := milestonesCount(1) + repo2OpenCount, repo2ClosedCount := milestonesCount(2) + + openCounts, err := CountMilestonesByRepoIDs([]int64{1, 2}, false) + assert.NoError(t, err) + assert.EqualValues(t, repo1OpenCount, openCounts[1]) + assert.EqualValues(t, repo2OpenCount, openCounts[2]) + + closedCounts, err := CountMilestonesByRepoIDs([]int64{1, 2}, true) + assert.NoError(t, err) + assert.EqualValues(t, repo1ClosedCount, closedCounts[1]) + assert.EqualValues(t, repo2ClosedCount, closedCounts[2]) +} + +func TestGetMilestonesByRepoIDs(t *testing.T) { + assert.NoError(t, PrepareTestDatabase()) + repo1 := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository) + repo2 := AssertExistsAndLoadBean(t, &Repository{ID: 2}).(*Repository) + test := func(sortType string, sortCond func(*Milestone) int) { + for _, page := range []int{0, 1} { + openMilestones, err := GetMilestonesByRepoIDs([]int64{repo1.ID, repo2.ID}, page, false, sortType) + assert.NoError(t, err) + assert.Len(t, openMilestones, repo1.NumOpenMilestones+repo2.NumOpenMilestones) + values := make([]int, len(openMilestones)) + for i, milestone := range openMilestones { + values[i] = sortCond(milestone) + } + assert.True(t, sort.IntsAreSorted(values)) + + closedMilestones, err := GetMilestonesByRepoIDs([]int64{repo1.ID, repo2.ID}, page, true, sortType) + assert.NoError(t, err) + assert.Len(t, closedMilestones, repo1.NumClosedMilestones+repo2.NumClosedMilestones) + values = make([]int, len(closedMilestones)) + for i, milestone := range closedMilestones { + values[i] = sortCond(milestone) + } + assert.True(t, sort.IntsAreSorted(values)) + } + } + test("furthestduedate", func(milestone *Milestone) int { + return -int(milestone.DeadlineUnix) + }) + test("leastcomplete", func(milestone *Milestone) int { + return milestone.Completeness + }) + test("mostcomplete", func(milestone *Milestone) int { + return -milestone.Completeness + }) + test("leastissues", func(milestone *Milestone) int { + return milestone.NumIssues + }) + test("mostissues", func(milestone *Milestone) int { + return -milestone.NumIssues + }) + test("soonestduedate", func(milestone *Milestone) int { + return int(milestone.DeadlineUnix) + }) +} + +func TestLoadTotalTrackedTime(t *testing.T) { + assert.NoError(t, PrepareTestDatabase()) + milestone := AssertExistsAndLoadBean(t, &Milestone{ID: 1}).(*Milestone) + + assert.NoError(t, milestone.LoadTotalTrackedTime()) + + assert.Equal(t, milestone.TotalTrackedTime, int64(3662)) +} + +func TestGetMilestonesStats(t *testing.T) { + assert.NoError(t, PrepareTestDatabase()) + repo1 := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository) + repo2 := AssertExistsAndLoadBean(t, &Repository{ID: 2}).(*Repository) + + milestoneStats, err := GetMilestonesStats([]int64{repo1.ID, repo2.ID}) + assert.NoError(t, err) + assert.EqualValues(t, repo1.NumOpenMilestones+repo2.NumOpenMilestones, milestoneStats.OpenCount) + assert.EqualValues(t, repo1.NumClosedMilestones+repo2.NumClosedMilestones, milestoneStats.ClosedCount) +} diff --git a/modules/context/context.go b/modules/context/context.go index ef6c19ed1..4b590a718 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -334,6 +334,7 @@ func Contexter() macaron.Handler { ctx.Data["IsLandingPageOrganizations"] = setting.LandingPageURL == setting.LandingPageOrganizations ctx.Data["ShowRegistrationButton"] = setting.Service.ShowRegistrationButton + ctx.Data["ShowMilestonesDashboardPage"] = setting.Service.ShowMilestonesDashboardPage ctx.Data["ShowFooterBranding"] = setting.ShowFooterBranding ctx.Data["ShowFooterVersion"] = setting.ShowFooterVersion diff --git a/modules/setting/service.go b/modules/setting/service.go index 9407231ac..c463b0a9d 100644 --- a/modules/setting/service.go +++ b/modules/setting/service.go @@ -21,6 +21,7 @@ var Service struct { DisableRegistration bool AllowOnlyExternalRegistration bool ShowRegistrationButton bool + ShowMilestonesDashboardPage bool RequireSignInView bool EnableNotifyMail bool EnableBasicAuth bool @@ -62,6 +63,7 @@ func newService() { Service.AllowOnlyExternalRegistration = sec.Key("ALLOW_ONLY_EXTERNAL_REGISTRATION").MustBool() Service.EmailDomainWhitelist = sec.Key("EMAIL_DOMAIN_WHITELIST").Strings(",") Service.ShowRegistrationButton = sec.Key("SHOW_REGISTRATION_BUTTON").MustBool(!(Service.DisableRegistration || Service.AllowOnlyExternalRegistration)) + Service.ShowMilestonesDashboardPage = sec.Key("SHOW_MILESTONES_DASHBOARD_PAGE").MustBool(true) Service.RequireSignInView = sec.Key("REQUIRE_SIGNIN_VIEW").MustBool() Service.EnableBasicAuth = sec.Key("ENABLE_BASIC_AUTHENTICATION").MustBool(true) Service.EnableReverseProxyAuth = sec.Key("ENABLE_REVERSE_PROXY_AUTHENTICATION").MustBool() diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index c6fd3b863..c5cfb1663 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -66,6 +66,7 @@ forks = Forks activities = Activities pull_requests = Pull Requests issues = Issues +milestones = Milestones cancel = Cancel add = Add diff --git a/routers/routes/routes.go b/routers/routes/routes.go index cfd4a6097..60fd93df9 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -254,6 +254,13 @@ func RegisterRoutes(m *macaron.Macaron) { } } + reqMilestonesDashboardPageEnabled := func(ctx *context.Context) { + if !setting.Service.ShowMilestonesDashboardPage { + ctx.Error(403) + return + } + } + m.Use(user.GetNotificationCount) // FIXME: not all routes need go through same middlewares. @@ -276,6 +283,7 @@ func RegisterRoutes(m *macaron.Macaron) { m.Combo("/install", routers.InstallInit).Get(routers.Install). Post(bindIgnErr(auth.InstallForm{}), routers.InstallPost) m.Get("/^:type(issues|pulls)$", reqSignIn, user.Issues) + m.Get("/milestones", reqSignIn, reqMilestonesDashboardPageEnabled, user.Milestones) // ***** START: User ***** m.Group("/user", func() { @@ -556,6 +564,7 @@ func RegisterRoutes(m *macaron.Macaron) { m.Group("/:org", func() { m.Get("/dashboard", user.Dashboard) m.Get("/^:type(issues|pulls)$", user.Issues) + m.Get("/milestones", reqMilestonesDashboardPageEnabled, user.Milestones) m.Get("/members", org.Members) m.Get("/members/action/:action", org.MembersAction) diff --git a/routers/user/home.go b/routers/user/home.go index a1060f371..426f15bfa 100644 --- a/routers/user/home.go +++ b/routers/user/home.go @@ -18,17 +18,20 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "github.com/keybase/go-crypto/openpgp" "github.com/keybase/go-crypto/openpgp/armor" + "github.com/unknwon/com" ) const ( - tplDashboard base.TplName = "user/dashboard/dashboard" - tplIssues base.TplName = "user/dashboard/issues" - tplProfile base.TplName = "user/profile" + tplDashboard base.TplName = "user/dashboard/dashboard" + tplIssues base.TplName = "user/dashboard/issues" + tplMilestones base.TplName = "user/dashboard/milestones" + tplProfile base.TplName = "user/profile" ) // getDashboardContextUser finds out dashboard is viewing as which context user. @@ -150,6 +153,190 @@ func Dashboard(ctx *context.Context) { ctx.HTML(200, tplDashboard) } +// Milestones render the user milestones page +func Milestones(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("milestones") + ctx.Data["PageIsMilestonesDashboard"] = true + + ctxUser := getDashboardContextUser(ctx) + if ctx.Written() { + return + } + + sortType := ctx.Query("sort") + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + + reposQuery := ctx.Query("repos") + isShowClosed := ctx.Query("state") == "closed" + + // Get repositories. + var err error + var userRepoIDs []int64 + if ctxUser.IsOrganization() { + env, err := ctxUser.AccessibleReposEnv(ctx.User.ID) + if err != nil { + ctx.ServerError("AccessibleReposEnv", err) + return + } + userRepoIDs, err = env.RepoIDs(1, ctxUser.NumRepos) + if err != nil { + ctx.ServerError("env.RepoIDs", err) + return + } + } else { + unitType := models.UnitTypeIssues + userRepoIDs, err = ctxUser.GetAccessRepoIDs(unitType) + if err != nil { + ctx.ServerError("ctxUser.GetAccessRepoIDs", err) + return + } + } + if len(userRepoIDs) == 0 { + userRepoIDs = []int64{-1} + } + + var repoIDs []int64 + if issueReposQueryPattern.MatchString(reposQuery) { + // remove "[" and "]" from string + reposQuery = reposQuery[1 : len(reposQuery)-1] + //for each ID (delimiter ",") add to int to repoIDs + reposSet := false + for _, rID := range strings.Split(reposQuery, ",") { + // Ensure nonempty string entries + if rID != "" && rID != "0" { + reposSet = true + rIDint64, err := strconv.ParseInt(rID, 10, 64) + if err == nil && com.IsSliceContainsInt64(userRepoIDs, rIDint64) { + repoIDs = append(repoIDs, rIDint64) + } + } + } + if reposSet && len(repoIDs) == 0 { + // force an empty result + repoIDs = []int64{-1} + } + } else { + log.Error("issueReposQueryPattern not match with query") + } + + if len(repoIDs) == 0 { + repoIDs = userRepoIDs + } + + counts, err := models.CountMilestonesByRepoIDs(userRepoIDs, isShowClosed) + if err != nil { + ctx.ServerError("CountMilestonesByRepoIDs", err) + return + } + + milestones, err := models.GetMilestonesByRepoIDs(repoIDs, page, isShowClosed, sortType) + if err != nil { + ctx.ServerError("GetMilestonesByRepoIDs", err) + return + } + + showReposMap := make(map[int64]*models.Repository, len(counts)) + for rID := range counts { + if rID == -1 { + break + } + repo, err := models.GetRepositoryByID(rID) + if err != nil { + if models.IsErrRepoNotExist(err) { + ctx.NotFound("GetRepositoryByID", err) + return + } else if err != nil { + ctx.ServerError("GetRepositoryByID", fmt.Errorf("[%d]%v", rID, err)) + return + } + } + showReposMap[rID] = repo + + // Check if user has access to given repository. + perm, err := models.GetUserRepoPermission(repo, ctxUser) + if err != nil { + ctx.ServerError("GetUserRepoPermission", fmt.Errorf("[%d]%v", rID, err)) + return + } + + if !perm.CanRead(models.UnitTypeIssues) { + if log.IsTrace() { + log.Trace("Permission Denied: User %-v cannot read %-v of repo %-v\n"+ + "User in repo has Permissions: %-+v", + ctxUser, + models.UnitTypeIssues, + repo, + perm) + } + ctx.Status(404) + return + } + } + + showRepos := models.RepositoryListOfMap(showReposMap) + sort.Sort(showRepos) + if err = showRepos.LoadAttributes(); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + for _, m := range milestones { + m.Repo = showReposMap[m.RepoID] + m.RenderedContent = string(markdown.Render([]byte(m.Content), m.Repo.Link(), m.Repo.ComposeMetas())) + if m.Repo.IsTimetrackerEnabled() { + err := m.LoadTotalTrackedTime() + if err != nil { + ctx.ServerError("LoadTotalTrackedTime", err) + return + } + } + } + + milestoneStats, err := models.GetMilestonesStats(repoIDs) + if err != nil { + ctx.ServerError("GetMilestoneStats", err) + return + } + + totalMilestoneStats, err := models.GetMilestonesStats(userRepoIDs) + if err != nil { + ctx.ServerError("GetMilestoneStats", err) + return + } + + var pagerCount int + if isShowClosed { + ctx.Data["State"] = "closed" + ctx.Data["Total"] = totalMilestoneStats.ClosedCount + pagerCount = int(milestoneStats.ClosedCount) + } else { + ctx.Data["State"] = "open" + ctx.Data["Total"] = totalMilestoneStats.OpenCount + pagerCount = int(milestoneStats.OpenCount) + } + + ctx.Data["Milestones"] = milestones + ctx.Data["Repos"] = showRepos + ctx.Data["Counts"] = counts + ctx.Data["MilestoneStats"] = milestoneStats + ctx.Data["SortType"] = sortType + if len(repoIDs) != len(userRepoIDs) { + ctx.Data["RepoIDs"] = repoIDs + } + ctx.Data["IsShowClosed"] = isShowClosed + + pager := context.NewPagination(pagerCount, setting.UI.IssuePagingNum, page, 5) + pager.AddParam(ctx, "repos", "RepoIDs") + pager.AddParam(ctx, "sort", "SortType") + pager.AddParam(ctx, "state", "State") + ctx.Data["Page"] = pager + + ctx.HTML(200, tplMilestones) +} + // Regexp for repos query var issueReposQueryPattern = regexp.MustCompile(`^\[\d+(,\d+)*,?\]$`) diff --git a/routers/user/home_test.go b/routers/user/home_test.go index 9d4136ac8..e5bbd0e98 100644 --- a/routers/user/home_test.go +++ b/routers/user/home_test.go @@ -31,3 +31,42 @@ func TestIssues(t *testing.T) { assert.Len(t, ctx.Data["Issues"], 1) assert.Len(t, ctx.Data["Repos"], 1) } + +func TestMilestones(t *testing.T) { + setting.UI.IssuePagingNum = 1 + assert.NoError(t, models.LoadFixtures()) + + ctx := test.MockContext(t, "milestones") + test.LoadUser(t, ctx, 2) + ctx.SetParams("sort", "issues") + ctx.Req.Form.Set("state", "closed") + ctx.Req.Form.Set("sort", "furthestduedate") + Milestones(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, map[int64]int64{1: 1}, ctx.Data["Counts"]) + assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) + assert.EqualValues(t, "furthestduedate", ctx.Data["SortType"]) + assert.EqualValues(t, 1, ctx.Data["Total"]) + assert.Len(t, ctx.Data["Milestones"], 1) + assert.Len(t, ctx.Data["Repos"], 1) +} + +func TestMilestonesForSpecificRepo(t *testing.T) { + setting.UI.IssuePagingNum = 1 + assert.NoError(t, models.LoadFixtures()) + + ctx := test.MockContext(t, "milestones") + test.LoadUser(t, ctx, 2) + ctx.SetParams("sort", "issues") + ctx.SetParams("repo", "1") + ctx.Req.Form.Set("state", "closed") + ctx.Req.Form.Set("sort", "furthestduedate") + Milestones(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, map[int64]int64{1: 1}, ctx.Data["Counts"]) + assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) + assert.EqualValues(t, "furthestduedate", ctx.Data["SortType"]) + assert.EqualValues(t, 1, ctx.Data["Total"]) + assert.Len(t, ctx.Data["Milestones"], 1) + assert.Len(t, ctx.Data["Repos"], 1) +} diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index fdba57d5b..a09b4b832 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -12,6 +12,7 @@ {{.i18n.Tr "dashboard"}} {{.i18n.Tr "issues"}} {{.i18n.Tr "pull_requests"}} + {{if .ShowMilestonesDashboardPage}}{{.i18n.Tr "milestones"}}{{end}} {{.i18n.Tr "explore"}} {{else if .IsLandingPageHome}} {{.i18n.Tr "home"}} diff --git a/templates/user/dashboard/milestones.tmpl b/templates/user/dashboard/milestones.tmpl new file mode 100644 index 000000000..495119f44 --- /dev/null +++ b/templates/user/dashboard/milestones.tmpl @@ -0,0 +1,119 @@ +{{template "base/head" .}} +
+ {{template "user/dashboard/navbar" .}} +
+
+ +
+ + + +
+ {{range .Milestones}} +
  • +
    {{if not $.RepoIDs}}{{.Repo.FullName}}{{end}}
    + {{.Name}} +
    +
    +
    +
    +
    +
    + {{ $closedDate:= TimeSinceUnix .ClosedDateUnix $.Lang }} + {{if .IsClosed}} + {{$.i18n.Tr "repo.milestones.closed" $closedDate|Str2html}} + {{else}} + + {{if .DeadlineString}} + {{.DeadlineString}} + {{else}} + {{$.i18n.Tr "repo.milestones.no_due_date"}} + {{end}} + {{end}} + + {{$.i18n.Tr "repo.milestones.open_tab" .NumOpenIssues}} + {{$.i18n.Tr "repo.milestones.close_tab" .NumClosedIssues}} + {{if .TotalTrackedTime}} {{.TotalTrackedTime|Sec2Time}}{{end}} + +
    + {{if and (or $.CanWriteIssues $.CanWritePulls) (not $.Repository.IsArchived)}} + + {{end}} + {{if .Content}} +
    + {{.RenderedContent|Str2html}} +
    + {{end}} +
  • + {{end}} + + {{template "base/paginate" .}} +
    + +
    +
    +
    +
    +{{template "base/footer" .}} diff --git a/templates/user/dashboard/navbar.tmpl b/templates/user/dashboard/navbar.tmpl index 25c45325a..ed44c3537 100644 --- a/templates/user/dashboard/navbar.tmpl +++ b/templates/user/dashboard/navbar.tmpl @@ -12,12 +12,12 @@ {{.i18n.Tr "home.switch_dashboard_context"}}