0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-06 08:29:54 +02:00

fixup! feat: implement instance-wide informational banner

This commit is contained in:
Nicolas 2026-02-12 20:21:18 +01:00
parent b53ba46a26
commit dacd1941f1
9 changed files with 61 additions and 69 deletions

View File

@ -62,10 +62,9 @@ const (
)
type InstanceNotice struct {
Enabled bool
Message string
Level string
ShowIcon bool
Enabled bool
Message string
Level string
StartTime int64
EndTime int64
@ -73,8 +72,7 @@ type InstanceNotice struct {
func DefaultInstanceNotice() InstanceNotice {
return InstanceNotice{
Level: InstanceNoticeLevelInfo,
ShowIcon: true,
Level: InstanceNoticeLevelInfo,
}
}

View File

@ -3288,12 +3288,11 @@
"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.message_too_long": "Message must be at most %d characters.",
"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.",

View File

@ -80,18 +80,17 @@ type pageGlobalDataType struct {
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"
return "octicon-check"
case setting.InstanceNoticeLevelWarning:
return "octicon-alert"
case setting.InstanceNoticeLevelDanger:
return "octicon-stop"
return "octicon-alert"
default:
return "octicon-info"
}
@ -105,7 +104,6 @@ func getInstanceNoticeBanner(ctx *context.Context) *InstanceNoticeBannerTmplInfo
return &InstanceNoticeBannerTmplInfo{
Message: notice.Message,
Level: notice.Level,
ShowIcon: notice.ShowIcon,
IconName: instanceNoticeIconName(notice.Level),
}
}

View File

@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
system_model "code.gitea.io/gitea/models/system"
"code.gitea.io/gitea/modules/cache"
@ -29,6 +30,8 @@ import (
const (
tplConfig templates.TplName = "admin/config"
tplConfigSettings templates.TplName = "admin/config_settings/config_settings"
instanceNoticeMessageMaxLength = 2000
)
// SendTestMail send test mail to confirm mail service is OK
@ -148,6 +151,7 @@ func Config(ctx *context.Context) {
ctx.Data["Webhook"] = setting.Webhook
instanceNotice := setting.GetInstanceNotice(ctx)
ctx.Data["InstanceNotice"] = instanceNotice
ctx.Data["InstanceNoticeMessageMaxLength"] = instanceNoticeMessageMaxLength
if instanceNotice.StartTime > 0 {
ctx.Data["InstanceNoticeStartTime"] = time.Unix(instanceNotice.StartTime, 0).In(setting.DefaultUILocation).Format("2006-01-02T15:04")
}
@ -237,7 +241,6 @@ func SetInstanceNotice(ctx *context.Context) {
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"))
@ -258,6 +261,11 @@ func SetInstanceNotice(ctx *context.Context) {
ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice")
return
}
if utf8.RuneCountInString(message) > instanceNoticeMessageMaxLength {
ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.message_too_long", instanceNoticeMessageMaxLength))
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")
@ -268,7 +276,6 @@ func SetInstanceNotice(ctx *context.Context) {
Enabled: enabled,
Message: message,
Level: level,
ShowIcon: showIcon,
StartTime: startTime,
EndTime: endTime,
}

View File

@ -279,10 +279,10 @@
<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.message"}}</label>
<textarea name="message" rows="4" maxlength="{{.InstanceNoticeMessageMaxLength}}" 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">
@ -292,13 +292,7 @@
<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="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}}">
@ -308,25 +302,23 @@
<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}}
<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="tw-leading-6">
{{if eq .InstanceNotice.Level "success"}}{{svg "octicon-check"}}{{else if eq .InstanceNotice.Level "warning"}}{{svg "octicon-alert"}}{{else if eq .InstanceNotice.Level "danger"}}{{svg "octicon-alert"}}{{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 id="instance-notice-preview-content" class="render-content markup tw-text-center">{{ctx.RenderUtils.MarkdownToHtml .InstanceNotice.Message}}</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"}}</span>
<span data-level="warning">{{svg "octicon-alert"}}</span>
<span data-level="danger">{{svg "octicon-alert"}}</span>
</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>

View File

@ -177,14 +177,12 @@
{{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}}
{{$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">
<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>
<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>

View File

@ -5,6 +5,7 @@ package integration
import (
"net/http"
"strings"
"testing"
"time"
@ -23,10 +24,9 @@ func TestInstanceNoticeVisibility(t *testing.T) {
setInstanceNoticeForTest(t, setting.DefaultInstanceNotice())
setInstanceNoticeForTest(t, setting.InstanceNotice{
Enabled: true,
Message: "Planned **upgrade** in progress.",
Level: setting.InstanceNoticeLevelWarning,
ShowIcon: true,
Enabled: true,
Message: "Planned **upgrade** in progress.",
Level: setting.InstanceNoticeLevelWarning,
})
t.Run("AnonymousUserSeesBanner", func(t *testing.T) {
@ -86,16 +86,25 @@ func TestInstanceNoticeAdminCRUD(t *testing.T) {
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",
"enabled": "true",
"message": "Admin set banner",
"level": "danger",
})
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{
"enabled": "true",
"message": strings.Repeat("a", 2001),
"level": "warning",
})
adminSession.MakeRequest(t, req, http.StatusSeeOther)
notice = setting.GetInstanceNotice(t.Context())
assert.Equal(t, "Admin set banner", notice.Message)
assert.Equal(t, setting.InstanceNoticeLevelDanger, notice.Level)

View File

@ -21,7 +21,6 @@ function createPreviewDOM() {
<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>
@ -31,7 +30,7 @@ function createPreviewDOM() {
<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>
<span data-level="danger"><svg data-icon="warning"></svg></span>
</div>
</div>
`;
@ -69,22 +68,17 @@ describe('Admin Instance Notice Preview', () => {
expect(previewContent.innerHTML).toContain('Rendered message');
});
test('updates preview class and icon when level and icon toggle change', () => {
test('updates preview class and icon when level changes', () => {
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);
expect(previewIcon.innerHTML).toContain('data-icon="warning"');
});
test('queues a second render while first request is in flight and re-renders with latest text', async () => {

View File

@ -10,12 +10,11 @@ function initInstanceNoticePreview(elAdminConfig: HTMLDivElement): void {
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;
if (!inputMessage || !selectLevel || !preview || !previewIcon || !previewContent || !iconContainer) return;
const iconHTMLByLevel = new Map<string, string>();
for (const el of iconContainer.querySelectorAll<HTMLElement>('[data-level]')) {
@ -33,7 +32,6 @@ function initInstanceNoticePreview(elAdminConfig: HTMLDivElement): void {
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;
@ -68,7 +66,6 @@ function initInstanceNoticePreview(elAdminConfig: HTMLDivElement): void {
renderPreviewMarkdown();
});
selectLevel.addEventListener('change', updateStyle);
inputShowIcon.addEventListener('change', updateStyle);
updateStyle();
}