Allow users to hide all "Add more units..." hints

Repositories displaying an "Add more..." tab on the header is a neat way
to let people discover they can enable more units. However, displaying
it all the time for repository owners, even when they deliberately do
not want to enable more units gets noisy very fast.

As such, this patch introduces a new setting which lets people disable
this hint under the appearance settings.

Fixes #2378.

Signed-off-by: Gergely Nagy <forgejo@gergo.csillger.hu>
This commit is contained in:
Gergely Nagy 2024-03-01 13:22:40 +01:00
parent 96d2f2b8cd
commit 36147f580c
No known key found for this signature in database
15 changed files with 233 additions and 24 deletions

View file

@ -50,6 +50,8 @@ var migrations = []*Migration{
NewMigration("create the forgejo_repo_flag table", forgejo_v1_22.CreateRepoFlagTable), NewMigration("create the forgejo_repo_flag table", forgejo_v1_22.CreateRepoFlagTable),
// v5 -> v6 // v5 -> v6
NewMigration("Add wiki_branch to repository", forgejo_v1_22.AddWikiBranchToRepository), NewMigration("Add wiki_branch to repository", forgejo_v1_22.AddWikiBranchToRepository),
// v6 -> v7
NewMigration("Add enable_repo_unit_hints to the user table", forgejo_v1_22.AddUserRepoUnitHintsSetting),
} }
// GetCurrentDBVersion returns the current Forgejo database version. // GetCurrentDBVersion returns the current Forgejo database version.

View file

@ -0,0 +1,17 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_22 //nolint
import (
"xorm.io/xorm"
)
func AddUserRepoUnitHintsSetting(x *xorm.Engine) error {
type User struct {
ID int64
EnableRepoUnitHints bool `xorm:"NOT NULL DEFAULT true"`
}
return x.Sync(&User{})
}

View file

