i18n: Add `locale` to backend (#102233)

pull/103152/head
Laura Fernández 4 months ago committed by GitHub
parent 7808cc960d
commit 4ad0492d3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  2. 3
      kinds/preferences/preferences_kind.cue
  3. 4
      packages/grafana-data/src/types/featureToggles.gen.ts
  4. 4
      packages/grafana-schema/src/raw/preferences/x/preferences_types.gen.ts
  5. 2
      pkg/api/dtos/prefs.go
  6. 8
      pkg/api/index.go
  7. 9
      pkg/api/preferences.go
  8. 2
      pkg/kinds/preferences/preferences_spec_gen.go
  9. 6
      pkg/services/featuremgmt/registry.go
  10. 1
      pkg/services/featuremgmt/toggles_gen.csv
  11. 4
      pkg/services/featuremgmt/toggles_gen.go
  12. 12
      pkg/services/featuremgmt/toggles_gen.json
  13. 3
      pkg/services/preference/model.go
  14. 16
      pkg/services/preference/prefapi/api.go
  15. 13
      pkg/services/preference/prefimpl/pref.go
  16. 37
      pkg/services/preference/prefimpl/pref_test.go
  17. 4
      pkg/services/team/teamapi/api.go
  18. 4
      pkg/services/team/teamapi/team.go
  19. 2
      pkg/services/team/teamapi/team_members_test.go
  20. 10
      public/api-merged.json
  21. 10
      public/openapi3.json

@ -222,6 +222,7 @@ Experimental features might be changed or removed without prior notice.
| `newLogsPanel` | Enables the new logs panel in Explore |
| `pluginsCDNSyncLoader` | Load plugins from CDN synchronously |
| `assetSriChecks` | Enables SRI checks for Grafana JavaScript assets |
| `localeFormatPreference` | Specify the locale so we can show the correct format for numbers and dates |
| `localizationForPlugins` | Enables localization for plugins |
## Development feature toggles

@ -27,6 +27,9 @@ lineage: schemas: [{
// Selected language (beta)
language?: string
// Selected locale (beta)
locale?: string
// Explore query history preferences
queryHistory?: #QueryHistoryPreference

@ -1047,6 +1047,10 @@ export interface FeatureToggles {
*/
unifiedStorageHistoryPruner?: boolean;
/**
* Specify the locale so we can show the correct format for numbers and dates
*/
localeFormatPreference?: boolean;
/**
* Enables the unified storage grpc connection pool
*/
unifiedStorageGrpcConnectionPool?: boolean;

@ -46,6 +46,10 @@ export interface Preferences {
* Selected language (beta)
*/
language?: string;
/**
* Selected locale (beta)
*/
locale?: string;
/**
* Navigation preferences
*/

@ -17,6 +17,7 @@ type UpdatePrefsCmd struct {
WeekStart string `json:"weekStart"`
QueryHistory *pref.QueryHistoryPreference `json:"queryHistory,omitempty"`
Language string `json:"language"`
Locale string `json:"locale"`
Cookies []pref.CookieType `json:"cookies,omitempty"`
Navbar *pref.NavbarPreference `json:"navbar,omitempty"`
}
@ -32,6 +33,7 @@ type PatchPrefsCmd struct {
Timezone *string `json:"timezone,omitempty"`
WeekStart *string `json:"weekStart,omitempty"`
Language *string `json:"language,omitempty"`
Locale *string `json:"locale,omitempty"`
QueryHistory *pref.QueryHistoryPreference `json:"queryHistory,omitempty"`
HomeDashboardUID *string `json:"homeDashboardUID,omitempty"`
Cookies []pref.CookieType `json:"cookies,omitempty"`

@ -50,7 +50,7 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
// Locale is used for some number and date/time formatting, whereas language is used just for
// translating words in the interface
acceptLangHeader := c.Req.Header.Get("Accept-Language")
locale := "en-US"
locale := "en-US" // default to en-US formatting, but use the accept-lang header or user's preference
language := "" // frontend will set the default language
if prefs.JSONData.Language != "" {
@ -62,6 +62,12 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
locale = parts[0]
}
if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagLocaleFormatPreference) {
if prefs.JSONData.Locale != "" {
locale = prefs.JSONData.Locale
}
}
appURL := hs.Cfg.AppURL
appSubURL := hs.Cfg.AppSubURL

@ -69,7 +69,7 @@ func (hs *HTTPServer) GetUserPreferences(c *contextmodel.ReqContext) response.Re
return response.Error(http.StatusInternalServerError, "Failed to update user preferences", err)
}
return prefapi.GetPreferencesFor(c.Req.Context(), hs.DashboardService, hs.preferenceService, c.SignedInUser.GetOrgID(), userID, 0)
return prefapi.GetPreferencesFor(c.Req.Context(), hs.DashboardService, hs.preferenceService, hs.Features, c.SignedInUser.GetOrgID(), userID, 0)
}
// swagger:route PUT /user/preferences user_preferences updateUserPreferences
@ -95,7 +95,7 @@ func (hs *HTTPServer) UpdateUserPreferences(c *contextmodel.ReqContext) response
}
return prefapi.UpdatePreferencesFor(c.Req.Context(), hs.DashboardService,
hs.preferenceService, c.SignedInUser.GetOrgID(), userID, 0, &dtoCmd)
hs.preferenceService, hs.Features, c.SignedInUser.GetOrgID(), userID, 0, &dtoCmd)
}
// swagger:route PATCH /user/preferences user_preferences patchUserPreferences
@ -153,6 +153,7 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te
WeekStart: dtoCmd.WeekStart,
HomeDashboardID: dtoCmd.HomeDashboardID,
Language: dtoCmd.Language,
Locale: dtoCmd.Locale,
QueryHistory: dtoCmd.QueryHistory,
CookiePreferences: dtoCmd.Cookies,
Navbar: dtoCmd.Navbar,
@ -175,7 +176,7 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te
// 403: forbiddenError
// 500: internalServerError
func (hs *HTTPServer) GetOrgPreferences(c *contextmodel.ReqContext) response.Response {
return prefapi.GetPreferencesFor(c.Req.Context(), hs.DashboardService, hs.preferenceService, c.SignedInUser.GetOrgID(), 0, 0)
return prefapi.GetPreferencesFor(c.Req.Context(), hs.DashboardService, hs.preferenceService, hs.Features, c.SignedInUser.GetOrgID(), 0, 0)
}
// swagger:route PUT /org/preferences org_preferences updateOrgPreferences
@ -194,7 +195,7 @@ func (hs *HTTPServer) UpdateOrgPreferences(c *contextmodel.ReqContext) response.
return response.Error(http.StatusBadRequest, "bad request data", err)
}
return prefapi.UpdatePreferencesFor(c.Req.Context(), hs.DashboardService, hs.preferenceService, c.SignedInUser.GetOrgID(), 0, 0, &dtoCmd)
return prefapi.UpdatePreferencesFor(c.Req.Context(), hs.DashboardService, hs.preferenceService, hs.Features, c.SignedInUser.GetOrgID(), 0, 0, &dtoCmd)
}
// swagger:route PATCH /org/preferences org_preferences patchOrgPreferences

