mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-11-04 06:24:11 +01: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:
		
							parent
							
								
									c5422fae9a
								
							
						
					
					
						commit
						936665bf85
					
				@ -114,6 +114,8 @@ const (
 | 
			
		||||
 | 
			
		||||
	CommentTypePin   // 36 pin Issue
 | 
			
		||||
	CommentTypeUnpin // 37 unpin Issue
 | 
			
		||||
 | 
			
		||||
	CommentTypeChangeTimeEstimate // 38 Change time estimate
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var commentStrings = []string{
 | 
			
		||||
@ -155,6 +157,7 @@ var commentStrings = []string{
 | 
			
		||||
	"pull_cancel_scheduled_merge",
 | 
			
		||||
	"pin",
 | 
			
		||||
	"unpin",
 | 
			
		||||
	"change_time_estimate",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (t CommentType) String() string {
 | 
			
		||||
 | 
			
		||||
@ -147,6 +147,9 @@ type Issue struct {
 | 
			
		||||
 | 
			
		||||
	// For view issue page.
 | 
			
		||||
	ShowRole RoleDescriptor `xorm:"-"`
 | 
			
		||||
 | 
			
		||||
	// Time estimate
 | 
			
		||||
	TimeEstimate int64 `xorm:"NOT NULL DEFAULT 0"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
@ -934,3 +937,28 @@ func insertIssue(ctx context.Context, issue *Issue) error {
 | 
			
		||||
 | 
			
		||||
	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(309, "Improve Notification table indices", v1_23.ImproveNotificationTableIndices),
 | 
			
		||||
		newMigration(310, "Add Priority to ProtectedBranch", v1_23.AddPriorityToProtectedBranch),
 | 
			
		||||
		newMigration(311, "Add TimeEstimate to Issue table", v1_23.AddTimeEstimateColumnToIssueTable),
 | 
			
		||||
	}
 | 
			
		||||
	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,
 | 
			
		||||
		"CountFmt": base.FormatNumberSI,
 | 
			
		||||
		"Sec2Time": util.SecToTime,
 | 
			
		||||
 | 
			
		||||
		"TimeEstimateString": timeEstimateString,
 | 
			
		||||
 | 
			
		||||
		"LoadTimes": func(startTime time.Time) string {
 | 
			
		||||
			return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms"
 | 
			
		||||
		},
 | 
			
		||||
@ -282,6 +285,14 @@ func userThemeName(user *user_model.User) string {
 | 
			
		||||
	return setting.UI.DefaultTheme
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func timeEstimateString(timeSec any) string {
 | 
			
		||||
	v, _ := util.ToInt64(timeSec)
 | 
			
		||||
	if v == 0 {
 | 
			
		||||
		return ""
 | 
			
		||||
	}
 | 
			
		||||
	return util.TimeEstimateString(v)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func panicIfDevOrTesting() {
 | 
			
		||||
	if !setting.IsProd || setting.IsInTesting {
 | 
			
		||||
		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.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.tracker = Time Tracker
 | 
			
		||||
issues.start_tracking_short = Start Timer
 | 
			
		||||
issues.start_tracking = Start Time Tracking
 | 
			
		||||
issues.start_tracking_history = `started working %s`
 | 
			
		||||
issues.timetracker_timer_start = Start timer
 | 
			
		||||
issues.timetracker_timer_stop = Stop timer
 | 
			
		||||
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.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 = `stopped working %s`
 | 
			
		||||
issues.cancel_tracking = Discard
 | 
			
		||||
issues.stop_tracking_history = worked for <b>%s</b> %s
 | 
			
		||||
issues.cancel_tracking_history = `canceled time tracking %s`
 | 
			
		||||
issues.add_time = Manually Add Time
 | 
			
		||||
issues.del_time = Delete this time log
 | 
			
		||||
issues.add_time_short = Add Time
 | 
			
		||||
issues.add_time_cancel = Cancel
 | 
			
		||||
issues.add_time_history = `added spent time %s`
 | 
			
		||||
issues.add_time_history = added spent time <b>%s</b> %s
 | 
			
		||||
issues.del_time_history= `deleted spent time %s`
 | 
			
		||||
issues.add_time_manually = Manually Add Time
 | 
			
		||||
issues.add_time_hours = Hours
 | 
			
		||||
issues.add_time_minutes = Minutes
 | 
			
		||||
issues.add_time_sum_to_small = No time was entered.
 | 
			
		||||
issues.time_spent_total = Total Time Spent
 | 
			
		||||
issues.time_spent_from_all_authors = `Total Time Spent: %s`
 | 
			
		||||
 | 
			
		||||
issues.due_date = Due Date
 | 
			
		||||
issues.invalid_due_date_format = "Due date format must be 'yyyy-mm-dd'."
 | 
			
		||||
issues.error_modifying_due_date = "Failed to modify the due date."
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,6 @@
 | 
			
		||||
package repo
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"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"))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	url := issue.Link()
 | 
			
		||||
	c.Redirect(url, http.StatusSeeOther)
 | 
			
		||||
	c.JSONRedirect("")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// CancelStopwatch cancel the stopwatch
 | 
			
		||||
@ -72,8 +70,7 @@ func CancelStopwatch(c *context.Context) {
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	url := issue.Link()
 | 
			
		||||
	c.Redirect(url, http.StatusSeeOther)
 | 
			
		||||
	c.JSONRedirect("")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetActiveStopwatch is the middleware that sets .ActiveStopwatch on context
 | 
			
		||||
 | 
			
		||||
@ -5,6 +5,7 @@ package repo
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/models/db"
 | 
			
		||||
@ -13,6 +14,7 @@ import (
 | 
			
		||||
	"code.gitea.io/gitea/modules/web"
 | 
			
		||||
	"code.gitea.io/gitea/services/context"
 | 
			
		||||
	"code.gitea.io/gitea/services/forms"
 | 
			
		||||
	issue_service "code.gitea.io/gitea/services/issue"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// AddTimeManually tracks time manually
 | 
			
		||||
@ -26,19 +28,16 @@ func AddTimeManually(c *context.Context) {
 | 
			
		||||
		c.NotFound("CanUseTimetracker", nil)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	url := issue.Link()
 | 
			
		||||
 | 
			
		||||
	if c.HasError() {
 | 
			
		||||
		c.Flash.Error(c.GetErrMsg())
 | 
			
		||||
		c.Redirect(url)
 | 
			
		||||
		c.JSONError(c.GetErrMsg())
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	total := time.Duration(form.Hours)*time.Hour + time.Duration(form.Minutes)*time.Minute
 | 
			
		||||
 | 
			
		||||
	if total <= 0 {
 | 
			
		||||
		c.Flash.Error(c.Tr("repo.issues.add_time_sum_to_small"))
 | 
			
		||||
		c.Redirect(url, http.StatusSeeOther)
 | 
			
		||||
		c.JSONError(c.Tr("repo.issues.add_time_sum_to_small"))
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -47,7 +46,7 @@ func AddTimeManually(c *context.Context) {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c.Redirect(url, http.StatusSeeOther)
 | 
			
		||||
	c.JSONRedirect("")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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.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("/time_estimate", repo.UpdateIssueTimeEstimate)
 | 
			
		||||
				m.Post("/reactions/{action}", web.Bind(forms.ReactionForm{}), repo.ChangeIssueReaction)
 | 
			
		||||
				m.Post("/lock", reqRepoIssuesOrPullsWriter, web.Bind(forms.IssueLockForm{}), repo.LockIssue)
 | 
			
		||||
				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
 | 
			
		||||
			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{
 | 
			
		||||
 | 
			
		||||
@ -43,6 +43,7 @@ var hiddenCommentTypeGroups = hiddenCommentTypeGroupsType{
 | 
			
		||||
		/*14*/ issues_model.CommentTypeAddTimeManual,
 | 
			
		||||
		/*15*/ issues_model.CommentTypeCancelTracking,
 | 
			
		||||
		/*26*/ issues_model.CommentTypeDeleteTimeManual,
 | 
			
		||||
		/*38*/ issues_model.CommentTypeChangeTimeEstimate,
 | 
			
		||||
	},
 | 
			
		||||
	"deadline": {
 | 
			
		||||
		/*16*/ issues_model.CommentTypeAddedDeadline,
 | 
			
		||||
 | 
			
		||||
@ -105,6 +105,13 @@ func ChangeTitle(ctx context.Context, issue *issues_model.Issue, doer *user_mode
 | 
			
		||||
	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.
 | 
			
		||||
func ChangeIssueRef(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, ref string) error {
 | 
			
		||||
	oldRef := issue.Ref
 | 
			
		||||
 | 
			
		||||
@ -1,60 +1,78 @@
 | 
			
		||||
{{if .Repository.IsTimetrackerEnabled ctx}}
 | 
			
		||||
	{{if and .CanUseTimetracker (not .Repository.IsArchived)}}
 | 
			
		||||
		<div class="divider"></div>
 | 
			
		||||
		<div class="ui timetrack">
 | 
			
		||||
			<span class="text"><strong>{{ctx.Locale.Tr "repo.issues.tracker"}}</strong></span>
 | 
			
		||||
			<div class="tw-mt-2">
 | 
			
		||||
				<form method="post" action="{{.Issue.Link}}/times/stopwatch/toggle" id="toggle_stopwatch_form">
 | 
			
		||||
					{{$.CsrfTokenHtml}}
 | 
			
		||||
				</form>
 | 
			
		||||
				<form method="post" action="{{.Issue.Link}}/times/stopwatch/cancel" id="cancel_stopwatch_form">
 | 
			
		||||
					{{$.CsrfTokenHtml}}
 | 
			
		||||
				</form>
 | 
			
		||||
				{{if $.IsStopwatchRunning}}
 | 
			
		||||
					<button class="ui fluid button issue-stop-time">
 | 
			
		||||
						{{svg "octicon-stopwatch" 16 "tw-mr-2"}}
 | 
			
		||||
						{{ctx.Locale.Tr "repo.issues.stop_tracking"}}
 | 
			
		||||
					</button>
 | 
			
		||||
					<button class="ui fluid button issue-cancel-time tw-mt-2">
 | 
			
		||||
						{{svg "octicon-trash" 16 "tw-mr-2"}}
 | 
			
		||||
						{{ctx.Locale.Tr "repo.issues.cancel_tracking"}}
 | 
			
		||||
					</button>
 | 
			
		||||
				{{else}}
 | 
			
		||||
					{{if .HasUserStopwatch}}
 | 
			
		||||
						<div class="ui warning message">
 | 
			
		||||
							{{ctx.Locale.Tr "repo.issues.tracking_already_started" .OtherStopwatchURL}}
 | 
			
		||||
						</div>
 | 
			
		||||
		<div>
 | 
			
		||||
			<div class="ui dropdown jump">
 | 
			
		||||
				<a class="text muted">
 | 
			
		||||
					<strong>{{ctx.Locale.Tr "repo.issues.tracker"}}</strong> {{svg "octicon-gear"}}
 | 
			
		||||
					{{if $.IsStopwatchRunning}}{{svg "octicon-stopwatch"}}{{end}}
 | 
			
		||||
				</a>
 | 
			
		||||
				<div class="menu">
 | 
			
		||||
					<a class="item issue-set-time-estimate show-modal" data-modal="#issue-time-set-estimate-modal">
 | 
			
		||||
						{{svg "octicon-pencil"}} {{ctx.Locale.Tr "repo.issues.time_estimate_set"}}
 | 
			
		||||
					</a>
 | 
			
		||||
					<div class="divider"></div>
 | 
			
		||||
					{{if $.IsStopwatchRunning}}
 | 
			
		||||
					<a class="item issue-stop-time link-action" data-url="{{.Issue.Link}}/times/stopwatch/toggle">
 | 
			
		||||
						{{svg "octicon-stopwatch"}} {{ctx.Locale.Tr "repo.issues.timetracker_timer_stop"}}
 | 
			
		||||
					</a>
 | 
			
		||||
					<a class="item issue-cancel-time link-action" data-url="{{.Issue.Link}}/times/stopwatch/cancel">
 | 
			
		||||
						{{svg "octicon-trash"}} {{ctx.Locale.Tr "repo.issues.timetracker_timer_discard"}}
 | 
			
		||||
					</a>
 | 
			
		||||
					{{else}}
 | 
			
		||||
					<a class="item issue-start-time link-action" data-url="{{.Issue.Link}}/times/stopwatch/toggle">
 | 
			
		||||
						{{svg "octicon-stopwatch"}} {{ctx.Locale.Tr "repo.issues.timetracker_timer_start"}}
 | 
			
		||||
					</a>
 | 
			
		||||
					<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}}
 | 
			
		||||
					<button class="ui fluid button issue-start-time" data-tooltip-content='{{ctx.Locale.Tr "repo.issues.start_tracking"}}'>
 | 
			
		||||
						{{svg "octicon-stopwatch" 16 "tw-mr-2"}}
 | 
			
		||||
						{{ctx.Locale.Tr "repo.issues.start_tracking_short"}}
 | 
			
		||||
					</button>
 | 
			
		||||
					<div class="ui mini modal issue-start-time-modal">
 | 
			
		||||
						<div class="header">{{ctx.Locale.Tr "repo.issues.add_time"}}</div>
 | 
			
		||||
						<div class="content">
 | 
			
		||||
							<form method="post" id="add_time_manual_form" action="{{.Issue.Link}}/times/add" class="ui input fluid tw-gap-2">
 | 
			
		||||
								{{$.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" class="ui compact">
 | 
			
		||||
							</form>
 | 
			
		||||
						</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{{if and (not $.IsStopwatchRunning) .HasUserStopwatch}}
 | 
			
		||||
				<div class="ui warning message">{{ctx.Locale.Tr "repo.issues.tracking_already_started" .OtherStopwatchURL}}</div>
 | 
			
		||||
			{{end}}
 | 
			
		||||
 | 
			
		||||
			{{if .Issue.TimeEstimate}}
 | 
			
		||||
				<div class="tw-my-2">{{ctx.Locale.Tr "repo.issues.time_estimate_display" (TimeEstimateString .Issue.TimeEstimate)}}</div>
 | 
			
		||||
			{{end}}
 | 
			
		||||
 | 
			
		||||
			{{/* set time estimate modal */}}
 | 
			
		||||
			<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">
 | 
			
		||||
							<button class="ui primary approve button">{{ctx.Locale.Tr "repo.issues.add_time_short"}}</button>
 | 
			
		||||
							<button class="ui cancel button">{{ctx.Locale.Tr "repo.issues.add_time_cancel"}}</button>
 | 
			
		||||
							<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button>
 | 
			
		||||
							<button class="ui primary button">{{ctx.Locale.Tr "repo.issues.save"}}</button>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
					<button class="ui fluid button issue-add-time tw-mt-2" data-tooltip-content='{{ctx.Locale.Tr "repo.issues.add_time"}}'>
 | 
			
		||||
						{{svg "octicon-plus" 16 "tw-mr-2"}}
 | 
			
		||||
						{{ctx.Locale.Tr "repo.issues.add_time_short"}}
 | 
			
		||||
					</button>
 | 
			
		||||
				{{end}}
 | 
			
		||||
				</form>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{{/* manually add time modal */}}
 | 
			
		||||
			<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>
 | 
			
		||||
	{{end}}
 | 
			
		||||
	{{if .WorkingUsers}}
 | 
			
		||||
		<div class="divider"></div>
 | 
			
		||||
		<div class="ui comments">
 | 
			
		||||
			<span class="text"><strong>{{ctx.Locale.Tr "repo.issues.time_spent_from_all_authors" ($.Issue.TotalTrackedTime | Sec2Time)}}</strong></span>
 | 
			
		||||
		<div class="ui comments tw-mt-2">
 | 
			
		||||
			{{ctx.Locale.Tr "repo.issues.time_spent_from_all_authors" ($.Issue.TotalTrackedTime | Sec2Time)}}
 | 
			
		||||
			<div>
 | 
			
		||||
				{{range $user, $trackedtime := .WorkingUsers}}
 | 
			
		||||
					<div class="comment tw-mt-2">
 | 
			
		||||
 | 
			
		||||
@ -12,7 +12,8 @@
 | 
			
		||||
		26 = DELETE_TIME_MANUAL, 27 = REVIEW_REQUEST, 28 = MERGE_PULL_REQUEST,
 | 
			
		||||
		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,
 | 
			
		||||
		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}}
 | 
			
		||||
			<div class="timeline-item comment" id="{{.HashTag}}">
 | 
			
		||||
			{{if .OriginalAuthor}}
 | 
			
		||||
@ -250,18 +251,11 @@
 | 
			
		||||
				{{template "shared/user/avatarlink" dict "user" .Poster}}
 | 
			
		||||
				<span class="text grey muted-links">
 | 
			
		||||
					{{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>
 | 
			
		||||
				{{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>
 | 
			
		||||
		{{else if eq .Type 14}}
 | 
			
		||||
			<div class="timeline-item event" id="{{.HashTag}}">
 | 
			
		||||
@ -269,18 +263,11 @@
 | 
			
		||||
				{{template "shared/user/avatarlink" dict "user" .Poster}}
 | 
			
		||||
				<span class="text grey muted-links">
 | 
			
		||||
					{{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>
 | 
			
		||||
				{{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>
 | 
			
		||||
		{{else if eq .Type 15}}
 | 
			
		||||
			<div class="timeline-item event" id="{{.HashTag}}">
 | 
			
		||||
@ -703,6 +690,20 @@
 | 
			
		||||
					{{else}}{{ctx.Locale.Tr "repo.issues.unpin_comment" $createdStr}}{{end}}
 | 
			
		||||
				</span>
 | 
			
		||||
			</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}}
 | 
			
		||||
 | 
			
		||||
@ -2,14 +2,10 @@
 | 
			
		||||
	{{if (not .comment.Time.Deleted)}}
 | 
			
		||||
		{{if (or .ctxData.IsAdmin (and .ctxData.IsSigned (eq .ctxData.SignedUserID .comment.PosterID)))}}
 | 
			
		||||
			<span class="tw-float-right">
 | 
			
		||||
				<div class="ui mini modal issue-delete-time-modal" data-id="{{.comment.Time.ID}}">
 | 
			
		||||
					<form method="post" class="delete-time-form" action="{{.ctxData.RepoLink}}/issues/{{.ctxData.Issue.Index}}/times/{{.comment.TimeID}}/delete">
 | 
			
		||||
						{{.ctxData.CsrfTokenHtml}}
 | 
			
		||||
					</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"}}">
 | 
			
		||||
				<button class="ui icon button compact mini link-action" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.del_time"}}"
 | 
			
		||||
								data-url="{{.ctxData.RepoLink}}/issues/{{.ctxData.Issue.Index}}/times/{{.comment.TimeID}}/delete?id={{.comment.Time.ID}}"
 | 
			
		||||
								data-modal-confirm="{{ctx.Locale.Tr "repo.issues.del_time"}}"
 | 
			
		||||
				>
 | 
			
		||||
					{{svg "octicon-trash"}}
 | 
			
		||||
				</button>
 | 
			
		||||
			</span>
 | 
			
		||||
 | 
			
		||||
@ -24,15 +24,9 @@ func NewHTMLParser(t testing.TB, body *bytes.Buffer) *HTMLDoc {
 | 
			
		||||
	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
 | 
			
		||||
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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -9,7 +9,6 @@ import (
 | 
			
		||||
	"testing"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/modules/test"
 | 
			
		||||
	"code.gitea.io/gitea/tests"
 | 
			
		||||
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
@ -17,22 +16,24 @@ import (
 | 
			
		||||
 | 
			
		||||
func TestViewTimetrackingControls(t *testing.T) {
 | 
			
		||||
	defer tests.PrepareTestEnv(t)()
 | 
			
		||||
	session := loginUser(t, "user2")
 | 
			
		||||
	testViewTimetrackingControls(t, session, "user2", "repo1", "1", true)
 | 
			
		||||
	// user2/repo1
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestNotViewTimetrackingControls(t *testing.T) {
 | 
			
		||||
	defer tests.PrepareTestEnv(t)()
 | 
			
		||||
	session := loginUser(t, "user5")
 | 
			
		||||
	testViewTimetrackingControls(t, session, "user2", "repo1", "1", false)
 | 
			
		||||
	// user2/repo1
 | 
			
		||||
}
 | 
			
		||||
	t.Run("Exist", func(t *testing.T) {
 | 
			
		||||
		defer tests.PrintCurrentTest(t)()
 | 
			
		||||
		session := loginUser(t, "user2")
 | 
			
		||||
		testViewTimetrackingControls(t, session, "user2", "repo1", "1", true)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
func TestViewTimetrackingControlsDisabled(t *testing.T) {
 | 
			
		||||
	defer tests.PrepareTestEnv(t)()
 | 
			
		||||
	session := loginUser(t, "user2")
 | 
			
		||||
	testViewTimetrackingControls(t, session, "org3", "repo3", "1", false)
 | 
			
		||||
	t.Run("Non-exist", func(t *testing.T) {
 | 
			
		||||
		defer tests.PrintCurrentTest(t)()
 | 
			
		||||
		session := loginUser(t, "user5")
 | 
			
		||||
		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) {
 | 
			
		||||
@ -41,40 +42,40 @@ func testViewTimetrackingControls(t *testing.T, session *TestSession, user, repo
 | 
			
		||||
 | 
			
		||||
	htmlDoc := NewHTMLParser(t, resp.Body)
 | 
			
		||||
 | 
			
		||||
	htmlDoc.AssertElement(t, ".timetrack .issue-start-time", canTrackTime)
 | 
			
		||||
	htmlDoc.AssertElement(t, ".timetrack .issue-add-time", canTrackTime)
 | 
			
		||||
	htmlDoc.AssertElement(t, ".issue-start-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(),
 | 
			
		||||
	})
 | 
			
		||||
	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)
 | 
			
		||||
		htmlDoc = NewHTMLParser(t, resp.Body)
 | 
			
		||||
 | 
			
		||||
		events := htmlDoc.doc.Find(".event > span.text")
 | 
			
		||||
		assert.Contains(t, events.Last().Text(), "started working")
 | 
			
		||||
 | 
			
		||||
		htmlDoc.AssertElement(t, ".timetrack .issue-stop-time", true)
 | 
			
		||||
		htmlDoc.AssertElement(t, ".timetrack .issue-cancel-time", true)
 | 
			
		||||
		htmlDoc.AssertElement(t, ".issue-stop-time", true)
 | 
			
		||||
		htmlDoc.AssertElement(t, ".issue-cancel-time", true)
 | 
			
		||||
 | 
			
		||||
		// Sleep for 1 second to not get wrong order for stopping timer
 | 
			
		||||
		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(),
 | 
			
		||||
		})
 | 
			
		||||
		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)
 | 
			
		||||
		htmlDoc = NewHTMLParser(t, resp.Body)
 | 
			
		||||
 | 
			
		||||
		events = htmlDoc.doc.Find(".event > span.text")
 | 
			
		||||
		assert.Contains(t, events.Last().Text(), "stopped working")
 | 
			
		||||
		htmlDoc.AssertElement(t, ".event .detail .octicon-clock", true)
 | 
			
		||||
		assert.Contains(t, events.Last().Text(), "worked for ")
 | 
			
		||||
	} else {
 | 
			
		||||
		session.MakeRequest(t, req, http.StatusNotFound)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -11,37 +11,6 @@ import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
@ -26,7 +26,6 @@ import {initPdfViewer} from './render/pdf.ts';
 | 
			
		||||
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
 | 
			
		||||
import {
 | 
			
		||||
  initRepoIssueReferenceRepositorySearch,
 | 
			
		||||
  initRepoIssueTimeTracking,
 | 
			
		||||
  initRepoIssueWipTitle,
 | 
			
		||||
  initRepoPullRequestMergeInstruction,
 | 
			
		||||
  initRepoPullRequestAllowMaintainerEdit,
 | 
			
		||||
@ -184,7 +183,6 @@ onDomReady(() => {
 | 
			
		||||
    initRepoIssueList,
 | 
			
		||||
    initRepoIssueSidebarList,
 | 
			
		||||
    initRepoIssueReferenceRepositorySearch,
 | 
			
		||||
    initRepoIssueTimeTracking,
 | 
			
		||||
    initRepoIssueWipTitle,
 | 
			
		||||
    initRepoMigration,
 | 
			
		||||
    initRepoMigrationStatusChecker,
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user