mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-11-24 06:06:24 +00:00
feat(ui): add more emoji and code block rendering in issues
This commit is contained in:
parent
60bcdc8bc3
commit
4a74113dee
|
@ -73,6 +73,8 @@ var (
|
|||
|
||||
// EmojiShortCodeRegex find emoji by alias like :smile:
|
||||
EmojiShortCodeRegex = regexp.MustCompile(`:[-+\w]+:`)
|
||||
|
||||
InlineCodeBlockRegex = regexp.MustCompile("`[^`]+`")
|
||||
)
|
||||
|
||||
// CSS class for action keywords (e.g. "closes: #1")
|
||||
|
@ -243,6 +245,7 @@ func RenderIssueTitle(
|
|||
title string,
|
||||
) (string, error) {
|
||||
return renderProcessString(ctx, []processor{
|
||||
inlineCodeBlockProcessor,
|
||||
issueIndexPatternProcessor,
|
||||
commitCrossReferencePatternProcessor,
|
||||
hashCurrentPatternProcessor,
|
||||
|
@ -251,6 +254,19 @@ func RenderIssueTitle(
|
|||
}, title)
|
||||
}
|
||||
|
||||
// RenderRefIssueTitle to process title on places where an issue is referenced
|
||||
func RenderRefIssueTitle(
|
||||
ctx *RenderContext,
|
||||
title string,
|
||||
) (string, error) {
|
||||
return renderProcessString(ctx, []processor{
|
||||
inlineCodeBlockProcessor,
|
||||
issueIndexPatternProcessor,
|
||||
emojiShortCodeProcessor,
|
||||
emojiProcessor,
|
||||
}, title)
|
||||
}
|
||||
|
||||
func renderProcessString(ctx *RenderContext, procs []processor, content string) (string, error) {
|
||||
var buf strings.Builder
|
||||
if err := postProcess(ctx, procs, strings.NewReader(content), &buf); err != nil {
|
||||
|
@ -438,6 +454,24 @@ func createKeyword(content string) *html.Node {
|
|||
return span
|
||||
}
|
||||
|
||||
func createInlineCode(content string) *html.Node {
|
||||
code := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: atom.Code.String(),
|
||||
Attr: []html.Attribute{},
|
||||
}
|
||||
|
||||
code.Attr = append(code.Attr, html.Attribute{Key: "class", Val: "inline-code-block"})
|
||||
|
||||
text := &html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: content,
|
||||
}
|
||||
|
||||
code.AppendChild(text)
|
||||
return code
|
||||
}
|
||||
|
||||
func createEmoji(content, class, name string) *html.Node {
|
||||
span := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
|
@ -1070,6 +1104,21 @@ func filePreviewPatternProcessor(ctx *RenderContext, node *html.Node) {
|
|||
}
|
||||
}
|
||||
|
||||
func inlineCodeBlockProcessor(ctx *RenderContext, node *html.Node) {
|
||||
start := 0
|
||||
next := node.NextSibling
|
||||
for node != nil && node != next && start < len(node.Data) {
|
||||
m := InlineCodeBlockRegex.FindStringSubmatchIndex(node.Data[start:])
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
|
||||
code := node.Data[m[0]+1 : m[1]-1]
|
||||
replaceContent(node, m[0], m[1], createInlineCode(code))
|
||||
node = node.NextSibling.NextSibling
|
||||
}
|
||||
}
|
||||
|
||||
// emojiShortCodeProcessor for rendering text like :smile: into emoji
|
||||
func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
|
||||
start := 0
|
||||
|
|
|
@ -172,11 +172,12 @@ func NewFuncMap() template.FuncMap {
|
|||
"RenderCommitMessage": RenderCommitMessage,
|
||||
"RenderCommitMessageLinkSubject": RenderCommitMessageLinkSubject,
|
||||
|
||||
"RenderCommitBody": RenderCommitBody,
|
||||
"RenderCodeBlock": RenderCodeBlock,
|
||||
"RenderIssueTitle": RenderIssueTitle,
|
||||
"RenderEmoji": RenderEmoji,
|
||||
"ReactionToEmoji": ReactionToEmoji,
|
||||
"RenderCommitBody": RenderCommitBody,
|
||||
"RenderCodeBlock": RenderCodeBlock,
|
||||
"RenderIssueTitle": RenderIssueTitle,
|
||||
"RenderRefIssueTitle": RenderRefIssueTitle,
|
||||
"RenderEmoji": RenderEmoji,
|
||||
"ReactionToEmoji": ReactionToEmoji,
|
||||
|
||||
"RenderMarkdownToHtml": RenderMarkdownToHtml,
|
||||
"RenderLabel": RenderLabel,
|
||||
|
|
|
@ -130,6 +130,17 @@ func RenderIssueTitle(ctx context.Context, text string, metas map[string]string)
|
|||
return template.HTML(renderedText)
|
||||
}
|
||||
|
||||
// RenderRefIssueTitle renders referenced issue/pull title with defined post processors
|
||||
func RenderRefIssueTitle(ctx context.Context, text string) template.HTML {
|
||||
renderedText, err := markup.RenderRefIssueTitle(&markup.RenderContext{Ctx: ctx}, template.HTMLEscapeString(text))
|
||||
if err != nil {
|
||||
log.Error("RenderRefIssueTitle: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return template.HTML(renderedText)
|
||||
}
|
||||
|
||||
// RenderLabel renders a label
|
||||
// locale is needed due to an import cycle with our context providing the `Tr` function
|
||||
func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML {
|
||||
|
|
|
@ -35,8 +35,8 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
|||
mail@domain.com
|
||||
@mention-user test
|
||||
#123
|
||||
space
|
||||
`
|
||||
space
|
||||
` + "`code :+1: #123 code`\n"
|
||||
|
||||
var testMetas = map[string]string{
|
||||
"user": "user13",
|
||||
|
@ -115,8 +115,8 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
|||
<a href="mailto:mail@domain.com" class="mailto">mail@domain.com</a>
|
||||
<a href="/mention-user" class="mention">@mention-user</a> test
|
||||
<a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
|
||||
space`
|
||||
|
||||
space
|
||||
` + "`code <span class=\"emoji\" aria-label=\"thumbs up\">👍</span> <a href=\"/user13/repo11/issues/123\" class=\"ref-issue\">#123</a> code`"
|
||||
assert.EqualValues(t, expected, RenderCommitBody(context.Background(), testInput, testMetas))
|
||||
}
|
||||
|
||||
|
@ -152,11 +152,38 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
|||
mail@domain.com
|
||||
@mention-user test
|
||||
<a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
|
||||
space
|
||||
space
|
||||
<code class="inline-code-block">code :+1: #123 code</code>
|
||||
`
|
||||
assert.EqualValues(t, expected, RenderIssueTitle(context.Background(), testInput, testMetas))
|
||||
}
|
||||
|
||||
func TestRenderRefIssueTitle(t *testing.T) {
|
||||
expected := ` space @mention-user
|
||||
/just/a/path.bin
|
||||
https://example.com/file.bin
|
||||
[local link](file.bin)
|
||||
[remote link](https://example.com)
|
||||
[[local link|file.bin]]
|
||||
[[remote link|https://example.com]]
|
||||
![local image](image.jpg)
|
||||
![remote image](https://example.com/image.jpg)
|
||||
[[local image|image.jpg]]
|
||||
[[remote link|https://example.com/image.jpg]]
|
||||
https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash
|
||||
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
|
||||
https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb
|
||||
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
||||
<span class="emoji" aria-label="thumbs up">👍</span>
|
||||
mail@domain.com
|
||||
@mention-user test
|
||||
#123
|
||||
space
|
||||
<code class="inline-code-block">code :+1: #123 code</code>
|
||||
`
|
||||
assert.EqualValues(t, expected, RenderRefIssueTitle(context.Background(), testInput))
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToHtml(t *testing.T) {
|
||||
expected := `<p>space <a href="/mention-user" rel="nofollow">@mention-user</a><br/>
|
||||
/just/a/path.bin
|
||||
|
@ -177,7 +204,8 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
|||
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a>
|
||||
<a href="/mention-user" rel="nofollow">@mention-user</a> test
|
||||
#123
|
||||
space</p>
|
||||
space
|
||||
<code>code :+1: #123 code</code></p>
|
||||
`
|
||||
assert.EqualValues(t, expected, RenderMarkdownToHtml(context.Background(), testInput))
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
<div class="issue-card-icon">
|
||||
{{template "shared/issueicon" .}}
|
||||
</div>
|
||||
<a class="issue-card-title muted issue-title tw-break-anywhere" href="{{.Link}}">{{.Title | RenderEmoji ctx | RenderCodeBlock}}</a>
|
||||
<a class="issue-card-title muted issue-title tw-break-anywhere" href="{{.Link}}">{{RenderRefIssueTitle $.Context .Title}}</a>
|
||||
{{if and $.isPinnedIssueCard $.Page.IsRepoAdmin}}
|
||||
<a role="button" class="issue-card-unpin muted tw-flex tw-items-center" data-tooltip-content={{ctx.Locale.Tr "repo.issues.unpin_issue"}} data-issue-id="{{.ID}}" data-unpin-url="{{$.Page.Link}}/unpin/{{.Index}}">
|
||||
{{svg "octicon-x" 16}}
|
||||
|
|
|
@ -149,7 +149,7 @@
|
|||
{{if eq .RefAction 3}}</del>{{end}}
|
||||
|
||||
<div class="detail flex-text-block">
|
||||
<span class="text grey muted-links"><a href="{{.RefIssueLink ctx}}"><b>{{.RefIssueTitle ctx}}</b> {{.RefIssueIdent ctx}}</a></span>
|
||||
<span class="text grey muted-links"><a href="{{.RefIssueLink ctx}}"><b>{{.RefIssueTitle ctx | RenderEmoji $.Context | RenderCodeBlock}}</b> {{.RefIssueIdent ctx}}</a></span>
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .Type 4}}
|
||||
|
@ -226,7 +226,7 @@
|
|||
{{template "shared/user/avatarlink" dict "user" .Poster}}
|
||||
<span class="text grey muted-links">
|
||||
{{template "shared/user/authorlink" .Poster}}
|
||||
{{ctx.Locale.Tr "repo.issues.change_title_at" (.OldTitle|RenderEmoji $.Context) (.NewTitle|RenderEmoji $.Context) $createdStr}}
|
||||
{{ctx.Locale.Tr "repo.issues.change_title_at" (RenderRefIssueTitle $.Context .OldTitle) (RenderRefIssueTitle $.Context .NewTitle) $createdStr}}
|
||||
</span>
|
||||
</div>
|
||||
{{else if eq .Type 11}}
|
||||
|
@ -339,10 +339,11 @@
|
|||
{{svg "octicon-plus"}}
|
||||
<span class="text grey muted-links">
|
||||
<a href="{{.DependentIssue.Link}}">
|
||||
{{$strTitle := RenderRefIssueTitle $.Context .DependentIssue.Title}}
|
||||
{{if eq .DependentIssue.RepoID .Issue.RepoID}}
|
||||
#{{.DependentIssue.Index}} {{.DependentIssue.Title}}
|
||||
#{{.DependentIssue.Index}} {{$strTitle}}
|
||||
{{else}}
|
||||
{{.DependentIssue.Repo.FullName}}#{{.DependentIssue.Index}} - {{.DependentIssue.Title}}
|
||||
{{.DependentIssue.Repo.FullName}}#{{.DependentIssue.Index}} - {{$strTitle}}
|
||||
{{end}}
|
||||
</a>
|
||||
</span>
|
||||
|
@ -362,10 +363,11 @@
|
|||
{{svg "octicon-trash"}}
|
||||
<span class="text grey muted-links">
|
||||
<a href="{{.DependentIssue.Link}}">
|
||||
{{$strTitle := RenderRefIssueTitle $.Context .DependentIssue.Title}}
|
||||
{{if eq .DependentIssue.RepoID .Issue.RepoID}}
|
||||
#{{.DependentIssue.Index}} {{.DependentIssue.Title}}
|
||||
#{{.DependentIssue.Index}} {{$strTitle}}
|
||||
{{else}}
|
||||
{{.DependentIssue.Repo.FullName}}#{{.DependentIssue.Index}} - {{.DependentIssue.Title}}
|
||||
{{.DependentIssue.Repo.FullName}}#{{.DependentIssue.Index}} - {{$strTitle}}
|
||||
{{end}}
|
||||
</a>
|
||||
</span>
|
||||
|
|
|
@ -19,8 +19,8 @@
|
|||
{{range .BlockingDependencies}}
|
||||
<div class="item dependency{{if .Issue.IsClosed}} is-closed{{end}} tw-flex tw-items-center tw-justify-between">
|
||||
<div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis">
|
||||
<a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}">
|
||||
#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}
|
||||
<a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}}}">
|
||||
#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}}}
|
||||
</a>
|
||||
<div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}">
|
||||
{{.Repository.OwnerName}}/{{.Repository.Name}}
|
||||
|
@ -51,8 +51,9 @@
|
|||
{{range .BlockedByDependencies}}
|
||||
<div class="item dependency{{if .Issue.IsClosed}} is-closed{{end}} tw-flex tw-items-center tw-justify-between">
|
||||
<div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis">
|
||||
<a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}">
|
||||
#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}
|
||||
{{$title := RenderRefIssueTitle $.Context .Issue.Title}}
|
||||
<a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}}">
|
||||
#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}}
|
||||
</a>
|
||||
<div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}">
|
||||
{{.Repository.OwnerName}}/{{.Repository.Name}}
|
||||
|
@ -73,8 +74,8 @@
|
|||
<div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis">
|
||||
<div class="gt-ellipsis">
|
||||
<span data-tooltip-content="{{ctx.Locale.Tr "repo.issues.dependency.no_permission.can_remove"}}">{{svg "octicon-lock" 16}}</span>
|
||||
<span class="title" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}">
|
||||
#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}
|
||||
<span class="title" data-tooltip-content="#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .DependentIssue.Title}}">
|
||||
#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .DependentIssue.Title}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}">
|
||||
|
|
|
@ -7,8 +7,7 @@
|
|||
{{$canEditIssueTitle := and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .Repository.IsArchived)}}
|
||||
<div class="issue-title" id="issue-title-display">
|
||||
<h1 class="tw-break-anywhere">
|
||||
{{RenderIssueTitle $.Context .Issue.Title ($.Repository.ComposeMetas ctx) | RenderCodeBlock}}
|
||||
<span class="index">#{{.Issue.Index}}</span>
|
||||
{{RenderIssueTitle $.Context .Issue.Title ($.Repository.ComposeMetas ctx)}} <span class="index">#{{.Issue.Index}}</span>
|
||||
</h1>
|
||||
<div class="button-row">
|
||||
{{if $canEditIssueTitle}}
|
||||
|
|
|
@ -153,7 +153,7 @@
|
|||
{{range .Activity.MergedPRs}}
|
||||
<p class="desc">
|
||||
<span class="ui purple label">{{ctx.Locale.Tr "repo.activity.merged_prs_label"}}</span>
|
||||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{RenderRefIssueTitle $.Context .Issue.Title}}</a>
|
||||
{{TimeSinceUnix .MergedUnix ctx.Locale}}
|
||||
</p>
|
||||
{{end}}
|
||||
|
@ -172,7 +172,7 @@
|
|||
{{range .Activity.OpenedPRs}}
|
||||
<p class="desc">
|
||||
<span class="ui green label">{{ctx.Locale.Tr "repo.activity.opened_prs_label"}}</span>
|
||||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{RenderRefIssueTitle $.Context .Issue.Title}}</a>
|
||||
{{TimeSinceUnix .Issue.CreatedUnix ctx.Locale}}
|
||||
</p>
|
||||
{{end}}
|
||||
|
@ -191,7 +191,7 @@
|
|||
{{range .Activity.ClosedIssues}}
|
||||
<p class="desc">
|
||||
<span class="ui red label">{{ctx.Locale.Tr "repo.activity.closed_issue_label"}}</span>
|
||||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{RenderRefIssueTitle $.Context .Title}}</a>
|
||||
{{TimeSinceUnix .ClosedUnix ctx.Locale}}
|
||||
</p>
|
||||
{{end}}
|
||||
|
@ -210,7 +210,7 @@
|
|||
{{range .Activity.OpenedIssues}}
|
||||
<p class="desc">
|
||||
<span class="ui green label">{{ctx.Locale.Tr "repo.activity.new_issue_label"}}</span>
|
||||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{RenderRefIssueTitle $.Context .Title}}</a>
|
||||
{{TimeSinceUnix .CreatedUnix ctx.Locale}}
|
||||
</p>
|
||||
{{end}}
|
||||
|
@ -228,9 +228,9 @@
|
|||
<span class="ui green label">{{ctx.Locale.Tr "repo.activity.unresolved_conv_label"}}</span>
|
||||
#{{.Index}}
|
||||
{{if .IsPull}}
|
||||
<a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
<a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{RenderRefIssueTitle $.Context .Title}}</a>
|
||||
{{else}}
|
||||
<a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
<a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{RenderRefIssueTitle $.Context .Title}}</a>
|
||||
{{end}}
|
||||
{{TimeSinceUnix .UpdatedUnix ctx.Locale}}
|
||||
</p>
|
||||
|
|
|
@ -58,7 +58,7 @@
|
|||
<div class="notifications-bottom-row tw-text-16 tw-py-0.5">
|
||||
<span class="issue-title tw-break-anywhere">
|
||||
{{if .Issue}}
|
||||
{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}
|
||||
{{RenderRefIssueTitle $.Context .Issue.Title}}
|
||||
{{else}}
|
||||
{{.Repository.FullName}}
|
||||
{{end}}
|
||||
|
|
162
tests/integration/repo_issue_title_test.go
Normal file
162
tests/integration/repo_issue_title_test.go
Normal file
|
@ -0,0 +1,162 @@
|
|||
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
issue_service "code.gitea.io/gitea/services/issue"
|
||||
pull_service "code.gitea.io/gitea/services/pull"
|
||||
files_service "code.gitea.io/gitea/services/repository/files"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueTitles(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
repo, _, f := tests.CreateDeclarativeRepo(t, user, "issue-titles", nil, nil, nil)
|
||||
defer f()
|
||||
|
||||
session := loginUser(t, user.LoginName)
|
||||
|
||||
title := "Title :+1: `code`"
|
||||
issue1 := createIssue(t, user, repo, title, "Test issue")
|
||||
issue2 := createIssue(t, user, repo, title, "Ref #1")
|
||||
|
||||
titleHTML := []string{
|
||||
"Title",
|
||||
`<span class="emoji" aria-label="thumbs up">👍</span>`,
|
||||
`<code class="inline-code-block">code</code>`,
|
||||
}
|
||||
|
||||
t.Run("Main issue title", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
html := extractHTML(t, session, issue1, "div.issue-title-header > * > h1")
|
||||
assertContainsAll(t, titleHTML, html)
|
||||
})
|
||||
|
||||
t.Run("Referenced issue comment", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
html := extractHTML(t, session, issue1, "div.timeline > div.timeline-item:nth-child(3) > div.detail > * > a")
|
||||
assertContainsAll(t, titleHTML, html)
|
||||
})
|
||||
|
||||
t.Run("Dependent issue comment", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
err := issues_model.CreateIssueDependency(db.DefaultContext, user, issue1, issue2)
|
||||
require.NoError(t, err)
|
||||
|
||||
html := extractHTML(t, session, issue1, "div.timeline > div:nth-child(3) > div.detail > * > a")
|
||||
assertContainsAll(t, titleHTML, html)
|
||||
})
|
||||
|
||||
t.Run("Dependent issue sidebar", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
html := extractHTML(t, session, issue1, "div.item.dependency > * > a.title")
|
||||
assertContainsAll(t, titleHTML, html)
|
||||
})
|
||||
|
||||
t.Run("Referenced pull comment", func(t *testing.T) {
|
||||
_, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user, &files_service.ChangeRepoFilesOptions{
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "update",
|
||||
TreePath: "README.md",
|
||||
ContentReader: strings.NewReader("Update README"),
|
||||
},
|
||||
},
|
||||
Message: "Update README",
|
||||
OldBranch: "main",
|
||||
NewBranch: "branch",
|
||||
Author: &files_service.IdentityOptions{
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
},
|
||||
Committer: &files_service.IdentityOptions{
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
},
|
||||
Dates: &files_service.CommitDateOptions{
|
||||
Author: time.Now(),
|
||||
Committer: time.Now(),
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
pullIssue := &issues_model.Issue{
|
||||
RepoID: repo.ID,
|
||||
Title: title,
|
||||
Content: "Closes #1",
|
||||
PosterID: user.ID,
|
||||
Poster: user,
|
||||
IsPull: true,
|
||||
}
|
||||
|
||||
pullRequest := &issues_model.PullRequest{
|
||||
HeadRepoID: repo.ID,
|
||||
BaseRepoID: repo.ID,
|
||||
HeadBranch: "branch",
|
||||
BaseBranch: "main",
|
||||
HeadRepo: repo,
|
||||
BaseRepo: repo,
|
||||
Type: issues_model.PullRequestGitea,
|
||||
}
|
||||
|
||||
err = pull_service.NewPullRequest(git.DefaultContext, repo, pullIssue, nil, nil, pullRequest, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
html := extractHTML(t, session, issue1, "div.timeline > div:nth-child(4) > div.detail > * > a")
|
||||
assertContainsAll(t, titleHTML, html)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func createIssue(t *testing.T, user *user_model.User, repo *repo_model.Repository, title, content string) *issues_model.Issue {
|
||||
issue := &issues_model.Issue{
|
||||
RepoID: repo.ID,
|
||||
Title: title,
|
||||
Content: content,
|
||||
PosterID: user.ID,
|
||||
Poster: user,
|
||||
}
|
||||
|
||||
err := issue_service.NewIssue(db.DefaultContext, repo, issue, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
return issue
|
||||
}
|
||||
|
||||
func extractHTML(t *testing.T, session *TestSession, issue *issues_model.Issue, query string) string {
|
||||
req := NewRequest(t, "GET", issue.HTMLURL())
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
doc := NewHTMLParser(t, resp.Body)
|
||||
res, err := doc.doc.Find(query).Html()
|
||||
require.NoError(t, err)
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func assertContainsAll(t *testing.T, expected []string, actual string) {
|
||||
for i := range expected {
|
||||
assert.Contains(t, actual, expected[i])
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import {defineConfig} from 'vitest/config';
|
||||
import vuePlugin from '@vitejs/plugin-vue';
|
||||
import {stringPlugin} from 'vite-string-plugin';
|
||||
import {resolve} from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
|
@ -13,6 +14,9 @@ export default defineConfig({
|
|||
passWithNoTests: true,
|
||||
globals: true,
|
||||
watch: false,
|
||||
alias: {
|
||||
'monaco-editor': resolve(import.meta.dirname, '/node_modules/monaco-editor/esm/vs/editor/editor.api'),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
stringPlugin(),
|
||||
|
|
|
@ -8,6 +8,7 @@ import {toAbsoluteUrl} from '../utils.js';
|
|||
import {initDropzone} from './common-global.js';
|
||||
import {POST, GET} from '../modules/fetch.js';
|
||||
import {showErrorToast} from '../modules/toast.js';
|
||||
import {emojiHTML} from './emoji.js';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
|
@ -124,7 +125,7 @@ export function initRepoIssueSidebarList() {
|
|||
return;
|
||||
}
|
||||
filteredResponse.results.push({
|
||||
name: `#${issue.number} ${htmlEscape(issue.title)
|
||||
name: `#${issue.number} ${issueTitleHTML(htmlEscape(issue.title))
|
||||
}<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`,
|
||||
value: issue.id,
|
||||
});
|
||||
|
@ -731,3 +732,9 @@ export function initArchivedLabelHandler() {
|
|||
toggleElem(label, label.classList.contains('checked'));
|
||||
}
|
||||
}
|
||||
|
||||
// Render the issue's title. It converts emojis and code blocks syntax into their respective HTML equivalent.
|
||||
export function issueTitleHTML(title) {
|
||||
return title.replaceAll(/:[-+\w]+:/g, (emoji) => emojiHTML(emoji.substring(1, emoji.length - 1)))
|
||||
.replaceAll(/`[^`]+`/g, (code) => `<code class="inline-code-block">${code.substring(1, code.length - 1)}</code>`);
|
||||
}
|
||||
|
|
24
web_src/js/features/repo-issue.test.js
Normal file
24
web_src/js/features/repo-issue.test.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
import {vi} from 'vitest';
|
||||
|
||||
import {issueTitleHTML} from './repo-issue.js';
|
||||
|
||||
// monaco-editor does not have any exports fields, which trips up vitest
|
||||
vi.mock('./comp/ComboMarkdownEditor.js', () => ({}));
|
||||
// jQuery is missing
|
||||
vi.mock('./common-global.js', () => ({}));
|
||||
|
||||
test('Convert issue title to html', () => {
|
||||
expect(issueTitleHTML('')).toEqual('');
|
||||
expect(issueTitleHTML('issue title')).toEqual('issue title');
|
||||
|
||||
const expected_thumbs_up = `<span class="emoji" title=":+1:">👍</span>`;
|
||||
expect(issueTitleHTML(':+1:')).toEqual(expected_thumbs_up);
|
||||
expect(issueTitleHTML(':invalid emoji:')).toEqual(':invalid emoji:');
|
||||
|
||||
const expected_code_block = `<code class="inline-code-block">code</code>`;
|
||||
expect(issueTitleHTML('`code`')).toEqual(expected_code_block);
|
||||
expect(issueTitleHTML('`invalid code')).toEqual('`invalid code');
|
||||
expect(issueTitleHTML('invalid code`')).toEqual('invalid code`');
|
||||
|
||||
expect(issueTitleHTML('issue title :+1: `code`')).toEqual(`issue title ${expected_thumbs_up} ${expected_code_block}`);
|
||||
});
|
Loading…
Reference in a new issue