@ -25,6 +25,8 @@ type Spec struct {
Theme *string `json:"theme,omitempty"`
// Selected language (beta)
Language *string `json:"language,omitempty"`
// Selected locale (beta)
Locale *string `json:"locale,omitempty"`
// Explore query history preferences
QueryHistory *QueryHistoryPreference `json:"queryHistory,omitempty"`
// Cookie preferences

@ -1800,6 +1800,12 @@ var (
HideFromAdminPage: true,
HideFromDocs: true,
},
{
Name: "localeFormatPreference",
Description: "Specify the locale so we can show the correct format for numbers and dates",
Stage: FeatureStageExperimental,
Owner: grafanaFrontendPlatformSquad,
},
{
Name: "unifiedStorageGrpcConnectionPool",
Description: "Enables the unified storage grpc connection pool",

@ -237,6 +237,7 @@ inviteUserExperimental,experimental,@grafana/sharing-squad,false,false,true
noBackdropBlur,experimental,@grafana/grafana-frontend-platform,false,false,true
alertingMigrationUI,experimental,@grafana/alerting-squad,false,false,true
unifiedStorageHistoryPruner,experimental,@grafana/search-and-storage,false,false,false
localeFormatPreference,experimental,@grafana/grafana-frontend-platform,false,false,false
unifiedStorageGrpcConnectionPool,experimental,@grafana/search-and-storage,false,false,false
alertingRuleRecoverDeleted,GA,@grafana/alerting-squad,false,false,true
localizationForPlugins,experimental,@grafana/plugins-platform-backend,false,false,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
237 noBackdropBlur experimental @grafana/grafana-frontend-platform false false true
238 alertingMigrationUI experimental @grafana/alerting-squad false false true
239 unifiedStorageHistoryPruner experimental @grafana/search-and-storage false false false
240 localeFormatPreference experimental @grafana/grafana-frontend-platform false false false
241 unifiedStorageGrpcConnectionPool experimental @grafana/search-and-storage false false false
242 alertingRuleRecoverDeleted GA @grafana/alerting-squad false false true
243 localizationForPlugins experimental @grafana/plugins-platform-backend false false false

@ -959,6 +959,10 @@ const (
// Enables the unified storage history pruner
FlagUnifiedStorageHistoryPruner = "unifiedStorageHistoryPruner"
// FlagLocaleFormatPreference
// Specify the locale so we can show the correct format for numbers and dates
FlagLocaleFormatPreference = "localeFormatPreference"
// FlagUnifiedStorageGrpcConnectionPool
// Enables the unified storage grpc connection pool
FlagUnifiedStorageGrpcConnectionPool = "unifiedStorageGrpcConnectionPool"

@ -2469,6 +2469,18 @@
"frontend": true
}
},
{
"metadata": {
"name": "localeFormatPreference",
"resourceVersion": "1742397804851",
"creationTimestamp": "2025-03-19T15:23:24Z"
},
"spec": {
"description": "Specify the locale so we can show the correct format for numbers and dates",
"stage": "experimental",
"codeowner": "@grafana/grafana-frontend-platform"
}
},
{
"metadata": {
"name": "localizationForPlugins",

@ -65,6 +65,7 @@ type SavePreferenceCommand struct {
WeekStart string `json:"weekStart,omitempty"`
Theme string `json:"theme,omitempty"`
Language string `json:"language,omitempty"`
Locale string `json:"locale,omitempty"`
QueryHistory *QueryHistoryPreference `json:"queryHistory,omitempty"`
CookiePreferences []CookieType `json:"cookiePreferences,omitempty"`
Navbar *NavbarPreference `json:"navbar,omitempty"`
@ -81,6 +82,7 @@ type PatchPreferenceCommand struct {
WeekStart *string `json:"weekStart,omitempty"`
Theme *string `json:"theme,omitempty"`
Language *string `json:"language,omitempty"`
Locale *string `json:"locale,omitempty"`
QueryHistory *QueryHistoryPreference `json:"queryHistory,omitempty"`
CookiePreferences []CookieType `json:"cookiePreferences,omitempty"`
Navbar *NavbarPreference `json:"navbar,omitempty"`
@ -88,6 +90,7 @@ type PatchPreferenceCommand struct {
type PreferenceJSONData struct {
Language string `json:"language"`
Locale string `json:"locale"`
QueryHistory QueryHistoryPreference `json:"queryHistory"`
CookiePreferences map[string]struct{} `json:"cookiePreferences"`
Navbar NavbarPreference `json:"navbar"`

@ -9,11 +9,12 @@ import (
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/kinds/preferences"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
pref "github.com/grafana/grafana/pkg/services/preference"
)
func UpdatePreferencesFor(ctx context.Context,
dashboardService dashboards.DashboardService, preferenceService pref.Service,
dashboardService dashboards.DashboardService, preferenceService pref.Service, features featuremgmt.FeatureToggles,
orgID, userID, teamId int64, dtoCmd *dtos.UpdatePrefsCmd) response.Response {
if dtoCmd.Theme != "" && !pref.IsValidThemeID(dtoCmd.Theme) {
return response.Error(http.StatusBadRequest, "Invalid theme", nil)
@ -49,6 +50,10 @@ func UpdatePreferencesFor(ctx context.Context,
Navbar: dtoCmd.Navbar,
}
if features.IsEnabled(ctx, featuremgmt.FlagLocaleFormatPreference) {
saveCmd.Locale = dtoCmd.Locale
}
if err := preferenceService.Save(ctx, &saveCmd); err != nil {
return response.ErrOrFallback(http.StatusInternalServerError, "Failed to save preferences", err)
}
@ -58,7 +63,7 @@ func UpdatePreferencesFor(ctx context.Context,
func GetPreferencesFor(ctx context.Context,
dashboardService dashboards.DashboardService, preferenceService pref.Service,
orgID, userID, teamID int64) response.Response {
features featuremgmt.FeatureToggles, orgID, userID, teamID int64) response.Response {
prefsQuery := pref.GetPreferenceQuery{UserID: userID, OrgID: orgID, TeamID: teamID}
preference, err := preferenceService.Get(ctx, &prefsQuery)
@ -67,7 +72,6 @@ func GetPreferencesFor(ctx context.Context,
}
var dashboardUID string
// when homedashboardID is 0, that means it is the default home dashboard, no UID would be returned in the response
if preference.HomeDashboardID != 0 {
query := dashboards.GetDashboardQuery{ID: preference.HomeDashboardID, OrgID: orgID}
@ -97,6 +101,12 @@ func GetPreferencesFor(ctx context.Context,
dto.Language = &preference.JSONData.Language
}
if features.IsEnabled(ctx, featuremgmt.FlagLocaleFormatPreference) {
if preference.JSONData.Locale != "" {
dto.Locale = &preference.JSONData.Locale
}
}
if preference.JSONData.Navbar.BookmarkUrls != nil {
dto.Navbar = &preferences.NavbarPreference{
BookmarkUrls: []string{},

@ -67,6 +67,10 @@ func (s *Service) GetWithDefaults(ctx context.Context, query *pref.GetPreference
res.JSONData.Language = p.JSONData.Language
}
if p.JSONData.Locale != "" {
res.JSONData.Locale = p.JSONData.Locale
}
if p.JSONData.QueryHistory.HomeTab != "" {
res.JSONData.QueryHistory.HomeTab = p.JSONData.QueryHistory.HomeTab
}
@ -174,6 +178,13 @@ func (s *Service) Patch(ctx context.Context, cmd *pref.PatchPreferenceCommand) e
preference.JSONData.Language = *cmd.Language
}
if cmd.Locale != nil {
if preference.JSONData == nil {
preference.JSONData = &pref.PreferenceJSONData{}
}
preference.JSONData.Locale = *cmd.Locale
}
if cmd.Navbar != nil && cmd.Navbar.BookmarkUrls != nil {
if preference.JSONData == nil {
preference.JSONData = &pref.PreferenceJSONData{}
@ -266,8 +277,8 @@ func parseCookiePreferences(prefs []pref.CookieType) (map[string]struct{}, error
func preferenceData(cmd *pref.SavePreferenceCommand) (*pref.PreferenceJSONData, error) {
jsonData := &pref.PreferenceJSONData{
Language: cmd.Language,
Locale: cmd.Locale,
}
if cmd.Navbar != nil {
jsonData.Navbar = *cmd.Navbar
}

@ -92,6 +92,7 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) {
WeekStart: &weekStartOne,
JSONData: &pref.PreferenceJSONData{
Language: "en-GB",
Locale: "en-US",
},
},
pref.Preference{
@ -103,6 +104,7 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) {
WeekStart: &weekStartTwo,
JSONData: &pref.PreferenceJSONData{
Language: "en-AU",
Locale: "es-ES",
},
},
)
@ -118,6 +120,7 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) {
HomeDashboardID: 4,
JSONData: &pref.PreferenceJSONData{
Language: "en-AU",
Locale: "es-ES",
},
}
if diff := cmp.Diff(expected, preference); diff != "" {
@ -137,6 +140,7 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) {
HomeDashboardID: 1,
JSONData: &pref.PreferenceJSONData{
Language: "en-GB",
Locale: "en-US",
},
}
if diff := cmp.Diff(expected, preference); diff != "" {
@ -157,6 +161,9 @@ func TestGetDefaults_JSONData(t *testing.T) {
orgPreferencesWithLanguageJsonData := pref.PreferenceJSONData{
Language: "en-GB",
}
orgPreferencesWithLocaleJsonData := pref.PreferenceJSONData{
Locale: "en-US",
}
team2PreferencesJsonData := pref.PreferenceJSONData{}
team1PreferencesJsonData := pref.PreferenceJSONData{}
@ -217,6 +224,36 @@ func TestGetDefaults_JSONData(t *testing.T) {
}, preference)
})
t.Run("user JSONData with missing locale does not override org preference", func(t *testing.T) {
prefService := &Service{
store: newFake(),
defaults: prefsFromConfig(setting.NewCfg()),
}
insertPrefs(t, prefService.store,
pref.Preference{
OrgID: 1,
JSONData: &orgPreferencesWithLocaleJsonData,
},
pref.Preference{
OrgID: 1,
UserID: 1,
JSONData: &userPreferencesJsonData,
},
)
query := &pref.GetPreferenceWithDefaultsQuery{OrgID: 1, UserID: 1}
preference, err := prefService.GetWithDefaults(context.Background(), query)
require.NoError(t, err)
require.Equal(t, &pref.Preference{
WeekStart: &weekStart,
JSONData: &pref.PreferenceJSONData{
Locale: "en-US",
QueryHistory: queryPreference,
},
}, preference)
})
t.Run("teams have precedence over org and are read in ascending order", func(t *testing.T) {
prefService := &Service{
store: newFake(),

@ -6,6 +6,7 @@ import (
"github.com/grafana/grafana/pkg/middleware/requestmeta"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/licensing"
pref "github.com/grafana/grafana/pkg/services/preference"
"github.com/grafana/grafana/pkg/services/team"
@ -23,6 +24,7 @@ type TeamAPI struct {
preferenceService pref.Service
ds dashboards.DashboardService
logger log.Logger
features featuremgmt.FeatureToggles
}
func ProvideTeamAPI(
@ -36,6 +38,7 @@ func ProvideTeamAPI(
cfg *setting.Cfg,
preferenceService pref.Service,
ds dashboards.DashboardService,
features featuremgmt.FeatureToggles,
) *TeamAPI {
tapi := &TeamAPI{
teamService: teamService,
@ -47,6 +50,7 @@ func ProvideTeamAPI(
preferenceService: preferenceService,
ds: ds,
logger: log.New("team-api"),
features: features,
}
tapi.registerRoutes(routeRegister, acEvaluator)

@ -251,7 +251,7 @@ func (tapi *TeamAPI) getTeamPreferences(c *contextmodel.ReqContext) response.Res
return response.Error(http.StatusBadRequest, "teamId is invalid", err)
}
return prefapi.GetPreferencesFor(c.Req.Context(), tapi.ds, tapi.preferenceService, c.SignedInUser.GetOrgID(), 0, teamId)
return prefapi.GetPreferencesFor(c.Req.Context(), tapi.ds, tapi.preferenceService, tapi.features, c.SignedInUser.GetOrgID(), 0, teamId)
}
// swagger:route PUT /teams/{team_id}/preferences teams updateTeamPreferences
@ -274,7 +274,7 @@ func (tapi *TeamAPI) updateTeamPreferences(c *contextmodel.ReqContext) response.
return response.Error(http.StatusBadRequest, "teamId is invalid", err)
}
return prefapi.UpdatePreferencesFor(c.Req.Context(), tapi.ds, tapi.preferenceService, c.SignedInUser.GetOrgID(), 0, teamId, &dtoCmd)
return prefapi.UpdatePreferencesFor(c.Req.Context(), tapi.ds, tapi.preferenceService, tapi.features, c.SignedInUser.GetOrgID(), 0, teamId, &dtoCmd)
}
// swagger:parameters updateTeamPreferences

@ -47,6 +47,7 @@ func SetupAPITestServer(t *testing.T, teamService team.Service, opts ...func(a *
cfg,
preftest.NewPreferenceServiceFake(),
dashboards.NewFakeDashboardService(t),
featuremgmt.WithFeatures(),
)
for _, o := range opts {
o(a)
@ -302,6 +303,7 @@ func Test_getTeamMembershipUpdates(t *testing.T) {
cfg,
preftest.NewPreferenceServiceFake(),
dashboards.NewFakeDashboardService(t),
featuremgmt.WithFeatures(),
)
user := &user.SignedInUser{UserID: 1, OrgID: 1, OrgRole: org.RoleAdmin, Permissions: map[int64]map[string][]string{1: {accesscontrol.ActionOrgUsersRead: {"users:id:*"}}}}

@ -17990,6 +17990,9 @@
"language": {
"type": "string"
},
"locale": {
"type": "string"
},
"navbar": {
"$ref": "#/definitions/NavbarPreference"
},
@ -18609,6 +18612,10 @@
"description": "Selected language (beta)",
"type": "string"
},
"locale": {
"description": "Selected locale (beta)",
"type": "string"
},
"navbar": {
"$ref": "#/definitions/NavbarPreference"
},
@ -22060,6 +22067,9 @@
"language": {
"type": "string"
},
"locale": {
"type": "string"
},
"navbar": {
"$ref": "#/definitions/NavbarPreference"
},

@ -7985,6 +7985,9 @@
"language": {
"type": "string"
},
"locale": {
"type": "string"
},
"navbar": {
"$ref": "#/components/schemas/NavbarPreference"
},
@ -8604,6 +8607,10 @@
"description": "Selected language (beta)",
"type": "string"
},
"locale": {
"description": "Selected locale (beta)",
"type": "string"
},
"navbar": {
"$ref": "#/components/schemas/NavbarPreference"
},
@ -12054,6 +12061,9 @@
"language": {
"type": "string"
},
"locale": {
"type": "string"
},
"navbar": {
"$ref": "#/components/schemas/NavbarPreference"
},

Loading…
Cancel
Save