mirror of
https://github.com/go-gitea/gitea.git
synced 2026-02-22 11:05:42 +01:00
feat: implement instance-wide informational banner
This commit is contained in:
parent
5e9b9b33d1
commit
b53ba46a26
@ -4,6 +4,7 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@ -53,9 +54,72 @@ type RepositoryStruct struct {
|
||||
GitGuideRemoteName *config.Value[string]
|
||||
}
|
||||
|
||||
const (
|
||||
InstanceNoticeLevelInfo = "info"
|
||||
InstanceNoticeLevelSuccess = "success"
|
||||
InstanceNoticeLevelWarning = "warning"
|
||||
InstanceNoticeLevelDanger = "danger"
|
||||
)
|
||||
|
||||
type InstanceNotice struct {
|
||||
Enabled bool
|
||||
Message string
|
||||
Level string
|
||||
ShowIcon bool
|
||||
|
||||
StartTime int64
|
||||
EndTime int64
|
||||
}
|
||||
|
||||
func DefaultInstanceNotice() InstanceNotice {
|
||||
return InstanceNotice{
|
||||
Level: InstanceNoticeLevelInfo,
|
||||
ShowIcon: true,
|
||||
}
|
||||
}
|
||||
|
||||
func IsValidInstanceNoticeLevel(level string) bool {
|
||||
switch level {
|
||||
case InstanceNoticeLevelInfo, InstanceNoticeLevelSuccess, InstanceNoticeLevelWarning, InstanceNoticeLevelDanger:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (n *InstanceNotice) Normalize() {
|
||||
if !IsValidInstanceNoticeLevel(n.Level) {
|
||||
n.Level = InstanceNoticeLevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func (n *InstanceNotice) IsActive(now int64) bool {
|
||||
if !n.Enabled || n.Message == "" {
|
||||
return false
|
||||
}
|
||||
if n.StartTime > 0 && now < n.StartTime {
|
||||
return false
|
||||
}
|
||||
if n.EndTime > 0 && now > n.EndTime {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func GetInstanceNotice(ctx context.Context) InstanceNotice {
|
||||
notice := Config().InstanceNotice.Banner.Value(ctx)
|
||||
notice.Normalize()
|
||||
return notice
|
||||
}
|
||||
|
||||
type InstanceNoticeStruct struct {
|
||||
Banner *config.Value[InstanceNotice]
|
||||
}
|
||||
|
||||
type ConfigStruct struct {
|
||||
Picture *PictureStruct
|
||||
Repository *RepositoryStruct
|
||||
Picture *PictureStruct
|
||||
Repository *RepositoryStruct
|
||||
InstanceNotice *InstanceNoticeStruct
|
||||
}
|
||||
|
||||
var (
|
||||
@ -74,6 +138,9 @@ func initDefaultConfig() {
|
||||
OpenWithEditorApps: config.ValueJSON[OpenWithEditorAppsType]("repository.open-with.editor-apps"),
|
||||
GitGuideRemoteName: config.ValueJSON[string]("repository.git-guide-remote-name").WithDefault("origin"),
|
||||
},
|
||||
InstanceNotice: &InstanceNoticeStruct{
|
||||
Banner: config.ValueJSON[InstanceNotice]("instance.notice").WithDefault(DefaultInstanceNotice()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3277,6 +3277,29 @@
|
||||
"admin.config.cache_test_failed": "Failed to probe the cache: %v.",
|
||||
"admin.config.cache_test_slow": "Cache test successful, but response is slow: %s.",
|
||||
"admin.config.cache_test_succeeded": "Cache test successful, got a response in %s.",
|
||||
"admin.config.instance_notice": "Instance Banner",
|
||||
"admin.config.instance_notice.settings": "Settings",
|
||||
"admin.config.instance_notice.enabled": "Enable banner",
|
||||
"admin.config.instance_notice.message": "Message",
|
||||
"admin.config.instance_notice.message_placeholder": "This message supports Markdown.",
|
||||
"admin.config.instance_notice.message_required": "Message is required when the banner is enabled.",
|
||||
"admin.config.instance_notice.level": "Level",
|
||||
"admin.config.instance_notice.level.info": "Info",
|
||||
"admin.config.instance_notice.level.success": "Success",
|
||||
"admin.config.instance_notice.level.warning": "Warning",
|
||||
"admin.config.instance_notice.level.danger": "Danger",
|
||||
"admin.config.instance_notice.show_icon": "Show icon",
|
||||
"admin.config.instance_notice.start_time": "Start time",
|
||||
"admin.config.instance_notice.end_time": "End time",
|
||||
"admin.config.instance_notice.invalid_time": "Invalid date-time value. Use the picker format.",
|
||||
"admin.config.instance_notice.invalid_time_range": "End time must be after start time.",
|
||||
"admin.config.instance_notice.markdown_hint": "This banner is informational only and does not block access.",
|
||||
"admin.config.instance_notice.preview": "Preview",
|
||||
"admin.config.instance_notice.save": "Save banner",
|
||||
"admin.config.instance_notice.save_success": "Instance banner settings updated.",
|
||||
"admin.config.instance_notice.delete": "Delete banner",
|
||||
"admin.config.instance_notice.delete_success": "Instance banner deleted.",
|
||||
"admin.config.instance_notice.edit_hint": "Edit this banner",
|
||||
"admin.config.session_config": "Session Configuration",
|
||||
"admin.config.session_provider": "Session Provider",
|
||||
"admin.config.provider_config": "Provider Config",
|
||||
|
||||
@ -12,6 +12,8 @@ import (
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
@ -69,14 +71,50 @@ type pageGlobalDataType struct {
|
||||
IsSigned bool
|
||||
IsSiteAdmin bool
|
||||
|
||||
InstanceNoticeBanner *InstanceNoticeBannerTmplInfo
|
||||
|
||||
GetNotificationUnreadCount func() int64
|
||||
GetActiveStopwatch func() *StopwatchTmplInfo
|
||||
}
|
||||
|
||||
type InstanceNoticeBannerTmplInfo struct {
|
||||
Message string
|
||||
Level string
|
||||
ShowIcon bool
|
||||
IconName string
|
||||
}
|
||||
|
||||
func instanceNoticeIconName(level string) string {
|
||||
switch level {
|
||||
case setting.InstanceNoticeLevelSuccess:
|
||||
return "octicon-check-circle"
|
||||
case setting.InstanceNoticeLevelWarning:
|
||||
return "octicon-alert"
|
||||
case setting.InstanceNoticeLevelDanger:
|
||||
return "octicon-stop"
|
||||
default:
|
||||
return "octicon-info"
|
||||
}
|
||||
}
|
||||
|
||||
func getInstanceNoticeBanner(ctx *context.Context) *InstanceNoticeBannerTmplInfo {
|
||||
notice := setting.GetInstanceNotice(ctx)
|
||||
if !notice.IsActive(int64(timeutil.TimeStampNow())) {
|
||||
return nil
|
||||
}
|
||||
return &InstanceNoticeBannerTmplInfo{
|
||||
Message: notice.Message,
|
||||
Level: notice.Level,
|
||||
ShowIcon: notice.ShowIcon,
|
||||
IconName: instanceNoticeIconName(notice.Level),
|
||||
}
|
||||
}
|
||||
|
||||
func PageGlobalData(ctx *context.Context) {
|
||||
var data pageGlobalDataType
|
||||
data.IsSigned = ctx.Doer != nil
|
||||
data.IsSiteAdmin = ctx.Doer != nil && ctx.Doer.IsAdmin
|
||||
data.InstanceNoticeBanner = getInstanceNoticeBanner(ctx)
|
||||
data.GetNotificationUnreadCount = sync.OnceValue(func() int64 { return notificationUnreadCount(ctx) })
|
||||
data.GetActiveStopwatch = sync.OnceValue(func() *StopwatchTmplInfo { return getActiveStopwatch(ctx) })
|
||||
ctx.Data["PageGlobalData"] = data
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
system_model "code.gitea.io/gitea/models/system"
|
||||
"code.gitea.io/gitea/modules/cache"
|
||||
@ -145,6 +146,14 @@ func Config(ctx *context.Context) {
|
||||
ctx.Data["Service"] = setting.Service
|
||||
ctx.Data["DbCfg"] = setting.Database
|
||||
ctx.Data["Webhook"] = setting.Webhook
|
||||
instanceNotice := setting.GetInstanceNotice(ctx)
|
||||
ctx.Data["InstanceNotice"] = instanceNotice
|
||||
if instanceNotice.StartTime > 0 {
|
||||
ctx.Data["InstanceNoticeStartTime"] = time.Unix(instanceNotice.StartTime, 0).In(setting.DefaultUILocation).Format("2006-01-02T15:04")
|
||||
}
|
||||
if instanceNotice.EndTime > 0 {
|
||||
ctx.Data["InstanceNoticeEndTime"] = time.Unix(instanceNotice.EndTime, 0).In(setting.DefaultUILocation).Format("2006-01-02T15:04")
|
||||
}
|
||||
|
||||
ctx.Data["MailerEnabled"] = false
|
||||
if setting.MailService != nil {
|
||||
@ -187,6 +196,92 @@ func Config(ctx *context.Context) {
|
||||
ctx.HTML(http.StatusOK, tplConfig)
|
||||
}
|
||||
|
||||
func parseDatetimeLocalValue(raw string) (int64, error) {
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
tm, err := time.ParseInLocation("2006-01-02T15:04", raw, setting.DefaultUILocation)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tm.Unix(), nil
|
||||
}
|
||||
|
||||
func SetInstanceNotice(ctx *context.Context) {
|
||||
saveInstanceNotice := func(instanceNotice setting.InstanceNotice) {
|
||||
instanceNotice.Normalize()
|
||||
marshaled, err := json.Marshal(instanceNotice)
|
||||
if err != nil {
|
||||
ctx.ServerError("Marshal", err)
|
||||
return
|
||||
}
|
||||
if err := system_model.SetSettings(ctx, map[string]string{
|
||||
setting.Config().InstanceNotice.Banner.DynKey(): string(marshaled),
|
||||
}); err != nil {
|
||||
ctx.ServerError("SetSettings", err)
|
||||
return
|
||||
}
|
||||
config.GetDynGetter().InvalidateCache()
|
||||
}
|
||||
|
||||
if ctx.FormString("action") == "delete" {
|
||||
saveInstanceNotice(setting.DefaultInstanceNotice())
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
ctx.Flash.Success(ctx.Tr("admin.config.instance_notice.delete_success"))
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice")
|
||||
return
|
||||
}
|
||||
|
||||
enabled := ctx.FormBool("enabled")
|
||||
message := strings.TrimSpace(ctx.FormString("message"))
|
||||
level := strings.TrimSpace(ctx.FormString("level"))
|
||||
showIcon := ctx.FormBool("show_icon")
|
||||
startTime, err := parseDatetimeLocalValue(strings.TrimSpace(ctx.FormString("start_time")))
|
||||
if err != nil {
|
||||
ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.invalid_time"))
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice")
|
||||
return
|
||||
}
|
||||
endTime, err := parseDatetimeLocalValue(strings.TrimSpace(ctx.FormString("end_time")))
|
||||
if err != nil {
|
||||
ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.invalid_time"))
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice")
|
||||
return
|
||||
}
|
||||
if !setting.IsValidInstanceNoticeLevel(level) {
|
||||
level = setting.InstanceNoticeLevelInfo
|
||||
}
|
||||
if enabled && message == "" {
|
||||
ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.message_required"))
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice")
|
||||
return
|
||||
}
|
||||
if startTime > 0 && endTime > 0 && endTime < startTime {
|
||||
ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.invalid_time_range"))
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice")
|
||||
return
|
||||
}
|
||||
|
||||
instanceNotice := setting.InstanceNotice{
|
||||
Enabled: enabled,
|
||||
Message: message,
|
||||
Level: level,
|
||||
ShowIcon: showIcon,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
}
|
||||
|
||||
saveInstanceNotice(instanceNotice)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("admin.config.instance_notice.save_success"))
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice")
|
||||
}
|
||||
|
||||
func ConfigSettings(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("admin.config_settings")
|
||||
ctx.Data["PageIsAdminConfig"] = true
|
||||
|
||||
@ -717,6 +717,7 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Group("/config", func() {
|
||||
m.Get("", admin.Config)
|
||||
m.Post("", admin.ChangeConfig)
|
||||
m.Post("/instance_notice", admin.SetInstanceNotice)
|
||||
m.Post("/test_mail", admin.SendTestMail)
|
||||
m.Post("/test_cache", admin.TestCache)
|
||||
m.Get("/settings", admin.ConfigSettings)
|
||||
|
||||
@ -265,6 +265,79 @@
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<h4 id="instance-notice" class="ui top attached header">
|
||||
{{ctx.Locale.Tr "admin.config.instance_notice"}}
|
||||
</h4>
|
||||
<div class="ui attached table segment">
|
||||
<dl class="admin-dl-horizontal">
|
||||
<dt class="tw-py-1 tw-flex tw-items-center">{{ctx.Locale.Tr "admin.config.instance_notice.settings"}}</dt>
|
||||
<dd class="tw-py-0">
|
||||
<form class="ui form ignore-dirty" action="{{AppSubUrl}}/-/admin/config/instance_notice" method="post">
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="enabled" value="true" {{if .InstanceNotice.Enabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "admin.config.instance_notice.enabled"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "admin.config.instance_notice.message"}}</label>
|
||||
<textarea name="message" rows="4" maxlength="2000" placeholder="{{ctx.Locale.Tr "admin.config.instance_notice.message_placeholder"}}">{{.InstanceNotice.Message}}</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "admin.config.instance_notice.level"}}</label>
|
||||
<select class="ui dropdown" name="level">
|
||||
<option value="info" {{if eq .InstanceNotice.Level "info"}}selected{{end}}>{{ctx.Locale.Tr "admin.config.instance_notice.level.info"}}</option>
|
||||
<option value="success" {{if eq .InstanceNotice.Level "success"}}selected{{end}}>{{ctx.Locale.Tr "admin.config.instance_notice.level.success"}}</option>
|
||||
<option value="warning" {{if eq .InstanceNotice.Level "warning"}}selected{{end}}>{{ctx.Locale.Tr "admin.config.instance_notice.level.warning"}}</option>
|
||||
<option value="danger" {{if eq .InstanceNotice.Level "danger"}}selected{{end}}>{{ctx.Locale.Tr "admin.config.instance_notice.level.danger"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="show_icon" value="true" {{if .InstanceNotice.ShowIcon}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "admin.config.instance_notice.show_icon"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "admin.config.instance_notice.start_time"}}</label>
|
||||
<input type="datetime-local" name="start_time" value="{{.InstanceNoticeStartTime}}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "admin.config.instance_notice.end_time"}}</label>
|
||||
<input type="datetime-local" name="end_time" value="{{.InstanceNoticeEndTime}}">
|
||||
</div>
|
||||
</div>
|
||||
<p class="text tw-mb-3">{{ctx.Locale.Tr "admin.config.instance_notice.markdown_hint"}}</p>
|
||||
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "admin.config.instance_notice.preview"}}</label>
|
||||
<div id="instance-notice-preview" class="ui {{if eq .InstanceNotice.Level "success"}}positive{{else if eq .InstanceNotice.Level "warning"}}warning{{else if eq .InstanceNotice.Level "danger"}}negative{{else}}info{{end}} message tw-mt-2">
|
||||
<div class="tw-flex tw-items-start tw-justify-center tw-gap-2">
|
||||
<div id="instance-notice-preview-icon" class="{{if not .InstanceNotice.ShowIcon}}tw-hidden{{end}} tw-leading-6">
|
||||
{{if eq .InstanceNotice.Level "success"}}{{svg "octicon-check-circle"}}{{else if eq .InstanceNotice.Level "warning"}}{{svg "octicon-alert"}}{{else if eq .InstanceNotice.Level "danger"}}{{svg "octicon-stop"}}{{else}}{{svg "octicon-info"}}{{end}}
|
||||
</div>
|
||||
<div id="instance-notice-preview-content" class="render-content markup tw-text-center">{{ctx.RenderUtils.MarkdownToHtml .InstanceNotice.Message}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="instance-notice-preview-icons" class="tw-hidden">
|
||||
<span data-level="info">{{svg "octicon-info"}}</span>
|
||||
<span data-level="success">{{svg "octicon-check-circle"}}</span>
|
||||
<span data-level="warning">{{svg "octicon-alert"}}</span>
|
||||
<span data-level="danger">{{svg "octicon-stop"}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="ui tiny primary button">{{ctx.Locale.Tr "admin.config.instance_notice.save"}}</button>
|
||||
</form>
|
||||
<form class="ui form ignore-dirty tw-mt-3" action="{{AppSubUrl}}/-/admin/config/instance_notice" method="post">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<button class="ui tiny button">{{ctx.Locale.Tr "admin.config.instance_notice.delete"}}</button>
|
||||
</form>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "admin.config.session_config"}}
|
||||
</h4>
|
||||
|
||||
@ -176,3 +176,19 @@
|
||||
</div>
|
||||
{{end}}
|
||||
</nav>
|
||||
{{if and .PageGlobalData .PageGlobalData.InstanceNoticeBanner}}
|
||||
{{$banner := .PageGlobalData.InstanceNoticeBanner}}
|
||||
<div class="ui {{if eq $banner.Level "success"}}positive{{else if eq $banner.Level "warning"}}warning{{else if eq $banner.Level "danger"}}negative{{else}}info{{end}} attached message tw-m-0 tw-rounded-none">
|
||||
<div class="tw-flex tw-items-start tw-justify-center tw-gap-2">
|
||||
{{if $banner.ShowIcon}}
|
||||
<span class="tw-leading-6">{{svg $banner.IconName}}</span>
|
||||
{{end}}
|
||||
<div class="render-content markup tw-text-center">{{ctx.RenderUtils.MarkdownToHtml $banner.Message}}</div>
|
||||
</div>
|
||||
{{if .PageGlobalData.IsSiteAdmin}}
|
||||
<div class="tw-mt-2 tw-text-center">
|
||||
<a href="{{AppSubUrl}}/-/admin/config#instance-notice">{{ctx.Locale.Tr "admin.config.instance_notice.edit_hint"}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
119
tests/integration/instance_notice_test.go
Normal file
119
tests/integration/instance_notice_test.go
Normal file
@ -0,0 +1,119 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
system_model "code.gitea.io/gitea/models/system"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/setting/config"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInstanceNoticeVisibility(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
setInstanceNoticeForTest(t, setting.DefaultInstanceNotice())
|
||||
|
||||
setInstanceNoticeForTest(t, setting.InstanceNotice{
|
||||
Enabled: true,
|
||||
Message: "Planned **upgrade** in progress.",
|
||||
Level: setting.InstanceNoticeLevelWarning,
|
||||
ShowIcon: true,
|
||||
})
|
||||
|
||||
t.Run("AnonymousUserSeesBanner", func(t *testing.T) {
|
||||
resp := MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "Planned <strong>upgrade</strong> in progress.")
|
||||
})
|
||||
|
||||
t.Run("NormalUserSeesBanner", func(t *testing.T) {
|
||||
sess := loginUser(t, "user2")
|
||||
resp := sess.MakeRequest(t, NewRequest(t, "GET", "/user/settings"), http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "Planned <strong>upgrade</strong> in progress.")
|
||||
})
|
||||
|
||||
t.Run("AdminSeesBannerAndEditHint", func(t *testing.T) {
|
||||
sess := loginUser(t, "user1")
|
||||
resp := sess.MakeRequest(t, NewRequest(t, "GET", "/-/admin"), http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "Planned <strong>upgrade</strong> in progress.")
|
||||
assert.Contains(t, resp.Body.String(), "Edit this banner")
|
||||
})
|
||||
|
||||
t.Run("APIRequestUnchanged", func(t *testing.T) {
|
||||
MakeRequest(t, NewRequest(t, "GET", "/api/v1/version"), http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInstanceNoticeTimeWindow(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
setInstanceNoticeForTest(t, setting.DefaultInstanceNotice())
|
||||
|
||||
now := time.Now().Unix()
|
||||
setInstanceNoticeForTest(t, setting.InstanceNotice{
|
||||
Enabled: true,
|
||||
Message: "Future banner",
|
||||
Level: setting.InstanceNoticeLevelInfo,
|
||||
StartTime: now + 3600,
|
||||
EndTime: now + 7200,
|
||||
})
|
||||
|
||||
resp := MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK)
|
||||
assert.NotContains(t, resp.Body.String(), "Future banner")
|
||||
|
||||
setInstanceNoticeForTest(t, setting.InstanceNotice{
|
||||
Enabled: true,
|
||||
Message: "Expired banner",
|
||||
Level: setting.InstanceNoticeLevelInfo,
|
||||
StartTime: now - 7200,
|
||||
EndTime: now - 3600,
|
||||
})
|
||||
|
||||
resp = MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK)
|
||||
assert.NotContains(t, resp.Body.String(), "Expired banner")
|
||||
}
|
||||
|
||||
func TestInstanceNoticeAdminCRUD(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
setInstanceNoticeForTest(t, setting.DefaultInstanceNotice())
|
||||
|
||||
adminSession := loginUser(t, "user1")
|
||||
req := NewRequestWithValues(t, "POST", "/-/admin/config/instance_notice", map[string]string{
|
||||
"enabled": "true",
|
||||
"message": "Admin set banner",
|
||||
"level": "danger",
|
||||
"show_icon": "true",
|
||||
})
|
||||
adminSession.MakeRequest(t, req, http.StatusSeeOther)
|
||||
|
||||
notice := setting.GetInstanceNotice(t.Context())
|
||||
assert.True(t, notice.Enabled)
|
||||
assert.True(t, notice.ShowIcon)
|
||||
assert.Equal(t, "Admin set banner", notice.Message)
|
||||
assert.Equal(t, setting.InstanceNoticeLevelDanger, notice.Level)
|
||||
|
||||
req = NewRequestWithValues(t, "POST", "/-/admin/config/instance_notice", map[string]string{
|
||||
"action": "delete",
|
||||
})
|
||||
adminSession.MakeRequest(t, req, http.StatusSeeOther)
|
||||
|
||||
notice = setting.GetInstanceNotice(t.Context())
|
||||
assert.Equal(t, setting.DefaultInstanceNotice(), notice)
|
||||
}
|
||||
|
||||
func setInstanceNoticeForTest(t *testing.T, notice setting.InstanceNotice) {
|
||||
t.Helper()
|
||||
marshaled, err := json.Marshal(notice)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, system_model.SetSettings(t.Context(), map[string]string{
|
||||
setting.Config().InstanceNotice.Banner.DynKey(): string(marshaled),
|
||||
}))
|
||||
config.GetDynGetter().InvalidateCache()
|
||||
}
|
||||
136
web_src/js/features/admin/config.test.ts
Normal file
136
web_src/js/features/admin/config.test.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import {beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
import {initAdminConfigs} from './config.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
|
||||
vi.mock('../../modules/fetch.ts', () => ({
|
||||
POST: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../modules/tippy.ts', () => ({
|
||||
showTemporaryTooltip: vi.fn(),
|
||||
}));
|
||||
|
||||
function createPreviewDOM() {
|
||||
document.body.innerHTML = `
|
||||
<div class="page-content admin config">
|
||||
<form class="ui form" action="/-/admin/config/instance_notice" method="post">
|
||||
<textarea name="message">Initial message</textarea>
|
||||
<select name="level">
|
||||
<option value="info" selected>Info</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="danger">Danger</option>
|
||||
</select>
|
||||
<input type="checkbox" name="show_icon" value="true" checked>
|
||||
</form>
|
||||
<div id="instance-notice-preview" class="ui info message">
|
||||
<div id="instance-notice-preview-icon"></div>
|
||||
<div id="instance-notice-preview-content"></div>
|
||||
</div>
|
||||
<div id="instance-notice-preview-icons" class="tw-hidden">
|
||||
<span data-level="info"><svg data-icon="info"></svg></span>
|
||||
<span data-level="success"><svg data-icon="success"></svg></span>
|
||||
<span data-level="warning"><svg data-icon="warning"></svg></span>
|
||||
<span data-level="danger"><svg data-icon="danger"></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
describe('Admin Instance Notice Preview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createPreviewDOM();
|
||||
});
|
||||
|
||||
test('renders markdown preview on input', async () => {
|
||||
vi.mocked(POST).mockResolvedValue({
|
||||
text: async () => '<p>Rendered message</p>',
|
||||
} as Response);
|
||||
|
||||
initAdminConfigs();
|
||||
|
||||
const messageInput = document.querySelector<HTMLTextAreaElement>('textarea[name="message"]')!;
|
||||
messageInput.value = 'Updated message';
|
||||
messageInput.dispatchEvent(new Event('input'));
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(POST).toHaveBeenCalledWith('/-/markup', expect.objectContaining({
|
||||
data: expect.any(FormData),
|
||||
}));
|
||||
|
||||
const formData = vi.mocked(POST).mock.calls[0][1]?.data as FormData;
|
||||
expect(formData.get('mode')).toBe('comment');
|
||||
expect(formData.get('text')).toBe('Updated message');
|
||||
|
||||
const previewContent = document.querySelector('#instance-notice-preview-content')!;
|
||||
expect(previewContent.innerHTML).toContain('Rendered message');
|
||||
});
|
||||
|
||||
test('updates preview class and icon when level and icon toggle change', () => {
|
||||
initAdminConfigs();
|
||||
|
||||
const levelSelect = document.querySelector<HTMLSelectElement>('select[name="level"]')!;
|
||||
const showIcon = document.querySelector<HTMLInputElement>('input[name="show_icon"]')!;
|
||||
const preview = document.querySelector<HTMLDivElement>('#instance-notice-preview')!;
|
||||
const previewIcon = document.querySelector<HTMLDivElement>('#instance-notice-preview-icon')!;
|
||||
|
||||
levelSelect.value = 'danger';
|
||||
levelSelect.dispatchEvent(new Event('change'));
|
||||
expect(preview.classList.contains('negative')).toBe(true);
|
||||
expect(previewIcon.innerHTML).toContain('data-icon="danger"');
|
||||
|
||||
showIcon.checked = false;
|
||||
showIcon.dispatchEvent(new Event('change'));
|
||||
expect(previewIcon.classList.contains('tw-hidden')).toBe(true);
|
||||
});
|
||||
|
||||
test('queues a second render while first request is in flight and re-renders with latest text', async () => {
|
||||
let firstResolve: ((value: Response) => void) | undefined;
|
||||
const firstPending = new Promise<Response>((resolve) => {
|
||||
firstResolve = resolve;
|
||||
});
|
||||
|
||||
vi.mocked(POST)
|
||||
.mockImplementationOnce(async () => await firstPending)
|
||||
.mockResolvedValueOnce({
|
||||
text: async () => '<p>Second render</p>',
|
||||
} as Response);
|
||||
|
||||
initAdminConfigs();
|
||||
|
||||
const messageInput = document.querySelector<HTMLTextAreaElement>('textarea[name="message"]')!;
|
||||
|
||||
messageInput.value = 'First value';
|
||||
messageInput.dispatchEvent(new Event('input'));
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
messageInput.value = 'Latest value';
|
||||
messageInput.dispatchEvent(new Event('input'));
|
||||
|
||||
firstResolve?.({
|
||||
text: async () => '<p>First render</p>',
|
||||
} as Response);
|
||||
|
||||
for (let i = 0; i < 10 && vi.mocked(POST).mock.calls.length < 2; i++) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
expect(POST).toHaveBeenCalledTimes(2);
|
||||
const secondData = vi.mocked(POST).mock.calls[1][1]?.data as FormData;
|
||||
expect(secondData.get('text')).toBe('Latest value');
|
||||
|
||||
const previewContent = document.querySelector('#instance-notice-preview-content')!;
|
||||
for (let i = 0; i < 10 && !previewContent.innerHTML.includes('Second render'); i++) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
expect(previewContent.innerHTML).toContain('Second render');
|
||||
});
|
||||
});
|
||||
@ -1,8 +1,78 @@
|
||||
import {showTemporaryTooltip} from '../../modules/tippy.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {html, htmlRaw} from '../../utils/html.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
function initInstanceNoticePreview(elAdminConfig: HTMLDivElement): void {
|
||||
const form = elAdminConfig.querySelector<HTMLFormElement>('form[action$="/-/admin/config/instance_notice"]');
|
||||
if (!form) return;
|
||||
|
||||
const inputMessage = form.querySelector<HTMLTextAreaElement>('textarea[name="message"]');
|
||||
const selectLevel = form.querySelector<HTMLSelectElement>('select[name="level"]');
|
||||
const inputShowIcon = form.querySelector<HTMLInputElement>('input[name="show_icon"]');
|
||||
const preview = elAdminConfig.querySelector<HTMLDivElement>('#instance-notice-preview');
|
||||
const previewIcon = elAdminConfig.querySelector<HTMLDivElement>('#instance-notice-preview-icon');
|
||||
const previewContent = elAdminConfig.querySelector<HTMLDivElement>('#instance-notice-preview-content');
|
||||
const iconContainer = elAdminConfig.querySelector<HTMLDivElement>('#instance-notice-preview-icons');
|
||||
if (!inputMessage || !selectLevel || !inputShowIcon || !preview || !previewIcon || !previewContent || !iconContainer) return;
|
||||
|
||||
const iconHTMLByLevel = new Map<string, string>();
|
||||
for (const el of iconContainer.querySelectorAll<HTMLElement>('[data-level]')) {
|
||||
iconHTMLByLevel.set(el.getAttribute('data-level')!, el.innerHTML);
|
||||
}
|
||||
|
||||
const classByLevel: Record<string, string> = {
|
||||
info: 'info',
|
||||
success: 'positive',
|
||||
warning: 'warning',
|
||||
danger: 'negative',
|
||||
};
|
||||
|
||||
const updateStyle = () => {
|
||||
preview.classList.remove('info', 'positive', 'warning', 'negative');
|
||||
preview.classList.add(classByLevel[selectLevel.value] || 'info');
|
||||
previewIcon.innerHTML = iconHTMLByLevel.get(selectLevel.value) || iconHTMLByLevel.get('info') || '';
|
||||
previewIcon.classList.toggle('tw-hidden', !inputShowIcon.checked);
|
||||
};
|
||||
|
||||
let renderRequesting = false;
|
||||
let pendingRender = false;
|
||||
const renderPreviewMarkdown = async () => {
|
||||
if (renderRequesting) {
|
||||
pendingRender = true;
|
||||
return;
|
||||
}
|
||||
renderRequesting = true;
|
||||
try {
|
||||
while (true) {
|
||||
pendingRender = false;
|
||||
const formData = new FormData();
|
||||
formData.append('mode', 'comment');
|
||||
formData.append('text', inputMessage.value);
|
||||
try {
|
||||
const response = await POST(`${appSubUrl}/-/markup`, {data: formData});
|
||||
const rendered = await response.text();
|
||||
previewContent.innerHTML = html`${htmlRaw(rendered)}`;
|
||||
} catch (error) {
|
||||
console.error('Error rendering instance notice preview:', error);
|
||||
}
|
||||
if (!pendingRender) break;
|
||||
}
|
||||
} finally {
|
||||
renderRequesting = false;
|
||||
}
|
||||
};
|
||||
|
||||
inputMessage.addEventListener('input', () => {
|
||||
renderPreviewMarkdown();
|
||||
});
|
||||
selectLevel.addEventListener('change', updateStyle);
|
||||
inputShowIcon.addEventListener('change', updateStyle);
|
||||
|
||||
updateStyle();
|
||||
}
|
||||
|
||||
export function initAdminConfigs(): void {
|
||||
const elAdminConfig = document.querySelector<HTMLDivElement>('.page-content.admin.config');
|
||||
if (!elAdminConfig) return;
|
||||
@ -21,4 +91,6 @@ export function initAdminConfigs(): void {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initInstanceNoticePreview(elAdminConfig);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user