mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	Issue time estimate, meaningful time tracking (#23113)
Redesign the time tracker side bar, and add "time estimate" support (in "1d 2m" format) Closes #23112 --------- Co-authored-by: stuzer05 <stuzer05@gmail.com> Co-authored-by: Yarden Shoham <hrsi88@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: 6543 <6543@obermui.de> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
		| @@ -114,6 +114,8 @@ const ( | |||||||
|  |  | ||||||
| 	CommentTypePin   // 36 pin Issue | 	CommentTypePin   // 36 pin Issue | ||||||
| 	CommentTypeUnpin // 37 unpin Issue | 	CommentTypeUnpin // 37 unpin Issue | ||||||
|  |  | ||||||
|  | 	CommentTypeChangeTimeEstimate // 38 Change time estimate | ||||||
| ) | ) | ||||||
|  |  | ||||||
| var commentStrings = []string{ | var commentStrings = []string{ | ||||||
| @@ -155,6 +157,7 @@ var commentStrings = []string{ | |||||||
| 	"pull_cancel_scheduled_merge", | 	"pull_cancel_scheduled_merge", | ||||||
| 	"pin", | 	"pin", | ||||||
| 	"unpin", | 	"unpin", | ||||||
|  | 	"change_time_estimate", | ||||||
| } | } | ||||||
|  |  | ||||||
| func (t CommentType) String() string { | func (t CommentType) String() string { | ||||||
|   | |||||||
| @@ -147,6 +147,9 @@ type Issue struct { | |||||||
|  |  | ||||||
| 	// For view issue page. | 	// For view issue page. | ||||||
| 	ShowRole RoleDescriptor `xorm:"-"` | 	ShowRole RoleDescriptor `xorm:"-"` | ||||||
|  |  | ||||||
|  | 	// Time estimate | ||||||
|  | 	TimeEstimate int64 `xorm:"NOT NULL DEFAULT 0"` | ||||||
| } | } | ||||||
|  |  | ||||||
| var ( | var ( | ||||||
| @@ -934,3 +937,28 @@ func insertIssue(ctx context.Context, issue *Issue) error { | |||||||
|  |  | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // ChangeIssueTimeEstimate changes the plan time of this issue, as the given user. | ||||||
|  | func ChangeIssueTimeEstimate(ctx context.Context, issue *Issue, doer *user_model.User, timeEstimate int64) error { | ||||||
|  | 	return db.WithTx(ctx, func(ctx context.Context) error { | ||||||
|  | 		if err := UpdateIssueCols(ctx, &Issue{ID: issue.ID, TimeEstimate: timeEstimate}, "time_estimate"); err != nil { | ||||||
|  | 			return fmt.Errorf("updateIssueCols: %w", err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if err := issue.LoadRepo(ctx); err != nil { | ||||||
|  | 			return fmt.Errorf("loadRepo: %w", err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		opts := &CreateCommentOptions{ | ||||||
|  | 			Type:    CommentTypeChangeTimeEstimate, | ||||||
|  | 			Doer:    doer, | ||||||
|  | 			Repo:    issue.Repo, | ||||||
|  | 			Issue:   issue, | ||||||
|  | 			Content: fmt.Sprintf("%d", timeEstimate), | ||||||
|  | 		} | ||||||
|  | 		if _, err := CreateComment(ctx, opts); err != nil { | ||||||
|  | 			return fmt.Errorf("createComment: %w", err) | ||||||
|  | 		} | ||||||
|  | 		return nil | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|   | |||||||
| @@ -368,6 +368,7 @@ func prepareMigrationTasks() []*migration { | |||||||
| 		newMigration(308, "Add index(user_id, is_deleted) for action table", v1_23.AddNewIndexForUserDashboard), | 		newMigration(308, "Add index(user_id, is_deleted) for action table", v1_23.AddNewIndexForUserDashboard), | ||||||
| 		newMigration(309, "Improve Notification table indices", v1_23.ImproveNotificationTableIndices), | 		newMigration(309, "Improve Notification table indices", v1_23.ImproveNotificationTableIndices), | ||||||
| 		newMigration(310, "Add Priority to ProtectedBranch", v1_23.AddPriorityToProtectedBranch), | 		newMigration(310, "Add Priority to ProtectedBranch", v1_23.AddPriorityToProtectedBranch), | ||||||
|  | 		newMigration(311, "Add TimeEstimate to Issue table", v1_23.AddTimeEstimateColumnToIssueTable), | ||||||
| 	} | 	} | ||||||
| 	return preparedMigrations | 	return preparedMigrations | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										16
									
								
								models/migrations/v1_23/v311.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								models/migrations/v1_23/v311.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | // Copyright 2024 The Gitea Authors. All rights reserved. | ||||||
|  | // SPDX-License-Identifier: MIT | ||||||
|  |  | ||||||
|  | package v1_23 //nolint | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"xorm.io/xorm" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func AddTimeEstimateColumnToIssueTable(x *xorm.Engine) error { | ||||||
|  | 	type Issue struct { | ||||||
|  | 		TimeEstimate int64 `xorm:"NOT NULL DEFAULT 0"` | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return x.Sync(new(Issue)) | ||||||
|  | } | ||||||
| @@ -70,6 +70,9 @@ func NewFuncMap() template.FuncMap { | |||||||
| 		"FileSize": base.FileSize, | 		"FileSize": base.FileSize, | ||||||
| 		"CountFmt": base.FormatNumberSI, | 		"CountFmt": base.FormatNumberSI, | ||||||
| 		"Sec2Time": util.SecToTime, | 		"Sec2Time": util.SecToTime, | ||||||
|  |  | ||||||
|  | 		"TimeEstimateString": timeEstimateString, | ||||||
|  |  | ||||||
| 		"LoadTimes": func(startTime time.Time) string { | 		"LoadTimes": func(startTime time.Time) string { | ||||||
| 			return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms" | 			return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms" | ||||||
| 		}, | 		}, | ||||||
| @@ -282,6 +285,14 @@ func userThemeName(user *user_model.User) string { | |||||||
| 	return setting.UI.DefaultTheme | 	return setting.UI.DefaultTheme | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func timeEstimateString(timeSec any) string { | ||||||
|  | 	v, _ := util.ToInt64(timeSec) | ||||||
|  | 	if v == 0 { | ||||||
|  | 		return "" | ||||||
|  | 	} | ||||||
|  | 	return util.TimeEstimateString(v) | ||||||
|  | } | ||||||
|  |  | ||||||
| func panicIfDevOrTesting() { | func panicIfDevOrTesting() { | ||||||
| 	if !setting.IsProd || setting.IsInTesting { | 	if !setting.IsProd || setting.IsInTesting { | ||||||
| 		panic("legacy template functions are for backward compatibility only, do not use them in new code") | 		panic("legacy template functions are for backward compatibility only, do not use them in new code") | ||||||
|   | |||||||
							
								
								
									
										85
									
								
								modules/util/time_str.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								modules/util/time_str.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,85 @@ | |||||||
|  | // Copyright 2024 Gitea. All rights reserved. | ||||||
|  | // SPDX-License-Identifier: MIT | ||||||
|  |  | ||||||
|  | package util | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"regexp" | ||||||
|  | 	"strconv" | ||||||
|  | 	"strings" | ||||||
|  | 	"sync" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type timeStrGlobalVarsType struct { | ||||||
|  | 	units []struct { | ||||||
|  | 		name string | ||||||
|  | 		num  int64 | ||||||
|  | 	} | ||||||
|  | 	re *regexp.Regexp | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // When tracking working time, only hour/minute/second units are accurate and could be used. | ||||||
|  | // For other units like "day", it depends on "how many working hours in a day": 6 or 7 or 8? | ||||||
|  | // So at the moment, we only support hour/minute/second units. | ||||||
|  | // In the future, it could be some configurable options to help users | ||||||
|  | // to convert the working time to different units. | ||||||
|  |  | ||||||
|  | var timeStrGlobalVars = sync.OnceValue[*timeStrGlobalVarsType](func() *timeStrGlobalVarsType { | ||||||
|  | 	v := &timeStrGlobalVarsType{} | ||||||
|  | 	v.re = regexp.MustCompile(`(?i)(\d+)\s*([hms])`) | ||||||
|  | 	v.units = []struct { | ||||||
|  | 		name string | ||||||
|  | 		num  int64 | ||||||
|  | 	}{ | ||||||
|  | 		{"h", 60 * 60}, | ||||||
|  | 		{"m", 60}, | ||||||
|  | 		{"s", 1}, | ||||||
|  | 	} | ||||||
|  | 	return v | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | func TimeEstimateParse(timeStr string) (int64, error) { | ||||||
|  | 	if timeStr == "" { | ||||||
|  | 		return 0, nil | ||||||
|  | 	} | ||||||
|  | 	var total int64 | ||||||
|  | 	matches := timeStrGlobalVars().re.FindAllStringSubmatchIndex(timeStr, -1) | ||||||
|  | 	if len(matches) == 0 { | ||||||
|  | 		return 0, fmt.Errorf("invalid time string: %s", timeStr) | ||||||
|  | 	} | ||||||
|  | 	if matches[0][0] != 0 || matches[len(matches)-1][1] != len(timeStr) { | ||||||
|  | 		return 0, fmt.Errorf("invalid time string: %s", timeStr) | ||||||
|  | 	} | ||||||
|  | 	for _, match := range matches { | ||||||
|  | 		amount, err := strconv.ParseInt(timeStr[match[2]:match[3]], 10, 64) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return 0, fmt.Errorf("invalid time string: %v", err) | ||||||
|  | 		} | ||||||
|  | 		unit := timeStr[match[4]:match[5]] | ||||||
|  | 		found := false | ||||||
|  | 		for _, u := range timeStrGlobalVars().units { | ||||||
|  | 			if strings.ToLower(unit) == u.name { | ||||||
|  | 				total += amount * u.num | ||||||
|  | 				found = true | ||||||
|  | 				break | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		if !found { | ||||||
|  | 			return 0, fmt.Errorf("invalid time unit: %s", unit) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return total, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TimeEstimateString(amount int64) string { | ||||||
|  | 	var timeParts []string | ||||||
|  | 	for _, u := range timeStrGlobalVars().units { | ||||||
|  | 		if amount >= u.num { | ||||||
|  | 			num := amount / u.num | ||||||
|  | 			amount %= u.num | ||||||
|  | 			timeParts = append(timeParts, fmt.Sprintf("%d%s", num, u.name)) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return strings.Join(timeParts, " ") | ||||||
|  | } | ||||||
							
								
								
									
										55
									
								
								modules/util/time_str_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								modules/util/time_str_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | |||||||
|  | // Copyright 2024 Gitea. All rights reserved. | ||||||
|  | // SPDX-License-Identifier: MIT | ||||||
|  |  | ||||||
|  | package util | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"testing" | ||||||
|  |  | ||||||
|  | 	"github.com/stretchr/testify/assert" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func TestTimeStr(t *testing.T) { | ||||||
|  | 	t.Run("Parse", func(t *testing.T) { | ||||||
|  | 		// Test TimeEstimateParse | ||||||
|  | 		tests := []struct { | ||||||
|  | 			input  string | ||||||
|  | 			output int64 | ||||||
|  | 			err    bool | ||||||
|  | 		}{ | ||||||
|  | 			{"1h", 3600, false}, | ||||||
|  | 			{"1m", 60, false}, | ||||||
|  | 			{"1s", 1, false}, | ||||||
|  | 			{"1h 1m 1s", 3600 + 60 + 1, false}, | ||||||
|  | 			{"1d1x", 0, true}, | ||||||
|  | 		} | ||||||
|  | 		for _, test := range tests { | ||||||
|  | 			t.Run(test.input, func(t *testing.T) { | ||||||
|  | 				output, err := TimeEstimateParse(test.input) | ||||||
|  | 				if test.err { | ||||||
|  | 					assert.NotNil(t, err) | ||||||
|  | 				} else { | ||||||
|  | 					assert.Nil(t, err) | ||||||
|  | 				} | ||||||
|  | 				assert.Equal(t, test.output, output) | ||||||
|  | 			}) | ||||||
|  | 		} | ||||||
|  | 	}) | ||||||
|  | 	t.Run("String", func(t *testing.T) { | ||||||
|  | 		tests := []struct { | ||||||
|  | 			input  int64 | ||||||
|  | 			output string | ||||||
|  | 		}{ | ||||||
|  | 			{3600, "1h"}, | ||||||
|  | 			{60, "1m"}, | ||||||
|  | 			{1, "1s"}, | ||||||
|  | 			{3600 + 1, "1h 1s"}, | ||||||
|  | 		} | ||||||
|  | 		for _, test := range tests { | ||||||
|  | 			t.Run(test.output, func(t *testing.T) { | ||||||
|  | 				output := TimeEstimateString(test.input) | ||||||
|  | 				assert.Equal(t, test.output, output) | ||||||
|  | 			}) | ||||||
|  | 		} | ||||||
|  | 	}) | ||||||
|  | } | ||||||
| @@ -1670,27 +1670,34 @@ issues.comment_on_locked = You cannot comment on a locked issue. | |||||||
| issues.delete = Delete | issues.delete = Delete | ||||||
| issues.delete.title = Delete this issue? | issues.delete.title = Delete this issue? | ||||||
| issues.delete.text = Do you really want to delete this issue? (This will permanently remove all content. Consider closing it instead, if you intend to keep it archived) | issues.delete.text = Do you really want to delete this issue? (This will permanently remove all content. Consider closing it instead, if you intend to keep it archived) | ||||||
|  |  | ||||||
| issues.tracker = Time Tracker | issues.tracker = Time Tracker | ||||||
| issues.start_tracking_short = Start Timer | issues.timetracker_timer_start = Start timer | ||||||
| issues.start_tracking = Start Time Tracking | issues.timetracker_timer_stop = Stop timer | ||||||
| issues.start_tracking_history = `started working %s` | issues.timetracker_timer_discard = Discard timer | ||||||
|  | issues.timetracker_timer_manually_add = Add Time | ||||||
|  |  | ||||||
|  | issues.time_estimate_placeholder = 1h 2m | ||||||
|  | issues.time_estimate_set = Set estimated time | ||||||
|  | issues.time_estimate_display = Estimate: %s | ||||||
|  | issues.change_time_estimate_at = changed time estimate to <b>%s</b> %s | ||||||
|  | issues.remove_time_estimate_at = removed time estimate %s | ||||||
|  | issues.time_estimate_invalid = Time estimate format is invalid | ||||||
|  | issues.start_tracking_history = started working %s | ||||||
| issues.tracker_auto_close = Timer will be stopped automatically when this issue gets closed | issues.tracker_auto_close = Timer will be stopped automatically when this issue gets closed | ||||||
| issues.tracking_already_started = `You have already started time tracking on <a href="%s">another issue</a>!` | issues.tracking_already_started = `You have already started time tracking on <a href="%s">another issue</a>!` | ||||||
| issues.stop_tracking = Stop Timer | issues.stop_tracking_history = worked for <b>%s</b> %s | ||||||
| issues.stop_tracking_history = `stopped working %s` |  | ||||||
| issues.cancel_tracking = Discard |  | ||||||
| issues.cancel_tracking_history = `canceled time tracking %s` | issues.cancel_tracking_history = `canceled time tracking %s` | ||||||
| issues.add_time = Manually Add Time |  | ||||||
| issues.del_time = Delete this time log | issues.del_time = Delete this time log | ||||||
| issues.add_time_short = Add Time | issues.add_time_history = added spent time <b>%s</b> %s | ||||||
| issues.add_time_cancel = Cancel |  | ||||||
| issues.add_time_history = `added spent time %s` |  | ||||||
| issues.del_time_history= `deleted spent time %s` | issues.del_time_history= `deleted spent time %s` | ||||||
|  | issues.add_time_manually = Manually Add Time | ||||||
| issues.add_time_hours = Hours | issues.add_time_hours = Hours | ||||||
| issues.add_time_minutes = Minutes | issues.add_time_minutes = Minutes | ||||||
| issues.add_time_sum_to_small = No time was entered. | issues.add_time_sum_to_small = No time was entered. | ||||||
| issues.time_spent_total = Total Time Spent | issues.time_spent_total = Total Time Spent | ||||||
| issues.time_spent_from_all_authors = `Total Time Spent: %s` | issues.time_spent_from_all_authors = `Total Time Spent: %s` | ||||||
|  |  | ||||||
| issues.due_date = Due Date | issues.due_date = Due Date | ||||||
| issues.invalid_due_date_format = "Due date format must be 'yyyy-mm-dd'." | issues.invalid_due_date_format = "Due date format must be 'yyyy-mm-dd'." | ||||||
| issues.error_modifying_due_date = "Failed to modify the due date." | issues.error_modifying_due_date = "Failed to modify the due date." | ||||||
|   | |||||||
| @@ -4,7 +4,6 @@ | |||||||
| package repo | package repo | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"net/http" |  | ||||||
| 	"strings" | 	"strings" | ||||||
|  |  | ||||||
| 	"code.gitea.io/gitea/models/db" | 	"code.gitea.io/gitea/models/db" | ||||||
| @@ -40,8 +39,7 @@ func IssueStopwatch(c *context.Context) { | |||||||
| 		c.Flash.Success(c.Tr("repo.issues.tracker_auto_close")) | 		c.Flash.Success(c.Tr("repo.issues.tracker_auto_close")) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	url := issue.Link() | 	c.JSONRedirect("") | ||||||
| 	c.Redirect(url, http.StatusSeeOther) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // CancelStopwatch cancel the stopwatch | // CancelStopwatch cancel the stopwatch | ||||||
| @@ -72,8 +70,7 @@ func CancelStopwatch(c *context.Context) { | |||||||
| 		}) | 		}) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	url := issue.Link() | 	c.JSONRedirect("") | ||||||
| 	c.Redirect(url, http.StatusSeeOther) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // GetActiveStopwatch is the middleware that sets .ActiveStopwatch on context | // GetActiveStopwatch is the middleware that sets .ActiveStopwatch on context | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ package repo | |||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"net/http" | 	"net/http" | ||||||
|  | 	"strings" | ||||||
| 	"time" | 	"time" | ||||||
|  |  | ||||||
| 	"code.gitea.io/gitea/models/db" | 	"code.gitea.io/gitea/models/db" | ||||||
| @@ -13,6 +14,7 @@ import ( | |||||||
| 	"code.gitea.io/gitea/modules/web" | 	"code.gitea.io/gitea/modules/web" | ||||||
| 	"code.gitea.io/gitea/services/context" | 	"code.gitea.io/gitea/services/context" | ||||||
| 	"code.gitea.io/gitea/services/forms" | 	"code.gitea.io/gitea/services/forms" | ||||||
|  | 	issue_service "code.gitea.io/gitea/services/issue" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // AddTimeManually tracks time manually | // AddTimeManually tracks time manually | ||||||
| @@ -26,19 +28,16 @@ func AddTimeManually(c *context.Context) { | |||||||
| 		c.NotFound("CanUseTimetracker", nil) | 		c.NotFound("CanUseTimetracker", nil) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 	url := issue.Link() |  | ||||||
|  |  | ||||||
| 	if c.HasError() { | 	if c.HasError() { | ||||||
| 		c.Flash.Error(c.GetErrMsg()) | 		c.JSONError(c.GetErrMsg()) | ||||||
| 		c.Redirect(url) |  | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	total := time.Duration(form.Hours)*time.Hour + time.Duration(form.Minutes)*time.Minute | 	total := time.Duration(form.Hours)*time.Hour + time.Duration(form.Minutes)*time.Minute | ||||||
|  |  | ||||||
| 	if total <= 0 { | 	if total <= 0 { | ||||||
| 		c.Flash.Error(c.Tr("repo.issues.add_time_sum_to_small")) | 		c.JSONError(c.Tr("repo.issues.add_time_sum_to_small")) | ||||||
| 		c.Redirect(url, http.StatusSeeOther) |  | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -47,7 +46,7 @@ func AddTimeManually(c *context.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	c.Redirect(url, http.StatusSeeOther) | 	c.JSONRedirect("") | ||||||
| } | } | ||||||
|  |  | ||||||
| // DeleteTime deletes tracked time | // DeleteTime deletes tracked time | ||||||
| @@ -83,5 +82,38 @@ func DeleteTime(c *context.Context) { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	c.Flash.Success(c.Tr("repo.issues.del_time_history", util.SecToTime(t.Time))) | 	c.Flash.Success(c.Tr("repo.issues.del_time_history", util.SecToTime(t.Time))) | ||||||
| 	c.Redirect(issue.Link()) | 	c.JSONRedirect("") | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func UpdateIssueTimeEstimate(ctx *context.Context) { | ||||||
|  | 	issue := GetActionIssue(ctx) | ||||||
|  | 	if ctx.Written() { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { | ||||||
|  | 		ctx.Error(http.StatusForbidden) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	timeStr := strings.TrimSpace(ctx.FormString("time_estimate")) | ||||||
|  |  | ||||||
|  | 	total, err := util.TimeEstimateParse(timeStr) | ||||||
|  | 	if err != nil { | ||||||
|  | 		ctx.JSONError(ctx.Tr("repo.issues.time_estimate_invalid")) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// No time changed | ||||||
|  | 	if issue.TimeEstimate == total { | ||||||
|  | 		ctx.JSONRedirect("") | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if err := issue_service.ChangeTimeEstimate(ctx, issue, ctx.Doer, total); err != nil { | ||||||
|  | 		ctx.ServerError("ChangeTimeEstimate", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	ctx.JSONRedirect("") | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1235,6 +1235,7 @@ func registerRoutes(m *web.Router) { | |||||||
| 						m.Post("/cancel", repo.CancelStopwatch) | 						m.Post("/cancel", repo.CancelStopwatch) | ||||||
| 					}) | 					}) | ||||||
| 				}) | 				}) | ||||||
|  | 				m.Post("/time_estimate", repo.UpdateIssueTimeEstimate) | ||||||
| 				m.Post("/reactions/{action}", web.Bind(forms.ReactionForm{}), repo.ChangeIssueReaction) | 				m.Post("/reactions/{action}", web.Bind(forms.ReactionForm{}), repo.ChangeIssueReaction) | ||||||
| 				m.Post("/lock", reqRepoIssuesOrPullsWriter, web.Bind(forms.IssueLockForm{}), repo.LockIssue) | 				m.Post("/lock", reqRepoIssuesOrPullsWriter, web.Bind(forms.IssueLockForm{}), repo.LockIssue) | ||||||
| 				m.Post("/unlock", reqRepoIssuesOrPullsWriter, repo.UnlockIssue) | 				m.Post("/unlock", reqRepoIssuesOrPullsWriter, repo.UnlockIssue) | ||||||
|   | |||||||
| @@ -76,6 +76,11 @@ func ToTimelineComment(ctx context.Context, repo *repo_model.Repository, c *issu | |||||||
| 			// so we check for the "|" delimiter and convert new to legacy format on demand | 			// so we check for the "|" delimiter and convert new to legacy format on demand | ||||||
| 			c.Content = util.SecToTime(c.Content[1:]) | 			c.Content = util.SecToTime(c.Content[1:]) | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | 		if c.Type == issues_model.CommentTypeChangeTimeEstimate { | ||||||
|  | 			timeSec, _ := util.ToInt64(c.Content) | ||||||
|  | 			c.Content = util.TimeEstimateString(timeSec) | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	comment := &api.TimelineComment{ | 	comment := &api.TimelineComment{ | ||||||
|   | |||||||
| @@ -43,6 +43,7 @@ var hiddenCommentTypeGroups = hiddenCommentTypeGroupsType{ | |||||||
| 		/*14*/ issues_model.CommentTypeAddTimeManual, | 		/*14*/ issues_model.CommentTypeAddTimeManual, | ||||||
| 		/*15*/ issues_model.CommentTypeCancelTracking, | 		/*15*/ issues_model.CommentTypeCancelTracking, | ||||||
| 		/*26*/ issues_model.CommentTypeDeleteTimeManual, | 		/*26*/ issues_model.CommentTypeDeleteTimeManual, | ||||||
|  | 		/*38*/ issues_model.CommentTypeChangeTimeEstimate, | ||||||
| 	}, | 	}, | ||||||
| 	"deadline": { | 	"deadline": { | ||||||
| 		/*16*/ issues_model.CommentTypeAddedDeadline, | 		/*16*/ issues_model.CommentTypeAddedDeadline, | ||||||
|   | |||||||
| @@ -105,6 +105,13 @@ func ChangeTitle(ctx context.Context, issue *issues_model.Issue, doer *user_mode | |||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // ChangeTimeEstimate changes the time estimate of this issue, as the given user. | ||||||
|  | func ChangeTimeEstimate(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, timeEstimate int64) (err error) { | ||||||
|  | 	issue.TimeEstimate = timeEstimate | ||||||
|  |  | ||||||
|  | 	return issues_model.ChangeIssueTimeEstimate(ctx, issue, doer, timeEstimate) | ||||||
|  | } | ||||||
|  |  | ||||||
| // ChangeIssueRef changes the branch of this issue, as the given user. | // ChangeIssueRef changes the branch of this issue, as the given user. | ||||||
| func ChangeIssueRef(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, ref string) error { | func ChangeIssueRef(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, ref string) error { | ||||||
| 	oldRef := issue.Ref | 	oldRef := issue.Ref | ||||||
|   | |||||||
| @@ -1,60 +1,78 @@ | |||||||
| {{if .Repository.IsTimetrackerEnabled ctx}} | {{if .Repository.IsTimetrackerEnabled ctx}} | ||||||
| 	{{if and .CanUseTimetracker (not .Repository.IsArchived)}} | 	{{if and .CanUseTimetracker (not .Repository.IsArchived)}} | ||||||
| 		<div class="divider"></div> | 		<div class="divider"></div> | ||||||
| 		<div class="ui timetrack"> | 		<div> | ||||||
| 			<span class="text"><strong>{{ctx.Locale.Tr "repo.issues.tracker"}}</strong></span> | 			<div class="ui dropdown jump"> | ||||||
| 			<div class="tw-mt-2"> | 				<a class="text muted"> | ||||||
| 				<form method="post" action="{{.Issue.Link}}/times/stopwatch/toggle" id="toggle_stopwatch_form"> | 					<strong>{{ctx.Locale.Tr "repo.issues.tracker"}}</strong> {{svg "octicon-gear"}} | ||||||
| 					{{$.CsrfTokenHtml}} | 					{{if $.IsStopwatchRunning}}{{svg "octicon-stopwatch"}}{{end}} | ||||||
| 				</form> | 				</a> | ||||||
| 				<form method="post" action="{{.Issue.Link}}/times/stopwatch/cancel" id="cancel_stopwatch_form"> | 				<div class="menu"> | ||||||
| 					{{$.CsrfTokenHtml}} | 					<a class="item issue-set-time-estimate show-modal" data-modal="#issue-time-set-estimate-modal"> | ||||||
| 				</form> | 						{{svg "octicon-pencil"}} {{ctx.Locale.Tr "repo.issues.time_estimate_set"}} | ||||||
| 				{{if $.IsStopwatchRunning}} | 					</a> | ||||||
| 					<button class="ui fluid button issue-stop-time"> | 					<div class="divider"></div> | ||||||
| 						{{svg "octicon-stopwatch" 16 "tw-mr-2"}} | 					{{if $.IsStopwatchRunning}} | ||||||
| 						{{ctx.Locale.Tr "repo.issues.stop_tracking"}} | 					<a class="item issue-stop-time link-action" data-url="{{.Issue.Link}}/times/stopwatch/toggle"> | ||||||
| 					</button> | 						{{svg "octicon-stopwatch"}} {{ctx.Locale.Tr "repo.issues.timetracker_timer_stop"}} | ||||||
| 					<button class="ui fluid button issue-cancel-time tw-mt-2"> | 					</a> | ||||||
| 						{{svg "octicon-trash" 16 "tw-mr-2"}} | 					<a class="item issue-cancel-time link-action" data-url="{{.Issue.Link}}/times/stopwatch/cancel"> | ||||||
| 						{{ctx.Locale.Tr "repo.issues.cancel_tracking"}} | 						{{svg "octicon-trash"}} {{ctx.Locale.Tr "repo.issues.timetracker_timer_discard"}} | ||||||
| 					</button> | 					</a> | ||||||
| 				{{else}} | 					{{else}} | ||||||
| 					{{if .HasUserStopwatch}} | 					<a class="item issue-start-time link-action" data-url="{{.Issue.Link}}/times/stopwatch/toggle"> | ||||||
| 						<div class="ui warning message"> | 						{{svg "octicon-stopwatch"}} {{ctx.Locale.Tr "repo.issues.timetracker_timer_start"}} | ||||||
| 							{{ctx.Locale.Tr "repo.issues.tracking_already_started" .OtherStopwatchURL}} | 					</a> | ||||||
| 						</div> | 					<a class="item issue-add-time show-modal" data-modal="#issue-time-manually-add-modal"> | ||||||
|  | 						{{svg "octicon-plus"}} {{ctx.Locale.Tr "repo.issues.timetracker_timer_manually_add"}} | ||||||
|  | 					</a> | ||||||
| 					{{end}} | 					{{end}} | ||||||
| 					<button class="ui fluid button issue-start-time" data-tooltip-content='{{ctx.Locale.Tr "repo.issues.start_tracking"}}'> | 				</div> | ||||||
| 						{{svg "octicon-stopwatch" 16 "tw-mr-2"}} | 			</div> | ||||||
| 						{{ctx.Locale.Tr "repo.issues.start_tracking_short"}} |  | ||||||
| 					</button> | 			{{if and (not $.IsStopwatchRunning) .HasUserStopwatch}} | ||||||
| 					<div class="ui mini modal issue-start-time-modal"> | 				<div class="ui warning message">{{ctx.Locale.Tr "repo.issues.tracking_already_started" .OtherStopwatchURL}}</div> | ||||||
| 						<div class="header">{{ctx.Locale.Tr "repo.issues.add_time"}}</div> | 			{{end}} | ||||||
| 						<div class="content"> |  | ||||||
| 							<form method="post" id="add_time_manual_form" action="{{.Issue.Link}}/times/add" class="ui input fluid tw-gap-2"> | 			{{if .Issue.TimeEstimate}} | ||||||
| 								{{$.CsrfTokenHtml}} | 				<div class="tw-my-2">{{ctx.Locale.Tr "repo.issues.time_estimate_display" (TimeEstimateString .Issue.TimeEstimate)}}</div> | ||||||
| 								<input placeholder='{{ctx.Locale.Tr "repo.issues.add_time_hours"}}' type="number" name="hours"> | 			{{end}} | ||||||
| 								<input placeholder='{{ctx.Locale.Tr "repo.issues.add_time_minutes"}}' type="number" name="minutes" class="ui compact"> |  | ||||||
| 							</form> | 			{{/* set time estimate modal */}} | ||||||
| 						</div> | 			<div class="ui mini modal" id="issue-time-set-estimate-modal"> | ||||||
|  | 				<div class="header">{{ctx.Locale.Tr "repo.issues.time_estimate_set"}}</div> | ||||||
|  | 				<form method="post" class="ui form form-fetch-action" action="{{.Issue.Link}}/time_estimate"> | ||||||
|  | 					<div class="content"> | ||||||
|  | 						{{$.CsrfTokenHtml}} | ||||||
|  | 						<input name="time_estimate" placeholder="{{ctx.Locale.Tr "repo.issues.time_estimate_placeholder"}}" value="{{TimeEstimateString .Issue.TimeEstimate}}"> | ||||||
| 						<div class="actions"> | 						<div class="actions"> | ||||||
| 							<button class="ui primary approve button">{{ctx.Locale.Tr "repo.issues.add_time_short"}}</button> | 							<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button> | ||||||
| 							<button class="ui cancel button">{{ctx.Locale.Tr "repo.issues.add_time_cancel"}}</button> | 							<button class="ui primary button">{{ctx.Locale.Tr "repo.issues.save"}}</button> | ||||||
| 						</div> | 						</div> | ||||||
| 					</div> | 					</div> | ||||||
| 					<button class="ui fluid button issue-add-time tw-mt-2" data-tooltip-content='{{ctx.Locale.Tr "repo.issues.add_time"}}'> | 				</form> | ||||||
| 						{{svg "octicon-plus" 16 "tw-mr-2"}} | 			</div> | ||||||
| 						{{ctx.Locale.Tr "repo.issues.add_time_short"}} |  | ||||||
| 					</button> | 			{{/* manually add time modal */}} | ||||||
| 				{{end}} | 			<div class="ui mini modal" id="issue-time-manually-add-modal"> | ||||||
|  | 				<div class="header">{{ctx.Locale.Tr "repo.issues.add_time_manually"}}</div> | ||||||
|  | 				<form method="post" class="ui form form-fetch-action" action="{{.Issue.Link}}/times/add"> | ||||||
|  | 					<div class="content flex-text-block"> | ||||||
|  | 						{{$.CsrfTokenHtml}} | ||||||
|  | 						<input placeholder='{{ctx.Locale.Tr "repo.issues.add_time_hours"}}' type="number" name="hours">: | ||||||
|  | 						<input placeholder='{{ctx.Locale.Tr "repo.issues.add_time_minutes"}}' type="number" name="minutes"> | ||||||
|  | 					</div> | ||||||
|  | 					<div class="actions"> | ||||||
|  | 						<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button> | ||||||
|  | 						<button class="ui primary button">{{ctx.Locale.Tr "repo.issues.timetracker_timer_manually_add"}}</button> | ||||||
|  | 					</div> | ||||||
|  | 				</form> | ||||||
| 			</div> | 			</div> | ||||||
| 		</div> | 		</div> | ||||||
| 	{{end}} | 	{{end}} | ||||||
| 	{{if .WorkingUsers}} | 	{{if .WorkingUsers}} | ||||||
| 		<div class="divider"></div> | 		<div class="ui comments tw-mt-2"> | ||||||
| 		<div class="ui comments"> | 			{{ctx.Locale.Tr "repo.issues.time_spent_from_all_authors" ($.Issue.TotalTrackedTime | Sec2Time)}} | ||||||
| 			<span class="text"><strong>{{ctx.Locale.Tr "repo.issues.time_spent_from_all_authors" ($.Issue.TotalTrackedTime | Sec2Time)}}</strong></span> |  | ||||||
| 			<div> | 			<div> | ||||||
| 				{{range $user, $trackedtime := .WorkingUsers}} | 				{{range $user, $trackedtime := .WorkingUsers}} | ||||||
| 					<div class="comment tw-mt-2"> | 					<div class="comment tw-mt-2"> | ||||||
|   | |||||||
| @@ -12,7 +12,8 @@ | |||||||
| 		26 = DELETE_TIME_MANUAL, 27 = REVIEW_REQUEST, 28 = MERGE_PULL_REQUEST, | 		26 = DELETE_TIME_MANUAL, 27 = REVIEW_REQUEST, 28 = MERGE_PULL_REQUEST, | ||||||
| 		29 = PULL_PUSH_EVENT, 30 = PROJECT_CHANGED, 31 = PROJECT_BOARD_CHANGED | 		29 = PULL_PUSH_EVENT, 30 = PROJECT_CHANGED, 31 = PROJECT_BOARD_CHANGED | ||||||
| 		32 = DISMISSED_REVIEW, 33 = COMMENT_TYPE_CHANGE_ISSUE_REF, 34 = PR_SCHEDULE_TO_AUTO_MERGE, | 		32 = DISMISSED_REVIEW, 33 = COMMENT_TYPE_CHANGE_ISSUE_REF, 34 = PR_SCHEDULE_TO_AUTO_MERGE, | ||||||
| 		35 = CANCEL_SCHEDULED_AUTO_MERGE_PR, 36 = PIN_ISSUE, 37 = UNPIN_ISSUE --> | 		35 = CANCEL_SCHEDULED_AUTO_MERGE_PR, 36 = PIN_ISSUE, 37 = UNPIN_ISSUE, | ||||||
|  | 		38 = COMMENT_TYPE_CHANGE_TIME_ESTIMATE --> | ||||||
| 		{{if eq .Type 0}} | 		{{if eq .Type 0}} | ||||||
| 			<div class="timeline-item comment" id="{{.HashTag}}"> | 			<div class="timeline-item comment" id="{{.HashTag}}"> | ||||||
| 			{{if .OriginalAuthor}} | 			{{if .OriginalAuthor}} | ||||||
| @@ -250,18 +251,11 @@ | |||||||
| 				{{template "shared/user/avatarlink" dict "user" .Poster}} | 				{{template "shared/user/avatarlink" dict "user" .Poster}} | ||||||
| 				<span class="text grey muted-links"> | 				<span class="text grey muted-links"> | ||||||
| 					{{template "shared/user/authorlink" .Poster}} | 					{{template "shared/user/authorlink" .Poster}} | ||||||
| 					{{ctx.Locale.Tr "repo.issues.stop_tracking_history" $createdStr}} | 					{{$timeStr := .RenderedContent}} {{/* compatibility with time comments made before v1.21 */}} | ||||||
|  | 					{{if not $timeStr}}{{$timeStr = .Content|Sec2Time}}{{end}} | ||||||
|  | 					{{ctx.Locale.Tr "repo.issues.stop_tracking_history" $timeStr $createdStr}} | ||||||
| 				</span> | 				</span> | ||||||
| 				{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} | 				{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} | ||||||
| 				<div class="detail flex-text-block"> |  | ||||||
| 					{{svg "octicon-clock"}} |  | ||||||
| 					{{if .RenderedContent}} |  | ||||||
| 						{{/* compatibility with time comments made before v1.21 */}} |  | ||||||
| 						<span class="text grey muted-links">{{.RenderedContent}}</span> |  | ||||||
| 					{{else}} |  | ||||||
| 						<span class="text grey muted-links">{{.Content|Sec2Time}}</span> |  | ||||||
| 					{{end}} |  | ||||||
| 				</div> |  | ||||||
| 			</div> | 			</div> | ||||||
| 		{{else if eq .Type 14}} | 		{{else if eq .Type 14}} | ||||||
| 			<div class="timeline-item event" id="{{.HashTag}}"> | 			<div class="timeline-item event" id="{{.HashTag}}"> | ||||||
| @@ -269,18 +263,11 @@ | |||||||
| 				{{template "shared/user/avatarlink" dict "user" .Poster}} | 				{{template "shared/user/avatarlink" dict "user" .Poster}} | ||||||
| 				<span class="text grey muted-links"> | 				<span class="text grey muted-links"> | ||||||
| 					{{template "shared/user/authorlink" .Poster}} | 					{{template "shared/user/authorlink" .Poster}} | ||||||
| 					{{ctx.Locale.Tr "repo.issues.add_time_history" $createdStr}} | 					{{$timeStr := .RenderedContent}} {{/* compatibility with time comments made before v1.21 */}} | ||||||
|  | 					{{if not $timeStr}}{{$timeStr = .Content|Sec2Time}}{{end}} | ||||||
|  | 					{{ctx.Locale.Tr "repo.issues.add_time_history" $timeStr $createdStr}} | ||||||
| 				</span> | 				</span> | ||||||
| 				{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} | 				{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} | ||||||
| 				<div class="detail flex-text-block"> |  | ||||||
| 					{{svg "octicon-clock"}} |  | ||||||
| 					{{if .RenderedContent}} |  | ||||||
| 						{{/* compatibility with time comments made before v1.21 */}} |  | ||||||
| 						<span class="text grey muted-links">{{.RenderedContent}}</span> |  | ||||||
| 					{{else}} |  | ||||||
| 						<span class="text grey muted-links">{{.Content|Sec2Time}}</span> |  | ||||||
| 					{{end}} |  | ||||||
| 				</div> |  | ||||||
| 			</div> | 			</div> | ||||||
| 		{{else if eq .Type 15}} | 		{{else if eq .Type 15}} | ||||||
| 			<div class="timeline-item event" id="{{.HashTag}}"> | 			<div class="timeline-item event" id="{{.HashTag}}"> | ||||||
| @@ -703,6 +690,20 @@ | |||||||
| 					{{else}}{{ctx.Locale.Tr "repo.issues.unpin_comment" $createdStr}}{{end}} | 					{{else}}{{ctx.Locale.Tr "repo.issues.unpin_comment" $createdStr}}{{end}} | ||||||
| 				</span> | 				</span> | ||||||
| 			</div> | 			</div> | ||||||
|  | 		{{else if eq .Type 38}} | ||||||
|  | 			<div class="timeline-item event" id="{{.HashTag}}"> | ||||||
|  | 				<span class="badge">{{svg "octicon-clock"}}</span> | ||||||
|  | 				{{template "shared/user/avatarlink" dict "Context" $.Context "user" .Poster}} | ||||||
|  | 				<span class="text grey muted-links"> | ||||||
|  | 					{{template "shared/user/authorlink" .Poster}} | ||||||
|  | 					{{$timeStr := .Content|TimeEstimateString}} | ||||||
|  | 					{{if $timeStr}} | ||||||
|  | 						{{ctx.Locale.Tr "repo.issues.change_time_estimate_at" $timeStr $createdStr}} | ||||||
|  | 					{{else}} | ||||||
|  | 						{{ctx.Locale.Tr "repo.issues.remove_time_estimate_at" $createdStr}} | ||||||
|  | 					{{end}} | ||||||
|  | 				</span> | ||||||
|  | 			</div> | ||||||
| 		{{end}} | 		{{end}} | ||||||
| 	{{end}} | 	{{end}} | ||||||
| {{end}} | {{end}} | ||||||
|   | |||||||
| @@ -2,14 +2,10 @@ | |||||||
| 	{{if (not .comment.Time.Deleted)}} | 	{{if (not .comment.Time.Deleted)}} | ||||||
| 		{{if (or .ctxData.IsAdmin (and .ctxData.IsSigned (eq .ctxData.SignedUserID .comment.PosterID)))}} | 		{{if (or .ctxData.IsAdmin (and .ctxData.IsSigned (eq .ctxData.SignedUserID .comment.PosterID)))}} | ||||||
| 			<span class="tw-float-right"> | 			<span class="tw-float-right"> | ||||||
| 				<div class="ui mini modal issue-delete-time-modal" data-id="{{.comment.Time.ID}}"> | 				<button class="ui icon button compact mini link-action" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.del_time"}}" | ||||||
| 					<form method="post" class="delete-time-form" action="{{.ctxData.RepoLink}}/issues/{{.ctxData.Issue.Index}}/times/{{.comment.TimeID}}/delete"> | 								data-url="{{.ctxData.RepoLink}}/issues/{{.ctxData.Issue.Index}}/times/{{.comment.TimeID}}/delete?id={{.comment.Time.ID}}" | ||||||
| 						{{.ctxData.CsrfTokenHtml}} | 								data-modal-confirm="{{ctx.Locale.Tr "repo.issues.del_time"}}" | ||||||
| 					</form> | 				> | ||||||
| 					<div class="header">{{ctx.Locale.Tr "repo.issues.del_time"}}</div> |  | ||||||
| 					{{template "base/modal_actions_confirm"}} |  | ||||||
| 				</div> |  | ||||||
| 				<button class="ui icon button compact mini issue-delete-time" data-id="{{.comment.Time.ID}}" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.del_time"}}"> |  | ||||||
| 					{{svg "octicon-trash"}} | 					{{svg "octicon-trash"}} | ||||||
| 				</button> | 				</button> | ||||||
| 			</span> | 			</span> | ||||||
|   | |||||||
| @@ -24,15 +24,9 @@ func NewHTMLParser(t testing.TB, body *bytes.Buffer) *HTMLDoc { | |||||||
| 	return &HTMLDoc{doc: doc} | 	return &HTMLDoc{doc: doc} | ||||||
| } | } | ||||||
|  |  | ||||||
| // GetInputValueByID for get input value by id |  | ||||||
| func (doc *HTMLDoc) GetInputValueByID(id string) string { |  | ||||||
| 	text, _ := doc.doc.Find("#" + id).Attr("value") |  | ||||||
| 	return text |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetInputValueByName for get input value by name | // GetInputValueByName for get input value by name | ||||||
| func (doc *HTMLDoc) GetInputValueByName(name string) string { | func (doc *HTMLDoc) GetInputValueByName(name string) string { | ||||||
| 	text, _ := doc.doc.Find("input[name=\"" + name + "\"]").Attr("value") | 	text, _ := doc.doc.Find(`input[name="` + name + `"]`).Attr("value") | ||||||
| 	return text | 	return text | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -9,7 +9,6 @@ import ( | |||||||
| 	"testing" | 	"testing" | ||||||
| 	"time" | 	"time" | ||||||
|  |  | ||||||
| 	"code.gitea.io/gitea/modules/test" |  | ||||||
| 	"code.gitea.io/gitea/tests" | 	"code.gitea.io/gitea/tests" | ||||||
|  |  | ||||||
| 	"github.com/stretchr/testify/assert" | 	"github.com/stretchr/testify/assert" | ||||||
| @@ -17,22 +16,24 @@ import ( | |||||||
|  |  | ||||||
| func TestViewTimetrackingControls(t *testing.T) { | func TestViewTimetrackingControls(t *testing.T) { | ||||||
| 	defer tests.PrepareTestEnv(t)() | 	defer tests.PrepareTestEnv(t)() | ||||||
| 	session := loginUser(t, "user2") |  | ||||||
| 	testViewTimetrackingControls(t, session, "user2", "repo1", "1", true) |  | ||||||
| 	// user2/repo1 |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestNotViewTimetrackingControls(t *testing.T) { | 	t.Run("Exist", func(t *testing.T) { | ||||||
| 	defer tests.PrepareTestEnv(t)() | 		defer tests.PrintCurrentTest(t)() | ||||||
| 	session := loginUser(t, "user5") | 		session := loginUser(t, "user2") | ||||||
| 	testViewTimetrackingControls(t, session, "user2", "repo1", "1", false) | 		testViewTimetrackingControls(t, session, "user2", "repo1", "1", true) | ||||||
| 	// user2/repo1 | 	}) | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestViewTimetrackingControlsDisabled(t *testing.T) { | 	t.Run("Non-exist", func(t *testing.T) { | ||||||
| 	defer tests.PrepareTestEnv(t)() | 		defer tests.PrintCurrentTest(t)() | ||||||
| 	session := loginUser(t, "user2") | 		session := loginUser(t, "user5") | ||||||
| 	testViewTimetrackingControls(t, session, "org3", "repo3", "1", false) | 		testViewTimetrackingControls(t, session, "user2", "repo1", "1", false) | ||||||
|  | 	}) | ||||||
|  |  | ||||||
|  | 	t.Run("Disabled", func(t *testing.T) { | ||||||
|  | 		defer tests.PrintCurrentTest(t)() | ||||||
|  | 		session := loginUser(t, "user2") | ||||||
|  | 		testViewTimetrackingControls(t, session, "org3", "repo3", "1", false) | ||||||
|  | 	}) | ||||||
| } | } | ||||||
|  |  | ||||||
| func testViewTimetrackingControls(t *testing.T, session *TestSession, user, repo, issue string, canTrackTime bool) { | func testViewTimetrackingControls(t *testing.T, session *TestSession, user, repo, issue string, canTrackTime bool) { | ||||||
| @@ -41,40 +42,40 @@ func testViewTimetrackingControls(t *testing.T, session *TestSession, user, repo | |||||||
|  |  | ||||||
| 	htmlDoc := NewHTMLParser(t, resp.Body) | 	htmlDoc := NewHTMLParser(t, resp.Body) | ||||||
|  |  | ||||||
| 	htmlDoc.AssertElement(t, ".timetrack .issue-start-time", canTrackTime) | 	htmlDoc.AssertElement(t, ".issue-start-time", canTrackTime) | ||||||
| 	htmlDoc.AssertElement(t, ".timetrack .issue-add-time", canTrackTime) | 	htmlDoc.AssertElement(t, ".issue-add-time", canTrackTime) | ||||||
|  |  | ||||||
| 	req = NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", issue, "times", "stopwatch", "toggle"), map[string]string{ | 	issueLink := path.Join(user, repo, "issues", issue) | ||||||
|  | 	req = NewRequestWithValues(t, "POST", path.Join(issueLink, "times", "stopwatch", "toggle"), map[string]string{ | ||||||
| 		"_csrf": htmlDoc.GetCSRF(), | 		"_csrf": htmlDoc.GetCSRF(), | ||||||
| 	}) | 	}) | ||||||
| 	if canTrackTime { | 	if canTrackTime { | ||||||
| 		resp = session.MakeRequest(t, req, http.StatusSeeOther) | 		session.MakeRequest(t, req, http.StatusOK) | ||||||
|  |  | ||||||
| 		req = NewRequest(t, "GET", test.RedirectURL(resp)) | 		req = NewRequest(t, "GET", issueLink) | ||||||
| 		resp = session.MakeRequest(t, req, http.StatusOK) | 		resp = session.MakeRequest(t, req, http.StatusOK) | ||||||
| 		htmlDoc = NewHTMLParser(t, resp.Body) | 		htmlDoc = NewHTMLParser(t, resp.Body) | ||||||
|  |  | ||||||
| 		events := htmlDoc.doc.Find(".event > span.text") | 		events := htmlDoc.doc.Find(".event > span.text") | ||||||
| 		assert.Contains(t, events.Last().Text(), "started working") | 		assert.Contains(t, events.Last().Text(), "started working") | ||||||
|  |  | ||||||
| 		htmlDoc.AssertElement(t, ".timetrack .issue-stop-time", true) | 		htmlDoc.AssertElement(t, ".issue-stop-time", true) | ||||||
| 		htmlDoc.AssertElement(t, ".timetrack .issue-cancel-time", true) | 		htmlDoc.AssertElement(t, ".issue-cancel-time", true) | ||||||
|  |  | ||||||
| 		// Sleep for 1 second to not get wrong order for stopping timer | 		// Sleep for 1 second to not get wrong order for stopping timer | ||||||
| 		time.Sleep(time.Second) | 		time.Sleep(time.Second) | ||||||
|  |  | ||||||
| 		req = NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", issue, "times", "stopwatch", "toggle"), map[string]string{ | 		req = NewRequestWithValues(t, "POST", path.Join(issueLink, "times", "stopwatch", "toggle"), map[string]string{ | ||||||
| 			"_csrf": htmlDoc.GetCSRF(), | 			"_csrf": htmlDoc.GetCSRF(), | ||||||
| 		}) | 		}) | ||||||
| 		resp = session.MakeRequest(t, req, http.StatusSeeOther) | 		session.MakeRequest(t, req, http.StatusOK) | ||||||
|  |  | ||||||
| 		req = NewRequest(t, "GET", test.RedirectURL(resp)) | 		req = NewRequest(t, "GET", issueLink) | ||||||
| 		resp = session.MakeRequest(t, req, http.StatusOK) | 		resp = session.MakeRequest(t, req, http.StatusOK) | ||||||
| 		htmlDoc = NewHTMLParser(t, resp.Body) | 		htmlDoc = NewHTMLParser(t, resp.Body) | ||||||
|  |  | ||||||
| 		events = htmlDoc.doc.Find(".event > span.text") | 		events = htmlDoc.doc.Find(".event > span.text") | ||||||
| 		assert.Contains(t, events.Last().Text(), "stopped working") | 		assert.Contains(t, events.Last().Text(), "worked for ") | ||||||
| 		htmlDoc.AssertElement(t, ".event .detail .octicon-clock", true) |  | ||||||
| 	} else { | 	} else { | ||||||
| 		session.MakeRequest(t, req, http.StatusNotFound) | 		session.MakeRequest(t, req, http.StatusNotFound) | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -11,37 +11,6 @@ import {initRepoIssueSidebar} from './repo-issue-sidebar.ts'; | |||||||
|  |  | ||||||
| const {appSubUrl} = window.config; | const {appSubUrl} = window.config; | ||||||
|  |  | ||||||
| export function initRepoIssueTimeTracking() { |  | ||||||
|   $(document).on('click', '.issue-add-time', () => { |  | ||||||
|     $('.issue-start-time-modal').modal({ |  | ||||||
|       duration: 200, |  | ||||||
|       onApprove() { |  | ||||||
|         $('#add_time_manual_form').trigger('submit'); |  | ||||||
|       }, |  | ||||||
|     }).modal('show'); |  | ||||||
|     $('.issue-start-time-modal input').on('keydown', (e) => { |  | ||||||
|       if (e.key === 'Enter') { |  | ||||||
|         $('#add_time_manual_form').trigger('submit'); |  | ||||||
|       } |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
|   $(document).on('click', '.issue-start-time, .issue-stop-time', () => { |  | ||||||
|     $('#toggle_stopwatch_form').trigger('submit'); |  | ||||||
|   }); |  | ||||||
|   $(document).on('click', '.issue-cancel-time', () => { |  | ||||||
|     $('#cancel_stopwatch_form').trigger('submit'); |  | ||||||
|   }); |  | ||||||
|   $(document).on('click', 'button.issue-delete-time', function () { |  | ||||||
|     const sel = `.issue-delete-time-modal[data-id="${$(this).data('id')}"]`; |  | ||||||
|     $(sel).modal({ |  | ||||||
|       duration: 200, |  | ||||||
|       onApprove() { |  | ||||||
|         $(`${sel} form`).trigger('submit'); |  | ||||||
|       }, |  | ||||||
|     }).modal('show'); |  | ||||||
|   }); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * @param {HTMLElement} item |  * @param {HTMLElement} item | ||||||
|  */ |  */ | ||||||
|   | |||||||
| @@ -26,7 +26,6 @@ import {initPdfViewer} from './render/pdf.ts'; | |||||||
| import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts'; | import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts'; | ||||||
| import { | import { | ||||||
|   initRepoIssueReferenceRepositorySearch, |   initRepoIssueReferenceRepositorySearch, | ||||||
|   initRepoIssueTimeTracking, |  | ||||||
|   initRepoIssueWipTitle, |   initRepoIssueWipTitle, | ||||||
|   initRepoPullRequestMergeInstruction, |   initRepoPullRequestMergeInstruction, | ||||||
|   initRepoPullRequestAllowMaintainerEdit, |   initRepoPullRequestAllowMaintainerEdit, | ||||||
| @@ -184,7 +183,6 @@ onDomReady(() => { | |||||||
|     initRepoIssueList, |     initRepoIssueList, | ||||||
|     initRepoIssueSidebarList, |     initRepoIssueSidebarList, | ||||||
|     initRepoIssueReferenceRepositorySearch, |     initRepoIssueReferenceRepositorySearch, | ||||||
|     initRepoIssueTimeTracking, |  | ||||||
|     initRepoIssueWipTitle, |     initRepoIssueWipTitle, | ||||||
|     initRepoMigration, |     initRepoMigration, | ||||||
|     initRepoMigrationStatusChecker, |     initRepoMigrationStatusChecker, | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user