Prettify number of issues (#17760)

* Prettify number of issues

- Use the PrettyNumber function to add commas in large amount of issues.

* Use client-side formatting

* prettify on both server and client

* remove unused i18n entries

* handle more cases, support other int types in PrettyNumber

* specify locale to avoid issues with node default locale

* remove superfluos argument

* introduce template helper, octicon tweaks, js refactor

* Update modules/templates/helper.go

* Apply some suggestions.

* Add comment

* Update templates/user/dashboard/issues.tmpl

Co-authored-by: silverwind <me@silverwind.io>

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
Gusted 2022-06-12 14:08:23 +02:00 committed by GitHub
parent 0097fbc2ac
commit 796c4eca0b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 118 additions and 45 deletions

View File

@ -24,6 +24,7 @@ import (
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
) )
@ -143,9 +144,9 @@ func FileSize(s int64) string {
} }
// PrettyNumber produces a string form of the given number in base 10 with // PrettyNumber produces a string form of the given number in base 10 with
// commas after every three orders of magnitud // commas after every three orders of magnitude
func PrettyNumber(v int64) string { func PrettyNumber(i interface{}) string {
return humanize.Comma(v) return humanize.Comma(util.NumberIntoInt64(i))
} }
// Subtract deals with subtraction of all types of number. // Subtract deals with subtraction of all types of number.

View File

@ -117,6 +117,7 @@ func TestFileSize(t *testing.T) {
func TestPrettyNumber(t *testing.T) { func TestPrettyNumber(t *testing.T) {
assert.Equal(t, "23,342,432", PrettyNumber(23342432)) assert.Equal(t, "23,342,432", PrettyNumber(23342432))
assert.Equal(t, "23,342,432", PrettyNumber(int32(23342432)))
assert.Equal(t, "0", PrettyNumber(0)) assert.Equal(t, "0", PrettyNumber(0))
assert.Equal(t, "-100,000", PrettyNumber(-100000)) assert.Equal(t, "-100,000", PrettyNumber(-100000))
} }

View File

@ -107,6 +107,7 @@ func NewFuncMap() []template.FuncMap {
"RawTimeSince": timeutil.RawTimeSince, "RawTimeSince": timeutil.RawTimeSince,
"FileSize": base.FileSize, "FileSize": base.FileSize,
"PrettyNumber": base.PrettyNumber, "PrettyNumber": base.PrettyNumber,
"JsPrettyNumber": JsPrettyNumber,
"Subtract": base.Subtract, "Subtract": base.Subtract,
"EntryIcon": base.EntryIcon, "EntryIcon": base.EntryIcon,
"MigrationIcon": MigrationIcon, "MigrationIcon": MigrationIcon,
@ -1005,3 +1006,11 @@ func mirrorRemoteAddress(ctx context.Context, m *repo_model.Repository, remoteNa
return a return a
} }
// JsPrettyNumber renders a number using english decimal separators, e.g. 1,200 and subsequent
// JS will replace the number with locale-specific separators, based on the user's selected language
func JsPrettyNumber(i interface{}) template.HTML {
num := util.NumberIntoInt64(i)
return template.HTML(`<span class="js-pretty-number" data-value="` + strconv.FormatInt(num, 10) + `">` + base.PrettyNumber(num) + `</span>`)
}

View File

@ -224,3 +224,21 @@ func Dedent(s string) string {
} }
return strings.TrimSpace(s) return strings.TrimSpace(s)
} }
// NumberIntoInt64 transform a given int into int64.
func NumberIntoInt64(number interface{}) int64 {
var value int64
switch v := number.(type) {
case int:
value = int64(v)
case int8:
value = int64(v)
case int16:
value = int64(v)
case int32:
value = int64(v)
case int64:
value = v
}
return value
}

View File

