@@ -112,6 +112,8 @@ const ( | |||
CommentTypePin // 36 pin Issue | |||
CommentTypeUnpin // 37 unpin Issue | |||
CommentTypeChangeTimeEstimate // 38 Change time estimate | |||
) | |||
var commentStrings = []string{ | |||
@@ -151,6 +153,7 @@ var commentStrings = []string{ | |||
"change_issue_ref", | |||
"pull_scheduled_merge", | |||
"pull_cancel_scheduled_merge", | |||
"change_time_estimate", | |||
"pin", | |||
"unpin", | |||
} |
@@ -140,6 +140,9 @@ type Issue struct { | |||
// For view issue page. | |||
ShowRole RoleDescriptor `xorm:"-"` | |||
// Time estimate | |||
TimeEstimate int64 `xorm:"NOT NULL DEFAULT 0"` | |||
} | |||
var ( | |||
@@ -909,3 +912,33 @@ func insertIssue(ctx context.Context, issue *Issue) error { | |||
return nil | |||
} | |||
// ChangeIssueTimeEstimate changes the plan time of this issue, as the given user. | |||
func ChangeIssueTimeEstimate(issue *Issue, doer *user_model.User, timeEstimate int64) (err error) { | |||
ctx, committer, err := db.TxContext(db.DefaultContext) | |||
if err != nil { | |||
return err | |||
} | |||
defer committer.Close() | |||
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 committer.Commit() | |||
} |
@@ -586,6 +586,8 @@ var migrations = []Migration{ | |||
NewMigration("Add everyone_access_mode for repo_unit", v1_23.AddRepoUnitEveryoneAccessMode), | |||
// v298 -> v299 | |||
NewMigration("Drop wrongly created table o_auth2_application", v1_23.DropWronglyCreatedTable), | |||
// v300 -> v301 | |||
NewMigration("Add TimeEstimate to issue table", v1_23.AddTimeEstimateColumnToIssueTable), | |||
} | |||
// GetCurrentDBVersion returns the current db version |
@@ -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)) | |||
} |
@@ -65,12 +65,14 @@ func NewFuncMap() template.FuncMap { | |||
// ----------------------------------------------------------------- | |||
// time / number / format | |||
"FileSize": base.FileSize, | |||
"CountFmt": base.FormatNumberSI, | |||
"TimeSince": timeutil.TimeSince, | |||
"TimeSinceUnix": timeutil.TimeSinceUnix, | |||
"DateTime": timeutil.DateTime, | |||
"Sec2Time": util.SecToTime, | |||
"FileSize": base.FileSize, | |||
"CountFmt": base.FormatNumberSI, | |||
"TimeSince": timeutil.TimeSince, | |||
"TimeSinceUnix": timeutil.TimeSinceUnix, | |||
"DateTime": timeutil.DateTime, | |||
"Sec2Time": util.SecToTime, | |||
"SecToTimeExact": util.SecToTimeExact, | |||
"TimeEstimateToStr": util.TimeEstimateToStr, | |||
"LoadTimes": func(startTime time.Time) string { | |||
return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms" | |||
}, |
@@ -66,6 +66,43 @@ func SecToTime(durationVal any) string { | |||
return strings.TrimRight(formattedTime, " ") | |||
} | |||
func SecToTimeExact(duration int64, withSeconds bool) string { | |||
formattedTime := "" | |||
// The following four variables are calculated by taking | |||
// into account the previously calculated variables, this avoids | |||
// pitfalls when using remainders. As that could lead to incorrect | |||
// results when the calculated number equals the quotient number. | |||
remainingDays := duration / (60 * 60 * 24) | |||
years := remainingDays / 365 | |||
remainingDays -= years * 365 | |||
months := remainingDays * 12 / 365 | |||
remainingDays -= months * 365 / 12 | |||
weeks := remainingDays / 7 | |||
remainingDays -= weeks * 7 | |||
days := remainingDays | |||
// The following three variables are calculated without depending | |||
// on the previous calculated variables. | |||
hours := (duration / 3600) % 24 | |||
minutes := (duration / 60) % 60 | |||
seconds := duration % 60 | |||
// Show exact time information | |||
formattedTime = formatTime(years, "year", formattedTime) | |||
formattedTime = formatTime(months, "month", formattedTime) | |||
formattedTime = formatTime(weeks, "week", formattedTime) | |||
formattedTime = formatTime(days, "day", formattedTime) | |||
formattedTime = formatTime(hours, "hour", formattedTime) | |||
formattedTime = formatTime(minutes, "minute", formattedTime) | |||
if withSeconds { | |||
formattedTime = formatTime(seconds, "second", formattedTime) | |||
} | |||
// The formatTime() function always appends a space at the end. This will be trimmed | |||
return strings.TrimRight(formattedTime, " ") | |||
} | |||
// formatTime appends the given value to the existing forammattedTime. E.g: | |||
// formattedTime = "1 year" | |||
// input: value = 3, name = "month" |
@@ -0,0 +1,99 @@ | |||
// Copyright 2022 Gitea. All rights reserved. | |||
// SPDX-License-Identifier: MIT | |||
package util | |||
import ( | |||
"fmt" | |||
"math" | |||
"regexp" | |||
"strconv" | |||
"strings" | |||
) | |||
var ( | |||
// Time estimate match regex | |||
rTimeEstimateOnlyHours = regexp.MustCompile(`^([\d]+)$`) | |||
rTimeEstimateWeeks = regexp.MustCompile(`([\d]+)w`) | |||
rTimeEstimateDays = regexp.MustCompile(`([\d]+)d`) | |||
rTimeEstimateHours = regexp.MustCompile(`([\d]+)h`) | |||
rTimeEstimateMinutes = regexp.MustCompile(`([\d]+)m`) | |||
) | |||
// TimeEstimateFromStr returns time estimate in seconds from formatted string | |||
func TimeEstimateFromStr(timeStr string) int64 { | |||
timeTotal := 0 | |||
// If single number entered, assume hours | |||
timeStrMatches := rTimeEstimateOnlyHours.FindStringSubmatch(timeStr) | |||
if len(timeStrMatches) > 0 { | |||
raw, _ := strconv.Atoi(timeStrMatches[1]) | |||
timeTotal += raw * (60 * 60) | |||
} else { | |||
// Find time weeks | |||
timeStrMatches = rTimeEstimateWeeks.FindStringSubmatch(timeStr) | |||
if len(timeStrMatches) > 0 { | |||
raw, _ := strconv.Atoi(timeStrMatches[1]) | |||
timeTotal += raw * (60 * 60 * 24 * 7) | |||
} | |||
// Find time days | |||
timeStrMatches = rTimeEstimateDays.FindStringSubmatch(timeStr) | |||
if len(timeStrMatches) > 0 { | |||
raw, _ := strconv.Atoi(timeStrMatches[1]) | |||
timeTotal += raw * (60 * 60 * 24) | |||
} | |||
// Find time hours | |||
timeStrMatches = rTimeEstimateHours.FindStringSubmatch(timeStr) | |||
if len(timeStrMatches) > 0 { | |||
raw, _ := strconv.Atoi(timeStrMatches[1]) | |||
timeTotal += raw * (60 * 60) | |||
} | |||
// Find time minutes | |||
timeStrMatches = rTimeEstimateMinutes.FindStringSubmatch(timeStr) | |||
if len(timeStrMatches) > 0 { | |||
raw, _ := strconv.Atoi(timeStrMatches[1]) | |||
timeTotal += raw * (60) | |||
} | |||
} | |||
return int64(timeTotal) | |||
} | |||
// TimeEstimateStr returns formatted time estimate string from seconds (e.g. "2w 4d 12h 5m") | |||
func TimeEstimateToStr(amount int64) string { | |||
var timeParts []string | |||
timeSeconds := float64(amount) | |||
// Format weeks | |||
weeks := math.Floor(timeSeconds / (60 * 60 * 24 * 7)) | |||
if weeks > 0 { | |||
timeParts = append(timeParts, fmt.Sprintf("%dw", int64(weeks))) | |||
} | |||
timeSeconds -= weeks * (60 * 60 * 24 * 7) | |||
// Format days | |||
days := math.Floor(timeSeconds / (60 * 60 * 24)) | |||
if days > 0 { | |||
timeParts = append(timeParts, fmt.Sprintf("%dd", int64(days))) | |||
} | |||
timeSeconds -= days * (60 * 60 * 24) | |||
// Format hours | |||
hours := math.Floor(timeSeconds / (60 * 60)) | |||
if hours > 0 { | |||
timeParts = append(timeParts, fmt.Sprintf("%dh", int64(hours))) | |||
} | |||
timeSeconds -= hours * (60 * 60) | |||
// Format minutes | |||
minutes := math.Floor(timeSeconds / (60)) | |||
if minutes > 0 { | |||
timeParts = append(timeParts, fmt.Sprintf("%dm", int64(minutes))) | |||
} | |||
return strings.Join(timeParts, " ") | |||
} |
@@ -1480,6 +1480,11 @@ issues.add_assignee_at = `was assigned by <b>%s</b> %s` | |||
issues.remove_assignee_at = `was unassigned by <b>%s</b> %s` | |||
issues.remove_self_assignment = `removed their assignment %s` | |||
issues.change_title_at = `changed title from <b><strike>%s</strike></b> to <b>%s</b> %s` | |||
issues.time_estimate = `Time Estimate` | |||
issues.add_time_estimate = `3w 4d 12h` | |||
issues.change_time_estimate_at = `changed time estimate to <b>%s</b> %s` | |||
issues.remove_time_estimate = `removed time estimate %s` | |||
issues.time_estimate_invalid = `Time estimate format is invalid` | |||
issues.change_ref_at = `changed reference from <b><strike>%s</strike></b> to <b>%s</b> %s` | |||
issues.remove_ref_at = `removed reference <b>%s</b> %s` | |||
issues.add_ref_at = `added reference <b>%s</b> %s` | |||
@@ -1650,20 +1655,20 @@ 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.stop_tracking_history = `worked for <b>%s</b> %s` | |||
issues.cancel_tracking = Discard | |||
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.del_time_history= `deleted spent time %s` | |||
issues.add_time_history = `added spent time <b>%s</b> %s` | |||
issues.del_time_history= `deleted spent time <b>%s</b> %s` | |||
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.time_spent_from_all_authors = `Total Time Spent:` | |||
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." |
@@ -13,6 +13,7 @@ import ( | |||
"math/big" | |||
"net/http" | |||
"net/url" | |||
"regexp" | |||
"slices" | |||
"sort" | |||
"strconv" | |||
@@ -1771,6 +1772,9 @@ func ViewIssue(ctx *context.Context) { | |||
comment.Content = comment.Content[1:] | |||
} | |||
} | |||
} else if comment.Type == issues_model.CommentTypeChangeTimeEstimate { | |||
timeSec, _ := util.ToInt64(comment.Content) | |||
comment.Content = util.SecToTimeExact(timeSec, timeSec < 60) | |||
} | |||
if comment.Type == issues_model.CommentTypeClose || comment.Type == issues_model.CommentTypeMergePull { | |||
@@ -2208,6 +2212,57 @@ func UpdateIssueTitle(ctx *context.Context) { | |||
}) | |||
} | |||
// UpdateIssueTimeEstimate change issue's planned time | |||
var ( | |||
rTimeEstimateStr = regexp.MustCompile(`^([\d]+w)?\s?([\d]+d)?\s?([\d]+h)?\s?([\d]+m)?$`) | |||
rTimeEstimateStrHoursOnly = regexp.MustCompile(`^([\d]+)$`) | |||
) | |||
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 | |||
} | |||
url := issue.Link() | |||
timeStr := ctx.FormString("time_estimate") | |||
// Validate input | |||
if !rTimeEstimateStr.MatchString(timeStr) && !rTimeEstimateStrHoursOnly.MatchString(timeStr) { | |||
ctx.Flash.Error(ctx.Tr("repo.issues.time_estimate_invalid")) | |||
ctx.Redirect(url, http.StatusSeeOther) | |||
return | |||
} | |||
total := util.TimeEstimateFromStr(timeStr) | |||
// User entered something wrong | |||
if total == 0 && len(timeStr) != 0 { | |||
ctx.Flash.Error(ctx.Tr("repo.issues.time_estimate_invalid")) | |||
ctx.Redirect(url, http.StatusSeeOther) | |||
return | |||
} | |||
// No time changed | |||
if issue.TimeEstimate == total { | |||
ctx.Redirect(url, http.StatusSeeOther) | |||
return | |||
} | |||
if err := issue_service.ChangeTimeEstimate(issue, ctx.Doer, total); err != nil { | |||
ctx.ServerError("ChangeTimeEstimate", err) | |||
return | |||
} | |||
ctx.Redirect(url, http.StatusSeeOther) | |||
} | |||
// UpdateIssueRef change issue's ref (branch) | |||
func UpdateIssueRef(ctx *context.Context) { | |||
issue := GetActionIssue(ctx) |
@@ -34,7 +34,7 @@ func AddTimeManually(c *context.Context) { | |||
return | |||
} | |||
total := time.Duration(form.Hours)*time.Hour + time.Duration(form.Minutes)*time.Minute | |||
total := util.TimeEstimateFromStr(form.TimeString) | |||
if total <= 0 { | |||
c.Flash.Error(c.Tr("repo.issues.add_time_sum_to_small")) | |||
@@ -42,7 +42,7 @@ func AddTimeManually(c *context.Context) { | |||
return | |||
} | |||
if _, err := issues_model.AddTime(c, c.Doer, issue, int64(total.Seconds()), time.Now()); err != nil { | |||
if _, err := issues_model.AddTime(c, c.Doer, issue, total, time.Now()); err != nil { | |||
c.ServerError("AddTime", err) | |||
return | |||
} |
@@ -1202,6 +1202,7 @@ func registerRoutes(m *web.Route) { | |||
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 "|" delimeter 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.SecToTimeExact(timeSec, timeSec < 60) | |||
} | |||
} | |||
comment := &api.TimelineComment{ |
@@ -877,8 +877,7 @@ func (f *DeleteRepoFileForm) Validate(req *http.Request, errs binding.Errors) bi | |||
// AddTimeManuallyForm form that adds spent time manually. | |||
type AddTimeManuallyForm struct { | |||
Hours int `binding:"Range(0,1000)"` | |||
Minutes int `binding:"Range(0,1000)"` | |||
TimeString string | |||
} | |||
// Validate validates the fields |
@@ -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, |
@@ -9,8 +9,6 @@ import ( | |||
"fmt" | |||
"html" | |||
"net/url" | |||
"regexp" | |||
"strconv" | |||
"strings" | |||
"time" | |||
@@ -23,64 +21,11 @@ import ( | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/references" | |||
"code.gitea.io/gitea/modules/repository" | |||
"code.gitea.io/gitea/modules/util" | |||
) | |||
const ( | |||
secondsByMinute = float64(time.Minute / time.Second) // seconds in a minute | |||
secondsByHour = 60 * secondsByMinute // seconds in an hour | |||
secondsByDay = 8 * secondsByHour // seconds in a day | |||
secondsByWeek = 5 * secondsByDay // seconds in a week | |||
secondsByMonth = 4 * secondsByWeek // seconds in a month | |||
) | |||
var reDuration = regexp.MustCompile(`(?i)^(?:(\d+([\.,]\d+)?)(?:mo))?(?:(\d+([\.,]\d+)?)(?:w))?(?:(\d+([\.,]\d+)?)(?:d))?(?:(\d+([\.,]\d+)?)(?:h))?(?:(\d+([\.,]\d+)?)(?:m))?$`) | |||
// timeLogToAmount parses time log string and returns amount in seconds | |||
func timeLogToAmount(str string) int64 { | |||
matches := reDuration.FindAllStringSubmatch(str, -1) | |||
if len(matches) == 0 { | |||
return 0 | |||
} | |||
match := matches[0] | |||
var a int64 | |||
// months | |||
if len(match[1]) > 0 { | |||
mo, _ := strconv.ParseFloat(strings.Replace(match[1], ",", ".", 1), 64) | |||
a += int64(mo * secondsByMonth) | |||
} | |||
// weeks | |||
if len(match[3]) > 0 { | |||
w, _ := strconv.ParseFloat(strings.Replace(match[3], ",", ".", 1), 64) | |||
a += int64(w * secondsByWeek) | |||
} | |||
// days | |||
if len(match[5]) > 0 { | |||
d, _ := strconv.ParseFloat(strings.Replace(match[5], ",", ".", 1), 64) | |||
a += int64(d * secondsByDay) | |||
} | |||
// hours | |||
if len(match[7]) > 0 { | |||
h, _ := strconv.ParseFloat(strings.Replace(match[7], ",", ".", 1), 64) | |||
a += int64(h * secondsByHour) | |||
} | |||
// minutes | |||
if len(match[9]) > 0 { | |||
d, _ := strconv.ParseFloat(strings.Replace(match[9], ",", ".", 1), 64) | |||
a += int64(d * secondsByMinute) | |||
} | |||
return a | |||
} | |||
func issueAddTime(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, time time.Time, timeLog string) error { | |||
amount := timeLogToAmount(timeLog) | |||
amount := util.TimeEstimateFromStr(timeLog) | |||
if amount == 0 { | |||
return nil | |||
} |
@@ -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(issue *issues_model.Issue, doer *user_model.User, timeEstimate int64) (err error) { | |||
issue.TimeEstimate = timeEstimate | |||
return issues_model.ChangeIssueTimeEstimate(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 |
@@ -62,7 +62,7 @@ | |||
{{end -}} | |||
{{- range .ReviewComments}} | |||
<hr> | |||
{{$.locale.Tr "mail.issue.in_tree_path" .TreePath}} | |||
{{ctx.Locale.Tr "mail.issue.in_tree_path" .TreePath}} | |||
<div class="review"> | |||
<pre>{{.Patch}}</pre> | |||
<div>{{.RenderedContent}}</div> |
@@ -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}} | |||
@@ -249,18 +250,18 @@ | |||
{{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}} | |||
</span> | |||
{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} | |||
<div class="detail flex-text-block"> | |||
{{svg "octicon-clock"}} | |||
{{$timeStr := ""}} | |||
{{if .RenderedContent}} | |||
{{/* compatibility with time comments made before v1.21 */}} | |||
<span class="text grey muted-links">{{.RenderedContent}}</span> | |||
{{$timeStr = .RenderedContent}} | |||
{{else}} | |||
<span class="text grey muted-links">{{.Content|Sec2Time}}</span> | |||
{{$timeStr = .Content|Sec2Time}} | |||
{{end}} | |||
</div> | |||
{{ctx.Locale.Tr "repo.issues.stop_tracking_history" $timeStr $createdStr | SafeHTML}} | |||
</span> | |||
{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} | |||
</div> | |||
{{else if eq .Type 14}} | |||
<div class="timeline-item event" id="{{.HashTag}}"> | |||
@@ -268,18 +269,18 @@ | |||
{{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}} | |||
</span> | |||
{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} | |||
<div class="detail flex-text-block"> | |||
{{svg "octicon-clock"}} | |||
{{$timeStr := ""}} | |||
{{if .RenderedContent}} | |||
{{/* compatibility with time comments made before v1.21 */}} | |||
<span class="text grey muted-links">{{.RenderedContent}}</span> | |||
{{$timeStr = .RenderedContent}} | |||
{{else}} | |||
<span class="text grey muted-links">{{.Content|Sec2Time}}</span> | |||
{{$timeStr = .Content|Sec2Time}} | |||
{{end}} | |||
</div> | |||
{{ctx.Locale.Tr "repo.issues.add_time_history" $timeStr $createdStr | SafeHTML}} | |||
</span> | |||
{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} | |||
</div> | |||
{{else if eq .Type 15}} | |||
<div class="timeline-item event" id="{{.HashTag}}"> | |||
@@ -680,6 +681,15 @@ | |||
{{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}} | |||
{{ctx.Locale.Tr "repo.issues.change_time_estimate_at" .Content $createdStr | SafeHTML}} | |||
</span> | |||
</div> | |||
{{end}} | |||
{{end}} | |||
{{end}} |
@@ -278,8 +278,22 @@ | |||
{{end}} | |||
{{if .Repository.IsTimetrackerEnabled $.Context}} | |||
{{if and .CanUseTimetracker (not .Repository.IsArchived)}} | |||
<div class="divider"></div> | |||
<div class="ui timetrack"> | |||
<div class="ui divider"></div> | |||
<div> | |||
<span class="text"><strong>{{ctx.Locale.Tr "repo.issues.time_estimate"}}</strong></span> | |||
<form method="post" id="set_time_estimate_form" class="gt-mt-3" action="{{.Issue.Link}}/time_estimate"> | |||
{{$.CsrfTokenHtml}} | |||
<div class="ui input fluid"> | |||
<input name="time_estimate" placeholder='{{ctx.Locale.Tr "repo.issues.add_time_estimate"}}' value="{{TimeEstimateToStr .Issue.TimeEstimate}}" data-value="{{$.Issue.TimeEstimate}}" type="text" > | |||
</div> | |||
<button class="ui fluid button green tooltip tw-mt-1"> | |||
{{ctx.Locale.Tr "repo.issues.save"}} | |||
</button> | |||
</form> | |||
</div> | |||
<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"> | |||
@@ -311,9 +325,8 @@ | |||
<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"> | |||
{{$.CsrfTokenHtml}} | |||
<input placeholder='{{ctx.Locale.Tr "repo.issues.add_time_estimate"}}' type="text" name="time_string"> | |||
</form> | |||
</div> | |||
<div class="actions"> | |||
@@ -332,8 +345,9 @@ | |||
{{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> | |||
<div class="text"><strong>{{ctx.Locale.Tr "repo.issues.time_spent_from_all_authors" | SafeHTML}}</strong></div> | |||
<div>{{SecToTimeExact .Issue.TotalTrackedTime false}}</div> | |||
<div class="gt-mt-3"> | |||
{{range $user, $trackedtime := .WorkingUsers}} | |||
<div class="comment tw-mt-2"> | |||
<a class="avatar"> |
@@ -73,8 +73,7 @@ func testViewTimetrackingControls(t *testing.T, session *TestSession, user, repo | |||
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) | |||
} |