@ -146,6 +146,7 @@ type User struct {
DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"` DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"`
Theme string `xorm:"NOT NULL DEFAULT ''"` Theme string `xorm:"NOT NULL DEFAULT ''"`
KeepActivityPrivate bool `xorm:"NOT NULL DEFAULT false"` KeepActivityPrivate bool `xorm:"NOT NULL DEFAULT false"`
EnableRepoUnitHints bool `xorm:"NOT NULL DEFAULT true"`
} }
func init() { func init() {

View file

@ -67,13 +67,14 @@ func (u User) MarshalJSON() ([]byte, error) {
// UserSettings represents user settings // UserSettings represents user settings
// swagger:model // swagger:model
type UserSettings struct { type UserSettings struct {
FullName string `json:"full_name"` FullName string `json:"full_name"`
Website string `json:"website"` Website string `json:"website"`
Description string `json:"description"` Description string `json:"description"`
Location string `json:"location"` Location string `json:"location"`
Language string `json:"language"` Language string `json:"language"`
Theme string `json:"theme"` Theme string `json:"theme"`
DiffViewStyle string `json:"diff_view_style"` DiffViewStyle string `json:"diff_view_style"`
EnableRepoUnitHints bool `json:"enable_repo_unit_hints"`
// Privacy // Privacy
HideEmail bool `json:"hide_email"` HideEmail bool `json:"hide_email"`
HideActivity bool `json:"hide_activity"` HideActivity bool `json:"hide_activity"`
@ -82,13 +83,14 @@ type UserSettings struct {
// UserSettingsOptions represents options to change user settings // UserSettingsOptions represents options to change user settings
// swagger:model // swagger:model
type UserSettingsOptions struct { type UserSettingsOptions struct {
FullName *string `json:"full_name" binding:"MaxSize(100)"` FullName *string `json:"full_name" binding:"MaxSize(100)"`
Website *string `json:"website" binding:"OmitEmpty;ValidUrl;MaxSize(255)"` Website *string `json:"website" binding:"OmitEmpty;ValidUrl;MaxSize(255)"`
Description *string `json:"description" binding:"MaxSize(255)"` Description *string `json:"description" binding:"MaxSize(255)"`
Location *string `json:"location" binding:"MaxSize(50)"` Location *string `json:"location" binding:"MaxSize(50)"`
Language *string `json:"language"` Language *string `json:"language"`
Theme *string `json:"theme"` Theme *string `json:"theme"`
DiffViewStyle *string `json:"diff_view_style"` DiffViewStyle *string `json:"diff_view_style"`
EnableRepoUnitHints *bool `json:"enable_repo_unit_hints"`
// Privacy // Privacy
HideEmail *bool `json:"hide_email"` HideEmail *bool `json:"hide_email"`
HideActivity *bool `json:"hide_activity"` HideActivity *bool `json:"hide_activity"`

View file

@ -703,6 +703,11 @@ continue = Continue
cancel = Cancel cancel = Cancel
language = Language language = Language
ui = Theme ui = Theme
hints = Hints
additional_repo_units_hint_description = Display an "Add more units..." button for repositories that do not have all available units enabled.
additional_repo_units_hint = Encourage enabling additional repository units
update_hints = Update hints
update_hints_success = Hints have been updated.
hidden_comment_types = Hidden comment types hidden_comment_types = Hidden comment types
hidden_comment_types_description = Comment types checked here will not be shown inside issue pages. Checking "Label" for example removes all "<user> added/removed <label>" comments. hidden_comment_types_description = Comment types checked here will not be shown inside issue pages. Checking "Label" for example removes all "<user> added/removed <label>" comments.
hidden_comment_types.ref_tooltip = Comments where this issue was referenced from another issue/commit/… hidden_comment_types.ref_tooltip = Comments where this issue was referenced from another issue/commit/…

View file

@ -55,6 +55,7 @@ func UpdateUserSettings(ctx *context.APIContext) {
DiffViewStyle: optional.FromPtr(form.DiffViewStyle), DiffViewStyle: optional.FromPtr(form.DiffViewStyle),
KeepEmailPrivate: optional.FromPtr(form.HideEmail), KeepEmailPrivate: optional.FromPtr(form.HideEmail),
KeepActivityPrivate: optional.FromPtr(form.HideActivity), KeepActivityPrivate: optional.FromPtr(form.HideActivity),
EnableRepoUnitHints: optional.FromPtr(form.EnableRepoUnitHints),
} }
if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil {
ctx.InternalServerError(err) ctx.InternalServerError(err)

View file

@ -393,6 +393,25 @@ func UpdateUserLang(ctx *context.Context) {
ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
} }
// UpdateUserHints updates a user's hints settings
func UpdateUserHints(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.UpdateHintsForm)
ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsAppearance"] = true
opts := &user_service.UpdateOptions{
EnableRepoUnitHints: optional.Some(form.EnableRepoUnitHints),
}
if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil {
ctx.ServerError("UpdateUser", err)
return
}
log.Trace("User settings updated: %s", ctx.Doer.Name)
ctx.Flash.Success(translation.NewLocale(ctx.Doer.Language).TrString("settings.update_hints_success"))
ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
}
// UpdateUserHiddenComments update a user's shown comment types // UpdateUserHiddenComments update a user's shown comment types
func UpdateUserHiddenComments(ctx *context.Context) { func UpdateUserHiddenComments(ctx *context.Context) {
err := user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes, forms.UserHiddenCommentTypesFromRequest(ctx).String()) err := user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes, forms.UserHiddenCommentTypesFromRequest(ctx).String())

View file

@ -568,6 +568,7 @@ func registerRoutes(m *web.Route) {
m.Group("/appearance", func() { m.Group("/appearance", func() {
m.Get("", user_setting.Appearance) m.Get("", user_setting.Appearance)
m.Post("/language", web.Bind(forms.UpdateLanguageForm{}), user_setting.UpdateUserLang) m.Post("/language", web.Bind(forms.UpdateLanguageForm{}), user_setting.UpdateUserLang)
m.Post("/hints", web.Bind(forms.UpdateHintsForm{}), user_setting.UpdateUserHints)
m.Post("/hidden_comments", user_setting.UpdateUserHiddenComments) m.Post("/hidden_comments", user_setting.UpdateUserHiddenComments)
m.Post("/theme", web.Bind(forms.UpdateThemeForm{}), user_setting.UpdateUIThemePost) m.Post("/theme", web.Bind(forms.UpdateThemeForm{}), user_setting.UpdateUIThemePost)
}) })

View file

@ -86,15 +86,16 @@ func toUser(ctx context.Context, user *user_model.User, signed, authed bool) *ap
// User2UserSettings return UserSettings based on a user // User2UserSettings return UserSettings based on a user
func User2UserSettings(user *user_model.User) api.UserSettings { func User2UserSettings(user *user_model.User) api.UserSettings {
return api.UserSettings{ return api.UserSettings{
FullName: user.FullName, FullName: user.FullName,
Website: user.Website, Website: user.Website,
Location: user.Location, Location: user.Location,
Language: user.Language, Language: user.Language,
Description: user.Description, Description: user.Description,
Theme: user.Theme, Theme: user.Theme,
HideEmail: user.KeepEmailPrivate, HideEmail: user.KeepEmailPrivate,
HideActivity: user.KeepActivityPrivate, HideActivity: user.KeepActivityPrivate,
DiffViewStyle: user.DiffViewStyle, DiffViewStyle: user.DiffViewStyle,
EnableRepoUnitHints: user.EnableRepoUnitHints,
} }
} }

View file

@ -234,6 +234,11 @@ type UpdateLanguageForm struct {
Language string Language string
} }
// UpdateHintsForm form for updating user hint settings
type UpdateHintsForm struct {
EnableRepoUnitHints bool
}
// Validate validates the fields // Validate validates the fields
func (f *UpdateLanguageForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { func (f *UpdateLanguageForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req) ctx := context.GetValidateContext(req)

View file

@ -37,6 +37,7 @@ type UpdateOptions struct {
EmailNotificationsPreference optional.Option[string] EmailNotificationsPreference optional.Option[string]
SetLastLogin bool SetLastLogin bool
RepoAdminChangeTeamAccess optional.Option[bool] RepoAdminChangeTeamAccess optional.Option[bool]
EnableRepoUnitHints optional.Option[bool]
} }
func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) error { func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) error {
@ -83,6 +84,11 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er
cols = append(cols, "diff_view_style") cols = append(cols, "diff_view_style")
} }
if opts.EnableRepoUnitHints.Has() {
u.EnableRepoUnitHints = opts.EnableRepoUnitHints.Value()
cols = append(cols, "enable_repo_unit_hints")
}
if opts.AllowGitHook.Has() { if opts.AllowGitHook.Has() {
u.AllowGitHook = opts.AllowGitHook.Value() u.AllowGitHook = opts.AllowGitHook.Value()

View file

@ -172,7 +172,7 @@
{{end}} {{end}}
{{if .Permission.IsAdmin}} {{if .Permission.IsAdmin}}
{{if not (.Repository.AllUnitsEnabled ctx)}} {{if and .SignedUser.EnableRepoUnitHints (not (.Repository.AllUnitsEnabled ctx))}}
<a class="{{if .PageIsRepoSettingsUnits}}active {{end}}item" href="{{.RepoLink}}/settings/units"> <a class="{{if .PageIsRepoSettingsUnits}}active {{end}}item" href="{{.RepoLink}}/settings/units">
{{svg "octicon-diff-added"}} {{ctx.Locale.Tr "repo.settings.units.add_more"}} {{svg "octicon-diff-added"}} {{ctx.Locale.Tr "repo.settings.units.add_more"}}
</a> </a>

View file

@ -23853,6 +23853,10 @@
"type": "string", "type": "string",
"x-go-name": "DiffViewStyle" "x-go-name": "DiffViewStyle"
}, },
"enable_repo_unit_hints": {
"type": "boolean",
"x-go-name": "EnableRepoUnitHints"
},
"full_name": { "full_name": {
"type": "string", "type": "string",
"x-go-name": "FullName" "x-go-name": "FullName"
@ -23897,6 +23901,10 @@
"type": "string", "type": "string",
"x-go-name": "DiffViewStyle" "x-go-name": "DiffViewStyle"
}, },
"enable_repo_unit_hints": {
"type": "boolean",
"x-go-name": "EnableRepoUnitHints"
},
"full_name": { "full_name": {
"type": "string", "type": "string",
"x-go-name": "FullName" "x-go-name": "FullName"

View file

@ -66,6 +66,25 @@
</form> </form>
</div> </div>
<!-- Hints -->
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.hints"}}
</h4>
<div class="ui attached segment">
<form class="ui form" action="{{.Link}}/hints" method="post">
{{.CsrfTokenHtml}}
<div class="inline field">
<div class="ui checkbox" data-tooltip-content="{{ctx.Locale.Tr "settings.additional_repo_units_hint_description"}}">
<input name="enable_repo_unit_hints" type="checkbox" {{if $.SignedUser.EnableRepoUnitHints}}checked{{end}}>
<label>{{ctx.Locale.Tr "settings.additional_repo_units_hint"}}</label>
</div>
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "settings.update_hints"}}</button>
</div>
</form>
</div>
<!-- Shown comment event types --> <!-- Shown comment event types -->
<h4 class="ui top attached header"> <h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.hidden_comment_types"}} {{ctx.Locale.Tr "settings.hidden_comment_types"}}

View file

@ -4,12 +4,14 @@
package integration package integration
import ( import (
"fmt"
"net/http" "net/http"
"testing" "testing"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@ -306,3 +308,123 @@ func TestUserLocationMapLink(t *testing.T) {
htmlDoc := NewHTMLParser(t, resp.Body) htmlDoc := NewHTMLParser(t, resp.Body)
htmlDoc.AssertElement(t, `a[href="https://example/foo/A%2Fb"]`, true) htmlDoc.AssertElement(t, `a[href="https://example/foo/A%2Fb"]`, true)
} }
func TestUserHints(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
// Create a known-good repo, with only one unit enabled
repo, _, f := CreateDeclarativeRepo(t, user, "", []unit_model.Type{
unit_model.TypeCode,
}, []unit_model.Type{
unit_model.TypePullRequests,
unit_model.TypeProjects,
unit_model.TypePackages,
unit_model.TypeActions,
unit_model.TypeIssues,
unit_model.TypeWiki,
}, nil)
defer f()
ensureRepoUnitHints := func(t *testing.T, hints bool) {
t.Helper()
req := NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{
EnableRepoUnitHints: &hints,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var userSettings api.UserSettings
DecodeJSON(t, resp, &userSettings)
assert.Equal(t, hints, userSettings.EnableRepoUnitHints)
}
t.Run("API", func(t *testing.T) {
t.Run("setting hints on and off", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
ensureRepoUnitHints(t, true)
ensureRepoUnitHints(t, false)
})
t.Run("retrieving settings", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
for _, v := range []bool{true, false} {
ensureRepoUnitHints(t, v)
req := NewRequest(t, "GET", "/api/v1/user/settings").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var userSettings api.UserSettings
DecodeJSON(t, resp, &userSettings)
assert.Equal(t, v, userSettings.EnableRepoUnitHints)
}
})
})
t.Run("user settings", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Set a known-good state, that isn't the default
ensureRepoUnitHints(t, false)
assertHintState := func(t *testing.T, enabled bool) {
t.Helper()
req := NewRequest(t, "GET", "/user/settings/appearance")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
_, hintChecked := htmlDoc.Find(`input[name="enable_repo_unit_hints"]`).Attr("checked")
assert.Equal(t, enabled, hintChecked)
}
t.Run("view", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
assertHintState(t, false)
})
t.Run("change", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithValues(t, "POST", "/user/settings/appearance/hints", map[string]string{
"_csrf": GetCSRF(t, session, "/user/settings/appearance"),
"enable_repo_unit_hints": "true",
})
session.MakeRequest(t, req, http.StatusSeeOther)
assertHintState(t, true)
})
})
t.Run("repo view", func(t *testing.T) {
assertAddMore := func(t *testing.T, present bool) {
t.Helper()
req := NewRequest(t, "GET", repo.Link())
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
htmlDoc.AssertElement(t, fmt.Sprintf("a[href='%s/settings/units']", repo.Link()), present)
}
t.Run("hints enabled", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
ensureRepoUnitHints(t, true)
assertAddMore(t, true)
})
t.Run("hints disabled", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
ensureRepoUnitHints(t, false)
assertAddMore(t, false)
})
})
}