@ -1259,8 +1259,6 @@ issues.change_ref_at = `changed reference from <b><strike>%s</strike></b> to <b>
issues.remove_ref_at = `removed reference <b>%s</b> %s` issues.remove_ref_at = `removed reference <b>%s</b> %s`
issues.add_ref_at = `added reference <b>%s</b> %s` issues.add_ref_at = `added reference <b>%s</b> %s`
issues.delete_branch_at = `deleted branch <b>%s</b> %s` issues.delete_branch_at = `deleted branch <b>%s</b> %s`
issues.open_tab = %d Open
issues.close_tab = %d Closed
issues.filter_label = Label issues.filter_label = Label
issues.filter_label_exclude = `Use <code>alt</code> + <code>click/enter</code> to exclude labels` issues.filter_label_exclude = `Use <code>alt</code> + <code>click/enter</code> to exclude labels`
issues.filter_label_no_select = All labels issues.filter_label_no_select = All labels
@ -1613,8 +1611,6 @@ pulls.auto_merge_newly_scheduled_comment = `scheduled this pull request to auto
pulls.auto_merge_canceled_schedule_comment = `canceled auto merging this pull request when all checks succeed %[1]s` pulls.auto_merge_canceled_schedule_comment = `canceled auto merging this pull request when all checks succeed %[1]s`
milestones.new = New Milestone milestones.new = New Milestone
milestones.open_tab = %d Open
milestones.close_tab = %d Closed
milestones.closed = Closed %s milestones.closed = Closed %s
milestones.update_ago = Updated %s ago milestones.update_ago = Updated %s ago
milestones.no_due_date = No due date milestones.no_due_date = No due date

View File

