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:
Illya Marchenko 2024-12-05 15:07:53 +02:00 committed by GitHub
parent c5422fae9a
commit 936665bf85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 390 additions and 164 deletions

View File

@ -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 {

View File

@ -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
})
}

View File

@ -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
} }

View 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))
}

View File

@ -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
View 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, " ")
}

View 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)
})
}
})
}

View File

@ -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."

View File

@ -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

View File

@ -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("")
} }

View File

@ -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)

View File

@ -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{

View File

@ -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,

View File

@ -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

View File

@ -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">

View File

@ -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}}

View File

@ -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>

View File

@ -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
} }

View File

@ -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)
} }

View File

@ -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
*/ */

View File

@ -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,