@ -18,11 +18,11 @@
<div class="ui compact tiny menu"> <div class="ui compact tiny menu">
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/milestones?state=open&q={{$.Keyword}}"> <a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/milestones?state=open&q={{$.Keyword}}">
{{svg "octicon-milestone" 16 "mr-3"}} {{svg "octicon-milestone" 16 "mr-3"}}
{{.i18n.Tr "repo.milestones.open_tab" .OpenCount}} {{JsPrettyNumber .OpenCount}}&nbsp;{{.i18n.Tr "repo.issues.open_title"}}
</a> </a>
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/milestones?state=closed&q={{$.Keyword}}"> <a class="item{{if .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/milestones?state=closed&q={{$.Keyword}}">
{{svg "octicon-milestone" 16 "mr-3"}} {{svg "octicon-check" 16 "mr-3"}}
{{.i18n.Tr "repo.milestones.close_tab" .ClosedCount}} {{JsPrettyNumber .ClosedCount}}&nbsp;{{.i18n.Tr "repo.issues.closed_title"}}
</a> </a>
</div> </div>
</div> </div>
@ -83,8 +83,10 @@
{{end}} {{end}}
{{end}} {{end}}
<span class="issue-stats"> <span class="issue-stats">
{{svg "octicon-issue-opened"}} {{$.i18n.Tr "repo.issues.open_tab" .NumOpenIssues}} {{svg "octicon-issue-opened" 16 "mr-3"}}
{{svg "octicon-issue-closed"}} {{$.i18n.Tr "repo.issues.close_tab" .NumClosedIssues}} {{JsPrettyNumber .NumOpenIssues}}&nbsp;{{$.i18n.Tr "repo.issues.open_title"}}
{{svg "octicon-check" 16 "mr-3"}}
{{JsPrettyNumber .NumClosedIssues}}&nbsp;{{$.i18n.Tr "repo.issues.closed_title"}}
{{if .TotalTrackedTime}}{{svg "octicon-clock"}} {{.TotalTrackedTime|Sec2Time}}{{end}} {{if .TotalTrackedTime}}{{svg "octicon-clock"}} {{.TotalTrackedTime|Sec2Time}}{{end}}
{{if .UpdatedUnix}}{{svg "octicon-clock"}} {{$.i18n.Tr "repo.milestones.update_ago" (.TimeSinceUpdate|Sec2Time)}}{{end}} {{if .UpdatedUnix}}{{svg "octicon-clock"}} {{$.i18n.Tr "repo.milestones.update_ago" (.TimeSinceUpdate|Sec2Time)}}{{end}}
</span> </span>

View File

@ -1,10 +1,14 @@
<div class="ui compact tiny menu"> <div class="ui compact tiny menu">
<a class="{{if not .IsShowClosed}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state=open&labels={{.SelectLabels}}&milestone={{.MilestoneID}}&assignee={{.AssigneeID}}"> <a class="{{if not .IsShowClosed}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state=open&labels={{.SelectLabels}}&milestone={{.MilestoneID}}&assignee={{.AssigneeID}}">
{{if .PageIsPullList}}
{{svg "octicon-git-pull-request" 16 "mr-3"}}
{{else}}
{{svg "octicon-issue-opened" 16 "mr-3"}} {{svg "octicon-issue-opened" 16 "mr-3"}}
{{.i18n.Tr "repo.issues.open_tab" .IssueStats.OpenCount}} {{end}}
{{JsPrettyNumber .IssueStats.OpenCount}}&nbsp;{{.i18n.Tr "repo.issues.open_title"}}
</a> </a>
<a class="{{if .IsShowClosed}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type={{.ViewType}}&sort={{$.SortType}}&state=closed&labels={{.SelectLabels}}&milestone={{.MilestoneID}}&assignee={{.AssigneeID}}"> <a class="{{if .IsShowClosed}}active{{end}} item" href="{{$.Link}}?q={{$.Keyword}}&type={{.ViewType}}&sort={{$.SortType}}&state=closed&labels={{.SelectLabels}}&milestone={{.MilestoneID}}&assignee={{.AssigneeID}}">
{{svg "octicon-issue-closed" 16 "mr-3"}} {{svg "octicon-check" 16 "mr-3"}}
{{.i18n.Tr "repo.issues.close_tab" .IssueStats.ClosedCount}} {{JsPrettyNumber .IssueStats.ClosedCount}}&nbsp;{{.i18n.Tr "repo.issues.closed_title"}}
</a> </a>
</div> </div>

View File

@ -14,12 +14,12 @@
{{template "base/alert" .}} {{template "base/alert" .}}
<div class="ui compact tiny menu"> <div class="ui compact tiny menu">
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/projects?state=open"> <a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/projects?state=open">
{{svg "octicon-project" 16 "mr-2"}} {{svg "octicon-project" 16 "mr-3"}}
{{.i18n.Tr "repo.issues.open_tab" .OpenCount}} {{JsPrettyNumber .OpenCount}}&nbsp;{{.i18n.Tr "repo.issues.open_title"}}
</a> </a>
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/projects?state=closed"> <a class="item{{if .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/projects?state=closed">
{{svg "octicon-check" 16 "mr-2"}} {{svg "octicon-check" 16 "mr-3"}}
{{.i18n.Tr "repo.milestones.close_tab" .ClosedCount}} {{JsPrettyNumber .ClosedCount}}&nbsp;{{.i18n.Tr "repo.issues.closed_title"}}
</a> </a>
</div> </div>
@ -47,8 +47,10 @@
{{svg "octicon-clock"}} {{$.i18n.Tr "repo.milestones.closed" $closedDate|Str2html}} {{svg "octicon-clock"}} {{$.i18n.Tr "repo.milestones.closed" $closedDate|Str2html}}
{{end}} {{end}}
<span class="issue-stats"> <span class="issue-stats">
{{svg "octicon-issue-opened"}} {{$.i18n.Tr "repo.issues.open_tab" .NumOpenIssues}} {{svg "octicon-issue-opened" 16 "mr-3"}}
{{svg "octicon-issue-closed"}} {{$.i18n.Tr "repo.issues.close_tab" .NumClosedIssues}} {{JsPrettyNumber .NumOpenIssues}}&nbsp;{{$.i18n.Tr "repo.issues.open_title"}}
{{svg "octicon-check" 16 "mr-3"}}
{{JsPrettyNumber .NumClosedIssues}}&nbsp;{{$.i18n.Tr "repo.issues.closed_title"}}
</span> </span>
</div> </div>
{{if and (or $.CanWriteIssues $.CanWritePulls) (not $.Repository.IsArchived)}} {{if and (or $.CanWriteIssues $.CanWritePulls) (not $.Repository.IsArchived)}}

View File

@ -61,11 +61,11 @@
<div class="ui compact tiny menu"> <div class="ui compact tiny menu">
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open&q={{$.Keyword}}"> <a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open&q={{$.Keyword}}">
{{svg "octicon-issue-opened" 16 "mr-3"}} {{svg "octicon-issue-opened" 16 "mr-3"}}
{{.i18n.Tr "repo.issues.open_tab" .IssueStats.OpenCount}} {{JsPrettyNumber .ShownIssueStats.OpenCount}}&nbsp;{{.i18n.Tr "repo.issues.open_title"}}
</a> </a>
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed&q={{$.Keyword}}"> <a class="item{{if .IsShowClosed}} active{{end}}" href="{{.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed&q={{$.Keyword}}">
{{svg "octicon-issue-closed" 16 "mr-3"}} {{svg "octicon-issue-closed" 16 "mr-3"}}
{{.i18n.Tr "repo.issues.close_tab" .IssueStats.ClosedCount}} {{JsPrettyNumber .ShownIssueStats.ClosedCount}}&nbsp;{{.i18n.Tr "repo.issues.closed_title"}}
</a> </a>
</div> </div>
</div> </div>

View File

@ -38,12 +38,12 @@
<div class="column"> <div class="column">
<div class="ui compact tiny menu"> <div class="ui compact tiny menu">
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open&q={{$.Keyword}}"> <a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open&q={{$.Keyword}}">
{{svg "octicon-issue-opened" 16 "mr-3"}} {{svg "octicon-milestone" 16 "mr-3"}}
{{.i18n.Tr "repo.milestones.open_tab" .MilestoneStats.OpenCount}} {{JsPrettyNumber .MilestoneStats.OpenCount}}&nbsp;{{.i18n.Tr "repo.issues.open_title"}}
</a> </a>
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed&q={{$.Keyword}}"> <a class="item{{if .IsShowClosed}} active{{end}}" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed&q={{$.Keyword}}">
{{svg "octicon-issue-closed" 16 "mr-3"}} {{svg "octicon-check" 16 "mr-3"}}
{{.i18n.Tr "repo.milestones.close_tab" .MilestoneStats.ClosedCount}} {{JsPrettyNumber .MilestoneStats.ClosedCount}}&nbsp;{{.i18n.Tr "repo.issues.closed_title"}}
</a> </a>
</div> </div>
</div> </div>
@ -103,9 +103,13 @@
{{end}} {{end}}
{{end}} {{end}}
<span class="issue-stats"> <span class="issue-stats">
{{svg "octicon-issue-opened"}} {{$.i18n.Tr "repo.milestones.open_tab" .NumOpenIssues}} {{svg "octicon-issue-opened" 16 "mr-3"}}
{{svg "octicon-issue-closed"}} {{$.i18n.Tr "repo.milestones.close_tab" .NumClosedIssues}} {{JsPrettyNumber .NumOpenIssues}}&nbsp;{{$.i18n.Tr "repo.issues.open_title"}}
{{if .TotalTrackedTime}}{{svg "octicon-clock"}} {{.TotalTrackedTime|Sec2Time}}{{end}} {{svg "octicon-check" 16 "mr-3"}}
{{JsPrettyNumber .NumClosedIssues}}&nbsp;{{$.i18n.Tr "repo.issues.closed_title"}}
{{if .TotalTrackedTime}}
{{svg "octicon-clock"}} {{.TotalTrackedTime|Sec2Time}}
{{end}}
</span> </span>
</div> </div>
{{if and (or $.CanWriteIssues $.CanWritePulls) (not $.Repository.IsArchived)}} {{if and (or $.CanWriteIssues $.CanWritePulls) (not $.Repository.IsArchived)}}

View File

@ -0,0 +1,14 @@
import {prettyNumber} from '../utils.js';
const {lang} = document.documentElement;
export function initFormattingReplacements() {
// replace english formatted numbers with locale-specific separators
for (const el of document.getElementsByClassName('js-pretty-number')) {
const num = Number(el.getAttribute('data-value'));
const formatted = prettyNumber(num, lang);
if (formatted && formatted !== el.textContent) {
el.textContent = formatted;
}
}
}

View File

@ -84,6 +84,11 @@ import {initRepoBranchButton} from './features/repo-branch.js';
import {initCommonOrganization} from './features/common-organization.js'; import {initCommonOrganization} from './features/common-organization.js';
import {initRepoWikiForm} from './features/repo-wiki.js'; import {initRepoWikiForm} from './features/repo-wiki.js';
import {initRepoCommentForm, initRepository} from './features/repo-legacy.js'; import {initRepoCommentForm, initRepository} from './features/repo-legacy.js';
import {initFormattingReplacements} from './features/formatting.js';
// Run time-critical code as soon as possible. This is safe to do because this
// script appears at the end of <body> and rendered HTML is accessible at that point.
initFormattingReplacements();
// Silence fomantic's error logging when tabs are used without a target content element // Silence fomantic's error logging when tabs are used without a target content element
$.fn.tab.settings.silent = true; $.fn.tab.settings.silent = true;

View File

@ -90,3 +90,10 @@ export function strSubMatch(full, sub) {
} }
return res; return res;
} }
// pretty-print a number using locale-specific separators, e.g. 1200 -> 1,200
export function prettyNumber(num, locale = 'en-US') {
if (typeof num !== 'number') return '';
const {format} = new Intl.NumberFormat(locale);
return format(num);
}

View File

@ -1,5 +1,5 @@
import { import {
basename, extname, isObject, uniq, stripTags, joinPaths, parseIssueHref, strSubMatch, basename, extname, isObject, uniq, stripTags, joinPaths, parseIssueHref, strSubMatch, prettyNumber,
} from './utils.js'; } from './utils.js';
test('basename', () => { test('basename', () => {
@ -85,7 +85,6 @@ test('parseIssueHref', () => {
expect(parseIssueHref('')).toEqual({owner: undefined, repo: undefined, type: undefined, index: undefined}); expect(parseIssueHref('')).toEqual({owner: undefined, repo: undefined, type: undefined, index: undefined});
}); });
test('strSubMatch', () => { test('strSubMatch', () => {
expect(strSubMatch('abc', '')).toEqual(['abc']); expect(strSubMatch('abc', '')).toEqual(['abc']);
expect(strSubMatch('abc', 'a')).toEqual(['', 'a', 'bc']); expect(strSubMatch('abc', 'a')).toEqual(['', 'a', 'bc']);
@ -98,3 +97,14 @@ test('strSubMatch', () => {
expect(strSubMatch('aabbcc', 'abc')).toEqual(['', 'a', 'a', 'b', 'b', 'c', 'c']); expect(strSubMatch('aabbcc', 'abc')).toEqual(['', 'a', 'a', 'b', 'b', 'c', 'c']);
expect(strSubMatch('the/directory', 'hedir')).toEqual(['t', 'he', '/', 'dir', 'ectory']); expect(strSubMatch('the/directory', 'hedir')).toEqual(['t', 'he', '/', 'dir', 'ectory']);
}); });
test('prettyNumber', () => {
expect(prettyNumber()).toEqual('');
expect(prettyNumber(null)).toEqual('');
expect(prettyNumber(undefined)).toEqual('');
expect(prettyNumber('1200')).toEqual('');
expect(prettyNumber(12345678, 'en-US')).toEqual('12,345,678');
expect(prettyNumber(12345678, 'de-DE')).toEqual('12.345.678');
expect(prettyNumber(12345678, 'be-BE')).toEqual('12 345 678');
expect(prettyNumber(12345678, 'hi-IN')).toEqual('1,23,45,678');
});